fix(sqlite): seek to the positional resume point instead of replaying the window - #75
Merged
Merged
Conversation
The positional cursor on cloudsync_payload_chunks stated its resume lower bound only inside (db_version>? OR (db_version=? AND seq>=?)). The two arms carry distinct parameters, so SQLite derives no range from the disjunction and cloudsync_changes' xBestIndex was offered an upper bound and a site filter but no lower bound. Every call re-read the window from the start, evaluating cloudsync_col_value() on each discarded row, which made a full drain quadratic in the number of chunks. State db_version>=? explicitly alongside the disjunction. The term is logically implied, so the same rows are selected; it exists so the constraint loop emits a lower bound into the generated inner SQL and the (db_version) index can seek. Draining a 188-chunk window locally: 6110ms -> 213ms, with per-chunk cost now constant in the window size rather than proportional to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
andinux
force-pushed
the
fix/payload-chunks-resume-seek
branch
from
September 23, 2026 13:52
9db6809 to
56b3763
Compare
make chunk-bench times a real positional drain per chunk index, then replays the same resume points straight at cloudsync_changes in three SQL shapes and prints the idxStr each one produces, so whether a lower bound reaches xBestIndex is visible rather than inferred. It asserts every shape selects the identical row at each resume point. CI builds it along with the other test binaries, via the wildcard in TEST_SRC, but never runs it: the timings are machine-dependent and the shape of the curve, not the absolute numbers, is the result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
andinux
force-pushed
the
fix/payload-chunks-resume-seek
branch
from
September 23, 2026 14:02
56b3763 to
5bac6e1
Compare
A window the size of the one in the stall incident (~1 GB, 5 MiB chunks) costs 21s to seed and minutes to drain unfixed, so seeding it per run and per build is not workable. Add CHUNK_BENCH_DB/KEEP/REUSE so one seeded database can be measured by two builds of the extension in turn, CHUNK_BENCH_EXT to pick the build, and CHUNK_BENCH_PHASE2=0 to skip Phase 2, which is itself quadratic and dominates at that size. Flags now parse through env_flag. env_int treats 0 as unset, so that a stray empty value cannot ask for zero rows, which silently made CHUNK_BENCH_PHASE2=0 a no-op. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rain Review findings on this PR: drain_positional overwrote the step result with SQLITE_OK after the loop, so a failed resume returned success with fewer chunks -- and a truncated drain is indistinguishable from a fast one, which is the exact number this benchmark exists to produce. Report it and propagate. SQLITE_DONE counts as a failure too: the loop only runs while the previous chunk said it was not final, so the stream still owes a chunk. Pre-existing, from when the file was added. The benchmark called the unfixed spelling "current" and the fixed one "proposed", which inverts once this PR lands. They are now "old" and "fixed", so Phase 2 reads as the regression check it becomes after merge. Drop the pointer to docs/internal/payload-chunks-resume-scan.md from the comment in payload_chunks_filter(): that note is not committed, so a reader of the public source cannot follow it. The comment now carries the reasoning. Cite the function rather than line numbers, which had already gone stale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
andinux
added a commit
that referenced
this pull request
Sep 24, 2026
Preparation is unbounded. #75 removed the quadratic term, so a drain is now linear in the stream, but linear still means a large enough tenant cannot finish inside the job deadline -- and because nothing is persisted until the loop reaches is_final, every attempt restarts from chunk 0 and keeps no progress. max_window_bytes ends the scan once that many payload bytes have been emitted, reporting an ordinary complete stream over a smaller window: is_final with the watermark lowered to the last db_version emitted. The caller checkpoints there and asks again, so preparation is bounded with no new resumable state and no protocol change. Unset (the default) is byte-identical to today. Two conditions are load-bearing. A window may not end mid-value or mid-db_version: the receive cursor must land on a complete db_version or the next request skips the unapplied remainder, since it resumes with db_version > since and no seq. And because that boundary test is what stops the scan, a db_version larger than the whole budget is still emitted in full, so a window can never come out empty and stall the drain. window_capped is an output column, so it does not move the hidden columns a table-valued call binds positionally; max_window_bytes is declared last, taking argument 8 and leaving 1..7 as they were. Also: an explicit NULL for resume_db_version now means "not given", as it already did for site_id. Callers must pass NULLs to reach a later argument, and reading one as a resume point of 0 silently restarted the scan at the start of the window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
The defect
The positional cursor on
cloudsync_payload_chunkswas designed to make each/checkcall an O(1) seek to where the previous call stopped. It does not do that today.The resume lower bound is stated only inside the disjunction
(db_version>? OR (db_version=? AND seq>=?)). SQLite will derive a range term from a disjunction like this — but only when it can see both arms compare against the same value. The two arms here carry distinct anonymous parameters, and SQLite does not assume two parameters hold the same value, so it derives nothing.cloudsync_changesis a virtual table that string-builds its inner SQL from the constraintsxBestIndexreceives, so the clause it actually runs is:An upper bound and a site filter, and nothing saying where to start. Every call re-reads the window from the beginning and discards rows until it reaches the resume point — and each discarded row costs nearly as much as an emitted one, because the generated subquery evaluates
cloudsync_col_value()plus two joins per row. A full drain is therefore quadratic in the number of chunks.In production this took one tenant's change export to ~25 minutes, and before a server-side deadline was raised it never completed at all.
The distinction is invisible in the SQL text, which is why this survived review:
The fix
State the lower bound explicitly alongside the untouched disjunction:
The term is logically implied by the disjunction, so it selects exactly the same rows. It exists only so the constraint loop emits a lower bound into the generated inner SQL and the
(db_version)index can seek.Reusing one parameter across both arms would work identically. I chose the explicit conjunct because it states the bound the code depends on, rather than relying on a planner inference that a later edit could silently undo by splitting the parameter again.
Measurements
make chunk-bench(added here) reproduces the defect and measures the fix locally — no network, no server.Per-chunk latency by decile of chunk index, same run:
There is no single speedup multiplier here, and that is the point: the multiplier is proportional to the window size. Doubling the chunks doubled it (15.8x -> 29.2x). Growth exponents: before 3.86x for 2x the chunks (~N^1.95), after 2.09x (~N^1.06). The invariant is the last column — per-chunk cost stops depending on the window at all. O(N^2) -> O(N).
The benchmark also asserts that all three SQL shapes select the identical row at every resume point (0 differences across 93 points), which is the correctness claim the fix rests on.
At a realistic window size
The third row is a ~1 GB window at the 5 MiB default
payload_max_chunk_size, which is the size andshape a large tenant actually produces: 200 000 rows of 5 KB across 20 000
db_versions, 194 chunks,1 008 637 552 payload bytes. One seeded database (21 s), measured by two builds of the extension in
turn, each running alone on an idle machine.
Isolating the resume seek from the chunk encoding over the same 193 resume points:
Those two numbers decompose the drain exactly, which is the check that the measurement is sound:
replay alone is 183 s, chunk encoding (untouched by this fix) is 6.1 s, and 183 + 6.1 = 189 s against
a measured 186 s — 1.6% apart. Nothing else in the call scales with position.
Reproduce with:
Not a PostgreSQL defect
src/postgresql/cloudsync_postgresql.c:1400-1407writes the same disjunction with$3in both arms, so the parameter-identity trap does not apply, and PostgreSQL's planner handles OR'd range bounds natively rather than through a vtab constraint pipeline. No change there.Testing
dist/unit— 156 tests, all OKdist/review_regressions— 0 failuresdist/network_unit— all passedmake chunk-benchat 94 and 188 chunks, curve flat after the fixNotes for reviewers
TEST_SRCwildcard picks up everything intest/), but never run there: timings are machine-dependent, and the shape of the curve rather than the absolute numbers is the result. Being compiled on every target is deliberate — it is what keeps it from rotting on musl and MinGW.makefirst.make dist/chunk_benchbuilds the test binary but notdist/cloudsync.dylib, which is what the benchmarkload_extensions — measuring against a stale extension shows the fix doing nothing.CHUNK_BENCH_TXNS=1is the negative control: onedb_versionfor the whole window, where no bound ondb_versioncan narrow anything. Cost there is flat and high (~65 ms/chunk) both before and after, as it should be.cloudsync_sqlite.c:1049and:1342claiming "O(1) seek per chunk" were aspirational; they are now accurate, so they are left alone.🤖 Generated with Claude Code