From f6acf35aa6f4c216e8faed20e36102ad2063da03 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 14 May 2026 17:47:22 -0400 Subject: [PATCH 1/5] CS-11156: cross-replica clearLocalCaches broadcast via NOTIFY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CS-11043 publish-realm fix invalidates the publish-handling replica's #sourceCache / #moduleCache before the reindex enqueues so the reindex's prerender doesn't see pre-swap bytes. That fix is correct on one replica. On two+ replicas behind a load balancer, peers still hold pre-swap bytes in their own caches and the reindex's HTTP fan-out to peers serves stale source — back into boxel_index.isolated_html, served forever. Extends the existing per-path `realm_file_changes` NOTIFY channel with a bulk payload `:*` meaning "drop every cached path for this realm". Wired into publish, unpublish, and delete realm handlers; on receive, peers call `Realm.clearLocalCaches()`. * runtime-common/realm.ts: `REALM_FILE_CHANGES_WILDCARD` sentinel, standalone `notifyAllFileChanges(dbAdapter, realmURL)` emitter, and `Realm.notifyAllFileChanges()` instance form. Same fire-and-forget semantics as `Realm.#notifyFileChange`; missed NOTIFY is a bounded staleness window per §9 of the registry doc, not data corruption. * realm-file-changes-listener.ts: dispatch branches on the wildcard payload to `Realm.clearLocalCaches()`. Existing per-path parser + realm lookup reused as-is. * handle-publish-realm.ts: keeps the sync local `clearLocalCaches()` before the reindex enqueue (replica's own prerender fan-out must bypass its cache) and adds the broadcast after. Self-NOTIFY is a no-op since clearLocalCaches is idempotent. * handle-unpublish-realm.ts and handle-delete-realm.ts: broadcast after the FS removal. Defense-in-depth against the brief window before peers unmount via `NOTIFY realm_registry`. Tests in realm-file-changes-listener-test.ts: * parsePayload returns `path: '*'` for both `host:port` and port-less URLs * dispatch routes wildcard to `clearLocalCaches`, not `invalidateCache` * end-to-end through the live LISTEN client: the new emitter → Postgres NOTIFY → the listener → `clearLocalCaches` on a fake peer-side realm Stacks on #4840 (CS-11125 — per-realm advisory locks on the data plane). The lock is what makes the broadcast's "after the swap" ordering meaningful — without serialization a concurrent same-realm write could land in the staleness window. Linear: https://linear.app/cardstack/issue/CS-11156 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handlers/handle-delete-realm.ts | 12 ++ .../handlers/handle-publish-realm.ts | 6 + .../handlers/handle-unpublish-realm.ts | 10 ++ .../lib/realm-file-changes-listener.ts | 29 +++- .../tests/realm-file-changes-listener-test.ts | 137 ++++++++++++++++-- packages/runtime-common/realm.ts | 58 +++++++- 6 files changed, 230 insertions(+), 22 deletions(-) diff --git a/packages/realm-server/handlers/handle-delete-realm.ts b/packages/realm-server/handlers/handle-delete-realm.ts index fc117f61d40..a682a270880 100644 --- a/packages/realm-server/handlers/handle-delete-realm.ts +++ b/packages/realm-server/handlers/handle-delete-realm.ts @@ -5,6 +5,7 @@ import { ensureTrailingSlash, fetchRealmPermissions, getMatrixUsername, + notifyAllFileChanges, param, PUBLISHED_DIRECTORY_NAME, query, @@ -239,6 +240,17 @@ export default function handleDeleteRealm({ Sentry.captureException(error); } + // CS-11156. Broadcast a bulk cache-invalidation for the source realm + // and each removed published realm so any peer replicas that still + // have these realms mounted drop their #sourceCache / #moduleCache + // before the reconciler unmount lands via NOTIFY realm_registry. + // Best-effort, fire-and-forget; missed NOTIFY is a bounded + // staleness window resolved by the unmount itself. + for (let publishedRealm of publishedRealms) { + await notifyAllFileChanges(dbAdapter, publishedRealm.url); + } + await notifyAllFileChanges(dbAdapter, realmURL); + await setContextResponse( ctxt, new Response(null, { diff --git a/packages/realm-server/handlers/handle-publish-realm.ts b/packages/realm-server/handlers/handle-publish-realm.ts index 953a9fc3472..0d3e8513fff 100644 --- a/packages/realm-server/handlers/handle-publish-realm.ts +++ b/packages/realm-server/handlers/handle-publish-realm.ts @@ -572,7 +572,13 @@ export default function handlePublishRealm({ let mountedRealmForCacheClear = await reconciler.lookupOrMount(publishedRealmURL); if (mountedRealmForCacheClear) { + // Sync local clear before the reindex enqueue: this replica's + // own prerender fetches must bypass the pre-swap cache. The + // broadcast below covers the same staleness on peer replicas + // (CS-11156) — self-receive is a no-op since clearLocalCaches + // is idempotent. mountedRealmForCacheClear.clearLocalCaches(); + await mountedRealmForCacheClear.notifyAllFileChanges(); } // Refresh the index. For a new publish this is redundant diff --git a/packages/realm-server/handlers/handle-unpublish-realm.ts b/packages/realm-server/handlers/handle-unpublish-realm.ts index 9a96e698166..8d2aa5af66a 100644 --- a/packages/realm-server/handlers/handle-unpublish-realm.ts +++ b/packages/realm-server/handlers/handle-unpublish-realm.ts @@ -4,6 +4,7 @@ import { query, SupportedMimeType, logger, + notifyAllFileChanges, param, removeRealmPermissions, fetchRealmPermissions, @@ -153,6 +154,15 @@ export default function handleUnpublishRealm({ // mode where we delete files but the registry row sticks around. removeRealmFiles(publishedRealmPath); + // CS-11156. Broadcast a bulk cache-invalidation to peer replicas so + // any that still have this realm mounted drop their #sourceCache / + // #moduleCache before the reconciler unmount lands. The per-file + // deleteAll above already emitted per-path NOTIFYs covering bytes + // that existed on disk; this bulk emit closes the brief window + // between the registry-row delete commit and the peers' reaction. + // Best-effort, fire-and-forget. + await notifyAllFileChanges(dbAdapter, publishedRealmURL); + // Removing this derivative just changed the source realm's // `RealmInfo.lastPublishedAt` map (rows where `source_url = // sourceRealmURL`). Without invalidating the source's cached diff --git a/packages/realm-server/lib/realm-file-changes-listener.ts b/packages/realm-server/lib/realm-file-changes-listener.ts index 230e94dc591..bad2977e44f 100644 --- a/packages/realm-server/lib/realm-file-changes-listener.ts +++ b/packages/realm-server/lib/realm-file-changes-listener.ts @@ -1,5 +1,9 @@ import type { Realm } from '@cardstack/runtime-common'; -import { logger, REALM_FILE_CHANGES_CHANNEL } from '@cardstack/runtime-common'; +import { + logger, + REALM_FILE_CHANGES_CHANNEL, + REALM_FILE_CHANGES_WILDCARD, +} from '@cardstack/runtime-common'; import type { PgAdapter, NotificationSubscription } from '@cardstack/postgres'; const log = logger('realm-server:file-changes-listener'); @@ -12,6 +16,12 @@ const log = logger('realm-server:file-changes-listener'); // #moduleCache entries. If it's not mounted, the notification is dropped — // this instance has no stale state to clear. // +// Bulk variant: when the path is the wildcard sentinel `*` (CS-11156), +// `realm.clearLocalCaches()` drops every cached path for that realm. Emitted +// by the publish-realm / unpublish-realm / delete-realm handlers after the +// FS swap or removal so peers (whose file-watcher events do NOT cross +// replicas) bypass their pre-swap cached bytes on the next read. +// // The LISTEN is backed by `PgAdapter.subscribe` (shared multiplexed // notification client). There is no periodic work to run between // notifications — the whole dispatch is in the payload — so we don't keep a @@ -87,7 +97,11 @@ export class RealmFileChangesListener { return; } try { - realm.invalidateCache(parsed.path); + if (parsed.path === REALM_FILE_CHANGES_WILDCARD) { + realm.clearLocalCaches(); + } else { + realm.invalidateCache(parsed.path); + } } catch (err: unknown) { log.warn( `invalidateCache failed for ${parsed.url} ${parsed.path}: ${String(err)}`, @@ -96,11 +110,14 @@ export class RealmFileChangesListener { } } -// Payload shape: `:`. Realm URLs always carry a -// trailing slash (enforced by `ensureTrailingSlash` throughout the code), -// so the separator between URL and path is the first `:` that immediately +// Payload shape: `:` or `:*` (bulk +// invalidation — CS-11156). Realm URLs always carry a trailing slash +// (enforced by `ensureTrailingSlash` throughout the code), so the +// separator between URL and path is the first `:` that immediately // follows a `/`. That avoids false matches on the scheme colon -// (`http://...`) and any host:port colon (`localhost:4201`). +// (`http://...`) and any host:port colon (`localhost:4201`). The same +// regex handles both shapes; the wildcard payload parses as +// `path = '*'`. const PAYLOAD_SEPARATOR = /\/:/; export function parsePayload( diff --git a/packages/realm-server/tests/realm-file-changes-listener-test.ts b/packages/realm-server/tests/realm-file-changes-listener-test.ts index 9007d5d323a..19b66f189f0 100644 --- a/packages/realm-server/tests/realm-file-changes-listener-test.ts +++ b/packages/realm-server/tests/realm-file-changes-listener-test.ts @@ -1,23 +1,31 @@ import { module, test } from 'qunit'; import { basename } from 'path'; import type { PgAdapter } from '@cardstack/postgres'; -import type { Realm } from '@cardstack/runtime-common'; +import { notifyAllFileChanges, type Realm } from '@cardstack/runtime-common'; import { setupDB } from './helpers'; import { RealmFileChangesListener, parsePayload, } from '../lib/realm-file-changes-listener'; -// Minimal fake `Realm` — the listener only calls `.url` (via lookup) and -// `.invalidateCache(path)`, so that's all we need to stub. +// Minimal fake `Realm` — the listener calls `.url` (via lookup), +// `.invalidateCache(path)` for per-path payloads, and `.clearLocalCaches()` +// for wildcard payloads (CS-11156). Stub both; tests pick whichever they +// care about. function makeFakeRealm( url: string, - onInvalidate: (path: string) => void, + hooks: { + onInvalidate?: (path: string) => void; + onClearAll?: () => void; + }, ): Realm { return { url, invalidateCache(path: string) { - onInvalidate(path); + hooks.onInvalidate?.(path); + }, + clearLocalCaches() { + hooks.onClearAll?.(); }, } as unknown as Realm; } @@ -78,14 +86,29 @@ module(basename(__filename), function () { test('returns undefined when the path is empty', function (assert) { assert.strictEqual(parsePayload('http://x/:'), undefined); }); + + test('parses a wildcard payload (bulk invalidation, CS-11156)', function (assert) { + assert.deepEqual(parsePayload('http://x.test/r/:*'), { + url: 'http://x.test/r/', + path: '*', + }); + }); + + test('parses a wildcard payload against a url with a port (CS-11156)', function (assert) { + assert.deepEqual(parsePayload('http://localhost:4201/luke/src/:*'), { + url: 'http://localhost:4201/luke/src/', + path: '*', + }); + }); }); module('RealmFileChangesListener (dispatch)', function () { test('handleNotification forwards to the mounted realm', function (assert) { const invalidations: Array<{ url: string; path: string }> = []; - const realmA = makeFakeRealm('http://x.test/a/', (path) => - invalidations.push({ url: 'http://x.test/a/', path }), - ); + const realmA = makeFakeRealm('http://x.test/a/', { + onInvalidate: (path) => + invalidations.push({ url: 'http://x.test/a/', path }), + }); const listener = new RealmFileChangesListener({ dbAdapter: {} as unknown as PgAdapter, lookupMountedRealm: (url) => @@ -99,6 +122,33 @@ module(basename(__filename), function () { ]); }); + test('handleNotification with wildcard payload calls clearLocalCaches and not invalidateCache (CS-11156)', function (assert) { + const invalidations: string[] = []; + const clearAllCount = { value: 0 }; + const realmA = makeFakeRealm('http://x.test/a/', { + onInvalidate: (path) => invalidations.push(path), + onClearAll: () => clearAllCount.value++, + }); + const listener = new RealmFileChangesListener({ + dbAdapter: {} as unknown as PgAdapter, + lookupMountedRealm: (url) => + url === 'http://x.test/a/' ? realmA : undefined, + }); + + listener.handleNotification('http://x.test/a/:*'); + + assert.strictEqual( + clearAllCount.value, + 1, + 'clearLocalCaches called exactly once', + ); + assert.deepEqual( + invalidations, + [], + 'invalidateCache not called for wildcard payload', + ); + }); + test('handleNotification drops silently when the url is not mounted', function (assert) { const invalidations: string[] = []; const listener = new RealmFileChangesListener({ @@ -154,9 +204,9 @@ module(basename(__filename), function () { test('NOTIFY realm_file_changes → listener → invalidateCache', async function (assert) { const invalidations: Array<{ url: string; path: string }> = []; const realmUrl = 'http://x.test/listen-e2e/'; - const realmA = makeFakeRealm(realmUrl, (path) => - invalidations.push({ url: realmUrl, path }), - ); + const realmA = makeFakeRealm(realmUrl, { + onInvalidate: (path) => invalidations.push({ url: realmUrl, path }), + }); const listener = new RealmFileChangesListener({ dbAdapter, lookupMountedRealm: (url) => (url === realmUrl ? realmA : undefined), @@ -179,6 +229,71 @@ module(basename(__filename), function () { } }); + test('notifyAllFileChanges round-trip: emitter → NOTIFY → listener → clearLocalCaches (CS-11156)', async function (assert) { + // Models the cross-replica case: the emitter is what the publish / + // unpublish / delete realm handlers call after the FS swap; the + // listener is a peer replica's subscription. End-to-end through the + // shared Postgres NOTIFY channel. + const clearAllCount = { value: 0 }; + const realmUrl = 'http://x.test/listen-e2e-bulk-emit/'; + const realmA = makeFakeRealm(realmUrl, { + onClearAll: () => clearAllCount.value++, + }); + const listener = new RealmFileChangesListener({ + dbAdapter, + lookupMountedRealm: (url) => (url === realmUrl ? realmA : undefined), + }); + await listener.start(); + try { + await notifyAllFileChanges(dbAdapter, realmUrl); + + await waitFor(() => + clearAllCount.value > 0 ? clearAllCount.value : undefined, + ); + assert.strictEqual( + clearAllCount.value, + 1, + 'peer-side clearLocalCaches called once after the bulk emit', + ); + } finally { + await listener.shutDown(); + } + }); + + test('NOTIFY realm_file_changes wildcard → listener → clearLocalCaches (CS-11156)', async function (assert) { + const invalidations: string[] = []; + const clearAllCount = { value: 0 }; + const realmUrl = 'http://x.test/listen-e2e-bulk/'; + const realmA = makeFakeRealm(realmUrl, { + onInvalidate: (path) => invalidations.push(path), + onClearAll: () => clearAllCount.value++, + }); + const listener = new RealmFileChangesListener({ + dbAdapter, + lookupMountedRealm: (url) => (url === realmUrl ? realmA : undefined), + }); + await listener.start(); + try { + await dbAdapter.notify('realm_file_changes', `${realmUrl}:*`); + + await waitFor(() => + clearAllCount.value > 0 ? clearAllCount.value : undefined, + ); + assert.strictEqual( + clearAllCount.value, + 1, + 'clearLocalCaches called exactly once', + ); + assert.deepEqual( + invalidations, + [], + 'invalidateCache not called for wildcard', + ); + } finally { + await listener.shutDown(); + } + }); + test('NOTIFY for an unmounted realm is dropped silently', async function (assert) { const lookups: string[] = []; const listener = new RealmFileChangesListener({ diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index de66c384415..722de13cfc8 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -285,11 +285,51 @@ const SOURCE_ETAG_VARIANT = 'source'; const CARD_JSON_ETAG_VARIANT = 'card'; // Postgres NOTIFY channel for cross-instance invalidation of #sourceCache / -// #moduleCache entries on file writes. Payload shape is `:`. +// #moduleCache entries on file writes. Two payload shapes: +// +// `:` — invalidate a single path's cached source + +// (for executable extensions) module entry. Emitted by every +// single-file write/delete via Realm.#notifyFileChange. Receiver +// calls Realm.invalidateCache(path). +// `:*` — bulk-invalidate every cached path for this +// realm. Emitted by the publish-realm / unpublish-realm / +// delete-realm handlers after the FS swap or removal, so peer +// replicas (which do NOT receive the file-watcher events that +// drive single-file invalidation in-process) drop pre-swap bytes +// from `#sourceCache` / `#moduleCache` before serving the next +// source read. Receiver calls Realm.clearLocalCaches(). See +// CS-11156. (`*` is reserved as the wildcard sentinel; real +// LocalPath values never contain it.) +// // See docs/db-authoritative-realm-registry.md §6 "Cache invalidation channel" // and §9 "Cache-invalidation NOTIFY missed" for the semantics (best-effort, // missed-NOTIFY is a cache-staleness window, not data corruption). export const REALM_FILE_CHANGES_CHANNEL = 'realm_file_changes'; +export const REALM_FILE_CHANGES_WILDCARD = '*'; + +// Standalone form of `Realm.notifyAllFileChanges()`. Use when the caller +// has a DBAdapter + realm URL but no mounted `Realm` instance — e.g. +// the delete-realm handler runs after the realm has already been torn +// down, so it cannot route through an instance method. Same best-effort +// semantics as `Realm.#notifyFileChange`: failures are logged and +// swallowed (missed NOTIFY is a bounded staleness window, not data +// corruption). See CS-11156. +export async function notifyAllFileChanges( + dbAdapter: DBAdapter, + realmURL: string, +): Promise { + try { + await dbAdapter.notify( + REALM_FILE_CHANGES_CHANNEL, + `${realmURL}:${REALM_FILE_CHANGES_WILDCARD}`, + ); + } catch (err: unknown) { + logger('realm').warn( + `notify ${REALM_FILE_CHANGES_CHANNEL} (bulk) failed for ${realmURL}: ${String(err)}`, + ); + } +} + export const FILE_META_RESERVED_KEYS = new Set([ 'name', 'url', @@ -1511,6 +1551,17 @@ export class Realm { } } + // Bulk variant of `#notifyFileChange` — broadcast "drop every cached path + // for this realm" to peer realm-server instances. The publish-realm / + // unpublish-realm / delete-realm handlers emit this after the FS swap or + // removal so peer replicas (which do NOT see the local file-watcher events + // that drive single-file invalidation) drop pre-swap bytes from + // `#sourceCache` / `#moduleCache` before the next source read. Receiver + // calls `Realm.clearLocalCaches()`. See CS-11156. + async notifyAllFileChanges(): Promise { + return notifyAllFileChanges(this.#dbAdapter, this.url); + } + createJWT(claims: TokenClaims, expiration: ms.StringValue): string { return this.#adapter.createJWT(claims, expiration, this.#realmSecretSeed); } @@ -4108,10 +4159,7 @@ export class Realm { let foreignDeps = this.hasForeignRealmDeps(entry.deps); let etag = foreignDeps ? undefined - : buildCardJsonEtag( - entry.indexedAt, - this.getCachedRealmInfoHash(), - ); + : buildCardJsonEtag(entry.indexedAt, this.getCachedRealmInfoHash()); return createResponse({ body: JSON.stringify(existingDoc, null, 2), init: { From f0a3a63c1cd73de5c154216763dc1917e6287cb0 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 14 May 2026 22:35:34 -0400 Subject: [PATCH 2/5] Encapsulate clearLocalCaches + broadcast in a single Realm method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the initial CS-11156 PR. The publish-realm handler had to call two methods in sequence to fully invalidate the publishing replica's cache plus all peers' caches: mountedRealmForCacheClear.clearLocalCaches(); await mountedRealmForCacheClear.notifyAllFileChanges(); Every future emitter would have to remember both lines. Mirroring `CachingDefinitionLookup.clearRealmCache(url)` — which bundles local generation bump + DB DELETE + cross-instance NOTIFY in one method — introduce `Realm.clearLocalCachesAndBroadcast()` that does both steps and let the handler make one call. Also drop `Realm.notifyAllFileChanges()`. It was a thin wrapper around the standalone free function `notifyAllFileChanges(dbAdapter, url)` and they were used inconsistently — publish used the method, unpublish used the free function despite having a Realm instance in scope. The two surfaces collapse to one clear rule: - Need local clear AND broadcast (publish handler, realm staying up): `realm.clearLocalCachesAndBroadcast()`. - Need ONLY the peer broadcast (unpublish/delete handlers, realm being torn down — local cache is about to be GC'd with the Realm instance): `notifyAllFileChanges(dbAdapter, url)`. `Realm.clearLocalCaches()` stays as the local-only primitive the LISTEN handler calls on receive (no broadcast, no NOTIFY loop). The free function `notifyAllFileChanges` is the single cross-replica emit surface — the Realm class no longer needs to know about channel names or payload formats. No behavior change. All 16 realm-file-changes-listener tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handlers/handle-publish-realm.ts | 12 +++--- packages/runtime-common/realm.ts | 38 ++++++++++++------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/realm-server/handlers/handle-publish-realm.ts b/packages/realm-server/handlers/handle-publish-realm.ts index 0d3e8513fff..67d1d801dc5 100644 --- a/packages/realm-server/handlers/handle-publish-realm.ts +++ b/packages/realm-server/handlers/handle-publish-realm.ts @@ -572,13 +572,11 @@ export default function handlePublishRealm({ let mountedRealmForCacheClear = await reconciler.lookupOrMount(publishedRealmURL); if (mountedRealmForCacheClear) { - // Sync local clear before the reindex enqueue: this replica's - // own prerender fetches must bypass the pre-swap cache. The - // broadcast below covers the same staleness on peer replicas - // (CS-11156) — self-receive is a no-op since clearLocalCaches - // is idempotent. - mountedRealmForCacheClear.clearLocalCaches(); - await mountedRealmForCacheClear.notifyAllFileChanges(); + // Sync local clear + cross-replica NOTIFY in one call. The + // local clear is what this replica's reindex fan-out needs; + // the broadcast (CS-11156) covers peers that still have the + // realm mounted with pre-swap bytes. + await mountedRealmForCacheClear.clearLocalCachesAndBroadcast(); } // Refresh the index. For a new publish this is redundant diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 722de13cfc8..994a15f0a7c 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -307,10 +307,15 @@ const CARD_JSON_ETAG_VARIANT = 'card'; export const REALM_FILE_CHANGES_CHANNEL = 'realm_file_changes'; export const REALM_FILE_CHANGES_WILDCARD = '*'; -// Standalone form of `Realm.notifyAllFileChanges()`. Use when the caller -// has a DBAdapter + realm URL but no mounted `Realm` instance — e.g. -// the delete-realm handler runs after the realm has already been torn -// down, so it cannot route through an instance method. Same best-effort +// Emit a bulk `:*` NOTIFY on the `realm_file_changes` channel so +// peer realm-server replicas drop every cached path for this realm. Use +// directly when the caller has a DBAdapter + realm URL but isn't keeping +// the realm running locally (the unpublish-realm and delete-realm +// handlers — the realm is about to be torn down, so this replica's own +// in-process cache will be garbage-collected with the Realm instance). +// When the caller wants the SAME local cache wipe AND the broadcast +// — i.e. its own next read must not hit pre-swap bytes — call +// `Realm.clearLocalCachesAndBroadcast()` instead. Same best-effort // semantics as `Realm.#notifyFileChange`: failures are logged and // swallowed (missed NOTIFY is a bounded staleness window, not data // corruption). See CS-11156. @@ -1551,15 +1556,22 @@ export class Realm { } } - // Bulk variant of `#notifyFileChange` — broadcast "drop every cached path - // for this realm" to peer realm-server instances. The publish-realm / - // unpublish-realm / delete-realm handlers emit this after the FS swap or - // removal so peer replicas (which do NOT see the local file-watcher events - // that drive single-file invalidation) drop pre-swap bytes from - // `#sourceCache` / `#moduleCache` before the next source read. Receiver - // calls `Realm.clearLocalCaches()`. See CS-11156. - async notifyAllFileChanges(): Promise { - return notifyAllFileChanges(this.#dbAdapter, this.url); + // Drop this replica's own `#sourceCache` / `#moduleCache` AND broadcast + // the same wipe to peer replicas. Used by the publish-realm handler + // before the reindex enqueue: this replica's own prerender fan-out must + // bypass its cache (sync local clear), and peer replicas must drop + // their pre-swap bytes too (cross-instance NOTIFY). Self-receive of the + // NOTIFY is a no-op since `clearLocalCaches()` is idempotent. + // + // Bundles local + broadcast in one call, mirroring + // `CachingDefinitionLookup.clearRealmCache(url)` — handlers don't have + // to remember both steps. Callers that only need the peer broadcast + // (because their own Realm instance is about to be unmounted anyway — + // unpublish/delete handlers) use the standalone `notifyAllFileChanges` + // free function above instead. + async clearLocalCachesAndBroadcast(): Promise { + this.clearLocalCaches(); + await notifyAllFileChanges(this.#dbAdapter, this.url); } createJWT(claims: TokenClaims, expiration: ms.StringValue): string { From 4691944f088bce196036d5e9a945737f23aaeb4b Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 14 May 2026 23:01:46 -0400 Subject: [PATCH 3/5] Replace raw modules DELETE with definitionLookup.clearRealmCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish-realm handler had a hand-rolled `DELETE FROM modules WHERE resolved_realm_url = $1` to drop stale error entries before the reindex fan-out. That covers the DB rows but is strictly weaker than `CachingDefinitionLookup.clearRealmCache(url)`, which: 1. bumps the per-realm generation counter so in-flight prerenders on this replica that started before the DELETE see a mismatch at persist time and discard their result instead of re-inserting a row this invalidation just removed, 2. drops in-flight prerender promises for the realm so new callers install their own pending against post-swap state rather than joining a stale shared transpile, 3. runs the same DELETE, and 4. broadcasts on `module_cache_invalidated` so peer realm-server replicas perform 1-3 on their own state. The raw DELETE did only step 3. The reindex worker's prerender fan-out fires immediately after this code path through HTTP into both this realm-server and its peers, so missing steps 1, 2, and 4 was exactly the modules-cache analog of the byte-cache staleness this PR fixes via `clearLocalCachesAndBroadcast()`. `clearRealmCache` already runs via the post-fullIndex completion path in `Realm.startReindex` (realm.ts:1068), but that's at the *end* of the reindex — too late for the prerender fan-out at the start. Running it pre-reindex ensures the rebuild starts against a coherent cache on every replica. `definitionLookup` is already plumbed through `CreateRoutesArgs`; the handler just needed to destructure it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handlers/handle-publish-realm.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/realm-server/handlers/handle-publish-realm.ts b/packages/realm-server/handlers/handle-publish-realm.ts index 67d1d801dc5..ba2194fb889 100644 --- a/packages/realm-server/handlers/handle-publish-realm.ts +++ b/packages/realm-server/handlers/handle-publish-realm.ts @@ -223,6 +223,7 @@ function ensureRealmIndexBoilerplateOptIn(publishedRealmPath: string): void { export default function handlePublishRealm({ dbAdapter, + definitionLookup, matrixClient, queue, realmSecretSeed, @@ -526,12 +527,20 @@ export default function handlePublishRealm({ // it up. ensureRealmIndexBoilerplateOptIn(publishedRealmPath); - // Clear stale modules cache for the published realm so that - // error entries from a previous publish don't persist - await query(dbAdapter, [ - `DELETE FROM modules WHERE resolved_realm_url =`, - param(publishedRealmURL), - ]); + // Clear stale modules cache for the published realm (including + // error entries from a previous publish) before the reindex's + // prerender fan-out, so its HTTP module fetches don't hit + // cached pre-swap state on this replica or its peers. + // `clearRealmCache` bundles the DB DELETE + in-flight prerender + // drop + per-realm generation bump + cross-instance NOTIFY on + // `module_cache_invalidated` — the modules-cache analog of + // `clearLocalCachesAndBroadcast()` below. Without those extra + // steps (which a raw `DELETE FROM modules` would miss), an + // in-flight prerender that started before the DELETE could + // re-insert a stale row at persist time, and peer replicas + // would keep their cached rows + generation counters until + // their own next invalidation arrived. + await definitionLookup.clearRealmCache(publishedRealmURL); let lastPublishedAt = Date.now().toString(); try { From fba531d365a58a0e167f08c5c6f005318261465b Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 14 May 2026 23:31:18 -0400 Subject: [PATCH 4/5] Tier 1 rename: Realm byte caches disambiguated from DefinitionLookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two completely different caches were both called "the module cache" in this codebase: - `Realm.#moduleCache` — in-process bytes of transpiled JS (the prerender's input) - `CachingDefinitionLookup`'s `modules` DB table — assembled card definitions (the prerender's output) Both even had a type named `ModuleCacheEntry` with different shapes. The juxtaposition in `handle-publish-realm.ts` after #4842 (`definitionLookup.clearRealmCache(url)` next to `realm.clearLocalCachesAndBroadcast()`) made the collision impossible to ignore. This commit renames the Realm-side cache to make the "transpiled JS bytes" framing explicit at the API surface, and renames the public cache-wipe methods so each call site self-documents which cache it touches. - `Realm.#moduleCache` → `Realm.#transpiledModuleCache` - Type `ModuleCacheEntry` (in `realm.ts`, local to that file) → `TranspiledModuleEntry` - `Realm.clearLocalCaches()` → `Realm.clearLocalSourceCaches()` - `Realm.clearLocalCachesAndBroadcast()` → `Realm.clearLocalSourceCachesAndBroadcast()` - Internal helpers renamed consistently (`#dropModuleCacheEntry`, `#bumpModuleCacheGeneration`, the generation maps, etc.) Mechanical rename — no behavior change. 16/16 listener tests pass. Tier 2 (DefinitionLookup-side renames: `ModuleCacheEntry` → `DefinitionCacheEntry`, `clearRealmCache` → `clearRealmDefinitions`, `clearAllModules` → `clearAllDefinitions`, etc.) is a separate follow-up commit. Tier 3 (DB column + NOTIFY channel rename, needs a deploy plan for rolling-update compatibility) is deliberately deferred. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handlers/handle-delete-realm.ts | 2 +- .../handlers/handle-publish-realm.ts | 6 +- .../handlers/handle-unpublish-realm.ts | 2 +- .../lib/realm-file-changes-listener.ts | 6 +- .../tests/card-source-endpoints-test.ts | 10 +- .../tests/module-cache-race-test.ts | 18 +-- .../tests/publish-unpublish-realm-test.ts | 2 +- .../tests/realm-file-changes-listener-test.ts | 16 +-- packages/runtime-common/realm.ts | 110 +++++++++--------- 9 files changed, 86 insertions(+), 86 deletions(-) diff --git a/packages/realm-server/handlers/handle-delete-realm.ts b/packages/realm-server/handlers/handle-delete-realm.ts index a682a270880..a9bb9328346 100644 --- a/packages/realm-server/handlers/handle-delete-realm.ts +++ b/packages/realm-server/handlers/handle-delete-realm.ts @@ -242,7 +242,7 @@ export default function handleDeleteRealm({ // CS-11156. Broadcast a bulk cache-invalidation for the source realm // and each removed published realm so any peer replicas that still - // have these realms mounted drop their #sourceCache / #moduleCache + // have these realms mounted drop their #sourceCache / #transpiledModuleCache // before the reconciler unmount lands via NOTIFY realm_registry. // Best-effort, fire-and-forget; missed NOTIFY is a bounded // staleness window resolved by the unmount itself. diff --git a/packages/realm-server/handlers/handle-publish-realm.ts b/packages/realm-server/handlers/handle-publish-realm.ts index ba2194fb889..03547fc2c3a 100644 --- a/packages/realm-server/handlers/handle-publish-realm.ts +++ b/packages/realm-server/handlers/handle-publish-realm.ts @@ -534,7 +534,7 @@ export default function handlePublishRealm({ // `clearRealmCache` bundles the DB DELETE + in-flight prerender // drop + per-realm generation bump + cross-instance NOTIFY on // `module_cache_invalidated` — the modules-cache analog of - // `clearLocalCachesAndBroadcast()` below. Without those extra + // `clearLocalSourceCachesAndBroadcast()` below. Without those extra // steps (which a raw `DELETE FROM modules` would miss), an // in-flight prerender that started before the DELETE could // re-insert a stale row at persist time, and peer replicas @@ -576,7 +576,7 @@ export default function handlePublishRealm({ // // For a new publish, lookupOrMount mounts the realm fresh // (registry row was just upserted above); the cache is - // empty so clearLocalCaches is a no-op. Either way the + // empty so clearLocalSourceCaches is a no-op. Either way the // reindex below sees correct source. let mountedRealmForCacheClear = await reconciler.lookupOrMount(publishedRealmURL); @@ -585,7 +585,7 @@ export default function handlePublishRealm({ // local clear is what this replica's reindex fan-out needs; // the broadcast (CS-11156) covers peers that still have the // realm mounted with pre-swap bytes. - await mountedRealmForCacheClear.clearLocalCachesAndBroadcast(); + await mountedRealmForCacheClear.clearLocalSourceCachesAndBroadcast(); } // Refresh the index. For a new publish this is redundant diff --git a/packages/realm-server/handlers/handle-unpublish-realm.ts b/packages/realm-server/handlers/handle-unpublish-realm.ts index 8d2aa5af66a..5592065cd8b 100644 --- a/packages/realm-server/handlers/handle-unpublish-realm.ts +++ b/packages/realm-server/handlers/handle-unpublish-realm.ts @@ -156,7 +156,7 @@ export default function handleUnpublishRealm({ // CS-11156. Broadcast a bulk cache-invalidation to peer replicas so // any that still have this realm mounted drop their #sourceCache / - // #moduleCache before the reconciler unmount lands. The per-file + // #transpiledModuleCache before the reconciler unmount lands. The per-file // deleteAll above already emitted per-path NOTIFYs covering bytes // that existed on disk; this bulk emit closes the brief window // between the registry-row delete commit and the peers' reaction. diff --git a/packages/realm-server/lib/realm-file-changes-listener.ts b/packages/realm-server/lib/realm-file-changes-listener.ts index bad2977e44f..7b49e20e936 100644 --- a/packages/realm-server/lib/realm-file-changes-listener.ts +++ b/packages/realm-server/lib/realm-file-changes-listener.ts @@ -13,11 +13,11 @@ const log = logger('realm-server:file-changes-listener'); // runtime-common/realm.ts), every listener subscribed on this channel looks // up the URL in its lookup function. If the realm is mounted locally, // `realm.invalidateCache(path)` clears the matching #sourceCache / -// #moduleCache entries. If it's not mounted, the notification is dropped — +// #transpiledModuleCache entries. If it's not mounted, the notification is dropped — // this instance has no stale state to clear. // // Bulk variant: when the path is the wildcard sentinel `*` (CS-11156), -// `realm.clearLocalCaches()` drops every cached path for that realm. Emitted +// `realm.clearLocalSourceCaches()` drops every cached path for that realm. Emitted // by the publish-realm / unpublish-realm / delete-realm handlers after the // FS swap or removal so peers (whose file-watcher events do NOT cross // replicas) bypass their pre-swap cached bytes on the next read. @@ -98,7 +98,7 @@ export class RealmFileChangesListener { } try { if (parsed.path === REALM_FILE_CHANGES_WILDCARD) { - realm.clearLocalCaches(); + realm.clearLocalSourceCaches(); } else { realm.invalidateCache(parsed.path); } diff --git a/packages/realm-server/tests/card-source-endpoints-test.ts b/packages/realm-server/tests/card-source-endpoints-test.ts index 928b023dac1..c33cf16b2b1 100644 --- a/packages/realm-server/tests/card-source-endpoints-test.ts +++ b/packages/realm-server/tests/card-source-endpoints-test.ts @@ -234,14 +234,14 @@ module(basename(__filename), function () { ); }); - // CS-11043. clearLocalCaches() is the public surface the + // CS-11043. clearLocalSourceCaches() is the public surface the // publish-realm handler invokes after the FS swap so that the - // pre-swap bytes living in #sourceCache / #moduleCache don't get + // pre-swap bytes living in #sourceCache / #transpiledModuleCache don't get // served to the reindex job (which would then write stale // isolated_html into boxel_index). Functionally equivalent to // __testOnlyClearCaches minus the test-only transpile-counter // reset. - test('clearLocalCaches drops cached source bytes', async function (assert) { + test('clearLocalSourceCaches drops cached source bytes', async function (assert) { let cacheTestPath = 'clear-local-caches.gts'; await testRealm.write( cacheTestPath, @@ -260,7 +260,7 @@ module(basename(__filename), function () { 'precondition: second fetch hits the source cache', ); - testRealm.clearLocalCaches(); + testRealm.clearLocalSourceCaches(); let afterClear = await request .get(`/${cacheTestPath}`) @@ -268,7 +268,7 @@ module(basename(__filename), function () { assert.strictEqual( afterClear.headers['x-boxel-cache'], 'miss', - 'fetch after clearLocalCaches is a miss — the #sourceCache entry was dropped', + 'fetch after clearLocalSourceCaches is a miss — the #sourceCache entry was dropped', ); }); diff --git a/packages/realm-server/tests/module-cache-race-test.ts b/packages/realm-server/tests/module-cache-race-test.ts index 3300ba23c6d..075bbccca1f 100644 --- a/packages/realm-server/tests/module-cache-race-test.ts +++ b/packages/realm-server/tests/module-cache-race-test.ts @@ -26,12 +26,12 @@ import { } from './helpers'; // CS-11028: regression coverage for the persist-after-invalidate race in -// Realm.#moduleCache. The scenario: reader A enters fallbackHandle for +// Realm.#transpiledModuleCache. The scenario: reader A enters fallbackHandle for // foo.gts, snapshots the module-cache generation, then awaits transpileJS // (50–500 ms). While A is in-flight, invalidateCache(foo.gts) runs — // synchronously bumping the per-path generation and clearing whatever was // in the cache (a no-op if A hadn't filled it yet). Without the fix A's -// post-transpile #moduleCache.set re-fills the slot with pre-invalidation +// post-transpile #transpiledModuleCache.set re-fills the slot with pre-invalidation // bytes, so the next reader sees stale code until something else triggers // another invalidate. The fix snapshots the generation BEFORE the first // await and discards the cache write when the generation moved. @@ -46,7 +46,7 @@ import { // `x-boxel-cache` header — a miss proves A's cache write was discarded. module(basename(__filename), function () { module( - 'Realm.#moduleCache invalidate-during-transpile race', + 'Realm.#transpiledModuleCache invalidate-during-transpile race', function (hooks) { let realmURL = new URL('http://127.0.0.1:4444/test/'); let testRealm: Realm; @@ -123,7 +123,7 @@ module(basename(__filename), function () { await new Promise((resolve) => setTimeout(resolve, 50)); // Invalidate mid-transpile. The generation counter bumps; A's - // post-transpile #moduleCache.set will compare its snapshot against + // post-transpile #transpiledModuleCache.set will compare its snapshot against // the now-bumped counter and skip the write. testRealm.invalidateCache(modulePath); @@ -211,7 +211,7 @@ module(basename(__filename), function () { ); // The canonical request side already worked before this fix - // (#moduleCache.invalidate cleared the canonical entry), but + // (#transpiledModuleCache.invalidate cleared the canonical entry), but // verify it still misses so the fix doesn't regress it. let canonicalResponse = await request .get(`/${canonicalPath}`) @@ -227,7 +227,7 @@ module(basename(__filename), function () { test('in-flight transpile result is dropped when testRealm.write fires concurrently (user-visible bug)', async function (assert) { // The original bug report: "file edited and saved, host page reload // serves the pre-edit module bytes." A user write goes through - // writeMany, which used to mutate #moduleCache directly without + // writeMany, which used to mutate #transpiledModuleCache directly without // bumping the generation counter — letting an in-flight transpile's // post-await cache.set silently fill the slot writeMany just // cleared. This test exercises the same code path the user does. @@ -298,7 +298,7 @@ module(basename(__filename), function () { // tracks a monotonic transpile counter exposed via // __testOnlyGetTranspileCallCount so the tests can assert "exactly one // transpile call" directly rather than inferring it from timing. - module('Realm.#moduleCache in-flight transpile dedup', function (hooks) { + module('Realm.#transpiledModuleCache in-flight transpile dedup', function (hooks) { let realmURL = new URL('http://127.0.0.1:4444/test/'); let testRealm: Realm; let request: RealmRequest; @@ -676,7 +676,7 @@ module(basename(__filename), function () { // coalesce behavior is exercised by module-cache-coordination-test.ts // and reused via the shared MODULE_CACHE_POPULATED_CHANNEL. module( - 'Realm.#moduleCache L2 module_transpile_cache (DB-backed)', + 'Realm.#transpiledModuleCache L2 module_transpile_cache (DB-backed)', function (hooks) { let realmURL = new URL('http://127.0.0.1:4444/test/'); let testRealm: Realm; @@ -972,7 +972,7 @@ module(basename(__filename), function () { // the advisory-lock + NOTIFY channel. Mirrors the // CachingDefinitionLookup two-instance test in // module-cache-coordination-test.ts but exercises the transpile flow. - module('Realm.#moduleCache L2 cross-instance coalesce', function (hooks) { + module('Realm.#transpiledModuleCache L2 cross-instance coalesce', function (hooks) { let dbAdapter: PgAdapter; let publisher: import('@cardstack/runtime-common').QueuePublisher; let runner: import('@cardstack/runtime-common').QueueRunner; diff --git a/packages/realm-server/tests/publish-unpublish-realm-test.ts b/packages/realm-server/tests/publish-unpublish-realm-test.ts index 961fb4ad988..77d3252911c 100644 --- a/packages/realm-server/tests/publish-unpublish-realm-test.ts +++ b/packages/realm-server/tests/publish-unpublish-realm-test.ts @@ -905,7 +905,7 @@ module(basename(__filename), function () { // file-watcher catches up (potentially many hours later in // production), the published URL keeps serving stale HTML. // - // The fix (handle-publish-realm calling Realm.clearLocalCaches() + // The fix (handle-publish-realm calling Realm.clearLocalSourceCaches() // before enqueueing the reindex) is verified end-to-end by the // matrix Playwright test, but the data-layer invariant is faster // to assert here: after republish, the boxel_index row for the diff --git a/packages/realm-server/tests/realm-file-changes-listener-test.ts b/packages/realm-server/tests/realm-file-changes-listener-test.ts index 19b66f189f0..12fdae71cfb 100644 --- a/packages/realm-server/tests/realm-file-changes-listener-test.ts +++ b/packages/realm-server/tests/realm-file-changes-listener-test.ts @@ -9,7 +9,7 @@ import { } from '../lib/realm-file-changes-listener'; // Minimal fake `Realm` — the listener calls `.url` (via lookup), -// `.invalidateCache(path)` for per-path payloads, and `.clearLocalCaches()` +// `.invalidateCache(path)` for per-path payloads, and `.clearLocalSourceCaches()` // for wildcard payloads (CS-11156). Stub both; tests pick whichever they // care about. function makeFakeRealm( @@ -24,7 +24,7 @@ function makeFakeRealm( invalidateCache(path: string) { hooks.onInvalidate?.(path); }, - clearLocalCaches() { + clearLocalSourceCaches() { hooks.onClearAll?.(); }, } as unknown as Realm; @@ -122,7 +122,7 @@ module(basename(__filename), function () { ]); }); - test('handleNotification with wildcard payload calls clearLocalCaches and not invalidateCache (CS-11156)', function (assert) { + test('handleNotification with wildcard payload calls clearLocalSourceCaches and not invalidateCache (CS-11156)', function (assert) { const invalidations: string[] = []; const clearAllCount = { value: 0 }; const realmA = makeFakeRealm('http://x.test/a/', { @@ -140,7 +140,7 @@ module(basename(__filename), function () { assert.strictEqual( clearAllCount.value, 1, - 'clearLocalCaches called exactly once', + 'clearLocalSourceCaches called exactly once', ); assert.deepEqual( invalidations, @@ -229,7 +229,7 @@ module(basename(__filename), function () { } }); - test('notifyAllFileChanges round-trip: emitter → NOTIFY → listener → clearLocalCaches (CS-11156)', async function (assert) { + test('notifyAllFileChanges round-trip: emitter → NOTIFY → listener → clearLocalSourceCaches (CS-11156)', async function (assert) { // Models the cross-replica case: the emitter is what the publish / // unpublish / delete realm handlers call after the FS swap; the // listener is a peer replica's subscription. End-to-end through the @@ -253,14 +253,14 @@ module(basename(__filename), function () { assert.strictEqual( clearAllCount.value, 1, - 'peer-side clearLocalCaches called once after the bulk emit', + 'peer-side clearLocalSourceCaches called once after the bulk emit', ); } finally { await listener.shutDown(); } }); - test('NOTIFY realm_file_changes wildcard → listener → clearLocalCaches (CS-11156)', async function (assert) { + test('NOTIFY realm_file_changes wildcard → listener → clearLocalSourceCaches (CS-11156)', async function (assert) { const invalidations: string[] = []; const clearAllCount = { value: 0 }; const realmUrl = 'http://x.test/listen-e2e-bulk/'; @@ -282,7 +282,7 @@ module(basename(__filename), function () { assert.strictEqual( clearAllCount.value, 1, - 'clearLocalCaches called exactly once', + 'clearLocalSourceCaches called exactly once', ); assert.deepEqual( invalidations, diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 994a15f0a7c..d675bebccb7 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -285,7 +285,7 @@ const SOURCE_ETAG_VARIANT = 'source'; const CARD_JSON_ETAG_VARIANT = 'card'; // Postgres NOTIFY channel for cross-instance invalidation of #sourceCache / -// #moduleCache entries on file writes. Two payload shapes: +// #transpiledModuleCache entries on file writes. Two payload shapes: // // `:` — invalidate a single path's cached source + // (for executable extensions) module entry. Emitted by every @@ -296,8 +296,8 @@ const CARD_JSON_ETAG_VARIANT = 'card'; // delete-realm handlers after the FS swap or removal, so peer // replicas (which do NOT receive the file-watcher events that // drive single-file invalidation in-process) drop pre-swap bytes -// from `#sourceCache` / `#moduleCache` before serving the next -// source read. Receiver calls Realm.clearLocalCaches(). See +// from `#sourceCache` / `#transpiledModuleCache` before serving the next +// source read. Receiver calls Realm.clearLocalSourceCaches(). See // CS-11156. (`*` is reserved as the wildcard sentinel; real // LocalPath values never contain it.) // @@ -315,7 +315,7 @@ export const REALM_FILE_CHANGES_WILDCARD = '*'; // in-process cache will be garbage-collected with the Realm instance). // When the caller wants the SAME local cache wipe AND the broadcast // — i.e. its own next read must not hit pre-swap bytes — call -// `Realm.clearLocalCachesAndBroadcast()` instead. Same best-effort +// `Realm.clearLocalSourceCachesAndBroadcast()` instead. Same best-effort // semantics as `Realm.#notifyFileChange`: failures are logged and // swallowed (missed NOTIFY is a bounded staleness window, not data // corruption). See CS-11156. @@ -366,7 +366,7 @@ type CachedSourceRedirectEntry = { type SourceCacheEntry = CachedSourceFileEntry | CachedSourceRedirectEntry; -type ModuleCacheEntry = { +type TranspiledModuleEntry = { canonicalPath: LocalPath; body: string; headers: Record; @@ -662,29 +662,29 @@ export class Realm { #definitionLookup: DefinitionLookup; #copiedFromRealm: URL | undefined; #sourceCache = new AliasCache(); - #moduleCache = new AliasCache(); - // CS-11028: per-path generation counters for #moduleCache. Bumped + #transpiledModuleCache = new AliasCache(); + // CS-11028: per-path generation counters for #transpiledModuleCache. Bumped // synchronously by invalidateCache(path) before any await. fallbackHandle // snapshots at entry and discards its post-transpile cache write if the // path's generation moved during the in-flight transpile — otherwise the // pre-invalidation bytes would re-populate the slot that invalidate just // cleared and serve stale code until the next invalidate of that path. - // #moduleCacheGlobalGeneration covers __testOnlyClearCaches, which wipes + // #transpiledModuleCacheGlobalGeneration covers __testOnlyClearCaches, which wipes // the whole map; a snapshot taken before the wipe sees its `path` // component reset to 0 alongside the live counter, so a global generation // is the only thing that reliably mismatches afterwards. - #moduleCacheGenerations: Map = new Map(); - #moduleCacheGlobalGeneration = 0; + #transpiledModuleCacheGenerations: Map = new Map(); + #transpiledModuleCacheGlobalGeneration = 0; // CS-11029: in-process inflight dedup for the transpile pipeline. - // Concurrent same-path callers that miss #moduleCache used to each call + // Concurrent same-path callers that miss #transpiledModuleCache used to each call // transpileJS independently — 50–500 ms of babel + ember-template- // compilation + decorator transforms wasted per duplicate. The map is // keyed by local path so the second-and-onward caller awaits the first // caller's promise instead of running babel again. Invalidation paths // (writeMany, invalidateCache, the full-index clear, etc.) drop the - // entry through the shared #dropModuleCacheEntry / - // #dropAllModuleCacheEntries helpers so post-invalidate callers don't - // join a stale transpile whose #moduleCache.set will be discarded by + // entry through the shared #dropTranspiledModuleEntry / + // #dropAllTranspiledModuleCacheEntries helpers so post-invalidate callers don't + // join a stale transpile whose #transpiledModuleCache.set will be discarded by // CS-11028's generation guard anyway. Identity-checked cleanup on // settle is the same shape as CachingDefinitionLookup's #inFlight — a // newer pending entry installed after a drop survives an older @@ -1071,7 +1071,7 @@ export class Realm { let completed = indexingCompleted.then(async ({ invalidations }) => { await this.#definitionLookup.clearRealmCache(this.url); - this.#dropAllModuleCacheEntries(); + this.#dropAllTranspiledModuleCacheEntries(); if (invalidations.length > 0) { this.broadcastIncrementalInvalidationEvent(invalidations); } @@ -1377,7 +1377,7 @@ export class Realm { __testOnlyClearCaches() { this.#sourceCache.clear(); - this.#dropAllModuleCacheEntries(); + this.#dropAllTranspiledModuleCacheEntries(); // Reset the transpile counter so each test reasons about its own // delta. Production never reads this counter — only the CS-11029 // dedup tests do (CS-11029). @@ -1389,7 +1389,7 @@ export class Realm { // reindex enqueues — so that subsequent source reads (which the // reindex's prerender fans out across many of) bypass any // pre-swap bytes the realm still has in `#sourceCache` / - // `#moduleCache`. The Phase-3-PR-2 publish flow relies on the + // `#transpiledModuleCache`. The Phase-3-PR-2 publish flow relies on the // NodeAdapter file-watcher to pick up the swap, but that's an // async-event race against the immediately-enqueued reindex; this // method makes the invalidation synchronous from the publish @@ -1399,9 +1399,9 @@ export class Realm { // CS-11156 will replace the publish handler's local call here with // a cross-replica NOTIFY broadcast; this method stays as the // bulk-invalidate primitive the receiver invokes. - clearLocalCaches(): void { + clearLocalSourceCaches(): void { this.#sourceCache.clear(); - this.#dropAllModuleCacheEntries(); + this.#dropAllTranspiledModuleCacheEntries(); } // CS-11029 test seams: tests need to assert "N concurrent same-path @@ -1435,26 +1435,26 @@ export class Realm { invalidateCache(path: LocalPath): void { this.#sourceCache.invalidate(path); if (hasExecutableExtension(path)) { - this.#dropModuleCacheEntry(path); + this.#dropTranspiledModuleEntry(path); } } // CS-11028: shared drop helper for any in-process site that invalidates a - // single #moduleCache entry — writeMany, delete/deleteAll, the local + // single #transpiledModuleCache entry — writeMany, delete/deleteAll, the local // file-watcher callback, the index-updater's executable-invalidation // cascade, the public invalidateCache entry point, etc. Bumps the // per-path generation BEFORE the cache delete so a concurrent in-flight // transpile for the same path — already past its generation snapshot in // fallbackHandle — observes the new value at persist time and drops its - // #moduleCache.set instead of re-filling the slot we're about to empty. - #dropModuleCacheEntry(path: LocalPath): void { - this.#bumpModuleCacheGeneration(path); - this.#moduleCache.invalidate(path); + // #transpiledModuleCache.set instead of re-filling the slot we're about to empty. + #dropTranspiledModuleEntry(path: LocalPath): void { + this.#bumpTranspiledModuleCacheGeneration(path); + this.#transpiledModuleCache.invalidate(path); // CS-11029: drop the in-flight transpile entry too. Existing // waiters on the old promise still receive its result — their // requests preceded the invalidate, so pre-invalidation bytes are // the correct response. But a caller arriving AFTER this point - // must not join the stale transpile (its #moduleCache.set is + // must not join the stale transpile (its #transpiledModuleCache.set is // about to be discarded by the generation guard); they install // their own pending against current source instead. this.#inFlightTranspiles.delete(path); @@ -1470,27 +1470,27 @@ export class Realm { void this.#deleteTranspileCacheRow(canonicalPath); } - // Wipes every #moduleCache entry and bumps the global generation so any + // Wipes every #transpiledModuleCache entry and bumps the global generation so any // in-flight transpile whose snapshot was taken before this wipe discards // its post-transpile cache write rather than re-populating the // just-cleared map (CS-11028). The per-path map is cleared because the // generations it held are no longer reachable — the global counter is // what catches in-flight snapshots after a wipe. - #dropAllModuleCacheEntries(): void { - this.#moduleCache.clear(); - this.#moduleCacheGenerations.clear(); - this.#moduleCacheGlobalGeneration += 1; - // CS-11029: same reason as #dropModuleCacheEntry — post-wipe + #dropAllTranspiledModuleCacheEntries(): void { + this.#transpiledModuleCache.clear(); + this.#transpiledModuleCacheGenerations.clear(); + this.#transpiledModuleCacheGlobalGeneration += 1; + // CS-11029: same reason as #dropTranspiledModuleEntry — post-wipe // callers must not join a stale transpile. this.#inFlightTranspiles.clear(); // CS-11030: fire-and-forget bulk DELETE for the realm's L2 rows. void this.#deleteAllTranspileCacheRows(); } - #bumpModuleCacheGeneration(path: LocalPath): void { - this.#moduleCacheGenerations.set( + #bumpTranspiledModuleCacheGeneration(path: LocalPath): void { + this.#transpiledModuleCacheGenerations.set( path, - (this.#moduleCacheGenerations.get(path) ?? 0) + 1, + (this.#transpiledModuleCacheGenerations.get(path) ?? 0) + 1, ); } @@ -1511,33 +1511,33 @@ export class Realm { global: number; } { let pathGens = new Map(); - pathGens.set(localPath, this.#moduleCacheGenerations.get(localPath) ?? 0); + pathGens.set(localPath, this.#transpiledModuleCacheGenerations.get(localPath) ?? 0); if (!hasExecutableExtension(localPath)) { for (let ext of executableExtensions) { let candidate = localPath + ext; pathGens.set( candidate, - this.#moduleCacheGenerations.get(candidate) ?? 0, + this.#transpiledModuleCacheGenerations.get(candidate) ?? 0, ); } } - return { pathGens, global: this.#moduleCacheGlobalGeneration }; + return { pathGens, global: this.#transpiledModuleCacheGlobalGeneration }; } - #moduleCacheGenerationChanged( + #transpiledModuleCacheGenerationChanged( canonicalPath: LocalPath, snapshot: { pathGens: Map; global: number }, ): boolean { - if (this.#moduleCacheGlobalGeneration !== snapshot.global) { + if (this.#transpiledModuleCacheGlobalGeneration !== snapshot.global) { return true; } let snapGen = snapshot.pathGens.get(canonicalPath) ?? 0; - let curGen = this.#moduleCacheGenerations.get(canonicalPath) ?? 0; + let curGen = this.#transpiledModuleCacheGenerations.get(canonicalPath) ?? 0; return curGen !== snapGen; } // Broadcast a file-change notification to peer realm-server instances so - // they can invalidate their own #sourceCache / #moduleCache entries for the + // they can invalidate their own #sourceCache / #transpiledModuleCache entries for the // same path. Best-effort — failures are logged and swallowed because the // local write already succeeded and a missed NOTIFY is a bounded cache- // staleness window (see docs §9 "Cache-invalidation NOTIFY missed"), not @@ -1556,12 +1556,12 @@ export class Realm { } } - // Drop this replica's own `#sourceCache` / `#moduleCache` AND broadcast + // Drop this replica's own `#sourceCache` / `#transpiledModuleCache` AND broadcast // the same wipe to peer replicas. Used by the publish-realm handler // before the reindex enqueue: this replica's own prerender fan-out must // bypass its cache (sync local clear), and peer replicas must drop // their pre-swap bytes too (cross-instance NOTIFY). Self-receive of the - // NOTIFY is a no-op since `clearLocalCaches()` is idempotent. + // NOTIFY is a no-op since `clearLocalSourceCaches()` is idempotent. // // Bundles local + broadcast in one call, mirroring // `CachingDefinitionLookup.clearRealmCache(url)` — handlers don't have @@ -1569,8 +1569,8 @@ export class Realm { // (because their own Realm instance is about to be unmounted anyway — // unpublish/delete handlers) use the standalone `notifyAllFileChanges` // free function above instead. - async clearLocalCachesAndBroadcast(): Promise { - this.clearLocalCaches(); + async clearLocalSourceCachesAndBroadcast(): Promise { + this.clearLocalSourceCaches(); await notifyAllFileChanges(this.#dbAdapter, this.url); } @@ -2537,7 +2537,7 @@ export class Realm { Boolean(request.headers.get('X-Boxel-Disable-Module-Cache')); if (!moduleCachingDisabled) { - let cached = this.#moduleCache.get(localPath); + let cached = this.#transpiledModuleCache.get(localPath); if (cached) { try { let etag = cached.headers.etag; @@ -2610,12 +2610,12 @@ export class Realm { if ( !moduleCachingDisabled && cacheGenSnapshot && - !this.#moduleCacheGenerationChanged( + !this.#transpiledModuleCacheGenerationChanged( result.canonicalPath, cacheGenSnapshot, ) ) { - this.#moduleCache.set(localPath, { + this.#transpiledModuleCache.set(localPath, { canonicalPath: result.canonicalPath, body: result.body, headers: result.headers, @@ -3645,7 +3645,7 @@ export class Realm { for (const invalidatedURL of invalidatedURLs) { if (hasExecutableExtension(invalidatedURL.href)) { let invalidatedPath = this.paths.local(invalidatedURL); - this.#dropModuleCacheEntry(invalidatedPath); + this.#dropTranspiledModuleEntry(invalidatedPath); changedDependencyKeys.add(moduleDependencyKey(invalidatedPath)); definitionInvalidations.push( this.#definitionLookup.invalidate(invalidatedURL.href), @@ -3658,7 +3658,7 @@ export class Realm { for (let invalidatedModuleURL of invalidatedModuleURLs) { try { let invalidatedPath = this.paths.local(new URL(invalidatedModuleURL)); - this.#dropModuleCacheEntry(invalidatedPath); + this.#dropTranspiledModuleEntry(invalidatedPath); changedDependencyKeys.add(moduleDependencyKey(invalidatedPath)); } catch (_err) { // ignore invalidations outside this realm @@ -3667,15 +3667,15 @@ export class Realm { } let dependentInvalidations = collectDependentModuleCacheInvalidations( changedDependencyKeys, - this.moduleCacheDependencyEntries(), + this.transpiledModuleDependencyEntries(), ); for (let invalidatedPath of dependentInvalidations) { - this.#dropModuleCacheEntry(invalidatedPath); + this.#dropTranspiledModuleEntry(invalidatedPath); } } - private *moduleCacheDependencyEntries() { - for (let [, cachedEntry] of this.#moduleCache.entries()) { + private *transpiledModuleDependencyEntries() { + for (let [, cachedEntry] of this.#transpiledModuleCache.entries()) { yield { canonicalPath: cachedEntry.canonicalPath, dependencyKeys: cachedEntry.dependencyKeys, From bbdeef8f02c3211d9f54524c326277d8982384b7 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 14 May 2026 23:35:28 -0400 Subject: [PATCH 5/5] Tier 2 rename: DefinitionLookup speaks "definitions," not "modules" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CachingDefinitionLookup` caches assembled card *definitions* (per-export results, error entries, dependency lists). It's not a module-byte cache. But every public type and method on it was named "module cache" or "modules" — which collided directly with `Realm.#transpiledModuleCache` (renamed last commit), the actual JS-bytes cache. Public API now reads as what it does: - `ModuleCacheEntry` → `DefinitionCacheEntry` - `ModuleCacheEntries` → `DefinitionCacheEntries` - `ModuleCacheEntryQuery` → `DefinitionCacheEntryQuery` - `getModuleCacheEntry` → `getCachedDefinitions` - `getModuleCacheEntries` → `getCachedDefinitionsBatch` - `clearAllModules` → `clearAllDefinitions` - `clearRealmCache` → `clearRealmDefinitions` Plus internal-consistency renames on the notify-emitter helpers (`notifyModuleCacheInvalidations` → `notifyDefinitionCacheInvalidations`, etc.). What deliberately did NOT move (Tier 3, deferred — needs a deploy plan for rolling-update compatibility between replicas listening on the old vs. new channel name): - `modules` DB table name and the `MODULES_TABLE` JS constant - `module_cache_invalidated` NOTIFY channel name and the `MODULE_CACHE_INVALIDATED_CHANNEL` constant - File names containing "module-cache-*" All 16 realm-file-changes-listener tests, 21 module-cache-invalidation- listener tests, and 9 module-cache-coordination tests pass after the rename. `tsc` clean across runtime-common / realm-server / host. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/unit/index-query-engine-test.ts | 8 +- .../handlers/handle-full-reindex.ts | 2 +- .../handlers/handle-post-deployment.ts | 2 +- .../handlers/handle-publish-realm.ts | 4 +- .../realm-server/handlers/handle-reindex.ts | 2 +- .../lib/module-cache-coordination.ts | 2 +- .../lib/module-cache-invalidation-listener.ts | 2 +- packages/realm-server/main.ts | 2 +- .../tests/definition-lookup-test.ts | 20 +-- packages/realm-server/tests/indexing-test.ts | 12 +- .../tests/matches-filter-integration-test.ts | 8 +- .../tests/module-cache-coordination-test.ts | 10 +- ...module-cache-invalidation-listener-test.ts | 8 +- packages/runtime-common/definition-lookup.ts | 136 +++++++++--------- packages/runtime-common/index-runner.ts | 10 +- .../index-runner/dependency-resolver.ts | 8 +- .../index-backed-dependency-errors.ts | 12 +- packages/runtime-common/realm.ts | 14 +- 18 files changed, 131 insertions(+), 131 deletions(-) diff --git a/packages/host/tests/unit/index-query-engine-test.ts b/packages/host/tests/unit/index-query-engine-test.ts index 76cfd4efe1d..60f5300ce23 100644 --- a/packages/host/tests/unit/index-query-engine-test.ts +++ b/packages/host/tests/unit/index-query-engine-test.ts @@ -240,17 +240,17 @@ module('Unit | query', function (hooks) { // no-op for tests return []; }, - async clearRealmCache(_realmURL: string): Promise { + async clearRealmDefinitions(_realmURL: string): Promise { // no-op for tests }, - async getModuleCacheEntry(): Promise { + async getCachedDefinitions(): Promise { return undefined; }, - async getModuleCacheEntries(): Promise> { + async getCachedDefinitionsBatch(): Promise> { return {}; }, registerRealm() {}, - async clearAllModules(): Promise { + async clearAllDefinitions(): Promise { // no-op for tests }, forRealm() { diff --git a/packages/realm-server/handlers/handle-full-reindex.ts b/packages/realm-server/handlers/handle-full-reindex.ts index bc7bf6fda6d..381cfc35f26 100644 --- a/packages/realm-server/handlers/handle-full-reindex.ts +++ b/packages/realm-server/handlers/handle-full-reindex.ts @@ -15,7 +15,7 @@ export default function handleFullReindex({ return async function (ctxt: Koa.Context, _next: Koa.Next) { let realmUrls = await getFullReindexRealmUrls(dbAdapter); - await definitionLookup.clearAllModules(); + await definitionLookup.clearAllDefinitions(); await queue.publish({ jobType: `full-reindex`, diff --git a/packages/realm-server/handlers/handle-post-deployment.ts b/packages/realm-server/handlers/handle-post-deployment.ts index cc524edf548..f045419850f 100644 --- a/packages/realm-server/handlers/handle-post-deployment.ts +++ b/packages/realm-server/handlers/handle-post-deployment.ts @@ -27,7 +27,7 @@ export default function handlePostDeployment({ return; } - await definitionLookup.clearAllModules(); + await definitionLookup.clearAllDefinitions(); let boxelUiChangeCheckerResult = await compareCurrentBoxelUIChecksum(assetsURL); diff --git a/packages/realm-server/handlers/handle-publish-realm.ts b/packages/realm-server/handlers/handle-publish-realm.ts index 03547fc2c3a..7ee77d470d5 100644 --- a/packages/realm-server/handlers/handle-publish-realm.ts +++ b/packages/realm-server/handlers/handle-publish-realm.ts @@ -531,7 +531,7 @@ export default function handlePublishRealm({ // error entries from a previous publish) before the reindex's // prerender fan-out, so its HTTP module fetches don't hit // cached pre-swap state on this replica or its peers. - // `clearRealmCache` bundles the DB DELETE + in-flight prerender + // `clearRealmDefinitions` bundles the DB DELETE + in-flight prerender // drop + per-realm generation bump + cross-instance NOTIFY on // `module_cache_invalidated` — the modules-cache analog of // `clearLocalSourceCachesAndBroadcast()` below. Without those extra @@ -540,7 +540,7 @@ export default function handlePublishRealm({ // re-insert a stale row at persist time, and peer replicas // would keep their cached rows + generation counters until // their own next invalidation arrived. - await definitionLookup.clearRealmCache(publishedRealmURL); + await definitionLookup.clearRealmDefinitions(publishedRealmURL); let lastPublishedAt = Date.now().toString(); try { diff --git a/packages/realm-server/handlers/handle-reindex.ts b/packages/realm-server/handlers/handle-reindex.ts index 30e8eb68411..f6496ea4b6c 100644 --- a/packages/realm-server/handlers/handle-reindex.ts +++ b/packages/realm-server/handlers/handle-reindex.ts @@ -80,7 +80,7 @@ export async function reindex({ definitionLookup: DefinitionLookup; priority?: number; }) { - await definitionLookup.clearRealmCache(realm.url); + await definitionLookup.clearRealmDefinitions(realm.url); return await enqueueReindexRealmJob( realm.url, await realm.getRealmOwnerUsername(), diff --git a/packages/realm-server/lib/module-cache-coordination.ts b/packages/realm-server/lib/module-cache-coordination.ts index 88493c6089f..21fa8630347 100644 --- a/packages/realm-server/lib/module-cache-coordination.ts +++ b/packages/realm-server/lib/module-cache-coordination.ts @@ -46,7 +46,7 @@ export function hashCoalesceKeyForAdvisoryLock(key: string): string { // // The pinned connection ONLY holds the advisory lock and emits the // NOTIFY. The `fn` callback issues its DB work (readFromDatabaseCache, -// persistModuleCacheEntry) through the shared dbAdapter — a separate +// persistDefinitionCacheEntry) through the shared dbAdapter — a separate // pool connection autocommits each query as today. Pool pressure is // bounded by N processes (each pins one extra client per concurrent // coordinated load) rather than N × M concurrent callers, since the diff --git a/packages/realm-server/lib/module-cache-invalidation-listener.ts b/packages/realm-server/lib/module-cache-invalidation-listener.ts index a13c012f99c..d6720050388 100644 --- a/packages/realm-server/lib/module-cache-invalidation-listener.ts +++ b/packages/realm-server/lib/module-cache-invalidation-listener.ts @@ -9,7 +9,7 @@ const log = logger('realm-server:module-cache-invalidation-listener'); // Cross-instance module-cache invalidation broadcast (CS-10952). Peer // realm-server processes emit `NOTIFY module_cache_invalidated, ''` -// from CachingDefinitionLookup.invalidate / clearRealmCache / clearAllModules +// from CachingDefinitionLookup.invalidate / clearRealmDefinitions / clearAllDefinitions // after their DELETE commits; this listener parses the payload and replays // the appropriate generation bump on the locally-attached // CachingDefinitionLookup so its in-flight prerenders observe the diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index b7855d84a9e..6a3509d3ef7 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -339,7 +339,7 @@ const getIndexHTML = async () => { log.info('Skipping modules cache clear on startup (opted out via env)'); } else { log.info('Clearing modules cache...'); - await definitionLookup.clearAllModules(); + await definitionLookup.clearAllDefinitions(); } // Backfill realm_registry from CLI args (bootstrap), on-disk source realms, diff --git a/packages/realm-server/tests/definition-lookup-test.ts b/packages/realm-server/tests/definition-lookup-test.ts index e1fb88eee57..d4f792ff2bd 100644 --- a/packages/realm-server/tests/definition-lookup-test.ts +++ b/packages/realm-server/tests/definition-lookup-test.ts @@ -1706,7 +1706,7 @@ module(basename(__filename), function () { ); }); - test('in-flight prerender result is dropped when clearRealmCache runs concurrently', async function (assert) { + test('in-flight prerender result is dropped when clearRealmDefinitions runs concurrently', async function (assert) { await dbAdapter.execute('DELETE FROM modules'); let moduleURL = `${realmURL}stale-persist-clear-realm.gts`; @@ -1752,14 +1752,14 @@ module(basename(__filename), function () { }); await new Promise((resolve) => setTimeout(resolve, 0)); - await lookup.clearRealmCache(realmURL); + await lookup.clearRealmDefinitions(realmURL); releaseGate(); let result = await Promise.allSettled([pA]); assert.strictEqual( result[0].status, 'rejected', - 'A rejects after clearRealmCache leaves an empty cache', + 'A rejects after clearRealmDefinitions leaves an empty cache', ); assert.strictEqual(calls, 1); @@ -1770,14 +1770,14 @@ module(basename(__filename), function () { assert.strictEqual( rows.length, 0, - 'clearRealmCache is honored — A did not re-insert the row', + 'clearRealmDefinitions is honored — A did not re-insert the row', ); }); - test('in-flight prerender result is dropped when clearAllModules runs concurrently', async function (assert) { + test('in-flight prerender result is dropped when clearAllDefinitions runs concurrently', async function (assert) { await dbAdapter.execute('DELETE FROM modules'); - // clearAllModules drains state for every realm — including realms + // clearAllDefinitions drains state for every realm — including realms // that have never been individually invalidated. Use a fresh module // URL so the realm has no #generations entry going in; this guards // against the per-realm map missing the realm at clear time. @@ -1824,14 +1824,14 @@ module(basename(__filename), function () { }); await new Promise((resolve) => setTimeout(resolve, 0)); - await lookup.clearAllModules(); + await lookup.clearAllDefinitions(); releaseGate(); let result = await Promise.allSettled([pA]); assert.strictEqual( result[0].status, 'rejected', - 'A rejects after clearAllModules leaves an empty cache', + 'A rejects after clearAllDefinitions leaves an empty cache', ); assert.strictEqual(calls, 1); @@ -1842,7 +1842,7 @@ module(basename(__filename), function () { assert.strictEqual( rows.length, 0, - 'clearAllModules is honored — A did not re-insert the row', + 'clearAllDefinitions is honored — A did not re-insert the row', ); }); @@ -1925,7 +1925,7 @@ module(basename(__filename), function () { test('a settled in-flight promise does not delete a newer in-flight under the same key', async function (assert) { // Identity-check regression guard. Without the identity check in - // loadModuleCacheEntry's .finally, A's settle would delete B's + // loadDefinitionCacheEntry's .finally, A's settle would delete B's // freshly-installed entry and cause D to race a third prerender. await dbAdapter.execute('DELETE FROM modules'); diff --git a/packages/realm-server/tests/indexing-test.ts b/packages/realm-server/tests/indexing-test.ts index 48cf484ffe5..ddc89e8b8b7 100644 --- a/packages/realm-server/tests/indexing-test.ts +++ b/packages/realm-server/tests/indexing-test.ts @@ -2007,7 +2007,7 @@ module(basename(__filename), function () { ); if (definitionLookup) { - let moduleEntries = await definitionLookup.getModuleCacheEntries({ + let moduleEntries = await definitionLookup.getCachedDefinitionsBatch({ moduleUrls: [fileDefAlias], cacheScope: 'public', authUserId: '', @@ -2138,7 +2138,7 @@ module(basename(__filename), function () { if (!definitionLookup) { assert.ok(false, 'definition lookup is available'); } else { - let deepModuleEntry = await definitionLookup.getModuleCacheEntry( + let deepModuleEntry = await definitionLookup.getCachedDefinitions( `${testRealm}deep-card`, ); assert.strictEqual( @@ -2221,7 +2221,7 @@ module(basename(__filename), function () { // definition lookup errors are expected while dependencies are missing } - deepModuleEntry = await definitionLookup.getModuleCacheEntry( + deepModuleEntry = await definitionLookup.getCachedDefinitions( `${testRealm}deep-card`, ); if (deepModuleEntry?.error?.error) { @@ -2240,7 +2240,7 @@ module(basename(__filename), function () { assert.ok(false, 'expected deep-card module error details'); } - let middleModuleEntry = await definitionLookup.getModuleCacheEntry( + let middleModuleEntry = await definitionLookup.getCachedDefinitions( `${testRealm}middle-field`, ); assert.strictEqual( @@ -2484,7 +2484,7 @@ module(basename(__filename), function () { let definitionLookup = (testRealmServer?.testRealmServer as any) ?.definitionLookup as DefinitionLookup | undefined; if (definitionLookup) { - let moduleBEntry = await definitionLookup.getModuleCacheEntry( + let moduleBEntry = await definitionLookup.getCachedDefinitions( `${testRealm}module-b`, ); assert.strictEqual( @@ -2503,7 +2503,7 @@ module(basename(__filename), function () { assert.ok(false, 'expected module-b error details'); } - let moduleAEntry = await definitionLookup.getModuleCacheEntry( + let moduleAEntry = await definitionLookup.getCachedDefinitions( `${testRealm}module-a`, ); assert.strictEqual( diff --git a/packages/realm-server/tests/matches-filter-integration-test.ts b/packages/realm-server/tests/matches-filter-integration-test.ts index 346c9b0f50d..bdc470ee9e3 100644 --- a/packages/realm-server/tests/matches-filter-integration-test.ts +++ b/packages/realm-server/tests/matches-filter-integration-test.ts @@ -25,13 +25,13 @@ const stubDefinitionLookup: DefinitionLookup = { async invalidate() { return []; }, - async clearRealmCache() {}, - async clearAllModules() {}, + async clearRealmDefinitions() {}, + async clearAllDefinitions() {}, registerRealm() {}, - async getModuleCacheEntry() { + async getCachedDefinitions() { return undefined; }, - async getModuleCacheEntries() { + async getCachedDefinitionsBatch() { return {}; }, forRealm() { diff --git a/packages/realm-server/tests/module-cache-coordination-test.ts b/packages/realm-server/tests/module-cache-coordination-test.ts index 4655f15910d..87ac4c42fa2 100644 --- a/packages/realm-server/tests/module-cache-coordination-test.ts +++ b/packages/realm-server/tests/module-cache-coordination-test.ts @@ -371,9 +371,9 @@ module(basename(__filename), function () { // A starts first. A's prerender will gate. While A holds the // lock + is awaiting the gate, B issues its lookup; B contends // the lock, observes acquired:false, parks on NOTIFY. - const pA = lookupA.getModuleCacheEntry(moduleURL); + const pA = lookupA.getCachedDefinitions(moduleURL); await new Promise((r) => setTimeout(r, 100)); - const pB = lookupB.getModuleCacheEntry(moduleURL); + const pB = lookupB.getCachedDefinitions(moduleURL); await new Promise((r) => setTimeout(r, 100)); // At this point: A is awaiting the gate (one prerender call @@ -439,7 +439,7 @@ module(basename(__filename), function () { undefined, realmURL, ); - const p = lookup.getModuleCacheEntry(moduleURL); + const p = lookup.getCachedDefinitions(moduleURL); await new Promise((r) => setTimeout(r, 50)); aPrerender.release(); const entry = await p; @@ -467,7 +467,7 @@ module(basename(__filename), function () { coordinatorA, realmURL, ); - const pA = lookupA.getModuleCacheEntry(moduleURL); + const pA = lookupA.getCachedDefinitions(moduleURL); await new Promise((r) => setTimeout(r, 50)); aPrerender.release(); await pA; @@ -485,7 +485,7 @@ module(basename(__filename), function () { coordinatorB, realmURL, ); - const entryB = await lookupB.getModuleCacheEntry(moduleURL); + const entryB = await lookupB.getCachedDefinitions(moduleURL); assert.ok(entryB, 'B returned the cached entry'); assert.strictEqual( bPrerender.callsFor(moduleURL), diff --git a/packages/realm-server/tests/module-cache-invalidation-listener-test.ts b/packages/realm-server/tests/module-cache-invalidation-listener-test.ts index 5072c24cd61..5d7b986a480 100644 --- a/packages/realm-server/tests/module-cache-invalidation-listener-test.ts +++ b/packages/realm-server/tests/module-cache-invalidation-listener-test.ts @@ -421,7 +421,7 @@ module(basename(__filename), function () { } }); - test('A.clearRealmCache(url) → B listener bumps B.bumpRealmGeneration', async function (assert) { + test('A.clearRealmDefinitions(url) → B listener bumps B.bumpRealmGeneration', async function (assert) { const realmURL = 'http://x.test/peer-clear-realm/'; const instanceB = new CachingDefinitionLookup( dbAdapter, @@ -444,7 +444,7 @@ module(basename(__filename), function () { stubVirtualNetwork, stubCreatePrerenderAuth, ); - await instanceA.clearRealmCache(realmURL); + await instanceA.clearRealmDefinitions(realmURL); const seen = await waitFor(() => recorderB.realm.length > 0 ? recorderB.realm : undefined, @@ -455,7 +455,7 @@ module(basename(__filename), function () { } }); - test('A.clearAllModules() → B listener bumps B.bumpGlobalGeneration', async function (assert) { + test('A.clearAllDefinitions() → B listener bumps B.bumpGlobalGeneration', async function (assert) { const instanceB = new CachingDefinitionLookup( dbAdapter, stubPrerenderer, @@ -477,7 +477,7 @@ module(basename(__filename), function () { stubVirtualNetwork, stubCreatePrerenderAuth, ); - await instanceA.clearAllModules(); + await instanceA.clearAllDefinitions(); await waitFor(() => (recorderB.global > 0 ? true : undefined)); assert.strictEqual( diff --git a/packages/runtime-common/definition-lookup.ts b/packages/runtime-common/definition-lookup.ts index 394e97214c6..07ae2147267 100644 --- a/packages/runtime-common/definition-lookup.ts +++ b/packages/runtime-common/definition-lookup.ts @@ -43,8 +43,8 @@ const PREFERRED_EXECUTABLE_EXTENSIONS = ['.gts', '.ts', '.gjs', '.js']; // peer realm-server processes can bump their in-memory generation counters // in lockstep with the DB. Payload is JSON; one of: // {"k":"module","r":,"m":[,...]} — invalidate fan-out -// {"k":"realm","r":} — clearRealmCache -// {"k":"global"} — clearAllModules +// {"k":"realm","r":} — clearRealmDefinitions +// {"k":"global"} — clearAllDefinitions // Self-notify is idempotent: the emitting process already bumped its // counter synchronously before the DB delete, and a second bump on // listener receive is observationally equivalent (counters are monotonic @@ -56,7 +56,7 @@ const NOTIFY_PAYLOAD_BUDGET = 7000; // Postgres NOTIFY channel for cross-instance prerender-coalesce wakeups // (CS-10953). The winner of a `pg_try_advisory_xact_lock` for a given // inFlightKey emits this notify (with the inFlightKey as payload) inside -// the same transaction as its persistModuleCacheEntry, so peer waiters +// the same transaction as its persistDefinitionCacheEntry, so peer waiters // see the signal only on commit (the cache row is visible to their // re-read by the time their wait resolves). Loser path on // missed-NOTIFY falls back to a bounded-timeout re-read. @@ -156,7 +156,7 @@ function parseJsonValue(value: T | string | null): T | null { export type CacheScope = 'public' | 'realm-auth'; type LocalRealm = Pick; -export interface ModuleCacheEntry { +export interface DefinitionCacheEntry { definitions: Record; deps: string[]; error?: ErrorEntry; @@ -166,14 +166,14 @@ export interface ModuleCacheEntry { createdAt?: number; } -export interface ModuleCacheEntryQuery { +export interface DefinitionCacheEntryQuery { moduleUrls: string[]; cacheScope: CacheScope; authUserId: string; resolvedRealmURL: string; } -export type ModuleCacheEntries = Record; +export type DefinitionCacheEntries = Record; interface WriteToDatabaseCacheParams { moduleUrl: string; @@ -268,14 +268,14 @@ export interface DefinitionLookup { codeRef: ResolvedCodeRef, ): Promise; invalidate(moduleURL: string): Promise; - clearRealmCache(resolvedRealmURL: string): Promise; - clearAllModules(): Promise; + clearRealmDefinitions(resolvedRealmURL: string): Promise; + clearAllDefinitions(): Promise; registerRealm(realm: LocalRealm): void; forRealm(realm: LocalRealm): DefinitionLookup; - getModuleCacheEntry(moduleUrl: string): Promise; - getModuleCacheEntries( - query: ModuleCacheEntryQuery, - ): Promise; + getCachedDefinitions(moduleUrl: string): Promise; + getCachedDefinitionsBatch( + query: DefinitionCacheEntryQuery, + ): Promise; } interface LookupContext { @@ -291,13 +291,13 @@ export class CachingDefinitionLookup implements DefinitionLookup { userId: string, permissions: RealmPermissions, ) => string; - // Dedupes concurrent loadModuleCacheEntry calls that would hit the same + // Dedupes concurrent loadDefinitionCacheEntry calls that would hit the same // cache row so a single prerenderer round-trip is shared by all waiters // instead of each caller racing to the prerenderer independently. - #inFlight = new Map>(); + #inFlight = new Map>(); // Invalidation generations. Bumped synchronously by invalidate / - // clearRealmCache / clearAllModules before the DB delete. - // loadModuleCacheEntryUncached snapshots all three values at entry and + // clearRealmDefinitions / clearAllDefinitions before the DB delete. + // loadDefinitionCacheEntryUncached snapshots all three values at entry and // re-checks them just before persist; if any changed, the in-flight // prerender's result is discarded rather than re-inserted, so the cache // wipe isn't undone by a prerender that started against pre-invalidation @@ -307,15 +307,15 @@ export class CachingDefinitionLookup implements DefinitionLookup { // specific URLs avoids spuriously discarding an in-flight prerender // for an unrelated module in the same realm. // - #realmGenerations: keyed by `resolvedRealmURL`; bumped by - // clearRealmCache() so every in-flight prerender for that realm is + // clearRealmDefinitions() so every in-flight prerender for that realm is // invalidated, including modules not yet in #moduleGenerations. - // - #globalGeneration: bumped by clearAllModules() so every in-flight + // - #globalGeneration: bumped by clearAllDefinitions() so every in-flight // prerender is invalidated regardless of realm or module URL. #moduleGenerations = new Map(); #realmGenerations = new Map(); #globalGeneration = 0; // CS-10953 cross-process prerender coalescer. Optional — when undefined, - // loadModuleCacheEntryUncached runs the original uncoordinated path. + // loadDefinitionCacheEntryUncached runs the original uncoordinated path. // Constructed only by the realm-server main when // PRERENDER_COALESCE_ACROSS_PROCESSES is enabled and the dbAdapter is pg. #populateCoordinator?: PopulateCoordinator; @@ -381,9 +381,9 @@ export class CachingDefinitionLookup implements DefinitionLookup { return undefined; } - async getModuleCacheEntry( + async getCachedDefinitions( moduleUrl: string, - ): Promise { + ): Promise { let canonicalModuleURL = canonicalURL(moduleUrl); let context = await this.buildLookupContext(canonicalModuleURL); if (!context) { @@ -396,7 +396,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { cacheScope, resolvedRealmURL, } = context; - return await this.loadModuleCacheEntry({ + return await this.loadDefinitionCacheEntry({ moduleURL: canonicalModuleURL, realmURL, resolvedRealmURL, @@ -410,20 +410,20 @@ export class CachingDefinitionLookup implements DefinitionLookup { return await query(this.#dbAdapter, expression, coerceTypes); } - private async loadModuleCacheEntry(args: { + private async loadDefinitionCacheEntry(args: { moduleURL: string; realmURL: string; resolvedRealmURL: string; cacheScope: CacheScope; cacheUserId: string; prerenderUserId: string; - }): Promise { + }): Promise { let key = inFlightKey(args); let existing = this.#inFlight.get(key); if (existing) { return await existing; } - let pending: Promise; + let pending: Promise; // Two paths inside #inFlight: // - With a populate coordinator (CS-10953): coordinated path adds // a pg_try_advisory_xact_lock around the prerender so at most @@ -434,9 +434,9 @@ export class CachingDefinitionLookup implements DefinitionLookup { // runs the prerender and persist directly. This is the path // used by every test that doesn't construct a coordinator and // by sqlite/in-memory deployments. - let core: Promise = this.#populateCoordinator - ? this.loadModuleCacheEntryCoordinated(args, this.#populateCoordinator) - : this.loadModuleCacheEntryUncached(args); + let core: Promise = this.#populateCoordinator + ? this.loadDefinitionCacheEntryCoordinated(args, this.#populateCoordinator) + : this.loadDefinitionCacheEntryUncached(args); pending = core.finally(() => { // Identity-check before deletion: an invalidation path may have // dropped our entry mid-flight, after which a newer caller can @@ -478,7 +478,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { // returns the post-invalidate cache state (undefined or fresher // row); coordinator notifies regardless so peer waiters wake // promptly. Same observable behavior as N=1 generation-mismatch. - private async loadModuleCacheEntryCoordinated( + private async loadDefinitionCacheEntryCoordinated( args: { moduleURL: string; realmURL: string; @@ -488,7 +488,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { prerenderUserId: string; }, coordinator: PopulateCoordinator, - ): Promise { + ): Promise { let coalesceKey = inFlightKey(args); for (let iteration = 0; iteration < COALESCE_MAX_ITERATIONS; iteration++) { // Optimistic pre-lock cache read. On a hit we skip the lock @@ -507,7 +507,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { } let outcome = await coordinator.tryAcquireAndRun(coalesceKey, async () => - this.loadModuleCacheEntryUncached(args), + this.loadDefinitionCacheEntryUncached(args), ); if (outcome.acquired) { // Winner. Result might be undefined if all populationCandidates @@ -523,11 +523,11 @@ export class CachingDefinitionLookup implements DefinitionLookup { await coordinator.waitForKey(coalesceKey, COALESCE_NOTIFY_WAIT_MS); } throw new Error( - `loadModuleCacheEntryCoordinated exceeded ${COALESCE_MAX_ITERATIONS} iterations for ${coalesceKey}; peer prerender appears stuck or NOTIFY broadcast is broken`, + `loadDefinitionCacheEntryCoordinated exceeded ${COALESCE_MAX_ITERATIONS} iterations for ${coalesceKey}; peer prerender appears stuck or NOTIFY broadcast is broken`, ); } - private async loadModuleCacheEntryUncached({ + private async loadDefinitionCacheEntryUncached({ moduleURL, realmURL, resolvedRealmURL, @@ -541,9 +541,9 @@ export class CachingDefinitionLookup implements DefinitionLookup { cacheScope: CacheScope; cacheUserId: string; prerenderUserId: string; - }): Promise { + }): Promise { // Snapshot invalidation generations BEFORE the first await. - // clearRealmCache (and any future synchronous bump) runs entirely before + // clearRealmDefinitions (and any future synchronous bump) runs entirely before // its first await, so a snapshot taken after an await above would already // include the bump and silently match at persist time. Invalidate happens // to await before bumping, so this point of failure is asymmetric — but @@ -598,7 +598,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { resolvedRealmURL, ); } - return await this.persistModuleCacheEntry( + return await this.persistDefinitionCacheEntry( candidateURL, response, resolvedRealmURL, @@ -639,7 +639,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { // Public so the cross-instance ModuleCacheInvalidationListener (CS-10952) // can replay an invalidation broadcast from a peer realm-server into this - // process's counters. Internal callers in invalidate() / clearRealmCache() + // process's counters. Internal callers in invalidate() / clearRealmDefinitions() // use the same methods. Bumping is idempotent w.r.t. correctness — a // double-bump from the self-notify echo is observationally indistinguishable // from a single bump because in-flight prerenders only test for snapshot @@ -667,7 +667,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { // the error TTL. This causes the entry to be treated as a cache miss so // the prerenderer is called again to get a fresh result. This prevents // transient prerender failures from being permanently cached. - private isExpiredErrorEntry(entry: ModuleCacheEntry): boolean { + private isExpiredErrorEntry(entry: DefinitionCacheEntry): boolean { if (!entry.error) { return false; } @@ -743,7 +743,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { resolvedRealmURL, } = context; - let moduleEntry = await this.loadModuleCacheEntry({ + let moduleEntry = await this.loadDefinitionCacheEntry({ moduleURL: canonicalModuleURL, realmURL, resolvedRealmURL, @@ -813,14 +813,14 @@ export class CachingDefinitionLookup implements DefinitionLookup { } this.dropInFlightForRealm(resolvedRealmURL, uniqueInvalidations); await this.deleteModuleAliases(resolvedRealmURL, uniqueInvalidations); - await this.notifyModuleCacheInvalidations( + await this.notifyDefinitionCacheInvalidations( resolvedRealmURL, uniqueInvalidations, ); return uniqueInvalidations; } - async clearRealmCache(resolvedRealmURL: string): Promise { + async clearRealmDefinitions(resolvedRealmURL: string): Promise { // Realm-scope bump: every in-flight prerender for this realm (any // module URL, any scope/user) sees the mismatch at persist time. this.bumpRealmGeneration(resolvedRealmURL); @@ -833,14 +833,14 @@ export class CachingDefinitionLookup implements DefinitionLookup { ['resolved_realm_url =', param(resolvedRealmURL)], ]) as Expression), ]); - await this.notifyRealmCacheInvalidation(resolvedRealmURL); + await this.notifyRealmDefinitionCacheInvalidation(resolvedRealmURL); } - async clearAllModules(): Promise { + async clearAllDefinitions(): Promise { this.bumpGlobalGeneration(); this.#inFlight.clear(); await this.query(['DELETE FROM', MODULES_TABLE]); - await this.notifyGlobalCacheInvalidation(); + await this.notifyGlobalDefinitionCacheInvalidation(); } // pg_notify emission helpers. Mirror Realm.#notifyFileChange's best-effort @@ -862,7 +862,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { // either way. Postgres caps NOTIFY payloads at 8000 bytes, so the module // emitter chunks the URL list to stay under a 7000-byte budget — common // case is one notify; pathological fan-out becomes a handful. - private async notifyModuleCacheInvalidations( + private async notifyDefinitionCacheInvalidations( resolvedRealmURL: string, moduleURLs: string[], ): Promise { @@ -897,7 +897,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { await flush(); } - private async notifyRealmCacheInvalidation( + private async notifyRealmDefinitionCacheInvalidation( resolvedRealmURL: string, ): Promise { if (this.#dbAdapter.kind !== 'pg') { @@ -908,7 +908,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { ); } - private async notifyGlobalCacheInvalidation(): Promise { + private async notifyGlobalDefinitionCacheInvalidation(): Promise { if (this.#dbAdapter.kind !== 'pg') { return; } @@ -941,8 +941,8 @@ export class CachingDefinitionLookup implements DefinitionLookup { // round-trip cannot be cancelled and still completes, but because the // invalidation path also bumps the module / realm / global generation // synchronously before awaiting the DB delete, the in-flight's generation - // check in loadModuleCacheEntryUncached observes the bump before - // persistModuleCacheEntry runs and discards the result via + // check in loadDefinitionCacheEntryUncached observes the bump before + // persistDefinitionCacheEntry runs and discards the result via // readFromDatabaseCache instead of repopulating the cleared row. This // drop step is the in-flight-map half of the same fix: it ensures new // callers arriving after the invalidation don't attach to the now- @@ -1099,7 +1099,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { cacheScope: CacheScope, authUserId: string, resolvedRealmURL: string, - ): Promise { + ): Promise { let moduleAlias = normalizeExecutableURL(moduleUrl); let rows = (await this.query( [ @@ -1154,9 +1154,9 @@ export class CachingDefinitionLookup implements DefinitionLookup { }; } - async getModuleCacheEntries( - query: ModuleCacheEntryQuery, - ): Promise { + async getCachedDefinitionsBatch( + query: DefinitionCacheEntryQuery, + ): Promise { if (query.moduleUrls.length === 0) { return {}; } @@ -1196,7 +1196,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { resolved_realm_url: string | null; }[]; - let entries: ModuleCacheEntries = {}; + let entries: DefinitionCacheEntries = {}; let assignEntry = (key: string, row: (typeof rows)[number]) => { let definitions = parseJsonValue>( @@ -1295,13 +1295,13 @@ export class CachingDefinitionLookup implements DefinitionLookup { ]); } - private async persistModuleCacheEntry( + private async persistDefinitionCacheEntry( moduleUrl: string, response: ModuleRenderResponse, resolvedRealmURL: string, cacheScope: CacheScope, userId: string, - ): Promise { + ): Promise { let entryURL = new URL(moduleUrl); let normalizedDeps = this.normalizeDependencies( response.deps ?? [], @@ -1329,7 +1329,7 @@ export class CachingDefinitionLookup implements DefinitionLookup { if (errorEntry?.error.deps?.length) { deps = [...new Set([...deps, ...errorEntry.error.deps])]; } - let cacheEntry: ModuleCacheEntry = { + let cacheEntry: DefinitionCacheEntry = { definitions: response.definitions ?? {}, deps, error: errorEntry, @@ -1717,12 +1717,12 @@ class RealmScopedDefinitionLookup implements DefinitionLookup { return await this.#inner.invalidate(moduleURL); } - async clearRealmCache(resolvedRealmURL: string): Promise { - await this.#inner.clearRealmCache(resolvedRealmURL); + async clearRealmDefinitions(resolvedRealmURL: string): Promise { + await this.#inner.clearRealmDefinitions(resolvedRealmURL); } - async clearAllModules(): Promise { - await this.#inner.clearAllModules(); + async clearAllDefinitions(): Promise { + await this.#inner.clearAllDefinitions(); } registerRealm(realm: LocalRealm): void { @@ -1733,15 +1733,15 @@ class RealmScopedDefinitionLookup implements DefinitionLookup { return this.#inner.forRealm(realm); } - async getModuleCacheEntry( + async getCachedDefinitions( moduleUrl: string, - ): Promise { - return await this.#inner.getModuleCacheEntry(moduleUrl); + ): Promise { + return await this.#inner.getCachedDefinitions(moduleUrl); } - async getModuleCacheEntries( - query: ModuleCacheEntryQuery, - ): Promise { - return await this.#inner.getModuleCacheEntries(query); + async getCachedDefinitionsBatch( + query: DefinitionCacheEntryQuery, + ): Promise { + return await this.#inner.getCachedDefinitionsBatch(query); } } diff --git a/packages/runtime-common/index-runner.ts b/packages/runtime-common/index-runner.ts index be0a529b34b..06741f00da6 100644 --- a/packages/runtime-common/index-runner.ts +++ b/packages/runtime-common/index-runner.ts @@ -147,13 +147,13 @@ export class IndexRunner { this.#definitionLookup = definitionLookup; this.#dependencyResolver = new IndexRunnerDependencyManager({ realmURL: this.#realmURL, - readModuleCacheEntries: async (moduleIds) => { + readDefinitionCacheEntries: async (moduleIds) => { if (moduleIds.length === 0) { return {}; } let { resolvedRealmURL, cacheScope, authUserId } = await this.getModuleCacheContext(); - return await this.#definitionLookup.getModuleCacheEntries({ + return await this.#definitionLookup.getCachedDefinitionsBatch({ moduleUrls: moduleIds, cacheScope, authUserId, @@ -555,8 +555,8 @@ export class IndexRunner { // // Cache hits are O(1) DB reads inside DefinitionLookup. Cache // misses go through the read-through path - // (loadModuleCacheEntryUncached → getModuleDefinitionsViaPrerenderer - // → persistModuleCacheEntry), the same flow `lookupDefinition` + // (loadDefinitionCacheEntryUncached → getModuleDefinitionsViaPrerenderer + // → persistDefinitionCacheEntry), the same flow `lookupDefinition` // uses; DefinitionLookup owns the in-flight dedup and the cross- // process coalescer, so two callers asking for the same URL share // one prerender. @@ -630,7 +630,7 @@ export class IndexRunner { let failed = 0; for (let moduleUrl of toWarm) { try { - await this.#definitionLookup.getModuleCacheEntry(moduleUrl); + await this.#definitionLookup.getCachedDefinitions(moduleUrl); } catch { failed += 1; } diff --git a/packages/runtime-common/index-runner/dependency-resolver.ts b/packages/runtime-common/index-runner/dependency-resolver.ts index 6af81f43618..e8ed7bfba2f 100644 --- a/packages/runtime-common/index-runner/dependency-resolver.ts +++ b/packages/runtime-common/index-runner/dependency-resolver.ts @@ -3,7 +3,7 @@ import type { SearchIndexErrorEntry, SingleCardDocument, } from '../index'; -import type { ModuleCacheEntries } from '../definition-lookup'; +import type { DefinitionCacheEntries } from '../definition-lookup'; import type { SerializedError } from '../error'; import { canonicalURL } from './dependency-url'; import { IndexBackedDependencyErrors } from './index-backed-dependency-errors'; @@ -16,7 +16,7 @@ type OrderingDependencyRow = Pick; interface DependencyResolverOptions { realmURL: URL; - readModuleCacheEntries(moduleIds: string[]): Promise; + readDefinitionCacheEntries(moduleIds: string[]): Promise; getDependencyRows(urls: string[]): Promise; // Slim projection (url, type, deps only) used by invalidation ordering. // Selection priority is applied server-side; see IndexWriter. @@ -42,7 +42,7 @@ export class IndexRunnerDependencyManager { constructor({ realmURL, - readModuleCacheEntries, + readDefinitionCacheEntries, getDependencyRows, getOrderingDependencyRows, getInvalidations, @@ -50,7 +50,7 @@ export class IndexRunnerDependencyManager { this.#getOrderingDependencyRows = getOrderingDependencyRows; this.#indexBackedDependencyErrors = new IndexBackedDependencyErrors({ realmURL, - readModuleCacheEntries, + readDefinitionCacheEntries, getDependencyRows, getInvalidations, }); diff --git a/packages/runtime-common/index-runner/index-backed-dependency-errors.ts b/packages/runtime-common/index-runner/index-backed-dependency-errors.ts index 40b7518d834..54c08b0348b 100644 --- a/packages/runtime-common/index-runner/index-backed-dependency-errors.ts +++ b/packages/runtime-common/index-runner/index-backed-dependency-errors.ts @@ -1,5 +1,5 @@ import type { DependencyIndexRow, SearchIndexErrorEntry } from '../index'; -import type { ModuleCacheEntries } from '../definition-lookup'; +import type { DefinitionCacheEntries } from '../definition-lookup'; import type { SerializedError } from '../error'; import { cardIdToURL } from '../card-reference-resolver'; import { canonicalURL } from './dependency-url'; @@ -11,26 +11,26 @@ import { interface IndexBackedDependencyErrorOptions { realmURL: URL; - readModuleCacheEntries(moduleIds: string[]): Promise; + readDefinitionCacheEntries(moduleIds: string[]): Promise; getDependencyRows(urls: string[]): Promise; getInvalidations(): string[]; } export class IndexBackedDependencyErrors { #realmURL: URL; - #readModuleCacheEntries: (moduleIds: string[]) => Promise; + #readDefinitionCacheEntries: (moduleIds: string[]) => Promise; #getDependencyRows: (urls: string[]) => Promise; #getInvalidations: () => string[]; #relationshipDependencyRows = new Map(); constructor({ realmURL, - readModuleCacheEntries, + readDefinitionCacheEntries, getDependencyRows, getInvalidations, }: IndexBackedDependencyErrorOptions) { this.#realmURL = realmURL; - this.#readModuleCacheEntries = readModuleCacheEntries; + this.#readDefinitionCacheEntries = readDefinitionCacheEntries; this.#getDependencyRows = getDependencyRows; this.#getInvalidations = getInvalidations; } @@ -194,7 +194,7 @@ export class IndexBackedDependencyErrors { if (deps.length === 0) { return []; } - let entries = await this.#readModuleCacheEntries(deps); + let entries = await this.#readDefinitionCacheEntries(deps); let errors: SerializedError[] = []; for (let entry of Object.values(entries)) { if (!entry.error?.error) { diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index d675bebccb7..31a0927c038 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -683,7 +683,7 @@ export class Realm { // caller's promise instead of running babel again. Invalidation paths // (writeMany, invalidateCache, the full-index clear, etc.) drop the // entry through the shared #dropTranspiledModuleEntry / - // #dropAllTranspiledModuleCacheEntries helpers so post-invalidate callers don't + // #dropAllTranspiledDefinitionCacheEntries helpers so post-invalidate callers don't // join a stale transpile whose #transpiledModuleCache.set will be discarded by // CS-11028's generation guard anyway. Identity-checked cleanup on // settle is the same shape as CachingDefinitionLookup's #inFlight — a @@ -1070,8 +1070,8 @@ export class Realm { ); let completed = indexingCompleted.then(async ({ invalidations }) => { - await this.#definitionLookup.clearRealmCache(this.url); - this.#dropAllTranspiledModuleCacheEntries(); + await this.#definitionLookup.clearRealmDefinitions(this.url); + this.#dropAllTranspiledDefinitionCacheEntries(); if (invalidations.length > 0) { this.broadcastIncrementalInvalidationEvent(invalidations); } @@ -1377,7 +1377,7 @@ export class Realm { __testOnlyClearCaches() { this.#sourceCache.clear(); - this.#dropAllTranspiledModuleCacheEntries(); + this.#dropAllTranspiledDefinitionCacheEntries(); // Reset the transpile counter so each test reasons about its own // delta. Production never reads this counter — only the CS-11029 // dedup tests do (CS-11029). @@ -1401,7 +1401,7 @@ export class Realm { // bulk-invalidate primitive the receiver invokes. clearLocalSourceCaches(): void { this.#sourceCache.clear(); - this.#dropAllTranspiledModuleCacheEntries(); + this.#dropAllTranspiledDefinitionCacheEntries(); } // CS-11029 test seams: tests need to assert "N concurrent same-path @@ -1476,7 +1476,7 @@ export class Realm { // just-cleared map (CS-11028). The per-path map is cleared because the // generations it held are no longer reachable — the global counter is // what catches in-flight snapshots after a wipe. - #dropAllTranspiledModuleCacheEntries(): void { + #dropAllTranspiledDefinitionCacheEntries(): void { this.#transpiledModuleCache.clear(); this.#transpiledModuleCacheGenerations.clear(); this.#transpiledModuleCacheGlobalGeneration += 1; @@ -1564,7 +1564,7 @@ export class Realm { // NOTIFY is a no-op since `clearLocalSourceCaches()` is idempotent. // // Bundles local + broadcast in one call, mirroring - // `CachingDefinitionLookup.clearRealmCache(url)` — handlers don't have + // `CachingDefinitionLookup.clearRealmDefinitions(url)` — handlers don't have // to remember both steps. Callers that only need the peer broadcast // (because their own Realm instance is about to be unmounted anyway — // unpublish/delete handlers) use the standalone `notifyAllFileChanges`