Skip to content

Read related records in the background when a query selects, filters, joins or sorts on a relationship - #3615

Merged
kriszyp merged 24 commits into
mainfrom
kris/async-relationship-select
Oct 28, 2026
Merged

kriszyp merged 24 commits into
mainfrom
kris/async-relationship-select

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 27, 2026 •

Copy link
Copy Markdown
Member
The author pulls a lever that moves crates away from a jammed trapdoor to robots fetching from the cellar in the background

⊙ Problem

Queries that select, filter on, join through or sort by a @relationship read each related record synchronously. When one is not in memory (after a restart, on a new worker, under memory pressure), the worker blocks until storage returns, once per related record, and every other request on that worker waits.

🤔 Worth doing for all four query paths, without a latency measurement?

I think so: select alone would leave filters, joins and sorts blocking, and warm reads cost the same. Dropping those three would remove about 250 lines.

💡 Solution

Relationship reads in queries now use the async-capable read that top-level rows use, so a related record that is not in memory is read in the background.

  • Select: a row is yielded only after its relationships load.
  • Filter: conditions like children.label=x, including AND/OR groups and multi-hop paths.
  • Join: a leading relationship condition.
  • Sort: sort keys on related attributes.

Synchronous iteration (spread, asArray) and record.<relation> in application code stay synchronous. Warm queries cost the same as before.

⚖️ Alternatives

  • Make record.<relation> async: breaks the documented synchronous accessor for application code.
  • One MaybePromise path instead of sync + async forms: sync iteration and the vector-index pushdown need a boolean immediately.
  • An async flag on the existing resolver: enough for select and sort, but filters and joins still need Promise-aware composition.
  • Load selected related records through loadLocalRecord: about 0.3 µs slower per warm related row, and it changes db-read analytics and last-modified accounting.

Follow-ups: synchronous relationship conditions in the vector-index pushdown (harper#3587); one read per step when a query filters, sorts and selects on one relationship (harper#3606).

🔧 Changes

🤔 Should a filter on a missing related record never match?

A child id that no longer resolves is skipped, so children.label != x does not match through it. Before, a to-many filter threw on it; a missing many-to-one never matched.

✅ Verification

Benchmark medians, 2000 parents, 30 runs each, three interleaved runs per side (ms)
Query This branch main
select child 5.44 / 5.24 / 5.40 5.27 / 5.21 / 5.75
select children 5.29 / 5.59 / 9.24 5.30 / 5.72 / 7.83
select parents (one-to-many) 11.74 / 12.20 / 11.62 11.96 / 11.88 / 12.82
filter child.name 4.72 / 4.70 / 4.72 4.74 / 4.63 / 5.03
filter children.name 4.31 / 4.79 / 4.27 4.12 / 4.19 / 4.31
sort child.name 3.45 / 5.46 / 5.50 5.37 / 5.35 / 5.89
sort children.name 4.59 / 4.73 / 4.59 4.52 / 4.53 / 4.66
leading join parents.score (154 rows) 0.25 / 0.25 / 0.26 0.24 / 0.24 / 0.24
plain filter 0.32 / 0.31 / 0.31 0.31 / 0.31 / 0.31
  • 32 relationship, select and sort unit suites: 952 passing on RocksDB, 484 on LMDB; transaction and derived-index suites 137 and 80; relationship integration suites 89/89.
  • PR QA: Do cold relationship queries keep one snapshot while related records change mid-query? — inconclusive @ 9c8e300 (no violation, cold == warm under concurrent writers; the oracle did not discriminate for the to-one filter or the leading join), qa-pr-harper-3615-f4d1e7b

🤖 Generated by Claude Opus 5.5 (Claude Code); posted via @kriszyp.

Release target: main — performance change to query execution; no customer has asked for it on v5.3

Related PRs: #2155 overlaps (async bodies of joinTo/joinFrom), #3221 overlaps (select projection handler and its Promise chaining), #3279 overlaps (relationship filters, preserveForSelect and the projector), #3316 overlaps (relationship read permissions must also cover loadEntries and the async join reads), #3456 overlaps (select projection handler), #3571 overlaps (SKIP handling and cancellation in sorted iteration), #3605 overlaps (per-row work in transformEntryForSelect), #3610 overlaps (new loaders return residency stubs as the synchronous resolvers do), #3619 overlaps (cross-database fix must reach loadEntries and the async join reads), 53 others independent
Complexity: complicated
Framing-Verdict: chosen-approach-sound (e83832442c09)

Deferred findings

Late Grok findings

3 surviving (highest major) — Grok reviewed d3f0201
  • major — resources/Table.ts:6902 — When an index provides the first sort key and sort.next exists, rows are grouped by comparing groupingValue !== lastGroupingValue. For a relationship path such as ['child','name'], getAttributeValue now returns a Promise whenever the related read misses the cache (`resources/Table.ts:10161-1
  • minor — resources/search.ts:1403 — The async joinTo reads related records with store.get(...) without passing the query's read transaction, and the same applies to joinFrom at resources/search.ts:1518-1519. Both then await between rows. A commit landing during that await can change a related row that has not been read yet. A
  • minor — resources/Table.ts:10150 — sort: { attribute: 'child' } sorts on the relationship name itself rather than a path into it. That case still calls the resolver's synchronous form (getSync/getEntry), so each cold related row blocks the worker during the sort. This contradicts the invariant this PR adds at `resources/DESIGN.

Not standing: none. Grok ran 19m (19m from launch to report); the fold adjudication (cursor-grok@d3f0201b94c2-767c23e1, claude) took 1m.

Late Grok findings

5 surviving (highest minor) — Grok reviewed 24c6106
  • minor — resources/Table.ts:10256 — Relationship reads that are already in flight continue after the query is returned or its read snapshot is released. The nested-hop continuation at 10260 starts the next hop's read, loadRelatedEntry (10223-10229) has no lifetime check, and loadRemainingRelatedEntries checks the snapshot only at
  • nit — resources/search.ts:1530 — The scan's upstream iterator.return() is not awaited: the scan settles before cleanup finishes. The async joinTo return() (1443-1446) only sets scan.closed, so the upstream iterator is closed only after a pending next() settles. The async joinTo iterator has no throw(). Scope: in-scope
  • nit — resources/search.ts:1538 — In someRelatedMatch, a toMany related predicate gets match(value) with no entry argument. The sync sibling at 1872 (subObject.some(nextFilter)) passes the array index instead, so main already behaves this way. Scope: pre-existing — Convergence-Class: pre-existing
  • nit — unitTests/resources/relationshipAsyncReads.test.js:385 — No test covers return() while a scan or sort collection is pending, a related read or batch in flight at teardown, or the sync joinTo path. These are cancellation-path gaps, not security branches. Scope: in-scope — Convergence-Class: previously-adjudicated
  • nit — unitTests/resources/relationshipAsyncReads.test.js:76 — Remaining comment narration: :76 restates the depth guard beside it. resources/Table.ts:10183 and :68 are borderline. Most of the earlier-listed comments are gone. The ones left (Table.ts:6841-6843, 6983, 10198-10199, 10214; search.ts:1479, 1498, 1531) explain constraints and sho

Not standing: none. Grok ran 14m (14m from launch to report); the fold adjudication (cursor-grok@24c6106f59a3-d268f6fb, claude) took 2m.

Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; blocked=claude(fallback)(out-of-budget); declined=muse; rounds=13; full=5 @ fd30606

Review-Attention: deep ~85m (critical: Table.ts, search.ts; decisions: batch-vs-window-related-reads, per-row-cap-not-global, direct-store-related-reads, released-snapshot-is-503, do-less-alternative) @ fd30606

kriszyp and others added 12 commits October 26, 2026 11:42
A query's select of a @relationship resolved related records through the
relationship accessor, which reads with getSync/getEntry so that
record.<relation> stays synchronous. On RocksDB that blocks the worker on
every related record that misses the in-memory caches, once per related id.

Relationship resolvers now expose resolveKeys (the FK value, or the ids from
the `to` index scan) and a toMany flag. The select path passes those keys to
the target table's select transform, which loads each record through
loadLocalRecord: async under async iteration, so a cold related read happens
in the background and the projection waits on its Promise. filterMissing is
applied after loading, and the sorted nested-select iterator now drops ids
that loaded as missing. The record.<relation> accessor and the filter/sort
paths are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rts on a relationship

Filters, leading joins and sort keys over a @relationship read related
records synchronously (resolver returnEntry, store.getSync), blocking the
worker on every related record that misses the in-memory caches.

- Relationship resolvers gain loadEntries: the returnEntry result read with
  async: true, settling to a Promise when a read goes to storage.
- A relationship filter from filterByType keeps its synchronous form and
  adds a maybeAsync variant built on loadEntries, including the filtered
  select (fromRecord). AND/OR groups and multi-hop paths chain through it,
  and transformToEntries applies filters through it. The HNSW pushdown and
  join-local filters keep the synchronous form.
- joinTo/joinFrom iterate the related search asynchronously under async
  iteration and read related records with store.get.
- getAttributeValue resolves relationship hops through loadEntries, and the
  post-ordering iterator awaits sort keys.
- utility/when.ts gains someWhen/everyWhen for MaybePromise predicates.

The relationship tests move to relationshipAsyncReads.test.js and count the
related table's blocking vs Promise-returning reads for select, filter,
join and sort; the DESIGN.md bullet is rewritten and re-nested below the
accessor's known-gap note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nship reads cheap

Pre-push review fixes for the async relationship reads:

- A filtered to-many select whose related keys arrive as a Promise pushed
  the child projection after Promise.all had read the row's promises, so
  the row could be yielded without the relationship. handleResolvedValue
  now returns its Promise so the continuation waits on it.
- fromRecord no longer calls .map on a null foreign key.
- The index-ordered grouping value is awaited when it is a relationship
  path, so ties keep the next sort key's order; a sort on the
  relationship attribute itself also reads through loadEntries.
- Async joins read related records with the query's read transaction,
  return the related iterator when a scan fails, stop scanning once the
  join iterator is returned, and answer next() synchronously after the
  scan instead of allocating a Promise per joined id.
- Warm paths avoid per-row allocations: a single-key load for many-to-one
  relationships, closure-free filter and related-record matching (which
  replaces the someWhen/everyWhen helpers), filter matchers resolved once
  per query, and no await when a sort key is already settled.

Simulated cold reads in the tests now settle on a later event-loop turn,
as real cold reads do, and records are snapshotted as they are yielded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d joins cleanly

Round-2 pre-push review fixes:

- transformToEntries keeps its original synchronous filter loop unless a
  filter carries a maybeAsync variant, so queries without relationship
  conditions pay nothing extra per row.
- A sort on the relationship attribute itself reuses one path array per
  resolver instead of allocating one per row.
- A join scan stopped by return() yields nothing instead of its partial
  mappings or ids.
- resources/DESIGN.md: the synchronous filter form stays for the
  custom-index pushdown and the synchronous join bodies only.
- Comment fixes: the matchesFilters doc sits on its function again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round-3 pre-push review fixes:

- A join iterator answers next() with done once returned, its return()
  always returns an iterator result, and scanRelated stops as soon as a
  pending read or filter settles after return().
- Related-record loads read nothing through a finished read transaction,
  as loadLocalRecord already does.
- A one-to-many loadEntries creates a continuation only when its keys
  arrive as a Promise.
- The test teardown tolerates a setup that threw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round-4 pre-push review fixes:

- transformToEntries uses a relationship filter's maybeAsync variant only
  when the results are iterated asynchronously. A synchronous consumer
  (spread, asArray) got a Promise in place of an entry, which the select
  transform then read as an id.
- scanRelated stops once returned after an awaited iterator step, the
  joinFrom delivery callback yields done after return(), and a rejected
  upstream return() is ignored instead of left unhandled.
- A one-to-many resolveKeys reads nothing through a finished read
  transaction.
- Comment trims.

Adds relationshipWarmReads.bench.js, which times warm relationship
select, filter and sort queries over 2000 parents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…selects synchronous

Round-5 pre-push review fixes:

- The select path takes related entries from loadEntries (async-capable
  getEntry) and projects them as before, instead of handing raw keys to
  the target table's transform. Loading through loadLocalRecord measured
  about 0.3 us per warm related row slower and changed db-read analytics
  and last-modified accounting; this drops the keysOnly branch and the
  resolver.relationship field.
- Under synchronous iteration, the select path and a filtered select's
  fromRecord use the synchronous resolver, and transformToEntries never
  takes the maybeAsync filters for an eagerly mapped plain array.
- The sorted iterator stops once returned; joinTo's delivery callback and
  both join scans' related reads respect return() and a finished read
  transaction.
- relationshipWarmReads.bench.js adds to-many select, filter and sort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s when a step throws

Round-6 pre-push review fixes:

- Join scans stop when their read transaction is finished, not only when
  the join iterator is returned: request teardown calls onDone() and
  never returns the iterator.
- The sorted iterator checks for return() after every awaited entry and
  grouping value.
- Sort-key collection, related-entry loads and a filtered select's
  matching observe the reads still pending when a later step throws, so
  their rejections are not reported as unhandled.
- relationshipWarmReads.bench.js adds a one-to-many select and a leading
  one-to-many join.
- Comment trims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… is torn down

Round-7 pre-push review fixes:

- Join delivery (next(), the scan's completion callbacks) checks
  scanStopped, so a scan stopped by a finished read transaction yields
  nothing instead of its partial mappings or ids.
- The sorted iterator also stops on a finished read transaction, checks
  after the awaited dbIterator.next() and after an awaited projection.
- A row's select observes the related reads it already started when a
  later attribute throws.
- resources/DESIGN.md says what a stopped join or sort yields.
- Comment trims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kriszyp kriszyp added this to the v5.4 milestone Oct 27, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces asynchronous relationship reads to the database, allowing for non-blocking access to related records. The changes update core query logic in Table.ts and search.ts to support asynchronous iteration, filtering, and joining, while adding new test and benchmark suites. Feedback identifies a critical this binding issue in a callback, a potential TypeError when processing synchronous iterables, and the need for improved null-safety when loading related entries.

Comment thread resources/Table.ts Resolved
Comment thread resources/search.ts Resolved
Comment thread resources/Table.ts Resolved
kriszyp and others added 2 commits October 26, 2026 18:39
…/DESIGN.md

Keeps resources/DESIGN.md at main's length (the file's 1000-line budget is
already exceeded on main; harper#3430 prunes it). The accessor's known gap
now points at harper#3588.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review October 27, 2026 01:19
@kriszyp
kriszyp requested a review from kylebernhardy October 27, 2026 01:19
A custom index's search can return a plain Array, which has no
Symbol.asyncIterator; scanRelated now falls back to its synchronous
iterator, whose results it already handles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kriszyp and others added 2 commits October 26, 2026 19:52
loadRelatedEntries, loadRelatedEntry and the one-to-many resolveKeys
checked isDone on a handle fetched again through getReadTxn(), which is
never the finished one once doneReadTxn() has detached it, so the checks
could not fire. Join scans and the sorted iterator keep their checks,
which hold the query's own read transaction. Comment trims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ip-select

Picks up harper#3477's review-coverage grammar for the late= footer segment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-3615-prfix-framing-rebase
kriszyp and others added 4 commits October 26, 2026 21:12
…pshot

Request teardown (closeOwnedReadIterators, the long-transaction monitor)
releases a query's read snapshot through onDone() without returning its
iterator. The sort and join stop checks relied on readTxn.isDone, which
RocksDB handles never set, so on RocksDB an abandoned sort kept reading
related records and could still yield a row.

Both now check getReadTransactionGuard(readTxn) after each awaited read.
On a released RocksDB snapshot it throws the same ReadSnapshotExpiredError
(503), or the open-too-long error, that a range read over that snapshot
throws, matching the existing contract that a late consumer is told the
snapshot expired rather than seeing a scan end as if complete. LMDB keeps
the isDone check.

The sort teardown test fails without this change; the join test pins
behavior the related index range's own guard mostly provided already.
resources/DESIGN.md now describes the guard instead of claiming isDone
stops the scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… guard stops

Review fixes on the RocksDB teardown stop and related loading:

- Once one related read goes to storage, loadRelatedEntries reads the rest
  at most RELATED_READS_IN_FLIGHT (16) at a time, so a row with thousands
  of related ids cannot queue them all ahead of every other request's
  background reads. A new test pins the window with 40 cold related ids.
- When the read-snapshot guard throws in the sorted iterator, the native
  range iterator is closed before rethrowing, since a rejected next() is
  never followed by return().
- scanRelated's cleanup cannot replace the expired-snapshot error with a
  native close error, and joinFrom's throw() marks the scan closed.
- resources/DESIGN.md names the read window and the one synchronous path
  left under an async relationship filter (attributeComparator's adaptive
  id-set switch, harper#3625).
- Comment trims, including utility/when.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on any rejection

Round-11 review fixes:

- The related-read window counted the read already in flight separately,
  so it awaited that read alone before starting any other: a row with 2-16
  cold related ids paid two storage round trips instead of one. The first
  read now counts toward the window; the filterMissing test asserts all 3
  reads go out together, which fails on the previous commit.
- Later read batches check the read-snapshot guard, so a row loading after
  teardown fails with the named expired-snapshot error.
- The sorted iterator closes its upstream range iterator when next()
  rejects for any reason, not only when the snapshot guard throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve conflicts with "Fix REST and operations API select projection defects" (#3221):
- Table.ts: take main's form of the select continuation (handleResolvedValue returns its chain,
  the caller collects it), which covers the ordering case this branch handled inline.
- The FK relationship resolver's new "mark the request uncacheable" step now also runs on the
  async loadEntries path, through one shared markRelatedReadUncacheable helper.
- DESIGN.md: keep main's reflowed bullets and HTTP-caching note; the .to known gap points to
  harper#3588.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kriszyp and others added 2 commits October 27, 2026 10:59
Resolve the conflict with nested relationship constraints (#3279) in the .to resolver: keep the
shared searchRelated scan and main's lazy parameter.

#3279 routes a relationship select with nested conditions, sort, offset or limit through
Table._prepareRelationshipSelection, which read each related record with a blocking getEntry and
so undid this branch's async select for those queries (relationshipAsyncReads' sorted cases read
synchronously after the merge). Under async iteration that path now reads the related keys
(resolveKeys, or a filter's matched ids) and loads each candidate with async: true in the
candidate source, ahead of the yield point, so #3279's cancellation-at-first-yield guarantee holds.
The FK resolveKeys marks the request uncacheable, so every key-based read path does, and the FK
resolver exposes filterMissing for the selection to skip missing records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kriszyp
kriszyp merged commit 6e92cd2 into main Oct 28, 2026
98 of 101 checks passed
@kriszyp
kriszyp deleted the kris/async-relationship-select branch October 28, 2026 04:36
kriszyp added a commit that referenced this pull request Oct 28, 2026
v5.3 has neither #3221 (nested sort/offset/limit and `*` sub-selects)
nor #3615 (relationship selections), so the conflicted regions are
rebuilt from v5.3's code with only #3456's own delta applied:

- search plain JSON and @computed sub-selects project through the
  resolver-free nestedSelectTransform instead of the outer table's
  resolvers (previously TableResource.transformEntryForSelect)
- table propertyResolvers get a null prototype
- selector-keyed transform caches, `__proto__` read only as own data,
  function values project as absent, async arrays and forceNulls settle
- lone/mixed `*` handling in transformForSelect, so search `meta{*}`
  keeps returning the stored value after moving off transformEntryForSelect

Dropped the nested sort/limit test, which needs #3221. DESIGN.md keeps
v5.3's hard-wrapped conventions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xoBAAS5hcrcgN8zhS3jE7
Dispatch-Task: cherry-resolve-kriszyp_harper_3456-051c9c57
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.

1 participant