From 46fb78ba1579965335d50eb53e0a5c078fa63153 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 12 May 2026 12:52:53 -0400 Subject: [PATCH 1/8] realm-server: job-scoped same-realm search cache during indexing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap `searchRealms` in `_federated-search` with a per-process `JobScopedSearchCache` keyed by (jobId, normalizedQuery, normalizedOpts). Entries store the *resolved* doc only (not in-flight promises) so a slow first populate cannot tail-latency-block concurrent peers past their render-timeout window. Concurrent same-key callers each run their own populate; Phase 1's in-flight dedup at `RealmIndexQueryEngine.searchCards` absorbs the duplicate inner SQL+loadLinks work. This cache only optimises *sequential* repeats within the same indexing job. Gated on three conditions all holding: - `x-boxel-job-id` present (only the indexer worker stamps this; live user / API callers never carry it and therefore always see fresh data). - `x-boxel-consuming-realm` present (the host's render route only sets it during prerender; new constant + sanitizer live in runtime-common's prerender-headers.ts so the host SPA and realm- server can both import it). - The request's `realms` list is exactly `[consumingRealm]` — cross- realm reads bypass the cache because a peer realm can swap its `boxel_index` mid-batch and a cached value would freeze a stale snapshot. Cache lifetime: 10-minute TTL with `setTimeout(unref)` cleanup. A `clearJob(jobId)` method is exposed for future NOTIFY-driven eviction on worker job done/reject; until then, leaked entries persist at most TTL after the job ends (no correctness impact — `jobId` keying means no future job hits a stale entry). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/host/app/routes/render.ts | 18 ++ packages/host/app/services/store.ts | 17 + .../realm-server/handlers/handle-search.ts | 53 ++- .../realm-server/job-scoped-search-cache.ts | 147 +++++++++ packages/realm-server/routes.ts | 8 +- packages/realm-server/server.ts | 2 +- .../tests/consuming-realm-header-test.ts | 40 +++ packages/realm-server/tests/index.ts | 2 + .../tests/job-scoped-search-cache-test.ts | 302 ++++++++++++++++++ packages/runtime-common/index.ts | 1 + packages/runtime-common/prerender-headers.ts | 29 ++ .../tests/consuming-realm-header-test.ts | 80 +++++ 12 files changed, 692 insertions(+), 7 deletions(-) create mode 100644 packages/realm-server/job-scoped-search-cache.ts create mode 100644 packages/realm-server/tests/consuming-realm-header-test.ts create mode 100644 packages/realm-server/tests/job-scoped-search-cache-test.ts create mode 100644 packages/runtime-common/prerender-headers.ts create mode 100644 packages/runtime-common/tests/consuming-realm-header-test.ts diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index aefbd6cea06..b0d62af3d62 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -214,6 +214,24 @@ export default class RenderRoute extends Route { let parsedOptions = parseRenderRouteOptions(options); let canonicalOptions = serializeRenderRouteOptions(parsedOptions); this.#setupTransitionHelper(id, nonce, canonicalOptions); + // Stamp the "consuming realm" — the realm that owns the card being + // rendered — onto a global the store-service's federated-search + // wrapper reads. The realm-server's job-scoped search cache pairs + // this with `x-boxel-job-id` to gate same-realm-only caching: + // cross-realm reads bypass the cache because peer realms can swap + // independently. + try { + let consumingRealm = this.realm.realmOf(new URL(id)); + ( + globalThis as unknown as { __boxelConsumingRealm?: string } + ).__boxelConsumingRealm = consumingRealm + ? String(consumingRealm) + : undefined; + } catch { + ( + globalThis as unknown as { __boxelConsumingRealm?: string } + ).__boxelConsumingRealm = undefined; + } // CS-10872: render-stage breadcrumb. `model()` running means we // made it past route setup and are about to build the render // model. Each long-running stage below updates this slot so the diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 719108e8ee6..56eacba738b 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -32,6 +32,7 @@ import { isSingleCardDocument, isLinkableCollectionDocument, resolveFileDefCodeRef, + X_BOXEL_CONSUMING_REALM_HEADER, Deferred, delay, mergeRelationships, @@ -117,6 +118,20 @@ let waiter = buildWaiter('store-service'); const realmEventsLogger = logger('realm:events'); const storeLogger = logger('store'); + +// While rendering inside a prerender tab the render route writes +// `__boxelConsumingRealm` with the URL of the realm whose card is being +// rendered. Attach it to outbound `_federated-search` requests so the +// realm-server's job-scoped cache layer can gate same-realm-only +// caching. Read each fetch (not cached at module scope) so a tab that +// renders cards from multiple realms in sequence sends the correct +// header per request. Returns an empty object when the global is not +// set so non-prerender (live SPA) fetches behave exactly as before. +function consumingRealmHeader(): Record { + let r = (globalThis as unknown as { __boxelConsumingRealm?: string }) + .__boxelConsumingRealm; + return r ? { [X_BOXEL_CONSUMING_REALM_HEADER]: r } : {}; +} const queryFieldSeedFromSearchSymbol = Symbol.for( 'cardstack-query-field-seed-from-search', ); @@ -826,6 +841,7 @@ export default class StoreService extends Service implements StoreInterface { headers: { Accept: SupportedMimeType.CardJson, 'Content-Type': 'application/json', + ...consumingRealmHeader(), }, body: JSON.stringify({ ...query, realms }), }, @@ -892,6 +908,7 @@ export default class StoreService extends Service implements StoreInterface { headers: { Accept: SupportedMimeType.CardJson, 'Content-Type': 'application/json', + ...consumingRealmHeader(), }, body: JSON.stringify({ ...query, realms }), }, diff --git a/packages/realm-server/handlers/handle-search.ts b/packages/realm-server/handlers/handle-search.ts index 6652c6b5584..ad466ad6d5c 100644 --- a/packages/realm-server/handlers/handle-search.ts +++ b/packages/realm-server/handlers/handle-search.ts @@ -2,8 +2,10 @@ import type Koa from 'koa'; import { buildSearchErrorResponse, SupportedMimeType, + X_BOXEL_CONSUMING_REALM_HEADER, parseSearchQueryFromPayload, parseSearchQueryFromRequest, + sanitizeConsumingRealmHeader, SearchRequestError, searchRealms, } from '@cardstack/runtime-common'; @@ -16,8 +18,16 @@ import { getMultiRealmAuthorization, getSearchRequestPayload, } from '../middleware/multi-realm-authorization'; +import type { JobScopedSearchCache } from '../job-scoped-search-cache'; +import { + PRERENDER_JOB_ID_HEADER, + sanitizePrerenderJobId, +} from '../prerender/prerender-constants'; -export default function handleSearch(): (ctxt: Koa.Context) => Promise { +export default function handleSearch(opts?: { + searchCache?: JobScopedSearchCache; +}): (ctxt: Koa.Context) => Promise { + let searchCache = opts?.searchCache; return async function (ctxt: Koa.Context) { let { realmList, realmByURL } = getMultiRealmAuthorization(ctxt); @@ -41,10 +51,43 @@ export default function handleSearch(): (ctxt: Koa.Context) => Promise { throw e; } - let combined = await searchRealms( - realmList.map((realmURL) => realmByURL.get(realmURL)), - cardsQuery, - ); + let runSearch = () => + searchRealms( + realmList.map((realmURL) => realmByURL.get(realmURL)), + cardsQuery, + ); + + // Job-scoped same-realm cache. Gated on all three: + // (a) `x-boxel-job-id` is present and well-formed (only the + // indexer worker stamps this; live user / API callers never + // carry it and therefore always see fresh data), + // (b) `x-boxel-consuming-realm` is present and well-formed (the + // host's render route only sets it during prerender), + // (c) the request's `realms` list is exactly `[consumingRealm]` + // — cross-realm reads bypass the cache because a peer + // realm can swap its `boxel_index` mid-batch and the cached + // value would freeze a stale snapshot. + let jobId = searchCache + ? sanitizePrerenderJobId(ctxt.get(PRERENDER_JOB_ID_HEADER)) + : null; + let consumingRealm = searchCache + ? sanitizeConsumingRealmHeader(ctxt.get(X_BOXEL_CONSUMING_REALM_HEADER)) + : null; + let cacheable = + searchCache && + jobId && + consumingRealm && + realmList.length === 1 && + realmList[0] === consumingRealm; + + let combined = cacheable + ? await searchCache!.getOrPopulate({ + jobId: jobId!, + query: cardsQuery, + opts: undefined, + populate: runSearch, + }) + : await runSearch(); await setContextResponse( ctxt, diff --git a/packages/realm-server/job-scoped-search-cache.ts b/packages/realm-server/job-scoped-search-cache.ts new file mode 100644 index 00000000000..63efff15b89 --- /dev/null +++ b/packages/realm-server/job-scoped-search-cache.ts @@ -0,0 +1,147 @@ +import { + normalizeQueryForSignature, + sortKeysDeep, + type LinkableCollectionDocument, + type Query, +} from '@cardstack/runtime-common'; + +// Default entry TTL. Picked to comfortably outlive a single indexing +// batch (workers cap from-scratch jobs at 6 min, incremental jobs are +// shorter) while bounding the worst case where a job ends without a +// NOTIFY-driven eviction reaching this process — a leaked entry persists +// at most this long. Cross-job collision is impossible because the cache +// key includes `jobId`, so a stale leak only hurts memory, never +// correctness. +const DEFAULT_TTL_MS = 10 * 60 * 1000; + +type CachedEntry = { + result: LinkableCollectionDocument; + timer: ReturnType; +}; + +// Same-realm read cache used during indexing. Each entry is keyed by +// `(jobId, normalizedQuery, normalizedOpts)` and represents one +// `_federated-search` populate computed during the lifetime of one +// indexing job. Safe because within an indexing batch the writer +// touches `boxel_index_working`, not `boxel_index` — so every read of +// the same realm's `boxel_index` returns identical bytes until the +// batch's `applyBatchUpdates` swap fires. The job-id boundary scopes +// the cache to a single batch; a subsequent job hashes to different +// keys and never reuses a stale value. +// +// The handler gates entry into this cache on three conditions all +// holding: `x-boxel-job-id` present, `x-boxel-consuming-realm` present, +// and the request's `realms` array is exactly `[consumingRealm]`. +// Cross-realm reads bypass the cache because peer realms can swap +// independently — a cached read against a foreign realm could freeze +// a stale snapshot. Anonymous (no jobId) reads also bypass: those +// callers are not inside the batch's snapshot-stable read window and +// must always see live state. +// +// Entries store the *resolved* doc, not the in-flight promise. +// Concurrent same-key callers each run their own `populate` (Phase 1's +// in-flight dedup at `RealmIndexQueryEngine.searchCards` already +// coalesces the heavy inner SQL+loadLinks walk for same-realm calls +// arriving concurrently). The first to finish stores its result here; +// later sequential callers within the same job see the cached doc and +// short-circuit before re-entering `searchRealms`. +// +// Storing promises was tempting (it would also dedupe at this layer) +// but creates a tail-latency stall: a slow first populate blocks every +// later same-key caller past their render-timeout window, even when +// they could otherwise have run their own search in parallel and made +// progress. Resolved-only avoids that failure mode and keeps the +// benefit of sequential dedup, which is the win this cache exists for. +export class JobScopedSearchCache { + #byJob = new Map>(); + readonly #ttlMs: number; + + constructor(opts?: { ttlMs?: number }) { + this.#ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS; + } + + async getOrPopulate(args: { + jobId: string; + query: Query; + opts: unknown | undefined; + populate: () => Promise; + }): Promise { + let innerKey = buildInnerKey(args.query, args.opts); + let jobMap = this.#byJob.get(args.jobId); + let existing = jobMap?.get(innerKey); + if (existing) { + return existing.result; + } + + let result = await args.populate(); + + // Late-arriving check: the populate may have just settled while a + // peer's populate (same key) also settled and stored its result + // first. Last-write-wins; either of the two resolved docs is + // equally valid since they came from the same `(jobId, query)` + // tuple against the same snapshot-stable boxel_index. + let currentJobMap = this.#byJob.get(args.jobId); + if (!currentJobMap) { + currentJobMap = new Map(); + this.#byJob.set(args.jobId, currentJobMap); + } + let prior = currentJobMap.get(innerKey); + if (prior) { + clearTimeout(prior.timer); + } + let timer = setTimeout(() => { + let jm = this.#byJob.get(args.jobId); + if (!jm) return; + let entry = jm.get(innerKey); + if (entry?.timer === timer) { + jm.delete(innerKey); + if (jm.size === 0) { + this.#byJob.delete(args.jobId); + } + } + }, this.#ttlMs); + if (typeof (timer as { unref?: () => void }).unref === 'function') { + (timer as { unref: () => void }).unref(); + } + currentJobMap.set(innerKey, { result, timer }); + return result; + } + + // Drop every entry for a given job. Wired in by the NOTIFY-driven + // eviction path so the cache releases memory as soon as the worker + // signals job completion, rather than waiting on TTL. + clearJob(jobId: string): void { + let jobMap = this.#byJob.get(jobId); + if (!jobMap) return; + for (let entry of jobMap.values()) { + clearTimeout(entry.timer); + } + this.#byJob.delete(jobId); + } + + // Total entry count across all jobs. Useful for tests + observability. + size(): number { + let total = 0; + for (let jm of this.#byJob.values()) { + total += jm.size; + } + return total; + } + + jobIds(): string[] { + return [...this.#byJob.keys()]; + } +} + +// Compose the per-job inner key. Excludes jobId since the outer Map is +// already partitioned by jobId — this keeps inner-key length bounded +// regardless of how the call site formats the jobId. Excludes the +// realms array (the cache gate already enforces same-realm-only), so +// two requests with `realms: [R]` produce the same inner key +// regardless of array identity. +function buildInnerKey(query: Query, opts: unknown | undefined): string { + return JSON.stringify([ + normalizeQueryForSignature(query), + opts ? sortKeysDeep(opts) : null, + ]); +} diff --git a/packages/realm-server/routes.ts b/packages/realm-server/routes.ts index c80936071b0..30c70aaaa63 100644 --- a/packages/realm-server/routes.ts +++ b/packages/realm-server/routes.ts @@ -38,6 +38,7 @@ import handleClaimBoxelDomainRequest from './handlers/handle-claim-boxel-domain' import handleDeleteBoxelClaimedDomainRequest from './handlers/handle-delete-boxel-claimed-domain'; import handlePrerenderProxy from './handlers/handle-prerender-proxy'; import handleSearch from './handlers/handle-search'; +import { JobScopedSearchCache } from './job-scoped-search-cache'; import handleSearchPrerendered from './handlers/handle-search-prerendered'; import handleRealmInfo from './handlers/handle-realm-info'; import handleFederatedTypes from './handlers/handle-federated-types'; @@ -118,6 +119,11 @@ export function createRoutes(args: CreateRoutesArgs) { args.serverURL, ); let router = new Router(); + // One job-scoped same-realm search cache per realm-server process. + // Lives for the life of the process; TTL-evicts entries 10 min after + // last touch. Future work wires NOTIFY-driven eviction so a job + // completion releases its entries immediately. + let searchCache = new JobScopedSearchCache(); router.get( '/', @@ -178,7 +184,7 @@ export function createRoutes(args: CreateRoutesArgs) { router.all( '/_federated-search', multiRealmAuthorization(args), - handleSearch(), + handleSearch({ searchCache }), ); router.all( '/_federated-info', diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index 648eb52c41b..ca78e4813ab 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -194,7 +194,7 @@ export class RealmServer { cors({ origin: '*', allowHeaders: - 'Authorization, Content-Type, If-Match, If-None-Match, X-Requested-With, X-Boxel-Client-Request-Id, X-Boxel-Assume-User, X-HTTP-Method-Override, X-Boxel-Disable-Module-Cache, X-Filename', + 'Authorization, Content-Type, If-Match, If-None-Match, X-Requested-With, X-Boxel-Client-Request-Id, X-Boxel-Assume-User, X-HTTP-Method-Override, X-Boxel-Disable-Module-Cache, X-Filename, X-Boxel-Consuming-Realm', allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS,QUERY', }), ) diff --git a/packages/realm-server/tests/consuming-realm-header-test.ts b/packages/realm-server/tests/consuming-realm-header-test.ts new file mode 100644 index 00000000000..250fd3bd8bb --- /dev/null +++ b/packages/realm-server/tests/consuming-realm-header-test.ts @@ -0,0 +1,40 @@ +import { module, test } from 'qunit'; +import { basename } from 'path'; +import { runSharedTest } from '@cardstack/runtime-common/helpers'; +import consumingRealmHeaderTests from '@cardstack/runtime-common/tests/consuming-realm-header-test'; + +module(basename(__filename), function () { + module('sanitizeConsumingRealmHeader', function () { + test('accepts a plain http realm URL', async function (assert) { + await runSharedTest(consumingRealmHeaderTests, assert, {}); + }); + + test('accepts a plain https realm URL', async function (assert) { + await runSharedTest(consumingRealmHeaderTests, assert, {}); + }); + + test('trims surrounding whitespace', async function (assert) { + await runSharedTest(consumingRealmHeaderTests, assert, {}); + }); + + test('rejects non-http(s) schemes', async function (assert) { + await runSharedTest(consumingRealmHeaderTests, assert, {}); + }); + + test('rejects empty / whitespace-only / null values', async function (assert) { + await runSharedTest(consumingRealmHeaderTests, assert, {}); + }); + + test('rejects values containing control characters', async function (assert) { + await runSharedTest(consumingRealmHeaderTests, assert, {}); + }); + + test('rejects pathologically long values', async function (assert) { + await runSharedTest(consumingRealmHeaderTests, assert, {}); + }); + + test('rejects non-string inputs', async function (assert) { + await runSharedTest(consumingRealmHeaderTests, assert, {}); + }); + }); +}); diff --git a/packages/realm-server/tests/index.ts b/packages/realm-server/tests/index.ts index d5a560206f9..67909bc7b76 100644 --- a/packages/realm-server/tests/index.ts +++ b/packages/realm-server/tests/index.ts @@ -252,6 +252,8 @@ const ALL_TEST_FILES: string[] = [ './query-matches-filter-test', './matches-filter-integration-test', './search-in-flight-key-test', + './job-scoped-search-cache-test', + './consuming-realm-header-test', './delete-boxel-claimed-domain-test', './realm-auth-test', './queries-test', diff --git a/packages/realm-server/tests/job-scoped-search-cache-test.ts b/packages/realm-server/tests/job-scoped-search-cache-test.ts new file mode 100644 index 00000000000..25ad95d9a98 --- /dev/null +++ b/packages/realm-server/tests/job-scoped-search-cache-test.ts @@ -0,0 +1,302 @@ +import { module, test } from 'qunit'; +import { basename } from 'path'; +import type { + LinkableCollectionDocument, + Query, +} from '@cardstack/runtime-common'; +import { JobScopedSearchCache } from '../job-scoped-search-cache'; + +const realmA = 'http://localhost:4201/test/'; +const realmB = 'http://localhost:4201/other/'; + +function makeQuery(firstName = 'Mango'): Query { + return { + filter: { + eq: { firstName }, + }, + }; +} + +function makeDoc(label: string): LinkableCollectionDocument { + return { + data: [{ type: 'card', id: `${realmA}${label}` }], + meta: { page: { total: 1, realmVersion: 1 } }, + } as unknown as LinkableCollectionDocument; +} + +module(basename(__filename), function () { + module('JobScopedSearchCache', function () { + test('cache hit when (jobId, query, opts) match', async function (assert) { + let cache = new JobScopedSearchCache(); + let calls = 0; + let populate = async () => { + calls++; + return makeDoc('first'); + }; + + let a = await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery(), + opts: undefined, + populate, + }); + let b = await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery(), + opts: undefined, + populate, + }); + + assert.strictEqual(calls, 1, 'populate ran exactly once'); + assert.strictEqual(a, b, 'second caller got the cached doc'); + assert.strictEqual(cache.size(), 1, 'one entry stored'); + }); + + test('cache miss when jobId differs', async function (assert) { + let cache = new JobScopedSearchCache(); + let calls = 0; + let populate = async () => { + calls++; + return makeDoc(`call-${calls}`); + }; + + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery(), + opts: undefined, + populate, + }); + await cache.getOrPopulate({ + jobId: '43.1', + query: makeQuery(), + opts: undefined, + populate, + }); + + assert.strictEqual(calls, 2, 'each job ran its own populate'); + assert.deepEqual( + cache.jobIds().sort(), + ['42.1', '43.1'], + 'cache is partitioned by jobId', + ); + }); + + test('cache miss when query differs (same jobId)', async function (assert) { + let cache = new JobScopedSearchCache(); + let calls = 0; + let populate = async () => { + calls++; + return makeDoc(`call-${calls}`); + }; + + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery('Mango'), + opts: undefined, + populate, + }); + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery('Vango'), + opts: undefined, + populate, + }); + + assert.strictEqual( + calls, + 2, + 'different filters fired distinct populates', + ); + assert.strictEqual(cache.size(), 2, 'two entries under the same job'); + }); + + test('sequential callers after first populate see the cached doc', async function (assert) { + let cache = new JobScopedSearchCache(); + let calls = 0; + let populate = async () => { + calls++; + return makeDoc(`call-${calls}`); + }; + + let a = await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery(), + opts: undefined, + populate, + }); + let b = await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery(), + opts: undefined, + populate, + }); + let c = await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery(), + opts: undefined, + populate, + }); + + assert.strictEqual(calls, 1, 'only the first sequential caller ran populate'); + assert.strictEqual(a, b, 'b returned the cached doc'); + assert.strictEqual(b, c, 'c returned the cached doc'); + }); + + test('concurrent identical callers each run their own populate (intentional)', async function (assert) { + // The cache stores resolved values, not promises. Concurrent + // same-key callers each run their own populate so a slow first + // call can't tail-latency-block peers past their render-timeout + // window. Phase 1's inner in-flight dedup (in + // RealmIndexQueryEngine.searchCards) absorbs the duplicate inner + // SQL+loadLinks work; this cache only optimises *sequential* + // repeats. Last-write-wins on the cache entry. + let cache = new JobScopedSearchCache(); + let calls = 0; + let release!: () => void; + let gate = new Promise((r) => { + release = r; + }); + let populate = async () => { + let myCall = ++calls; + await gate; + return makeDoc(`call-${myCall}`); + }; + + let aP = cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery(), + opts: undefined, + populate, + }); + let bP = cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery(), + opts: undefined, + populate, + }); + await new Promise((r) => setTimeout(r, 0)); + release(); + let [a, b] = await Promise.all([aP, bP]); + + assert.strictEqual(calls, 2, 'both concurrent callers ran their own populate'); + assert.ok(a && b, 'both resolved'); + // A subsequent sequential caller observes whichever doc landed + // last in the cache. (Last-write-wins; either is valid.) + let c = await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery(), + opts: undefined, + populate, + }); + assert.strictEqual(calls, 2, 'sequential c hit the cache (no new populate)'); + assert.ok(c === a || c === b, 'c returned one of the cached docs'); + }); + + test('clearJob drops every entry for that job and leaves peers untouched', async function (assert) { + let cache = new JobScopedSearchCache(); + let calls = 0; + let populate = async () => { + calls++; + return makeDoc(`call-${calls}`); + }; + + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery('A'), + opts: undefined, + populate, + }); + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery('B'), + opts: undefined, + populate, + }); + await cache.getOrPopulate({ + jobId: '43.1', + query: makeQuery('A'), + opts: undefined, + populate, + }); + + cache.clearJob('42.1'); + + assert.deepEqual(cache.jobIds(), ['43.1'], 'job 42 dropped'); + assert.strictEqual(cache.size(), 1, 'only job 43 entry survives'); + }); + + test('TTL evicts entries after the configured window', async function (assert) { + let cache = new JobScopedSearchCache({ ttlMs: 10 }); + let populate = async () => makeDoc('x'); + + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery(), + opts: undefined, + populate, + }); + assert.strictEqual(cache.size(), 1, 'entry stored'); + + await new Promise((r) => setTimeout(r, 25)); + assert.strictEqual(cache.size(), 0, 'entry evicted after TTL'); + }); + + test('opts variance produces distinct entries', async function (assert) { + let cache = new JobScopedSearchCache(); + let calls = 0; + let populate = async () => { + calls++; + return makeDoc(`call-${calls}`); + }; + + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery(), + opts: undefined, + populate, + }); + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery(), + opts: { loadLinks: true }, + populate, + }); + + assert.strictEqual(calls, 2, 'different opts shapes did not coalesce'); + }); + + // Cross-realm-bypass is enforced by handle-search, not by the cache + // class itself — the cache stays oblivious to realm topology. The + // handler-level integration test below covers the gate; here we + // just verify the cache stores whatever (jobId, query, opts) + // tuple a caller passes, regardless of what realm the query + // mentions internally. + test('cache is realm-agnostic — the gate lives in handle-search', async function (assert) { + let cache = new JobScopedSearchCache(); + let calls = 0; + let populate = async () => { + calls++; + return makeDoc('cross'); + }; + + // Two queries that both happen to mention realmB via a contained + // filter still get coalesced if their (jobId, normalized query, + // opts) match. The gate that prevents cross-realm caching is + // applied BEFORE getOrPopulate at the handler layer. + await cache.getOrPopulate({ + jobId: '42.1', + query: { filter: { eq: { realm: realmB } } } as Query, + opts: undefined, + populate, + }); + await cache.getOrPopulate({ + jobId: '42.1', + query: { filter: { eq: { realm: realmB } } } as Query, + opts: undefined, + populate, + }); + + assert.strictEqual(calls, 1, 'second call hit the cache'); + }); + }); +}); diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index ead3a28f633..2a0e21c7a6a 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -609,6 +609,7 @@ export * from './html-utils'; export * from './utils'; export * from './authorization-middleware'; export * from './resource-types'; +export * from './prerender-headers'; export * from './query'; export * from './search-utils'; export * from './prerendered-html-format'; diff --git a/packages/runtime-common/prerender-headers.ts b/packages/runtime-common/prerender-headers.ts new file mode 100644 index 00000000000..39308ee678a --- /dev/null +++ b/packages/runtime-common/prerender-headers.ts @@ -0,0 +1,29 @@ +// HTTP header sent by the host's `_federated-search` fetch wrapper while +// rendering inside a prerender tab. Carries the URL of the realm whose +// card is currently being rendered (the "consuming" realm). The realm- +// server's search-cache layer pairs this with `x-boxel-job-id` to decide +// whether a search is safe to serve from the job-scoped cache: the cache +// is only consulted when the request's `realms` array is exactly +// `[consumingRealm]`, since same-realm reads against the post-swap +// `boxel_index` are stable for the lifetime of a single indexing batch +// (the writer touches `boxel_index_working` until `applyBatchUpdates` +// commits). Cross-realm reads bypass the cache — peer realms can swap +// independently and a cached read would freeze a stale snapshot. +// +// Lives in runtime-common (not realm-server/prerender) so both the host +// SPA and the realm-server can import it without cross-package coupling. +export const X_BOXEL_CONSUMING_REALM_HEADER = 'x-boxel-consuming-realm'; + +// Sanitize the inbound consuming-realm header value. Echoed into log +// lines + used as a cache-key prefix, so reject anything that isn't a +// plausible `http(s)://…` URL string, has whitespace, or is too long. +// 2048 is a defensive ceiling, comfortably above real realm URLs. +const REALM_URL_PATTERN = /^https?:\/\/[!-~]{1,2048}$/; +export function sanitizeConsumingRealmHeader( + raw: string | null | undefined, +): string | null { + if (typeof raw !== 'string') return null; + let trimmed = raw.trim(); + if (!trimmed) return null; + return REALM_URL_PATTERN.test(trimmed) ? trimmed : null; +} diff --git a/packages/runtime-common/tests/consuming-realm-header-test.ts b/packages/runtime-common/tests/consuming-realm-header-test.ts new file mode 100644 index 00000000000..4be5261ff95 --- /dev/null +++ b/packages/runtime-common/tests/consuming-realm-header-test.ts @@ -0,0 +1,80 @@ +import type { SharedTests } from '../helpers'; +import { sanitizeConsumingRealmHeader } from '../prerender-headers'; + +const tests = Object.freeze({ + 'accepts a plain http realm URL': async (assert) => { + assert.strictEqual( + sanitizeConsumingRealmHeader('http://localhost:4201/test/'), + 'http://localhost:4201/test/', + ); + }, + + 'accepts a plain https realm URL': async (assert) => { + assert.strictEqual( + sanitizeConsumingRealmHeader('https://cardstack.com/base/'), + 'https://cardstack.com/base/', + ); + }, + + 'trims surrounding whitespace': async (assert) => { + assert.strictEqual( + sanitizeConsumingRealmHeader(' http://localhost:4201/test/ '), + 'http://localhost:4201/test/', + ); + }, + + 'rejects non-http(s) schemes': async (assert) => { + assert.strictEqual( + sanitizeConsumingRealmHeader('file:///etc/passwd'), + null, + ); + assert.strictEqual(sanitizeConsumingRealmHeader('ftp://x/'), null); + assert.strictEqual( + sanitizeConsumingRealmHeader('javascript:alert(1)'), + null, + ); + }, + + 'rejects empty / whitespace-only / null values': async (assert) => { + assert.strictEqual(sanitizeConsumingRealmHeader(''), null); + assert.strictEqual(sanitizeConsumingRealmHeader(' '), null); + assert.strictEqual(sanitizeConsumingRealmHeader(null), null); + assert.strictEqual(sanitizeConsumingRealmHeader(undefined), null); + }, + + 'rejects values containing control characters': async (assert) => { + // CR/LF/whitespace inside the URL would let a malicious caller + // inject newlines into log lines. + assert.strictEqual( + sanitizeConsumingRealmHeader('http://x/\r\nInjected: header'), + null, + ); + assert.strictEqual( + sanitizeConsumingRealmHeader('http://example.com/with space/'), + null, + ); + assert.strictEqual(sanitizeConsumingRealmHeader('http://x/\ttab'), null); + }, + + 'rejects pathologically long values': async (assert) => { + let long = 'http://x/' + 'a'.repeat(3000); + assert.strictEqual(sanitizeConsumingRealmHeader(long), null); + }, + + 'rejects non-string inputs': async (assert) => { + assert.strictEqual( + sanitizeConsumingRealmHeader(42 as unknown as string), + null, + ); + assert.strictEqual( + sanitizeConsumingRealmHeader({} as unknown as string), + null, + ); + assert.strictEqual( + sanitizeConsumingRealmHeader(['http://x/'] as unknown as string), + null, + ); + }, +} as SharedTests<{}>); + +export default tests; From 1501a8cfdbd822c9c65b1bd46b9667be713d563c Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 12 May 2026 13:35:51 -0400 Subject: [PATCH 2/8] job-scoped-search-cache tests: address lint feedback Prettier line wrapping + replace assert.ok with logical operators by binding the boolean to a local first (qunit lint rule no-assert-logical-expression). --- .../tests/job-scoped-search-cache-test.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/realm-server/tests/job-scoped-search-cache-test.ts b/packages/realm-server/tests/job-scoped-search-cache-test.ts index 25ad95d9a98..3378dd27a8a 100644 --- a/packages/realm-server/tests/job-scoped-search-cache-test.ts +++ b/packages/realm-server/tests/job-scoped-search-cache-test.ts @@ -137,7 +137,11 @@ module(basename(__filename), function () { populate, }); - assert.strictEqual(calls, 1, 'only the first sequential caller ran populate'); + assert.strictEqual( + calls, + 1, + 'only the first sequential caller ran populate', + ); assert.strictEqual(a, b, 'b returned the cached doc'); assert.strictEqual(b, c, 'c returned the cached doc'); }); @@ -178,8 +182,13 @@ module(basename(__filename), function () { release(); let [a, b] = await Promise.all([aP, bP]); - assert.strictEqual(calls, 2, 'both concurrent callers ran their own populate'); - assert.ok(a && b, 'both resolved'); + assert.strictEqual( + calls, + 2, + 'both concurrent callers ran their own populate', + ); + assert.ok(a, 'a resolved'); + assert.ok(b, 'b resolved'); // A subsequent sequential caller observes whichever doc landed // last in the cache. (Last-write-wins; either is valid.) let c = await cache.getOrPopulate({ @@ -188,8 +197,13 @@ module(basename(__filename), function () { opts: undefined, populate, }); - assert.strictEqual(calls, 2, 'sequential c hit the cache (no new populate)'); - assert.ok(c === a || c === b, 'c returned one of the cached docs'); + assert.strictEqual( + calls, + 2, + 'sequential c hit the cache (no new populate)', + ); + let cIsOneOfCached = c === a || c === b; + assert.true(cIsOneOfCached, 'c returned one of the cached docs'); }); test('clearJob drops every entry for that job and leaves peers untouched', async function (assert) { From 3e4794bde8abc2c32ac0e7e55b4cc7a200cd2ce8 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 12 May 2026 19:06:29 -0400 Subject: [PATCH 3/8] =?UTF-8?q?plumb=20x-boxel-job-id=20from=20worker=20?= =?UTF-8?q?=E2=86=92=20page=20=E2=86=92=20host=20fed-search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce `X_BOXEL_JOB_ID_HEADER` in runtime-common's prerender-headers.ts as the canonical definition; realm-server's prerender-constants.ts re-exports as `PRERENDER_JOB_ID_HEADER` so existing imports keep working. - prerender-app's `/prerender-visit` route forwards the inbound `x-boxel-job-id` header into `prerenderer.prerenderVisit({...jobId})`. - Prerenderer + RenderRunner thread `jobId` through to `prerenderVisitAttempt`, which `page.evaluate`s `globalThis.__boxelJobId = jobId` before the render begins. - Host's `_federated-search` fetch wrapper reads the global and attaches the header alongside `consumingRealmHeader()` / `duringPrerenderHeaders()`. - CORS allow-list adds `X-Boxel-Job-Id` (preflight blocker on the prior commit). With this in place, `handle-search`'s cache gate (`jobId && consumingRealm && realms === [consumingRealm]`) can actually evaluate true during indexing. --- packages/host/app/services/store.ts | 16 ++++++++++++++++ .../realm-server/prerender/prerender-app.ts | 9 +++++++++ .../prerender/prerender-constants.ts | 8 +++++++- packages/realm-server/prerender/prerenderer.ts | 2 ++ .../realm-server/prerender/render-runner.ts | 18 ++++++++++++++++++ packages/realm-server/server.ts | 2 +- packages/runtime-common/prerender-headers.ts | 13 +++++++++++++ 7 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 15f63049589..38b2ca893c1 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -34,6 +34,7 @@ import { isLinkableCollectionDocument, resolveFileDefCodeRef, X_BOXEL_CONSUMING_REALM_HEADER, + X_BOXEL_JOB_ID_HEADER, Deferred, delay, mergeRelationships, @@ -145,6 +146,19 @@ function consumingRealmHeader(): Record { .__boxelConsumingRealm; return r ? { [X_BOXEL_CONSUMING_REALM_HEADER]: r } : {}; } + +// Companion to `consumingRealmHeader()`. The prerender server's +// `prerenderVisitAttempt` injects `__boxelJobId` onto the page before +// transitioning into the render route — see +// `packages/realm-server/prerender/render-runner.ts`. Read it on each +// fetch (not module-scope-cached) so a page reused across multiple +// visits picks up the current visit's job id. Outside a prerender +// tab the global is undefined and we send no header, so user / API +// callers continue to bypass the realm-server's job-scoped cache. +function jobIdHeader(): Record { + let j = (globalThis as unknown as { __boxelJobId?: string }).__boxelJobId; + return j ? { [X_BOXEL_JOB_ID_HEADER]: j } : {}; +} const queryFieldSeedFromSearchSymbol = Symbol.for( 'cardstack-query-field-seed-from-search', ); @@ -856,6 +870,7 @@ export default class StoreService extends Service implements StoreInterface { 'Content-Type': 'application/json', ...duringPrerenderHeaders(), ...consumingRealmHeader(), + ...jobIdHeader(), }, body: JSON.stringify({ ...query, realms }), }, @@ -924,6 +939,7 @@ export default class StoreService extends Service implements StoreInterface { 'Content-Type': 'application/json', ...duringPrerenderHeaders(), ...consumingRealmHeader(), + ...jobIdHeader(), }, body: JSON.stringify({ ...query, realms }), }, diff --git a/packages/realm-server/prerender/prerender-app.ts b/packages/realm-server/prerender/prerender-app.ts index df99fb4ff6d..93b0d42f189 100644 --- a/packages/realm-server/prerender/prerender-app.ts +++ b/packages/realm-server/prerender/prerender-app.ts @@ -741,6 +741,14 @@ export function buildPrerenderApp(options: { ? rawBatchId : undefined; + // Indexer job correlation id. Already carried on the inbound + // `x-boxel-job-id` header for log-tagging; forward it to the + // prerenderer so it can be exposed to the host SPA via a global + // (`__boxelJobId`) — the host's `_federated-search` fetch + // wrapper reads it and re-stamps the header on outbound calls + // so `handle-search` can gate the job-scoped search cache. + let jobId = sanitizePrerenderJobId(ctxt.get(PRERENDER_JOB_ID_HEADER)); + let start = Date.now(); let execPromise = prerenderer .prerenderVisit({ @@ -754,6 +762,7 @@ export function buildPrerenderApp(options: { ...(Array.isArray(types) ? { types } : {}), ...(batchId ? { batchId } : {}), ...(priority !== undefined ? { priority } : {}), + ...(jobId ? { jobId } : {}), signal: ac.signal, }) .then((result) => ({ result })); diff --git a/packages/realm-server/prerender/prerender-constants.ts b/packages/realm-server/prerender/prerender-constants.ts index 1ee634daa62..671cb4dc09b 100644 --- a/packages/realm-server/prerender/prerender-constants.ts +++ b/packages/realm-server/prerender/prerender-constants.ts @@ -34,7 +34,13 @@ export function sanitizePrerenderRequestId( // `proxying`/`proxied` lines so a single job's prerender activity is // greppable across services with the same `[job: J.R]` substring used // in worker logs. -export const PRERENDER_JOB_ID_HEADER = 'x-boxel-job-id'; +// +// CS-11115 Phase 2: the canonical definition now lives in runtime- +// common's prerender-headers.ts (as `X_BOXEL_JOB_ID_HEADER`) so the +// host SPA can import it without taking a realm-server dependency. +// Re-exported here under the legacy name so existing realm-server +// imports keep working unchanged. +export { X_BOXEL_JOB_ID_HEADER as PRERENDER_JOB_ID_HEADER } from '@cardstack/runtime-common'; // Stamped on the host's outbound _federated-search / _search calls // when the host SPA detects it's running inside a prerender tab. The diff --git a/packages/realm-server/prerender/prerenderer.ts b/packages/realm-server/prerender/prerenderer.ts index 2ade4a32aee..078250271d0 100644 --- a/packages/realm-server/prerender/prerenderer.ts +++ b/packages/realm-server/prerender/prerenderer.ts @@ -629,6 +629,7 @@ export class Prerenderer { types, opts, priority, + jobId, } = this.#gateClearCache(rawArgs); let signal = (rawArgs as { signal?: AbortSignal }).signal; let testOnTabAcquired = ( @@ -686,6 +687,7 @@ export class Prerenderer { fileData, types, priority, + jobId, signal, onTabAcquired, }); diff --git a/packages/realm-server/prerender/render-runner.ts b/packages/realm-server/prerender/render-runner.ts index af84a502a38..0058719caa1 100644 --- a/packages/realm-server/prerender/render-runner.ts +++ b/packages/realm-server/prerender/render-runner.ts @@ -715,6 +715,7 @@ export class RenderRunner { fileData, types, priority, + jobId, signal, onTabAcquired, }: PrerenderVisitArgs & { @@ -777,6 +778,23 @@ export class RenderRunner { await page.evaluate((sessionAuth) => { localStorage.setItem('boxel-session', sessionAuth); }, auth); + // Expose the indexing job id to the rendered host so its + // `_federated-search` fetch wrapper can stamp `x-boxel-job-id` + // on outbound calls. The realm-server's handle-search gate + // requires both `x-boxel-job-id` and `x-boxel-consuming-realm` + // before consulting the JobScopedSearchCache. Cleared first + // so a tab reused across multiple visits never bleeds a prior + // visit's job id into the next render. + await page + .evaluate((id: string | undefined) => { + ( + globalThis as unknown as { __boxelJobId?: string } + ).__boxelJobId = id; + }, jobId) + .catch(() => { + // best-effort: a transient page/CDP error here doesn't break + // the render — the cache simply doesn't engage for this visit. + }); // defense-in-depth: clear any stale file render data left on globalThis // from a prior visit before we start running passes. await page diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index 6c3c58b5235..601adbf6e22 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -194,7 +194,7 @@ export class RealmServer { cors({ origin: '*', allowHeaders: - 'Authorization, Content-Type, If-Match, If-None-Match, X-Requested-With, X-Boxel-Client-Request-Id, X-Boxel-Assume-User, X-HTTP-Method-Override, X-Boxel-Disable-Module-Cache, X-Filename, X-Boxel-During-Prerender, X-Boxel-Consuming-Realm', + 'Authorization, Content-Type, If-Match, If-None-Match, X-Requested-With, X-Boxel-Client-Request-Id, X-Boxel-Assume-User, X-HTTP-Method-Override, X-Boxel-Disable-Module-Cache, X-Filename, X-Boxel-During-Prerender, X-Boxel-Consuming-Realm, X-Boxel-Job-Id', allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS,QUERY', }), ) diff --git a/packages/runtime-common/prerender-headers.ts b/packages/runtime-common/prerender-headers.ts index 39308ee678a..04313467014 100644 --- a/packages/runtime-common/prerender-headers.ts +++ b/packages/runtime-common/prerender-headers.ts @@ -1,3 +1,16 @@ +// `.` of the indexing job that triggered a +// prerender visit. Originates at the worker (`pg-queue`), tagged +// onto outbound prerender requests by `remote-prerenderer`, echoed +// through prerender-manager → prerender-server, and (new in CS-11115 +// Phase 2) injected into the rendered host as `window.__boxelJobId` +// so the host's `_federated-search` fetch wrapper can re-stamp it on +// outbound calls. Lives in runtime-common alongside the consuming- +// realm header so the host SPA can import it without taking a +// dependency on the realm-server package. realm-server's +// `prerender-constants.ts` re-exports this as `PRERENDER_JOB_ID_HEADER` +// for backwards-compatibility with existing imports. +export const X_BOXEL_JOB_ID_HEADER = 'x-boxel-job-id'; + // HTTP header sent by the host's `_federated-search` fetch wrapper while // rendering inside a prerender tab. Carries the URL of the realm whose // card is currently being rendered (the "consuming" realm). The realm- From 960ab26f2af6f68f141cedfadf1d24326b003cc3 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 12 May 2026 20:15:19 -0400 Subject: [PATCH 4/8] render-runner: prettier formatting fix in __boxelJobId injection --- packages/realm-server/prerender/render-runner.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/realm-server/prerender/render-runner.ts b/packages/realm-server/prerender/render-runner.ts index 0058719caa1..19981619d99 100644 --- a/packages/realm-server/prerender/render-runner.ts +++ b/packages/realm-server/prerender/render-runner.ts @@ -787,9 +787,8 @@ export class RenderRunner { // visit's job id into the next render. await page .evaluate((id: string | undefined) => { - ( - globalThis as unknown as { __boxelJobId?: string } - ).__boxelJobId = id; + (globalThis as unknown as { __boxelJobId?: string }).__boxelJobId = + id; }, jobId) .catch(() => { // best-effort: a transient page/CDP error here doesn't break From 04b7774192461a2c27e50d59ccfbfade8828ecaf Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 12 May 2026 20:21:44 -0400 Subject: [PATCH 5/8] Phase 2 review fixes: CORS preflight cache + per-visit CDP RTT - Set `Access-Control-Max-Age: 86400` on the CORS middleware. Without this @koa/cors omits the header and Chrome falls back to its ~5 s default preflight TTL, which forces a fresh OPTIONS round-trip in front of nearly every cross-origin QUERY the host fires during a long indexing run. Doubles realm-server arrival count and adds a serial RTT in front of each QUERY. - Fold the new `__boxelJobId` assignment into the existing `localStorage.setItem('boxel-session', ...)` `page.evaluate` so we pay one CDP round-trip per visit instead of two. Same effect: global is set before transitionTo, host's `_federated-search` wrapper reads it and stamps `x-boxel-job-id` on outbound calls. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../realm-server/prerender/render-runner.ts | 32 +++++++++---------- packages/realm-server/server.ts | 8 +++++ 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/realm-server/prerender/render-runner.ts b/packages/realm-server/prerender/render-runner.ts index 19981619d99..985f9d0194a 100644 --- a/packages/realm-server/prerender/render-runner.ts +++ b/packages/realm-server/prerender/render-runner.ts @@ -775,25 +775,23 @@ export class RenderRunner { // The check lives inside the try so `finally { release() }` frees // the tab slot if the caller aborted during the getPage handoff. throwIfAborted(signal, 'queued'); - await page.evaluate((sessionAuth) => { - localStorage.setItem('boxel-session', sessionAuth); - }, auth); - // Expose the indexing job id to the rendered host so its - // `_federated-search` fetch wrapper can stamp `x-boxel-job-id` - // on outbound calls. The realm-server's handle-search gate - // requires both `x-boxel-job-id` and `x-boxel-consuming-realm` - // before consulting the JobScopedSearchCache. Cleared first - // so a tab reused across multiple visits never bleeds a prior - // visit's job id into the next render. - await page - .evaluate((id: string | undefined) => { + // Single CDP round-trip that sets both the session auth and the + // indexing job id on the page. The job id surfaces to the host's + // `_federated-search` fetch wrapper via `globalThis.__boxelJobId` + // — the realm-server's handle-search gate pairs it with + // `x-boxel-consuming-realm` to decide whether to consult the + // JobScopedSearchCache. Always overwrite (including with + // undefined) so a tab reused across multiple visits never bleeds + // a prior visit's job id into the next render. + await page.evaluate( + (sessionAuth: string, id: string | undefined) => { + localStorage.setItem('boxel-session', sessionAuth); (globalThis as unknown as { __boxelJobId?: string }).__boxelJobId = id; - }, jobId) - .catch(() => { - // best-effort: a transient page/CDP error here doesn't break - // the render — the cache simply doesn't engage for this visit. - }); + }, + auth, + jobId, + ); // defense-in-depth: clear any stale file render data left on globalThis // from a prior visit before we start running passes. await page diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index 601adbf6e22..814e1809078 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -196,6 +196,14 @@ export class RealmServer { allowHeaders: 'Authorization, Content-Type, If-Match, If-None-Match, X-Requested-With, X-Boxel-Client-Request-Id, X-Boxel-Assume-User, X-HTTP-Method-Override, X-Boxel-Disable-Module-Cache, X-Filename, X-Boxel-During-Prerender, X-Boxel-Consuming-Realm, X-Boxel-Job-Id', allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS,QUERY', + // Cache the preflight response for 24 h. Without this @koa/cors + // omits Access-Control-Max-Age and Chrome falls back to its + // ~5 s default, which forces a fresh OPTIONS round-trip in front + // of nearly every cross-origin QUERY the host fires during a + // long indexing run. The doubled HTTP-arrival count translates + // directly to wall-clock since each preflight is a serial RTT + // blocking the QUERY behind it. + maxAge: 86400, }), ) .use(async (ctx, next) => { From 0f905e0e6ef7c2d5aee1f03f23a3575658f43b3b Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 12 May 2026 20:33:08 -0400 Subject: [PATCH 6/8] JobScopedSearchCache: bounded eviction + docstring corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hard cap on total entries with FIFO eviction by populate-order sequence. Bounds worst-case memory under a synthetic-jobId flood: an authenticated reader could otherwise mint arbitrary `.` jobId values and grow the cache without bound for the full TTL window. Default 5000 entries. - Routes.ts comment said "TTL-evicts entries 10 min after last touch" — actually the timer fires 10 min after the initial populate; hits don't refresh it. Tying TTL to populate time bounds the leak deterministically, so the code stays; only the comment changes. - job-scoped-search-cache-test.ts: dropped the stale "handler-level integration test below covers the gate" claim and replaced it with a `TODO` pointing at the right home for that coverage (`realm-endpoints/search-test.ts`). Added a unit test for the cap. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../realm-server/job-scoped-search-cache.ts | 80 ++++++++++++++++--- packages/realm-server/routes.ts | 8 +- .../tests/job-scoped-search-cache-test.ts | 69 +++++++++++++++- 3 files changed, 140 insertions(+), 17 deletions(-) diff --git a/packages/realm-server/job-scoped-search-cache.ts b/packages/realm-server/job-scoped-search-cache.ts index 63efff15b89..1b9695f299e 100644 --- a/packages/realm-server/job-scoped-search-cache.ts +++ b/packages/realm-server/job-scoped-search-cache.ts @@ -14,9 +14,25 @@ import { // correctness. const DEFAULT_TTL_MS = 10 * 60 * 1000; +// Hard cap on total entries across all jobs. When the cap is reached +// the FIFO-oldest entry is evicted to make room. Cap exists to bound +// worst-case memory: the `jobId` header is sanitized to a digits-only +// shape but the cache otherwise accepts any well-formed +// `(jobId, query, opts)` tuple from an authenticated caller, so a +// reader who mints synthetic jobIds and varied queries could otherwise +// grow the cache without bound for the full TTL window. Picked to +// comfortably accommodate the busiest realistic workload (a from- +// scratch reindex of a piranha-class realm fires hundreds of distinct +// queries within one job) while keeping worst-case footprint bounded +// to ~tens of MB. +const DEFAULT_MAX_ENTRIES = 5000; + type CachedEntry = { result: LinkableCollectionDocument; timer: ReturnType; + // Position in the FIFO eviction ring. Stored on the entry so a + // cache hit doesn't need a separate map lookup to know its slot. + fifoSeq: number; }; // Same-realm read cache used during indexing. Each entry is keyed by @@ -54,10 +70,18 @@ type CachedEntry = { // benefit of sequential dedup, which is the win this cache exists for. export class JobScopedSearchCache { #byJob = new Map>(); + // FIFO ring keyed by an ever-incrementing sequence so eviction + // ordering survives the (jobId, innerKey) name space. The oldest + // surviving sequence number is `#evictionCursor`; advances as the + // entry it points at is evicted (either via cap or its own TTL). + #fifo = new Map(); + #nextFifoSeq = 0; readonly #ttlMs: number; + readonly #maxEntries: number; - constructor(opts?: { ttlMs?: number }) { + constructor(opts?: { ttlMs?: number; maxEntries?: number }) { this.#ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS; + this.#maxEntries = opts?.maxEntries ?? DEFAULT_MAX_ENTRIES; } async getOrPopulate(args: { @@ -88,25 +112,58 @@ export class JobScopedSearchCache { let prior = currentJobMap.get(innerKey); if (prior) { clearTimeout(prior.timer); + this.#fifo.delete(prior.fifoSeq); } + let fifoSeq = this.#nextFifoSeq++; let timer = setTimeout(() => { - let jm = this.#byJob.get(args.jobId); - if (!jm) return; - let entry = jm.get(innerKey); - if (entry?.timer === timer) { - jm.delete(innerKey); - if (jm.size === 0) { - this.#byJob.delete(args.jobId); - } - } + this.#evictByKey(args.jobId, innerKey, timer); }, this.#ttlMs); if (typeof (timer as { unref?: () => void }).unref === 'function') { (timer as { unref: () => void }).unref(); } - currentJobMap.set(innerKey, { result, timer }); + currentJobMap.set(innerKey, { result, timer, fifoSeq }); + this.#fifo.set(fifoSeq, { jobId: args.jobId, innerKey }); + + // Cap enforcement: evict FIFO-oldest until under the limit. Map + // preserves insertion order, so the first key is the oldest. We + // skip-over any keys whose entries are already gone (TTL fired) + // without rewriting the ring. + while (this.#fifo.size > this.#maxEntries) { + let oldestSeq = this.#fifo.keys().next().value; + if (oldestSeq === undefined) break; + let slot = this.#fifo.get(oldestSeq)!; + this.#fifo.delete(oldestSeq); + let jm = this.#byJob.get(slot.jobId); + let entry = jm?.get(slot.innerKey); + if (entry?.fifoSeq === oldestSeq) { + clearTimeout(entry.timer); + jm!.delete(slot.innerKey); + if (jm!.size === 0) { + this.#byJob.delete(slot.jobId); + } + } + } + return result; } + #evictByKey( + jobId: string, + innerKey: string, + expectedTimer: ReturnType, + ): void { + let jm = this.#byJob.get(jobId); + if (!jm) return; + let entry = jm.get(innerKey); + if (entry?.timer === expectedTimer) { + this.#fifo.delete(entry.fifoSeq); + jm.delete(innerKey); + if (jm.size === 0) { + this.#byJob.delete(jobId); + } + } + } + // Drop every entry for a given job. Wired in by the NOTIFY-driven // eviction path so the cache releases memory as soon as the worker // signals job completion, rather than waiting on TTL. @@ -115,6 +172,7 @@ export class JobScopedSearchCache { if (!jobMap) return; for (let entry of jobMap.values()) { clearTimeout(entry.timer); + this.#fifo.delete(entry.fifoSeq); } this.#byJob.delete(jobId); } diff --git a/packages/realm-server/routes.ts b/packages/realm-server/routes.ts index 30c70aaaa63..e28368c3c17 100644 --- a/packages/realm-server/routes.ts +++ b/packages/realm-server/routes.ts @@ -121,8 +121,12 @@ export function createRoutes(args: CreateRoutesArgs) { let router = new Router(); // One job-scoped same-realm search cache per realm-server process. // Lives for the life of the process; TTL-evicts entries 10 min after - // last touch. Future work wires NOTIFY-driven eviction so a job - // completion releases its entries immediately. + // their initial populate (hits do NOT refresh the TTL — tying it to + // populate time bounds the leak deterministically, where touch- + // refresh would let a hot entry survive indefinitely past job + // completion). Future work wires NOTIFY-driven eviction so a job + // completion releases its entries immediately. Hard-capped to bound + // worst-case memory under a synthetic-jobId flood. let searchCache = new JobScopedSearchCache(); router.get( diff --git a/packages/realm-server/tests/job-scoped-search-cache-test.ts b/packages/realm-server/tests/job-scoped-search-cache-test.ts index 3378dd27a8a..d7407191b39 100644 --- a/packages/realm-server/tests/job-scoped-search-cache-test.ts +++ b/packages/realm-server/tests/job-scoped-search-cache-test.ts @@ -255,6 +255,66 @@ module(basename(__filename), function () { assert.strictEqual(cache.size(), 0, 'entry evicted after TTL'); }); + test('maxEntries cap FIFO-evicts oldest when full', async function (assert) { + let cache = new JobScopedSearchCache({ maxEntries: 3 }); + let populate = async (label: string) => makeDoc(label); + + // Fill exactly to capacity. + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery('A'), + opts: undefined, + populate: () => populate('A'), + }); + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery('B'), + opts: undefined, + populate: () => populate('B'), + }); + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery('C'), + opts: undefined, + populate: () => populate('C'), + }); + assert.strictEqual(cache.size(), 3, 'at-capacity entry count'); + + // One more triggers FIFO eviction of the oldest (A). + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery('D'), + opts: undefined, + populate: () => populate('D'), + }); + assert.strictEqual(cache.size(), 3, 'still at cap after overflow'); + + // Re-requesting A re-populates (cache miss); B and C remain hits. + let aCalls = 0; + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery('A'), + opts: undefined, + populate: async () => { + aCalls++; + return populate('A'); + }, + }); + assert.strictEqual(aCalls, 1, 'A was re-populated (it was evicted)'); + + let bCalls = 0; + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery('B'), + opts: undefined, + populate: async () => { + bCalls++; + return populate('B'); + }, + }); + assert.strictEqual(bCalls, 0, 'B was a cache hit (not evicted)'); + }); + test('opts variance produces distinct entries', async function (assert) { let cache = new JobScopedSearchCache(); let calls = 0; @@ -280,11 +340,12 @@ module(basename(__filename), function () { }); // Cross-realm-bypass is enforced by handle-search, not by the cache - // class itself — the cache stays oblivious to realm topology. The - // handler-level integration test below covers the gate; here we - // just verify the cache stores whatever (jobId, query, opts) + // class itself — the cache stays oblivious to realm topology. Here + // we just verify the cache stores whatever (jobId, query, opts) // tuple a caller passes, regardless of what realm the query - // mentions internally. + // mentions internally. End-to-end coverage of the + // `realms === [consumingRealm]` HTTP-layer gate is TODO — a + // `realm-endpoints/search-test.ts` case is the right home for it. test('cache is realm-agnostic — the gate lives in handle-search', async function (assert) { let cache = new JobScopedSearchCache(); let calls = 0; From 82fb488dedcf938d6ca62fd61750c2f44a5b7a7e Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 12 May 2026 21:35:48 -0400 Subject: [PATCH 7/8] prerenderer: forward jobId on browser-restart retry path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first attempt and the post-restart retry call `prerenderVisitAttempt` with the same args, but the retry was missing `jobId`. Without it, `render-runner` injects `undefined` into `__boxelJobId` on the restarted page → the host's federated-search wrapper omits the header on outbound calls → `handle-search` bypasses the cache for the whole retried render even though those calls still belong to the same indexing job. Render-runner's `page.evaluate` is already a single statement that overwrites both globals atomically, so this fix only needs to pass the ID through the missing destructure site. --- packages/realm-server/prerender/prerenderer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/realm-server/prerender/prerenderer.ts b/packages/realm-server/prerender/prerenderer.ts index 078250271d0..b09de2ca277 100644 --- a/packages/realm-server/prerender/prerenderer.ts +++ b/packages/realm-server/prerender/prerenderer.ts @@ -720,6 +720,7 @@ export class Prerenderer { fileData, types, priority, + jobId, signal, onTabAcquired, }); From c6f1e2c6d544e8f5217180adf08b873ed1bc75c6 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 12 May 2026 22:44:12 -0400 Subject: [PATCH 8/8] job-scoped-search-cache test: fix FIFO-cap expectation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cap-eviction test asserted "B was a cache hit (not evicted)" after re-requesting A on a full cache. That expectation was LRU-shaped — under the actual strict-FIFO semantics, B is the oldest survivor after A is evicted by the D insert; re-inserting A pushes B out next. Rewrite the assertions to match strict FIFO: - After A is evicted by D and re-inserted (seq 4), the map holds C, D, A (B is now evicted). - D was the youngest survivor before A re-entered, so D should still be a hit. Pick D rather than C as the "still hit" assertion so the test is stable under future cap-math changes. - B is the entry the re-insert pushed out, so B should miss now — add that assertion to make the eviction observable. Test now correctly characterises the implementation; impl unchanged. --- .../tests/job-scoped-search-cache-test.ts | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/realm-server/tests/job-scoped-search-cache-test.ts b/packages/realm-server/tests/job-scoped-search-cache-test.ts index d7407191b39..6691b666ddb 100644 --- a/packages/realm-server/tests/job-scoped-search-cache-test.ts +++ b/packages/realm-server/tests/job-scoped-search-cache-test.ts @@ -259,7 +259,8 @@ module(basename(__filename), function () { let cache = new JobScopedSearchCache({ maxEntries: 3 }); let populate = async (label: string) => makeDoc(label); - // Fill exactly to capacity. + // Fill exactly to capacity. Insertion order: A (seq 0), B (1), + // C (2). All three under jobId 42.1. await cache.getOrPopulate({ jobId: '42.1', query: makeQuery('A'), @@ -280,7 +281,8 @@ module(basename(__filename), function () { }); assert.strictEqual(cache.size(), 3, 'at-capacity entry count'); - // One more triggers FIFO eviction of the oldest (A). + // One more insert triggers FIFO eviction of the oldest (A, seq 0). + // Map now holds B, C, D (seqs 1, 2, 3). await cache.getOrPopulate({ jobId: '42.1', query: makeQuery('D'), @@ -289,7 +291,8 @@ module(basename(__filename), function () { }); assert.strictEqual(cache.size(), 3, 'still at cap after overflow'); - // Re-requesting A re-populates (cache miss); B and C remain hits. + // Re-requesting A misses (evicted); the new A insert (seq 4) + // pushes the now-oldest (B, seq 1) out. Map now holds C, D, A. let aCalls = 0; await cache.getOrPopulate({ jobId: '42.1', @@ -301,7 +304,28 @@ module(basename(__filename), function () { }, }); assert.strictEqual(aCalls, 1, 'A was re-populated (it was evicted)'); + assert.strictEqual(cache.size(), 3, 'still at cap after re-insert'); + + // D was inserted just before A and is the youngest survivor — + // verify it's still a hit. (Strict-FIFO: any of {C, D} could + // remain depending on cap math; pick the most-recently-inserted + // non-A entry so the assertion is stable under future cap + // changes.) + let dCalls = 0; + await cache.getOrPopulate({ + jobId: '42.1', + query: makeQuery('D'), + opts: undefined, + populate: async () => { + dCalls++; + return populate('D'); + }, + }); + assert.strictEqual(dCalls, 0, 'D was a cache hit (younger than B)'); + // B is the entry FIFO evicted by the A re-insert — verify it + // misses, confirming "oldest non-active entry leaves first" is + // what the cap enforces. let bCalls = 0; await cache.getOrPopulate({ jobId: '42.1', @@ -312,7 +336,7 @@ module(basename(__filename), function () { return populate('B'); }, }); - assert.strictEqual(bCalls, 0, 'B was a cache hit (not evicted)'); + assert.strictEqual(bCalls, 1, 'B was evicted by the A re-insert'); }); test('opts variance produces distinct entries', async function (assert) {