Skip to content

CS-11030: cross-process transpile coalesce via module_transpile_cache#4755

Draft
lukemelia wants to merge 1 commit intocs-11029-in-process-inflight-transpile-dedup-for-realmmodulecachefrom
cs-11030-cross-process-transpile-coalesce-for-realmmodulecache
Draft

CS-11030: cross-process transpile coalesce via module_transpile_cache#4755
lukemelia wants to merge 1 commit intocs-11029-in-process-inflight-transpile-dedup-for-realmmodulecachefrom
cs-11030-cross-process-transpile-coalesce-for-realmmodulecache

Conversation

@lukemelia
Copy link
Copy Markdown
Contributor

@lukemelia lukemelia commented May 11, 2026

Stacks on #4752 (CS-11029), which stacks on #4750 (CS-11028). Review/merge those first.

The CS-11030 ticket recommended deferring until project-spec §11 was on the roadmap. The user asked for option 1 anyway — the DB-backed cache refactor that lets cross-process coalesce work. This PR delivers a minimum-viable §11 + the CS-10953-shaped coalesce on top, with an OCC generation column closing the invalidate-during-transpile race that L1's existing guard (CS-11028) alone couldn't cover at the L2 layer.

Summary

  • New module_transpile_cache table (UNLOGGED, RAM-backed, like the modules definition cache). Keyed on (realm_url, canonical_path). Columns: nullable body/headers/dependency_keys (so tombstones can sit in the row without bytes), generation BIGINT NOT NULL DEFAULT 0, created_at.
  • Lifts Realm.#moduleCache from purely in-memory L1 to L1+L2. L1 stays in-process for hot-path latency; L2 is the cross-process shared layer.
  • OCC writes. The writer captures the row's generation at the L2 read step (or 0 if absent), transpiles, then UPSERTs with that captured value via ON CONFLICT DO UPDATE WHERE existing.generation <= EXCLUDED.generation. An invalidate that lands during the transpile bumps the row past the captured value, so the writer's UPSERT is rejected by the WHERE clause and a stale transpile started before the invalidate cannot resurrect the row.
  • Tombstone-and-bump on invalidate. #deleteTranspileCacheRow UPSERTs body=NULL, generation = generation+1 rather than physically DELETEing. Physical DELETE would let a slow in-flight writer's INSERT succeed (no conflict, no row to compare against) and resurrect the stale bytes. Bulk wipe (#deleteAllTranspileCacheRows from __testOnlyClearCaches) likewise UPDATEs all rows to tombstones with bumped gen.
  • Read path: L1 miss → in-flight dedup (CS-11029) → L2 read → coordinator winner/loser → babel → L2 OCC-protected persist.
  • Coordinator: reuses the existing ModuleCacheCoordinator (CS-10953) and MODULE_CACHE_POPULATED_CHANNEL. The coalesce key prefix (transpile|... vs the CachingDefinitionLookup shape) keeps the two flows separate; waiters dispatch off the bounded int64 hash, so crosstalk is a benign hash miss.
  • Invalidation: every CS-11028 invalidation site (writeMany, delete/deleteAll, file-watcher callback, handleExecutableInvalidations cascade, full-index clear, public invalidateCache) now fire-and-forget tombstones the matching L2 row alongside the in-memory drop. Every peer's listener runs the same tombstone-and-bump on its own copy → self-healing on transient pg blips and idempotent on the bump.
  • main.ts passes the existing moduleCacheCoordinator as transpileCoordinator to every Realm it constructs.

The CS-11028 generation guard protects the in-memory L1 write; the new DB-resident generation column protects the L2 write. Together they close both layers of the invalidate-during-transpile race.

Linear: https://linear.app/cardstack/issue/CS-11030

Test plan

  • CI passes the Realm.#moduleCache L2 module_transpile_cache (DB-backed) module:
    • fresh transpile populates module_transpile_cache
    • L2 row serves a subsequent reader after L1 wipe (no re-transpile)
    • invalidateCache tombstones the L2 row and bumps generation
    • in-flight transpile that completes after invalidate cannot resurrect the L2 row (direct OCC guard exercise)
  • Existing CS-11028 + CS-11029 race/dedup tests still pass.
  • Existing CS-10953 ModuleCacheCoordinator tests still pass — the coordinator is unchanged; we just route a second flow through it.
  • Module-cache regression tests in realm-endpoints-test.ts still pass (etag, 304, content-type).

Follow-ups (NOT in this PR)

  • Two-instance integration test mirroring module-cache-coordination-test.ts for the transpile flow (gated babel + advisory-lock contention → exactly one transpile across both instances).
  • Bulk-wipe edge case: #deleteAllTranspileCacheRows bumps existing rows but doesn't tombstone paths that didn't yet have a row, so an in-flight writer for one of those paths could still resurrect. Narrow, only reachable from __testOnlyClearCaches.
  • Periodic tombstone GC if the table grows materially (UNLOGGED, but still bounded only by total path cardinality).
  • Carry dependency_keys through the L2 write so cross-process load skips the extractModuleDependencyKeys recomputation in fallbackHandle.

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 11, 2026

Host Test Results

    1 files  ±  0      1 suites  ±0   1h 38m 42s ⏱️ + 34m 6s
2 376 tests +564  2 047 ✅ +532  11 💤 +1    2 ❌ ± 0  316 🔥 +31 
2 395 runs  +567  1 750 ✅ +504  11 💤 +1  318 ❌ +31  316 🔥 +31 

Results for commit 333b135. ± Comparison against earlier commit feb1e11.

For more details on these errors, see this check.

Realm Server Test Results

    1 files  ±  0      1 suites  ±0   15m 22s ⏱️ + 10m 56s
1 312 tests +791  1 308 ✅ +787  0 💤 ±0  4 ❌ +4 
1 391 runs  +857  1 387 ✅ +853  0 💤 ±0  4 ❌ +4 

Results for commit 333b135. ± Comparison against earlier commit feb1e11.

For more details on these errors, see this check.

@lukemelia lukemelia force-pushed the cs-11029-in-process-inflight-transpile-dedup-for-realmmodulecache branch from 5e2aa2b to 53669fe Compare May 11, 2026 12:05
@lukemelia lukemelia force-pushed the cs-11030-cross-process-transpile-coalesce-for-realmmodulecache branch from 635b61b to 98e454c Compare May 11, 2026 12:07
@lukemelia lukemelia force-pushed the cs-11029-in-process-inflight-transpile-dedup-for-realmmodulecache branch from 53669fe to e687df6 Compare May 11, 2026 12:19
@lukemelia lukemelia force-pushed the cs-11030-cross-process-transpile-coalesce-for-realmmodulecache branch from 98e454c to feb1e11 Compare May 11, 2026 12:20
Lifts Realm.#moduleCache from a purely in-memory L1 to a two-layer
L1+L2 system. The new L2 is a Postgres-backed module_transpile_cache
table (UNLOGGED, RAM-backed, like the modules definition cache) keyed
on (realm_url, canonical_path) so peer realm-servers in the same fleet
can re-read a transpile produced by any other peer instead of each
running babel independently.

When a fresh transpile is needed:
  1. read module_transpile_cache first — if a peer (or this process on
     an earlier request that aged out of L1) already produced bytes,
     return them with no babel.
  2. with a coordinator: tryAcquireAndRun on coalesceKey
     `transpile|<realmURL>|<canonicalPath>`. The winner re-reads the
     row (peer may have written between the miss and the win),
     transpiles on miss, persists to the L2 table, and emits NOTIFY on
     the same MODULE_CACHE_POPULATED_CHANNEL CS-10953 already uses for
     definition-cache coalesce. Losers waitForKey + re-read; on missed
     NOTIFY they fall through to a local transpile + persist so the
     next reader still benefits.
  3. without a coordinator (sqlite / in-memory deployments): direct
     transpile + L2 persist; ON CONFLICT DO NOTHING absorbs any
     accidental cross-process race that may still occur if two
     processes share the same DB but skip the coordinator.

Invalidation paths — invalidateCache, writeMany, delete/deleteAll, the
local file-watcher callback, handleExecutableInvalidations, the full-
index clear — already route through the CS-11028 helpers; those now
fire-and-forget a DELETE against module_transpile_cache alongside the
existing in-memory drop. Fire-and-forget is acceptable because every
peer's listener runs the same DELETE for its own copy (self-healing on
transient pg blips), and a stale L2 row is overwritten by the next
writer's INSERT once the row is re-DELETEd anyway.

L2 persist is best-effort by design: a transient pg failure is
logged via realm.#log.warn but doesn't surface to the caller — the
caller already has the bytes in memory. The L1 generation guard from
CS-11028 still protects against invalidate-during-transpile races on
the local in-memory cache; the L2 race is bounded by the same NOTIFY
flow that wakes peers on invalidate.

Wiring: main.ts passes the existing moduleCacheCoordinator as
transpileCoordinator (the same instance powers both the prerender
coalesce and the transpile coalesce — coalesceKey prefixes keep the
two flows separate). Test helper and tests don't construct a
coordinator yet — the uncoordinated path exercises the L2 read/write
and DELETE plumbing; the coalesce semantics themselves are covered by
the CS-10953 ModuleCacheCoordinator tests already shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lukemelia lukemelia force-pushed the cs-11030-cross-process-transpile-coalesce-for-realmmodulecache branch from feb1e11 to 333b135 Compare May 11, 2026 12:37
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