Rindle docs and package mapSkip to main content

rindle_replica/
writer.rs

1//! The single-writer transaction: capture, derive against a read-only overlay, commit, deliver.
2//!
3//! The crate OWNS the transaction boundaries (we wrap `begin`/`commit`, we do not use
4//! SQLite's commit/rollback hooks): this is what lets us run the IVM derivation at
5//! exactly the right moment — after the writes are captured, against a pre-commit
6//! snapshot, before the durable COMMIT.
7
8use std::rc::Rc;
9
10use rindle::graph::NodeId;
11use rindle::value::OwnedValue;
12use rindle::{CaughtChange, RindleError};
13use rusqlite::params_from_iter;
14
15use crate::engine::Engine;
16use crate::mutations::MutationSql;
17use crate::query::{Callbacks, RegId};
18use crate::sql::{from_sql, to_value};
19use crate::{Inner, ReplicaError, TxId, Update};
20
21/// What one committed transaction did, beyond its data effects: the new global tx id
22/// (the commit version `cv` the optimistic protocol stamps on outgoing batches).
23/// Moved to the apply plane (the shared write-transaction state machine mints it —
24/// design 309); re-exported here so `rindle_replica::CommitInfo` is unchanged.
25pub use crate::apply::CommitInfo;
26
27/// An open write transaction on the single writer connection. Run ordinary SQL with
28/// [`exec`](WriteTxn::exec)/[`exec_batch`](WriteTxn::exec_batch); the preupdate hook
29/// captures row deltas. [`commit`](WriteTxn::commit) derives against a read-only
30/// pre-commit snapshot and batch overlay, commits SQL, then calls subscribers.
31/// Dropping without committing rolls back and delivers no events.
32pub struct WriteTxn {
33    inner: Rc<Inner>,
34    /// Set once committed or rolled back, so `Drop` doesn't double-finish.
35    done: bool,
36}
37
38/// Open the writer transaction. Batch-overlay derivation never writes from the worker,
39/// so an ordinary `BEGIN IMMEDIATE` is exact (the writer is already the sole committer
40/// behind `in_write`).
41pub(crate) fn begin(inner: Rc<Inner>) -> Result<WriteTxn, ReplicaError> {
42    if inner.in_write.get() {
43        return Err(ReplicaError::Open(
44            "a write transaction is already open (single writer)".into(),
45        ));
46    }
47    inner.cdc.reset(); // drop any stale captures
48    inner
49        .writer
50        .execute_batch("BEGIN IMMEDIATE")
51        .map_err(|e| ReplicaError::sqlite("writer BEGIN", e))?;
52    inner.in_write.set(true);
53    Ok(WriteTxn { inner, done: false })
54}
55
56impl WriteTxn {
57    /// Run one statement with positional parameters inside the open transaction.
58    /// Returns the number of rows changed. The preupdate hook captures the row deltas.
59    pub fn exec(&mut self, sql: &str, params: &[OwnedValue]) -> Result<usize, ReplicaError> {
60        self.inner
61            .writer
62            .execute(sql, params_from_iter(params.iter().map(to_value)))
63            .map_err(|e| ReplicaError::sqlite("exec", e))
64    }
65
66    /// Run a batch of statements (no parameters) inside the open transaction.
67    pub fn exec_batch(&mut self, sql: &str) -> Result<(), ReplicaError> {
68        self.inner
69            .writer
70            .execute_batch(sql)
71            .map_err(|e| ReplicaError::sqlite("exec_batch", e))
72    }
73
74    /// Commit: derive each query's incremental change against the pre-commit snapshot,
75    /// persist the tx watermark + COMMIT the durable data, then deliver
76    /// `Update::Changed` to affected subscribers. Returns the new tx id.
77    ///
78    /// Subscriber callbacks run synchronously after commit. A callback panic does not
79    /// roll back the SQL transaction.
80    ///
81    /// A transaction that exceeds the batch-delta memory budget still commits — it is
82    /// **shed** (design 306 D4): the engine rebuilds and every query re-hydrates,
83    /// subscribers receiving a replacing [`Update::Hydrated`] instead of `Changed`
84    /// events (see [`CommitInfo::shed`]). Any other derive error aborts the
85    /// transaction as before.
86    pub fn commit(self) -> Result<TxId, ReplicaError> {
87        self.commit_with_info().map(|info| info.tx_id)
88    }
89
90    /// [`commit`](Self::commit), additionally reporting the transaction's
91    /// [`CommitInfo`] — the `cv` to stamp on outgoing batches. The whole capture
92    /// (including `_rindle_client_mutations` rows) feeds the engine; lmid flows to
93    /// clients as ordinary query data (see `crate::mutations`).
94    pub fn commit_with_info(mut self) -> Result<CommitInfo, ReplicaError> {
95        self.done = true;
96        let inner = self.inner.clone();
97
98        // A capture error (e.g. invalid UTF-8) poisons the whole transaction.
99        if let Some(e) = inner.cdc.take_error() {
100            self.abort(&inner);
101            return Err(e.into());
102        }
103        let uncaptured = inner.cdc.uncaptured_user_event_count();
104        if uncaptured != 0 {
105            self.abort(&inner);
106            return Err(ReplicaError::Capture(format!(
107                "write transaction modified {uncaptured} row(s) in an unregistered application table"
108            )));
109        }
110
111        let captured = inner.cdc.drain();
112        let tx_id = inner.committed_tx.get() + 1;
113
114        // Derive per-query deltas while the writer txn is still OPEN (snapshot = T-1).
115        let mut shed = false;
116        let per_query: Vec<(NodeId, Vec<CaughtChange>)> = if captured.is_empty() {
117            Vec::new()
118        } else {
119            // Bind the result BEFORE matching: the shed arm re-borrows the engine
120            // mutably, so the scrutinee's shared borrow must already be released.
121            let derived = inner.engine.borrow().apply_batch(captured);
122            match derived {
123                Ok(pq) => pq,
124                // The D4 delta-overflow SHED (design 306): the transaction outgrew the
125                // batch delta, so it cannot be derived incrementally — but the DATA is
126                // fine, and failing a write the storage layer happily holds would be
127                // wrong. Treat it like load shedding: swap in a fresh engine now (while
128                // we can still abort if even that fails), COMMIT the data, and
129                // re-hydrate every query from the committed state below.
130                Err(ReplicaError::Rindle(RindleError::DeltaOverflow { .. })) => {
131                    if let Err(e) = rebuild_engine(&inner) {
132                        self.abort(&inner);
133                        return Err(e);
134                    }
135                    shed = true;
136                    Vec::new()
137                }
138                Err(e) => {
139                    self.abort(&inner);
140                    return Err(e);
141                }
142            }
143        };
144
145        // Persist the watermark IN the writer txn, then COMMIT (data + tx_id atomic).
146        if let Err(e) = persist_and_commit(&inner, tx_id) {
147            self.abort(&inner);
148            return Err(e);
149        }
150        inner.committed_tx.set(tx_id);
151        inner.in_write.set(false);
152
153        // Deliver AFTER the durable commit, so subscribers never see a tx that failed.
154        // A shed re-hydrates instead: registration on the fresh engine reads the
155        // just-committed state, and each subscriber gets a replacing `Hydrated`.
156        if shed {
157            rehydrate_all(&inner, tx_id);
158        } else {
159            deliver(&inner, tx_id, per_query);
160        }
161        Ok(CommitInfo {
162            tx_id: TxId(tx_id),
163            shed,
164        })
165    }
166
167    /// Explicitly roll back. Leaves every view untouched; delivers nothing.
168    pub fn rollback(mut self) {
169        self.done = true;
170        let inner = self.inner.clone();
171        self.abort(&inner);
172    }
173
174    /// Roll back the writer txn and clear capture + the write guard.
175    fn abort(&self, inner: &Inner) {
176        let _ = inner.writer.execute_batch("ROLLBACK");
177        inner.cdc.reset();
178        inner.in_write.set(false);
179    }
180}
181
182impl Drop for WriteTxn {
183    fn drop(&mut self) {
184        if !self.done {
185            let _ = self.inner.writer.execute_batch("ROLLBACK");
186            self.inner.cdc.reset();
187            self.inner.in_write.set(false);
188        }
189    }
190}
191
192/// The SQL `MutationTx` flavor (design §4.2): a server mutator runs against the open
193/// single-writer transaction, reading through the same connection so it sees its own
194/// uncommitted writes.
195impl MutationSql for WriteTxn {
196    fn exec(&mut self, sql: &str, params: &[OwnedValue]) -> Result<usize, ReplicaError> {
197        WriteTxn::exec(self, sql, params)
198    }
199
200    fn query(
201        &mut self,
202        sql: &str,
203        params: &[OwnedValue],
204    ) -> Result<Vec<Vec<OwnedValue>>, ReplicaError> {
205        let map_err = |e| ReplicaError::sqlite("mutator query", e);
206        let mut stmt = self.inner.writer.prepare(sql).map_err(map_err)?;
207        let n = stmt.column_count();
208        let rows = stmt
209            .query_map(params_from_iter(params.iter().map(to_value)), |r| {
210                (0..n).map(|i| from_sql(r.get_ref(i)?, i)).collect()
211            })
212            .map_err(map_err)?
213            .collect::<rusqlite::Result<Vec<Vec<OwnedValue>>>>()
214            .map_err(map_err)?;
215        Ok(rows)
216    }
217}
218
219/// Upsert the committed-tx watermark into `__replica_meta` (inside the writer txn, so
220/// it is durable atomically with the data), then COMMIT.
221fn persist_and_commit(inner: &Inner, tx_id: u64) -> Result<(), ReplicaError> {
222    inner
223        .writer
224        .execute(
225            "INSERT INTO __replica_meta(id, tx_id) VALUES(0, ?1) \
226             ON CONFLICT(id) DO UPDATE SET tx_id = excluded.tx_id",
227            [tx_id as i64],
228        )
229        .map_err(|e| ReplicaError::sqlite("persist watermark", e))?;
230    inner
231        .writer
232        .execute_batch("COMMIT")
233        .map_err(|e| ReplicaError::sqlite("COMMIT", e))?;
234    Ok(())
235}
236
237/// Deliver `Update::Changed` to each affected query's subscribers, without holding the
238/// `subs` borrow across user callbacks (they may register/inspect).
239fn deliver(inner: &Inner, tx_id: u64, per_query: Vec<(NodeId, Vec<CaughtChange>)>) {
240    for (sink, changes) in per_query {
241        // The engine reports by sink; subscriptions are keyed by the stable `RegId`.
242        let Some(reg) = reg_of(inner, sink) else {
243            continue;
244        };
245        let update = Update::Changed {
246            tx_id: TxId(tx_id),
247            changes,
248        };
249        fire(inner, reg, &update);
250    }
251}
252
253/// The stable registration id currently wired to the engine sink `sink`, if any.
254fn reg_of(inner: &Inner, sink: NodeId) -> Option<RegId> {
255    inner
256        .subs
257        .borrow()
258        .iter()
259        .find(|(_, entry)| entry.sink == sink)
260        .map(|(&reg, _)| reg)
261}
262
263/// Fire `update` at one query's subscribers. Takes the callbacks out of the entry so a
264/// callback can't deadlock on the `subs` borrow, then puts them back (preserving any
265/// added during delivery, which sit after).
266fn fire(inner: &Inner, reg: RegId, update: &Update) {
267    let mut cbs: Callbacks = {
268        let mut subs = inner.subs.borrow_mut();
269        match subs.get_mut(&reg) {
270            Some(entry) => std::mem::take(&mut entry.callbacks),
271            None => return,
272        }
273    };
274    for cb in cbs.iter_mut() {
275        cb(update);
276    }
277    let mut subs = inner.subs.borrow_mut();
278    if let Some(entry) = subs.get_mut(&reg) {
279        cbs.append(&mut entry.callbacks);
280        entry.callbacks = cbs;
281    }
282}
283
284/// The pre-commit half of the D4 shed: replace the (torn) engine with a fresh one,
285/// sources re-registered from the cached schemas — the single-thread twin of the
286/// worker threads' `fault_recover`, minus the `Faulted` events (queries re-hydrate in
287/// place below instead). Runs while the writer txn is still open so a failure here can
288/// still abort the transaction (the only fallible pieces are the operator-scratch
289/// backend and source re-registration).
290fn rebuild_engine(inner: &Inner) -> Result<(), ReplicaError> {
291    let mut engine = inner.engine.borrow_mut();
292    let mut fresh = Engine::new(
293        engine.worker_conn(),
294        engine.plan_queries(),
295        engine.operator_storage(),
296    )?;
297    fresh.set_max_delta_bytes(engine.max_delta_bytes());
298    for (table, ts) in inner.tables.borrow().iter() {
299        fresh.register_table(table, ts.clone())?;
300    }
301    *engine = fresh; // drops the torn graph + all its pipelines
302    Ok(())
303}
304
305/// The post-commit half of the D4 shed: re-register every subscribed query on the
306/// fresh engine (hydration reads the just-committed state) and hand each subscriber a
307/// replacing [`Update::Hydrated`]. A query whose re-registration fails (e.g. a §5.3
308/// sum overflow over the new state — a fresh `SELECT` errors too) is dropped from the
309/// registry: it can deliver no honest events, and its handle's `subscribe`/`destroy`
310/// degrade to no-ops.
311fn rehydrate_all(inner: &Inner, tx_id: u64) {
312    let mut regs: Vec<RegId> = inner.subs.borrow().keys().copied().collect();
313    regs.sort_unstable(); // registration order — deterministic delivery
314    for reg in regs {
315        let (query_id, ast) = {
316            let subs = inner.subs.borrow();
317            let entry = &subs[&reg];
318            (entry.query_id, entry.ast.clone())
319        };
320        let registered = inner.engine.borrow_mut().register_query(query_id, &ast);
321        match registered {
322            Ok((sink, initial)) => {
323                {
324                    let mut subs = inner.subs.borrow_mut();
325                    let entry = subs.get_mut(&reg).expect("entry collected above");
326                    entry.sink = sink;
327                    entry.hydrated = initial.clone();
328                    entry.hydrated_tx = tx_id;
329                }
330                let update = Update::Hydrated {
331                    tx_id: TxId(tx_id),
332                    changes: initial,
333                };
334                fire(inner, reg, &update);
335            }
336            Err(_) => {
337                inner.subs.borrow_mut().remove(&reg);
338            }
339        }
340    }
341}