rindle_replica/mutations.rs
1//! **Client mutations** — the upstream half of the optimistic-writes protocol
2//! (`OPTIMISTIC-WRITES-DESIGN.md` §4, §8.1–§8.2).
3//!
4//! A client ships named-mutator invocations as [`MutationEnvelope`]s
5//! (`{ client_id, mid, name, args }` — never code). The server looks `name` up in its
6//! own [`MutatorRegistry`] and runs the mutator against an abstract SQL write handle
7//! ([`MutationSql`], the SQL flavor of the design's `MutationTx`), **one transaction per
8//! mutation**, in `mid` order per client.
9//!
10//! ## `lmid` is co-transactional authoritative state — and flows AS DATA (§8.2)
11//!
12//! Each applied mutation's transaction also upserts
13//! `_rindle_client_mutations(client_id, last_mutation_id = mid)` — so the client's
14//! high-water mutation id (`lmid`) is **data**, durable atomically with the mutation's
15//! row effects, captured by the same CDC stream, and **hosted by the IVM engine like
16//! any other table**. Each client subscribes to its own one-row system query
17//! (`WHERE client_id = me`), so its lmid advance is delivered through the normal
18//! derive → batch → `cv_min` release path — in the SAME coherent release as the
19//! commit's effects. There is no side-channel: a release can never contain a commit's
20//! data without that commit's lmid row, and never an lmid ahead of undelivered data.
21//!
22//! ## There are no rejections
23//!
24//! A mutator that fails (returns [`MutationReject`], panics, or is unknown to the
25//! registry) is still **processed**: its row effects roll back, and `lmid` advances
26//! past its `mid` in a follow-up lmid-only transaction — a normal commit whose lmid
27//! row flows to the client like any data. The client drops the mutation from its
28//! pending stack via the ordinary `mid ≤ lmid` rule; the optimistic prediction
29//! rewinds out on that release (the snap-back). The protocol carries no
30//! rejected/error signal — `MutationReject`'s reason is server-side logging only.
31//!
32//! Server mutators are authoritative and run once, so they need **no** determinism
33//! contract — that constraint (§5) binds the *client* registry, which is re-invoked
34//! on every rebase.
35
36use std::collections::HashMap;
37use std::panic::{catch_unwind, AssertUnwindSafe};
38
39use rindle::value::OwnedValue;
40
41use crate::{Db, ReplicaError};
42use crate::{
43 CLIENT_MUTATIONS_TABLE, ROOM_CLIENT_MUTATIONS_TABLE, ROOM_MUTATION_OUTCOMES_TABLE,
44 ROOM_WATERMARK_TABLE, SCOPE_SESSIONS_TABLE,
45};
46
47/// The SQL flavor of the design's `MutationTx` (§4.2) — the abstract write handle a
48/// server mutator runs against. Statements execute inside the mutation's own open
49/// write transaction, and [`query`](MutationSql::query) reads **through the same
50/// connection**, so a mutator sees its own uncommitted writes and the effects of every
51/// lower-`mid` mutation — exactly what read-dependent mutators need (§4.1).
52pub trait MutationSql {
53 /// Run one statement with positional parameters; returns rows changed.
54 fn exec(&mut self, sql: &str, params: &[OwnedValue]) -> Result<usize, ReplicaError>;
55 /// Run a read returning all rows, each cell mapped from its raw SQLite storage
56 /// class (`INTEGER → Int`, `REAL → Float`, `TEXT → Str`, `NULL → Null`; BLOB is
57 /// an error). Raw classes, **not** the engine's number-widening coercion — the
58 /// mutator is writing SQL, not feeding the pipeline.
59 fn query(
60 &mut self,
61 sql: &str,
62 params: &[OwnedValue],
63 ) -> Result<Vec<Vec<OwnedValue>>, ReplicaError>;
64}
65
66/// The upstream wire envelope (§8.1): one named-mutator invocation. Mutations are
67/// totally ordered per client by `mid`; the wire carries `name` + `args`, never code.
68#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
69pub struct MutationEnvelope {
70 pub client_id: Box<str>,
71 pub mid: u64,
72 pub name: Box<str>,
73 /// JSON-serializable arguments (the determinism contract, §5).
74 pub args: serde_json::Value,
75}
76
77/// Why a mutator refused a mutation (permission/validation/SQL failure). Converts from
78/// the common error shapes so `?` works inside a mutator body.
79#[derive(Clone, Debug)]
80pub struct MutationReject(pub String);
81
82impl From<ReplicaError> for MutationReject {
83 fn from(e: ReplicaError) -> MutationReject {
84 MutationReject(e.to_string())
85 }
86}
87impl From<String> for MutationReject {
88 fn from(s: String) -> MutationReject {
89 MutationReject(s)
90 }
91}
92impl From<&str> for MutationReject {
93 fn from(s: &str) -> MutationReject {
94 MutationReject(s.to_string())
95 }
96}
97
98/// A server-side mutator: authoritative, run once, against the open write transaction.
99/// May read its own transaction's uncommitted writes via [`MutationSql::query`] (read-
100/// dependent mutators see the current base, §4.1). An `Err` rejects the mutation.
101pub type ServerMutator =
102 Box<dyn Fn(&mut dyn MutationSql, &serde_json::Value) -> Result<(), MutationReject>>;
103
104/// The server's registry of named mutators (§4.2). One of the **two** registries (the
105/// client's optimistic twin lives in the client engine); the wire only carries names,
106/// so sharing mutator code is a deployment choice, not a protocol mode.
107#[derive(Default)]
108pub struct MutatorRegistry {
109 map: HashMap<Box<str>, ServerMutator>,
110}
111
112impl MutatorRegistry {
113 pub fn new() -> MutatorRegistry {
114 MutatorRegistry::default()
115 }
116
117 /// Register `f` under `name` (replacing any previous registration).
118 pub fn register(
119 &mut self,
120 name: &str,
121 f: impl Fn(&mut dyn MutationSql, &serde_json::Value) -> Result<(), MutationReject> + 'static,
122 ) {
123 self.map.insert(name.into(), Box::new(f));
124 }
125
126 pub(crate) fn get(&self, name: &str) -> Option<&ServerMutator> {
127 self.map.get(name)
128 }
129}
130
131/// What [`Db::apply_mutations`] did: every transaction it committed (in order — applied
132/// mutations and lmid-only failure commits alike, each with its [`crate::CommitInfo`]).
133/// Duplicate (already-processed) envelopes commit nothing.
134#[derive(Debug, Default)]
135pub struct MutationOutcome {
136 pub commits: Vec<crate::CommitInfo>,
137}
138
139impl Db {
140 /// One-time (idempotent) setup for the client-mutations protocol: create
141 /// [`CLIENT_MUTATIONS_TABLE`] and register it like any base table (CDC capture +
142 /// engine-hosted source), so `lmid` rides the change stream co-transactionally
143 /// with the data AND is queryable — each client's one-row system query is an
144 /// ordinary registered query over it (§8.2). Rejected while a write transaction
145 /// is open (DDL would be invisible to the engine's worker until commit, like
146 /// [`register_table`](Db::register_table)).
147 pub fn enable_client_mutations(&self) -> Result<(), ReplicaError> {
148 if self.inner.in_write.get() {
149 return Err(ReplicaError::Open(
150 "cannot enable client mutations while a write transaction is open".into(),
151 ));
152 }
153 self.inner
154 .writer
155 .execute_batch(&format!(
156 "CREATE TABLE IF NOT EXISTS {CLIENT_MUTATIONS_TABLE} \
157 (client_id TEXT NOT NULL PRIMARY KEY, last_mutation_id INTEGER NOT NULL);\n\
158 CREATE TABLE IF NOT EXISTS {ROOM_CLIENT_MUTATIONS_TABLE} \
159 (doc TEXT NOT NULL, client_id TEXT NOT NULL, last_mutation_id INTEGER NOT NULL, \
160 PRIMARY KEY (doc, client_id))"
161 ))
162 .map_err(|e| ReplicaError::sqlite("create client mutations table", e))?;
163 self.register_table(CLIENT_MUTATIONS_TABLE)
164 }
165
166 /// One-time (idempotent) setup for the §4 realtime lifecycle
167 /// (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md): create the lifecycle tables and
168 /// register them — plus [`ROOM_CLIENT_MUTATIONS_TABLE`] — like any base table (CDC
169 /// capture + engine-hosted source), because each is load-bearing as SUBSCRIBABLE
170 /// data, not just durable state:
171 ///
172 /// - [`SCOPE_SESSIONS_TABLE`] — the occupancy-row delta arriving through a solo
173 /// client's daemon subscription IS the upgrade doorbell (§4.1);
174 /// - [`ROOM_WATERMARK_TABLE`] — the downgrade fence clears only when the client's
175 /// daemon subscription delivers the room's final `flush_seq` (§4.2);
176 /// - [`ROOM_MUTATION_OUTCOMES_TABLE`] — outcome resolution with no room socket alive
177 /// (created + registered here; POPULATED by Slice I-ii's flush split);
178 /// - [`ROOM_CLIENT_MUTATIONS_TABLE`] — "after downgrade, the doc-scoped ledger row is
179 /// ordinary footprint data" (§7.1, load-bearing for §7.5). Slice C created it
180 /// deliberately direct-SQL-only; the lifecycle is what needs it readable through
181 /// the daemon subscription plane, so its registration lands here, not in
182 /// [`enable_client_mutations`](Db::enable_client_mutations).
183 ///
184 /// Requires [`enable_client_mutations`](Db::enable_client_mutations) to have run
185 /// first: that call owns the room ledger's DDL, and re-issuing a second copy here
186 /// would be one edit away from two drifting schemas — enforced with a loud error
187 /// (the `require_enabled` idiom) instead. Rejected while a write transaction is
188 /// open, like the precedent.
189 pub fn enable_realtime_lifecycle(&self) -> Result<(), ReplicaError> {
190 if self.inner.in_write.get() {
191 return Err(ReplicaError::Open(
192 "cannot enable the realtime lifecycle while a write transaction is open".into(),
193 ));
194 }
195 if !self.inner.cdc.has_table(CLIENT_MUTATIONS_TABLE) {
196 return Err(ReplicaError::Mutation(
197 "realtime lifecycle requires client mutations — call enable_client_mutations() \
198 first (it owns the room ledger's DDL)"
199 .into(),
200 ));
201 }
202 self.inner
203 .writer
204 .execute_batch(&realtime_lifecycle_ddl())
205 .map_err(|e| ReplicaError::sqlite("create realtime lifecycle tables", e))?;
206 for table in [
207 SCOPE_SESSIONS_TABLE,
208 ROOM_WATERMARK_TABLE,
209 ROOM_MUTATION_OUTCOMES_TABLE,
210 ROOM_CLIENT_MUTATIONS_TABLE,
211 ] {
212 self.register_table(table)?;
213 }
214 Ok(())
215 }
216
217 /// The high-water mutation id durably recorded for `client_id` (0 if none — a new
218 /// client). What a server stamps on a (re)connecting client's handshake so it can
219 /// drop already-confirmed pending mutations. Requires
220 /// [`enable_client_mutations`](Db::enable_client_mutations).
221 pub fn client_lmid(&self, client_id: &str) -> Result<u64, ReplicaError> {
222 self.require_enabled()?;
223 use rusqlite::OptionalExtension;
224 let got = self
225 .read(|c| {
226 c.query_row(
227 &format!(
228 "SELECT last_mutation_id FROM {CLIENT_MUTATIONS_TABLE} \
229 WHERE client_id = ?1"
230 ),
231 [client_id],
232 |r| r.get::<_, i64>(0),
233 )
234 .optional()
235 })?
236 .map(|v| v as u64);
237 Ok(got.unwrap_or(0))
238 }
239
240 /// Apply a client's mutation push: each envelope's mutator runs in **its own**
241 /// transaction (with the co-transactional `lmid` upsert), in the order given.
242 /// Per envelope, against its client's stored `lmid`:
243 ///
244 /// - `mid ≤ lmid` — already processed: **skipped** (idempotent redelivery).
245 /// - `mid == lmid + 1` — applied; on mutator failure (error / panic / unknown
246 /// name) the effects roll back and `lmid` still advances in an lmid-only commit
247 /// (processed-as-no-op — there is no rejection signal).
248 /// - `mid > lmid + 1` — a gap (the client must send contiguously, so this should
249 /// be impossible): the push fails with [`ReplicaError::Mutation`] *at that
250 /// envelope* (prior envelopes stay applied — they are already durable).
251 ///
252 /// Every committed transaction fires the registered queries' subscriptions as
253 /// usual and is reported (with its [`crate::CommitInfo`]) in the outcome, so the
254 /// caller can drive a progress tracker / poke layer. Must be called with no write
255 /// transaction open (it manages its own).
256 pub fn apply_mutations(
257 &self,
258 registry: &MutatorRegistry,
259 envelopes: &[MutationEnvelope],
260 ) -> Result<MutationOutcome, ReplicaError> {
261 self.require_enabled()?;
262 let mut out = MutationOutcome::default();
263 for env in envelopes {
264 let stored = self.client_lmid(&env.client_id)?;
265 if env.mid <= stored {
266 continue; // duplicate redelivery — already processed
267 }
268 if env.mid != stored + 1 {
269 return Err(ReplicaError::Mutation(format!(
270 "mutation id gap for client {:?}: expected {}, got {}",
271 env.client_id,
272 stored + 1,
273 env.mid
274 )));
275 }
276
277 let mut txn = self.write()?;
278 // Unknown name is a rejection, not a protocol error: the registries may
279 // drift across deploys, and the client recovers via rejected + snap-back.
280 let invoked: Result<(), MutationReject> = match registry.get(&env.name) {
281 None => Err(MutationReject(format!("unknown mutator {:?}", env.name))),
282 Some(f) => match catch_unwind(AssertUnwindSafe(|| f(&mut txn, &env.args))) {
283 Ok(r) => r,
284 Err(_) => Err(MutationReject(format!("mutator {:?} panicked", env.name))),
285 },
286 };
287
288 match invoked {
289 Ok(()) => {
290 upsert_lmid(&mut txn, &env.client_id, env.mid)?;
291 out.commits.push(txn.commit_with_info()?);
292 }
293 Err(MutationReject(reason)) => {
294 txn.rollback();
295 // The lmid-only commit: the durable record that `mid` was
296 // processed (as a no-op). Its capture is just the lmid row, which
297 // flows to the client's own lmid query like any data — the
298 // pending prediction snaps back on that release. The reason is
299 // server-side observability only; the protocol carries no signal.
300 eprintln!(
301 "[rindle-replica] mutation {} for client {:?} failed (lmid still advances): {reason}",
302 env.mid, env.client_id
303 );
304 let mut lmid_txn = self.write()?;
305 upsert_lmid(&mut lmid_txn, &env.client_id, env.mid)?;
306 out.commits.push(lmid_txn.commit_with_info()?);
307 }
308 }
309 }
310 Ok(out)
311 }
312
313 fn require_enabled(&self) -> Result<(), ReplicaError> {
314 if !self.inner.cdc.has_table(CLIENT_MUTATIONS_TABLE) {
315 return Err(ReplicaError::Mutation(
316 "client mutations not enabled — call enable_client_mutations() first".into(),
317 ));
318 }
319 Ok(())
320 }
321}
322
323/// The §4 lifecycle DDL (idempotent), shared verbatim by [`Db::enable_realtime_lifecycle`]
324/// and [`Cluster::enable_realtime_lifecycle`](crate::Cluster::enable_realtime_lifecycle) so
325/// both paths mint identical schemas (the [`upsert_lmid`] sharing discipline, applied to
326/// DDL). [`ROOM_CLIENT_MUTATIONS_TABLE`] is deliberately absent: its DDL is owned by
327/// `enable_client_mutations`, which the lifecycle enables require first.
328pub(crate) fn realtime_lifecycle_ddl() -> String {
329 format!(
330 "CREATE TABLE IF NOT EXISTS {SCOPE_SESSIONS_TABLE} \
331 (scope TEXT NOT NULL, client_id TEXT NOT NULL, expires_at INTEGER NOT NULL, \
332 PRIMARY KEY (scope, client_id));\n\
333 CREATE TABLE IF NOT EXISTS {ROOM_WATERMARK_TABLE} \
334 (doc TEXT NOT NULL PRIMARY KEY, flush_seq INTEGER NOT NULL);\n\
335 CREATE TABLE IF NOT EXISTS {ROOM_MUTATION_OUTCOMES_TABLE} \
336 (doc TEXT NOT NULL, client_id TEXT NOT NULL, mid INTEGER NOT NULL, kind TEXT NOT NULL, \
337 reason TEXT, name TEXT, args TEXT, PRIMARY KEY (doc, client_id, mid))"
338 )
339}
340
341/// Upsert `last_mutation_id = mid` for `client_id` in the open transaction. Generic over
342/// the [`MutationSql`] write handle so both the single-thread [`WriteTxn`](crate::WriteTxn)
343/// and the parallel `ClusterWriteTxn` share it (WS3).
344pub(crate) fn upsert_lmid(
345 txn: &mut impl MutationSql,
346 client_id: &str,
347 mid: u64,
348) -> Result<(), ReplicaError> {
349 txn.exec(
350 &format!(
351 "INSERT INTO {CLIENT_MUTATIONS_TABLE}(client_id, last_mutation_id) \
352 VALUES(?1, ?2) ON CONFLICT(client_id) DO UPDATE \
353 SET last_mutation_id = excluded.last_mutation_id"
354 ),
355 &[OwnedValue::str(client_id), OwnedValue::Int(mid as i64)],
356 )?;
357 Ok(())
358}