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}