Shared per-database DuckDB engine worker (kernel worker host + both extensions)#212
Draft
qsliu2017 wants to merge 6 commits into
Draft
Shared per-database DuckDB engine worker (kernel worker host + both extensions)#212qsliu2017 wants to merge 6 commits into
qsliu2017 wants to merge 6 commits into
Conversation
e1dfceb to
9824968
Compare
9020816 to
88aba08
Compare
88aba08 to
90fe8fe
Compare
Foundation for dispatching DuckDB queries from backends to a shared per-database duckdb worker: - SessionChannel: per-session control/result shm_mq rings in a fixed session-slot region, with a serialized (global-process-lock) worker transport and a demux that routes scan_id-prefixed scan replies into per-scan lanes plus one metadata lane, so concurrent scans and metadata RPCs share one channel safely. - SessionPool: fixed session slots in main shared memory (one per concurrent dispatched query) with an attach refcount and per-acquire generations (TryAttachEnd rejects stale queued sessions). - PagePool + arrow_codec: a fixed page pool and a direct PG-datum to Arrow record-batch producer/consumer for zero-copy heap-scan transport (int2/4/8, float4/8, bool, date, timestamp[tz], text, bytea, decimals; unsupported column types fail loudly). - ScanQueue + ScanRing: the block-range task queue and per-scan ready-page rings for the parallel scan-producer pool. - session_protocol: DataChunk/QueryResult/RelationDesc codecs and the DescribeRel / MetaQuery / MetaExec RPC round-trips (typed errors preserve the duckdb exception type across the boundary). - RelationDesc: a materialized relation descriptor (columns, types, block count, cardinality) built on the backend and shipped to the worker in place of a live Relation handle.
pgddb::DuckdbWorker is the generic per-database DuckDB background worker: one shared engine per database serving N sessions concurrently, one thread per session. An extension subclasses it with its identity and engine accessors (Configure/Prime/Database/ServeFrame), constructs one instance, and calls Init() from _PG_init; the bgworker entrypoints just call Main()/ScanWorkerMain(). - duckdb_worker.cpp: worker registry + on-demand spawn (dead workers are reclaimed and respawned), generation-validated pending sessions, the backend-side result-stream service loop (BackendSession: catalog RPC, scan fetches, extension frames), session threads, and cleanup: transaction and subtransaction abort callbacks plus process-exit hooks release open sessions (subtransaction-scoped, so a MetaExec serviced in a conflict-retried subtransaction survives), the worker drains session threads via before_shmem_exit, and the main loop interrupts the ClientContext of cancelled sessions. - Dispatch never falls back to in-process execution for capacity: a full session pool or pending ring makes OpenSession wait, cancellably (a per-backend engine per overflow query is exactly the memory growth the worker exists to bound). In-process execution happens only for statements the dispatch gate excludes by semantics. - scan_producer.cpp: the streaming heap-scan producer shared by the inversion path (InversionScanStream: backend produces per fetch, read-ahead window) and the scan-pool path (PoolScanStream: CTID block ranges under the shipped snapshot into per-scan ready rings). - Worker execution is PG-free: catalog binds go through the DescribeRel RPC (SchemaItems::GetTable), the transaction manager takes no snapshot in a worker session, order pushdown skips its index probe, and heap scans are inverted through pgddb_open_remote_scan_hook. Session state is keyed by ClientContext as well as thread-locals, since scans and nested queries run on DuckDB scheduler threads; nested connections can be aliased into the registry. - pgddb_node consults pgddb_dispatch_to_worker_hook and streams results from the worker through the existing tuple-conversion loop; DML reads its (Count) chunk into es_processed on both paths. DESIGN.md and GLOSSARY.md describe the architecture; the concurrency cores are modeled in specs/tla (a later commit).
pgduckdb::DuckdbWorker dispatches read-only heap-table SELECTs to the shared duckdb worker when duckdb.use_shared_worker is on (default off): the backend ships its snapshot and the deparsed query, services the worker's catalog RPCs and inverted heap scans, and streams result chunks back. The worker runs multi-threaded (its execution is PG-free) and reads heap tables via the Arrow page transport, either produced by the requesting backend or by the scan-producer pool (duckdb.scan_pool_size / duckdb.scan_producers; TPC-H Q1 over 4M rows scales ~3-4x over in-process at 4-8 producers). GUCs: duckdb.use_shared_worker, duckdb.max_worker_sessions (session- pool slots), duckdb.arrow_pool_pages / duckdb.arrow_page_size, duckdb.scan_pool_size, duckdb.scan_producers. regression.conf exercises the pool in CI. The shared_worker test verifies off-vs-on result parity (scan, filter, aggregate, join, bool/timestamp/temp-table coverage), loud failure on an Arrow-unsupported column, error surfacing, statement_timeout cancellation, and worker kill + on-demand respawn.
pgducklake::DuckdbWorker dispatches read-only SELECTs and autocommit DML (no transaction block, no RETURNING) to the shared duckdb worker when ducklake.use_shared_worker is on (default off). Worker execution is PG-free: DuckLake metadata reads ship back to the requesting backend as MetaQuery and commit writes as MetaExec (the ServeFrame override), running in the backend's transaction -- so a dispatched write's metadata commit is atomic with the statement, and a commit conflict surfaces as a TransactionException for DuckLake's retry. Hybrid heap scans invert to the backend; inlined-data reads run on DuckLake's per-transaction metadata connection, which is aliased into the worker session registry for exactly the transaction's lifetime (its heap reads must invert too, and a stale alias would misroute a later session reusing the freed context address). GUCs mirror pg_duckdb: ducklake.max_worker_sessions, arrow_pool_pages, arrow_page_size, scan_pool_size, scan_producers. 1.2.0 adds the superuser diagnostics ducklake.worker_ping/worker_eval/worker_stats (the last lets tests assert a query really dispatched). Tests: worker_ping/worker_eval, worker_dispatch (off-vs-on parity for scans/aggregates/joins, hybrid and temp-table joins, bool/timestamp columns, dispatched DML with read-back, dispatch-gate assertions), worker_hardening (error surfacing, cancellation, unsupported-type failure, worker kill + respawn), the worker_concurrent_writes isolation spec, and an e2e test driving concurrent readers and writers through one worker.
TLC-checked models of the three concurrency cores, each action mapped to its C++ counterpart: - ControlProtocol: session-slot lifecycle (acquire with generation, pending-ring enqueue, generation-validated worker attach, dual detach refcount), capacity waits (pool-full and ring-full waits cannot starve: liveness checked under weak fairness), backend abort in every phase including mid-wait, worker crash + respawn. Bug-mode configs demonstrate the stale-pending-entry race and the crash-without-drain slot leak the design prevents. - ScanInversion: the read-ahead window over scan_id-prefixed replies and the per-lane demux; two scans plus a metadata round-trip interleave on one channel (FIFO per lane, window bounds, page conservation, termination). A no-routing bug config reproduces the cross-scan misrouting the scan_id prefix prevents. - ScanPool: page lifecycle and producer/consumer/teardown for the scan-producer pool (no leak, no double free, ring bounds, termination), with the producer-dies-silently hang as the bug config. Model checking during development surfaced two real lifecycle bugs (stale pending entries attaching freed slots; worker exit stranding attached refcounts) that are fixed in the implementation commits.
pg_ducklake 1.0.0 moved read_parquet into the ducklake schema, so ClickBench's unqualified call fails; patch the checkout before the benchmark runs. Overlaps the standing ClickBench CI fix PR and can be dropped once that lands.
90fe8fe to
7bffd0d
Compare
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.
What
Runs DuckDB analytical work in a shared per-database background worker instead of a DuckDB instance per PostgreSQL backend, bounding memory to one engine per database under concurrency (per-backend instances otherwise OOM). A backend ships its deparsed SQL and MVCC snapshot to the worker over shared memory and streams results back; the worker serves N sessions concurrently, one thread per session on one shared DuckDB instance.
Rebased onto current main. Six commits, reviewable in order:
feat: shared-memory transports for the duckdb worker-- session channel (control/result rings + a demux that routesscan_id-prefixed scan replies into per-scan lanes and RPC replies into a metadata lane), session pool (fixed shmem slots, one per concurrent dispatched query; attach refcount, per-acquire generations; a backend that finds the pool full WAITS cancellably for a slot -- capacity never falls back to in-process execution, which would recreate the per-backend memory blow-up under exactly the overload the worker exists to bound), Arrow page pool + PG-datum-to-Arrow codec, scan task queue + ready rings, the RPC protocol, andRelationDesc.feat: the duckdb worker base class in the kernel-- the genericpgddb::DuckdbWorkerbase class inlibpgduckdb/worker/(registry/spawn/respawn, generation-validated pending sessions, backend service loop, session threads, scan producers, abort/exit cleanup, cancellation propagation) plus the kernel catalog/scan/node integration.DESIGN.md/GLOSSARY.mddocument it.feat: pg_duckdb duckdb worker-- a thin subclass (pgduckdb::DuckdbWorker): dispatch gate (read-only heap SELECTs), GUCs, entrypoints, tests.feat: pg_ducklake duckdb worker-- subclass (pgducklake::DuckdbWorker) dispatching SELECTs + autocommit DML, with DuckLake metadata reads (MetaQuery) and commit writes (MetaExec) executed on the requesting backend inside its transaction (viaServeFrame), extension SQL 1.2.0 (worker diagnostics), tests.test: TLA+ models of the duckdb worker concurrency protocol-- TLC-checked models of the session-slot lifecycle (including liveness: capacity waits cannot starve), the scan demux, and the scan pool, with bug-mode configs reproducing the failure classes the design prevents.ci: qualify read_parquet in the ClickBench setup-- unrelated CI fix needed for green ClickBench; droppable if the standing CI-fix PR lands first.Design
DescribeRelRPC answered by the backend; heap scans are inverted (produced on the backend, or by a parallel scan-producer pool over CTID block ranges under the shipped snapshot, into zero-copy Arrow pages); DuckLake metadata SQL is remoted to the backend. That is what makes N concurrent sessions on one PG background worker possible.GetFilesForTableand inlined-data reads on DuckDB scheduler threads) share the channel safely -- the earlier "scans must be sequential" constraint that the TLA+ model documented is lifted, and the model now proves the routed version.ClientContext-keyed registry on scheduler threads; DuckLake's per-transaction nested metadata connection is aliased into the registry for exactly the transaction's lifetime.TransactionExceptionfor DuckLake's retry. Transactional writes and DDL stay in-process by design.Correctness work
Concurrency testing (a new e2e suite driving concurrent readers/writers through one worker) and TLC model checking found and fixed five real bugs during the rework: thread-local-only session resolution crashing on scheduler threads, a nested-connection alias outliving its transaction (cross-session misrouting via reused context addresses), subtransaction aborts freeing the channel out from under the RPC being serviced, stale pending-ring entries attaching freed/re-acquired slots, and missing bytea support in the Arrow codec (DuckLake inlined tables store text as bytea). The scan transport covers int2/4/8, float4/8, bool, date, timestamp[tz], text, bytea, and decimals; anything else fails loudly naming the column.
Tests
shared_workertest runs through the scan-producer pool in CI and covers type coverage, unsupported-type failure, error surfacing, cancellation, worker kill + respawn).worker_ping/worker_eval/worker_dispatch/worker_hardening: GUC-off/on parity incl. hybrid + temp-table joins and dispatched DML with read-back, dispatch-gate assertions viaducklake.worker_stats(), failure paths), isolation 4/4 (newworker_concurrent_writesspec), e2etest_shared_worker.py(concurrent readers + writers).specs/tla: 10 TLC configs -- 5 PASS proving the invariants, 5 bug-mode configs failing exactly as documented.🤖 Generated with Claude Code