perf(subscription-status): cache + parallelize + invalidate on mutations
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:
@@ -33,6 +33,17 @@ func (s *StripeService) SetCacheService(cache *CacheService) {
|
||||
s.cache = cache
|
||||
}
|
||||
|
||||
// invalidateStatusCache drops the per-user SubscriptionStatusResponse cache
|
||||
// after any tier-changing webhook so the next /api/subscription/status/ call
|
||||
// reflects the new state immediately instead of waiting out the 5-min TTL.
|
||||
// Best-effort: webhook handlers shouldn't fail just because Redis is down.
|
||||
func (s *StripeService) invalidateStatusCache(userID uint) {
|
||||
if s.cache == nil {
|
||||
return
|
||||
}
|
||||
_ = s.cache.InvalidateSubscriptionStatusForUsers(context.Background(), userID)
|
||||
}
|
||||
|
||||
// NewStripeService creates a new Stripe service. It initializes the global
|
||||
// Stripe API key from the STRIPE_SECRET_KEY environment variable. If the key
|
||||
// is not set, a warning is logged but the service is still returned (matching
|
||||
@@ -223,6 +234,7 @@ func (s *StripeService) handleCheckoutCompleted(event stripe.Event) error {
|
||||
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, models.PlatformStripe); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
s.invalidateStatusCache(userID)
|
||||
|
||||
customerID := ""
|
||||
if session.Customer != nil {
|
||||
@@ -264,6 +276,7 @@ func (s *StripeService) handleSubscriptionUpdated(event stripe.Event) error {
|
||||
if err := s.subscriptionRepo.UpgradeToPro(sub.UserID, expiresAt, models.PlatformStripe); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
s.invalidateStatusCache(sub.UserID)
|
||||
log.Info().Uint("user_id", sub.UserID).Str("status", string(subscription.Status)).Msg("Stripe subscription active")
|
||||
|
||||
case stripe.SubscriptionStatusPastDue:
|
||||
@@ -282,6 +295,7 @@ func (s *StripeService) handleSubscriptionUpdated(event stripe.Event) error {
|
||||
if err := s.subscriptionRepo.DowngradeToFree(sub.UserID); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
s.invalidateStatusCache(sub.UserID)
|
||||
log.Info().Uint("user_id", sub.UserID).Str("status", string(subscription.Status)).Msg("User downgraded to Free after Stripe subscription ended")
|
||||
}
|
||||
|
||||
@@ -314,6 +328,7 @@ func (s *StripeService) handleSubscriptionDeleted(event stripe.Event) error {
|
||||
if err := s.subscriptionRepo.DowngradeToFree(sub.UserID); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
s.invalidateStatusCache(sub.UserID)
|
||||
|
||||
log.Info().Uint("user_id", sub.UserID).Msg("User downgraded to Free after Stripe subscription deleted")
|
||||
|
||||
@@ -346,6 +361,7 @@ func (s *StripeService) handleInvoicePaid(event stripe.Event) error {
|
||||
if err := s.subscriptionRepo.UpgradeToPro(sub.UserID, expiresAt, models.PlatformStripe); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
s.invalidateStatusCache(sub.UserID)
|
||||
|
||||
log.Info().
|
||||
Uint("user_id", sub.UserID).
|
||||
|
||||
Reference in New Issue
Block a user