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:
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
@@ -38,6 +39,7 @@ type SubscriptionWebhookHandler struct {
|
||||
webhookEventRepo *repositories.WebhookEventRepository
|
||||
appleRootCerts []*x509.Certificate
|
||||
stripeService *services.StripeService
|
||||
cache *services.CacheService
|
||||
enabled bool
|
||||
}
|
||||
|
||||
@@ -61,6 +63,24 @@ func (h *SubscriptionWebhookHandler) SetStripeService(stripeService *services.St
|
||||
h.stripeService = stripeService
|
||||
}
|
||||
|
||||
// SetCacheService wires Redis caching so post-mutation invalidation drops the
|
||||
// per-user SubscriptionStatusResponse cache after Apple/Google webhooks change
|
||||
// tier or auto-renew state.
|
||||
func (h *SubscriptionWebhookHandler) SetCacheService(cache *services.CacheService) {
|
||||
h.cache = cache
|
||||
}
|
||||
|
||||
// invalidateStatusCache best-effort drops the per-user subscription_status
|
||||
// cache after a webhook mutation. Background context — webhook handlers run
|
||||
// in their own request lifecycle, but the cache write itself is fast enough
|
||||
// that we don't need to bound it.
|
||||
func (h *SubscriptionWebhookHandler) invalidateStatusCache(userID uint) {
|
||||
if h.cache == nil {
|
||||
return
|
||||
}
|
||||
_ = h.cache.InvalidateSubscriptionStatusForUsers(context.Background(), userID)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// Apple App Store Server Notifications v2
|
||||
// ====================
|
||||
@@ -356,6 +376,7 @@ func (h *SubscriptionWebhookHandler) handleAppleSubscribed(userID uint, tx *Appl
|
||||
if err := h.subscriptionRepo.SetAutoRenew(userID, autoRenew); err != nil {
|
||||
return err
|
||||
}
|
||||
h.invalidateStatusCache(userID)
|
||||
|
||||
log.Info().Uint("user_id", userID).Time("expires", expiresAt).Bool("auto_renew", autoRenew).Msg("Apple Webhook: User subscribed")
|
||||
return nil
|
||||
@@ -367,6 +388,7 @@ func (h *SubscriptionWebhookHandler) handleAppleRenewed(userID uint, tx *AppleTr
|
||||
if err := h.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil {
|
||||
return err
|
||||
}
|
||||
h.invalidateStatusCache(userID)
|
||||
|
||||
log.Info().Uint("user_id", userID).Time("expires", expiresAt).Msg("Apple Webhook: User renewed")
|
||||
return nil
|
||||
@@ -396,6 +418,7 @@ func (h *SubscriptionWebhookHandler) handleAppleRenewalStatusChange(userID uint,
|
||||
}
|
||||
log.Info().Uint("user_id", userID).Msg("Apple Webhook: User turned auto-renew back on")
|
||||
}
|
||||
h.invalidateStatusCache(userID)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -673,6 +696,7 @@ func (h *SubscriptionWebhookHandler) handleGoogleRenewed(userID uint, notificati
|
||||
if err := h.subscriptionRepo.UpgradeToPro(userID, newExpiry, "android"); err != nil {
|
||||
return err
|
||||
}
|
||||
h.invalidateStatusCache(userID)
|
||||
|
||||
log.Info().Uint("user_id", userID).Time("expires", newExpiry).Msg("Google Webhook: User renewed")
|
||||
return nil
|
||||
@@ -684,6 +708,7 @@ func (h *SubscriptionWebhookHandler) handleGoogleRecovered(userID uint, notifica
|
||||
if err := h.subscriptionRepo.UpgradeToPro(userID, newExpiry, "android"); err != nil {
|
||||
return err
|
||||
}
|
||||
h.invalidateStatusCache(userID)
|
||||
|
||||
log.Info().Uint("user_id", userID).Msg("Google Webhook: User subscription recovered")
|
||||
return nil
|
||||
@@ -698,6 +723,7 @@ func (h *SubscriptionWebhookHandler) handleGoogleCanceled(userID uint, notificat
|
||||
if err := h.subscriptionRepo.SetAutoRenew(userID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
h.invalidateStatusCache(userID)
|
||||
|
||||
log.Info().Uint("user_id", userID).Msg("Google Webhook: User canceled, will expire at end of period")
|
||||
return nil
|
||||
@@ -727,6 +753,7 @@ func (h *SubscriptionWebhookHandler) handleGoogleRestarted(userID uint, notifica
|
||||
if err := h.subscriptionRepo.SetAutoRenew(userID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
h.invalidateStatusCache(userID)
|
||||
|
||||
log.Info().Uint("user_id", userID).Msg("Google Webhook: User restarted subscription")
|
||||
return nil
|
||||
@@ -762,7 +789,11 @@ func (h *SubscriptionWebhookHandler) safeDowngradeToFree(userID uint, reason str
|
||||
sub, err := h.subscriptionRepo.FindByUserID(userID)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Could not find subscription for multi-source check, proceeding with downgrade")
|
||||
return h.subscriptionRepo.DowngradeToFree(userID)
|
||||
if dErr := h.subscriptionRepo.DowngradeToFree(userID); dErr != nil {
|
||||
return dErr
|
||||
}
|
||||
h.invalidateStatusCache(userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if Stripe subscription is still active
|
||||
@@ -789,6 +820,7 @@ func (h *SubscriptionWebhookHandler) safeDowngradeToFree(userID uint, reason str
|
||||
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
h.invalidateStatusCache(userID)
|
||||
|
||||
log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: User downgraded to free (no other active sources)")
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user