Skip to content

fix(sqlite): seek to the positional resume point instead of replaying the window - #75

Merged
andinux merged 4 commits into
mainfrom
fix/payload-chunks-resume-seek
Sep 23, 2026
Merged

andinux merged 4 commits into
mainfrom
fix/payload-chunks-resume-seek

Conversation

@andinux

@andinux andinux commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

The defect

The positional cursor on cloudsync_payload_chunks was designed to make each /check call 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_changes is a virtual table that string-builds its inner SQL from the constraints xBestIndex receives, so the clause it actually runs is:

WHERE db_version <= ? AND site_id != ? ORDER BY db_version, seq ASC

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:

two distinct parameters  ->  WHERE db_version <= ? AND site_id != ?
one parameter reused     ->  WHERE db_version <= ? AND db_version >= ? AND site_id != ?

The fix

State the lower bound explicitly alongside the untouched disjunction:

WHERE db_version<=? AND site_id<>? AND db_version>=? AND (db_version>? OR (db_version=? AND seq>=?))

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.

rows chunks chunk size drain, before drain, after speedup per chunk, before per chunk, after
3 000 94 256 KB 1 582 ms 100 ms 15.8x 16.8 ms 1.07 ms
6 000 188 256 KB 6 110 ms 210 ms 29.2x 32.5 ms 1.12 ms
200 000 194 5 MiB 186 058 ms 6 115 ms 30.4x 959.1 ms 31.5 ms

Per-chunk latency by decile of chunk index, same run:

before   2.2   5.2   9.4  14.8  14.0  18.8  20.0  24.2  29.2  31.9    last/first: 14.3x
after    1.04  1.00  1.29  1.04  1.39  1.12  1.01  1.00  1.00  1.02   last/first:  0.98x

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 and
shape 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.

per chunk, ms, by decile of chunk index
before   130  319  515  697  873  1055  1251  1396  1612  1781   last/first: 13.7x
after     34   29   30   30   30    29    34    32    30    35   last/first:  1.02x

Isolating the resume seek from the chunk encoding over the same 193 resume points:

before after
seek, total 182 920 ms 44 ms
seek, first -> last decile 156 ms -> 1 771 ms 0.24 ms -> 0.22 ms

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:

CHUNK_BENCH_ROWS=200000 CHUNK_BENCH_ROW_BYTES=5000 CHUNK_BENCH_TXNS=20000 \
CHUNK_BENCH_CHUNK_SIZE=5242880 CHUNK_BENCH_REPEATS=1 \
CHUNK_BENCH_DB=dist/chunk-bench-1gb.sqlite CHUNK_BENCH_KEEP=1 \
CHUNK_BENCH_EXT=<build>/cloudsync.dylib make chunk-bench

Not a PostgreSQL defect

src/postgresql/cloudsync_postgresql.c:1400-1407 writes the same disjunction with $3 in 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 OK
  • dist/review_regressions — 0 failures
  • dist/network_unit — all passed
  • make chunk-bench at 94 and 188 chunks, curve flat after the fix

Notes for reviewers

  • The benchmark is built by CI along with the other test binaries (the TEST_SRC wildcard picks up everything in test/), 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.
  • If you re-run it, make first. make dist/chunk_bench builds the test binary but not dist/cloudsync.dylib, which is what the benchmark load_extensions — measuring against a stale extension shows the fix doing nothing.
  • CHUNK_BENCH_TXNS=1 is the negative control: one db_version for the whole window, where no bound on db_version can narrow anything. Cost there is flat and high (~65 ms/chunk) both before and after, as it should be.
  • The existing comments at cloudsync_sqlite.c:1049 and :1342 claiming "O(1) seek per chunk" were aspirational; they are now accurate, so they are left alone.

🤖 Generated with Claude Code

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
andinux force-pushed the fix/payload-chunks-resume-seek branch from 9db6809 to 56b3763 Compare September 23, 2026 13:52
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
andinux force-pushed the fix/payload-chunks-resume-seek branch from 56b3763 to 5bac6e1 Compare September 23, 2026 14:02
andinux and others added 2 commits September 23, 2026 08:22
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
andinux merged commit b75146e into main Sep 23, 2026
38 checks passed
@andinux
andinux deleted the fix/payload-chunks-resume-seek branch September 23, 2026 16:05
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>
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