Repository navigation
Read related records in the background when a query selects, filters, joins or sorts on a relationship - #3615
Merged
Merged
Conversation
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>
Contributor
There was a problem hiding this comment.
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.
…/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>
…ip-select # Conflicts: # resources/Table.ts
kriszyp
marked this pull request as ready for review
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>
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
…ip-select # Conflicts: # resources/Table.ts
…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>
This was referenced Oct 27, 2026
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>
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
Queries that select, filter on, join through or sort by a
@relationshipread 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.
children.label=x, including AND/OR groups and multi-hop paths.Synchronous iteration (spread,
asArray) andrecord.<relation>in application code stay synchronous. Warm queries cost the same as before.⚖️ Alternatives
record.<relation>async: breaks the documented synchronous accessor for application code.loadLocalRecord: about 0.3 µs slower per warm related row, and it changesdb-readanalytics 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
resources/Table.ts: resolvers gainresolveKeys/loadEntriesfor many-to-one and array FKs, read throughloadRelatedEntries, which keeps at most 16 related reads in flight once one goes to storage; select takes entries fromloadEntriesand observes reads it started if a later attribute throws, and a select with nested conditions, sort or limit reads related keys, then each record withasync: true;transformToEntriesusesmaybeAsyncfilters only under async iteration; sort keys and grouping values are awaited, with relationship hops ingetPathValue. When request teardown releases the read snapshot mid-query, the sorted iterator checks the read-snapshot guard after each awaited read, so on RocksDB it fails with the same expired-snapshot 503 a range read over that snapshot gives, and it closes its range iterator whenevernext()rejects.resources/search.ts: relationship filters get amaybeAsyncvariant, and a filtered select'sfromRecordstays sync under sync iteration; groups combine throughmatchesFilters;joinToandjoinFromscan asynchronously throughscanRelatedwith the query's read transaction, stopping the same way on a released snapshot;mapSettlingOnThrowobserves pending matches.resources/DESIGN.md: the query-path rule beside the synchronous-accessor contract, the read window, teardown, and the one synchronous path left (harper#3625).utility/when.ts: a comment that restatedwhen()removed.🤔 Should a filter on a missing related record never match?
A child id that no longer resolves is skipped, so
children.label != xdoes not match through it. Before, a to-many filter threw on it; a missing many-to-one never matched.✅ Verification
unitTests/resources/relationshipAsyncReads.test.js: counts blocking vs Promise-returning related reads for select, filter, join and sort, with one real cold RocksDB read (flushed SST, cleared record cache); the rest simulate cold reads. It also covers synchronous iteration, nested-constraint selects, the related-read window and request teardown mid-query. Each case fails onmainor on an earlier commit of this branch.unitTests/resources/relationshipWarmReads.bench.js, everything cached: no difference frommainbeyond run-to-run noise, except a leading one-to-many join at about 65 ns per joined row.Benchmark medians, 2000 parents, 30 runs each, three interleaved runs per side (ms)
mainchildchildrenparents(one-to-many)child.namechildren.namechild.namechildren.nameparents.score(154 rows)selectandsortunit suites: 952 passing on RocksDB, 484 on LMDB; transaction and derived-index suites 137 and 80; relationship integration suites 89/89.🤖 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
df07c8ab76a9db41major performance —resources/search.ts— Custom-index pushdown evaluates relationship siblings synchronously, blocking per cold related read — filed Vector-index pushdown evaluates relationship conditions synchronously, blocking on cold related reads #3587bfdbc83f2d11f0cfmajor performance —resources/search.ts— Async relationship filter leaf still reaches blocking adaptive inverse-key collection — filed Relationship filter's adaptive id-set switch still reads related records synchronously #36258dac330643cbe8cfnit performance —resources/Table.ts— Filter, sort and select on the same relationship each read the related row — filed A query that filters, sorts and selects on one relationship reads each related record once per step #3606aaa911f43627c71cnit correctness —resources/DESIGN.md— .to accessor asArray can return a Promise on caching tables — filed One-to-many relationship property can yield Promises when the related table is a caching table #3588ef49aa00280e2e58nit performance —resources/search.ts— Async fromRecord discards loaded entries, so select re-reads matching related rows — filed A query that filters, sorts and selects on one relationship reads each related record once per step #3606b3ecc55fe7cf0358nit correctness —resources/search.ts— toMany related predicates are called without the entry argument — NOT FILEDLate Grok findings
3 surviving (highest major) — Grok reviewed d3f0201
resources/Table.ts:6902— When an index provides the first sort key andsort.nextexists, rows are grouped by comparinggroupingValue !== lastGroupingValue. For a relationship path such as['child','name'],getAttributeValuenow returns a Promise whenever the related read misses the cache (`resources/Table.ts:10161-1resources/search.ts:1403— The asyncjoinToreads related records withstore.get(...)without passing the query's read transaction, and the same applies tojoinFromatresources/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. Aresources/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
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, andloadRemainingRelatedEntrieschecks the snapshot only atresources/search.ts:1530— The scan's upstreamiterator.return()is not awaited: the scan settles before cleanup finishes. The asyncjoinToreturn()(1443-1446) only setsscan.closed, so the upstream iterator is closed only after a pendingnext()settles. The asyncjoinToiterator has nothrow(). Scope: in-scoperesources/search.ts:1538— InsomeRelatedMatch, a toMany related predicate getsmatch(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-existingunitTests/resources/relationshipAsyncReads.test.js:385— No test coversreturn()while a scan or sort collection is pending, a related read or batch in flight at teardown, or the syncjoinTopath. These are cancellation-path gaps, not security branches. Scope: in-scope — Convergence-Class: previously-adjudicatedunitTests/resources/relationshipAsyncReads.test.js:76— Remaining comment narration::76restates the depth guard beside it.resources/Table.ts:10183and:68are 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 shoNot 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