Cache _federated-search-prerendered live-search fallback symmetrically#4870
Conversation
Today the JobScopedSearchCache covers _federated-search only; the prerendered endpoint's `searchPrerenderedRealms()` path runs uncached, so any realm whose card templates use `PrerenderedSearchResource` (`host/app/resources/prerendered-search.ts`) bypasses the per-job dedup even during a worker-driven reindex. Extending the cache to `_federated-search-prerendered` brings the two endpoints into symmetry. Gating mirrors `handle-search.ts`: the cache is consulted only when both `x-boxel-job-id` AND `x-boxel-consuming-realm` are present and well-formed, i.e. the request was stamped by indexer-driven prerender traffic. User-facing API callers never carry both headers and continue to observe live SQL state. The prerendered handler's request shape carries `htmlFormat`, `cardUrls`, and `renderType` which materially change the response. These are passed through `opts` so the existing `sortKeysDeep`- canonicalised inner key segregates entries that differ on any of them — and so the shared cache instance also segregates entries from the two endpoints by key (no collision possible without identical parameters across both shapes). The cache instance is shared between the two handlers (`routes.ts:188` and `routes.ts:200`). `CachedEntry.result` is now typed `unknown` and `getOrPopulate<T>` is generic so the same store holds both `LinkableCollectionDocument` (federated-search) and `PrerenderedCardCollectionDocument` (prerendered) results. Also folds in observability: per-job hit/miss counters that emit a single `log.debug` line once a job's last entry leaves the cache — either via `clearJob` (worker signals completion) or via TTL eviction of the last surviving entry, whichever fires first. Off by default; enable via `LOG_LEVELS=job-scoped-search-cache=debug` to confirm cache utilisation on a specific batch. Host-side: the three prerender-context header helpers (`duringPrerenderHeaders`, `consumingRealmHeader`, `jobIdHeader`) moved from `store.ts` into a shared `host/app/lib/prerender-fetch-headers.ts` so the new `PrerenderedSearchResource.fetchPrerenderedCards` call can stamp them on outbound requests using the same code path as `store.search`. No behaviour change for live (non-prerender) SPA fetches — the globals are unset and the headers stay absent, so those calls keep bypassing the cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR extends the existing per-job JobScopedSearchCache so _federated-search-prerendered participates in the same job-scoped dedup behavior as _federated-search, and wires the necessary prerender-context headers from the host to the prerendered search endpoint.
Changes:
- Add cache gating + lookup to
handle-search-prerenderedusing the existingJobScopedSearchCacheinstance. - Generalize
JobScopedSearchCacheto store multiple result shapes and add per-job hit/miss debug stats logging. - Extract host-side prerender-context header helpers into a shared module and reuse them in
PrerenderedSearchResource. - Add realm-server endpoint tests covering caching behavior for
_federated-search-prerendered.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/realm-server/tests/server-endpoints/search-prerendered-test.ts | Adds tests validating cache hit/miss behavior and key segregation for prerendered search. |
| packages/realm-server/routes.ts | Passes the shared searchCache instance into handleSearchPrerendered. |
| packages/realm-server/job-scoped-search-cache.ts | Makes the cache generic across result types and adds per-job hit/miss instrumentation. |
| packages/realm-server/handlers/handle-search-prerendered.ts | Adds job-scoped cache gating (job id + consuming realm headers) and caches prerendered search results. |
| packages/host/app/services/store.ts | Replaces inline prerender header helpers with shared helper imports. |
| packages/host/app/resources/prerendered-search.ts | Adds prerender-context headers to _federated-search-prerendered requests. |
| packages/host/app/lib/prerender-fetch-headers.ts | New shared helper module for prerender-context request headers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ss-count Two findings from review: 1. `#stats` map could leak: miss-count was incremented before `await populate()`. If populate threw before any entry got stored, the jobId never acquired a `#byJob` map and the entry in `#stats` would persist forever (no TTL timer, no later eviction path to trigger cleanup). Fix: defer the `miss += 1` until after populate resolves. If populate throws we count nothing — observationally equivalent to "never happened" from the cache's perspective, with no leaked stats entry. 2. The "last entry for this job left the cache" stats log only fired on the TTL-eviction path. Cap eviction (the FIFO loop in `getOrPopulate` when `#fifo.size > #maxEntries`) could remove the last entry for a job without ever emitting the summary line. Same bug shape for `clearJob`. Fix: factor the flush into a private `#flushStats(jobId, reason)` helper and call it from all three paths (TTL eviction, cap eviction, clearJob), passing the eviction reason as a tag in the log line so operators can tell why the flush fired. Hit-count integrity is preserved: a hit implies a prior `populate()` resolved (no other code path stores entries), so the stats entry was allocated then. The hit-side `if (stat) stat.hits += 1` is defensive against hand-rolled tests that might pre-seed entries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Preview deploymentsHost Test Results 1 files 1 suites 1h 50m 8s ⏱️ Results for commit b257fa5. Realm Server Test Results 1 files ± 0 1 suites ±0 8m 35s ⏱️ + 1m 20s Results for commit b257fa5. ± Comparison against earlier commit 4bba392. |
Why
Today the
JobScopedSearchCachecovers_federated-searchonly; the prerendered endpoint'ssearchPrerenderedRealms()path runs uncached. That means any realm whose card templates usePrerenderedSearchResource(host/app/resources/prerendered-search.ts) bypasses the per-job dedup even during a worker-driven reindex. Extending the cache to_federated-search-prerenderedbrings the two endpoints into symmetry — same per-job-id boundary, same gating, same memory contract.Bench data on
ctse/ambitious-piranhashowed that its cards happen to usestore.search(which already hits the cache), so the cache was doing real work there (peaks ~53%, settles 40-50%). Other realms whose templates reach forPrerenderedSearchResourcewould not have benefited; this PR closes that gap.What this changes
Server side (
packages/realm-server/handlers/handle-search-prerendered.ts)Gating mirrors
handle-search.ts: the cache is consulted only when bothx-boxel-job-idANDx-boxel-consuming-realmare present and well-formed. Same sanitiser helpers (sanitizePrerenderJobId,sanitizeConsumingRealmHeader). User-facing API callers never carry both headers and continue to observe live SQL state.The prerendered handler's request shape carries
htmlFormat/cardUrls/renderType, which materially change the response. These are passed throughoptsso the existingsortKeysDeep-canonicalised inner key segregates entries that differ on any of them. The shared cache instance also segregates entries from the two endpoints by key — no collision possible without identical parameters across both shapes.Server side (
packages/realm-server/job-scoped-search-cache.ts)getOrPopulate<T>is now generic.CachedEntry.resultisunknown. The same instance storesLinkableCollectionDocument(federated-search) andPrerenderedCardCollectionDocument(prerendered) results.log.debugline when the job's last entry leaves the cache (either viaclearJobor via TTL eviction of the last surviving entry, whichever fires first). Off by default; enable viaLOG_LEVELS=job-scoped-search-cache=debugto confirm cache utilisation on a specific batch. The instrumentation costs are nil on the hot path — one Map lookup + integer increment per call — and the log emits once per job, not per call.Server side (
packages/realm-server/routes.ts)One-line wiring: pass the existing
searchCacheinstance (already constructed at line 118) intohandleSearchPrerendered()alongsidehandleSearch(). Single instance, two handlers.Host side (
packages/host/app/lib/prerender-fetch-headers.ts, NEW)Extracts the three prerender-context header helpers (
duringPrerenderHeaders,consumingRealmHeader,jobIdHeader) fromstore.tsso bothstore.searchandPrerenderedSearchResource.fetchPrerenderedCardscan stamp them on outbound requests using the same code path. No behavioural change for live (non-prerender) SPA fetches — the globals (__boxelDuringPrerender,__boxelConsumingRealm,__boxelJobId) are unset and the headers stay absent, so those calls keep bypassing the cache.Host side (
packages/host/app/resources/prerendered-search.ts)fetchPrerenderedCardsnow spreads the three header helpers onto its outboundQUERY /_federated-search-prerendered, mirroring whatstore.searchalready does for/_federated-search.Tests
packages/realm-server/tests/server-endpoints/search-prerendered-test.tsgets 321 lines of new coverage. Each test mirrors a guarantee:htmlFormat/cardUrls/renderType(same query, different htmlFormat → cache miss).(jobId, realms, cardsQuery)from_federated-searchand_federated-search-prerendereddon't collide because theiroptsshapes differ (the prerendered endpoint always passes the three extra keys).Tests were authored locally; CI is the verification pass.
Test plan
PrerenderedSearchResource-backed<Search>blocks shows thejob-scoped search cache statsline (enableLOG_LEVELS=job-scoped-search-cache=debugfirst) with a non-zero hit count.🤖 Generated with Claude Code