Rindle docs and package mapSkip to main content

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}