Build query result rows one row at a time - #992
Open
paracycle wants to merge 2 commits into
Open
Conversation
paracycle
force-pushed
the
uk-cypher-api-refactors
branch
2 times, most recently
from
August 6, 2026 21:46
69d6b71 to
460fcfa
Compare
`Rubydex::Query::Result#each` built every row before it yielded the first one. A caller that read one row still paid for all of them, twice: once as `CCell`s in C memory, and once as Ruby objects. The row iterator is now a cursor. `rdx_result_set_rows` builds only the column strings. `rdx_rows_iter_next` releases the cells of the previous row, then converts one row under the graph read lock. The lock is released before the function returns, so the caller may run any code between two calls. `each` now converts one row, yields it, and discards it, unless `rows` already built the whole array. `first` and `find` therefore stop as soon as the block breaks. `rows` keeps its behaviour: it collects every row through the same cursor, freezes the array, and reuses it. The walk also shares its Hash keys. It builds one frozen UTF-8 String per column, and every row of the walk uses those keys. `rb_hash_aset` stores a frozen String key as it is, instead of duplicating and freezing it, so a wide result no longer allocates a key String per cell. Allocations for 300 rows and 2 columns, counted with `GC.stat(:total_allocated_objects)`: | Call | Before | After | | --------------- | ------ | ----- | | `result.first` | 1516 | 19 | | `result.rows` | 1506 | 907 | Two contracts change: - A `CResultRow` from `rdx_rows_iter_next` stays valid only until the next call or until `rdx_rows_iter_free`, not until the free alone. The C extension copies every value into Ruby objects at once, so it complies. - The graph read lock is no longer held for the whole walk. A block that writes to the graph therefore changes how later rows resolve their node cells. A declaration that disappeared falls back to its display name. Holding the lock across `rb_yield` is not an option, because a block that calls `graph.index_source` would then deadlock the thread. The new tests cover the early exit, an `Enumerator` without a block, a `break` and a raising block, and the shared frozen keys.
Now that rows are built one at a time, the graph can change in the middle of a walk. `build_cell` handled that by falling back to a plain String when it could not resolve a node, which quietly turned a `Declaration` column into a `String` halfway through a result. I hit exactly that while testing the cursor: after a `delete_document` inside an `each` block, the remaining rows came back as `"Cat"` and `"Dog"` instead of handles, with no signal at all. So the fallback is gone. `build_cell` returns the node name as an error, `rdx_rows_iter_next` reports `MissingNode`, `rdx_rows_iter_error` names the node, and the extension raises `Rubydex::StaleQueryResultError`, which is a `Rubydex::QueryError`. The `List` and `Map` arms free the cells they already built before they propagate, so a half-built row leaks nothing. The check is narrow on purpose, and this is not mutation detection: - It only fires when the graph no longer holds a node that the result returned, or when a node id cannot be decoded. - It does not fire for a re-index that keeps the ids. A declaration id comes from the name, so the same names still resolve while the definitions and ancestors behind them may differ. - It says nothing about a handle that a walk already handed out. Those resolve against the graph on each call, exactly like the handles that `Graph#[]` returns. `render`, `columns`, `size`, and `empty?` keep working after the graph changes, since they read the executed result set and never touch the graph. I did not add a graph revision counter for this. It would need a change to the graph pointer in `graph_api.rs`, and it would fire on `load_config`, `set_encoding`, and completion, none of which can drop a node. The missing node is the condition that actually matters here.
paracycle
force-pushed
the
uk-lazy-query-rows
branch
from
August 6, 2026 21:55
05de426 to
dd9d491
Compare
Morriar
reviewed
Aug 7, 2026
| "MATCH (c:Class) WHERE c.name IN ['Animal', 'Dog'] RETURN c.name ORDER BY c.name", | ||
| ).run(graph) | ||
|
|
||
| enumerator = result.each |
Contributor
There was a problem hiding this comment.
Do we need to mark the block as optional here:
Lines 553 to 554 in dd9d491
st0012
approved these changes
Aug 7, 2026
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.
TL;DR
Rubydex::Query::Result#eachbuilt every row before it yielded the first one. It converts one row at a time now, sofirston a 300 row result went from 1516 allocations down to 19. Also, the walk shares one frozen key String per column instead of allocating a key per cell.This stacks on #991, so it is based on
uk-cypher-api-refactors. I recommend reviewing per commit.The problem
#eachcalled#rows, which built the whole array first. A caller that read one row still paid for all of them, twice: once asCCells in C memory, and once as Ruby objects.firstandfindwere the worst case, since they break after one row and then throw the other 299 away.What changed
The row iterator is a cursor now.
rdx_result_set_rowsbuilds only the column strings, andrdx_rows_iter_nextfrees the cells of the previous row and converts one row under the graph read lock.The lock is released before the call returns, so a block can run any code between two rows, including code that writes to the graph. Holding it across
rb_yieldis not an option, since a block that callsgraph.index_sourcewould deadlock the thread.rowskeeps its old behaviour and collects everything through the same cursor, soeachstill walks the memoized array once it exists.Allocations for 300 rows and 2 columns, from
GC.stat(:total_allocated_objects), measured against a real build of each tree:result.firstresult.rowsThe first row is the streaming win. The second is the shared keys: 600 key Strings disappear.
The stale node check
Since the graph can change mid-walk now, the second commit removes a fallback in
build_cellthat turned an unresolvable node into a plain String. I hit it while testing the cursor: after adelete_documentinside aneachblock, the remaining rows came back as"Cat"and"Dog"instead of handles, with no signal at all. A column changing type halfway through a result is worse than an exception.So
rdx_rows_iter_nextreportsMissingNodeand the extension raisesRubydex::StaleQueryResultError, which is aRubydex::QueryError. The message names the node.The check is narrow on purpose, and it is not mutation detection:
Graph#[].render,columns,size, andempty?keep working after a change, since they read the executed result set and never touch the graph.I did not add a graph revision counter for this. It would need a change to the graph pointer in
graph_api.rs, and it would fire onload_config,set_encoding, and completion, none of which can drop a node.Verification
bundle exec rake test: 356 Ruby runs, 1597 assertions, plus the Rust suite. 0 failures.cargo fmt --check,cargo clippy, andrubocopare all clean.Enumeratorwithout a block, abreakand a raising block, the shared frozen keys, and the four stale result cases.GC.verify_compaction_referencesandGC.compact, and then compares against an eagerrowscall. Everything matches, so nothing Ruby hands back points into the freed cells.Tophatting