perf(subscription-status): cache + parallelize + invalidate on mutations
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled

GET /api/subscription/status/ was the slowest endpoint in the API at
p50≈1750ms / p95≈2425ms — about 12× the floor for our cluster→Neon
geography. Jaeger traces showed seven sequential SQL queries each
costing roughly one transatlantic RTT (~110ms), with the actual queries
running in 0.073ms at the database. Pure network serialization, not slow
SQL.

Three changes, in order of leverage:

1. Cache the assembled SubscriptionStatusResponse per-user in Redis with
   a 5-minute TTL. Hot path collapses to a single Redis GET (~5ms) on
   warm reads; the TTL is a safety net against missed invalidations.

2. Parallelize the three independent COUNT queries in getUserUsage
   (task_task / task_contractor / task_document) via golang.org/x/sync
   errgroup. Three RTTs collapse to one. Also dropped the redundant
   residence_residence COUNT — len(residenceIDs) from FindResidenceIDsByOwner
   is the same number, no need to re-query.

3. Wire explicit invalidation into every mutation that could change a
   user's response — residence/task/contractor/document CRUD,
   residence membership changes (JoinWithCode, RemoveUser, DeleteResidence),
   and every subscription tier flip across the IAP/Stripe/webhook surface.
   Residence-scoped invalidations fan out to every user with access via a
   new ResidenceRepository.FindUserIDsByResidence helper, so members of a
   shared residence don't see stale `usage` numbers when another member
   adds a task.

Net effect: warm path goes from ~1350ms to ~5ms (Redis hit). Cold path
goes from ~1350ms to ~250-450ms (5 sequential queries → 2 phases:
residence IDs lookup, then parallel task/contractor/document counts).

Also fixed a pre-existing CheckLimit signature drift in
internal/integration/subscription_is_free_test.go that was blocking the
package build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-05-01 11:00:23 -07:00
parent 0798ae8d74
commit 9bee436e86
11 changed files with 286 additions and 34 deletions
+14 -3
View File
@@ -266,8 +266,10 @@ func (s *ResidenceService) CreateResidence(ctx context.Context, req *requests.Cr
}
if s.cache != nil {
// Owner now has a new residence — drop cached IDs so the next
// list-residences call doesn't omit it.
// list-residences call doesn't omit it. Also bust the subscription
// status cache so properties_count reflects the new residence.
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, ownerID)
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, ownerID)
}
// Reload with relations
@@ -450,6 +452,10 @@ func (s *ResidenceService) DeleteResidence(ctx context.Context, residenceID, use
}
if s.cache != nil && len(affectedUserIDs) > 0 {
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, affectedUserIDs...)
// All counts (properties + tasks/contractors/documents that lived in
// the deleted residence) just dropped for every member, not only the
// owner.
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, affectedUserIDs...)
}
// Get updated summary
@@ -578,8 +584,11 @@ func (s *ResidenceService) JoinWithCode(ctx context.Context, code string, userID
return nil, apperrors.Internal(err)
}
if s.cache != nil {
// The joining user's residence-IDs cache is now stale.
// The joining user's residence-IDs cache is now stale, and their
// subscription status now reflects an extra residence with all of its
// tasks/contractors/documents.
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, userID)
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
}
// Mark share code as used (one-time use)
@@ -663,8 +672,10 @@ func (s *ResidenceService) RemoveUser(ctx context.Context, residenceID, userIDTo
return apperrors.Internal(err)
}
if s.cache != nil {
// The removed user's residence-IDs cache is now stale.
// The removed user lost access to one residence and all of its
// tasks/contractors/documents — their counts must be recomputed.
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, userIDToRemove)
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userIDToRemove)
}
return nil