8.7 KiB
Two-Tier Cache Pattern
Status: active
Owner: platform-scaling
Related: packages/shared-store/README.md, docs/improvement-plan/03-scaling-infrastructure/pubsub-cache-invalidation.md
Why
A SharedStore-only cache pays one Redis round-trip per read (~1-3 ms on a healthy network). Hot paths that hit the same key 10-30 times per request (entitlement checks, limit counters, UI feature gating) accumulate enough overhead that we need an in-process L1 — but an in-process L1 alone goes stale across replicas. The two-tier cache combines both:
| Tier | Backing | Latency | Consistency |
|---|---|---|---|
| L1 | Map / LRUCache in process |
sub-microsecond | per-replica |
| L2 | SharedStore (Redis or memory) |
1-3 ms | cross-replica |
| Pub/Sub | Redis PUBLISH on cache:<ns>:invalidate |
< 50 ms typical fan-out | best-effort, see safety net |
A mutating call invalidates its local L1+L2 and publishes the key on the namespace channel. Every replica's subscriber receives the message and drops the key from its L1 (and optionally the L2). Reads probe L1 first; on miss, fall back to L2; on L2 miss, refresh from the source-of-truth (typically Postgres) and populate both tiers.
Decision rubric
Use a two-tier cache when all three hold:
- The key is read more than ~5x per request, or by every request (subscription, tenant, entitlements, feature flag).
- Source-of-truth mutations are rare relative to reads (Stripe webhook firing seconds-to-minutes apart, admin edits).
- Stale reads have user-visible consequences (showing the wrong plan limit, denying access after an upgrade).
Otherwise, prefer:
- In-process LRU only when consistency doesn't matter (route-stable computed values, request-scoped caches).
- SharedStore cache only when the read-rate is low and consistency must be strict (idempotency keys, session lookups).
- No cache when the source is already fast (cached Prisma result, in-memory blueprint).
Channel naming
Channels follow shared-store:<store-namespace>:ch:cache:<cache-namespace>:invalidate. Concretely, the web SharedStore (namespace: "web") emits subscription invalidations on shared-store:web:ch:cache:subscription:invalidate. The API SharedStore (namespace: "api") emits on shared-store:api:ch:cache:subscription:invalidate.
For cross-app fan-out (Stripe webhook in API → web replicas), both apps must use the same SharedStore namespace so they share a channel. The current implementation uses per-app namespaces (api, web) but both connect to the same Redis logical DB index (default 1). The actual channel string is namespaced under the writing app's SharedStore prefix. If you need cross-app fan-out, either:
- Use a dedicated shared namespace (e.g.
namespace: "platform") for both apps' two-tier caches, or - Have the publishing app PUBLISH the message via a SharedStore configured with the receiving app's namespace explicitly.
The Stripe webhook → subscription pattern in this codebase publishes on shared-store:api:ch:cache:subscription:invalidate. For a production multi-replica web deploy to actually receive that broadcast, configure both apps/api/src/cache/subscription-cache-publisher.ts and apps/web/app/utils/services/.server/subscriptionCache.server.ts to construct their SharedStores with the same namespace (recommended: introduce getCacheSharedStore({ namespace: "cache" }) for both apps). The current implementation broadcasts within the API process and applies the same clearTenantSubscriptionCache semantics on the web side via local mutation-paths that already publish; the cross-process broadcast is the work item documented under Deviations in the detail-md.
Reconnect semantics
ioredis auto-reconnects on connection loss with exponential backoff. While disconnected, the subscriber is offline and any PUBLISH issued by another replica is lost forever (pub/sub has no persistence). Mitigations layered in order:
- L1 TTL is the floor. Default
CACHE_L1_TTL_SECONDS=300(5 min). Any missed invalidation self-heals within five minutes because the L1 entry expires. Lower the TTL for hotter paths if stale-window tolerance shrinks; raise it for marketing-content-grade caches. - Reconnect flush. Each two-tier cache exposes
flushLocal()so a higher-level boot hook cancache.flushLocal()on a Redis reconnect event. The minimal implementation is "TTL is enough"; a more aggressive deployment can wiresubscriber.on('ready', () => cache.flushLocal())in app boot. - Outbox fallback. For strictly-consistent flows (billing, entitlement enforcement), the source of truth writes an outbox event (see
docs/architecture/outbox-pattern.md); the outbox dispatcher consumes the event and re-issues the invalidation. This guarantees at-least-once delivery and survives Redis outages — at the cost of one DB round-trip and ~1 second dispatch latency.
Channel inventory
| Channel | Publisher | Subscriber | Payload | Defined in |
|---|---|---|---|---|
cache:subscription:invalidate |
apps/api/src/billing/stripe-webhook-route.ts (after updateTenantSubscriptionProductFromStripe); apps/web/.../subscriptionService.ts (clearTenantSubscriptionCache, clearAllSubscriptionsCache) |
apps/web/app/utils/services/.server/subscriptionCache.server.ts → bridges into clearCacheKey('tenantSubscription:<tenantId>') |
tenantId (string) or "*" for wildcard |
subscriptionCache.server.ts + subscription-cache-publisher.ts |
cache:tenant:invalidate |
apps/web/.../tenant-cache.server.ts (publishTenantInvalidation) |
apps/web/.../tenant-cache.server.ts → clears tenant:, tenantSimple:, tenantIdOrSlug: cachified keys |
tenantId (string) or "*" |
tenant-cache.server.ts |
cache:entitlements:invalidate |
piggy-backs on subscription — entitlements derive from the subscription and recompute on the next read after the subscription L1 is flushed | piggy-backs on subscription | n/a — chained | n/a (chain) |
cache:feature-flags:invalidate |
packages/feature-flags publishers, including CLI feature-flag enable/disable/rule updates |
web feature-flag cache subscribers through the shared two-tier cache bootstrap | { flagKey?, tenantId?, all? } |
packages/feature-flags/src/publisher.ts |
Implementing a new two-tier cache
- Create a
<namespace>-cache.server.tsmodule per app (api/web) that wrapscreateTwoTierCache({ store: getSharedStore(), namespace, ttlSeconds, pubsubEnabled, logger }). - Expose two functions:
publish<Namespace>Invalidation(key)(for mutators) andsubscribe<Namespace>Cache()(idempotent boot hook). - The subscriber callback bridges into the existing local cache representation (
clearCacheKey()for cachified-LRU,Map.delete()for ad-hoc maps). - Add the boot hook to
apps/web/app/cache-bootstrap.server.ts(or equivalent API bootstrap) so the subscriber starts before request traffic. - Add publisher call-sites at every mutation that owns the SoT (database write, admin action, scheduled job).
- Write a co-located test that drives the two-tier helper directly against a
MemoryAdapterand asserts the cachified-bridge fires.
Feature-Flag Cache Notes
Feature-flag invalidation is now implemented through the shared two-tier cache
publisher path. CLI feature-flag mutations publish to
cache:feature-flags:invalidate after the audit write, and web subscribers
flush L1 entries immediately instead of waiting for the TTL window. Keep new
feature-flag mutators on the same publisher path.
Kill switch
CACHE_INVALIDATION_ENABLED=false (env var) flips both publishers and subscribers into local-only mode. The cache still works — read-through still populates L1+L2 — but invalidate becomes a local-only delete + no PUBLISH, and subscribers do not start. The system degrades to "every replica has its own L1 that expires after CACHE_L1_TTL_SECONDS". Use this switch:
- During a Redis incident to stop publisher errors from spamming Sentry
- During a deployment when subscribers can't keep up with traffic patterns
- During investigation of a stale-cache bug to confirm whether the publisher or subscriber path is at fault
Always re-enable after the incident; the L1-only mode has no replica-consistency floor below the L1 TTL.
Tests
- Unit tests for
createTwoTierCachecover round-trip, fallback, cross-replica invalidation,localOnly, wildcard flush, pubsub kill-switch —packages/shared-store/src/helpers/two-tier-cache.test.ts - Subscription cache integration:
apps/web/app/utils/services/.server/subscriptionCache.server.test.ts - Tenant cache integration:
apps/web/app/utils/db/tenant-cache.server.test.ts - Stripe webhook publish path:
apps/api/src/billing/stripe-webhook-route.test.ts(assertspublishSubscriptionInvalidation(tenantId)is called after the DB update)