Skip to content

feat: PostgreSQL-backed cross-replica Data Cache handler (P2)#539

Merged
Zheaoli merged 3 commits into
mainfrom
feat/multi-replica-pg-cache-handler
Jun 22, 2026
Merged

feat: PostgreSQL-backed cross-replica Data Cache handler (P2)#539
Zheaoli merged 3 commits into
mainfrom
feat/multi-replica-pg-cache-handler

Conversation

@Zheaoli

@Zheaoli Zheaoli commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Delivers instant cross-replica consistency for the public Data Cache — the core of multi-replica readiness. PicImpact memoises its public read path with unstable_cache + revalidateTag (server/lib/cache.ts), but Next's default cache handler keeps that data per-instance, so a revalidateTag on the replica handling an admin write doesn't reach the others; they serve stale data until the safety-net TTL expires. This routes the Data Cache through Postgres so an invalidation on one replica is seen by all.

No application-layer changes: server/lib/cache.ts is untouched — wiring a custom cacheHandler is enough for the existing unstable_cache calls to flow through it.

How it works

  • server/lib/pg-cache-handler.cjs — a custom Next cacheHandler:
    • L2 = Postgres (next_cache_entries + next_cache_tags, auto-created), shared by all replicas; L1 = a bounded, module-level in-memory cache for hot reads (the handler is instantiated per request, so shared state lives at module scope).
    • Cross-instance invalidation = get() returns a miss when any of an entry's tags was invalidated after the entry was written. Next's own in-process tag manifest can't be fed from another replica, but it only runs when get() returns data — so get() is made the decision point, consulting the shared manifest.
    • Postgres-authoritative manifest: refreshed on a short TTL (so a missed NOTIFY, or a transaction-pooled connection that can't LISTEN, still converges within ~1s) and busted instantly via LISTEN/NOTIFY when a direct connection is available (uses DIRECT_URL for the listener, since LISTEN needs a persistent session).
    • Clock-skew safe: both an entry's write time and a tag's invalidation time are stamped by the Postgres server clock, so the "written before invalidated?" comparison can't be fooled by skew between replicas.
    • Instant consistency: a revalidateTag forces a recompute on the next read on every replica. Cross-instance stale-while-revalidate is intentionally not implemented (it would let a replica serve one stale read after an admin change); per-entry safety-net TTLs (revalidate seconds) are still honored by Next.
  • next.config.mjs — wires the handler and sets cacheMaxMemorySize: 0 so Next's per-instance memory cache doesn't mask it.
  • server/lib/cache-cleanup.ts — a long-unused-entry sweep, driven by the same single external cron as the preprocess tick (/api/v1/preprocess-tasks/tick), so it never runs as a per-replica timer.
  • Adds pg + @types/pg (pg was already declared in serverExternalPackages).
  • Documents the shared cache, DIRECT_URL usage, and CACHE_HANDLER_DEBUG in docs/multi-replica.md and .env.example.

Notes

  • Instant model trade-off: after an invalidation, each replica recomputes independently on its next read. For PicImpact's deterministic DB-query cache functions every replica recomputes the same current-DB value, so there's no divergence — just a little redundant work on a hot key right after a write (negligible at this scale; a per-key single-flight could be added later if any key gets hot and expensive).
  • Only unstable_cache (FETCH-kind) entries currently flow through the handler; PicImpact's routes are all dynamic (no ISR) and image optimization keeps its own cache. The handler stores any kind generically and never throws into the request path.

Test plan

  • tsc --noEmit clean for the changed files; next build passes with the handler wired.
  • 2-instance build against Postgres (next start x2, one database):
    • read on instance A then B returns the same cached value (shared L2);
    • revalidateTag on A → next read on B recomputes (cross-replica invalidation);
    • kill B, revalidateTag on A (B misses the NOTIFY), restart B → B recomputes (cold-start reload makes Postgres authoritative);
    • repeated reads without a write stay stable (no over-invalidation).

Make admin changes propagate across replicas instantly. PicImpact's public
read path is memoised with unstable_cache + revalidateTag (server/lib/cache.ts),
but Next's default cache handler keeps that data per-instance, so a
revalidateTag on the replica handling an admin write doesn't reach the others.

- Add a custom Next cacheHandler (server/lib/pg-cache-handler.cjs) backed by
  Postgres: cached values + per-tag invalidation timestamps live in shared
  tables (next_cache_entries / next_cache_tags, auto-created), with a bounded
  module-level in-memory L1 for hot reads. Wire it in next.config.mjs and set
  cacheMaxMemorySize: 0 so Next's per-instance memory cache doesn't mask it.
- Cross-instance invalidation works by get() returning a miss when any of an
  entry's tags was invalidated after the entry was written. The tag manifest is
  Postgres-authoritative: refreshed on a short TTL (so a missed NOTIFY or a
  transaction-pooled connection that can't LISTEN still converges within ~1s)
  and busted instantly via LISTEN/NOTIFY when a direct connection is available
  (uses DIRECT_URL for the listener). Ordering uses the Postgres server clock
  for both writes and invalidations, so replica clock skew can't miss an
  invalidation. Consistency is instant (a revalidated tag forces recompute on
  the next read on every replica); per-entry safety-net TTLs are left to Next.
- Add a long-unused-entry sweep (server/lib/cache-cleanup.ts) driven by the
  same single external cron as the preprocess tick, so it never runs as a
  per-replica timer.
- Add pg (and @types/pg); pg was already declared in serverExternalPackages.
- Document the shared cache + DIRECT_URL/CACHE_HANDLER_DEBUG in
  docs/multi-replica.md and .env.example.

Verified with a 2-instance build against Postgres: shared reads, cross-replica
invalidation, cold-start reload after a missed NOTIFY, and a stable steady state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
picimpact Ready Ready Preview, Comment Jun 22, 2026 3:06pm

- docs/multi-replica.md §2: account for the handler's own pool (max 4) + the
  persistent LISTEN client (~+5 connections per replica) in the max_connections
  budgeting, and cross-reference the DIRECT_URL requirement for LISTEN.
- cache-cleanup.ts: note that only next_cache_entries is swept; next_cache_tags
  is a fixed small enum today, but flag that unbounded tags (e.g. per-image)
  would need their own sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cross-replica cancellation is already implemented, not a follow-up: the cancel
endpoint sets status='cancelling' (atomic, cross-replica) and the running
replica re-reads it at its per-image checkpoint and stops — within ~one item,
not a lease window. Correct §5 which previously described it pessimistically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Zheaoli Zheaoli merged commit b9685d3 into main Jun 22, 2026
6 checks passed
@Zheaoli Zheaoli deleted the feat/multi-replica-pg-cache-handler branch June 22, 2026 15:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant