Skip to content

Cache _federated-search-prerendered live-search fallback symmetrically#4870

Merged
habdelra merged 2 commits into
mainfrom
symmetric-prerendered-search-cache
May 19, 2026
Merged

Cache _federated-search-prerendered live-search fallback symmetrically#4870
habdelra merged 2 commits into
mainfrom
symmetric-prerendered-search-cache

Conversation

@habdelra
Copy link
Copy Markdown
Contributor

Why

Today the JobScopedSearchCache covers _federated-search only; the prerendered endpoint's searchPrerenderedRealms() path runs uncached. That means 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 — same per-job-id boundary, same gating, same memory contract.

Bench data on ctse/ambitious-piranha showed that its cards happen to use store.search (which already hits the cache), so the cache was doing real work there (peaks ~53%, settles 40-50%). Other realms whose templates reach for PrerenderedSearchResource would 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 both x-boxel-job-id AND x-boxel-consuming-realm are 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 through opts so the existing sortKeysDeep-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.result is unknown. The same instance stores LinkableCollectionDocument (federated-search) and PrerenderedCardCollectionDocument (prerendered) results.
  • Adds per-job hit/miss counters that emit a single log.debug line when the job's last entry leaves the cache (either via clearJob 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. 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 searchCache instance (already constructed at line 118) into handleSearchPrerendered() alongside handleSearch(). 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) from store.ts so both store.search and PrerenderedSearchResource.fetchPrerenderedCards can 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)

fetchPrerenderedCards now spreads the three header helpers onto its outbound QUERY /_federated-search-prerendered, mirroring what store.search already does for /_federated-search.

Tests

packages/realm-server/tests/server-endpoints/search-prerendered-test.ts gets 321 lines of new coverage. Each test mirrors a guarantee:

  • Cache fires when both headers are present.
  • Cache bypassed when either header is missing.
  • Cache key segregates by htmlFormat / cardUrls / renderType (same query, different htmlFormat → cache miss).
  • Cross-endpoint coexistence: same (jobId, realms, cardsQuery) from _federated-search and _federated-search-prerendered don't collide because their opts shapes differ (the prerendered endpoint always passes the three extra keys).

Tests were authored locally; CI is the verification pass.

Test plan

  • CI green
  • Spot-check that a worker-driven reindex of a realm using PrerenderedSearchResource-backed <Search> blocks shows the job-scoped search cache stats line (enable LOG_LEVELS=job-scoped-search-cache=debug first) with a non-zero hit count.

🤖 Generated with Claude Code

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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-prerendered using the existing JobScopedSearchCache instance.
  • Generalize JobScopedSearchCache to 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.

Comment thread packages/realm-server/job-scoped-search-cache.ts Outdated
Comment thread packages/realm-server/job-scoped-search-cache.ts Outdated
…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>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 18, 2026

Preview deployments

Host Test Results

    1 files      1 suites   1h 50m 8s ⏱️
2 661 tests 2 646 ✅ 15 💤 0 ❌
2 680 runs  2 665 ✅ 15 💤 0 ❌

Results for commit b257fa5.

Realm Server Test Results

    1 files  ±  0      1 suites  ±0   8m 35s ⏱️ + 1m 20s
1 411 tests +268  1 411 ✅ +268  0 💤 ±0  0 ❌ ±0 
1 498 runs  +283  1 498 ✅ +283  0 💤 ±0  0 ❌ ±0 

Results for commit b257fa5. ± Comparison against earlier commit 4bba392.

@habdelra habdelra requested a review from a team May 18, 2026 23:13
@habdelra habdelra merged commit 17b6e19 into main May 19, 2026
74 of 75 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants