perf: route UPDATE range predicates through an index-seek scan (#666) - #672
Merged
Conversation
read_indexed_range's SELECT fast path (range_scan.rs) already matched oracle's SeekGT+Next idiom with no per-row bound check. The real gap (confirmed against real sqlite3 EXPLAIN, not just EXPLAIN QUERY PLAN) was that UPDATE never used an index at all for `WHERE col >/>=/</<= lit`/BETWEEN predicates against a leading-indexed column, falling back to a full Rewind/Next scan with a per-row compile_cond filter. Adds try_compile_range_row_seek (range_scan.rs), a SELECT-agnostic variant of the existing forward-comparison/BETWEEN seek builders, and wires it into update.rs. Runs in two passes: pass 1 (read-only IdxNext walk) records matched rowids into an in-memory ephemeral table, then pass 2 replays them against the table cursor to do the actual update — necessary because the index cursor doing the range walk has no save/restore protection against the same scan's own index-maintenance writes mutating the very b-tree it's iterating (unlike TableCursor's snapshotted frames). update_filtered_range: ~148ms -> ~107ms (~28% faster, cargo bench --bench crud). DELETE's equivalent fast path was prototyped but reverted: for a highly-selective predicate on a small table, the required two-pass materialization (an unavoidable random-order re-seek per matched row, since this engine's cursors can't safely self-mutate mid-scan) cost more than the full-scan it replaced. Real sqlite3 avoids this because its cursors support save/restore across writes — a materially larger, separate change. Follow-up: #666 DELETE fast path blocked on cursor save/restore. spend: ~2x the issue's original "small" estimate — the premise (fold an existing per-row bound-check into the seek) didn't hold once checked against real sqlite3 EXPLAIN; the actual gap (UPDATE/DELETE never seeking at all) took full-fix-sized work for one of the two statements. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
iheitlager
force-pushed
the
fix/666-range-scan-seek-bound
branch
from
August 30, 2026 12:25
6e3a7ec to
bb84ef3
Compare
Closed
4 tasks
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.
Summary
read_indexed_range's SELECT fast path (range_scan.rs) already matches oracle'sSeekGT+Nextidiom with no per-row bound check — verified against realsqlite3EXPLAIN(notEXPLAIN QUERY PLAN), confirming the issue's original premise didn't hold for SELECT.UPDATE ... WHERE col >/>=/</<= lit/BETWEEN(a leading-indexed column) never used the index at all — fullRewind/Nextscan + per-rowcompile_cond. Addstry_compile_range_row_seek(a SELECT-agnostic variant of the existing forward-comparison/BETWEENseek builders) and wires it intoupdate.rs.IdxNextwalk) records matched rowids into an in-memory ephemeral table; pass 2 replays them against the table cursor to do the actual update. Necessary because the index cursor doing the range walk has no save/restore protection against the scan's own index-maintenance writes mutating the very b-tree it's iterating (unlikeTableCursor's snapshotted frames).update_filtered_range: ~148ms → ~107ms (~28% faster,cargo bench --bench crud).Scope note (re: #666's acceptance criteria)
DELETE's equivalent fast path was prototyped and made correct, but reverted: for a highly-selective predicate on a small table, the required two-pass materialization (an unavoidable random-order re-seek per matched row, since
xdoesn't correlate with rowid in the bench fixture and this engine's cursors can't safely self-mutate mid-scan) cost more than the full scan it replaced (~10% regression ondelete_filtered_range). Real sqlite3 avoids this because its btree cursors support save/restore across writes — a materially larger, separate change. Filed as a follow-up rather than shipping a regression.spend: ~2x the issue's original "small" estimate. The premise (fold an existing per-row bound-check into the seek) didn't hold once checked against real sqlite3
EXPLAIN; the actual gap (UPDATE/DELETE never seeking at all) took full-fix-sized work for one of the two statements.Test plan
cargo test— full suite passes (979+ lib tests, all integration suites)cargo clippy --all-targets— cleanrange_predicate_update_compiles_to_index_seek,range_predicate_update_touches_only_matching_rows,between_predicate_update_touches_only_matching_rows(tests/unit/codegen_update_test.rs)cargo bench --bench crud -- update_filtered_range— ~28% faster vs. pre-change baseline🤖 Generated with Claude Code