rindle_replica/derivation_pool.rs
1//! [`DerivationPool`] — the worker-pool half of [`Cluster`](crate::Cluster) for a host
2//! that owns its own writer connection and change capture (design 308 §5).
3//!
4//! `Cluster` bundles the whole write plane: it owns the single writer connection, runs
5//! the preupdate capture on it, and coordinates snapshot barriers with writes and
6//! [`ClusterWriteTxn::commit`](crate::ClusterWriteTxn::commit). An embedding like
7//! `rindle-sqlite-ext` inverts that: the **host application** owns the writer connection
8//! and the transaction lifecycle, capture happens on the host's connection, and the only
9//! coordination point is a `sqlite3_commit_hook` that fires *before* the host's COMMIT is
10//! durable or visible. This facade exposes exactly the machinery such a host needs — the
11//! sharded worker pool and the begin-barrier → push → finish → gate handshake — without
12//! a second writer connection or a second capture hook.
13//!
14//! ## The handshake, from a commit hook
15//!
16//! 1. The host's preupdate capture buffers the transaction's row changes.
17//! 2. In the commit hook (pre-durability, pre-visibility): [`begin`](DerivationPool::begin)
18//! — every worker opens + pins its own read snapshot, which sees **T-1** because the
19//! writer's changes are not yet visible — then [`PoolTxn::push`] the captured batch
20//! (bounded chunks, one shared `Arc` per chunk) and [`PoolTxn::finish`] for the
21//! [`PoolGate`].
22//! 3. Release the gate — [`commit`](PoolGate::commit) once the host's COMMIT will proceed,
23//! [`abort`](PoolGate::abort) if it will not — and return from the hook. The workers
24//! derive against their pinned T-1 snapshots *concurrently* with the host's commit
25//! fsync. The barrier and bounded capture fan-out can block the writer.
26//!
27//! The pool trusts the supplied verdict; it cannot observe the host's SQL commit.
28//! To give `Progressed` the same durable-commit meaning as `Cluster`, release the gate
29//! after a successful COMMIT. A host that releases it inside a pre-commit hook must
30//! handle later commit failure itself, invalidate affected subscriptions, and prevent
31//! consumers from treating provisional host outcomes as durable confirmation.
32//!
33//! Derived events flow out the bounded channel returned by [`spawn`](DerivationPool::spawn)
34//! as [`ClusterEvent`]s, exactly as under `Cluster`: `Update::Hydrated` at registration,
35//! speculative `Update::Changed` per pushed chunk, the per-worker `Progressed` commit
36//! marker (the release signal — `Changed(N)` always precedes `Progressed(N)` per worker),
37//! and terminal `Faulted`. The same draining contract applies: **the consumer must drain
38//! the channel continuously** — a full channel blocks a worker mid-commit, and the next
39//! barrier then times out.
40//!
41//! ## What the caller owns
42//!
43//! - **Transaction ids**: a monotonic `tx_id` per pushed transaction. `Cluster` persists
44//! its watermark inside the writer transaction; a hook-driven host cannot write from
45//! inside the hook, so its ids are typically in-memory (its consumers recover by
46//! re-subscribe + fresh hydrate, not by replaying a durable cursor).
47//! - **The single-writer contract**: exactly one connection writes, and every `begin`
48//! happens while that writer's transaction is still open (the T-1 window). Nothing
49//! here can check that; it is the same contract `Cluster` enforces by construction.
50//! - **Barrier-failure recovery**: `begin` returning `None` means at least one worker
51//! missed the barrier and was aborted/reaped — but unlike `Cluster`, the host usually
52//! cannot veto its own commit, so the transaction still lands and **every** worker's
53//! pipelines are now stale. The caller must treat all registered queries as faulted
54//! (deregister + re-register to re-hydrate at head).
55
56use std::path::Path;
57use std::sync::mpsc::Receiver;
58
59use rindle::Ast;
60use rindle_cdc::Captured;
61
62use crate::parallel::{
63 event_channel_bound, CommitGate, StreamBegin, StreamTx, WorkerPool, PUSH_CHUNK_ROWS,
64};
65use crate::schema::TableSchema;
66use crate::{ChangeEvent, ClusterEvent, OpenOptions, QueryId, ReplicaError};
67
68/// The worker-pool seam for an external writer. See the module docs. Single-thread-use,
69/// like [`Cluster`](crate::Cluster): the pool handle lives on the writer's thread (the
70/// hooks fire there), and only `Send` data crosses to the workers.
71pub struct DerivationPool {
72 pool: WorkerPool,
73 n_workers: usize,
74}
75
76impl DerivationPool {
77 /// Spawn `n_workers` IVM worker threads over the **file-backed** database at `path`,
78 /// using the planner, operator-storage, journal, and foreign-key options. The
79 /// host owns its writer's `wal_autocheckpoint`; the pool does not configure it.
80 /// Returns the pool
81 /// and the bounded channel-out of derived [`ClusterEvent`]s (`max(256, n_workers · 16)`
82 /// events — FOLLOWER-LAG-SHED §4, rung 0b; see the module docs' draining contract). Asserts
83 /// `sqlite3_threadsafe() != 0`.
84 ///
85 /// [`Cluster::open_with`]: crate::Cluster::open_with
86 pub fn spawn(
87 path: impl AsRef<Path>,
88 n_workers: usize,
89 opts: OpenOptions,
90 ) -> Result<(DerivationPool, Receiver<ClusterEvent>), ReplicaError> {
91 assert!(n_workers > 0, "a derivation pool needs at least one worker");
92 if unsafe { rusqlite::ffi::sqlite3_threadsafe() } == 0 {
93 return Err(ReplicaError::NotThreadsafe);
94 }
95 let path = path.as_ref();
96 let (out_tx, out_rx) = std::sync::mpsc::sync_channel(event_channel_bound(n_workers));
97 let pool = WorkerPool::spawn(
98 path,
99 n_workers,
100 opts.plan_queries,
101 opts.operator_storage,
102 opts.journal,
103 opts.foreign_keys,
104 out_tx,
105 )?;
106 Ok((DerivationPool { pool, n_workers }, out_rx))
107 }
108
109 /// The number of worker slots (fixed at spawn; the `query_id % n` shard count).
110 pub fn n_workers(&self) -> usize {
111 self.n_workers
112 }
113
114 /// The worker slot that hosts (or would host) `query_id`. The pool owns the shard
115 /// mapping; callers key per-query progress by this index rather than recomputing it.
116 pub fn worker_of(&self, query_id: QueryId) -> usize {
117 (query_id.0 % self.n_workers as u64) as usize
118 }
119
120 /// Build the table's source on **every** worker (any may host a query referencing
121 /// it). Idempotence and the PK-UNIQUE-index prerequisite are the caller's concern —
122 /// the discovery/ensure half lives with whoever owns a connection (the host), e.g.
123 /// [`discover_table_schema`](crate::discover_table_schema) +
124 /// [`ensure_unique_pk_index`](crate::ensure_unique_pk_index). Must not be called
125 /// while the writer's transaction is open: the workers build sources against
126 /// committed state.
127 pub fn register_table(&self, table: &str, schema: TableSchema) -> Result<(), ReplicaError> {
128 self.pool.register_table_all(table, schema)
129 }
130
131 /// Register + hydrate a live query on its shard ([`worker_of`](Self::worker_of));
132 /// blocks until the worker built + hydrated it (a `BuildError` surfaces here), then
133 /// the worker emits the `Hydrated` baseline — stamped `hydrated_tx`, the caller's
134 /// committed watermark — to the channel-out. Returns the hosting worker's index.
135 pub fn register_query(
136 &self,
137 query_id: QueryId,
138 ast: Ast,
139 hydrated_tx: u64,
140 ) -> Result<usize, ReplicaError> {
141 let worker = self.worker_of(query_id);
142 self.pool
143 .register_query_on(worker, query_id, ast, hydrated_tx)?;
144 Ok(worker)
145 }
146
147 /// Gracefully tear down a query (solicited — no `Faulted` event; after this returns
148 /// no further events arrive for `query_id`). Returns `true` if a live query was
149 /// found and removed; idempotent.
150 pub fn deregister_query(&self, query_id: QueryId) -> bool {
151 self.pool
152 .deregister_query_on(self.worker_of(query_id), query_id)
153 > 0
154 }
155
156 /// Re-read the current assembled view of `query_id` from its hosting worker as
157 /// hydration `Add`s (the SSR one-shot). FIFO with commits on that worker, so the
158 /// returned view reflects every transaction whose gate was released before this
159 /// call. A degraded shard or unregistered query reads as an empty snapshot; a raised
160 /// read boundary on a live worker is `Err`. Must not be called between
161 /// [`begin`](Self::begin) and the gate release.
162 pub fn read_snapshot(&self, query_id: QueryId) -> Result<Vec<ChangeEvent>, ReplicaError> {
163 self.pool
164 .read_snapshot_on(self.worker_of(query_id), query_id)
165 }
166
167 /// The begin-barrier for one committing transaction: every live worker opens + pins
168 /// its read snapshot at the pre-commit state and acks. `Some` ⇒ all pinned — stream
169 /// the capture with [`PoolTxn::push`] and finish for the gate. `None` ⇒ a worker
170 /// died or stalled at the barrier: the workers that did pin were rolled back cleanly
171 /// (nothing was pushed) and the offender was reaped/respawned (its lost queries got
172 /// terminal `Faulted` events) — but every OTHER worker now misses this transaction
173 /// too, so if the host's commit proceeds anyway, the caller must fault + re-register
174 /// every remaining query (see the module docs).
175 pub fn begin(&self, tx_id: u64) -> Option<PoolTxn> {
176 // `tx_begin` is a send; `await_acks` is the fence. A hook-driven host has no
177 // writer work to overlap them with (it is already at its commit edge), so this
178 // facade keeps the blocking begin contract and collects the acks immediately.
179 match self.pool.tx_begin(tx_id) {
180 StreamBegin::Ready(mut stream) => {
181 if stream.await_acks() {
182 return Some(PoolTxn { stream });
183 }
184 // A worker stalled past the barrier watchdog: nothing pushed yet → a
185 // clean snapshot rollback of the workers that pinned, then detect +
186 // respawn the offender(s).
187 stream.finish().abort();
188 self.pool.reap();
189 None
190 }
191 StreamBegin::Failed(stream) => {
192 // A worker was dead at send. Same recovery: nothing pushed yet → a clean
193 // snapshot rollback of any worker that pinned, then detect + respawn.
194 stream.finish().abort();
195 self.pool.reap();
196 None
197 }
198 }
199 }
200
201 /// Liveness sweep: block until every worker has drained its command queue,
202 /// respawning any that died or hung (their lost queries get terminal `Faulted`
203 /// events). The quiesce point — a returned sweep implies all prior registrations and
204 /// released gates have emitted their events to the channel-out. Mirrors
205 /// [`Cluster::sync`](crate::Cluster::sync).
206 pub fn reap(&self) {
207 self.pool.reap();
208 }
209
210 /// Set the per-push derive deadline (FOLLOWER-LAG-SHED §6.6), in ms; `0` disables
211 /// it. Applies immediately to every worker, respawns included. Default 10 s.
212 pub fn set_push_deadline_ms(&self, ms: u64) {
213 self.pool.set_push_deadline_ms(ms);
214 }
215
216 /// Override every worker engine's design-306 D4 delta-byte budget (see
217 /// [`Db::set_max_delta_bytes`](crate::Db::set_max_delta_bytes)). Workers re-read it
218 /// at each commit barrier, so it lands on the next transaction, respawns included.
219 /// A worker whose batch delta overflows the budget sheds itself: tear down +
220 /// terminal `Faulted` per hosted query.
221 pub fn set_max_delta_bytes(&self, bytes: usize) {
222 self.pool.set_max_delta_bytes_all(bytes);
223 }
224
225 /// Test seam: lower the barrier-ack + liveness-ping timeouts so death/stall
226 /// recovery paths run fast. Doc-hidden; not part of the supported API.
227 #[doc(hidden)]
228 pub fn __test_set_timeouts(&self, ack: std::time::Duration, ping: std::time::Duration) {
229 self.pool.set_timeouts(ack, ping);
230 }
231
232 /// Test seam: force a derivation-fault recovery on the worker hosting `on_query`
233 /// (tears down every query on that worker, emitting terminal `Faulted` for each).
234 /// Doc-hidden; not part of the supported API.
235 #[doc(hidden)]
236 pub fn __test_trigger_fault(&self, on_query: QueryId) {
237 self.pool.trigger_fault(self.worker_of(on_query));
238 }
239
240 /// Test seam: kill the worker thread hosting `on_query` (a true thread death,
241 /// detected + recovered by the next [`reap`](Self::reap)/barrier). Doc-hidden; not
242 /// part of the supported API.
243 #[doc(hidden)]
244 pub fn __test_kill_worker(&self, on_query: QueryId) {
245 self.pool.kill_worker(self.worker_of(on_query));
246 }
247}
248
249/// An in-flight transaction across the pool: every live worker holds a pinned pre-commit
250/// snapshot, awaiting pushed chunks and the terminal gate. Dropping it without
251/// [`finish`](Self::finish) releases the workers' snapshots as an abort.
252#[must_use = "a PoolTxn must be finished (or dropped) to release the workers' held snapshots"]
253pub struct PoolTxn {
254 stream: StreamTx,
255}
256
257impl PoolTxn {
258 /// The transaction id this barrier was opened for.
259 pub fn tx_id(&self) -> u64 {
260 self.stream.tx_id()
261 }
262
263 /// Fan captured changes to every worker in bounded chunks (each one shared `Arc` —
264 /// a refcount bump per worker, never a row copy). Each worker derives the chunk
265 /// against its held snapshot and speculatively emits the deltas, tagged this
266 /// transaction; the consumer holds them until the worker's `Progressed` marker. A
267 /// worker saturated past its in-flight credit for too long is dropped from the
268 /// stream (the liveness sweep owns it from there — its queries fault).
269 pub fn push(&mut self, changes: &[Captured]) {
270 for chunk in changes.chunks(PUSH_CHUNK_ROWS) {
271 self.stream.push(chunk.to_vec().into());
272 }
273 }
274
275 /// Close the push stream and take the commit gate — the **unsent terminal command**.
276 /// Each worker keeps its snapshot until it receives `TxFinish` with the verdict.
277 /// Bounded delivery can still block a worker. Release (or drop) the gate
278 /// **before** the next [`begin`](DerivationPool::begin) — a begin that overtakes an
279 /// unreleased gate makes the affected workers tear down + rehydrate.
280 pub fn finish(self) -> PoolGate {
281 PoolGate {
282 gate: self.stream.finish(),
283 }
284 }
285}
286
287/// The commit-verdict gate, held between "all workers pinned + pushed" and the host
288/// COMMIT's outcome. Releasing it sends each worker its terminal `TxFinish` with the
289/// verdict inline; dropping it without either releases the workers as an abort (no
290/// worker hangs).
291#[must_use = "a PoolGate must be committed or aborted, else workers discard the tx as an abort"]
292pub struct PoolGate {
293 gate: CommitGate,
294}
295
296impl PoolGate {
297 /// Report a successful host commit. Workers emit `Progressed(N)` after their
298 /// speculative `Changed(N)` slices and discard derivation state. The pool trusts
299 /// this verdict; it does not check the host connection. Releasing from a pre-commit
300 /// hook requires separate host-commit failure handling; see the module docs.
301 pub fn commit(self) {
302 self.gate.commit();
303 }
304
305 /// The host's transaction will not commit: a worker that received pushes has
306 /// irreversibly advanced its operator state for a transaction that never happened,
307 /// so it tears down + re-hydrates (terminal `Faulted` per hosted query); a worker
308 /// that received nothing rolls its snapshot back cleanly.
309 pub fn abort(self) {
310 self.gate.abort();
311 }
312}