Skip to content

perf: route UPDATE range predicates through an index-seek scan (#666) - #672

Merged
iheitlager merged 2 commits into
mainfrom
fix/666-range-scan-seek-bound
Aug 30, 2026
Merged

perf: route UPDATE range predicates through an index-seek scan (#666)#672
iheitlager merged 2 commits into
mainfrom
fix/666-range-scan-seek-bound

Conversation

@iheitlager

Copy link
Copy Markdown
Member

Summary

  • read_indexed_range's SELECT fast path (range_scan.rs) already matches oracle's SeekGT+Next idiom with no per-row bound check — verified against real sqlite3 EXPLAIN (not EXPLAIN QUERY PLAN), confirming the issue's original premise didn't hold for SELECT.
  • The real gap was that UPDATE ... WHERE col >/>=/</<= lit/BETWEEN (a leading-indexed column) never used the index at all — full Rewind/Next scan + per-row compile_cond. Adds try_compile_range_row_seek (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; 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 (unlike TableCursor'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 x doesn'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 on delete_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 — clean
  • New unit tests: range_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

iheitlager and others added 2 commits August 30, 2026 14:24
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>
0.18.9 isn't tagged yet, matching #663's own fold-back (e01fccf) —
no reason to cut 0.18.10 for this fix.
@iheitlager
iheitlager force-pushed the fix/666-range-scan-seek-bound branch from 6e3a7ec to bb84ef3 Compare August 30, 2026 12:25
@iheitlager
iheitlager merged commit f70e92b into main Aug 30, 2026
6 checks passed
@iheitlager
iheitlager deleted the fix/666-range-scan-seek-bound branch August 30, 2026 12:29
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