rindle_replica/cluster.rs
1//! A single writer/coordinator with independent live-query engines on worker threads.
2//! [`Db`](crate::Db) delivers callbacks on its owner thread; [`Cluster`] distributes
3//! query registrations and sends events through a bounded channel.
4//!
5//! ## Transaction and delivery order
6//!
7//! 1. [`write`](Cluster::write) opens the controlled writer with `BEGIN IMMEDIATE`.
8//! Captured row changes start the worker snapshot barrier. Each worker pins a
9//! read-only snapshot before the writer can commit.
10//! 2. Captured chunks stream to workers while SQL runs. Workers derive with an
11//! in-memory batch overlay and can emit provisional `Changed` slices immediately.
12//! 3. [`commit`](ClusterWriteTxn::commit) sends remaining chunks, waits for snapshot
13//! acknowledgments, commits SQL, and sends the commit verdict to the workers.
14//! 4. After all slices and a successful verdict, each worker emits `Progressed`.
15//! Failed or aborted speculative derivation faults affected query registrations.
16//!
17//! A receiver must buffer changes until progress from the relevant workers confirms
18//! them. A rollback after streamed changes requires discarding those changes and
19//! re-registering faulted queries. See [`ClusterEvent`](crate::ClusterEvent).
20//!
21//! Registration hydrates a committed snapshot and is rejected while workers hold a
22//! speculative view. Each query stays on one worker, preserving per-query order.
23//! The coordinator is `!Send`; only owned captures and events cross thread boundaries.
24//!
25//! ## Composition (design 309)
26//!
27//! The store half — connections, capture, the write-transaction state machine, DDL —
28//! lives in the engine-free apply plane ([`crate::apply`]): `Cluster` =
29//! [`ApplyStore`] + [`WorkerPool`] + a schema-only [`Engine`], with the pool behind
30//! the apply plane's [`CommitFanout`] seam ([`PoolFanout`]). Every store-half method
31//! here DELEGATES, so the live follower and a headless applier share one
32//! implementation.
33
34use std::cell::{Cell, RefCell};
35use std::path::Path;
36use std::rc::Rc;
37use std::sync::mpsc::Receiver;
38use std::sync::Arc;
39
40use rindle::value::{OwnedValue, Schema};
41use rindle::{Ast, CaughtChange};
42use rindle_cdc::Captured;
43use rusqlite::{Connection, OptionalExtension};
44
45use crate::analyze::AnalyzeReport;
46use crate::apply::{
47 ApplyStore, ApplyTxn, CommitFanout, CommitInfo, FanoutGate, FanoutStream,
48 StreamBegin as ApplyStreamBegin,
49};
50use crate::engine::{analyze_standalone, AnalyzeSpec, Engine, OperatorStorage};
51use crate::mutations::{
52 realtime_lifecycle_ddl, upsert_lmid, MutationEnvelope, MutationOutcome, MutationReject,
53 MutationSql, MutatorRegistry,
54};
55use crate::parallel::{
56 event_channel_bound, open_journal, CommitGate as PoolCommitGate,
57 StreamBegin as PoolStreamBegin, StreamTx as PoolStreamTx, WorkerPool,
58};
59use crate::schema;
60use crate::{
61 ClusterEvent, OpenOptions, QueryId, ReplicaError, SqlStatementRequest, StatementResult,
62 StatementRunError, TxId,
63};
64use crate::{
65 CLIENT_MUTATIONS_TABLE, ROOM_CLIENT_MUTATIONS_TABLE, ROOM_MUTATION_OUTCOMES_TABLE,
66 ROOM_WATERMARK_TABLE, SCOPE_SESSIONS_TABLE,
67};
68use rindle_wire::family_key::Binding;
69
70pub use crate::apply::DdlMigrationError;
71
72/// The worker pool behind the apply plane's [`CommitFanout`] seam: the cluster's
73/// derivation half. Owns the speculative-window bookkeeping too — the fence is read
74/// only by the derivation surface ([`Cluster::read_snapshot`] /
75/// [`Cluster::view_is_speculative`]), so it belongs on this side of the trait
76/// (design 309 §3).
77pub(crate) struct PoolFanout {
78 pub(crate) pool: WorkerPool,
79 pub(crate) n_workers: usize,
80 /// True once the open transaction has PUSHED a speculative chunk to the workers
81 /// (the capture pump crossed `PUSH_CHUNK_ROWS`) — the window in which a worker's
82 /// assembled view has advanced past the last commit with uncommitted rows.
83 /// Strictly narrower than the store's `in_write`: a transaction that never crosses
84 /// the streaming threshold buffers entirely on the writer, so every worker's view
85 /// still sits at the last commit and [`Cluster::read_snapshot`] stays serviceable
86 /// throughout. Cleared by commit and by every rollback path (the stream/gate
87 /// wrappers below own the transitions). `Rc` so the per-transaction wrappers share
88 /// the one cell.
89 ///
90 /// Note the eager begin-barrier (`ApplyTxn::maybe_pump`,
91 /// `follow-ups/cluster-barrier-wakeup.md` §6.1) opens the worker stream — and pins
92 /// each worker's snapshot — at the transaction's first *captured change*, before
93 /// anything is pushed. That wider window deliberately does NOT set this flag:
94 /// batch-overlay derivation never writes through the worker connection, so a
95 /// registration's cold hydrate inside the pinned (plain `BEGIN`) snapshot is a pure
96 /// read of the committed post-(N-1) state — exactly the `hydrated_tx` it is stamped
97 /// with. Only *pushed* chunks (folded into the sources' overlays, visible to a
98 /// fresh hydrate) poison a baseline, and this flag starts precisely there.
99 pub(crate) speculative: Rc<Cell<bool>>,
100}
101
102impl CommitFanout for PoolFanout {
103 fn tx_begin(&self, tx_id: u64) -> ApplyStreamBegin {
104 let wrap = |inner: PoolStreamTx| -> Box<dyn FanoutStream> {
105 Box::new(PoolStream {
106 inner: Some(inner),
107 speculative: self.speculative.clone(),
108 })
109 };
110 match self.pool.tx_begin(tx_id) {
111 PoolStreamBegin::Ready(stream) => ApplyStreamBegin::Ready(wrap(stream)),
112 PoolStreamBegin::Failed(stream) => ApplyStreamBegin::Failed(wrap(stream)),
113 }
114 }
115
116 fn recover_failed_begin(&self) {
117 // A worker died/stalled at the begin-barrier: reap + respawn the offender(s) so
118 // the caller's retry can succeed.
119 self.pool.reap();
120 }
121}
122
123/// One in-flight transaction's fan-out over the pool ([`crate::parallel::StreamTx`]
124/// behind the apply-plane trait), carrying the speculative-fence transitions.
125struct PoolStream {
126 /// `None` once finished (the gate owns the rest of the lifecycle).
127 inner: Option<PoolStreamTx>,
128 speculative: Rc<Cell<bool>>,
129}
130
131impl FanoutStream for PoolStream {
132 fn tx_id(&self) -> u64 {
133 self.inner.as_ref().expect("stream not finished").tx_id()
134 }
135
136 fn push(&mut self, changes: Arc<[Captured]>) {
137 self.inner
138 .as_mut()
139 .expect("stream not finished")
140 .push(changes);
141 // The worker graphs now hold uncommitted rows: fence the one-shot view snapshot
142 // until this txn's terminal marker (see `Cluster::view_is_speculative`).
143 self.speculative.set(true);
144 }
145
146 fn await_acks(&mut self) -> bool {
147 self.inner
148 .as_mut()
149 .expect("stream not finished")
150 .await_acks()
151 }
152
153 fn finish(mut self: Box<Self>) -> Box<dyn FanoutGate> {
154 let gate = self.inner.take().expect("stream not finished").finish();
155 Box::new(PoolGate {
156 gate,
157 speculative: self.speculative.clone(),
158 })
159 }
160}
161
162impl Drop for PoolStream {
163 fn drop(&mut self) {
164 // Dropped without `finish` — an abort/rollback: the fence clears with the stream.
165 // (The inner `StreamTx`'s own Drop releases the workers' held snapshots; a worker
166 // that already received pushes tears down + re-hydrates.)
167 if self.inner.is_some() {
168 self.speculative.set(false);
169 }
170 }
171}
172
173struct PoolGate {
174 gate: PoolCommitGate,
175 speculative: Rc<Cell<bool>>,
176}
177
178impl FanoutGate for PoolGate {
179 fn commit(self: Box<Self>) {
180 self.speculative.set(false);
181 self.gate.commit();
182 }
183
184 fn abort(self: Box<Self>) {
185 self.speculative.set(false);
186 self.gate.abort();
187 }
188}
189
190/// Shared coordinator-thread state behind a [`Cluster`] (held via `Rc` so the
191/// `Cluster` handle and its [`ClusterWriteTxn`]s reference one store + pool).
192/// Field order is the teardown order: the store's own reader-before-writer ordering
193/// (see [`ApplyStore`]) plus pool-joins-before-the-schema-engine-closes keep a
194/// read-write connection last, so SQLite's cleanup checkpoint can unlink the
195/// `-wal`/`-shm` sidecars (pinned by `tests/wal_sidecars.rs`).
196struct ClusterInner {
197 /// The engine-free store half: reader + observed writer + capture + the committed-tx
198 /// watermark (design 309). Shared (`Rc`) with the write transactions and with
199 /// [`ClusterConsumer`](crate::ClusterConsumer)'s apply surface.
200 store: Rc<ApplyStore>,
201 /// The worker pool behind the apply plane's commit fan-out seam, plus the
202 /// speculative-window fence.
203 fanout: Rc<PoolFanout>,
204 /// A **schema-only** engine on the coordinator's own connection, `register_table`'d in
205 /// lockstep with the workers (a source per table, no queries). The coordinator has no
206 /// worker `Engine`, so this is where [`Cluster::view_schema`] resolves a query's view
207 /// [`Schema`] — using the *exact same* `Engine::register_table` source-schema mapping
208 /// the workers use, so the view schema lines up with the change stream. It never
209 /// derives changes, so sharing the coordinator thread is sound. Doubles
210 /// as the registered-tables record (idempotency via `has_source`).
211 schema_engine: RefCell<Engine>,
212}
213
214/// The multi-threaded replica handle (cheap `Rc` clone). Single-thread-use on the
215/// coordinator side; a server runs it on a dedicated owner thread and **continuously**
216/// drains the [`ClusterEvent`] channel returned by [`open`](Cluster::open) on whatever
217/// thread it likes — but it must never STOP draining while writes flow (the channel-out
218/// is bounded; see `open`'s draining contract).
219#[derive(Clone)]
220pub struct Cluster {
221 inner: Rc<ClusterInner>,
222}
223
224impl Cluster {
225 /// Open a parallel replica over a **file-backed** SQLite database with
226 /// `n_workers` IVM worker threads. Returns the handle and the channel-out
227 /// `Receiver` for derived [`ClusterEvent`]s. A fresh file gets ordinary
228 /// `journal_mode = wal` (design 306 D5); an existing `wal` or `wal2` file keeps
229 /// its mode. Asserts `sqlite3_threadsafe() != 0`, like [`Db::open`].
230 ///
231 /// # Draining contract
232 ///
233 /// The consumer **MUST drain the returned `Receiver` continuously**, on its own
234 /// thread, for the whole life of the `Cluster`. The channel-out is BOUNDED
235 /// (FOLLOWER-LAG-SHED §4, rung 0b — `event_channel_bound(n_workers)` events, to bound
236 /// memory under a slow consumer). A full event channel blocks a worker, which can
237 /// block capture streaming or a later snapshot barrier on the writer. Reading
238 /// events only after writes return can therefore deadlock. The drain loop must
239 /// also avoid blocking on slow downstream subscribers. Commit return does not
240 /// mean all worker events have been delivered; use `Progressed` for that boundary.
241 ///
242 /// Prefer [`ClusterConsumer`](crate::ClusterConsumer), which spawns and owns that
243 /// continuous drain thread for you; reach for raw `open` only when you intend to run
244 /// the drain loop yourself.
245 ///
246 /// [`Db::open`]: crate::Db::open
247 pub fn open(
248 path: impl AsRef<Path>,
249 n_workers: usize,
250 ) -> Result<(Cluster, Receiver<ClusterEvent>), ReplicaError> {
251 Self::open_with(path, n_workers, OpenOptions::default())
252 }
253
254 /// Like [`open`](Self::open), but with explicit control over the cost-based join-flip
255 /// planner. When `plan_queries` is true, every worker runs the planner (a
256 /// `SqliteCostModel` over its own connection, cached) at query registration to annotate
257 /// `flip` before lowering. Result-preserving; the plan is frozen per registration.
258 /// The planner is server-side (table-source) only and `open` enables it by default;
259 /// pass `false` here to opt out (e.g. to pin the unplanned path).
260 pub fn open_with_planning(
261 path: impl AsRef<Path>,
262 n_workers: usize,
263 plan_queries: bool,
264 ) -> Result<(Cluster, Receiver<ClusterEvent>), ReplicaError> {
265 Self::open_with(
266 path,
267 n_workers,
268 OpenOptions {
269 plan_queries,
270 ..OpenOptions::default()
271 },
272 )
273 }
274
275 /// Like [`open_with_planning`](Self::open_with_planning), but also selects the
276 /// operator-scratch-state backend for every worker `Engine`. Pass
277 /// [`OperatorStorage::SqliteSpill`] to spill stateful-operator state to a per-worker
278 /// on-disk temp database instead of RAM. The schema-only engine never builds stateful
279 /// operators, so it always stays memory-backed. Fresh files get plain `wal`
280 /// (design 306 D5); use [`open_with_journal`](Self::open_with_journal) for the
281 /// daemon's wal2 opt-in.
282 pub fn open_with_options(
283 path: impl AsRef<Path>,
284 n_workers: usize,
285 plan_queries: bool,
286 operator_storage: OperatorStorage,
287 ) -> Result<(Cluster, Receiver<ClusterEvent>), ReplicaError> {
288 Self::open_with(
289 path,
290 n_workers,
291 OpenOptions {
292 plan_queries,
293 operator_storage,
294 ..OpenOptions::default()
295 },
296 )
297 }
298
299 /// Like [`open`](Self::open), but with an explicit fresh-file [`JournalMode`] —
300 /// the daemon's roles pass [`JournalMode::Wal2`] here (design 306 D5: wal2 is
301 /// something the daemon requests, never something the library imposes). An
302 /// existing file already in `wal` or `wal2` keeps its mode either way.
303 ///
304 /// [`JournalMode`]: crate::JournalMode
305 /// [`JournalMode::Wal2`]: crate::JournalMode::Wal2
306 pub fn open_with_journal(
307 path: impl AsRef<Path>,
308 n_workers: usize,
309 journal: crate::JournalMode,
310 ) -> Result<(Cluster, Receiver<ClusterEvent>), ReplicaError> {
311 Self::open_with(
312 path,
313 n_workers,
314 OpenOptions {
315 journal,
316 ..OpenOptions::default()
317 },
318 )
319 }
320
321 /// The full-combination opener: every [`OpenOptions`] field is honored, so any
322 /// planner × operator-storage × journal combination is one call (e.g. a planned,
323 /// spill-backed cluster on a wal2 daemon store). The named rungs above are
324 /// conveniences over this.
325 pub fn open_with(
326 path: impl AsRef<Path>,
327 n_workers: usize,
328 opts: OpenOptions,
329 ) -> Result<(Cluster, Receiver<ClusterEvent>), ReplicaError> {
330 let OpenOptions {
331 plan_queries,
332 operator_storage,
333 journal,
334 wal_autocheckpoint,
335 foreign_keys,
336 } = opts;
337 assert!(n_workers > 0, "a cluster needs at least one worker");
338 let path = path.as_ref();
339 // The engine-free store half: writer (+ capture hook) and reader, the journal
340 // ritual, and the committed-tx watermark (design 309 §3). The writer begins
341 // IMMEDIATE — the one BEGIN flavor left with WriteThenAbort gone (306 S5).
342 let store = ApplyStore::open(
343 path,
344 journal,
345 crate::apply::DEFAULT_WRITER_BEGIN_SQL,
346 foreign_keys,
347 )?;
348 // Same meaning as on the single-thread `Db` (design 410 §3.5): the coordinator's
349 // writer is the only connection that appends WAL frames, and the pragma is
350 // connection-local. The workers open read-only-in-practice connections that never
351 // checkpoint, so they need none of it.
352 if let Some(pages) = wal_autocheckpoint {
353 crate::parallel::set_wal_autocheckpoint(store.writer_connection(), pages)?;
354 }
355 // The schema-only engine's own connection (it only reads schema to build sources).
356 let schema_conn = Rc::new(open_journal(path, journal, foreign_keys)?);
357
358 // Spawn the workers + channel-out. The channel is BOUNDED (FOLLOWER-LAG-SHED §4,
359 // rung 0b): a full queue blocks a worker's event send, so a slow drain
360 // backpressures the workers — and, through the bounded `TxPush` credits, the
361 // coordinator — instead of growing daemon memory without limit. Self-limiting and
362 // uniform; the drain loop's sink never blocks (it evicts a slow client), so the
363 // chain always makes progress (§4's no-deadlock argument).
364 let (out_tx, out_rx) = std::sync::mpsc::sync_channel(event_channel_bound(n_workers));
365 let pool = WorkerPool::spawn(
366 path,
367 n_workers,
368 plan_queries,
369 operator_storage,
370 journal,
371 foreign_keys,
372 out_tx,
373 )?;
374
375 // The schema engine only resolves view schemas; it never registers user queries,
376 // so planning is irrelevant and it builds no stateful operators — always off and
377 // memory-backed.
378 let schema_engine = Engine::new(schema_conn, false, OperatorStorage::Memory)?;
379 let inner = Rc::new(ClusterInner {
380 store: Rc::new(store),
381 fanout: Rc::new(PoolFanout {
382 pool,
383 n_workers,
384 speculative: Rc::new(Cell::new(false)),
385 }),
386 schema_engine: RefCell::new(schema_engine),
387 });
388 Ok((Cluster { inner }, out_rx))
389 }
390
391 /// The shared store half, for [`ClusterConsumer`](crate::ClusterConsumer)'s apply
392 /// surface (`ApplyConsumer::from_parts`).
393 pub(crate) fn store_rc(&self) -> Rc<ApplyStore> {
394 self.inner.store.clone()
395 }
396
397 /// The pool's fan-out, for the same composition seam.
398 pub(crate) fn fanout_rc(&self) -> Rc<dyn CommitFanout> {
399 self.inner.fanout.clone()
400 }
401
402 /// Register a base table: discover its columns + PK, ensure the PK UNIQUE index
403 /// `TableSource` requires, teach the capture hook the column types, and build
404 /// the source on **every** worker (any may host a query referencing it).
405 /// Idempotent. Rejects BLOB columns / PK-less tables.
406 ///
407 /// The table must be **plain** — SQL triggers and generated columns are rejected (see the
408 /// crate-level "supported usage" docs). Foreign-key cascades are represented by the explicit
409 /// row deltas observed by the preupdate hook.
410 pub fn register_table(&self, table: &str) -> Result<(), ReplicaError> {
411 if self.inner.schema_engine.borrow().has_source(table) {
412 return Ok(());
413 }
414 // The engine-free half (introspection + PK index + capture registration) lives on
415 // the store; lift the SAME introspection into the engine's `ColumnDef` shape and
416 // build the source on every worker AND on the coordinator's schema-only engine (the
417 // latter feeds `view_schema`). Build the schema engine first so `view_schema` is
418 // available the moment the table is registered; the workers host the live queries.
419 let ts = schema::lift_replicated(self.inner.store.register_table_capture(table)?);
420 self.inner
421 .schema_engine
422 .borrow_mut()
423 .register_table(table, ts.clone())?;
424 self.inner.fanout.pool.register_table_all(table, ts)?;
425 Ok(())
426 }
427
428 /// Register a live query under the caller-supplied [`QueryId`] tag. The query is
429 /// sharded to a worker, built + hydrated there, and its `Hydrated` baseline emitted
430 /// to the channel-out. Returns the **index of the worker that hosts it** (a `BuildError`
431 /// surfaces synchronously here as `Err`). Later writes can emit provisional `Changed`
432 /// slices before commit. That worker emits `Progressed` after successful commit and
433 /// all its slices — so the returned index is what the async progress layer (the drain
434 /// thread) keys a query's `cv` position by. The cluster owns the assignment; callers
435 /// pass the returned index along rather than recomputing it, so a future scheduling
436 /// change (work-stealing) stays internal to the cluster (§2.6).
437 ///
438 /// **Rejected while the open transaction is speculative**
439 /// ([`view_is_speculative`](Self::view_is_speculative)). Registration COLD-hydrates on the
440 /// worker, and a worker mid-stream holds the pushed chunks in its sources' overlays — so
441 /// the baseline would be drawn from rows no one has committed, while tagged with the
442 /// PRE-commit `hydrated_tx` read below. That is strictly worse than the equivalent
443 /// one-shot leak: a one-shot is thrown away, but a subscription applies every later
444 /// delta on top of its baseline, so a poisoned one breaks `view-after-write ==
445 /// fresh-query` for the life of the subscription. (A stream that has pinned snapshots
446 /// but pushed nothing is fine — the hydrate is a pure read of the committed post-(N-1)
447 /// state; see the `speculative` field docs.)
448 ///
449 /// Refusing (rather than deferring internally) is forced by the same threading that shapes
450 /// [`read_snapshot`](Self::read_snapshot): `WorkerPool::register_query_on` blocks this thread
451 /// on the worker's reply, and only this thread can send the transaction's terminal marker — so
452 /// a worker that held the registration until commit would deadlock against its own
453 /// coordinator. Callers that can wait should re-issue after the commit.
454 pub fn query(&self, query_id: QueryId, ast: Ast) -> Result<usize, ReplicaError> {
455 if self.inner.fanout.speculative.get() {
456 return Err(ReplicaError::Open(
457 "cannot register a query while a speculative write transaction is streaming \
458 (its baseline would contain uncommitted rows); retry after commit"
459 .into(),
460 ));
461 }
462 let worker = (query_id.0 % self.inner.fanout.n_workers as u64) as usize;
463 let hydrated_tx = self.inner.store.committed_tx_id().0;
464 // The sink id is returned for future per-query teardown; unused for now.
465 self.inner
466 .fanout
467 .pool
468 .register_query_on(worker, query_id, ast, hydrated_tx)?;
469 Ok(worker)
470 }
471
472 /// Register a **parameterized query family** (design 310 §5) under `query_id`: one
473 /// pipeline over `stripped` (the family's template AST) partitioned on `params`, with
474 /// `bindings` bound and hydrated up front — each emits a
475 /// [`Update::PartitionHydrated`](crate::Update::PartitionHydrated) instead of a
476 /// `Hydrated`. Placed on one worker by the same `query_id % n` rule as
477 /// [`query`](Self::query) (design §6, v1: one worker per family). Returns the worker.
478 pub fn family(
479 &self,
480 query_id: QueryId,
481 stripped: Ast,
482 params: Vec<Box<str>>,
483 bindings: Vec<Binding>,
484 ) -> Result<usize, ReplicaError> {
485 if self.inner.fanout.speculative.get() {
486 return Err(ReplicaError::Open(
487 "cannot register a query family while a speculative write transaction is \
488 streaming (its baseline would contain uncommitted rows); retry after commit"
489 .into(),
490 ));
491 }
492 let worker = (query_id.0 % self.inner.fanout.n_workers as u64) as usize;
493 let hydrated_tx = self.inner.store.committed_tx_id().0;
494 self.inner.fanout.pool.register_family_on(
495 worker,
496 query_id,
497 stripped,
498 params,
499 bindings,
500 hydrated_tx,
501 )?;
502 Ok(worker)
503 }
504
505 /// Bind one more partition of the family registered under `query_id` (design 310
506 /// §4.4): it hydrates on the hosting worker's latest committed snapshot and emits its
507 /// `PartitionHydrated`. Refused, like [`query`](Self::query), while a speculative
508 /// transaction is streaming.
509 pub fn bind(&self, query_id: QueryId, binding: Binding) -> Result<(), ReplicaError> {
510 if self.inner.fanout.speculative.get() {
511 return Err(ReplicaError::Open(
512 "cannot bind a family partition while a speculative write transaction is \
513 streaming; retry after commit"
514 .into(),
515 ));
516 }
517 let worker = (query_id.0 % self.inner.fanout.n_workers as u64) as usize;
518 let hydrated_tx = self.inner.store.committed_tx_id().0;
519 self.inner
520 .fanout
521 .pool
522 .bind_partition_on(worker, query_id, binding, hydrated_tx)
523 }
524
525 /// Unbind one partition of the family under `query_id`; nothing is emitted. `Ok(false)`
526 /// if it was not bound.
527 pub fn unbind(&self, query_id: QueryId, binding: Binding) -> Result<bool, ReplicaError> {
528 let worker = (query_id.0 % self.inner.fanout.n_workers as u64) as usize;
529 self.inner
530 .fanout
531 .pool
532 .unbind_partition_on(worker, query_id, binding)
533 }
534
535 /// Analyze `ast` COLD against the current committed state and return where its time
536 /// and rows go (`ANALYZE-QUERY-DESIGN.md`). Read-only and **fully self-contained**:
537 /// it runs on the CALLING thread over a private connection
538 /// ([`analyze_standalone`](crate::analyze_standalone)) — no worker engine, no live
539 /// statement cache, no lease — so it never enters a worker's command queue and a
540 /// genuinely slow cold hydrate cannot stall (or be stalled by) commits. `BuildError`
541 /// (unknown table/column, unsupported shape) surfaces as `Err`.
542 pub fn analyze_query(&self, ast: Ast) -> Result<AnalyzeReport, ReplicaError> {
543 analyze_standalone(&self.analyze_spec(), &ast)
544 }
545
546 /// The `Send` snapshot for running [`analyze_standalone`](crate::analyze_standalone)
547 /// on another thread: the daemon's `/analyze` builds one here on its command loop,
548 /// then runs the analysis on a dedicated thread so the loop — and with it the write
549 /// path — never blocks on a slow cold hydrate.
550 pub fn analyze_spec(&self) -> AnalyzeSpec {
551 self.inner.fanout.pool.analyze_spec()
552 }
553
554 /// Convenience: parse a Zero-wire AST from JSON, then [`Cluster::query`].
555 pub fn query_json(&self, query_id: QueryId, json: &str) -> Result<usize, ReplicaError> {
556 let ast: Ast = serde_json::from_str(json)
557 .map_err(|e| ReplicaError::Schema(format!("invalid AST JSON: {e}")))?;
558 self.query(query_id, ast)
559 }
560
561 /// Gracefully tear down a query the consumer no longer wants (destroyed /
562 /// dematerialized / unsubscribed): reclaim its pipeline on the hosting worker.
563 /// Unlike a fault, this is **solicited**, so it emits no [`ClusterEvent`] — after
564 /// it returns, no further events arrive for `query_id`. Returns `true` if a live
565 /// query was found and removed; idempotent (a repeat call returns `false`). The
566 /// reclaimed graph slots are recycled by a later [`query`](Self::query).
567 pub fn destroy_query(&self, query_id: QueryId) -> bool {
568 let worker = (query_id.0 % self.inner.fanout.n_workers as u64) as usize;
569 self.inner.fanout.pool.deregister_query_on(worker, query_id) > 0
570 }
571
572 /// Re-read the **current assembled view** of `query_id` as a one-shot snapshot — the
573 /// SSR read path (`SSR-DESIGN.md` §3). Routes to the hosting worker (same `query_id % n`
574 /// shard as [`query`](Self::query)) and returns the live view as hydration `Add`s, with
575 /// no subscription and no streaming state. A degraded shard or an unregistered query
576 /// yields an empty snapshot (the one-shot reports "no rows" rather than failing); a
577 /// raised read boundary on a live worker (§5.3 sum overflow) is `Err` — never a `NULL`
578 /// aggregate rendered as data. A snapshot is refused while the open transaction is
579 /// **speculative** ([`view_is_speculative`](Self::view_is_speculative)): it has already
580 /// streamed uncommitted changes into the worker graph, and only its terminal commit marker
581 /// makes those changes publicly visible. Callers that can wait should poll that predicate and
582 /// defer the read to the commit rather than surfacing this error — a caller pinned to one
583 /// thread with the writer (rindled's engine loop) must NOT block here: the same thread has to
584 /// keep running to reach the commit.
585 pub fn read_snapshot(&self, query_id: QueryId) -> Result<Vec<CaughtChange>, ReplicaError> {
586 if self.inner.fanout.speculative.get() {
587 return Err(ReplicaError::ReadRejected(
588 "view snapshot unavailable while a speculative write transaction is streaming; \
589 retry after commit"
590 .into(),
591 ));
592 }
593 let worker = (query_id.0 % self.inner.fanout.n_workers as u64) as usize;
594 self.inner.fanout.pool.read_snapshot_on(worker, query_id)
595 }
596
597 /// Whether the workers' assembled views currently carry **uncommitted** rows — true from the
598 /// moment an open transaction streams its first chunk (`PUSH_CHUNK_ROWS`) until that
599 /// transaction commits or rolls back. This is the exact window in which
600 /// [`read_snapshot`](Self::read_snapshot) refuses, exposed as a predicate so a caller can
601 /// QUEUE a one-shot read until the transaction closes instead of failing it. An open
602 /// transaction that never crosses the streaming threshold is *not* speculative — it holds
603 /// its changes on the writer, so reads stay serviceable.
604 pub fn view_is_speculative(&self) -> bool {
605 self.inner.fanout.speculative.get()
606 }
607
608 /// Open the single-writer transaction. Errors if one is already open.
609 pub fn write(&self) -> Result<ClusterWriteTxn, ReplicaError> {
610 let core = ApplyTxn::begin(self.inner.store.clone(), self.fanout_rc())?;
611 // Nothing streamed yet: reads stay serviceable until this txn crosses `PUSH_CHUNK_ROWS`.
612 self.inner.fanout.speculative.set(false);
613 Ok(ClusterWriteTxn { core })
614 }
615
616 /// The last durably-committed global tx id (0 if none yet).
617 pub fn committed_tx_id(&self) -> TxId {
618 self.inner.store.committed_tx_id()
619 }
620
621 /// The observed writer connection, for crate-internal host bookkeeping while the coordinator
622 /// thread is idle. Kept crate-private so external callers cannot write around capture.
623 pub(crate) fn writer_connection(&self) -> &Connection {
624 self.inner.store.writer_connection()
625 }
626
627 /// The database's schema cookie (`PRAGMA schema_version`), read on the writer connection so
628 /// an open mutation transaction's uncommitted DDL is visible. Hosts whose trusted SQL facade
629 /// runs no statement classification compare it across a transaction to detect DDL that must
630 /// invalidate reader pools and capture registration.
631 pub fn schema_cookie(&self) -> Result<i64, ReplicaError> {
632 self.inner.store.schema_cookie()
633 }
634
635 /// Walk every declared foreign key and report violating rows — the **opt-in audit**
636 /// a replica runs instead of paying for enforcement on every applied row.
637 ///
638 /// `PRAGMA foreign_key_check` reports what is in the file, not what SQLite would have
639 /// refused, so it answers the same question whether this cluster was opened
640 /// [`ForeignKeys::Enforced`](crate::ForeignKeys) or
641 /// [`ForeignKeys::Unenforced`](crate::ForeignKeys) — which is exactly why a follower
642 /// can apply with enforcement off and still prove referential integrity on demand.
643 /// It is a full scan of the referencing tables: an operator command, a post-restore
644 /// gate, or a soak assertion, never a per-commit step. Runs on the read-only
645 /// connection, so it takes no write lock and does not disturb an applier.
646 ///
647 /// Pass [`FOREIGN_KEY_AUDIT_ROW_CAP`](crate::FOREIGN_KEY_AUDIT_ROW_CAP) unless you
648 /// have a reason not to, or `0` for no cap.
649 pub fn foreign_key_audit(
650 &self,
651 max_rows: usize,
652 ) -> Result<crate::ForeignKeyAudit, ReplicaError> {
653 self.inner.store.foreign_key_audit(max_rows)
654 }
655
656 /// Block until every worker has drained its command queue — i.e. emitted the
657 /// `Hydrated`/`Changed` events for all preceding [`query`](Self::query) /
658 /// `commit` calls to the channel-out. A quiesce/test affordance (a steady-state
659 /// consumer just drains the channel continuously); relies on per-worker FIFO
660 /// command order, so a returned ping implies all prior work is done.
661 ///
662 /// Doubles as the liveness sweep: a worker that died (or hung) is detected here and
663 /// **respawned** (its lost queries delivered a terminal [`ClusterEvent::Faulted`]),
664 /// so a steady-state consumer that drains + periodically `sync()`s also self-heals.
665 pub fn sync(&self) {
666 self.inner.fanout.pool.reap();
667 }
668
669 /// Test seam: force a derivation-fault recovery on the worker that hosts
670 /// `on_query` — it tears down **every** query on that worker (they share one
671 /// graph) and emits a terminal [`ClusterEvent::Faulted`] for each, then rebuilds
672 /// a clean engine. Exposed (doc-hidden) because a real derive fault can't be
673 /// induced with valid data; not part of the supported API.
674 #[doc(hidden)]
675 pub fn __test_trigger_fault(&self, on_query: QueryId) {
676 let worker = (on_query.0 % self.inner.fanout.n_workers as u64) as usize;
677 self.inner.fanout.pool.trigger_fault(worker);
678 }
679
680 /// Test seam: **kill the worker thread** hosting `on_query` (simulating a true
681 /// thread death). The death is detected + recovered on the next [`sync`](Self::sync)
682 /// or `commit`. Doc-hidden; not part of the supported API.
683 #[doc(hidden)]
684 pub fn __test_kill_worker(&self, on_query: QueryId) {
685 let worker = (on_query.0 % self.inner.fanout.n_workers as u64) as usize;
686 self.inner.fanout.pool.kill_worker(worker);
687 }
688
689 /// Test seam: whether the shard hosting `on_query` is permanently degraded (the
690 /// crash-loop breaker tripped). Doc-hidden; not part of the supported API.
691 #[doc(hidden)]
692 pub fn __test_is_degraded(&self, on_query: QueryId) -> bool {
693 let worker = (on_query.0 % self.inner.fanout.n_workers as u64) as usize;
694 self.inner.fanout.pool.is_degraded(worker)
695 }
696
697 /// Test seam: lower the barrier-ack + liveness-ping timeouts so the death/stall
698 /// recovery paths run fast. Doc-hidden; not part of the supported API.
699 #[doc(hidden)]
700 pub fn __test_set_timeouts(&self, ack: std::time::Duration, ping: std::time::Duration) {
701 self.inner.fanout.pool.set_timeouts(ack, ping);
702 }
703
704 /// Set the per-push derive deadline (FOLLOWER-LAG-SHED §6.6 — the runaway-push bail),
705 /// in milliseconds; `0` disables it. A single chunk's derive exceeding this faults the
706 /// worker's queries promptly and cleanly (`FaultCause::PushDeadline`, no detached
707 /// thread) instead of wedging until the barrier watchdog detaches the whole worker.
708 /// Applies immediately to every worker, respawns included. Default:
709 /// 10 s, aligned with the barrier watchdog it pre-empts.
710 pub fn set_push_deadline_ms(&self, ms: u64) {
711 self.inner.fanout.pool.set_push_deadline_ms(ms);
712 }
713
714 /// Set the join membership pre-check bounds
715 /// (`designs/311-JOIN-MEMBERSHIP-PRECHECK-DESIGN.md` §8) on every worker engine —
716 /// respawns and post-fault rebuilds included. `per_join` is the distinct-key bound one
717 /// join may track (`None` = off, the engine default); `per_graph` caps the total across
718 /// a worker's joins. Applied at each worker's next commit barrier, before anything
719 /// derives; a change resets the workers' sets, which rebuild by observing the next
720 /// hydrate-shaped fetch through each join.
721 pub fn set_join_precheck_bounds(&self, per_join: Option<usize>, per_graph: usize) {
722 self.inner
723 .fanout
724 .pool
725 .set_join_precheck_bounds(per_join, per_graph);
726 }
727
728 /// Test seam: the crash-loop restart budget (respawns beyond it degrade the shard).
729 /// Doc-hidden; not part of the supported API.
730 #[doc(hidden)]
731 pub fn __test_restart_budget(&self) -> usize {
732 self.inner.fanout.pool.restart_budget()
733 }
734
735 /// Test seam: the join membership pre-check report of the worker hosting `on_query` —
736 /// the bounds in force on that worker's engine and the state of every join of every
737 /// query it hosts (see [`JoinPrecheckReport`](crate::JoinPrecheckReport)). Answered
738 /// FIFO behind that worker's earlier registrations and commits, so it reflects exactly
739 /// what the next registration on it would find. `None` if the shard is degraded.
740 /// Doc-hidden; not part of the supported API.
741 #[doc(hidden)]
742 pub fn __test_join_precheck_report(
743 &self,
744 on_query: QueryId,
745 ) -> Option<crate::JoinPrecheckReport> {
746 let worker = (on_query.0 % self.inner.fanout.n_workers as u64) as usize;
747 self.inner.fanout.pool.join_precheck_report_on(worker)
748 }
749
750 /// Test seam: force the `FlippedJoin` IN-batch chunk size on every worker engine
751 /// (`None` restores the engine default of 256), so a small fixture routes its parent
752 /// fetches through the chunked k-way `merge_node_streams` instead of the single-batch
753 /// path. The engine's own seam (`rindle::op::set_multi_constraint_chunk_size_for_test`)
754 /// is a **thread-local** read at `FlippedJoin::new`, and a cluster builds its pipelines
755 /// on worker threads, so setting it on the caller's thread reaches nothing; this
756 /// carries it to the workers, applied before each of their commands so registrations,
757 /// binds, post-fault rebuilds and respawns all see it. Already-built joins keep the
758 /// size they were constructed with. Doc-hidden; not part of the supported API.
759 #[doc(hidden)]
760 #[cfg(feature = "testkit")]
761 pub fn __test_set_flipped_join_chunk_size(&self, n: Option<usize>) {
762 self.inner.fanout.pool.set_flipped_join_chunk_size(n);
763 }
764
765 /// Test seam: the number of `(key, value)` entries in the operator scratch storage of
766 /// `on_query`'s pipeline on its worker — the leak probe of design 310 impl plan D5
767 /// (`family_pipeline.rs::unbind_leaves_no_zombie_state`) lifted to the cluster
768 /// boundary, so the fuzz lanes can assert an unbind actually released its partition's
769 /// slots instead of leaving a zombie the tree parity cannot see. Answered FIFO behind
770 /// that worker's earlier binds, unbinds and commits. `None` if the shard is degraded
771 /// or nothing is registered under `on_query`. Doc-hidden; not part of the supported API.
772 #[doc(hidden)]
773 pub fn __test_storage_entries(&self, on_query: QueryId) -> Option<usize> {
774 let worker = (on_query.0 % self.inner.fanout.n_workers as u64) as usize;
775 self.inner
776 .fanout
777 .pool
778 .storage_report_on(worker, on_query, false)
779 .map(|r| r.entries)
780 }
781
782 /// Test seam: every `(slot, key, value)` of `on_query`'s operator scratch storage —
783 /// the diagnostic twin of [`__test_storage_entries`](Self::__test_storage_entries),
784 /// for the failure message when a leak probe finds more than it expected. Doc-hidden;
785 /// not part of the supported API.
786 #[doc(hidden)]
787 pub fn __test_storage_dump(&self, on_query: QueryId) -> Vec<(usize, String, String)> {
788 let worker = (on_query.0 % self.inner.fanout.n_workers as u64) as usize;
789 self.inner
790 .fanout
791 .pool
792 .storage_report_on(worker, on_query, true)
793 .map(|r| r.dump)
794 .unwrap_or_default()
795 }
796
797 /// Run SQL against a physically read-only connection using SQLite snapshot semantics.
798 /// This connection does not see the writer's uncommitted changes. Schema changes
799 /// belong in [`Self::exec_ddl`], and row mutations in [`Self::write`].
800 pub fn read<T>(
801 &self,
802 f: impl FnOnce(&Connection) -> rusqlite::Result<T>,
803 ) -> Result<T, ReplicaError> {
804 self.inner.store.read(f)
805 }
806
807 /// The hierarchical view [`Schema`] `ast` materializes to over the registered tables —
808 /// the coordinator parity for [`Db::view_schema`](crate::Db::view_schema). Derived from
809 /// the schema-only engine (the same source-schema mapping the workers use), so the
810 /// shape, sort, `singular` flag, and in-view relationships line up with the change
811 /// stream. A layer that ships views to a remote (the normalized wire schema + `hello`)
812 /// needs this without registering the query. `BuildError` (unknown table/column)
813 /// surfaces as `Err`.
814 pub fn view_schema(&self, ast: &Ast) -> Result<Schema, ReplicaError> {
815 self.inner.schema_engine.borrow().view_schema(ast)
816 }
817
818 /// Run one **online maintenance pass** on the writer connection: refresh planner stats
819 /// (`PRAGMA optimize`, bounded by the `analysis_limit` set at open), return up to
820 /// `incremental_vacuum_pages` freelist pages to the OS once at least `freelist_threshold_pages`
821 /// have built up, and take a non-blocking PASSIVE WAL checkpoint — see [`MaintenanceOptions`].
822 ///
823 /// Designed to run on a timer in the gap between commits on the coordinator thread: each step
824 /// is bounded (no full `VACUUM`/`ANALYZE`, no exclusive lock) and the maintenance writes land
825 /// only in `sqlite_stat*` / the freelist, which the CDC capture hook ignores. It is **skipped**
826 /// (returning `MaintenanceReport::skipped()`) while a write transaction is open — e.g. a
827 /// follower stream holds one open across frames — so it never disturbs an in-flight txn.
828 ///
829 /// [`MaintenanceOptions`]: crate::MaintenanceOptions
830 pub fn maintain(
831 &self,
832 opts: &crate::MaintenanceOptions,
833 ) -> Result<crate::MaintenanceReport, ReplicaError> {
834 if self.inner.store.in_write() {
835 return Ok(crate::MaintenanceReport::skipped());
836 }
837 crate::maintenance::run(self.inner.store.writer_connection(), opts)
838 }
839
840 /// Checkpoint and truncate the WAL through the coordinator writer. Snapshot/install code uses
841 /// this after quiescing writes so the portable main file carries every committed frame. The
842 /// public [`Self::read`] connection is physically read-only and deliberately cannot perform a
843 /// checkpoint.
844 pub fn checkpoint_truncate(&self) -> Result<crate::WalCheckpoint, ReplicaError> {
845 self.inner.store.checkpoint_truncate()
846 }
847
848 /// Run schema DDL (`CREATE`/`ALTER`/`DROP`/`REINDEX`) against the writer connection — the
849 /// supported way to define the plain base tables you then [`register_table`](Self::register_table)
850 /// and write through. The historical bounded `ANALYZE` call is also accepted; row-changing
851 /// statements and row-producing DDL are rejected. The complete batch and schema-envelope
852 /// validation commit atomically. Rejected while a write transaction is open (DDL there would
853 /// be invisible to the workers until commit). Mirrors [`Db::exec_ddl`](crate::Db::exec_ddl).
854 pub fn exec_ddl(&self, sql: &str) -> Result<(), ReplicaError> {
855 self.inner.store.exec_ddl(sql)
856 }
857
858 /// Apply schema `statements` AND stamp a durable idempotency marker (`key` → `marker_table`)
859 /// in ONE ordinary transaction on the writer connection. See
860 /// [`ApplyStore::exec_ddl_with_marker`] — this is the same primitive, delegated (design
861 /// 309). A marker and its DDL commit together, so retrying an applied migration
862 /// can detect the marker without applying the statements again.
863 pub fn exec_ddl_with_marker(
864 &self,
865 marker_table: &str,
866 key: &str,
867 statements: &[String],
868 ) -> Result<schema::DdlApplyReport, ReplicaError> {
869 self.inner
870 .store
871 .exec_ddl_with_marker(marker_table, key, statements)
872 }
873
874 /// [`exec_ddl_with_marker`](Self::exec_ddl_with_marker), with one caller-owned bookkeeping
875 /// hook invoked after every real statement and its authorizer actions. See
876 /// [`ApplyStore::exec_ddl_with_marker_and_step_effects`].
877 pub fn exec_ddl_with_marker_and_step_effects<F>(
878 &self,
879 marker_table: &str,
880 key: &str,
881 statements: &[String],
882 apply_step_effects: F,
883 ) -> Result<schema::DdlApplyReport, ReplicaError>
884 where
885 F: FnMut(&Connection, &schema::DdlStep) -> rusqlite::Result<()>,
886 {
887 self.inner.store.exec_ddl_with_marker_and_step_effects(
888 marker_table,
889 key,
890 statements,
891 apply_step_effects,
892 )
893 }
894
895 /// Run a **full** `ANALYZE` on the writer to build deep planner statistics (`sqlite_stat4`),
896 /// not just the bounded `sqlite_stat1` the maintenance tick refreshes. Call this once after a
897 /// bulk load / seed (or periodically) to sharpen the cost model. Restores the bounded
898 /// `analysis_limit` afterward, so it does not slow the ongoing maintenance tick. Rejected
899 /// while a write transaction is open.
900 ///
901 /// This used to be described as what makes an `ORDER BY … LIMIT n` displacement re-fetch seek
902 /// rather than scan (GitHub #68). It is not, since the query builder began lifting a sargable
903 /// leading-column bound — see `maintenance::analyze_full`.
904 pub fn analyze_full(&self) -> Result<(), ReplicaError> {
905 if self.inner.store.in_write() {
906 return Err(ReplicaError::Open(
907 "cannot run a full ANALYZE while a write transaction is open".into(),
908 ));
909 }
910 crate::maintenance::analyze_full(self.inner.store.writer_connection())
911 }
912
913 /// One-time (idempotent) setup for the client-mutations protocol (the coordinator
914 /// parity for [`Db::enable_client_mutations`](crate::Db::enable_client_mutations)):
915 /// create [`CLIENT_MUTATIONS_TABLE`] and register it like any base table (CDC
916 /// capture + sources on the schema engine and every worker), so `lmid` rides the
917 /// change stream co-transactionally with the data and each client's one-row system
918 /// query can host it. Rejected mid-write-txn.
919 pub fn enable_client_mutations(&self) -> Result<(), ReplicaError> {
920 self.inner.store.create_client_mutations_tables()?;
921 self.register_table(CLIENT_MUTATIONS_TABLE)
922 }
923
924 /// One-time (idempotent) setup for the §4 realtime lifecycle (the coordinator parity
925 /// for [`Db::enable_realtime_lifecycle`](crate::Db::enable_realtime_lifecycle) — see
926 /// its doc for why every table is REGISTERED, not just created): the scope-session
927 /// doorbell (§4.1), the downgrade watermark fence (§4.2), the mutation-outcomes
928 /// resolution surface (populated by Slice I-ii), plus
929 /// [`ROOM_CLIENT_MUTATIONS_TABLE`] — §7.1's "ordinary footprint data after
930 /// downgrade". Requires [`enable_client_mutations`](Self::enable_client_mutations)
931 /// first (it owns the room ledger's DDL); rejected mid-write-txn.
932 pub fn enable_realtime_lifecycle(&self) -> Result<(), ReplicaError> {
933 if self.inner.store.in_write() {
934 return Err(ReplicaError::Open(
935 "cannot enable the realtime lifecycle while a write transaction is open".into(),
936 ));
937 }
938 if !self.inner.store.capture().has_table(CLIENT_MUTATIONS_TABLE) {
939 return Err(ReplicaError::Mutation(
940 "realtime lifecycle requires client mutations — call enable_client_mutations() \
941 first (it owns the room ledger's DDL)"
942 .into(),
943 ));
944 }
945 self.inner
946 .store
947 .writer_connection()
948 .execute_batch(&realtime_lifecycle_ddl())
949 .map_err(|e| ReplicaError::sqlite("create realtime lifecycle tables", e))?;
950 for table in [
951 SCOPE_SESSIONS_TABLE,
952 ROOM_WATERMARK_TABLE,
953 ROOM_MUTATION_OUTCOMES_TABLE,
954 ROOM_CLIENT_MUTATIONS_TABLE,
955 ] {
956 self.register_table(table)?;
957 }
958 Ok(())
959 }
960
961 /// Whether [`enable_realtime_lifecycle`](Self::enable_realtime_lifecycle) has run —
962 /// the cheap in-memory gate (`require_client_mutations`' idiom: capture registration
963 /// is the enable's observable effect) that `commit_room_flush` consults before its
964 /// §4.2 watermark co-commit, so a daemon that never enabled the lifecycle keeps
965 /// applying room flushes exactly as before.
966 pub(crate) fn realtime_lifecycle_enabled(&self) -> bool {
967 self.inner.store.capture().has_table(ROOM_WATERMARK_TABLE)
968 }
969
970 /// The high-water mutation id durably recorded for `client_id` (0 if none — a new
971 /// client). Coordinator parity for [`Db::client_lmid`](crate::Db::client_lmid);
972 /// requires [`enable_client_mutations`](Self::enable_client_mutations).
973 pub fn client_lmid(&self, client_id: &str) -> Result<u64, ReplicaError> {
974 self.require_client_mutations()?;
975 let got = self
976 .read(|c| {
977 c.query_row(
978 &format!(
979 "SELECT last_mutation_id FROM {CLIENT_MUTATIONS_TABLE} \
980 WHERE client_id = ?1"
981 ),
982 [client_id],
983 |r| r.get::<_, i64>(0),
984 )
985 .optional()
986 })?
987 .map(|v| v as u64);
988 Ok(got.unwrap_or(0))
989 }
990
991 /// Apply a client's mutation push over the cluster (the coordinator parity for
992 /// [`Db::apply_mutations`](crate::Db::apply_mutations)): each envelope's mutator runs
993 /// in **its own** transaction (with the co-transactional `lmid` upsert), in `mid` order.
994 /// Same per-envelope rules as the single-thread path — `mid ≤ lmid` skipped (idempotent
995 /// redelivery), `mid == lmid + 1` applied, a gap rejected at that envelope; a mutator
996 /// error / panic / unknown name rolls effects back and commits `lmid` only
997 /// (processed-as-no-op — no rejection signal).
998 ///
999 /// Unlike the `Db` path it does **not** return the changed-query set inline — every
1000 /// commit's batches (including the lmid row on the client's own system query) flow
1001 /// asynchronously through the drain. Must be called with no write transaction open
1002 /// (it manages its own).
1003 pub fn apply_mutations(
1004 &self,
1005 registry: &MutatorRegistry,
1006 envelopes: &[MutationEnvelope],
1007 ) -> Result<MutationOutcome, ReplicaError> {
1008 use std::panic::{catch_unwind, AssertUnwindSafe};
1009 self.require_client_mutations()?;
1010 let mut out = MutationOutcome::default();
1011 for env in envelopes {
1012 let stored = self.client_lmid(&env.client_id)?;
1013 if env.mid <= stored {
1014 continue; // duplicate redelivery — already processed
1015 }
1016 if env.mid != stored + 1 {
1017 return Err(ReplicaError::Mutation(format!(
1018 "mutation id gap for client {:?}: expected {}, got {}",
1019 env.client_id,
1020 stored + 1,
1021 env.mid
1022 )));
1023 }
1024
1025 let mut txn = self.write()?;
1026 let invoked: Result<(), MutationReject> = match registry.get(&env.name) {
1027 None => Err(MutationReject(format!("unknown mutator {:?}", env.name))),
1028 Some(f) => match catch_unwind(AssertUnwindSafe(|| f(&mut txn, &env.args))) {
1029 Ok(r) => r,
1030 Err(_) => Err(MutationReject(format!("mutator {:?} panicked", env.name))),
1031 },
1032 };
1033
1034 match invoked {
1035 Ok(()) => {
1036 upsert_lmid(&mut txn, &env.client_id, env.mid)?;
1037 out.commits.push(txn.commit_with_info()?);
1038 }
1039 Err(MutationReject(reason)) => {
1040 txn.rollback();
1041 // The lmid-only commit: the durable record that `mid` was processed
1042 // (as a no-op). Its capture is the lmid row, which derives through the
1043 // client's own system query and snaps the pending prediction back on
1044 // release. The reason is server-side observability only.
1045 eprintln!(
1046 "[rindle-replica] mutation {} for client {:?} failed (lmid still advances): {reason}",
1047 env.mid, env.client_id
1048 );
1049 let mut lmid_txn = self.write()?;
1050 upsert_lmid(&mut lmid_txn, &env.client_id, env.mid)?;
1051 out.commits.push(lmid_txn.commit_with_info()?);
1052 }
1053 }
1054 }
1055 Ok(out)
1056 }
1057
1058 fn require_client_mutations(&self) -> Result<(), ReplicaError> {
1059 if !self.inner.store.capture().has_table(CLIENT_MUTATIONS_TABLE) {
1060 return Err(ReplicaError::Mutation(
1061 "client mutations not enabled — call enable_client_mutations() first".into(),
1062 ));
1063 }
1064 Ok(())
1065 }
1066}
1067
1068/// An open write transaction on the cluster's single writer connection. Run SQL
1069/// with [`exec`](Self::exec)/[`exec_batch`](Self::exec_batch); [`commit`](Self::commit)
1070/// runs the snapshot/commit handshake. Workers can deliver provisional changes while
1071/// SQL is still running. Dropping rolls back SQL; if chunks were already streamed,
1072/// affected queries fault and need a new registration. See [`ClusterEvent`].
1073///
1074/// A thin wrapper over the apply plane's [`ApplyTxn`] carrying the cluster's fan-out
1075/// (design 309 §3): the state machine — capture pumping, the barriers, the commit
1076/// guards — is ONE implementation shared with the headless applier.
1077pub struct ClusterWriteTxn {
1078 core: ApplyTxn,
1079}
1080
1081impl ClusterWriteTxn {
1082 /// Run one statement with positional parameters inside the open transaction.
1083 pub fn exec(&mut self, sql: &str, params: &[OwnedValue]) -> Result<usize, ReplicaError> {
1084 self.core.exec(sql, params)
1085 }
1086
1087 /// Run one mutation statement under the shared writer time/VM budget and, for guarded public
1088 /// mutations, the reserved-object authorizer. This is the bounded twin of [`exec`](Self::exec)
1089 /// used by a standalone daemon's write plane; it still pumps capture between statements.
1090 pub(crate) fn exec_bounded(
1091 &mut self,
1092 sql: &str,
1093 params: &[OwnedValue],
1094 guarded: bool,
1095 ) -> Result<usize, StatementRunError> {
1096 self.core.exec_bounded(sql, params, guarded)
1097 }
1098
1099 /// Run one v1 public-SQL statement through the shared classifier/authorizer and writer
1100 /// budget. Interactive calls request a savepoint so ordinary statement failures preserve the
1101 /// surrounding transaction; one-shot batches let their coordinator roll the whole unit back.
1102 pub(crate) fn public_statement(
1103 &mut self,
1104 request: &SqlStatementRequest,
1105 preserve_on_error: bool,
1106 result_byte_limit: usize,
1107 ) -> Result<StatementResult, StatementRunError> {
1108 self.core
1109 .public_statement(request, preserve_on_error, result_byte_limit)
1110 }
1111
1112 pub(crate) fn is_open(&self) -> bool {
1113 self.core.is_open()
1114 }
1115
1116 pub(crate) fn captured_user_event_count(&self) -> usize {
1117 self.core.captured_user_event_count()
1118 }
1119
1120 pub(crate) fn next_tx_id(&self) -> TxId {
1121 self.core.next_tx_id()
1122 }
1123
1124 /// The open transaction's connection for narrow crate-internal bookkeeping helpers. External
1125 /// callers stay on typed mutation methods so they cannot bypass capture accidentally.
1126 pub(crate) fn connection(&self) -> &Connection {
1127 self.core.connection()
1128 }
1129
1130 /// The underlying apply-plane transaction, for the delegating apply surface
1131 /// ([`ClusterConsumer`](crate::ClusterConsumer)).
1132 pub(crate) fn core_mut(&mut self) -> &mut ApplyTxn {
1133 &mut self.core
1134 }
1135
1136 /// Consume the wrapper, yielding the apply-plane transaction (the commit_* delegates).
1137 pub(crate) fn into_core(self) -> ApplyTxn {
1138 self.core
1139 }
1140
1141 /// Run a batch of statements (no parameters) inside the open transaction.
1142 pub fn exec_batch(&mut self, sql: &str) -> Result<(), ReplicaError> {
1143 self.core.exec_batch(sql)
1144 }
1145
1146 /// Run a read **through the open transaction** (sees its own uncommitted writes — the
1147 /// read-dependent mutator contract, §4.1), each cell mapped from its raw SQLite storage
1148 /// class. The [`MutationSql`] query flavor for the parallel write path.
1149 pub fn query(
1150 &mut self,
1151 sql: &str,
1152 params: &[OwnedValue],
1153 ) -> Result<Vec<Vec<OwnedValue>>, ReplicaError> {
1154 self.core.query(sql, params)
1155 }
1156
1157 /// [`query`](Self::query), additionally reporting the result's column names in order —
1158 /// what a network front needs to answer a mutator-session read (`{cols, rows}` on the
1159 /// wire, zipped client-side; DAEMON-INTERACTIVE-TXN-DESIGN.md §4.1). Same open-transaction
1160 /// read-your-writes semantics and raw-storage-class cell mapping.
1161 pub fn query_with_cols(
1162 &mut self,
1163 sql: &str,
1164 params: &[OwnedValue],
1165 ) -> Result<(Vec<String>, Vec<Vec<OwnedValue>>), ReplicaError> {
1166 self.core.query_with_cols(sql, params)
1167 }
1168
1169 /// Finish capture, wait for worker snapshot acknowledgments, then commit SQL and
1170 /// its watermark. Returns the transaction ID without waiting for every derived
1171 /// event to reach the receiver. Workers may already have sent provisional changes;
1172 /// release them only after their `Progressed` markers confirm the commit.
1173 pub fn commit(self) -> Result<TxId, ReplicaError> {
1174 self.core.commit()
1175 }
1176
1177 /// [`commit`](Self::commit), additionally reporting the transaction's [`CommitInfo`] —
1178 /// the `cv` to stamp on outgoing batches.
1179 ///
1180 /// The whole capture — INCLUDING any `_rindle_client_mutations` rows — fans out to the
1181 /// workers: the lmid table is engine-hosted like any base table, so a client's lmid
1182 /// advance derives through its own system query and is released by the same `cv_min`
1183 /// as the commit's data (no metadata side-channel to race). An empty capture still
1184 /// crosses the worker barrier: it has no data to derive, but every worker must emit
1185 /// `Progressed(N)` so an older live query cannot pin a later query's seq-0 snapshot
1186 /// below its hydrate CV forever.
1187 pub fn commit_with_info(self) -> Result<CommitInfo, ReplicaError> {
1188 self.core.commit_with_info()
1189 }
1190
1191 /// Commit host-local metadata when the public SQL unit captured no application effects.
1192 /// This deliberately does not mint a `TxId`: the retained outcome has `cursor = NULL`, and
1193 /// no worker progress boundary exists for a metadata-only cache write.
1194 pub(crate) fn commit_metadata_only(self) -> Result<(), ReplicaError> {
1195 self.core.commit_metadata_only()
1196 }
1197
1198 /// Explicitly roll back. Leaves every view untouched; delivers nothing.
1199 pub fn rollback(self) {
1200 self.core.rollback()
1201 }
1202}
1203
1204/// The SQL `MutationTx` flavor (design §4.2) for the parallel write path — a server mutator
1205/// runs against the open cluster transaction, reading through the same connection so it
1206/// sees its own uncommitted writes (and lower-`mid` mutations' effects). Mirrors the
1207/// single-thread `impl MutationSql for WriteTxn`.
1208impl MutationSql for ClusterWriteTxn {
1209 fn exec(&mut self, sql: &str, params: &[OwnedValue]) -> Result<usize, ReplicaError> {
1210 ClusterWriteTxn::exec(self, sql, params)
1211 }
1212
1213 fn query(
1214 &mut self,
1215 sql: &str,
1216 params: &[OwnedValue],
1217 ) -> Result<Vec<Vec<OwnedValue>>, ReplicaError> {
1218 ClusterWriteTxn::query(self, sql, params)
1219 }
1220}
1221
1222#[cfg(test)]
1223mod stream_pump_tests {
1224 use super::*;
1225 use crate::parallel::PUSH_CHUNK_ROWS;
1226
1227 fn iv(n: i64) -> OwnedValue {
1228 OwnedValue::Int(n)
1229 }
1230
1231 /// The writer forwards captured changes to the workers **between statements**
1232 /// (`maybe_pump`), so a large transaction's capture buffer stays bounded by ~one
1233 /// `PUSH_CHUNK_ROWS` chunk instead of growing to the whole txn
1234 /// (`CLUSTER-INCREMENTAL-FANOUT-DESIGN.md` §7). This is the writer half of the `O(chunk)`
1235 /// memory bound (the per-worker output half landed in Slice 2). Single-row inserts mirror
1236 /// the replicator follower's `apply_muts` apply shape, where the hook fires once per row so
1237 /// the flush lands exactly on the chunk boundary (no mega-statement on the follower).
1238 #[test]
1239 fn writer_capture_buffer_stays_chunk_bounded_during_a_large_txn() {
1240 let dir = tempfile::tempdir().unwrap();
1241 let path = dir.path().join("pump.db");
1242 let (cluster, _events) = Cluster::open(&path, 2).unwrap();
1243 cluster
1244 .exec_ddl("CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER)")
1245 .unwrap();
1246 cluster.register_table("t").unwrap();
1247
1248 let mut w = cluster.write().unwrap();
1249 let rows = (PUSH_CHUNK_ROWS * 3) as i64; // well past one chunk
1250 let mut high = 0usize;
1251 for id in 0..rows {
1252 w.exec("INSERT INTO t VALUES (?,?)", &[iv(id), iv(id * 2)])
1253 .unwrap();
1254 high = high.max(w.core.store().capture().buffer_len());
1255 }
1256 // Without between-statement pumping this would reach `rows` (3× chunk); with it the
1257 // writer flushes at the chunk boundary, so the buffer never holds a whole txn.
1258 assert!(
1259 high <= PUSH_CHUNK_ROWS,
1260 "capture-buffer high-water {high} exceeded one chunk ({PUSH_CHUNK_ROWS}) — \
1261 the writer is not draining as it writes"
1262 );
1263 assert!(high > 0, "sanity: the writer captured rows");
1264 w.commit().unwrap();
1265 assert_eq!(cluster.committed_tx_id(), TxId(1));
1266 }
1267
1268 #[test]
1269 fn cached_writer_statement_auto_reprepares_after_schema_change() {
1270 let dir = tempfile::tempdir().unwrap();
1271 let path = dir.path().join("cached-writer-schema.db");
1272 let (cluster, _events) = Cluster::open(&path, 1).unwrap();
1273 cluster
1274 .exec_ddl("CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER)")
1275 .unwrap();
1276 cluster.register_table("t").unwrap();
1277
1278 let mut first = cluster.write().unwrap();
1279 first
1280 .exec("INSERT INTO t VALUES (?,?)", &[iv(1), iv(10)])
1281 .unwrap();
1282 first.commit().unwrap();
1283
1284 // Changes the schema cookie while leaving the mutation shape intact. The second write
1285 // checks out the cached VM; sqlite3_step automatically recompiles a v2/v3-prepared
1286 // statement rather than requiring an application-side cache flush.
1287 cluster
1288 .exec_ddl("CREATE INDEX t_v ON t(v)")
1289 .expect("schema change commits");
1290 let mut second = cluster.write().unwrap();
1291 second
1292 .exec("INSERT INTO t VALUES (?,?)", &[iv(2), iv(20)])
1293 .expect("cached statement automatically reprepares");
1294 second.commit().unwrap();
1295
1296 let count: i64 = cluster
1297 .read(|connection| connection.query_row("SELECT count(*) FROM t", [], |row| row.get(0)))
1298 .unwrap();
1299 assert_eq!(count, 2);
1300 }
1301
1302 #[test]
1303 fn one_shot_snapshot_refuses_speculative_streamed_state() {
1304 let dir = tempfile::tempdir().unwrap();
1305 let path = dir.path().join("snapshot-fence.db");
1306 let (cluster, _events) = Cluster::open(&path, 1).unwrap();
1307 cluster
1308 .exec_ddl("CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER)")
1309 .unwrap();
1310 cluster.register_table("t").unwrap();
1311 let query_id = QueryId(1);
1312 cluster.query(query_id, rindle::table("t").build()).unwrap();
1313
1314 let mut write = cluster.write().unwrap();
1315 for id in 0..(PUSH_CHUNK_ROWS as i64 + 1) {
1316 write
1317 .exec("INSERT INTO t VALUES (?,?)", &[iv(id), iv(id * 2)])
1318 .unwrap();
1319 }
1320 assert!(cluster.view_is_speculative());
1321 let error = cluster.read_snapshot(query_id).unwrap_err();
1322 assert!(
1323 matches!(error, ReplicaError::ReadRejected(_)),
1324 "open write exposed a one-shot view snapshot: {error}"
1325 );
1326
1327 write.commit().unwrap();
1328 assert!(!cluster.view_is_speculative());
1329 assert_eq!(
1330 cluster.read_snapshot(query_id).unwrap().len(),
1331 PUSH_CHUNK_ROWS + 1
1332 );
1333 }
1334
1335 /// The fence is scoped to SPECULATIVE state, not to "a write is open". A txn that never
1336 /// crosses `PUSH_CHUNK_ROWS` streams nothing, so every worker's view still sits at the last
1337 /// commit and the one-shot stays serviceable throughout — the read sees the PRE-txn state
1338 /// (committed), never the open txn's rows. Without this scoping, rindled's follower — which
1339 /// parks an open aggregate txn between upstream runs (FOLLOWER-LAG-SHED §5) — would reject
1340 /// SSR one-shots for essentially the whole batching window.
1341 #[test]
1342 fn one_shot_snapshot_serves_committed_state_under_a_small_open_write() {
1343 let dir = tempfile::tempdir().unwrap();
1344 let path = dir.path().join("snapshot-small-txn.db");
1345 let (cluster, _events) = Cluster::open(&path, 1).unwrap();
1346 cluster
1347 .exec_ddl("CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER)")
1348 .unwrap();
1349 cluster.register_table("t").unwrap();
1350 let query_id = QueryId(1);
1351 cluster.query(query_id, rindle::table("t").build()).unwrap();
1352
1353 let mut seed = cluster.write().unwrap();
1354 seed.exec("INSERT INTO t VALUES (?,?)", &[iv(0), iv(0)])
1355 .unwrap();
1356 seed.commit().unwrap();
1357
1358 // Well under the streaming threshold: nothing is pushed to the workers.
1359 let mut write = cluster.write().unwrap();
1360 for id in 1..10i64 {
1361 write
1362 .exec("INSERT INTO t VALUES (?,?)", &[iv(id), iv(id * 2)])
1363 .unwrap();
1364 }
1365 assert!(!cluster.view_is_speculative());
1366 assert_eq!(
1367 cluster.read_snapshot(query_id).unwrap().len(),
1368 1,
1369 "a non-streaming open txn must serve the last COMMITTED view, not its own rows"
1370 );
1371
1372 write.commit().unwrap();
1373 assert_eq!(cluster.read_snapshot(query_id).unwrap().len(), 10);
1374 }
1375
1376 /// Registering a query mid-stream must NOT hydrate from the speculative worker connection.
1377 /// Verified failure before the guard: the baseline came back with `PUSH_CHUNK_ROWS + 1` rows
1378 /// (the seed plus a whole uncommitted transaction) tagged with the PRE-commit `tx_id` — a
1379 /// subscription baseline that claims to be one commit and contains another. Unlike the
1380 /// one-shot leak this is durable damage: every later delta folds onto that wrong baseline.
1381 #[test]
1382 fn registering_a_query_midstream_is_refused_and_correct_after_commit() {
1383 let dir = tempfile::tempdir().unwrap();
1384 let path = dir.path().join("midstream-register.db");
1385 let (cluster, events) = Cluster::open(&path, 1).unwrap();
1386 cluster
1387 .exec_ddl("CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER)")
1388 .unwrap();
1389 cluster.register_table("t").unwrap();
1390
1391 let mut seed = cluster.write().unwrap();
1392 seed.exec("INSERT INTO t VALUES (?,?)", &[iv(0), iv(0)])
1393 .unwrap();
1394 seed.commit().unwrap();
1395 while events.try_recv().is_ok() {}
1396
1397 let mut write = cluster.write().unwrap();
1398 for id in 1..=(PUSH_CHUNK_ROWS as i64 + 1) {
1399 write
1400 .exec("INSERT INTO t VALUES (?,?)", &[iv(id), iv(id)])
1401 .unwrap();
1402 }
1403 assert!(cluster.view_is_speculative());
1404
1405 let qid = QueryId(7);
1406 let refused = cluster
1407 .query(qid, rindle::table("t").build())
1408 .expect_err("registering against a streaming txn must be refused");
1409 assert!(
1410 matches!(refused, ReplicaError::Open(_)),
1411 "unexpected refusal kind: {refused}"
1412 );
1413
1414 // After the commit the same registration is serviceable, and its baseline is the WHOLE
1415 // committed table tagged at the post-commit watermark.
1416 write.commit().unwrap();
1417 assert!(!cluster.view_is_speculative());
1418 let committed = cluster.committed_tx_id();
1419 cluster.query(qid, rindle::table("t").build()).unwrap();
1420
1421 let mut baseline = None;
1422 while let Ok(event) = events.try_recv() {
1423 if let crate::ClusterEvent::Update {
1424 query_id,
1425 update: crate::Update::Hydrated { tx_id, changes },
1426 } = event
1427 {
1428 if query_id == qid {
1429 baseline = Some((tx_id, changes.len()));
1430 }
1431 }
1432 }
1433 assert_eq!(
1434 baseline,
1435 Some((committed, PUSH_CHUNK_ROWS + 2)),
1436 "baseline must be the committed table at the post-commit watermark"
1437 );
1438 }
1439
1440 /// Teardown is the SAFE direction mid-stream, and the worker's FIFO comment now says so:
1441 /// reclaiming a query while a transaction is streaming must leave that transaction able to
1442 /// commit and must not disturb its sibling queries. (Registration is the unsafe direction —
1443 /// see `registering_a_query_midstream_is_refused_and_correct_after_commit`.)
1444 #[test]
1445 fn destroy_query_midstream_leaves_the_txn_intact() {
1446 let dir = tempfile::tempdir().unwrap();
1447 let path = dir.path().join("midstream-destroy.db");
1448 let (cluster, events) = Cluster::open(&path, 1).unwrap();
1449 cluster
1450 .exec_ddl("CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER)")
1451 .unwrap();
1452 cluster.register_table("t").unwrap();
1453 let keep = QueryId(1);
1454 let doomed = QueryId(2);
1455 cluster.query(keep, rindle::table("t").build()).unwrap();
1456 cluster.query(doomed, rindle::table("t").build()).unwrap();
1457
1458 let mut seed = cluster.write().unwrap();
1459 seed.exec("INSERT INTO t VALUES (?,?)", &[iv(0), iv(0)])
1460 .unwrap();
1461 seed.commit().unwrap();
1462 while events.try_recv().is_ok() {}
1463
1464 let mut write = cluster.write().unwrap();
1465 for id in 1..=(PUSH_CHUNK_ROWS as i64 + 1) {
1466 write
1467 .exec("INSERT INTO t VALUES (?,?)", &[iv(id), iv(id)])
1468 .unwrap();
1469 }
1470 assert!(cluster.view_is_speculative());
1471 assert!(cluster.destroy_query(doomed), "teardown mid-stream applies");
1472
1473 write.commit().expect("the streaming txn still commits");
1474 assert_eq!(
1475 cluster.read_snapshot(keep).unwrap().len(),
1476 PUSH_CHUNK_ROWS + 2,
1477 "the surviving query missed rows from the txn its sibling was torn down during"
1478 );
1479 let faults: Vec<_> = std::iter::from_fn(|| events.try_recv().ok())
1480 .filter(|e| matches!(e, crate::ClusterEvent::Faulted { .. }))
1481 .collect();
1482 assert!(
1483 faults.is_empty(),
1484 "solicited teardown faulted a query: {faults:?}"
1485 );
1486 }
1487
1488 /// Rolling back a streamed txn must clear the fence too — otherwise one aborted bulk apply
1489 /// would wedge every later one-shot read for the process's lifetime.
1490 #[test]
1491 fn rollback_of_a_streamed_txn_clears_the_snapshot_fence() {
1492 let dir = tempfile::tempdir().unwrap();
1493 let path = dir.path().join("snapshot-fence-rollback.db");
1494 let (cluster, _events) = Cluster::open(&path, 1).unwrap();
1495 cluster
1496 .exec_ddl("CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER)")
1497 .unwrap();
1498 cluster.register_table("t").unwrap();
1499
1500 let mut write = cluster.write().unwrap();
1501 for id in 0..(PUSH_CHUNK_ROWS as i64 + 1) {
1502 write
1503 .exec("INSERT INTO t VALUES (?,?)", &[iv(id), iv(id * 2)])
1504 .unwrap();
1505 }
1506 assert!(cluster.view_is_speculative());
1507 drop(write);
1508 assert!(
1509 !cluster.view_is_speculative(),
1510 "an aborted streamed txn left the one-shot fence armed"
1511 );
1512 }
1513}
1514
1515#[cfg(test)]
1516mod ddl_marker_tests {
1517 //! [`Cluster::exec_ddl_with_marker`] is the atom the follower's crash-window DDL dedup rests on:
1518 //! the DDL and its idempotency marker must land together (so `marker present` ⇔ `DDL applied`) or
1519 //! roll back together (so a genuine fault leaves no marker and re-applies/faults next time).
1520 use super::*;
1521
1522 const MARKER: &str = "_rindle_applied_ddl";
1523
1524 fn open() -> (Cluster, tempfile::TempDir) {
1525 let dir = tempfile::tempdir().unwrap();
1526 let (cluster, _events) = Cluster::open(dir.path().join("m.db"), 1).unwrap();
1527 cluster
1528 .exec_ddl(&format!(
1529 "CREATE TABLE {MARKER} (key TEXT PRIMARY KEY, actions TEXT)"
1530 ))
1531 .unwrap();
1532 (cluster, dir)
1533 }
1534
1535 /// The observe-mode authorizer (design 227 §3.6): the returned [`DdlApplyReport`] names
1536 /// exactly what the entry dropped/altered, PER STATEMENT and in execution order — through
1537 /// script-valued slots, comments between tokens, and quoting — because SQLite's parser
1538 /// reports the actions, not a text scan. Additive DDL observes nothing destructive. The
1539 /// report is also PERSISTED in the marker row (same transaction), so a replay can re-drive
1540 /// the decisions it feeds (fourth review pass).
1541 ///
1542 /// [`DdlApplyReport`]: crate::schema::DdlApplyReport
1543 #[test]
1544 fn marker_apply_reports_observed_destructive_actions() {
1545 let (cluster, _dir) = open();
1546 let created = cluster
1547 .exec_ddl_with_marker(
1548 MARKER,
1549 "m1",
1550 &["CREATE TABLE kv (k TEXT PRIMARY KEY NOT NULL, v TEXT); \
1551 CREATE INDEX kv_v ON kv (v)"
1552 .into()],
1553 )
1554 .unwrap();
1555 assert!(
1556 created.destructive_tables().next().is_none(),
1557 "got: {created:?}"
1558 );
1559 assert_eq!(
1560 created.steps.len(),
1561 2,
1562 "split at real boundaries: {created:?}"
1563 );
1564 // The explicit CREATE INDEX is captured by exact name (fifth review pass); the table's
1565 // implicit PK auto-index is not.
1566 assert_eq!(
1567 created
1568 .steps
1569 .iter()
1570 .flat_map(|s| s.created_indexes.iter())
1571 .collect::<Vec<_>>(),
1572 vec!["kv_v"],
1573 "got: {created:?}"
1574 );
1575
1576 // A mid-script, comment-laden drop + same-shape recreate: the authorizer still sees it,
1577 // attributed to the drop's OWN statement (order feeds the registry bookkeeping).
1578 let dropped = cluster
1579 .exec_ddl_with_marker(
1580 MARKER,
1581 "m2",
1582 &["CREATE TABLE decoy (d TEXT PRIMARY KEY NOT NULL); \
1583 DROP /* rebuild */ INDEX kv_v; \
1584 DROP -- rebuild\n TABLE \"kv\"; \
1585 CREATE TABLE kv (k TEXT PRIMARY KEY NOT NULL, v TEXT)"
1586 .into()],
1587 )
1588 .unwrap();
1589 assert_eq!(
1590 dropped.dropped_tables().collect::<Vec<_>>(),
1591 vec!["kv"],
1592 "got: {dropped:?}"
1593 );
1594 // The qualified/comment-laden `DROP INDEX kv_v` is captured by its exact bare name.
1595 assert_eq!(
1596 dropped
1597 .steps
1598 .iter()
1599 .flat_map(|s| s.dropped_indexes.iter())
1600 .collect::<Vec<_>>(),
1601 vec!["kv_v"],
1602 "got: {dropped:?}"
1603 );
1604 let drop_step = dropped
1605 .steps
1606 .iter()
1607 .position(|step| !step.dropped_tables.is_empty())
1608 .expect("a step observed the drop");
1609 assert_eq!(
1610 drop_step, 2,
1611 "attributed to the DROP TABLE statement itself"
1612 );
1613
1614 // The report round-trips from the marker row — the durable truth a replay consumes.
1615 let stored: Option<String> = cluster
1616 .read(|conn| {
1617 conn.query_row(
1618 &format!("SELECT actions FROM {MARKER} WHERE key = 'm2'"),
1619 [],
1620 |row| row.get(0),
1621 )
1622 .optional()
1623 })
1624 .unwrap();
1625 let parsed: crate::schema::DdlApplyReport =
1626 serde_json::from_str(&stored.expect("report persisted with the marker")).unwrap();
1627 assert_eq!(
1628 parsed.dropped_tables().collect::<Vec<_>>(),
1629 vec!["kv"],
1630 "stored report round-trips"
1631 );
1632
1633 let altered = cluster
1634 .exec_ddl_with_marker(MARKER, "m3", &["ALTER TABLE kv ADD COLUMN w TEXT".into()])
1635 .unwrap();
1636 assert!(
1637 altered
1638 .steps
1639 .iter()
1640 .any(|step| step.altered_tables.iter().any(|t| t == "kv")),
1641 "got: {altered:?}"
1642 );
1643 }
1644
1645 fn has_marker(cluster: &Cluster, key: &str) -> bool {
1646 cluster
1647 .read(|c| {
1648 c.query_row(
1649 &format!("SELECT 1 FROM {MARKER} WHERE key = ?1"),
1650 [key],
1651 |_| Ok(()),
1652 )
1653 .optional()
1654 .map(|r| r.is_some())
1655 })
1656 .unwrap()
1657 }
1658
1659 fn table_exists(cluster: &Cluster, name: &str) -> bool {
1660 cluster
1661 .read(|c| {
1662 c.query_row(
1663 "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
1664 [name],
1665 |_| Ok(()),
1666 )
1667 .optional()
1668 .map(|r| r.is_some())
1669 })
1670 .unwrap()
1671 }
1672
1673 #[test]
1674 fn success_applies_ddl_and_stamps_the_marker() {
1675 let (cluster, _dir) = open();
1676 cluster
1677 .exec_ddl_with_marker(
1678 MARKER,
1679 "m1",
1680 &["CREATE TABLE kv (k TEXT PRIMARY KEY NOT NULL, v TEXT)".into()],
1681 )
1682 .unwrap();
1683 assert!(table_exists(&cluster, "kv"), "DDL applied");
1684 assert!(has_marker(&cluster, "m1"), "marker stamped");
1685 assert!(
1686 !has_marker(&cluster, "m2"),
1687 "only the applied key is journaled"
1688 );
1689 }
1690
1691 #[test]
1692 fn a_failed_statement_rolls_back_ddl_and_marker_together() {
1693 let (cluster, _dir) = open();
1694 cluster
1695 .exec_ddl_with_marker(
1696 MARKER,
1697 "m1",
1698 &["CREATE TABLE kv (k TEXT PRIMARY KEY NOT NULL)".into()],
1699 )
1700 .unwrap();
1701 // Two statements: the first would succeed, the second faults (no such table). The whole unit
1702 // must roll back — neither the partial DDL nor the marker may survive.
1703 let err = cluster
1704 .exec_ddl_with_marker(
1705 MARKER,
1706 "m2",
1707 &[
1708 "ALTER TABLE kv ADD COLUMN v TEXT".into(),
1709 "ALTER TABLE ghost ADD COLUMN x TEXT".into(),
1710 ],
1711 )
1712 .unwrap_err();
1713 assert!(
1714 format!("{err}")
1715 .to_ascii_lowercase()
1716 .contains("no such table"),
1717 "{err}"
1718 );
1719 assert!(
1720 !has_marker(&cluster, "m2"),
1721 "a faulted unit leaves no marker"
1722 );
1723 assert!(
1724 !column_present(&cluster, "kv", "v"),
1725 "the first statement rolled back with the marker (atomic)"
1726 );
1727 }
1728
1729 #[test]
1730 fn a_failed_step_effect_rolls_back_ddl_effect_and_marker_together() {
1731 let (cluster, _dir) = open();
1732 let error = cluster
1733 .exec_ddl_with_marker_and_step_effects(
1734 MARKER,
1735 "m1",
1736 &["CREATE TABLE kv (k TEXT PRIMARY KEY NOT NULL)".into()],
1737 |_conn, _step| Err(rusqlite::Error::InvalidQuery),
1738 )
1739 .unwrap_err();
1740 assert!(error.to_string().contains("atomic step effects"), "{error}");
1741 assert!(!table_exists(&cluster, "kv"), "DDL rolled back");
1742 assert!(!has_marker(&cluster, "m1"), "marker rolled back");
1743 }
1744
1745 #[test]
1746 fn unsupported_follower_schema_rolls_back_before_stamping_marker() {
1747 let (cluster, _dir) = open();
1748 let error = cluster
1749 .exec_ddl_with_marker(
1750 MARKER,
1751 "blob-schema",
1752 &["CREATE TABLE bad (id INTEGER PRIMARY KEY, payload BLOB)".into()],
1753 )
1754 .unwrap_err();
1755 assert!(error.to_string().contains("unsupported"), "{error}");
1756 assert!(!table_exists(&cluster, "bad"));
1757 assert!(!has_marker(&cluster, "blob-schema"));
1758 }
1759
1760 #[test]
1761 fn trusted_marker_path_allows_atomic_create_copy_swap_migrations() {
1762 let (cluster, _dir) = open();
1763 cluster
1764 .exec_ddl_with_marker(
1765 MARKER,
1766 "schema",
1767 &[
1768 "CREATE TABLE kv (k TEXT PRIMARY KEY NOT NULL, v TEXT NOT NULL)".into(),
1769 "INSERT INTO kv VALUES ('a', 'value')".into(),
1770 ],
1771 )
1772 .unwrap();
1773 cluster
1774 .exec_ddl_with_marker(
1775 MARKER,
1776 "reshape",
1777 &[
1778 "CREATE TABLE kv_new (k TEXT PRIMARY KEY NOT NULL, v TEXT NOT NULL, note TEXT)"
1779 .into(),
1780 "INSERT INTO kv_new(k, v) SELECT k, v FROM kv".into(),
1781 "DROP TABLE kv".into(),
1782 "ALTER TABLE kv_new RENAME TO kv".into(),
1783 ],
1784 )
1785 .unwrap();
1786 assert!(has_marker(&cluster, "reshape"));
1787 assert!(column_present(&cluster, "kv", "note"));
1788 let value: String = cluster
1789 .read(|conn| conn.query_row("SELECT v FROM kv WHERE k = 'a'", [], |row| row.get(0)))
1790 .unwrap();
1791 assert_eq!(value, "value");
1792 }
1793
1794 fn column_present(cluster: &Cluster, table: &str, col: &str) -> bool {
1795 cluster
1796 .read(|c| {
1797 c.query_row(
1798 "SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2",
1799 [table, col],
1800 |_| Ok(()),
1801 )
1802 .optional()
1803 .map(|r| r.is_some())
1804 })
1805 .unwrap()
1806 }
1807}