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}