Stop resolving query-backed fields on side-loaded cards - #6097
Conversation
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
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Preview deploymentsHost Test Results 1 files ±0 1 suites ±0 2h 27m 53s ⏱️ - 6m 0s Results for commit f71afcf. ± Comparison against earlier commit f256a49. Realm Server Test Results 1 files ± 0 216 suites ±0 1h 15m 32s ⏱️ + 2m 40s Results for commit f71afcf. ± Comparison against earlier commit f256a49. |
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
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 removesCPU, 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/linksToManyfield stores the id of whatever it pointsat. Serving one is a read: the id is right there in the index row, and
loadLinksjust 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.
loadLinksis the routine that builds a card document'sincluded[]array. Itwalks 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/containsManyfield, with adefinition 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 theirtargets, 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 belowthem, 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
loadLinksresolves query-backed fields for the roots it was handed and not forthe closure it side-loads — the same confinement
linkFieldsalready 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.searchand nodataarray. That is thesame 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 anumbrella naming an empty set:
captureQueryFieldSeedDatain the host treatslinks.searchas the unambiguous signal that something authoritative resolvedthis 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 searchresource 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:
applySeedruns before the search, sothe resolved set was the field's value for the window in between, and a
template that reads the field directly rather than consulting
getRelationshipMembershipStatenow renders empty for that window instead ofshowing 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
resolveQueryFieldEagerlydefers 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 documentfall 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:
The render. The same six cards re-rendered five times each, on a stack
restarted fresh for each variant, reading
renderElapsedMsoff each card'sprerender-html visit diagnostics. Comparing per-card medians over the steady
passes, resolving the whole closure and resolving only the roots measure the
same:
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 arrivesinstead 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.searchmarker this pass wouldotherwise have written. Since
applyQueryResultsnever runs for it here, thechoke 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.
loadLinkstherefore reports the signal for a cross-realm resource that arrives already
resolved.
Tests
packages/realm-server/tests/root-only-query-fields-test.tsbuilds a parentcard 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.searchand nodataand no per-member sub-entries — the unresolved shape — while therequested 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
skipQueryBackedExpansionnarrows what a resolved field expands ratherthan 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 fieldis unresolved there too.
packages/host/tests/integration/components/nested-query-field-render-test.gtsrenders 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 childdirectly 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.tsand the realm search endpoint tests were run locally.🤖 Generated with Claude Code
https://claude.ai/code/session_01BFWkhPh2UwKbnLPBVzcXL7