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:
@@ -2,6 +2,7 @@ package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -238,7 +239,7 @@ func TestIntegration_IsFreeBypassesCheckLimit(t *testing.T) {
|
||||
|
||||
// ========== Test 1: Normal free user hits limit ==========
|
||||
// First property should succeed
|
||||
err = app.SubscriptionService.CheckLimit(userID, "properties")
|
||||
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "properties")
|
||||
assert.NoError(t, err, "First property should be allowed")
|
||||
|
||||
// Create a property to use up the limit
|
||||
@@ -249,7 +250,7 @@ func TestIntegration_IsFreeBypassesCheckLimit(t *testing.T) {
|
||||
app.DB.Create(residence)
|
||||
|
||||
// Second property should fail
|
||||
err = app.SubscriptionService.CheckLimit(userID, "properties")
|
||||
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "properties")
|
||||
assert.Error(t, err, "Second property should be blocked for normal free user")
|
||||
var appErr *apperrors.AppError
|
||||
require.ErrorAs(t, err, &appErr)
|
||||
@@ -262,17 +263,17 @@ func TestIntegration_IsFreeBypassesCheckLimit(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// ========== Test 3: IsFree user bypasses limit ==========
|
||||
err = app.SubscriptionService.CheckLimit(userID, "properties")
|
||||
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "properties")
|
||||
assert.NoError(t, err, "IsFree user should bypass property limits")
|
||||
|
||||
// Should also bypass other limits
|
||||
err = app.SubscriptionService.CheckLimit(userID, "tasks")
|
||||
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "tasks")
|
||||
assert.NoError(t, err, "IsFree user should bypass task limits")
|
||||
|
||||
err = app.SubscriptionService.CheckLimit(userID, "contractors")
|
||||
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "contractors")
|
||||
assert.NoError(t, err, "IsFree user should bypass contractor limits")
|
||||
|
||||
err = app.SubscriptionService.CheckLimit(userID, "documents")
|
||||
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "documents")
|
||||
assert.NoError(t, err, "IsFree user should bypass document limits")
|
||||
}
|
||||
|
||||
@@ -375,6 +376,6 @@ func TestIntegration_IsFreeWhenGlobalLimitationsDisabled(t *testing.T) {
|
||||
"With IsFree and global limitations disabled, limitations_enabled should be false")
|
||||
|
||||
// Both cases result in the same outcome - no limitations
|
||||
err = app.SubscriptionService.CheckLimit(userID, "properties")
|
||||
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "properties")
|
||||
assert.NoError(t, err, "Should bypass limits when global limitations are disabled")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user