Skip to content

Stop resolving query-backed fields on side-loaded cards - #6097

Merged
habdelra merged 4 commits into
mainfrom
cs-12951-realm-server-stop-evaluating-query-fields-on-side-loaded
Sep 13, 2026
Merged

Stop resolving query-backed fields on side-loaded cards#6097
habdelra merged 4 commits into
mainfrom
cs-12951-realm-server-stop-evaluating-query-fields-on-side-loaded

Conversation

@habdelra

@habdelra habdelra commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

A card document side-loads a whole closure of linked cards so a link has
something to render. Every one of those side-loaded cards used to resolve its
own query-backed fields, and each such field costs a walk of the card's entire
field tree plus a live search. This stops that: query-backed fields are
resolved for the cards a request names and not for the closure around them.

On one card in a local workspace that takes the document from 59 query-backed
relationship entries to 1
— the one on the card that was actually requested —
with included[] the same size and the rendered result unchanged. It removes
CPU, not bytes.

The rest of this explains what a query-backed field is, what a skipped one
carries instead, and what the two consumers of that answer do now.

What a query-backed field is, and why it is expensive

An ordinary linksTo / linksToMany field stores the id of whatever it points
at. Serving one is a read: the id is right there in the index row, and
loadLinks just has to go fetch the row it names.

A query-backed link field stores no target at all. Its value is declared as
a query — "every task assigned to me" — and what the field holds
is whatever that query returns at the moment somebody asks. Nothing about it can
come out of the index row, because the answer is not a property of this card:
it changes when some other card is written. So serving one means actually
running the query.

loadLinks is the routine that builds a card document's included[] array. It
walks the card's links breadth-first, a layer at a time: resolve this card's
relationships, collect the cards they name, expand those, and so on down to the
depth limit. At the top of every layer it resolves query-backed fields, and
doing that for one resource means walking that card's entire field tree —
descending through every non-primitive contains / containsMany field, with a
definition lookup at each step — and running an independent search for every
query-backed link field the walk finds.

Two things compound that. The results get written back as relationships carrying
a links.self, so the next layer of the walk follows them and expands their
targets, which then resolve their own query fields — a cascade. And the
mechanism that lets a caller narrow which relationships get followed
(linkFields) is applied to the walk's roots only and explicitly dropped below
them, so a side-loaded card gets no narrowing at all.

The shape of the cost

That card's document carries 101 resources. Across the closure there are 59
query-backed relationship entries, exactly one of which is on the card that was
requested — every other one is resolved on behalf of a card that is only
present so a link can be rendered.

What this changes

loadLinks resolves query-backed fields for the roots it was handed and not for
the closure it side-loads — the same confinement linkFields already has.

What a skipped field carries. Nothing extra is invented for it: the
relationship is left exactly the way the pristine index row carries it, which
means no umbrella entry, so no links.search and no data array. That is the
same shape a search already ships when a caller asks it to skip the assembly
pass entirely (omitIncluded). It matters that this is absence rather than an
umbrella naming an empty set: captureQueryFieldSeedData in the host treats
links.search as the unambiguous signal that something authoritative resolved
this field, and the deserializer's seed logic reads a resolved-and-empty seed as
"this field has no members" while reading an absent one as "nobody answered this
— go ask". Handing a consumer an empty umbrella would make the field render
blank; handing it nothing makes the field resolve itself.

Who reads a side-loaded card's resolved field. Nobody who needs it. A live
consumer re-runs the query for itself regardless of what seed the document
handed it — a query field's search resource is created live whenever it is not
inside a render context (ensureQueryFieldSearchResource), and a live search
resource re-queries so it picks up concurrent writes. What the walk resolved
for a side-loaded card in a live read is therefore superseded as soon as that
query lands. It is not worth nothing: applySeed runs before the search, so
the resolved set was the field's value for the window in between, and a
template that reads the field directly rather than consulting
getRelationshipMembershipState now renders empty for that window instead of
showing a first paint. That is the one cost on the live path, and it buys the
58-in-59 reduction above.

A render is the consumer that does read a document's answer, and it is not
short of anything either. Inside a render context resolveQueryFieldEagerly
defers to the field getter, so a query field resolves only when a template
actually reads it, and the store loads whatever that resolution names. A render
therefore pays for the fields it displays. The walk paid for every field in the
closure, which is the larger of the two by a wide margin — and measurably so,
below.

Effect

Measured against a workspace of real cards on a local stack.

The live read. Eleven report cards, each read cold as
application/vnd.card+json. Query-backed relationship entries per document
fall from 59 to 1 — the one on the card that was asked for — while included[]
stays the same size, because every card those queries reach is also reachable
over an ordinary stored link:

per document query-backed entries resources in the document
resolving the whole closure 59 (1 on the requested card, 58 on side-loaded ones) 101
resolving the roots 1 101

The render. The same six cards re-rendered five times each, on a stack
restarted fresh for each variant, reading renderElapsedMs off each card's
prerender-html visit diagnostics. Comparing per-card medians over the steady
passes, resolving the whole closure and resolving only the roots measure the
same:

card resolving the whole closure resolving the roots delta
1 879 ms 899 ms +20 ms
2 695 ms 704 ms +9 ms
3 793 ms 816 ms +23 ms
4 901 ms 867 ms −34 ms
5 703 ms 690 ms −13 ms
6 916 ms 900 ms −16 ms
mean paired delta −2 ms

Three cards each way, and a paired delta inside the run-to-run spread. So
confining the pass costs the render nothing on this workload, and the narrower
rule is one fewer case to hold in mind rather than a speedup.

What that measurement does not cover. A render resolves a query field only
when a template reads it, so a workspace whose side-loaded cards display
query-backed fields would trade the document's answer for a query per field
per card, issued inside the render. The cards measured above do not appear to
exercise that, so the parity result is silent on it rather than evidence
against it — an attempt to reproduce it on a synthetic realm did not render
query-backed fields at all, including on the card that was asked for, so it
produced no signal either way. Worth a look from anyone who knows a workspace
shaped that way. The mechanism that would absorb it if it bites is
skipQueryBackedExpansion, which is already the prerender's own flag.

A note worth stating so nobody reads the payload numbers as a failure: this
removes CPU, not bytes. In that workspace every card reachable over a
query-backed edge is also reachable over an ordinary stored link, so the closure
is the same size either way. Where that is not true — a card reachable only
through a side-loaded card's query — the card leaves included[] and arrives
instead through the query the consumer runs for itself.

Interaction with the card-document response cache

The response cache on card+json declines to retain a document whose assembly
resolved any query-backed field, because such a document is not a function of
the card's own index row. That signal narrows along with the walk: a card with
no query-backed fields of its own, whose closure happens to contain some, is
now retainable. That widening is the intended consequence — the document no
longer depends on those queries.

One case needs the signal raised by hand. A cross-realm link is served by its
own realm, where it is that request's root, so it arrives with its query-backed
fields already resolved and carrying the links.search marker this pass would
otherwise have written. Since applyQueryResults never runs for it here, the
choke point that reports the signal never fires, and the document would claim
not to be query-backed while carrying a peer's answer — retainable under a
validator that a write to the peer's matching cards does not move. loadLinks
therefore reports the signal for a cross-realm resource that arrives already
resolved.

Tests

packages/realm-server/tests/root-only-query-fields-test.ts builds a parent
card with its own query-backed field that statically links to a child with a
different one, and asserts on the count of query-field applications rather than
on timing: expanding the parent resolves exactly one, the requested card's. It
also asserts the side-loaded child's field arrives with no links.search and no
data and no per-member sub-entries — the unresolved shape — while the
requested card's field still carries both of its matches; that search results
are roots, so each result resolves its own fields and its closure does not; and
that skipQueryBackedExpansion narrows what a resolved field expands rather
than which cards resolve one — the requested card's field still names its
matches while none of them reach included[], and the side-loaded card's field
is unresolved there too.

packages/host/tests/integration/components/nested-query-field-render-test.gts
renders a parent card whose isolated template embeds a linked child that
displays its own query-backed field, and asserts both matching cards appear in
the rendered output — resolved by the client's own fetch, since the same test
confirms the parent's document carries no resolved umbrella for that field and
expands none of its matches into included[]. A third test reads the child
directly and confirms that, asked for by name, it resolves the field itself.

packages/realm-server/tests/load-links-batching-test.ts,
skip-query-backed-expansion-test.ts, prewarm-query-field-test.ts,
query-matches-filter-test.ts, card-document-cache-test.ts,
card-endpoints-test.ts, card-operations-core-test.ts, indexing-test.ts,
prerendering-test.ts and the realm search endpoint tests were run locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BFWkhPh2UwKbnLPBVzcXL7

habdelra and others added 3 commits September 12, 2026 20:08
A query-backed link field stores no target, so serving one runs a query.
The pass that resolves them ran over every resource each layer of the
link walk reached, and the relationships it wrote back were themselves
followed and expanded, whose targets resolved their own query fields in
turn. Nearly all of that work landed on cards present only as context for
rendering a link.

Confine the pass to the walk's roots, the way field restriction already
is. A side-loaded card's query-backed field is left the way the pristine
index row carries it — no umbrella, so no links.search and no data —
which reads as an unanswered field rather than as an answer of none, and
sends the consumer to its own query. A live consumer runs that query for
itself regardless of what the document says. A prerender, which reads
what it is handed, asks for the full walk with skipQueryBackedExpansion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFWkhPh2UwKbnLPBVzcXL7
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFWkhPh2UwKbnLPBVzcXL7
Resolving a query-backed field on a side-loaded card produces a seed no
consumer needs. A live one re-runs the query for itself regardless of what
the document says; a render resolves a query field only when a template
reads it, and loads whatever that resolution names, so it is not short of
anything either. Repeated prerenders of the same cards measure the same
with the pass confined and with it exempted for the render path, so the
narrower rule carries no cost and one fewer special case.

Step 1 now runs only for roots, where `linkFields` is already the caller's
own value, so the branch that stripped it below the root layer is dropped
with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFWkhPh2UwKbnLPBVzcXL7
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-13T02:13:05.592597Z f256a49 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±0      1 suites  ±0   2h 27m 53s ⏱️ - 6m 0s
4 746 tests +4  4 732 ✅ +4  14 💤 ±0  0 ❌ ±0 
4 761 runs  +4  4 747 ✅ +4  14 💤 ±0  0 ❌ ±0 

Results for commit f71afcf. ± Comparison against earlier commit f256a49.

Realm Server Test Results

    1 files  ± 0    216 suites  ±0   1h 15m 32s ⏱️ + 2m 40s
2 868 tests +31  2 868 ✅ +31  0 💤 ±0  0 ❌ ±0 
2 907 runs  +31  2 907 ✅ +31  0 💤 ±0  0 ❌ ±0 

Results for commit f71afcf. ± Comparison against earlier commit f256a49.

@habdelra
habdelra changed the base branch from cs-12950-realm-server-give-cardjson-get-the-response-cache-searches to main September 13, 2026 07:06
A cross-realm link is served by its own realm, where it is that request's
root, so it arrives with its query-backed fields resolved and carrying the
`links.search` marker. `applyQueryResults` never runs for it here, so the
choke point that reports the signal never fired and the document claimed
not to be query-backed while carrying a peer's answer — retainable in a
response cache under a validator that a write to the peer's matching cards
does not move. Report it where the resource arrives instead.

Assert the absence of a skipped field's relationship rather than its
length, since absent and empty are the two states the shape has to keep
apart, and cover the field resolving inside a render context, where the
document's answer is what a consumer would otherwise have read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFWkhPh2UwKbnLPBVzcXL7
@habdelra
habdelra requested a review from a team September 13, 2026 07:15
@habdelra habdelra changed the title Resolve a query-backed field for the cards a document was asked for Stop resolving query-backed fields on side-loaded cards Sep 13, 2026
@habdelra
habdelra merged commit 4c862c5 into main Sep 13, 2026
73 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.

2 participants