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
Conversation
Contributor
Host Test Results 1 files ± 0 1 suites ±0 1h 38m 42s ⏱️ + 34m 6s 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 Results for commit 333b135. ± Comparison against earlier commit feb1e11. For more details on these errors, see this check. |
5e2aa2b to
53669fe
Compare
635b61b to
98e454c
Compare
53669fe to
e687df6
Compare
98e454c to
feb1e11
Compare
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>
feb1e11 to
333b135
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
generationcolumn closing the invalidate-during-transpile race that L1's existing guard (CS-11028) alone couldn't cover at the L2 layer.Summary
module_transpile_cachetable (UNLOGGED, RAM-backed, like themodulesdefinition cache). Keyed on(realm_url, canonical_path). Columns: nullable body/headers/dependency_keys (so tombstones can sit in the row without bytes),generationBIGINT NOT NULL DEFAULT 0, created_at.Realm.#moduleCachefrom purely in-memory L1 to L1+L2. L1 stays in-process for hot-path latency; L2 is the cross-process shared layer.generationat the L2 read step (or 0 if absent), transpiles, then UPSERTs with that captured value viaON 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.#deleteTranspileCacheRowUPSERTsbody=NULL, generation = generation+1rather 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 (#deleteAllTranspileCacheRowsfrom__testOnlyClearCaches) likewise UPDATEs all rows to tombstones with bumped gen.ModuleCacheCoordinator(CS-10953) andMODULE_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.main.tspasses the existingmoduleCacheCoordinatorastranspileCoordinatorto every Realm it constructs.The CS-11028 generation guard protects the in-memory L1 write; the new DB-resident
generationcolumn 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
Realm.#moduleCache L2 module_transpile_cache (DB-backed)module:module_transpile_cacheinvalidateCachetombstones the L2 row and bumps generationModuleCacheCoordinatortests still pass — the coordinator is unchanged; we just route a second flow through it.realm-endpoints-test.tsstill pass (etag, 304, content-type).Follow-ups (NOT in this PR)
module-cache-coordination-test.tsfor the transpile flow (gated babel + advisory-lock contention → exactly one transpile across both instances).#deleteAllTranspileCacheRowsbumps 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.extractModuleDependencyKeysrecomputation in fallbackHandle.🤖 Generated with Claude Code