Files
cfw-autumn/.plans/v2-handoff-prompt.md
2026-04-01 21:44:36 +01:00

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), filterCusProductsByEntity in shared/utils/cusProductUtils/filterCusProductUtils.ts includes 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)

  1. Same nested FullCustomer shape everywhere — the cache stores the exact same nested customer_products[].customer_entitlements[] structure used in TypeScript in-memory. No flat/normalized format. No hydrate/dehydrate layer. The path index stays as-is.

  2. Lua scripts are key-agnostic — the existing deduction Lua scripts (deductFromCustomerEntitlements.lua) accept a cache_key and pathidx_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.

  3. 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.

  4. Billing actions (attach, updateSubscription) are OUT OF SCOPE — they need ALL customer products across ALL entities for Stripe subscription merging (buildStripeSubscriptionItemsUpdate diffs all products on a subscription). Billing actions will continue to query Postgres directly via CusService.getFull. After billing, invalidate all customer + entity caches.

  5. Rolling migration via percentage-based hashing — cherry-pick getCustomerBucket(customerId) and resolveCustomerId middleware from origin/feat/custom-redis. Use Bun.hash(customerId) % 100 to deterministically route customers to V1 or V2 cache format. Deploy at 0%, ramp 5 → 10 → 25 → 50 → 75 → 100. Staleness detection (isCacheStale pattern) 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 (no entityId) and entity-level (with entityId) modes.
  • server/src/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.ts — TypeScript hydration from flat query rows to nested FullCustomer
  • server/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
  • prepareFeatureDeduction calls fullCustomerToCustomerEntitlements which flattens customer_products[].customer_entitlements + extra_customer_entitlements into a single array, filtered by entity via cusEntMatchesEntity
  • Lua deduction uses path index for O(1) sub-path reads — never reads the full doc
  • After deduction, applyDeductionUpdateToFullCustomer directly walks customer_products[i].customer_entitlements[j] to mutate in-place

getCustomer:

  • Fetches FullCustomer, calls getApiCustomerBase which builds subscriptions (from customer_products), balances (from fullCustomerToCustomerEntitlements), and flags
  • Also returns entities array, invoices, billing controls

getEntity:

  • Currently fetches the ENTIRE FullCustomer, then calls filterCusProductsByEntity to 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 getOrCreateCustomer and getEntity endpoints
  • Snapshot the subscriptions array and balances object (minus breakdown field)
  • 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.md first)

Phase 1 — FullEntity type + cache utilities

  • Create FullEntity type in shared/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 to 2.0.0, add entity cache config

Phase 2 — Wire V2 query into CusService

  • Add CusService.getFullV2 — calls getSubjectCoreQuery + resultToFullCustomer (customer-level, no entityId)
  • Fix getSubjectCoreQuery entity mode — include internal_entity_id IS NULL products alongside entity-scoped ones
  • Add CusService.getFullEntity — calls updated query with entityId, hydrates to FullEntity
  • Update getOrSetCachedFullCustomer to use V2 query on cache miss
  • Create getOrSetCachedFullEntity for entity-specific cache flow

Phase 3 — Endpoint Migration

  • /check: if entity_idgetOrSetCachedFullEntity, else → bounded getOrSetCachedFullCustomer
  • /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_v2 Postgres function unchanged

Phase 5 — Rolling Migration

  • Cherry-pick from origin/feat/custom-redis:
    • getCustomerBucket(customerId) from server/src/external/redis/customerRedisRouting.tsBun.hash(id) % 100
    • resolveCustomerId middleware from server/src/honoMiddlewares/utils/resolveCustomerId.ts
    • isCacheStale() 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 type
  • shared/models/cusProductModels/cusProductModels.ts — FullCusProduct, CusProduct types
  • shared/models/cusProductModels/cusEntModels/cusEntModels.ts — FullCustomerEntitlement type
  • shared/utils/cusProductUtils/filterCusProductUtils.tsfilterCusProductsByEntity (entity inheritance logic)
  • shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts — flattens products + extra entitlements
  • shared/utils/cusEntUtils/filterCusEntUtils.tscusEntMatchesEntity
  • server/src/internal/customers/CusService.ts — current getFull
  • server/src/internal/customers/cusUtils/fullCustomerCacheUtils/ — all cache utilities
  • server/src/internal/customers/cache/pathIndex/buildPathIndex.ts — path index builder
  • server/src/_luaScriptsV2/fullCustomer/fullCustomerUtils.lua — path index reader in Lua
  • server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua — deduction hot path
  • server/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts — post-deduction mutation
  • server/src/internal/balances/utils/sync/syncItemV3.ts — Redis → Postgres sync

Files to create:

  • shared/models/cusModels/fullEntityModel.ts
  • server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullEntity.ts
  • server/src/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullEntity.ts
  • server/src/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullEntity.ts
  • server/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 products
  • server/src/internal/customers/CusService.ts — add getFullV2, getFullEntity
  • server/src/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.ts — bump version, add entity config
  • server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.ts — use V2 query
  • server/src/internal/api/check/handleCheck.ts — entity-aware cache routing
  • server/src/internal/balances/handlers/handleTrack.ts — entity-aware cache routing
  • server/src/internal/customers/handlers/handleGetOrCreateCustomer/handleGetOrCreateCustomerV2.ts — bounded FullCustomer
  • server/src/internal/entities/handlers/handleGetEntity/handleGetEntityV2.ts — FullEntity
  • server/src/internal/balances/utils/sync/syncItemV3.ts — entity cache awareness

Start with Phase 0 — comparison tests.