11 KiB
V2 Full Customer Cache — Handoff Prompt
Use this prompt in a new agent conversation to continue the implementation.
Prompt
Read the following plans before starting:
.plans/v2-full-customer-cache.md— high-level architecture and phases.plans/v2-cache-invalidation-indexes.md— cache invalidation + missing Postgres indexes.cursor/plans/v2_full_customer_query_49c5cc3a.plan.md— comprehensive research (rolling migration details, billing action constraints, Postgres gotchas, Redis best practices)
Problem
The current FullCustomer object stored in Redis as a single JSON blob grows unboundedly with entities. For a customer with N entities, the blob includes N entity-scoped customer_products (each with nested entitlements, prices). Our largest customer has ~76 entities, producing a ~4MB blob. This causes JSON.GET/JSON.SET latency spikes on Redis.
Solution: Bounded FullCustomer + Per-Entity FullEntity
Split into two cache objects:
Bounded FullCustomer — cached at {orgId}:env:fullcustomer:2.0.0:customerId
- Contains only customer-level products (
internal_entity_id IS NULL) - Contains aggregated entity balance data (from V2 query's entity aggregation CTEs)
- Contains entities array, subscriptions, invoices
- For our largest customer, this is ~10KB
FullEntity — cached at {orgId}:env:fullentity:1.0.0:customerId:entityId
- Contains entity-scoped products (
internal_entity_id = this entity) PLUS inherited customer-level products (internal_entity_id IS NULL) - Entity inheritance is critical: in default mode (
org.config.entity_product !== true),filterCusProductsByEntityinshared/utils/cusProductUtils/filterCusProductUtils.tsincludes both entity-scoped and customer-level products - Contains entity record, customer core fields (processor, billing controls, fingerprint)
- Contains extra_customer_entitlements matching this entity
- For our largest entity, this is ~105KB (11 products, 22 entitlements)
- Does NOT contain: other entities' data, aggregated data, invoices, subscriptions
Key Design Decisions (already finalized)
-
Same nested
FullCustomershape everywhere — the cache stores the exact same nestedcustomer_products[].customer_entitlements[]structure used in TypeScript in-memory. No flat/normalized format. No hydrate/dehydrate layer. The path index stays as-is. -
Lua scripts are key-agnostic — the existing deduction Lua scripts (
deductFromCustomerEntitlements.lua) accept acache_keyandpathidx_key. For entity operations, just pass the entity cache key and entity path index key instead of the customer ones. No Lua script changes needed. -
Size cap safety net — if a serialized entity/customer doc exceeds 500KB, skip caching and fall back to Postgres. This guarantees Redis objects are never unbounded.
-
Billing actions (attach, updateSubscription) are OUT OF SCOPE — they need ALL customer products across ALL entities for Stripe subscription merging (
buildStripeSubscriptionItemsUpdatediffs all products on a subscription). Billing actions will continue to query Postgres directly viaCusService.getFull. After billing, invalidate all customer + entity caches. -
Rolling migration via percentage-based hashing — cherry-pick
getCustomerBucket(customerId)andresolveCustomerIdmiddleware fromorigin/feat/custom-redis. UseBun.hash(customerId) % 100to deterministically route customers to V1 or V2 cache format. Deploy at 0%, ramp 5 → 10 → 25 → 50 → 75 → 100. Staleness detection (isCacheStalepattern) invalidates old-format cache when a customer's routing flips due to percentage change.
Existing V2 Query Work
The SQL layer is already built:
server/src/internal/customers/repos/sql/getSubjectCoreQuery.ts— flat normalized CTE query. Handles both customer-level (noentityId) and entity-level (withentityId) modes.server/src/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.ts— TypeScript hydration from flat query rows to nestedFullCustomerserver/src/internal/customers/repos/getFullCustomerV2.ts— repo function
CRITICAL GAP: The current getSubjectCoreQuery with entityId only fetches entity-scoped products (cp.internal_entity_id = entity.internal_id). It does NOT include customer-level products (internal_entity_id IS NULL). This must be updated so the entity query returns BOTH — supporting the inheritance model where entities inherit customer-level products.
How Endpoints Use the FullCustomer Today
check/track (hot path, 1-5K req/sec):
- Fetches FullCustomer from cache via
getOrSetCachedFullCustomer prepareFeatureDeductioncallsfullCustomerToCustomerEntitlementswhich flattenscustomer_products[].customer_entitlements+extra_customer_entitlementsinto a single array, filtered by entity viacusEntMatchesEntity- Lua deduction uses path index for O(1) sub-path reads — never reads the full doc
- After deduction,
applyDeductionUpdateToFullCustomerdirectly walkscustomer_products[i].customer_entitlements[j]to mutate in-place
getCustomer:
- Fetches FullCustomer, calls
getApiCustomerBasewhich builds subscriptions (fromcustomer_products), balances (fromfullCustomerToCustomerEntitlements), and flags - Also returns entities array, invoices, billing controls
getEntity:
- Currently fetches the ENTIRE FullCustomer, then calls
filterCusProductsByEntityto get entity-relevant products - After the V2 split: fetches FullEntity directly (already contains the filtered products)
Implementation Phases
Phase 0 — Comparison Tests (do first) Write integration tests that:
- Set up a customer with multiple entities, each with products/entitlements/balances
- Call
getOrCreateCustomerandgetEntityendpoints - Snapshot the
subscriptionsarray andbalancesobject (minusbreakdownfield) - These tests serve as the baseline — after the V2 rollout, re-run and assert equivalence
- Use existing test infrastructure (read
server/tests/_guides/general-test-guide.mdfirst)
Phase 1 — FullEntity type + cache utilities
- Create
FullEntitytype inshared/models/cusModels/fullEntityModel.ts - Create entity cache utilities in
server/src/internal/customers/cusUtils/fullCustomerCacheUtils/:getCachedFullEntity.ts,setCachedFullEntity.ts,deleteCachedFullEntity.ts,getOrSetCachedFullEntity.ts - Entity path index at
{orgId}:env:fullentity:pathidx:customerId:entityId - Update
fullCustomerCacheConfig.ts: bump customer cache version to2.0.0, add entity cache config
Phase 2 — Wire V2 query into CusService
- Add
CusService.getFullV2— callsgetSubjectCoreQuery+resultToFullCustomer(customer-level, no entityId) - Fix
getSubjectCoreQueryentity mode — includeinternal_entity_id IS NULLproducts alongside entity-scoped ones - Add
CusService.getFullEntity— calls updated query with entityId, hydrates to FullEntity - Update
getOrSetCachedFullCustomerto use V2 query on cache miss - Create
getOrSetCachedFullEntityfor entity-specific cache flow
Phase 3 — Endpoint Migration
/check: ifentity_id→getOrSetCachedFullEntity, else → boundedgetOrSetCachedFullCustomer/track: same routing as check/customers.get: bounded FullCustomer (V2 query provides aggregated entity data)/entities.get: FullEntity directly (no more fetch-all-then-filter)- Dual-cache deduction: inherited customer entitlements are embedded in entity cache. Deductions update the entity cache copy. Sync writes to Postgres from entity cache. Customer cache refreshes on next miss.
Phase 4 — syncItemV3 Compatibility
- Sync message includes
entityId(or cache key info) - Entity-scoped cusEnts → read from entity cache
- Customer-level cusEnts → read from customer cache
sync_balances_v2Postgres function unchanged
Phase 5 — Rolling Migration
- Cherry-pick from
origin/feat/custom-redis:getCustomerBucket(customerId)fromserver/src/external/redis/customerRedisRouting.ts—Bun.hash(id) % 100resolveCustomerIdmiddleware fromserver/src/honoMiddlewares/utils/resolveCustomerId.tsisCacheStale()pattern
- Add
resolveCacheVersion()function: bucket < migrationPercent → V2, else → V1 - Wire into all cache read/write paths
- V1 path: existing single-blob FullCustomer (
fullcustomer:1.0.0) - V2 path: bounded FullCustomer (
fullcustomer:2.0.0) + per-entity FullEntity (fullentity:1.0.0)
Key Files Reference
Existing files to understand:
shared/models/cusModels/fullCusModel.ts— FullCustomer typeshared/models/cusProductModels/cusProductModels.ts— FullCusProduct, CusProduct typesshared/models/cusProductModels/cusEntModels/cusEntModels.ts— FullCustomerEntitlement typeshared/utils/cusProductUtils/filterCusProductUtils.ts—filterCusProductsByEntity(entity inheritance logic)shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts— flattens products + extra entitlementsshared/utils/cusEntUtils/filterCusEntUtils.ts—cusEntMatchesEntityserver/src/internal/customers/CusService.ts— current getFullserver/src/internal/customers/cusUtils/fullCustomerCacheUtils/— all cache utilitiesserver/src/internal/customers/cache/pathIndex/buildPathIndex.ts— path index builderserver/src/_luaScriptsV2/fullCustomer/fullCustomerUtils.lua— path index reader in Luaserver/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua— deduction hot pathserver/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts— post-deduction mutationserver/src/internal/balances/utils/sync/syncItemV3.ts— Redis → Postgres sync
Files to create:
shared/models/cusModels/fullEntityModel.tsserver/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullEntity.tsserver/src/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullEntity.tsserver/src/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullEntity.tsserver/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullEntity.ts- Test files in
server/tests/
Files to modify:
server/src/internal/customers/repos/sql/getSubjectCoreQuery.ts— entity query must include customer-level productsserver/src/internal/customers/CusService.ts— add getFullV2, getFullEntityserver/src/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.ts— bump version, add entity configserver/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.ts— use V2 queryserver/src/internal/api/check/handleCheck.ts— entity-aware cache routingserver/src/internal/balances/handlers/handleTrack.ts— entity-aware cache routingserver/src/internal/customers/handlers/handleGetOrCreateCustomer/handleGetOrCreateCustomerV2.ts— bounded FullCustomerserver/src/internal/entities/handlers/handleGetEntity/handleGetEntityV2.ts— FullEntityserver/src/internal/balances/utils/sync/syncItemV3.ts— entity cache awareness