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)
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(®) {
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(®) {
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[®];
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(®).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(®);
338 }
339 }
340 }
341}