RFC 0010: Async↔sync bridge: mechanism, runtime, threading, and cancellation
- Date: 2026-07-09
Summary
Section titled “Summary”RFC 0003 makes the moraine core async and states it “spawns no runtime and
no threads of its own — the caller drives it,” scoping the DuckDB extension
entry points and the sync↔async bridge out to RFC 0006 and this RFC. RFC 0006
locates the
bridge — “the C-ABI layer owns the tokio runtime and block_ons core futures”
— but does not specify the runtime’s shape, how it stays live for SlateDB’s
background work, how a DuckDB query interrupt cancels an in-flight catalog
operation, or how a panic is kept from crossing the C ABI. This RFC settles
those. It first chooses the bridge mechanism — block_on on a
multi-threaded runtime, over a completion-callback or a
channel-to-a-background-runtime, because object-store latency dominates any
bridge overhead by three to four orders of magnitude — then designs the
runtime that choice needs: one long-lived multi-threaded tokio runtime per
moraine catalog instance, owned by the C-ABI layer; FFI entry points
block_on core futures from DuckDB’s (external, non-runtime) threads; DuckDB
interrupts map to future cancellation with a defined cancel-safety point at
the commit write; and every FFI boundary catch_unwinds so a panic becomes
a typed error, never undefined behavior.
- Pick the bridge mechanism deliberately. Choose how a synchronous FFI call
drives an async core —
block_on, a completion-callback, or a channel-to-a-dispatcher — with the tradeoffs recorded, so it is a decision and not an accident, and no more complex than the workload justifies. - The core stays runtime-free. No
tokio::runtimeor thread spawning inmoraine(RFC 0003). The runtime lives entirely in themoraine-duckdbC-ABI layer; a pure-Rust host brings its own and awaits directly. The core does spawn tasks onto whichever runtime is driving it — that is how the commit write is shielded, below — but it never creates one. - Background work makes progress. SlateDB’s background tasks (WAL flush, compaction) and concurrent DuckDB scan threads all progress while one or more DuckDB threads are blocked on a catalog call.
block_onis used only where it is safe. FFI entry points are invoked on DuckDB’s own OS threads, which are outside the runtime, soblock_onthere cannot deadlock a worker. Core code neverblock_ons.- Cancellation is honored and safe. A DuckDB query interrupt cancels an
in-flight catalog operation at an
awaitpoint and returns a typedInterrupted; cancellation before the RFC 0004 commit write has no effect on catalog state, and a committed commit is never “half-cancelled” — the write itself is shielded from cancellation, at the documented cost that an interrupt racing the write yields an ambiguous (Interrupted-but-landed) outcome equivalent to RFC 0011’sCommitDurableNotAcknowledged. - Panics never cross the C ABI. Every FFI boundary catches unwinding and
converts it to a typed error mapped to a DuckDB error code (RFC 0006), never
UB. Consistent with the CLAUDE.md no-panic-in-library policy and the
// SAFETY:-commentedunsafeallowed only inmoraine-duckdb.
Non-goals:
- The core’s async API surface — RFC 0003. This RFC wraps it; it does not change it.
StorageExtensionregistration, C-ABI marshalling, error-code mapping — RFC 0006. This RFC covers the runtime, threading, and cancellation that ride inside that C-ABI layer.- The runtime of a pure-Rust embedding host. RFC 0003 already says the host provides it; this RFC covers only the DuckDB extension’s runtime.
- The commit protocol — RFC 0004. This RFC defines where cancellation is safe relative to that protocol, not the protocol itself.
Background
Section titled “Background”RFC 0001 forbids the core from owning threads and confines unsafe to
moraine-duckdb with a // SAFETY: comment; RFC 0003 makes the core surface
async because “SlateDB is async (tokio)” and insists the core “spawns no
runtime and no threads of its own — the caller drives it, and the DuckDB
bridge owns the sync↔async translation,” leaving the bridge to this RFC.
RFC 0006 makes moraine-duckdb a thin C++ shim over a C ABI to the Rust core,
with the sync↔async bridge in the Rust C-ABI layer that “owns the tokio
runtime and block_ons core futures, so the C++ shim only ever calls
synchronous C functions,” and scopes the extension so moraine does not own
the data-file read/write path — DuckDB and DuckLake do.
The problem this leaves: DuckDB drives an extension synchronously, from its own
execution threads, and expects those calls to return values and to be
interruptible; the moraine core is a set of async fns over SlateDB. Bridging
them needs a runtime, a block_on discipline that cannot deadlock, a
cancellation path, and panic containment.
Design
Section titled “Design”The bridge mechanism: block_on vs. the alternatives
Section titled “The bridge mechanism: block_on vs. the alternatives”Bridging a synchronous DuckDB call to the async core has three real mechanisms. This RFC chooses the first; the rest of the Design section implements it.
A. block_on on a multi-threaded runtime (chosen). The FFI entry point, on
DuckDB’s own (external, non-runtime) thread, block_ons the core future;
worker threads drive the IO and SlateDB’s background tasks.
- For: the simplest possible bridge; matches DuckDB’s synchronous operator
model exactly (call, wait, return); the calling thread is external to the
runtime, so
block_oncannot deadlock a worker (see next subsection); the C ABI stays synchronous (RFC 0006); cancellation is aselect!of the future against an interrupt probe (see cancellation, below). - Against: one OS thread is parked per concurrent in-flight call — parked threads, not cores, bounded by DuckDB’s thread count. This is why the runtime must be multi-threaded: background IO must progress while those threads park.
B. Completion-callback (async C ABI). The FFI call submits the operation and returns immediately; the core future runs on the runtime and invokes a C callback on completion that resumes DuckDB.
- For: no thread parked while waiting; in principle more concurrency from fewer threads.
- Against: DuckDB’s storage/catalog extension points are synchronous (RFC
0006 registers a synchronous
StorageExtension); there is no async-operator / suspend-resume contract for a catalog callback to resume into, so it would park the DuckDB thread anyway (defeating its only advantage) or demand deep DuckDB changes moraine does not control. It also adds a callback protocol crossing the C ABI — more surface, more unwind-across-FFI risk — against RFC 0006’s “thin, synchronous C functions.” Should DuckDB ever grow an async catalog or operator contract, this is the option to re-open — but nothing here is built in anticipation of one.
C. Channel to a background runtime (dispatcher/actor). The runtime runs on dedicated background thread(s); the FFI thread serializes each request onto a channel to a dispatcher and blocks on a reply channel.
- For: decouples the runtime from calling threads; a single owned dispatcher could serialize commits, echoing RFC 0004’s single-committer funnel.
- Against: it is
block_onwith extra hops — the calling thread still blocks on the reply, saving nothing on latency — plus channel queueing and a dispatcher to manage. tokio’s multi-threaded runtime already is a work-stealing dispatcher, so A gets the same behavior with less machinery. Its one genuine use, funneling commits through one committer, is an RFC 0004 concern realizable above the bridge.
Decision: A. B and C exist to avoid parking a thread; A parks a thread but is the simplest correct mechanism and the only one that matches DuckDB’s synchronous execution model without faking async (B collapses back to blocking) or re-implementing the runtime’s own dispatcher (C).
Why bridge overhead is the wrong thing to optimize. This is the crux and the
reason the elaborate options lose. Per-call bridge overhead — parking a thread,
a select!, a channel hop — is on the order of microseconds. The operation
it wraps is one or more object-store round trips: a commit is PUT-bound
(~5–10 ms S3 Express, ~50–100 ms S3 Standard, per the README), a cold read is a
GET. Bridge overhead sits three to four orders of magnitude below the
operation, so a more sophisticated bridge optimizes noise. This inverts the
usual “async avoids blocking threads, so it is faster” intuition: async’s
thread-efficiency wins when many tasks each wait on cheap IO and threads are the
scarce resource; here the waited-on thing is a tens-of-milliseconds PUT and the
parked resource is a thread (bounded by DuckDB’s thread count), not a core.
Parking a thread to wait on a 50 ms PUT costs nothing worth reclaiming. The
rule: pick the simplest correct mechanism and spend complexity where the
milliseconds are — WAL group commit (RFC 0004), read consistency and caching
(RFC 0009) — not on the bridge.
One runtime, multi-threaded, per catalog instance
Section titled “One runtime, multi-threaded, per catalog instance”The C-ABI layer creates one tokio multi-threaded runtime per attached
moraine catalog (per slatedb::Db / Catalog instance, RFC 0003), at
ATTACH time, and holds it for the instance’s lifetime. It is:
- Multi-threaded, because multiple DuckDB threads (parallel scans) may each
block_ona catalog call at once, and SlateDB’s background flush/compaction tasks need worker threads to run while those threads are blocked. Acurrent_threadruntime would stall background IO whenever a DuckDB thread blocked, deadlocking parallel readers. - Long-lived, because SlateDB’s durability and compaction run on its
background tasks; the runtime must outlive any single call and live as long
as the catalog is attached. It is dropped (gracefully, flushing SlateDB) at
DETACH. - Per instance, not global, because each attached moraine catalog owns its
own
slatedb::Dband its lifecycle is tied to that attach. Multiple attached moraine catalogs get independent runtimes; this keepsDETACHteardown local and avoids a process-global whose worker count must serve an unknown number of catalogs. (Revisit if many-catalog processes make a shared runtime worth the coupling.)
Worker count: DuckDB’s own thread setting, clamped to 2..=8. The attach
entry point takes the host’s execution-thread count and sizes the pool from
it. Three constraints fix the rule:
- Track the host. DuckDB’s
threadssetting is the only number in the process that states how much parallelism the operator asked for, and it bounds how many DuckDB threads can be inside a catalog call at once. A session pinned withSET threads=1must not get a pool sized to the machine, on top of DuckDB’s own. - Floor of two. A CPU-bound poll holds its worker to completion, and SlateDB’s flush and compaction have to progress while it does — the same reason the runtime is multi-threaded at all, so it is a floor and not a tuning knob.
- Ceiling of eight. The pool waits on object storage, which yields its
worker at every await: four workers absorb thirty-two concurrent
materializations at a flat batch time, and eight concurrent decodes leave a
short call at ~0.4 ms (
BENCHMARK.md→ Core measurements, both halves). Past a handful, further workers only park — and they park in addition to DuckDB’s own threads, on cores DuckDB already sized itself to.
The size is fixed at attach. A session that changes threads afterwards keeps
the pool it attached with; rebuilding a runtime that owns live background
tasks costs more than the mismatch does.
block_on discipline
Section titled “block_on discipline”DuckDB calls each C-ABI entry point on one of DuckDB’s own OS threads — a
thread that is not a runtime worker. The entry point acquires the
instance’s runtime Handle and block_ons the core future:
- Legal here.
block_onon a non-runtime thread parks that thread while worker threads drive the future and SlateDB’s background tasks. Because DuckDB’s calling threads are external, no worker thread is ever blocked by ablock_on, so the classic “block_oninside the runtime deadlocks” hazard cannot arise. - The rule. FFI entry points (external DuckDB threads)
block_on; core code neverblock_ons — it only.awaits, so an embedding host (RFC 0003) can drive it on its own runtime unchanged. This split is the one invariant that keeps the async core portable and the bridge deadlock-free.
No reentrancy across the FFI in the catalog path
Section titled “No reentrancy across the FFI in the catalog path”RFC 0006 scopes moraine to catalog metadata: DuckDB/DuckLake own data-file
reads and writes. Therefore a catalog operation never calls back into DuckDB
mid-flight — it reads and writes SlateDB and returns. There is no re-entrant
“core → DuckDB → core” call chain in the catalog path, so no nested block_on
and no cross-FFI reentrancy hazard. This simplifying invariant is a gift of
RFC 0006’s scoping and is stated here so it is not accidentally broken by
routing a data-path callback through the catalog FFI.
Cancellation maps DuckDB interrupt → future drop
Section titled “Cancellation maps DuckDB interrupt → future drop”DuckDB supports query interruption; a long catalog operation (a large time- travel materialization, a commit stalled on a slow object-store PUT) must respond. The bridge implements cancellation as:
- The FFI entry point
block_ons aselect!of the operation against a cancellation probe supplied by the caller. - On interrupt, the cancellable portion of the operation is dropped
(async cancellation is drop), unwinding its
awaitstack and releasing resources, and the entry point returns a typedInterrupted(RFC 0003 taxonomy → DuckDB error code, RFC 0006).
How the interrupt arrives: a pollable flag, not a callback or a handle.
DuckDB offers no way to push a cancellation into an extension — there is no
callback to register and no cancellation handle to hold. What it does offer
is ClientContext::IsInterrupted(), one atomic load of the same flag its own
executor polls, safe to call from any thread. So the bridge pulls: every
cancellable entry point takes a probe function pointer and an opaque context,
the shim passes its moraine_shim_is_interrupted and the ClientContext, and
the bridge polls it — once before the future is first polled, then every
~100 ms while it is pending. The pre-poll check is load-bearing: a timer’s
first tick is pending even when already elapsed, so a future that completes on
its first poll would otherwise beat an interrupt that was already standing.
Two consequences follow from the probe being per call rather than per handle. Cancellation is scoped to the query that asked for it — several connections share one attached catalog, and one connection’s Ctrl-C must not abort or be consumed by another’s query. And the signal is level-triggered: nothing consumes it, so a probe that reports an interrupt cancels every call that observes it, and a later call with a quiet probe succeeds normally.
DETACH takes no probe and never will: it is teardown, and its wait is the
flush that makes committed data durable.
The commit write is shielded — select! alone is not the design. A bare
select! over the whole commit future would drop it at whatever await
it is parked on when the interrupt fires — including inside the durable
batch write itself, where the write has already been issued and dropping
the future cannot retract it. No design can both race a probe against the
whole future and promise that past the point of no return the operation
runs to completion; those are contradictory mechanisms. The bridge is
therefore split at the point of no return:
- Phases before the batch write (load-head, id allocation, batch
assembly, conflict retry) run inside the
select!and are freely cancellable: dropping them discards only staged in-memory state. - The batch write itself is spawned onto the runtime rather than awaited inline in the cancellable future, so no interrupt — and no drop of the FFI-side future — can abort a write mid-flight. The caller then waits for the spawned task, still racing the probe.
Both commit paths take that split. The verb path’s group commit (RFC 0004) already had it structurally: a batch is spawned the moment it seals, and its members wait on a channel rather than on the write, because a member that sealed the batch must not be able to take every other member’s commit down with it when its own caller is interrupted. The staged-row path has no batch to share, so it spawns its own write and waits on the join handle.
A spawned write that never reports back — its runtime shut down, or the task
lost to a panic — leaves the outcome unknown, which is the one answer a
caller must not read as “nothing landed”. The core raises CommitOutcomeUnknown for
it. The caller retains external files and reconciles the operation before
resubmitting; a newer head alone does not resolve its fate.
The bridge observes whether it has polled the commit future, not the exact
submission instant inside the core. It reports INTERRUPTED only before that
first poll; later cancellation conservatively reports COMMIT_OUTCOME_UNKNOWN.
The physical cases below explain why preparation may safely stop while a
submitted write must survive.
Cancel-safety, relative to RFC 0004, then has three cases instead of a clean two:
- Cancelled before the write is spawned: dropping preparation discards
its staged state. The ABI reports
INTERRUPTEDonly if the commit future was never polled; once polling starts it conservatively reportsCOMMIT_OUTCOME_UNKNOWNbecause it does not observe the core’s exact submission boundary. - Cancelled while the spawned write is in flight: the bridge returns
COMMIT_OUTCOME_UNKNOWNpromptly and the spawned write continues. It may land durably or fail, but is never torn. The caller retains external files and reconciles the operation before resubmitting. A newer head by itself does not establish whether this operation landed. - The core future reports its result: the bridge returns the committed snapshot normally. If cancellation stops the wait before that result is received, the outcome remains unknown even if the write is already durable.
Read operations (materialization, RFC 0009) are trivially cancel-safe —
they are pure select!, dropping them frees the read-snapshot and touches
no durable state.
Panic containment at the boundary
Section titled “Panic containment at the boundary”Unwinding across the C ABI is undefined behavior. Every FFI entry point wraps
its block_on in catch_unwind; a caught panic becomes a typed internal error
mapped to a DuckDB error code (RFC 0006), and the process survives. This is the
enforcement point for CLAUDE.md’s no-panic/unwrap/expect policy at the
one place the policy could otherwise cause UB rather than a clean error, and it
is the kind of boundary unsafe that moraine-duckdb carries a // SAFETY:
comment for.
Two provisioning stories, one core
Section titled “Two provisioning stories, one core”The runtime is provisioned in exactly one of two ways, never both:
| Host | Runtime | How the core is driven |
|---|---|---|
| Pure-Rust embedding (RFC 0003) | the host’s own tokio runtime | .await directly |
| DuckDB extension (this RFC) | one per-instance runtime in the C-ABI layer | FFI block_on |
The core is identical in both; only who owns the runtime differs. This is the concrete meaning of RFC 0003’s “the caller drives it.”
Test obligations
Section titled “Test obligations”Per RFC 0001, tested against real SlateDB on in-memory object_store; the FFI
paths are exercised by the cargo xtask e2e DuckDB harness (RFC 0006):
- Concurrency progresses. Multiple threads each
block_ona catalog call concurrently and all complete; SlateDB background flush advances while a thread is blocked (nocurrent_threadstall). - Cancel before polling. An operation interrupted before its commit future
is polled returns
INTERRUPTEDand leaves catalog state exactly as before. Cancellation after polling begins reportsCOMMIT_OUTCOME_UNKNOWNeven when the future was still preparing. - Cancel during the shielded write. An interrupt delivered while the
spawned batch write is in flight returns
COMMIT_OUTCOME_UNKNOWNpromptly; the write still completes (or fails) in the background, never torn — a subsequent read observes head at exactlyNor exactlyN+1, and a re-drive behaves per RFC 0011’sCommitDurableNotAcknowledged. - Commit past the point of no return. An interrupt delivered after the durable write completed still reports the committed snapshot; state is consistent.
- Read cancellation. An interrupted materialization returns
Interrupted, releases its read-snapshot, and leaves no durable effect. - Panic containment. A forced panic in a core call is caught at the FFI boundary and surfaced as a DuckDB error; the process does not abort and the runtime remains usable for the next call.
- Teardown.
DETACHdrops the runtime and flushes SlateDB with no outstanding-task leak.
Alternatives considered
Section titled “Alternatives considered”- A runtime per DuckDB connection. Rejected: connection-scoped runtimes
churn thread pools and, worse, SlateDB’s background flush/compaction want a
stable runtime tied to the
Db’s lifetime, not to whichever connection happens to be open. Per-catalog-instance matches the resource that actually owns background work. - A
current_threadruntime driven by the calling DuckDB thread. Rejected: a single-threaded runtime cannot advance SlateDB background IO while a DuckDB thread is blocked inblock_on, so parallel scans deadlock and durability stalls. Multi-threaded is required, not merely preferred. - The completion-callback (B) and channel-to-dispatcher (C) mechanisms.
Analyzed and rejected in “The bridge mechanism” above: B needs an async DuckDB
execution contract that does not exist and collapses back to blocking without
it; C is
block_onwith extra hops and no latency benefit. - A hybrid that ships A but adds C’s dispatcher speculatively. Rejected: it pays C’s complexity now for a commit-funnel need RFC 0004 can add later if a many-committer process appears. Ship A; add a dispatcher as an RFC 0004-layer change only when a workload demands it.
- Making the core synchronous (drop async, call SlateDB blocking). Rejected:
SlateDB is async (RFC 0001), and a sync core would have to
block_oninternally, welding the core to a runtime and breaking RFC 0003’s runtime-free, host-drivable surface. The async core is what lets a pure-Rust host embed moraine with its own runtime. - Ignoring cancellation (let long operations run to completion). Rejected: DuckDB users expect query interruption to work; a commit stalled on a slow object-store PUT would hang a session uninterruptibly. Cancel-safety is cheap because RFC 0004’s atomic-batch boundary already defines the only point of no return.
- Letting panics unwind across the C ABI (or aborting the process on panic).
Rejected: unwinding across the C ABI is UB, and aborting turns a recoverable
catalog error into a process crash.
catch_unwindat every boundary, converting to a typed error, is mandatory and is whymoraine-duckdbis the crate allowed boundaryunsafe.