Rindle docs and package mapSkip to main content

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}