Every statement on the pipeline's DuckDB connection holds the shared … - #283
Merged
Merged
Conversation
turbolytics
marked this pull request as ready for review
September 14, 2026 00:51
…lock A window manager failed polls on a healthy pipeline with "Attempting to execute an unsuccessful or closed pending query result". Once on the Bluesky demo at batch 500, and seven times in ten minutes at batch 1 on bluesky.kafka.windowed.yml with the v1.2.0 image. Since #270 a failed poll stops the process, so on main this is a crash at a rate set by batch size. DuckDB closes a pending result the moment another statement runs on its connection. The pipeline, the managers, the progress store and the debug API share one connection and one mutex, and three parties ran statements without the mutex: - The handler reset. Init drops or truncates the batch table after every batch and once at loop start. Unlocked since the Go rewrite (#123), so the race shipped in v1.0.0. At batch 1 it runs once per message. - The progress write. recordProgress released the lock, then ran its UPDATE. Added in #259. - The sqlcommand sink. Its flush runs a drop, an ingest and the user's SQL, as the pipeline sink, a manager sink or the DLQ sink. Each now takes the lock. The sink gets it through sinks.WithConnLock, and sinks.New refuses to build a sqlcommand sink without it, so a new call site that forgets fails at startup instead of racing. No caller flushes a sink while holding the lock, so the sink taking it cannot deadlock. A race has no deterministic reproduction, so the tests check the invariant instead. duckdb.LockChecked wraps a connection and records any statement, commit or rollback that finds the lock free, with the frames that led there. Two tests run single-threaded under it, where a free lock can only mean the caller let go: - core: the consume loop with a handler that touches the connection and a real progress store. Before the fix it named the handler reset at both call sites and the progress write. - run: the real InferredMemBatch handler, sqlcommand sinks in all three roles, the progress store and a tumbling manager, built by the functions the run command calls. Before the fix it named 15 sink statements. Evidence: - Both tests fail on the old code and pass on the new. - Fixing the progress write alone still failed a poll after 2,900 messages on the batch-1 config. That run is how the handler reset was found. - With all three fixed, the same config ran 15,000 messages with no poll failure. bluesky.postgres.windowed.yml ran 12,000 messages with no poll failure and published closed minutes to Postgres through the locked manager sink. - go test -short -race ./... passes. What breaks if this is wrong: a statement still runs unlocked and a manager poll fails, which on main stops the pipeline. A sink that took the lock while its caller held it would deadlock the pipeline instead. Closes #280.
turbolytics
force-pushed
the
fix/progress-write-lock
branch
from
September 14, 2026 01:17
6d162e4 to
757672c
Compare
This was referenced Sep 14, 2026
turbolytics
added a commit
that referenced
this pull request
Sep 14, 2026
The bluesky demo's first deploy of v2026.09.14 exited with: error reinitializing handler: checkpoint after truncate: Cannot CHECKPOINT: there are other write transactions active StructuredBatch checkpoints after every batch to reclaim the rows its truncate left (#247). DuckDB refuses a checkpoint while another connection holds an uncommitted UPDATE or DDL; an uncommitted INSERT or DELETE does not refuse it. #281 moved the window to connections of its own. Its watermark save is an UPDATE, and the sqlcommand sink drops and creates its batch table. A batch that re-initialised while either was open failed, and a failed batch stops the process. #283's rule, that every statement on the pipeline's connection holds its lock, stopped reaching the window when it moved, and no invariant said so. Init now skips a refused checkpoint; the next batch's checkpoint reclaims what it left. Any other checkpoint error still fails the batch. Each skip logs at debug and counts in handler_checkpoints_skipped_total, so a write held open for longer than a batch shows as a rising count rather than as unexplained memory growth. The enforced invariant pipeline.batch.independent_of_window_io says a window's sink write or close, however long, never fails the consume loop. The manager harness proves it for manager.watermark. It holds the window's flush open and runs three batches through the structured handler, then holds a close after its delete and watermark update, before the commit, and runs three more. Against the release code the second hold fails batch 1 with the production error. With the fix both holds pass, and both closes land once released. TestStructuredInit_SkipsACheckpointAnotherWriterHolds holds an uncommitted UPDATE on a second connection for 50 batches. Init returns no error, and table storage grows to 4,422 KiB. One batch after the writer commits, storage is back to 0 KiB. TestCoreConsumeLoop_CountsSkippedCheckpoints checks the counter. If this is wrong, a windowed StructuredBatch pipeline still exits when a close coincides with a batch, or skips checkpoints forever and grows the way #247 did, with the counter rising.
turbolytics
added a commit
that referenced
this pull request
Sep 14, 2026
…#289) * StructuredBatch skips a checkpoint another connection's write refuses The bluesky demo's first deploy of v2026.09.14 exited with: error reinitializing handler: checkpoint after truncate: Cannot CHECKPOINT: there are other write transactions active StructuredBatch checkpoints after every batch to reclaim the rows its truncate left (#247). DuckDB refuses a checkpoint while another connection holds an uncommitted UPDATE or DDL; an uncommitted INSERT or DELETE does not refuse it. #281 moved the window to connections of its own. Its watermark save is an UPDATE, and the sqlcommand sink drops and creates its batch table. A batch that re-initialised while either was open failed, and a failed batch stops the process. #283's rule, that every statement on the pipeline's connection holds its lock, stopped reaching the window when it moved, and no invariant said so. Init now skips a refused checkpoint; the next batch's checkpoint reclaims what it left. Any other checkpoint error still fails the batch. Each skip logs at debug and counts in handler_checkpoints_skipped_total, so a write held open for longer than a batch shows as a rising count rather than as unexplained memory growth. The enforced invariant pipeline.batch.independent_of_window_io says a window's sink write or close, however long, never fails the consume loop. The manager harness proves it for manager.watermark. It holds the window's flush open and runs three batches through the structured handler, then holds a close after its delete and watermark update, before the commit, and runs three more. Against the release code the second hold fails batch 1 with the production error. With the fix both holds pass, and both closes land once released. TestStructuredInit_SkipsACheckpointAnotherWriterHolds holds an uncommitted UPDATE on a second connection for 50 batches. Init returns no error, and table storage grows to 4,422 KiB. One batch after the writer commits, storage is back to 0 KiB. TestCoreConsumeLoop_CountsSkippedCheckpoints checks the counter. If this is wrong, a windowed StructuredBatch pipeline still exits when a close coincides with a batch, or skips checkpoints forever and grows the way #247 did, with the counter rising. * initHandler keeps its deferred unlock; one helper opens the test database Review of #289: initHandler released the pipeline lock by hand so it could read CheckpointSkipped after Init. A panic inside Init would have left the lock held, and every later statement on the pipeline's connection would block on it. The defer is back, and the flag is read under the lock, where it is the one this Init set. TestStructuredInit_SkipsACheckpointAnotherWriterHolds repeated newADBCConn's libduckdb fallback to reach a second connection. openTestDatabase holds it once, and both use it. go test -short on core, handlers, managers and cli/run: pass.
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.
…lock
A window manager failed polls on a healthy pipeline with "Attempting to execute an unsuccessful or closed pending query result". Once on the Bluesky demo at batch 500, and seven times in ten minutes at batch 1 on bluesky.kafka.windowed.yml with the v1.2.0 image. Since #270 a failed poll stops the process, so on main this is a crash at a rate set by batch size.
DuckDB closes a pending result the moment another statement runs on its connection. The pipeline, the managers, the progress store and the debug API share one connection and one mutex, and three parties ran statements without the mutex:
Each now takes the lock. The sink gets it through sinks.WithConnLock, and sinks.New refuses to build a sqlcommand sink without it, so a new call site that forgets fails at startup instead of racing. No caller flushes a sink while holding the lock, so the sink taking it cannot deadlock.
A race has no deterministic reproduction, so the tests check the invariant instead. duckdb.LockChecked wraps a connection and records any statement, commit or rollback that finds the lock free, with the frames that led there. Two tests run single-threaded under it, where a free lock can only mean the caller let go:
Evidence:
What breaks if this is wrong: a statement still runs unlocked and a manager poll fails, which on main stops the pipeline. A sink that took the lock while its caller held it would deadlock the pipeline instead.
Closes #280.
What this changes
Verification
go test -short -race ./...uv run --locked pytest tests/tooling -qmake coverage-page && git status --short docs/coverageis cleanuv run --locked pytest tests/release -q— required when the CLIsurface, a flag, the Dockerfile, or anything stamped at build time
changed
make soak— required when the change touches Arrow, ADBC, DuckDB,cgo, the consume loop, a handler, a sink, the metrics recording path,
or anything that allocates per message or per request
Soak verdict
Notes for the reviewer