Skip to content

Shared per-database DuckDB engine worker (kernel worker host + both extensions)#212

Draft
qsliu2017 wants to merge 6 commits into
mainfrom
feat/shared-duckdb-worker
Draft

Shared per-database DuckDB engine worker (kernel worker host + both extensions)#212
qsliu2017 wants to merge 6 commits into
mainfrom
feat/shared-duckdb-worker

Conversation

@qsliu2017

@qsliu2017 qsliu2017 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

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:

  1. feat: shared-memory transports for the duckdb worker -- session channel (control/result rings + a demux that routes scan_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, and RelationDesc.
  2. feat: the duckdb worker base class in the kernel -- the generic pgddb::DuckdbWorker base class in libpgduckdb/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.md document it.
  3. feat: pg_duckdb duckdb worker -- a thin subclass (pgduckdb::DuckdbWorker): dispatch gate (read-only heap SELECTs), GUCs, entrypoints, tests.
  4. 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 (via ServeFrame), extension SQL 1.2.0 (worker diagnostics), tests.
  5. 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.
  6. 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

  • PG-free worker execution. The worker holds no PG transaction or snapshot. Catalog binds go through a DescribeRel RPC 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.
  • One channel per session, demultiplexed. Scan replies carry the scan id and are routed into per-scan lanes; metadata/catalog replies into a serialized metadata lane. Concurrent scans and mid-execution metadata RPCs (e.g. DuckLake GetFilesForTable and 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.
  • Session resolution off the session thread. Worker-side PG-touching code resolves its session via a thread-local on the session thread and via a ClientContext-keyed registry on scheduler threads; DuckLake's per-transaction nested metadata connection is aliased into the registry for exactly the transaction's lifetime.
  • Dispatched DML atomicity (pg_ducklake). An autocommit INSERT/UPDATE/DELETE executes in the worker, but its DuckLake metadata commit runs on the backend, inside the backend's transaction -- commit is atomic with the statement, and commit conflicts surface as TransactionException for DuckLake's retry. Transactional writes and DDL stay in-process by design.
  • Lifecycle hardening. Backend transaction/subtransaction abort callbacks and process-exit hooks release open sessions (subtransaction-scoped, so an RPC serviced in a conflict-retried subtransaction survives); session-slot generations reject stale queued sessions; dead workers are detected by pid probe and respawned on demand; the worker drains session threads on any exit and interrupts the DuckDB context of cancelled sessions.

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

  • pg_duckdb regression 66/66 (the shared_worker test runs through the scan-producer pool in CI and covers type coverage, unsupported-type failure, error surfacing, cancellation, worker kill + respawn).
  • pg_ducklake regression 52/52 (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 via ducklake.worker_stats(), failure paths), isolation 4/4 (new worker_concurrent_writes spec), e2e test_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

@qsliu2017 qsliu2017 force-pushed the feat/shared-duckdb-worker branch from e1dfceb to 9824968 Compare June 23, 2026 03:28
@qsliu2017 qsliu2017 marked this pull request as draft June 23, 2026 11:43
@qsliu2017 qsliu2017 force-pushed the feat/shared-duckdb-worker branch from 9020816 to 88aba08 Compare July 7, 2026 11:11
@qsliu2017 qsliu2017 changed the title Shared DuckDB engine worker with parallel scan-producer pool Shared per-database DuckDB engine worker (kernel worker host + both extensions) Jul 7, 2026
@qsliu2017 qsliu2017 force-pushed the feat/shared-duckdb-worker branch from 88aba08 to 90fe8fe Compare July 8, 2026 10:16
qsliu2017 added 6 commits July 8, 2026 19:39
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.
@qsliu2017 qsliu2017 force-pushed the feat/shared-duckdb-worker branch from 90fe8fe to 7bffd0d Compare July 8, 2026 11:39
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