Rindle docs and package mapSkip to main content

rindle_cdc_apply/
txn.rs

1//! [`ApplyTxn`] — the single-writer transaction state machine of the apply plane,
2//! lifted from `rindle-replica`'s `ClusterWriteTxn` (design 309 §3). Run SQL with
3//! [`exec`](ApplyTxn::exec)/[`exec_batch`](ApplyTxn::exec_batch); the preupdate hook
4//! captures the row deltas; [`commit_with_info`](ApplyTxn::commit_with_info) drives the
5//! begin-barrier / push / finish / gate handshake against whatever [`CommitFanout`] the
6//! transaction was opened with. Dropping without committing rolls back.
7//!
8//! Control flow is IDENTICAL whether the fan-out is the live replica's worker pool or
9//! [`NoFanout`](super::NoFanout): the capture buffer still drains between statements at
10//! [`PUSH_CHUNK_ROWS`] (the writer-side memory bound for a large restore transaction),
11//! the capture-integrity guards (error poisoning, the uncaptured-row silent-divergence
12//! check) still gate every commit — the barriers just have no one to hold headless.
13
14use std::rc::Rc;
15
16use rindle_value::value::OwnedValue;
17use rusqlite::{params_from_iter, Connection};
18
19use rindle_cdc::Captured;
20use rindle_writeplane::{
21    classify_statement, install_public_authorizer, run_statement, sql, statement_is_insert,
22    writeplane, ReplicaError, SqlStatementRequest, StatementClass, StatementResult,
23    StatementRunError, TxId, SQL_RESULT_BYTE_LIMIT,
24};
25
26use super::fanout::{CommitFanout, FanoutStream, StreamBegin, PUSH_CHUNK_ROWS};
27use super::store::ApplyStore;
28
29/// What one committed transaction did, beyond its data effects: the new global tx id
30/// (the commit version `cv` the optimistic protocol stamps on outgoing batches).
31/// Client `lmid` advances are NOT reported here — `_rindle_client_mutations` rows ride
32/// the capture like any data and reach each client through its own system query (§8.2).
33#[derive(Clone, Debug)]
34pub struct CommitInfo {
35    pub tx_id: TxId,
36    /// True when this transaction overflowed the batch-delta row cap (design 306 D4)
37    /// and was **shed**: the data committed, but no incremental `Changed` events were
38    /// derived — the engine was rebuilt and every registered query re-hydrated, its
39    /// subscribers receiving a fresh `Update::Hydrated` instead. The parallel
40    /// `Cluster` reports its sheds per query via `ClusterEvent::Faulted`, never here;
41    /// a headless applier has nothing to shed.
42    pub shed: bool,
43}
44
45/// An open write transaction on the store's single writer connection. See the module
46/// docs; the derivation host's `ClusterWriteTxn` is a thin public wrapper over this.
47pub struct ApplyTxn {
48    store: Rc<ApplyStore>,
49    fanout: Rc<dyn CommitFanout>,
50    /// Set once committed or rolled back, so `Drop` doesn't double-finish.
51    done: bool,
52    /// The streaming fan-out for this transaction, opened **eagerly** at the first captured
53    /// change (`maybe_pump` — the begin-barrier *send*, `follow-ups/cluster-barrier-wakeup.md`
54    /// §6.1, so a live pool pins its snapshots concurrently with the writer's remaining
55    /// statements); the capture then forwards as bounded chunks between statements once it
56    /// crosses `PUSH_CHUNK_ROWS`, instead of buffering the whole txn on the writer
57    /// (`CLUSTER-INCREMENTAL-FANOUT-DESIGN.md` §7). `None` until the first captured change —
58    /// an empty txn never opens it and commits via the one-shot begin-barrier in
59    /// [`commit_with_info`](Self::commit_with_info). Dropping it (an abort/rollback) releases
60    /// whatever the fan-out pinned; a worker pool that already received pushes tears down +
61    /// re-hydrates (rehydrate-all, §5) — so rollback of a *streamed* txn is no longer free.
62    stream: Option<Box<dyn FanoutStream>>,
63    /// Set if the begin-barrier send failed mid-transaction (a worker was dead before the
64    /// first push): stop streaming and let the changes accumulate, so `commit_with_info` falls
65    /// back to the one-shot barrier (which repairs + fails cleanly + asks the caller to retry).
66    /// `exec` therefore never gains a mid-statement "worker died" failure. Nothing was pushed
67    /// before a first-open failure, so the fallback is a clean abort, not a rehydrate.
68    stream_failed: bool,
69}
70
71impl ApplyTxn {
72    /// Open the single-writer transaction (the store's `writer_begin_sql` flavor).
73    /// Errors if one is already open.
74    pub fn begin(
75        store: Rc<ApplyStore>,
76        fanout: Rc<dyn CommitFanout>,
77    ) -> Result<ApplyTxn, ReplicaError> {
78        if store.in_write.get() {
79            return Err(ReplicaError::Open(
80                "a write transaction is already open (single writer)".into(),
81            ));
82        }
83        store.cdc.reset();
84        store
85            .writer
86            .execute_batch(store.writer_begin_sql)
87            .map_err(|e| ReplicaError::sqlite("writer BEGIN", e))?;
88        store.in_write.set(true);
89        Ok(ApplyTxn {
90            store,
91            fanout,
92            done: false,
93            stream: None,
94            stream_failed: false,
95        })
96    }
97
98    /// The store this transaction is open on.
99    pub fn store(&self) -> &ApplyStore {
100        &self.store
101    }
102
103    /// Run one statement with positional parameters inside the open transaction.
104    pub fn exec(&mut self, sql: &str, params: &[OwnedValue]) -> Result<usize, ReplicaError> {
105        let n = {
106            let mut statement = self
107                .store
108                .writer
109                .prepare_cached(sql)
110                .map_err(|e| ReplicaError::sqlite("prepare exec", e))?;
111            statement
112                .execute(params_from_iter(params.iter().map(sql::to_value)))
113                .map_err(|e| ReplicaError::sqlite("exec", e))?
114        };
115        self.maybe_pump();
116        Ok(n)
117    }
118
119    /// Run one mutation statement under the shared writer time/VM budget and, for guarded public
120    /// mutations, the reserved-object authorizer. This is the bounded twin of [`exec`](Self::exec)
121    /// used by a standalone daemon's write plane; it still pumps capture between statements.
122    pub fn exec_bounded(
123        &mut self,
124        sql: &str,
125        params: &[OwnedValue],
126        guarded: bool,
127    ) -> Result<usize, StatementRunError> {
128        let conn = &self.store.writer;
129        let result = writeplane::with_writer_statement_budget(conn, false, || {
130            let _authorizer =
131                guarded.then(|| install_public_authorizer(conn, StatementClass::Write));
132            conn.execute(
133                sql,
134                params_from_iter(params.iter().map(rindle_writeplane::sql::to_value)),
135            )
136        })?;
137        let changed = result?;
138        self.maybe_pump();
139        Ok(changed)
140    }
141
142    /// Run one v1 public-SQL statement through the shared classifier/authorizer and writer
143    /// budget. Interactive calls request a savepoint so ordinary statement failures preserve the
144    /// surrounding transaction; one-shot batches let their coordinator roll the whole unit back.
145    pub fn public_statement(
146        &mut self,
147        request: &SqlStatementRequest,
148        preserve_on_error: bool,
149        result_byte_limit: usize,
150    ) -> Result<StatementResult, StatementRunError> {
151        let class = classify_statement(&request.sql);
152        if class == StatementClass::Ddl {
153            return Err(StatementRunError::Unsupported {
154                code: "DDL_IN_TRANSACTION",
155                message: "DDL is not supported inside an interactive transaction".into(),
156            });
157        }
158
159        let checkpoint = self.store.cdc.checkpoint();
160        let uncaptured_before = self.store.cdc.uncaptured_user_event_count();
161        if preserve_on_error {
162            if let Err(error) = self
163                .store
164                .writer
165                .execute_batch("SAVEPOINT _rindle_sql_statement")
166            {
167                self.abort_in_place();
168                return Err(StatementRunError::Sqlite(error));
169            }
170        }
171
172        let report_rowid = class == StatementClass::Write && statement_is_insert(&request.sql);
173        let attempted = (|| {
174            let result =
175                writeplane::with_writer_statement_budget(&self.store.writer, false, || {
176                    run_statement(&self.store.writer, request, class, report_rowid)
177                })??;
178            if let Some(error) = self.store.cdc.take_error() {
179                return Err(StatementRunError::ValueUnsupported(error.to_string()));
180            }
181            let uncaptured = self
182                .store
183                .cdc
184                .uncaptured_user_event_count()
185                .saturating_sub(uncaptured_before);
186            if uncaptured != 0 {
187                return Err(StatementRunError::Unsupported {
188                    code: "STATEMENT_FAILED",
189                    message: format!(
190                        "silent-divergence guard: SQL modified {uncaptured} row(s) in an \
191                         unregistered application table"
192                    ),
193                });
194            }
195            let encoded_len = serde_json::to_vec(&result.to_wire_json()?)
196                .map_err(|error| StatementRunError::Malformed(error.to_string()))?
197                .len();
198            if encoded_len > result_byte_limit {
199                return Err(StatementRunError::ResultLimit(format!(
200                    "aggregate SQL result exceeds the {SQL_RESULT_BYTE_LIMIT}-byte limit"
201                )));
202            }
203            Ok(result)
204        })();
205
206        match attempted {
207            Ok(result) => {
208                if preserve_on_error {
209                    if let Err(error) = self
210                        .store
211                        .writer
212                        .execute_batch("RELEASE _rindle_sql_statement")
213                    {
214                        self.abort_in_place();
215                        return Err(StatementRunError::Sqlite(error));
216                    }
217                }
218                self.maybe_pump();
219                Ok(result)
220            }
221            Err(error) => {
222                if preserve_on_error {
223                    if self
224                        .store
225                        .writer
226                        .execute_batch(
227                            "ROLLBACK TO _rindle_sql_statement; RELEASE _rindle_sql_statement",
228                        )
229                        .is_err()
230                    {
231                        self.abort_in_place();
232                    } else {
233                        self.store.cdc.rewind(checkpoint);
234                    }
235                }
236                Err(error)
237            }
238        }
239    }
240
241    /// Whether the underlying writer transaction is still open (neither finished by this
242    /// handle nor auto-aborted by SQLite).
243    pub fn is_open(&self) -> bool {
244        !self.done && !self.store.writer.is_autocommit()
245    }
246
247    /// How many user-table row events the capture hook has recorded this transaction.
248    pub fn captured_user_event_count(&self) -> usize {
249        self.store.cdc.event_count()
250    }
251
252    /// The tx id this transaction will commit as.
253    pub fn next_tx_id(&self) -> TxId {
254        TxId(self.store.committed_tx.get() + 1)
255    }
256
257    /// The open transaction's connection for narrow host bookkeeping helpers. Anything
258    /// written here still passes through the capture hook (the hook is on the
259    /// connection), so application rows written raw are counted — but hosts should stay
260    /// on the typed mutation methods and keep this for unregistered bookkeeping.
261    pub fn connection(&self) -> &Connection {
262        &self.store.writer
263    }
264
265    /// Run a batch of statements (no parameters) inside the open transaction.
266    pub fn exec_batch(&mut self, sql: &str) -> Result<(), ReplicaError> {
267        self.store
268            .writer
269            .execute_batch(sql)
270            .map_err(|e| ReplicaError::sqlite("exec_batch", e))?;
271        self.maybe_pump();
272        Ok(())
273    }
274
275    /// Open the begin-barrier at the transaction's **first captured change** (a send, not a
276    /// wait — `follow-ups/cluster-barrier-wakeup.md` §6.1: a live pool's workers wake, pin,
277    /// and ack while the writer keeps executing statements; the acks are collected at the
278    /// commit edge in [`finish_stream`](Self::finish_stream)), then forward the capture to
279    /// the fan-out as bounded chunks once it crosses `PUSH_CHUNK_ROWS`. Called between
280    /// statements so the writer's capture buffer stays ~`PUSH_CHUNK_ROWS` for a large
281    /// transaction instead of growing to the whole txn
282    /// (`CLUSTER-INCREMENTAL-FANOUT-DESIGN.md` §7); a small txn never crosses the push
283    /// threshold and streams its whole capture at commit — through the barrier opened here,
284    /// already acked by then in the common case. The hook fires once per *row*, so a
285    /// transaction built from single-row statements (the replicator follower's apply path,
286    /// `apply_muts`) pumps at exactly the chunk boundary — the §7 floor of "the largest single
287    /// statement" is one row there, so the bound is truly `O(chunk)`.
288    ///
289    /// A begin-barrier send failure here is non-fatal: stop streaming (`stream_failed`) and
290    /// leave the changes buffered, so the txn falls back to the commit-time barrier. Nothing
291    /// was pushed before a first-open failure, so that fallback is a clean abort, not a
292    /// rehydrate.
293    fn maybe_pump(&mut self) {
294        if self.stream_failed {
295            return;
296        }
297        if self.stream.is_none() {
298            if self.store.cdc.buffer_len() == 0 {
299                return;
300            }
301            let tx_id = self.store.committed_tx.get() + 1;
302            match self.fanout.tx_begin(tx_id) {
303                StreamBegin::Ready(stream) => self.stream = Some(stream),
304                StreamBegin::Failed(stream) => {
305                    // Nothing pushed yet → a clean snapshot rollback of whatever pinned.
306                    stream.finish().abort();
307                    self.stream_failed = true;
308                    return;
309                }
310            }
311        }
312        if self.store.cdc.buffer_len() < PUSH_CHUNK_ROWS {
313            return;
314        }
315        let drained = self.store.cdc.drain();
316        let stream = self.stream.as_mut().expect("opened above");
317        for chunk in drained.chunks(PUSH_CHUNK_ROWS) {
318            stream.push(chunk.to_vec().into());
319        }
320    }
321
322    /// Run a read **through the open transaction** (sees its own uncommitted writes — the
323    /// read-dependent mutator contract, §4.1), each cell mapped from its raw SQLite storage
324    /// class.
325    pub fn query(
326        &mut self,
327        sql_text: &str,
328        params: &[OwnedValue],
329    ) -> Result<Vec<Vec<OwnedValue>>, ReplicaError> {
330        let map_err = |e| ReplicaError::sqlite("mutator query", e);
331        let mut stmt = self.store.writer.prepare(sql_text).map_err(map_err)?;
332        let n = stmt.column_count();
333        let rows = stmt
334            .query_map(params_from_iter(params.iter().map(sql::to_value)), |r| {
335                (0..n).map(|i| sql::from_sql(r.get_ref(i)?, i)).collect()
336            })
337            .map_err(map_err)?
338            .collect::<rusqlite::Result<Vec<Vec<OwnedValue>>>>()
339            .map_err(map_err)?;
340        Ok(rows)
341    }
342
343    /// [`query`](Self::query), additionally reporting the result's column names in order —
344    /// what a network front needs to answer a mutator-session read (`{cols, rows}` on the
345    /// wire, zipped client-side; DAEMON-INTERACTIVE-TXN-DESIGN.md §4.1). Same open-transaction
346    /// read-your-writes semantics and raw-storage-class cell mapping.
347    pub fn query_with_cols(
348        &mut self,
349        sql_text: &str,
350        params: &[OwnedValue],
351    ) -> Result<(Vec<String>, Vec<Vec<OwnedValue>>), ReplicaError> {
352        let map_err = |e| ReplicaError::sqlite("mutator query", e);
353        let mut stmt = self.store.writer.prepare(sql_text).map_err(map_err)?;
354        // The session-read contract (`rindle_writeplane::session::SessionBackend::query`): a
355        // write smuggled through the read path would bypass the between-statement capture pump
356        // and the exec-side accounting, so reject it up front rather than trust the caller's
357        // compiler to only emit SELECTs.
358        if !stmt.readonly() {
359            return Err(ReplicaError::ReadRejected(
360                "mutator session query must be read-only".into(),
361            ));
362        }
363        let cols: Vec<String> = stmt.column_names().iter().map(|c| c.to_string()).collect();
364        let n = cols.len();
365        let rows = stmt
366            .query_map(params_from_iter(params.iter().map(sql::to_value)), |r| {
367                (0..n).map(|i| sql::from_sql(r.get_ref(i)?, i)).collect()
368            })
369            .map_err(map_err)?
370            .collect::<rusqlite::Result<Vec<Vec<OwnedValue>>>>()
371            .map_err(map_err)?;
372        Ok((cols, rows))
373    }
374
375    /// Commit: run the fan-out's begin-barrier, persist the watermark + COMMIT the durable
376    /// data, then release the gate. Returns the new tx id.
377    pub fn commit(self) -> Result<TxId, ReplicaError> {
378        self.commit_with_info().map(|info| info.tx_id)
379    }
380
381    /// [`commit`](Self::commit), additionally reporting the transaction's [`CommitInfo`] —
382    /// the `cv` to stamp on outgoing batches.
383    ///
384    /// The whole capture — INCLUDING any `_rindle_client_mutations` rows — flows to the
385    /// fan-out: on the live replica the lmid table is engine-hosted like any base table, so a
386    /// client's lmid advance derives through its own system query and is released by the same
387    /// `cv_min` as the commit's data (no metadata side-channel to race). An empty capture still
388    /// crosses the barrier: it has no data to derive, but on the live replica every worker must
389    /// emit `Progressed(N)` so an older live query cannot pin a later query's seq-0 snapshot
390    /// below its hydrate CV forever.
391    pub fn commit_with_info(mut self) -> Result<CommitInfo, ReplicaError> {
392        self.done = true;
393        let store = self.store.clone();
394
395        // A capture error (e.g. invalid UTF-8) poisons the whole transaction.
396        if let Some(e) = store.cdc.take_error() {
397            self.abort(&store);
398            return Err(e.into());
399        }
400        let uncaptured = store.cdc.uncaptured_user_event_count();
401        if uncaptured != 0 {
402            self.abort(&store);
403            return Err(ReplicaError::Capture(format!(
404                "write transaction modified {uncaptured} row(s) in an unregistered application table"
405            )));
406        }
407
408        // Whatever was captured since the last between-statement flush (`maybe_pump`): on a
409        // streamed txn just the tail (often only the lmid row, §4); on a small txn the whole
410        // transaction.
411        let remainder = store.cdc.drain();
412
413        // Already streaming this txn (its begin-barrier sent at the first captured change):
414        // flush the tail and finish the open begin-barrier — no second barrier, no
415        // empty-commit fast path.
416        if let Some(stream) = self.stream.take() {
417            return self.finish_stream(&store, stream, remainder);
418        }
419
420        let tx_id = store.committed_tx.get() + 1;
421
422        // The fallback path for a txn that never opened its stream (an empty capture, or a
423        // begin-barrier send failure mid-txn): send the begin-barrier now, then the
424        // (lmid-inclusive) capture streams as bounded `Arc` chunks and `finish_stream`
425        // collects the acks — for this path the fence is at its old commit-time position,
426        // with only the push work to overlap. A txn ≤ `PUSH_CHUNK_ROWS` is one chunk
427        // (wire-identical to the old batch). The empty case deliberately takes this path
428        // too: zero chunks are pushed, but the committed finish makes a live pool publish
429        // `Progressed(tx_id)`.
430        match self.fanout.tx_begin(tx_id) {
431            StreamBegin::Ready(stream) => self.finish_stream(&store, stream, remainder),
432            StreamBegin::Failed(stream) => {
433                // A worker was dead at the begin-barrier send — *before* any push, so nothing
434                // derived. The writer has NOT committed: roll the reached observers back
435                // cleanly (no fault), roll back the writer, repair the fan-out (reap +
436                // respawn the offender), and fail the write so the caller retries. The
437                // single-writer invariant holds — no observer can have missed a committed tx.
438                // Structurally unreachable under `NoFanout` (its begin never fails).
439                stream.finish().abort();
440                self.abort(&store);
441                self.fanout.recover_failed_begin();
442                Err(ReplicaError::Open(
443                    "a worker failed the commit barrier; it was respawned — retry the write".into(),
444                ))
445            }
446        }
447    }
448
449    /// Commit host-local metadata when the public SQL unit captured no application effects.
450    /// This deliberately does not mint a `TxId`: the retained outcome has `cursor = NULL`, and
451    /// no progress boundary exists for a metadata-only cache write.
452    pub fn commit_metadata_only(mut self) -> Result<(), ReplicaError> {
453        self.done = true;
454        let store = self.store.clone();
455        if let Some(error) = store.cdc.take_error() {
456            self.abort(&store);
457            return Err(error.into());
458        }
459        let event_count = store.cdc.event_count();
460        let uncaptured = store.cdc.uncaptured_user_event_count();
461        if event_count != 0 || uncaptured != 0 || self.stream.is_some() {
462            self.abort(&store);
463            return Err(ReplicaError::Capture(format!(
464                "metadata-only commit observed {event_count} user event(s), including \
465                 {uncaptured} unregistered event(s)"
466            )));
467        }
468        if let Err(error) = store.writer.execute_batch("COMMIT") {
469            self.abort(&store);
470            return Err(ReplicaError::sqlite("metadata-only COMMIT", error));
471        }
472        store.cdc.reset();
473        store.in_write.set(false);
474        Ok(())
475    }
476
477    /// Drive an open streaming transaction to completion: push the final `remainder` chunks
478    /// (the tail since the last `maybe_pump`), collect the begin-barrier **acks** (the fence
479    /// sent back at `tx_begin`, overlapped with the writer's own statements — usually already
480    /// buffered, so no park), `persist_and_commit`, then release the gate — a live pool sends
481    /// each worker its terminal marker with the verdict inline — or, on a COMMIT failure
482    /// after a partial fan-out, `abort` (a pool whose workers advanced operator state the
483    /// snapshot rollback can't undo tears down + re-hydrates; abort≙epoch-rehydrate, §4.1).
484    /// Shared by the streamed-mid-flight path and the small-txn one-shot path; the cursor
485    /// rides on the stream (`tx_id`), already minted at begin.
486    fn finish_stream(
487        &self,
488        store: &ApplyStore,
489        mut stream: Box<dyn FanoutStream>,
490        remainder: Vec<Captured>,
491    ) -> Result<CommitInfo, ReplicaError> {
492        let tx_id = stream.tx_id();
493        for chunk in remainder.chunks(PUSH_CHUNK_ROWS) {
494            stream.push(chunk.to_vec().into());
495        }
496        // The fence: every observer still in the stream must have pinned its post-(N-1)
497        // snapshot before COMMIT makes tx N visible (a late pin would derive against a base
498        // that already contains N). A failure here is the begin-barrier failure of old,
499        // discovered at the commit edge: nothing may commit — release the stream with an
500        // abort (workers that were pushed to rehydrate; un-pushed ones roll back cleanly),
501        // roll back the writer, repair the fan-out (reap + respawn the offender), and fail
502        // the write so the caller retries. Structurally unreachable under `NoFanout` (no
503        // acks to collect).
504        if !stream.await_acks() {
505            stream.finish().abort();
506            self.abort(store);
507            self.fanout.recover_failed_begin();
508            return Err(ReplicaError::Open(
509                "a worker failed the commit barrier; it was respawned — retry the write".into(),
510            ));
511        }
512        let gate = stream.finish();
513        match store.persist_and_commit(tx_id) {
514            Ok(()) => {
515                store.committed_tx.set(tx_id);
516                store.in_write.set(false);
517                // Release the gate: a live pool emits the `commit N` markers (`Progressed`).
518                gate.commit();
519                // A worker that overflowed its batch delta sheds itself (tear down +
520                // `Faulted` per query, consumers re-hydrate) — the commit never reports
521                // `shed` here.
522                Ok(CommitInfo {
523                    tx_id: TxId(tx_id),
524                    shed: false,
525                })
526            }
527            Err(e) => {
528                gate.abort();
529                self.abort(store);
530                Err(e)
531            }
532        }
533    }
534
535    /// Explicitly roll back. Delivers nothing; a live pool's views are untouched.
536    pub fn rollback(mut self) {
537        self.done = true;
538        let store = self.store.clone();
539        self.abort(&store);
540    }
541
542    /// Roll back the writer txn and clear capture + the write guard.
543    fn abort(&self, store: &ApplyStore) {
544        let _ = store.writer.execute_batch("ROLLBACK");
545        store.cdc.reset();
546        store.in_write.set(false);
547    }
548
549    fn abort_in_place(&mut self) {
550        if self.done {
551            return;
552        }
553        self.done = true;
554        let store = self.store.clone();
555        self.abort(&store);
556    }
557}
558
559impl Drop for ApplyTxn {
560    fn drop(&mut self) {
561        if !self.done {
562            let _ = self.store.writer.execute_batch("ROLLBACK");
563            self.store.cdc.reset();
564            self.store.in_write.set(false);
565        }
566        // The `stream` field (if any) drops after this body: the fan-out releases whatever
567        // it pinned — a live pool's workers that received pushes tear down + re-hydrate.
568    }
569}