Repository navigation
Snapshot: sql and scan return a streaming reader; fix rows() stopping early - #109
Merged
Merged
Conversation
A snapshot's rows() streamed each log's table on the snapshot's shared DuckDB connection, and a streaming result there ends -- read_next_batch raises StopIteration, as at the true end -- the moment anything else runs a query on that connection. A snapshot queried while its rows were read delivered 1,000,000 of 1,500,000 rows and reported success. Each scan now takes a cursor from the connection and closes it with its reader. Catch-up was not affected: nothing else queries the snapshot it reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FsSDkeb5rVAxA1FSmKKfQi
Snapshot.sql/scan and Live.sql/scan built the whole result as one pa.Table, and offsets cannot page a result in its place: a GROUP BY or a join has no offset order. They now return a Reader -- await reader.read_all() is the table, and async for batch in reader is one pa.RecordBatch at a time (batch_size=, DuckDB's 1,000,000 by default). A reader runs its query once, when first read, on a DuckDB cursor of its own and in a worker thread, so another read of the snapshot cannot end it short and the event loop never waits on it. It is valid while its snapshot is open: a batch asked for after the snapshot closed raises rather than ending the result early, and rows() checks the same at every row. A Live reader keeps the view as of the call, and a rebase retires the old base -- closed once its last reader finishes -- rather than closing it under one. Two sql calls at once no longer share the snapshot's view `log`. Stream.sql and Stream.scan read whole and still return a pa.Table, since a reader would outlive their snapshot. BREAKING CHANGE: await snapshot.sql(...) / scan(...) and the same on a Live view return a Reader, not a pa.Table: add .read_all(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FsSDkeb5rVAxA1FSmKKfQi
As litelink's scan and sql return a reader, so do Stream.sql and Stream.scan: a streamcast Reader, not a pyarrow RecordBatchReader, whose reads would run DuckDB on the event loop. They are no longer async. The reader opens a snapshot of its own at its first read -- where a refusal is raised -- takes over the reader made on it, and retires the snapshot, which closes once the reader is done. One closed unread opens nothing. The snapshot benchmark, main against this branch alternately, shows no change beyond the run-to-run spread. BREAKING CHANGE: await Stream.sql(...) / Stream.scan(...) returned a pa.Table; they return a Reader: await Stream.sql(...).read_all(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FsSDkeb5rVAxA1FSmKKfQi
Merged
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.
Closes #108. There are three commits, so the release notes list the bug fix separately from the two breaking changes.
fix(log):rows()no longer stops early_published.Table.scannow streams on a cursor of its own (Owningcloses the cursor together with its reader). Before, it streamed on the snapshot's shared connection, so any other query on that snapshot ended the replay at the next batch boundary, androws()finished as if it had read everything.test_a_query_while_rows_are_read_does_not_end_themfails against the old code, cut off at 50 of 600 rows because the test uses small batches, and passes with the fix.feat(stream)!:sqlandscanreturn aReaderSnapshot.sql/scanandLive.sql/scan. They are now plain methods (notasync), andbatch_size=defaults to DuckDB's 1,000,000.Live._viewchanges them on the snapshot for each later query.streamcast_tail,streamcast_emptyand the viewlogare registered on the reader's own cursor, so twosqlcalls no longer overwrite each other'slog.rows()now checks this on every row. The snapshot counts its open readers and closes its connection when the last one finishes.Live:rebaseretires the old base instead of closing it, so a reader keeps reading it and the base closes when its last reader finishes. Since_viewand the swap inrebasecontain noawait, the asyncio lock had nothing left to protect, so I removed it.streamcast.Readeris now exported..read_all()everywhere a full table is wanted:_live.wait_for,examples/otel/analytics.py,benchmarks/snapshot.py, the tests, README.md, docs/API.md and theexamples/README.mdsnippet from docs(examples): query the demo's log with a snapshot, not litelink #107.Tests. Every test below fails when the behaviour it covers is broken, and passes with this branch:
test_two_readers_on_one_snapshot_both_finish_wholeandtest_read_all_is_the_batches_put_together.test_leaving_it_early_releases_its_cursorandtest_a_batch_after_the_snapshot_closed_raises.test_a_batch_after_the_snapshot_closed_raises.test_the_event_loop_runs_while_it_reads.rows()checking per log instead of per row:test_rows_after_the_snapshot_closed_raises.test_a_rebase_does_not_end_it.test_rows_received_while_it_is_read_are_not_in_it.test_catchup.py::test_the_reads_cross_into_a_threadnow inspectsSnapshot._shut_if_idle, which is where the connection is closed now.feat(stream)!:Stream.sql/Stream.scanreturn a reader tooThese now return a reader, as litelink's
scanandsqldo. It is astreamcast.Reader, not apyarrow.RecordBatchReader: a pyarrow reader is synchronous, so eachread_next_batch()would run DuckDB's work on the event loop.async: useawait Stream.sql(uri, q).read_all(), orasync for batch in Stream.scan(uri).SnapshotUnavailableis raised there rather than at the call. The snapshot closes once the reader is done.TestTheOneShotForms. The close test fails when the reader doesn't close its own snapshot.Benchmark
just bench-snapshot --rows 100000 1000000 --logs 1 4 16onmainand on this branch, both on this VM.mainitself moved as much as that between runs. It took 426 and 474 ms for the full 1M-row scan, and 64 and 90 ms for the group-by. The branch took 421 and 370 ms, and 87 and 75 ms, which is inside the same ranges.to_arrow_reader().read_all()andto_arrow_table()cost the same when measured on their own: 103 ms against 100 ms for a sort of 3M rows.So I see no regression.
Breaking:
await snapshot.sql(...), and the same onLiveandStream, must becomeawait ….read_all(). This is a minor version bump under 0.x. The CHANGELOG has Breaking and Fixed entries.🤖 Generated with Claude Code
https://claude.ai/code/session_01FsSDkeb5rVAxA1FSmKKfQi