Rindle docs and package mapSkip to main content

rindle_cdc_apply/
fanout.rs

1//! The **commit fan-out seam** (design 309 §3): how an open apply transaction hands its
2//! captured row deltas to whatever wants to observe the commit — the live replica's IVM
3//! worker pool, or nothing at all.
4//!
5//! The apply plane's write-transaction state machine ([`ApplyTxn`](crate::ApplyTxn))
6//! interacts with its observer through exactly ONE call shape: `tx_begin(tx_id)` →
7//! `Ready`/`Failed`, then `push(chunk)`* / `finish()` → a gate released with `commit()` or
8//! `abort()`. That shape is lifted here as a trait so *absence* of a derivation pool is a
9//! **type** ([`NoFanout`]), not a zero-worker parameter value — the dropped first
10//! extraction attempt proved the zero-worker shape wrong at runtime (two
11//! `assert!(n_workers > 0)`s and six `% n_workers` sites; design 309 §2).
12//!
13//! Control flow through the transaction is IDENTICAL under both implementations: the
14//! capture buffer still drains at [`PUSH_CHUNK_ROWS`] (so the memory bound for a large
15//! restore transaction survives headless), the begin/finish barriers still run — they
16//! just have no one to hold.
17
18use std::sync::Arc;
19
20use rindle_cdc::Captured;
21
22/// Upper bound on one streamed fan-out chunk, in captured rows
23/// (`CLUSTER-INCREMENTAL-FANOUT-DESIGN.md` §4.2). Bounds the writer's transient capture
24/// buffer — and, on the live replica, the per-worker transient output and the
25/// cross-thread message — to `O(chunk)` rather than `O(txn)`; a txn ≤ this size streams
26/// as a single chunk. Owned by the apply plane because the between-statement drain
27/// (`ApplyTxn::maybe_pump`) is what enforces the writer-side bound, with or without a
28/// derivation pool on the other end.
29pub const PUSH_CHUNK_ROWS: usize = 1024;
30
31/// The observer of an apply store's commits. Implemented by the live replica's worker
32/// pool (fanning captured chunks out to the IVM workers behind the snapshot/commit
33/// handshake) and by [`NoFanout`] (a headless applier — backup replay, an external
34/// stream consumer — with no derivation to feed).
35pub trait CommitFanout {
36    /// Open the fan-out for transaction `tx_id` (the post-(N-1) → N cursor). A **send**,
37    /// not a wait (`follow-ups/cluster-barrier-wakeup.md` §6.1): the observer pins its
38    /// pre-commit state concurrently with the caller's remaining statements, and the
39    /// caller collects the confirmation at the commit edge
40    /// ([`FanoutStream::await_acks`] — the fence). `Ready` ⇒ stream chunks and finish;
41    /// `Failed` ⇒ the observer was unreachable at send — the caller must NOT commit: it
42    /// finishes the returned stream with an abort and fails the write (see
43    /// [`ApplyTxn::commit_with_info`](crate::ApplyTxn)).
44    fn tx_begin(&self, tx_id: u64) -> StreamBegin;
45
46    /// Repair after a `Failed` begin at commit time (the live pool reaps + respawns the
47    /// offending worker so the caller's retry can succeed). Headless: nothing to repair.
48    fn recover_failed_begin(&self) {}
49}
50
51/// The result of [`CommitFanout::tx_begin`]. Either way a stream handle is returned so
52/// whatever the observer pinned at the begin-barrier is always released.
53#[must_use = "a StreamBegin carries a stream that must be finished to release the fan-out"]
54pub enum StreamBegin {
55    Ready(Box<dyn FanoutStream>),
56    Failed(Box<dyn FanoutStream>),
57}
58
59/// One in-flight transaction's fan-out: bounded [`push`](Self::push) chunks, then a
60/// terminal [`finish`](Self::finish) handing back the commit-verdict gate. Dropping it
61/// without `finish` is an abort — the implementation must release anything it pinned.
62pub trait FanoutStream {
63    /// The transaction id this stream was opened for — carried on the stream so a
64    /// lazily-opened mid-transaction fan-out keeps its cursor without recomputation.
65    fn tx_id(&self) -> u64;
66
67    /// Forward one bounded chunk (≤ [`PUSH_CHUNK_ROWS`] rows, one shared `Arc` — a
68    /// refcount bump, never a row copy). May block for backpressure; must not fail
69    /// (a lost observer is the implementation's business to repair at `finish`).
70    fn push(&mut self, changes: Arc<[Captured]>);
71
72    /// Collect the begin-barrier acks sent back since [`CommitFanout::tx_begin`] — the
73    /// fence that must pass **before** the caller's durable COMMIT makes the transaction
74    /// visible (an observer pinning late would see a base that already contains it).
75    /// Called at the commit edge, after the last [`push`](Self::push); in the common case
76    /// the acks arrived while the caller ran its own statements and nothing parks.
77    /// `false` ⇒ an observer died or stayed stuck past the fence's watchdog: the caller
78    /// must NOT commit — finish with an abort,
79    /// [`recover_failed_begin`](CommitFanout::recover_failed_begin), and fail the write.
80    /// Headless observers have no acks to collect; the default always passes.
81    fn await_acks(&mut self) -> bool {
82        true
83    }
84
85    /// Close the push stream and return the gate the caller releases after (or instead
86    /// of) the durable COMMIT.
87    fn finish(self: Box<Self>) -> Box<dyn FanoutGate>;
88}
89
90/// The commit verdict for an in-flight fan-out, released after the writer's durable
91/// COMMIT (or its failure).
92pub trait FanoutGate {
93    /// The COMMIT succeeded: the observer publishes what it derived.
94    fn commit(self: Box<Self>);
95
96    /// The COMMIT failed (or never ran): the observer discards / recovers.
97    fn abort(self: Box<Self>);
98}
99
100/// The headless fan-out: accepts pushes by dropping them, commits trivially. What a
101/// consumer that applies a foreign CDC stream with **no derivation** composes with —
102/// `rindle-backup-sqlite`'s portable replay, an external SQLite-mirroring consumer.
103/// `tx_begin` never fails, so the `Failed` arms of the transaction machinery are
104/// structurally unreachable headless.
105pub struct NoFanout;
106
107impl CommitFanout for NoFanout {
108    fn tx_begin(&self, tx_id: u64) -> StreamBegin {
109        StreamBegin::Ready(Box::new(NoStream { tx_id }))
110    }
111}
112
113struct NoStream {
114    tx_id: u64,
115}
116
117impl FanoutStream for NoStream {
118    fn tx_id(&self) -> u64 {
119        self.tx_id
120    }
121
122    fn push(&mut self, _changes: Arc<[Captured]>) {}
123
124    fn finish(self: Box<Self>) -> Box<dyn FanoutGate> {
125        Box::new(NoGate)
126    }
127}
128
129struct NoGate;
130
131impl FanoutGate for NoGate {
132    fn commit(self: Box<Self>) {}
133
134    fn abort(self: Box<Self>) {}
135}