Skip to main content

rhiza_sql/
control.rs

1//! Durable control-plane state for physical SQLite effects.
2//!
3//! This database is deliberately separate from the canonical user database so
4//! node-local identity and qlog progress cannot change replicated user pages.
5
6#[cfg(test)]
7use std::sync::{Mutex, OnceLock};
8use std::{
9    collections::HashSet,
10    fmt::Write as _,
11    fs::OpenOptions,
12    path::{Path, PathBuf},
13};
14
15use rhiza_core::{ConfigurationState, LogAnchor, LogEntry, LogHash};
16use rusqlite::{
17    params, params_from_iter, types::Value, Connection, OpenFlags, OptionalExtension, Transaction,
18    TransactionBehavior,
19};
20use serde::{Deserialize, Serialize};
21
22use super::{ApplyProgress, Error, RequestConflict, RequestOutcome, Result};
23use crate::page_state::StateIdentityV3;
24
25const CONTROL_MAGIC: &[u8] = b"RHIZA-SQL-CONTROL\0\x06";
26const CONTROL_SCHEMA_VERSION: u64 = 6;
27const SNAPSHOT_MAGIC: &[u8] = b"QCTL\0\x06";
28const MAX_RESULT_BLOB_BYTES: usize = super::MAX_SQL_EFFECT_BYTES;
29const SQLITE_VARIABLE_LIMIT: usize = 999;
30const RECEIPT_LOOKUP_CHUNK_SIZE: usize = SQLITE_VARIABLE_LIMIT;
31const RECEIPT_INSERT_CHUNK_SIZE: usize = (SQLITE_VARIABLE_LIMIT - 2) / 3;
32
33const CREATE_CONTROL_META_SQL: &str = r#"CREATE TABLE control_meta (
34    key TEXT PRIMARY KEY,
35    value BLOB NOT NULL
36) WITHOUT ROWID;"#;
37const CREATE_REQUEST_RECEIPTS_SQL: &str = r#"CREATE TABLE request_receipts (
38    request_id TEXT PRIMARY KEY,
39    request_digest BLOB NOT NULL CHECK(length(request_digest) = 32),
40    original_log_index INTEGER NOT NULL CHECK(original_log_index >= 0),
41    original_log_hash BLOB NOT NULL CHECK(length(original_log_hash) = 32),
42    result_blob BLOB NOT NULL
43) WITHOUT ROWID;"#;
44const CREATE_PENDING_APPLY_SQL: &str = r#"CREATE TABLE pending_apply (
45    singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
46    base_index INTEGER NOT NULL CHECK(base_index >= 0),
47    base_hash BLOB NOT NULL CHECK(length(base_hash) = 32),
48    entry_index INTEGER NOT NULL CHECK(entry_index > 0),
49    entry_hash BLOB NOT NULL CHECK(length(entry_hash) = 32),
50    base_page_size INTEGER NOT NULL CHECK(base_page_size >= 512 AND base_page_size <= 65536),
51    base_page_count INTEGER NOT NULL CHECK(base_page_count > 0),
52    base_state_root BLOB NOT NULL CHECK(length(base_state_root) = 32),
53    target_page_size INTEGER NOT NULL CHECK(target_page_size >= 512 AND target_page_size <= 65536),
54    target_page_count INTEGER NOT NULL CHECK(target_page_count > 0),
55    target_state_root BLOB NOT NULL CHECK(length(target_state_root) = 32)
56);"#;
57const CREATE_EMBEDDED_LOG_SQL: &str = r#"CREATE TABLE embedded_qlog (
58    log_index INTEGER PRIMARY KEY CHECK(log_index > 0),
59    initial_configuration BLOB NOT NULL,
60    entry_bytes BLOB NOT NULL
61) WITHOUT ROWID;"#;
62
63const REQUIRED_META_KEYS: [&str; 12] = [
64    "magic",
65    "schema_version",
66    "cluster_id",
67    "node_id",
68    "epoch",
69    "configuration_state",
70    "recovery_generation",
71    "materializer_fingerprint",
72    "page_size",
73    "page_count",
74    "state_root",
75    "applied_tip",
76];
77
78type ExpectedColumn = (&'static str, &'static str, bool, i64);
79
80const CONTROL_META_COLUMNS: &[ExpectedColumn] =
81    &[("key", "TEXT", true, 1), ("value", "BLOB", true, 0)];
82const REQUEST_RECEIPT_COLUMNS: &[ExpectedColumn] = &[
83    ("request_id", "TEXT", true, 1),
84    ("request_digest", "BLOB", true, 0),
85    ("original_log_index", "INTEGER", true, 0),
86    ("original_log_hash", "BLOB", true, 0),
87    ("result_blob", "BLOB", true, 0),
88];
89const PENDING_APPLY_COLUMNS: &[ExpectedColumn] = &[
90    ("singleton", "INTEGER", false, 1),
91    ("base_index", "INTEGER", true, 0),
92    ("base_hash", "BLOB", true, 0),
93    ("entry_index", "INTEGER", true, 0),
94    ("entry_hash", "BLOB", true, 0),
95    ("base_page_size", "INTEGER", true, 0),
96    ("base_page_count", "INTEGER", true, 0),
97    ("base_state_root", "BLOB", true, 0),
98    ("target_page_size", "INTEGER", true, 0),
99    ("target_page_count", "INTEGER", true, 0),
100    ("target_state_root", "BLOB", true, 0),
101];
102const EMBEDDED_LOG_COLUMNS: &[ExpectedColumn] = &[
103    ("log_index", "INTEGER", true, 1),
104    ("initial_configuration", "BLOB", true, 0),
105    ("entry_bytes", "BLOB", true, 0),
106];
107
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct ControlIdentity {
110    cluster_id: String,
111    node_id: String,
112    epoch: u64,
113    configuration_state: ConfigurationState,
114    recovery_generation: u64,
115    materializer_fingerprint: LogHash,
116    user_state: StateIdentityV3,
117}
118
119impl ControlIdentity {
120    pub fn new(
121        cluster_id: impl Into<String>,
122        node_id: impl Into<String>,
123        epoch: u64,
124        configuration_state: ConfigurationState,
125        recovery_generation: u64,
126        materializer_fingerprint: LogHash,
127        user_state: StateIdentityV3,
128    ) -> Self {
129        Self {
130            cluster_id: cluster_id.into(),
131            node_id: node_id.into(),
132            epoch,
133            configuration_state,
134            recovery_generation,
135            materializer_fingerprint,
136            user_state,
137        }
138    }
139
140    pub fn cluster_id(&self) -> &str {
141        &self.cluster_id
142    }
143    pub fn node_id(&self) -> &str {
144        &self.node_id
145    }
146    pub const fn epoch(&self) -> u64 {
147        self.epoch
148    }
149    pub const fn configuration_state(&self) -> &ConfigurationState {
150        &self.configuration_state
151    }
152    pub const fn recovery_generation(&self) -> u64 {
153        self.recovery_generation
154    }
155    pub const fn materializer_fingerprint(&self) -> LogHash {
156        self.materializer_fingerprint
157    }
158    pub const fn user_state(&self) -> StateIdentityV3 {
159        self.user_state
160    }
161}
162
163#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
164#[serde(deny_unknown_fields)]
165pub struct RequestReceipt {
166    request_id: String,
167    request_digest: LogHash,
168    original_anchor: LogAnchor,
169    result_blob: Vec<u8>,
170}
171
172impl RequestReceipt {
173    pub fn new(
174        request_id: impl Into<String>,
175        request_digest: LogHash,
176        original_anchor: LogAnchor,
177        result_blob: Vec<u8>,
178    ) -> Self {
179        Self {
180            request_id: request_id.into(),
181            request_digest,
182            original_anchor,
183            result_blob,
184        }
185    }
186
187    pub fn request_id(&self) -> &str {
188        &self.request_id
189    }
190    pub const fn request_digest(&self) -> LogHash {
191        self.request_digest
192    }
193    pub const fn original_anchor(&self) -> LogAnchor {
194        self.original_anchor
195    }
196    pub fn result_blob(&self) -> &[u8] {
197        &self.result_blob
198    }
199}
200
201#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
202#[serde(deny_unknown_fields)]
203pub struct PendingApply {
204    base: LogAnchor,
205    entry: LogAnchor,
206    base_state: StateIdentityV3,
207    target_state: StateIdentityV3,
208}
209
210impl PendingApply {
211    pub const fn new(
212        base: LogAnchor,
213        entry: LogAnchor,
214        base_state: StateIdentityV3,
215        target_state: StateIdentityV3,
216    ) -> Self {
217        Self {
218            base,
219            entry,
220            base_state,
221            target_state,
222        }
223    }
224
225    pub const fn base(&self) -> LogAnchor {
226        self.base
227    }
228    pub const fn entry(&self) -> LogAnchor {
229        self.entry
230    }
231    pub const fn base_state(&self) -> StateIdentityV3 {
232        self.base_state
233    }
234    pub const fn target_state(&self) -> StateIdentityV3 {
235        self.target_state
236    }
237}
238
239#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
240#[serde(deny_unknown_fields)]
241struct ReplicatedSnapshot {
242    cluster_id: String,
243    epoch: u64,
244    configuration_state: ConfigurationState,
245    recovery_generation: u64,
246    materializer_fingerprint: LogHash,
247    user_state: StateIdentityV3,
248    applied_tip: LogAnchor,
249    receipts: Vec<RequestReceipt>,
250}
251
252pub struct ControlStore {
253    path: PathBuf,
254    conn: Connection,
255}
256
257#[cfg(test)]
258fn pending_query_audits() -> &'static Mutex<Vec<(PathBuf, usize)>> {
259    static AUDITS: OnceLock<Mutex<Vec<(PathBuf, usize)>>> = OnceLock::new();
260    AUDITS.get_or_init(|| Mutex::new(Vec::new()))
261}
262
263#[cfg(test)]
264pub(crate) fn begin_pending_query_audit(path: &Path) {
265    pending_query_audits()
266        .lock()
267        .unwrap()
268        .push((path.to_path_buf(), 0));
269}
270
271#[cfg(test)]
272pub(crate) fn pending_query_count(path: &Path) -> Option<usize> {
273    pending_query_audits().lock().ok().and_then(|audits| {
274        audits
275            .iter()
276            .rev()
277            .find(|(audited, _)| audited == path)
278            .map(|(_, count)| *count)
279    })
280}
281
282#[cfg(test)]
283fn note_pending_query(path: &Path) {
284    if let Ok(mut audits) = pending_query_audits().lock() {
285        if let Some((_, count)) = audits.iter_mut().rev().find(|(audited, _)| audited == path) {
286            *count += 1;
287        }
288    }
289}
290
291impl ControlStore {
292    /// Opens and validates an existing sidecar, or creates it if absent.
293    pub fn open(path: impl AsRef<Path>, identity: &ControlIdentity) -> Result<Self> {
294        let path = path.as_ref();
295        if path.exists() {
296            Self::open_existing(path, identity)
297        } else {
298            Self::create(path, identity)
299        }
300    }
301
302    pub fn create(path: impl AsRef<Path>, identity: &ControlIdentity) -> Result<Self> {
303        validate_new_identity(identity)?;
304        let path = path.as_ref();
305        if let Some(parent) = path
306            .parent()
307            .filter(|parent| !parent.as_os_str().is_empty())
308        {
309            std::fs::create_dir_all(parent).map_err(|error| Error::Io(error.to_string()))?;
310        }
311        OpenOptions::new()
312            .write(true)
313            .create_new(true)
314            .open(path)
315            .map_err(|error| Error::Io(error.to_string()))?;
316
317        let store = match Self::open_file(path) {
318            Ok(store) => store,
319            Err(error) => {
320                let _ = std::fs::remove_file(path);
321                return Err(error);
322            }
323        };
324        if let Err(error) = store.initialize(identity) {
325            drop(store);
326            let _ = std::fs::remove_file(path);
327            return Err(error);
328        }
329        sync_parent(path)?;
330        Ok(store)
331    }
332
333    pub fn open_existing(path: impl AsRef<Path>, identity: &ControlIdentity) -> Result<Self> {
334        validate_new_identity(identity)?;
335        let store = Self::open_existing_unchecked(path)?;
336        store.validate_identity(identity)?;
337        Ok(store)
338    }
339
340    /// Opens a sidecar and validates its durable format without imposing an
341    /// expected runtime identity. This is used by recovery paths that must load
342    /// the persisted identity before they can validate the paired user DB.
343    pub fn open_existing_unchecked(path: impl AsRef<Path>) -> Result<Self> {
344        let store = Self::open_file(path.as_ref())?;
345        store.validate_schema()?;
346        Ok(store)
347    }
348
349    pub fn read_identity(path: impl AsRef<Path>) -> Result<ControlIdentity> {
350        Self::open_existing_unchecked(path)?.identity()
351    }
352
353    fn open_file(path: &Path) -> Result<Self> {
354        let conn = Connection::open_with_flags(
355            path,
356            OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX,
357        )
358        .map_err(sqlite_error)?;
359        let journal: String = conn
360            .query_row("PRAGMA journal_mode = DELETE", [], |row| row.get(0))
361            .map_err(sqlite_error)?;
362        if !journal.eq_ignore_ascii_case("delete") {
363            return Err(Error::Sqlite(format!(
364                "control sidecar refused DELETE journal mode: {journal}"
365            )));
366        }
367        conn.pragma_update(None, "synchronous", "OFF")
368            .map_err(sqlite_error)?;
369        conn.pragma_update(None, "foreign_keys", "ON")
370            .map_err(sqlite_error)?;
371        conn.busy_timeout(std::time::Duration::from_secs(5))
372            .map_err(sqlite_error)?;
373        Ok(Self {
374            path: path.to_path_buf(),
375            conn,
376        })
377    }
378
379    fn initialize(&self, identity: &ControlIdentity) -> Result<()> {
380        let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)
381            .map_err(sqlite_error)?;
382        tx.execute_batch(CREATE_CONTROL_META_SQL)
383            .map_err(sqlite_error)?;
384        tx.execute_batch(CREATE_REQUEST_RECEIPTS_SQL)
385            .map_err(sqlite_error)?;
386        tx.execute_batch(CREATE_PENDING_APPLY_SQL)
387            .map_err(sqlite_error)?;
388        tx.execute_batch(CREATE_EMBEDDED_LOG_SQL)
389            .map_err(sqlite_error)?;
390        put_meta(&tx, "magic", CONTROL_MAGIC)?;
391        put_u64(&tx, "schema_version", CONTROL_SCHEMA_VERSION)?;
392        put_meta(&tx, "cluster_id", identity.cluster_id.as_bytes())?;
393        put_meta(&tx, "node_id", identity.node_id.as_bytes())?;
394        put_u64(&tx, "epoch", identity.epoch)?;
395        put_configuration(&tx, &identity.configuration_state)?;
396        put_u64(&tx, "recovery_generation", identity.recovery_generation)?;
397        put_hash(
398            &tx,
399            "materializer_fingerprint",
400            identity.materializer_fingerprint,
401        )?;
402        put_state(&tx, identity.user_state)?;
403        put_anchor(&tx, "applied_tip", LogAnchor::new(0, LogHash::ZERO))?;
404        tx.commit().map_err(sqlite_error)
405    }
406
407    pub fn path(&self) -> &Path {
408        &self.path
409    }
410
411    pub fn identity(&self) -> Result<ControlIdentity> {
412        Ok(ControlIdentity::new(
413            meta_text(&self.conn, "cluster_id")?,
414            meta_text(&self.conn, "node_id")?,
415            meta_u64(&self.conn, "epoch")?,
416            meta_configuration(&self.conn)?,
417            meta_u64(&self.conn, "recovery_generation")?,
418            meta_hash(&self.conn, "materializer_fingerprint")?,
419            meta_state(&self.conn)?,
420        ))
421    }
422
423    pub fn applied_tip(&self) -> Result<ApplyProgress> {
424        let anchor = meta_anchor(&self.conn, "applied_tip")?;
425        Ok(ApplyProgress::new(anchor.index(), anchor.hash()))
426    }
427
428    pub fn configuration_state(&self) -> Result<ConfigurationState> {
429        meta_configuration(&self.conn)
430    }
431
432    pub fn recovery_generation(&self) -> Result<u64> {
433        meta_u64(&self.conn, "recovery_generation")
434    }
435
436    pub fn materializer_fingerprint(&self) -> Result<LogHash> {
437        meta_hash(&self.conn, "materializer_fingerprint")
438    }
439
440    pub fn user_state(&self) -> Result<StateIdentityV3> {
441        meta_state(&self.conn)
442    }
443
444    pub fn lookup_request(
445        &self,
446        request_id: &str,
447        request_digest: LogHash,
448    ) -> Result<Option<RequestReceipt>> {
449        self.lookup_requests(&[(request_id, request_digest)])?
450            .pop()
451            .expect("one request produces one aligned lookup")
452    }
453
454    /// Returns receipts in the exact order of the requested `(id, digest)`
455    /// pairs using one bounded control-sidecar query.
456    ///
457    /// Request ids must be unique. Missing ids produce `Ok(None)`; an existing
458    /// id with another digest produces an aligned request-conflict error so a
459    /// caller can isolate that member without issuing another query.
460    pub fn lookup_requests(
461        &self,
462        requests: &[(&str, LogHash)],
463    ) -> Result<Vec<Result<Option<RequestReceipt>>>> {
464        lookup_requests_from(&self.conn, requests)
465    }
466
467    #[cfg(test)]
468    pub(crate) fn begin_pending(&self, pending: &PendingApply) -> Result<()> {
469        let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)
470            .map_err(sqlite_error)?;
471        if let Some(existing) = pending_from(&tx)? {
472            return if existing == *pending {
473                tx.commit().map_err(sqlite_error)
474            } else {
475                Err(Error::InvalidEntry(
476                    "a different physical apply is already pending".into(),
477                ))
478            };
479        }
480        let tip = meta_anchor(&tx, "applied_tip")?;
481        let user_state = meta_state(&tx)?;
482        if pending.base != tip || pending.base_state != user_state {
483            return Err(Error::InvalidEntry(
484                "pending apply does not match the committed base".into(),
485            ));
486        }
487        if pending.entry.index()
488            != tip
489                .index()
490                .checked_add(1)
491                .ok_or_else(|| Error::InvalidEntry("applied index is exhausted".into()))?
492        {
493            return Err(Error::InvalidEntry(
494                "pending apply entry is not the next slot".into(),
495            ));
496        }
497        insert_pending(&tx, pending)?;
498        tx.commit().map_err(sqlite_error)
499    }
500
501    /// Atomically makes the physical-apply intent and its complete local qlog entry durable.
502    pub(crate) fn begin_pending_with_entry(
503        &self,
504        pending: &PendingApply,
505        entry: &LogEntry,
506    ) -> Result<()> {
507        if LogAnchor::new(entry.index, entry.hash) != pending.entry
508            || entry.recompute_hash() != entry.hash
509        {
510            return Err(Error::InvalidEntry(
511                "embedded qlog entry does not match the pending apply".into(),
512            ));
513        }
514        let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)
515            .map_err(sqlite_error)?;
516        insert_or_validate_embedded_entry(&tx, entry)?;
517        if let Some(existing) = pending_from(&tx)? {
518            return if existing == *pending {
519                tx.commit().map_err(sqlite_error)
520            } else {
521                Err(Error::InvalidEntry(
522                    "a different physical apply is already pending".into(),
523                ))
524            };
525        }
526        let tip = meta_anchor(&tx, "applied_tip")?;
527        let user_state = meta_state(&tx)?;
528        if pending.base != tip || pending.base_state != user_state {
529            return Err(Error::InvalidEntry(
530                "pending apply does not match the committed base".into(),
531            ));
532        }
533        if pending.entry.index()
534            != tip
535                .index()
536                .checked_add(1)
537                .ok_or_else(|| Error::InvalidEntry("applied index is exhausted".into()))?
538        {
539            return Err(Error::InvalidEntry(
540                "pending apply entry is not the next slot".into(),
541            ));
542        }
543        insert_pending(&tx, pending)?;
544        tx.commit().map_err(sqlite_error)
545    }
546
547    /// Returns a contiguous interval from the qlog embedded in the control sidecar.
548    pub fn embedded_log_entries(
549        &self,
550        from_index: u64,
551        through_index: u64,
552    ) -> Result<Vec<LogEntry>> {
553        if from_index > through_index {
554            return Ok(Vec::new());
555        }
556        let mut statement = self
557            .conn
558            .prepare(
559                "SELECT log_index,initial_configuration,entry_bytes FROM embedded_qlog
560                 WHERE log_index >= ?1 AND log_index <= ?2 ORDER BY log_index",
561            )
562            .map_err(sqlite_error)?;
563        let rows = statement
564            .query_map(
565                params![u64_to_sql(from_index)?, u64_to_sql(through_index)?],
566                |row| {
567                    Ok((
568                        u64_from_sql(row.get(0)?)?,
569                        row.get::<_, Vec<u8>>(1)?,
570                        row.get::<_, Vec<u8>>(2)?,
571                    ))
572                },
573            )
574            .map_err(sqlite_error)?;
575        let cluster_id = meta_text(&self.conn, "cluster_id")?;
576        let mut expected = from_index;
577        let mut expected_configuration = None;
578        let mut entries = Vec::new();
579        for row in rows {
580            let (index, initial_configuration, encoded) = row.map_err(sqlite_error)?;
581            if index != expected {
582                return Err(Error::InvalidEntry(format!(
583                    "embedded qlog is missing index {expected}"
584                )));
585            }
586            let initial_configuration =
587                decode_configuration_bytes(&initial_configuration, "embedded qlog")?;
588            if expected_configuration
589                .as_ref()
590                .is_some_and(|expected| expected != &initial_configuration)
591            {
592                return Err(Error::InvalidEntry(
593                    "embedded qlog configuration chain is discontinuous".into(),
594                ));
595            }
596            let (entry, resulting_configuration) =
597                decode_embedded_log_entry(&encoded, &cluster_id, initial_configuration)?;
598            if entry.index != index {
599                return Err(Error::InvalidEntry(
600                    "embedded qlog key does not match its entry index".into(),
601                ));
602            }
603            entries.push(entry);
604            expected_configuration = Some(resulting_configuration);
605            expected = expected
606                .checked_add(1)
607                .ok_or_else(|| Error::InvalidEntry("embedded qlog index overflow".into()))?;
608        }
609        if expected <= through_index {
610            return Err(Error::InvalidEntry(format!(
611                "embedded qlog is missing index {expected}"
612            )));
613        }
614        Ok(entries)
615    }
616
617    /// Removes embedded qlog entries before a verified checkpoint anchor.
618    pub(crate) fn compact_embedded_log_before(&self, anchor_index: u64) -> Result<()> {
619        let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)
620            .map_err(sqlite_error)?;
621        let applied_index = meta_anchor(&tx, "applied_tip")?.index();
622        if anchor_index > applied_index {
623            return Err(Error::InvalidEntry(format!(
624                "cannot compact embedded qlog before anchor {anchor_index} beyond applied index {applied_index}"
625            )));
626        }
627        tx.execute(
628            "DELETE FROM embedded_qlog WHERE log_index < ?1",
629            [u64_to_sql(anchor_index)?],
630        )
631        .map_err(sqlite_error)?;
632        tx.commit().map_err(sqlite_error)
633    }
634
635    pub fn pending(&self) -> Result<Option<PendingApply>> {
636        #[cfg(test)]
637        note_pending_query(&self.path);
638        pending_from(&self.conn)
639    }
640
641    #[cfg(test)]
642    pub(crate) fn clear_pending(&self, expected: &PendingApply) -> Result<()> {
643        let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)
644            .map_err(sqlite_error)?;
645        match pending_from(&tx)? {
646            Some(actual) if actual == *expected => {
647                tx.execute("DELETE FROM pending_apply WHERE singleton = 1", [])
648                    .map_err(sqlite_error)?;
649                tx.commit().map_err(sqlite_error)
650            }
651            Some(_) => Err(Error::InvalidEntry(
652                "refusing to clear a different pending apply".into(),
653            )),
654            None => tx.commit().map_err(sqlite_error),
655        }
656    }
657
658    /// Durably advances a log entry that changes no replicated user data and
659    /// does not transition the configuration. The exact committed base is
660    /// checked again inside the same FULL-synchronous transaction as the tip
661    /// update, so this path cannot bypass an older physical apply intent.
662    #[cfg(test)]
663    pub(crate) fn commit_metadata_only_entry(
664        &self,
665        expected_base: LogAnchor,
666        entry: LogAnchor,
667        expected_configuration: &ConfigurationState,
668        expected_user_state: StateIdentityV3,
669    ) -> Result<()> {
670        let expected_entry_index = expected_base
671            .index()
672            .checked_add(1)
673            .ok_or_else(|| Error::InvalidEntry("applied index is exhausted".into()))?;
674        if entry.index() != expected_entry_index {
675            return Err(Error::InvalidEntry(
676                "metadata-only entry is not the next slot".into(),
677            ));
678        }
679
680        let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)
681            .map_err(sqlite_error)?;
682        if pending_from(&tx)?.is_some() {
683            return Err(Error::InvalidEntry(
684                "metadata-only commit cannot bypass a pending physical apply".into(),
685            ));
686        }
687        if meta_anchor(&tx, "applied_tip")? != expected_base {
688            return Err(Error::InvalidEntry(
689                "metadata-only commit does not match the committed base".into(),
690            ));
691        }
692        if meta_configuration(&tx)? != *expected_configuration {
693            return Err(Error::InvalidEntry(
694                "metadata-only commit configuration changed".into(),
695            ));
696        }
697        if meta_state(&tx)? != expected_user_state {
698            return Err(Error::InvalidEntry(
699                "metadata-only commit user state changed".into(),
700            ));
701        }
702
703        let changed = tx
704            .execute(
705                "UPDATE control_meta SET value = ?1 WHERE key = 'applied_tip' AND value = ?2",
706                params![
707                    anchor_bytes(entry).as_slice(),
708                    anchor_bytes(expected_base).as_slice()
709                ],
710            )
711            .map_err(sqlite_error)?;
712        if changed != 1 {
713            return Err(Error::InvalidEntry(
714                "metadata-only tip compare-and-swap affected an unexpected row count".into(),
715            ));
716        }
717        tx.commit().map_err(sqlite_error)
718    }
719
720    pub(crate) fn commit_metadata_only_entry_with_log(
721        &self,
722        expected_base: LogAnchor,
723        entry: &LogEntry,
724        expected_configuration: &ConfigurationState,
725        expected_user_state: StateIdentityV3,
726    ) -> Result<()> {
727        let entry_anchor = LogAnchor::new(entry.index, entry.hash);
728        let expected_entry_index = expected_base
729            .index()
730            .checked_add(1)
731            .ok_or_else(|| Error::InvalidEntry("applied index is exhausted".into()))?;
732        if entry.index != expected_entry_index || entry.recompute_hash() != entry.hash {
733            return Err(Error::InvalidEntry(
734                "metadata-only embedded entry is invalid or not the next slot".into(),
735            ));
736        }
737        let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)
738            .map_err(sqlite_error)?;
739        if pending_from(&tx)?.is_some()
740            || meta_anchor(&tx, "applied_tip")? != expected_base
741            || meta_configuration(&tx)? != *expected_configuration
742            || meta_state(&tx)? != expected_user_state
743        {
744            return Err(Error::InvalidEntry(
745                "metadata-only embedded commit does not match the committed base".into(),
746            ));
747        }
748        insert_or_validate_embedded_entry(&tx, entry)?;
749        put_anchor(&tx, "applied_tip", entry_anchor)?;
750        tx.commit().map_err(sqlite_error)
751    }
752
753    pub(crate) fn commit_applied(
754        &self,
755        pending: &PendingApply,
756        configuration_state: &ConfigurationState,
757        receipts: &[RequestReceipt],
758    ) -> Result<()> {
759        if receipts.len() > super::MAX_QWAL_V3_RECEIPTS {
760            return Err(Error::ResourceExhausted(format!(
761                "applied receipt batch exceeds {} members",
762                super::MAX_QWAL_V3_RECEIPTS
763            )));
764        }
765        let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)
766            .map_err(sqlite_error)?;
767        if pending_from(&tx)?.as_ref() != Some(pending) {
768            return Err(Error::InvalidEntry(
769                "pending apply intent is missing or different".into(),
770            ));
771        }
772        commit_applied_transaction(tx, pending, configuration_state, receipts)
773    }
774
775    /// Atomically publishes a locally installed QWAL target, its receipts, and
776    /// its rebuildable qlog mirror without a pre-install durability intent.
777    pub(crate) fn commit_rebuildable_apply(
778        &self,
779        pending: &PendingApply,
780        entry: &LogEntry,
781        configuration_state: &ConfigurationState,
782        receipts: &[RequestReceipt],
783    ) -> Result<()> {
784        let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)
785            .map_err(sqlite_error)?;
786        if pending_from(&tx)?
787            .as_ref()
788            .is_some_and(|existing| existing != pending)
789            || meta_anchor(&tx, "applied_tip")? != pending.base
790            || meta_state(&tx)? != pending.base_state
791            || LogAnchor::new(entry.index, entry.hash) != pending.entry
792            || entry.recompute_hash() != entry.hash
793        {
794            return Err(Error::InvalidEntry(
795                "rebuildable apply does not exactly extend the committed base".into(),
796            ));
797        }
798        insert_or_validate_embedded_entry(&tx, entry)?;
799        commit_applied_transaction(tx, pending, configuration_state, receipts)
800    }
801
802    pub fn export_replicated_snapshot(&self) -> Result<Vec<u8>> {
803        if self.pending()?.is_some() {
804            return Err(Error::InvalidSnapshot(
805                "cannot export control state while apply is pending".into(),
806            ));
807        }
808        let mut statement = self.conn.prepare(
809            "SELECT request_id, request_digest, original_log_index, original_log_hash, result_blob FROM request_receipts ORDER BY request_id",
810        ).map_err(sqlite_error)?;
811        let receipts = statement
812            .query_map([], |row| {
813                let request_id: String = row.get(0)?;
814                Ok(RequestReceipt::new(
815                    request_id,
816                    hash_from_blob(row.get(1)?)?,
817                    LogAnchor::new(u64_from_sql(row.get(2)?)?, hash_from_blob(row.get(3)?)?),
818                    row.get(4)?,
819                ))
820            })
821            .map_err(sqlite_error)?
822            .collect::<std::result::Result<Vec<_>, _>>()
823            .map_err(sqlite_error)?;
824        let tip = meta_anchor(&self.conn, "applied_tip")?;
825        let snapshot = ReplicatedSnapshot {
826            cluster_id: meta_text(&self.conn, "cluster_id")?,
827            epoch: meta_u64(&self.conn, "epoch")?,
828            configuration_state: meta_configuration(&self.conn)?,
829            recovery_generation: meta_u64(&self.conn, "recovery_generation")?,
830            materializer_fingerprint: meta_hash(&self.conn, "materializer_fingerprint")?,
831            user_state: meta_state(&self.conn)?,
832            applied_tip: tip,
833            receipts,
834        };
835        encode_snapshot(&snapshot)
836    }
837
838    #[cfg(test)]
839    pub(crate) fn import_replicated_snapshot(&self, encoded: &[u8]) -> Result<()> {
840        self.import_replicated_snapshot_with_recovery_generation(encoded, None)
841    }
842
843    /// Atomically imports replicated state while optionally rebinding it to a
844    /// recovery anchor generation. The destination node identity is retained.
845    pub(crate) fn import_replicated_snapshot_with_recovery_generation(
846        &self,
847        encoded: &[u8],
848        recovery_generation: Option<u64>,
849    ) -> Result<()> {
850        let snapshot = decode_snapshot(encoded)?;
851        let recovery_generation = recovery_generation.unwrap_or(snapshot.recovery_generation);
852        if recovery_generation == 0 {
853            return Err(Error::InvalidSnapshot(
854                "control snapshot recovery generation must be positive".into(),
855            ));
856        }
857        let local = self.identity()?;
858        if snapshot.cluster_id != local.cluster_id {
859            return Err(Error::IdentityMismatch("cluster_id".into()));
860        }
861        if snapshot.epoch != local.epoch {
862            return Err(Error::IdentityMismatch("epoch".into()));
863        }
864        if snapshot.materializer_fingerprint != local.materializer_fingerprint {
865            return Err(Error::IdentityMismatch("materializer_fingerprint".into()));
866        }
867        for receipt in &snapshot.receipts {
868            validate_receipt(receipt)?;
869        }
870
871        let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)
872            .map_err(sqlite_error)?;
873        tx.execute("DELETE FROM request_receipts", [])
874            .map_err(sqlite_error)?;
875        for receipt in &snapshot.receipts {
876            insert_or_validate_receipt(&tx, receipt)?;
877        }
878        put_configuration(&tx, &snapshot.configuration_state)?;
879        put_u64(&tx, "recovery_generation", recovery_generation)?;
880        validate_state(snapshot.user_state)?;
881        put_state(&tx, snapshot.user_state)?;
882        put_anchor(&tx, "applied_tip", snapshot.applied_tip)?;
883        tx.execute("DELETE FROM pending_apply", [])
884            .map_err(sqlite_error)?;
885        tx.execute("DELETE FROM embedded_qlog", [])
886            .map_err(sqlite_error)?;
887        tx.commit().map_err(sqlite_error)
888    }
889
890    fn validate_schema(&self) -> Result<()> {
891        let magic = get_meta(&self.conn, "magic")?;
892        if magic != CONTROL_MAGIC {
893            return Err(Error::Sqlite("invalid control sidecar magic".into()));
894        }
895        let version = meta_u64(&self.conn, "schema_version")?;
896        if version != CONTROL_SCHEMA_VERSION {
897            return Err(Error::Sqlite(format!(
898                "unsupported control sidecar schema version {version}"
899            )));
900        }
901        for key in REQUIRED_META_KEYS {
902            let _: Vec<u8> = get_meta(&self.conn, key)?;
903        }
904        for (table, expected_sql, expected_columns) in [
905            (
906                "control_meta",
907                CREATE_CONTROL_META_SQL,
908                CONTROL_META_COLUMNS,
909            ),
910            (
911                "request_receipts",
912                CREATE_REQUEST_RECEIPTS_SQL,
913                REQUEST_RECEIPT_COLUMNS,
914            ),
915            (
916                "pending_apply",
917                CREATE_PENDING_APPLY_SQL,
918                PENDING_APPLY_COLUMNS,
919            ),
920            (
921                "embedded_qlog",
922                CREATE_EMBEDDED_LOG_SQL,
923                EMBEDDED_LOG_COLUMNS,
924            ),
925        ] {
926            validate_table_schema(&self.conn, table, expected_sql, expected_columns)?;
927        }
928        let unexpected_objects: i64 = self.conn.query_row(
929            "SELECT count(*) FROM sqlite_schema
930             WHERE name NOT LIKE 'sqlite_%'
931               AND (type <> 'table' OR name NOT IN ('control_meta','request_receipts','pending_apply','embedded_qlog'))",
932            [],
933            |row| row.get(0),
934        ).map_err(sqlite_error)?;
935        if unexpected_objects != 0 {
936            return Err(Error::Sqlite(
937                "control sidecar contains unexpected schema objects".into(),
938            ));
939        }
940        let meta_count: i64 = self
941            .conn
942            .query_row("SELECT count(*) FROM control_meta", [], |row| row.get(0))
943            .map_err(sqlite_error)?;
944        if meta_count != i64::try_from(REQUIRED_META_KEYS.len()).expect("small key count") {
945            return Err(Error::Sqlite(
946                "control sidecar has unknown or duplicate metadata".into(),
947            ));
948        }
949        // Decode every typed value here so corrupt state fails before serving.
950        let identity = self.identity()?;
951        validate_new_identity(&identity)?;
952        let _ = meta_anchor(&self.conn, "applied_tip")?;
953        let pending = self.pending()?;
954        let pending_rows: i64 = self
955            .conn
956            .query_row("SELECT count(*) FROM pending_apply", [], |row| row.get(0))
957            .map_err(sqlite_error)?;
958        let expected_pending_rows = if pending.is_some() { 1 } else { 0 };
959        if pending_rows != expected_pending_rows {
960            return Err(Error::Sqlite(
961                "control sidecar has invalid pending apply rows".into(),
962            ));
963        }
964        validate_all_receipts(&self.conn)?;
965        Ok(())
966    }
967
968    fn validate_identity(&self, expected: &ControlIdentity) -> Result<()> {
969        let actual = self.identity()?;
970        if actual.cluster_id != expected.cluster_id {
971            return Err(Error::IdentityMismatch("cluster_id".into()));
972        }
973        if actual.node_id != expected.node_id {
974            return Err(Error::IdentityMismatch("node_id".into()));
975        }
976        if actual.epoch != expected.epoch {
977            return Err(Error::IdentityMismatch("epoch".into()));
978        }
979        if actual.configuration_state != expected.configuration_state {
980            return Err(Error::IdentityMismatch("configuration_state".into()));
981        }
982        if actual.recovery_generation != expected.recovery_generation {
983            return Err(Error::IdentityMismatch("recovery_generation".into()));
984        }
985        if actual.materializer_fingerprint != expected.materializer_fingerprint {
986            return Err(Error::IdentityMismatch("materializer_fingerprint".into()));
987        }
988        if actual.user_state != expected.user_state {
989            return Err(Error::IdentityMismatch("user_state".into()));
990        }
991        Ok(())
992    }
993}
994
995fn validate_new_identity(identity: &ControlIdentity) -> Result<()> {
996    if identity.cluster_id.is_empty() {
997        return Err(Error::IdentityMismatch("cluster_id".into()));
998    }
999    if identity.node_id.is_empty() {
1000        return Err(Error::IdentityMismatch("node_id".into()));
1001    }
1002    if identity.epoch == 0 {
1003        return Err(Error::IdentityMismatch("epoch".into()));
1004    }
1005    validate_state(identity.user_state)?;
1006    Ok(())
1007}
1008
1009fn validate_state(state: StateIdentityV3) -> Result<()> {
1010    if !(512..=65_536).contains(&state.page_size)
1011        || !state.page_size.is_power_of_two()
1012        || state.page_count == 0
1013        || state.state_root == LogHash::ZERO
1014    {
1015        return Err(Error::IdentityMismatch("user_state".into()));
1016    }
1017    Ok(())
1018}
1019
1020fn validate_table_schema(
1021    conn: &Connection,
1022    table: &str,
1023    expected_sql: &str,
1024    expected_columns: &[ExpectedColumn],
1025) -> Result<()> {
1026    let declared_sql: String = conn
1027        .query_row(
1028            "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?1",
1029            params![table],
1030            |row| row.get(0),
1031        )
1032        .optional()
1033        .map_err(sqlite_error)?
1034        .ok_or_else(|| Error::Sqlite(format!("control sidecar is missing table {table}")))?;
1035    if normalize_schema_sql(&declared_sql) != normalize_schema_sql(expected_sql) {
1036        return Err(Error::Sqlite(format!(
1037            "invalid control sidecar declaration for table {table}"
1038        )));
1039    }
1040
1041    let pragma =
1042        format!("SELECT name, type, [notnull], pk FROM pragma_table_info('{table}') ORDER BY cid");
1043    let mut statement = conn.prepare(&pragma).map_err(sqlite_error)?;
1044    let actual = statement
1045        .query_map([], |row| {
1046            Ok((
1047                row.get::<_, String>(0)?,
1048                row.get::<_, String>(1)?,
1049                row.get::<_, i64>(2)? != 0,
1050                row.get::<_, i64>(3)?,
1051            ))
1052        })
1053        .map_err(sqlite_error)?
1054        .collect::<std::result::Result<Vec<_>, _>>()
1055        .map_err(sqlite_error)?;
1056    let matches = actual.len() == expected_columns.len()
1057        && actual.iter().zip(expected_columns).all(
1058            |((name, declared_type, not_null, primary_key), expected)| {
1059                name == expected.0
1060                    && declared_type.eq_ignore_ascii_case(expected.1)
1061                    && *not_null == expected.2
1062                    && *primary_key == expected.3
1063            },
1064        );
1065    if !matches {
1066        return Err(Error::Sqlite(format!(
1067            "invalid control sidecar columns for table {table}"
1068        )));
1069    }
1070    Ok(())
1071}
1072
1073fn normalize_schema_sql(sql: &str) -> String {
1074    sql.chars()
1075        .filter(|character| !character.is_ascii_whitespace() && *character != ';')
1076        .flat_map(char::to_lowercase)
1077        .collect()
1078}
1079
1080fn commit_applied_transaction(
1081    tx: Transaction<'_>,
1082    pending: &PendingApply,
1083    configuration_state: &ConfigurationState,
1084    receipts: &[RequestReceipt],
1085) -> Result<()> {
1086    if receipts.len() > super::MAX_QWAL_V3_RECEIPTS {
1087        return Err(Error::ResourceExhausted(format!(
1088            "applied receipt batch exceeds {} members",
1089            super::MAX_QWAL_V3_RECEIPTS
1090        )));
1091    }
1092    if configuration_state.config_id() < meta_configuration(&tx)?.config_id() {
1093        return Err(Error::InvalidEntry(
1094            "configuration state moved backwards".into(),
1095        ));
1096    }
1097    let mut result_bytes = 0usize;
1098    let mut request_ids = HashSet::with_capacity(receipts.len());
1099    for receipt in receipts {
1100        validate_receipt(receipt)?;
1101        result_bytes = result_bytes
1102            .checked_add(receipt.result_blob.len())
1103            .ok_or_else(|| Error::ResourceExhausted("receipt result bytes overflow".into()))?;
1104        if result_bytes > super::MAX_QWAL_V3_BYTES {
1105            return Err(Error::ResourceExhausted(format!(
1106                "receipt results exceed {} bytes",
1107                super::MAX_QWAL_V3_BYTES
1108            )));
1109        }
1110        if receipt.original_anchor != pending.entry {
1111            return Err(Error::InvalidEntry(
1112                "request receipt anchor does not match applied entry".into(),
1113            ));
1114        }
1115        if !request_ids.insert(receipt.request_id.as_str()) {
1116            return Err(Error::InvalidEntry(
1117                "applied receipt request ids must be unique".into(),
1118            ));
1119        }
1120    }
1121    let lookups = receipts
1122        .iter()
1123        .map(|receipt| (receipt.request_id(), receipt.request_digest()))
1124        .collect::<Vec<_>>();
1125    let existing = lookup_requests_from(&tx, &lookups)?;
1126    let mut absent = Vec::with_capacity(receipts.len());
1127    for (receipt, existing) in receipts.iter().zip(existing) {
1128        match existing? {
1129            None => absent.push(receipt),
1130            Some(existing) if existing == *receipt => {}
1131            Some(existing) => {
1132                return Err(Error::RequestConflict(RequestConflict {
1133                    request_id: receipt.request_id.clone(),
1134                    original_outcome: RequestOutcome::new(
1135                        existing.original_anchor.index(),
1136                        existing.original_anchor.hash(),
1137                    ),
1138                }));
1139            }
1140        }
1141    }
1142    insert_receipts_bulk(&tx, pending.entry, &absent)?;
1143    put_anchor(&tx, "applied_tip", pending.entry)?;
1144    put_configuration(&tx, configuration_state)?;
1145    put_state(&tx, pending.target_state)?;
1146    tx.execute("DELETE FROM pending_apply WHERE singleton = 1", [])
1147        .map_err(sqlite_error)?;
1148    tx.commit().map_err(sqlite_error)
1149}
1150
1151fn validate_receipt(receipt: &RequestReceipt) -> Result<()> {
1152    if receipt.request_id.is_empty() || receipt.request_id.len() > usize::from(u16::MAX) {
1153        return Err(Error::InvalidCommand(
1154            "request id must contain 1..=65535 bytes".into(),
1155        ));
1156    }
1157    if receipt.result_blob.len() > MAX_RESULT_BLOB_BYTES {
1158        return Err(Error::ResourceExhausted(format!(
1159            "request result exceeds {MAX_RESULT_BLOB_BYTES} bytes"
1160        )));
1161    }
1162    super::validate_sql_result_blob_bounds(receipt.result_blob())
1163}
1164
1165fn validate_all_receipts(conn: &Connection) -> Result<()> {
1166    let mut statement = conn
1167        .prepare(
1168            "SELECT request_id, request_digest, original_log_index, original_log_hash, result_blob FROM request_receipts ORDER BY request_id",
1169        )
1170        .map_err(sqlite_error)?;
1171    let receipts = statement
1172        .query_map([], |row| {
1173            Ok(RequestReceipt::new(
1174                row.get::<_, String>(0)?,
1175                hash_from_blob(row.get(1)?)?,
1176                LogAnchor::new(u64_from_sql(row.get(2)?)?, hash_from_blob(row.get(3)?)?),
1177                row.get(4)?,
1178            ))
1179        })
1180        .map_err(sqlite_error)?;
1181    for receipt in receipts {
1182        validate_receipt(&receipt.map_err(sqlite_error)?)?;
1183    }
1184    Ok(())
1185}
1186
1187fn lookup_requests_from(
1188    conn: &Connection,
1189    requests: &[(&str, LogHash)],
1190) -> Result<Vec<Result<Option<RequestReceipt>>>> {
1191    if requests.is_empty() {
1192        return Ok(Vec::new());
1193    }
1194    if requests.len() > super::MAX_QWAL_V3_RECEIPTS {
1195        return Err(Error::ResourceExhausted(format!(
1196            "request lookup exceeds {} members",
1197            super::MAX_QWAL_V3_RECEIPTS
1198        )));
1199    }
1200    let mut request_ids = HashSet::with_capacity(requests.len());
1201    for (request_id, _) in requests {
1202        if !request_ids.insert(*request_id) {
1203            return Err(Error::InvalidCommand(format!(
1204                "duplicate request id in bulk lookup: {request_id}"
1205            )));
1206        }
1207    }
1208
1209    let mut aligned = Vec::with_capacity(requests.len());
1210    for chunk in requests.chunks(RECEIPT_LOOKUP_CHUNK_SIZE) {
1211        aligned.extend(lookup_request_chunk(conn, chunk)?);
1212    }
1213    Ok(aligned)
1214}
1215
1216fn lookup_request_chunk(
1217    conn: &Connection,
1218    requests: &[(&str, LogHash)],
1219) -> Result<Vec<Result<Option<RequestReceipt>>>> {
1220    let mut sql = String::from("WITH requested(position,request_id) AS (VALUES ");
1221    for index in 0..requests.len() {
1222        if index != 0 {
1223            sql.push(',');
1224        }
1225        write!(sql, "({index},?{})", index + 1)
1226            .expect("writing to an in-memory SQL string cannot fail");
1227    }
1228    sql.push_str(
1229        ") SELECT requested.request_id,receipts.request_digest,receipts.original_log_index,receipts.original_log_hash,receipts.result_blob FROM requested LEFT JOIN request_receipts AS receipts ON receipts.request_id=requested.request_id ORDER BY requested.position",
1230    );
1231    let mut statement = conn.prepare(&sql).map_err(sqlite_error)?;
1232    let receipts = statement
1233        .query_map(
1234            params_from_iter(requests.iter().map(|(request_id, _)| *request_id)),
1235            |row| {
1236                let request_id: String = row.get(0)?;
1237                let Some(request_digest) = row.get::<_, Option<Vec<u8>>>(1)? else {
1238                    return Ok(None);
1239                };
1240                Ok(Some(RequestReceipt::new(
1241                    request_id,
1242                    hash_from_blob(request_digest)?,
1243                    LogAnchor::new(u64_from_sql(row.get(2)?)?, hash_from_blob(row.get(3)?)?),
1244                    row.get(4)?,
1245                )))
1246            },
1247        )
1248        .map_err(sqlite_error)?
1249        .collect::<std::result::Result<Vec<_>, _>>()
1250        .map_err(sqlite_error)?;
1251    if receipts.len() != requests.len() {
1252        return Err(Error::Sqlite(
1253            "bulk receipt lookup did not preserve request cardinality".into(),
1254        ));
1255    }
1256    let mut aligned = Vec::with_capacity(receipts.len());
1257    for ((request_id, request_digest), receipt) in requests.iter().zip(receipts) {
1258        if let Some(receipt) = &receipt {
1259            if receipt.request_id() != *request_id {
1260                return Err(Error::Sqlite(
1261                    "bulk receipt lookup did not preserve request order".into(),
1262                ));
1263            }
1264            if receipt.request_digest() != *request_digest {
1265                aligned.push(Err(Error::RequestConflict(RequestConflict {
1266                    request_id: (*request_id).to_owned(),
1267                    original_outcome: RequestOutcome::new(
1268                        receipt.original_anchor.index(),
1269                        receipt.original_anchor.hash(),
1270                    ),
1271                })));
1272                continue;
1273            }
1274        }
1275        aligned.push(Ok(receipt));
1276    }
1277    Ok(aligned)
1278}
1279
1280fn insert_receipts_bulk(
1281    conn: &Connection,
1282    anchor: LogAnchor,
1283    receipts: &[&RequestReceipt],
1284) -> Result<()> {
1285    if receipts.is_empty() {
1286        return Ok(());
1287    }
1288    if receipts.len() > super::MAX_QWAL_V3_RECEIPTS {
1289        return Err(Error::ResourceExhausted(
1290            "bulk receipt insert exceeds the QWAL receipt limit".into(),
1291        ));
1292    }
1293    for chunk in receipts.chunks(RECEIPT_INSERT_CHUNK_SIZE) {
1294        insert_receipt_chunk(conn, anchor, chunk)?;
1295    }
1296    Ok(())
1297}
1298
1299fn insert_receipt_chunk(
1300    conn: &Connection,
1301    anchor: LogAnchor,
1302    receipts: &[&RequestReceipt],
1303) -> Result<()> {
1304    let bind_count = 2 + receipts.len() * 3;
1305    debug_assert!(bind_count <= SQLITE_VARIABLE_LIMIT);
1306
1307    let mut sql = String::from(
1308        "INSERT INTO request_receipts(request_id,request_digest,original_log_index,original_log_hash,result_blob) VALUES ",
1309    );
1310    for index in 0..receipts.len() {
1311        if index != 0 {
1312            sql.push(',');
1313        }
1314        let request_id = 3 + index * 3;
1315        let request_digest = request_id + 1;
1316        let result_blob = request_id + 2;
1317        write!(
1318            sql,
1319            "(?{request_id},?{request_digest},?1,?2,?{result_blob})"
1320        )
1321        .expect("writing to an in-memory SQL string cannot fail");
1322    }
1323    let mut values = Vec::with_capacity(bind_count);
1324    values.push(Value::Integer(u64_to_sql(anchor.index())?));
1325    values.push(Value::Blob(anchor.hash().as_bytes().to_vec()));
1326    for receipt in receipts {
1327        values.push(Value::Text(receipt.request_id.clone()));
1328        values.push(Value::Blob(receipt.request_digest.as_bytes().to_vec()));
1329        values.push(Value::Blob(receipt.result_blob.clone()));
1330    }
1331    let inserted = conn
1332        .execute(&sql, params_from_iter(values.iter()))
1333        .map_err(sqlite_error)?;
1334    if inserted != receipts.len() {
1335        return Err(Error::Sqlite(
1336            "bulk receipt insert affected an unexpected row count".into(),
1337        ));
1338    }
1339    Ok(())
1340}
1341
1342fn insert_or_validate_receipt(conn: &Connection, receipt: &RequestReceipt) -> Result<()> {
1343    if let Some(existing) = conn.query_row(
1344        "SELECT request_digest, original_log_index, original_log_hash, result_blob FROM request_receipts WHERE request_id=?1",
1345        params![receipt.request_id],
1346        |row| Ok(RequestReceipt::new(
1347            &receipt.request_id,
1348            hash_from_blob(row.get(0)?)?,
1349            LogAnchor::new(u64_from_sql(row.get(1)?)?, hash_from_blob(row.get(2)?)?),
1350            row.get(3)?,
1351        )),
1352    ).optional().map_err(sqlite_error)? {
1353        if existing == *receipt { return Ok(()); }
1354        return Err(Error::RequestConflict(RequestConflict {
1355            request_id: receipt.request_id.clone(),
1356            original_outcome: RequestOutcome::new(existing.original_anchor.index(), existing.original_anchor.hash()),
1357        }));
1358    }
1359    conn.execute(
1360        "INSERT INTO request_receipts(request_id,request_digest,original_log_index,original_log_hash,result_blob) VALUES(?1,?2,?3,?4,?5)",
1361        params![receipt.request_id, receipt.request_digest.as_bytes().as_slice(), u64_to_sql(receipt.original_anchor.index())?, receipt.original_anchor.hash().as_bytes().as_slice(), receipt.result_blob],
1362    ).map_err(sqlite_error)?;
1363    Ok(())
1364}
1365
1366fn insert_pending(conn: &Connection, value: &PendingApply) -> Result<()> {
1367    validate_state(value.base_state)?;
1368    validate_state(value.target_state)?;
1369    conn.execute(
1370        "INSERT INTO pending_apply(singleton,base_index,base_hash,entry_index,entry_hash,base_page_size,base_page_count,base_state_root,target_page_size,target_page_count,target_state_root) VALUES(1,?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
1371        params![u64_to_sql(value.base.index())?, value.base.hash().as_bytes().as_slice(), u64_to_sql(value.entry.index())?, value.entry.hash().as_bytes().as_slice(), i64::from(value.base_state.page_size), i64::from(value.base_state.page_count), value.base_state.state_root.as_bytes().as_slice(), i64::from(value.target_state.page_size), i64::from(value.target_state.page_count), value.target_state.state_root.as_bytes().as_slice()],
1372    ).map_err(sqlite_error)?;
1373    Ok(())
1374}
1375
1376fn insert_or_validate_embedded_entry(conn: &Connection, entry: &LogEntry) -> Result<()> {
1377    let cluster_id = meta_text(conn, "cluster_id")?;
1378    let epoch = meta_u64(conn, "epoch")?;
1379    let configuration = meta_configuration(conn)?;
1380    if entry.cluster_id != cluster_id
1381        || entry.epoch != epoch
1382        || configuration.validate_entry(entry).is_err()
1383    {
1384        return Err(Error::InvalidEntry(
1385            "embedded qlog entry identity is invalid".into(),
1386        ));
1387    }
1388    let index = u64_to_sql(entry.index)?;
1389    let existing = conn
1390        .query_row(
1391            "SELECT initial_configuration,entry_bytes FROM embedded_qlog WHERE log_index=?1",
1392            params![index],
1393            |row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, Vec<u8>>(1)?)),
1394        )
1395        .optional()
1396        .map_err(sqlite_error)?;
1397    if let Some((initial_configuration, existing)) = existing {
1398        let initial_configuration =
1399            decode_configuration_bytes(&initial_configuration, "embedded qlog")?;
1400        if initial_configuration == configuration
1401            && decode_embedded_log_entry(&existing, &cluster_id, initial_configuration)?.0 == *entry
1402        {
1403            return Ok(());
1404        }
1405        return Err(Error::InvalidEntry(
1406            "embedded qlog index already contains another entry".into(),
1407        ));
1408    }
1409    let encoded = rhiza_log::encode_segment(std::slice::from_ref(entry));
1410    let initial_configuration =
1411        serde_json::to_vec(&configuration).map_err(|error| Error::Sqlite(error.to_string()))?;
1412    conn.execute(
1413        "INSERT INTO embedded_qlog(log_index,initial_configuration,entry_bytes) VALUES(?1,?2,?3)",
1414        params![index, initial_configuration, encoded],
1415    )
1416    .map_err(sqlite_error)?;
1417    Ok(())
1418}
1419
1420fn decode_embedded_log_entry(
1421    encoded: &[u8],
1422    cluster_id: &str,
1423    initial_configuration: ConfigurationState,
1424) -> Result<(LogEntry, ConfigurationState)> {
1425    let entries = rhiza_log::decode_segment_for_cluster(encoded, cluster_id)
1426        .map_err(|error| Error::InvalidEntry(error.to_string()))?;
1427    let [entry] = entries.as_slice() else {
1428        return Err(Error::InvalidEntry(
1429            "embedded qlog value must contain exactly one entry".into(),
1430        ));
1431    };
1432    let resulting_configuration = initial_configuration
1433        .validate_entry(entry)
1434        .map_err(|error| Error::InvalidEntry(error.to_string()))?;
1435    Ok((entry.clone(), resulting_configuration))
1436}
1437
1438fn pending_from(conn: &Connection) -> Result<Option<PendingApply>> {
1439    conn.query_row(
1440        "SELECT base_index,base_hash,entry_index,entry_hash,base_page_size,base_page_count,base_state_root,target_page_size,target_page_count,target_state_root FROM pending_apply WHERE singleton=1",
1441        [],
1442        |row| Ok(PendingApply::new(
1443            LogAnchor::new(u64_from_sql(row.get(0)?)?, hash_from_blob(row.get(1)?)?),
1444            LogAnchor::new(u64_from_sql(row.get(2)?)?, hash_from_blob(row.get(3)?)?),
1445            StateIdentityV3 {
1446                page_size: u32_from_sql(row.get(4)?)?,
1447                page_count: u32_from_sql(row.get(5)?)?,
1448                state_root: hash_from_blob(row.get(6)?)?,
1449            },
1450            StateIdentityV3 {
1451                page_size: u32_from_sql(row.get(7)?)?,
1452                page_count: u32_from_sql(row.get(8)?)?,
1453                state_root: hash_from_blob(row.get(9)?)?,
1454            },
1455        )),
1456    ).optional().map_err(sqlite_error)?.map(|pending| {
1457        validate_state(pending.base_state)?;
1458        validate_state(pending.target_state)?;
1459        Ok(pending)
1460    }).transpose()
1461}
1462
1463fn encode_snapshot(snapshot: &ReplicatedSnapshot) -> Result<Vec<u8>> {
1464    let body =
1465        serde_json::to_vec(snapshot).map_err(|error| Error::InvalidSnapshot(error.to_string()))?;
1466    let digest = LogHash::digest(&[&body]);
1467    let mut encoded = Vec::with_capacity(SNAPSHOT_MAGIC.len() + 32 + body.len());
1468    encoded.extend_from_slice(SNAPSHOT_MAGIC);
1469    encoded.extend_from_slice(digest.as_bytes());
1470    encoded.extend_from_slice(&body);
1471    Ok(encoded)
1472}
1473
1474fn decode_snapshot(encoded: &[u8]) -> Result<ReplicatedSnapshot> {
1475    let payload = encoded.strip_prefix(SNAPSHOT_MAGIC).ok_or_else(|| {
1476        Error::InvalidSnapshot("control snapshot magic/version is invalid".into())
1477    })?;
1478    if payload.len() < 32 {
1479        return Err(Error::InvalidSnapshot(
1480            "control snapshot is truncated".into(),
1481        ));
1482    }
1483    let expected = LogHash::from_bytes(payload[..32].try_into().expect("32-byte checked slice"));
1484    let body = &payload[32..];
1485    if LogHash::digest(&[body]) != expected {
1486        return Err(Error::InvalidSnapshot(
1487            "control snapshot digest mismatch".into(),
1488        ));
1489    }
1490    let snapshot: ReplicatedSnapshot =
1491        serde_json::from_slice(body).map_err(|error| Error::InvalidSnapshot(error.to_string()))?;
1492    let canonical =
1493        serde_json::to_vec(&snapshot).map_err(|error| Error::InvalidSnapshot(error.to_string()))?;
1494    if canonical != body {
1495        return Err(Error::InvalidSnapshot(
1496            "control snapshot encoding is not canonical".into(),
1497        ));
1498    }
1499    let mut previous = None;
1500    for receipt in &snapshot.receipts {
1501        validate_receipt(receipt)?;
1502        if previous.is_some_and(|id: &str| id >= receipt.request_id.as_str()) {
1503            return Err(Error::InvalidSnapshot(
1504                "control snapshot receipts are not uniquely sorted".into(),
1505            ));
1506        }
1507        previous = Some(receipt.request_id.as_str());
1508    }
1509    Ok(snapshot)
1510}
1511
1512fn put_meta(conn: &Connection, key: &str, value: &[u8]) -> Result<()> {
1513    conn.execute(
1514        "INSERT OR REPLACE INTO control_meta(key,value) VALUES(?1,?2)",
1515        params![key, value],
1516    )
1517    .map_err(sqlite_error)?;
1518    Ok(())
1519}
1520fn get_meta(conn: &Connection, key: &str) -> Result<Vec<u8>> {
1521    conn.query_row(
1522        "SELECT value FROM control_meta WHERE key=?1",
1523        params![key],
1524        |row| row.get(0),
1525    )
1526    .optional()
1527    .map_err(sqlite_error)?
1528    .ok_or_else(|| Error::Sqlite(format!("control sidecar is missing {key}")))
1529}
1530fn put_u64(conn: &Connection, key: &str, value: u64) -> Result<()> {
1531    put_meta(conn, key, &value.to_be_bytes())
1532}
1533fn meta_u64(conn: &Connection, key: &str) -> Result<u64> {
1534    let bytes = get_meta(conn, key)?;
1535    let bytes: [u8; 8] = bytes
1536        .try_into()
1537        .map_err(|_| Error::Sqlite(format!("invalid control u64 {key}")))?;
1538    Ok(u64::from_be_bytes(bytes))
1539}
1540fn put_hash(conn: &Connection, key: &str, value: LogHash) -> Result<()> {
1541    put_meta(conn, key, value.as_bytes())
1542}
1543fn meta_hash(conn: &Connection, key: &str) -> Result<LogHash> {
1544    let bytes = get_meta(conn, key)?;
1545    Ok(LogHash::from_bytes(bytes.try_into().map_err(|_| {
1546        Error::Sqlite(format!("invalid control hash {key}"))
1547    })?))
1548}
1549fn put_state(conn: &Connection, state: StateIdentityV3) -> Result<()> {
1550    validate_state(state)?;
1551    put_u64(conn, "page_size", u64::from(state.page_size))?;
1552    put_u64(conn, "page_count", u64::from(state.page_count))?;
1553    put_hash(conn, "state_root", state.state_root)
1554}
1555fn meta_state(conn: &Connection) -> Result<StateIdentityV3> {
1556    let state = StateIdentityV3 {
1557        page_size: u32::try_from(meta_u64(conn, "page_size")?)
1558            .map_err(|_| Error::Sqlite("invalid control page_size".into()))?,
1559        page_count: u32::try_from(meta_u64(conn, "page_count")?)
1560            .map_err(|_| Error::Sqlite("invalid control page_count".into()))?,
1561        state_root: meta_hash(conn, "state_root")?,
1562    };
1563    validate_state(state)?;
1564    Ok(state)
1565}
1566fn meta_text(conn: &Connection, key: &str) -> Result<String> {
1567    String::from_utf8(get_meta(conn, key)?)
1568        .map_err(|_| Error::Sqlite(format!("invalid control text {key}")))
1569}
1570fn put_configuration(conn: &Connection, value: &ConfigurationState) -> Result<()> {
1571    let encoded = serde_json::to_vec(value).map_err(|error| Error::Sqlite(error.to_string()))?;
1572    put_meta(conn, "configuration_state", &encoded)
1573}
1574fn meta_configuration(conn: &Connection) -> Result<ConfigurationState> {
1575    decode_configuration_bytes(&get_meta(conn, "configuration_state")?, "control")
1576}
1577fn decode_configuration_bytes(encoded: &[u8], source: &str) -> Result<ConfigurationState> {
1578    serde_json::from_slice(encoded)
1579        .map_err(|error| Error::Sqlite(format!("invalid {source} configuration: {error}")))
1580}
1581fn put_anchor(conn: &Connection, key: &str, value: LogAnchor) -> Result<()> {
1582    put_meta(conn, key, &anchor_bytes(value))
1583}
1584fn anchor_bytes(value: LogAnchor) -> [u8; 40] {
1585    let mut encoded = [0_u8; 40];
1586    encoded[..8].copy_from_slice(&value.index().to_be_bytes());
1587    encoded[8..].copy_from_slice(value.hash().as_bytes());
1588    encoded
1589}
1590fn meta_anchor(conn: &Connection, key: &str) -> Result<LogAnchor> {
1591    let bytes = get_meta(conn, key)?;
1592    if bytes.len() != 40 {
1593        return Err(Error::Sqlite(format!("invalid control anchor {key}")));
1594    }
1595    Ok(LogAnchor::new(
1596        u64::from_be_bytes(bytes[..8].try_into().expect("length checked")),
1597        LogHash::from_bytes(bytes[8..].try_into().expect("length checked")),
1598    ))
1599}
1600
1601fn u64_to_sql(value: u64) -> Result<i64> {
1602    i64::try_from(value)
1603        .map_err(|_| Error::ResourceExhausted("control integer exceeds SQLite i64".into()))
1604}
1605fn u64_from_sql(value: i64) -> rusqlite::Result<u64> {
1606    u64::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, value))
1607}
1608fn u32_from_sql(value: i64) -> rusqlite::Result<u32> {
1609    u32::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, value))
1610}
1611fn hash_from_blob(bytes: Vec<u8>) -> rusqlite::Result<LogHash> {
1612    let bytes: [u8; 32] = bytes.try_into().map_err(|bytes: Vec<u8>| {
1613        rusqlite::Error::FromSqlConversionFailure(
1614            0,
1615            rusqlite::types::Type::Blob,
1616            Box::new(std::io::Error::new(
1617                std::io::ErrorKind::InvalidData,
1618                format!("expected 32-byte hash, got {} bytes", bytes.len()),
1619            )),
1620        )
1621    })?;
1622    Ok(LogHash::from_bytes(bytes))
1623}
1624fn sqlite_error(error: rusqlite::Error) -> Error {
1625    Error::Sqlite(error.to_string())
1626}
1627
1628fn sync_parent(path: &Path) -> Result<()> {
1629    let parent = path
1630        .parent()
1631        .filter(|parent| !parent.as_os_str().is_empty())
1632        .unwrap_or_else(|| Path::new("."));
1633    std::fs::File::open(parent)
1634        .and_then(|directory| directory.sync_all())
1635        .map_err(|error| Error::Io(error.to_string()))
1636}
1637
1638#[cfg(test)]
1639mod tests {
1640    use super::*;
1641
1642    fn hash(label: &[u8]) -> LogHash {
1643        LogHash::digest(&[label])
1644    }
1645    fn state(label: &[u8]) -> StateIdentityV3 {
1646        StateIdentityV3 {
1647            page_size: 512,
1648            page_count: 8,
1649            state_root: hash(label),
1650        }
1651    }
1652    fn identity(node: &str) -> ControlIdentity {
1653        ControlIdentity::new(
1654            "cluster",
1655            node,
1656            7,
1657            ConfigurationState::active(3, hash(b"config")),
1658            11,
1659            hash(b"fingerprint"),
1660            state(b"base-db"),
1661        )
1662    }
1663    fn pending() -> PendingApply {
1664        PendingApply::new(
1665            LogAnchor::new(0, LogHash::ZERO),
1666            LogAnchor::new(1, hash(b"entry")),
1667            state(b"base-db"),
1668            state(b"target-db"),
1669        )
1670    }
1671    fn receipt(digest: LogHash) -> RequestReceipt {
1672        receipt_for("request-1", digest)
1673    }
1674    fn receipt_for(request_id: &str, digest: LogHash) -> RequestReceipt {
1675        RequestReceipt::new(
1676            request_id,
1677            digest,
1678            LogAnchor::new(1, hash(b"entry")),
1679            crate::encode_sql_result(&crate::SqlCommandResult {
1680                statement_results: Vec::new(),
1681            })
1682            .unwrap(),
1683        )
1684    }
1685
1686    #[test]
1687    fn open_rejects_identity_mismatch() {
1688        let dir = tempfile::tempdir().unwrap();
1689        let path = dir.path().join("sqlite.control");
1690        ControlStore::create(&path, &identity("node-a")).unwrap();
1691        let error = match ControlStore::open_existing(&path, &identity("node-b")) {
1692            Ok(_) => panic!("mismatched node identity must fail"),
1693            Err(error) => error,
1694        };
1695        assert_eq!(error, Error::IdentityMismatch("node_id".into()));
1696    }
1697
1698    #[test]
1699    fn open_rejects_state_root_mismatch() {
1700        let dir = tempfile::tempdir().unwrap();
1701        let path = dir.path().join("sqlite.control");
1702        ControlStore::create(&path, &identity("node-a")).unwrap();
1703        let mut expected = identity("node-a");
1704        expected.user_state.state_root = hash(b"different-root");
1705
1706        assert!(matches!(
1707            ControlStore::open_existing(&path, &expected),
1708            Err(Error::IdentityMismatch(field)) if field == "user_state"
1709        ));
1710    }
1711
1712    #[test]
1713    fn control_identity_and_snapshot_reject_zero_state_root() {
1714        let dir = tempfile::tempdir().unwrap();
1715        let mut invalid = identity("node-a");
1716        invalid.user_state.state_root = LogHash::ZERO;
1717        assert!(matches!(
1718            ControlStore::create(dir.path().join("invalid.control"), &invalid),
1719            Err(Error::IdentityMismatch(field)) if field == "user_state"
1720        ));
1721
1722        let destination =
1723            ControlStore::create(dir.path().join("destination.control"), &identity("node-b"))
1724                .unwrap();
1725        let snapshot = ReplicatedSnapshot {
1726            cluster_id: "cluster".into(),
1727            epoch: 7,
1728            configuration_state: ConfigurationState::active(3, hash(b"config")),
1729            recovery_generation: 11,
1730            materializer_fingerprint: hash(b"fingerprint"),
1731            user_state: invalid.user_state(),
1732            applied_tip: LogAnchor::new(0, LogHash::ZERO),
1733            receipts: Vec::new(),
1734        };
1735
1736        assert!(matches!(
1737            destination.import_replicated_snapshot(&encode_snapshot(&snapshot).unwrap()),
1738            Err(Error::IdentityMismatch(field)) if field == "user_state"
1739        ));
1740        assert_eq!(destination.user_state().unwrap(), state(b"base-db"));
1741    }
1742
1743    #[test]
1744    fn control_identity_round_trips_page_state() {
1745        let dir = tempfile::tempdir().unwrap();
1746        let expected = identity("node-a");
1747        let store = ControlStore::create(dir.path().join("sqlite.control"), &expected).unwrap();
1748
1749        assert_eq!(store.identity().unwrap(), expected);
1750        assert_eq!(store.user_state().unwrap(), state(b"base-db"));
1751    }
1752
1753    #[test]
1754    fn quorum_authoritative_control_cache_disables_local_sync() {
1755        let dir = tempfile::tempdir().unwrap();
1756        let store =
1757            ControlStore::create(dir.path().join("sqlite.control"), &identity("node-a")).unwrap();
1758
1759        let synchronous: i64 = store
1760            .conn
1761            .query_row("PRAGMA synchronous", [], |row| row.get(0))
1762            .unwrap();
1763
1764        assert_eq!(synchronous, 0);
1765    }
1766
1767    #[test]
1768    fn rebuildable_apply_publishes_entry_tip_receipt_and_state_together() {
1769        let dir = tempfile::tempdir().unwrap();
1770        let original = identity("node-a");
1771        let store = ControlStore::create(dir.path().join("sqlite.control"), &original).unwrap();
1772        let configuration = original.configuration_state().clone();
1773        let entry_hash = LogEntry::calculate_hash(
1774            "cluster",
1775            1,
1776            7,
1777            configuration.config_id(),
1778            rhiza_core::EntryType::Noop,
1779            LogHash::ZERO,
1780            &[],
1781        );
1782        let entry = LogEntry {
1783            cluster_id: "cluster".into(),
1784            epoch: 7,
1785            config_id: configuration.config_id(),
1786            index: 1,
1787            entry_type: rhiza_core::EntryType::Noop,
1788            payload: Vec::new(),
1789            prev_hash: LogHash::ZERO,
1790            hash: entry_hash,
1791        };
1792        let pending = PendingApply::new(
1793            LogAnchor::new(0, LogHash::ZERO),
1794            LogAnchor::new(1, entry_hash),
1795            original.user_state(),
1796            state(b"target-db"),
1797        );
1798        let receipt = RequestReceipt::new(
1799            "request-1",
1800            hash(b"request"),
1801            pending.entry(),
1802            crate::encode_sql_result(&crate::SqlCommandResult {
1803                statement_results: Vec::new(),
1804            })
1805            .unwrap(),
1806        );
1807
1808        store
1809            .commit_rebuildable_apply(
1810                &pending,
1811                &entry,
1812                &configuration,
1813                std::slice::from_ref(&receipt),
1814            )
1815            .unwrap();
1816
1817        assert_eq!(store.pending().unwrap(), None);
1818        assert_eq!(store.embedded_log_entries(1, 1).unwrap(), [entry]);
1819        assert_eq!(store.user_state().unwrap(), pending.target_state());
1820        assert_eq!(
1821            store.applied_tip().unwrap(),
1822            ApplyProgress::new(pending.entry().index(), pending.entry().hash())
1823        );
1824        assert_eq!(
1825            store.lookup_request("request-1", hash(b"request")).unwrap(),
1826            Some(receipt)
1827        );
1828    }
1829
1830    #[test]
1831    fn bound_stop_embedded_log_survives_control_reopen_with_its_initial_configuration() {
1832        let dir = tempfile::tempdir().unwrap();
1833        let path = dir.path().join("sqlite.control");
1834        let original = identity("node-a");
1835        let stop_change = rhiza_core::ConfigChange::bound_stop(
1836            original.cluster_id(),
1837            original.configuration_state().config_id(),
1838            original.configuration_state().digest(),
1839            original.configuration_state().config_id() + 1,
1840            vec!["node-a".into(), "node-b".into(), "node-c".into()],
1841        )
1842        .unwrap();
1843        let command = stop_change.to_stored_command();
1844        let entry = LogEntry {
1845            cluster_id: original.cluster_id().into(),
1846            epoch: original.epoch(),
1847            config_id: original.configuration_state().config_id(),
1848            index: 1,
1849            entry_type: command.entry_type,
1850            payload: command.payload,
1851            prev_hash: LogHash::ZERO,
1852            hash: LogHash::ZERO,
1853        };
1854        let entry = LogEntry {
1855            hash: entry.recompute_hash(),
1856            ..entry
1857        };
1858        let stopped = original
1859            .configuration_state()
1860            .validate_entry(&entry)
1861            .unwrap();
1862        let pending = PendingApply::new(
1863            LogAnchor::new(0, LogHash::ZERO),
1864            LogAnchor::new(entry.index, entry.hash),
1865            original.user_state(),
1866            state(b"target-db"),
1867        );
1868
1869        let store = ControlStore::create(&path, &original).unwrap();
1870        store
1871            .commit_rebuildable_apply(&pending, &entry, &stopped, &[])
1872            .unwrap();
1873        drop(store);
1874
1875        let reopened_identity = ControlIdentity::new(
1876            original.cluster_id(),
1877            original.node_id(),
1878            original.epoch(),
1879            stopped,
1880            original.recovery_generation(),
1881            original.materializer_fingerprint(),
1882            pending.target_state(),
1883        );
1884        let reopened = ControlStore::open_existing(&path, &reopened_identity).unwrap();
1885        assert_eq!(reopened.embedded_log_entries(1, 1).unwrap(), [entry]);
1886    }
1887
1888    #[test]
1889    fn v6_control_and_snapshot_reject_v5_without_migration() {
1890        let dir = tempfile::tempdir().unwrap();
1891        let path = dir.path().join("sqlite.control");
1892        let store = ControlStore::create(&path, &identity("node-a")).unwrap();
1893        let mut old_snapshot = store.export_replicated_snapshot().unwrap();
1894        old_snapshot[SNAPSHOT_MAGIC.len() - 1] = 5;
1895        assert!(matches!(
1896            store.import_replicated_snapshot(&old_snapshot),
1897            Err(Error::InvalidSnapshot(_))
1898        ));
1899        drop(store);
1900
1901        let conn = Connection::open(&path).unwrap();
1902        conn.execute(
1903            "UPDATE control_meta SET value=?1 WHERE key='schema_version'",
1904            [5_u64.to_be_bytes().as_slice()],
1905        )
1906        .unwrap();
1907        drop(conn);
1908        assert!(matches!(
1909            ControlStore::open_existing_unchecked(&path),
1910            Err(Error::Sqlite(message)) if message.contains("version")
1911        ));
1912    }
1913
1914    #[test]
1915    fn verified_checkpoint_compaction_removes_only_the_covered_embedded_prefix() {
1916        let dir = tempfile::tempdir().unwrap();
1917        let store =
1918            ControlStore::create(dir.path().join("sqlite.control"), &identity("node-a")).unwrap();
1919        let configuration = identity("node-a").configuration_state().clone();
1920        let entry = |index, prev_hash| {
1921            let hash = LogEntry::calculate_hash(
1922                "cluster",
1923                index,
1924                7,
1925                configuration.config_id(),
1926                rhiza_core::EntryType::Noop,
1927                prev_hash,
1928                &[],
1929            );
1930            LogEntry {
1931                cluster_id: "cluster".into(),
1932                epoch: 7,
1933                config_id: configuration.config_id(),
1934                index,
1935                entry_type: rhiza_core::EntryType::Noop,
1936                payload: Vec::new(),
1937                prev_hash,
1938                hash,
1939            }
1940        };
1941        let first = entry(1, LogHash::ZERO);
1942        let second = entry(2, first.hash);
1943        store
1944            .commit_metadata_only_entry_with_log(
1945                LogAnchor::new(0, LogHash::ZERO),
1946                &first,
1947                &configuration,
1948                state(b"base-db"),
1949            )
1950            .unwrap();
1951        store
1952            .commit_metadata_only_entry_with_log(
1953                LogAnchor::new(1, first.hash),
1954                &second,
1955                &configuration,
1956                state(b"base-db"),
1957            )
1958            .unwrap();
1959
1960        store.compact_embedded_log_before(2).unwrap();
1961
1962        assert_eq!(store.embedded_log_entries(2, 2).unwrap(), [second]);
1963        assert!(store.embedded_log_entries(1, 1).is_err());
1964        assert!(store.compact_embedded_log_before(3).is_err());
1965    }
1966
1967    #[test]
1968    fn open_rejects_request_table_with_same_columns_but_no_primary_key() {
1969        let dir = tempfile::tempdir().unwrap();
1970        let path = dir.path().join("sqlite.control");
1971        drop(ControlStore::create(&path, &identity("node-a")).unwrap());
1972        let conn = Connection::open(&path).unwrap();
1973        conn.execute_batch(
1974            "DROP TABLE request_receipts;
1975             CREATE TABLE request_receipts (
1976                 request_id TEXT NOT NULL,
1977                 request_digest BLOB NOT NULL,
1978                 original_log_index INTEGER NOT NULL,
1979                 original_log_hash BLOB NOT NULL,
1980                 result_blob BLOB NOT NULL
1981             );",
1982        )
1983        .unwrap();
1984        drop(conn);
1985
1986        assert!(matches!(
1987            ControlStore::open_existing_unchecked(&path),
1988            Err(Error::Sqlite(_))
1989        ));
1990    }
1991
1992    #[test]
1993    fn open_rejects_pending_table_without_singleton_constraint() {
1994        let dir = tempfile::tempdir().unwrap();
1995        let path = dir.path().join("sqlite.control");
1996        drop(ControlStore::create(&path, &identity("node-a")).unwrap());
1997        let conn = Connection::open(&path).unwrap();
1998        conn.execute_batch(
1999            "DROP TABLE pending_apply;
2000             CREATE TABLE pending_apply (
2001                 singleton INTEGER PRIMARY KEY,
2002                 base_index INTEGER NOT NULL,
2003                 base_hash BLOB NOT NULL,
2004                 entry_index INTEGER NOT NULL,
2005                 entry_hash BLOB NOT NULL,
2006                 base_page_size INTEGER NOT NULL,
2007                 base_page_count INTEGER NOT NULL,
2008                 base_state_root BLOB NOT NULL,
2009                 target_page_size INTEGER NOT NULL,
2010                 target_page_count INTEGER NOT NULL,
2011                 target_state_root BLOB NOT NULL
2012             );",
2013        )
2014        .unwrap();
2015        drop(conn);
2016
2017        assert!(matches!(
2018            ControlStore::open_existing_unchecked(&path),
2019            Err(Error::Sqlite(_))
2020        ));
2021    }
2022
2023    #[test]
2024    fn lookup_returns_duplicate_and_rejects_conflicting_digest() {
2025        let dir = tempfile::tempdir().unwrap();
2026        let store =
2027            ControlStore::create(dir.path().join("sqlite.control"), &identity("node-a")).unwrap();
2028        let pending = pending();
2029        let receipt = receipt(hash(b"request"));
2030        store.begin_pending(&pending).unwrap();
2031        store
2032            .commit_applied(
2033                &pending,
2034                identity("node-a").configuration_state(),
2035                std::slice::from_ref(&receipt),
2036            )
2037            .unwrap();
2038        assert_eq!(
2039            store.lookup_request("request-1", hash(b"request")).unwrap(),
2040            Some(receipt)
2041        );
2042        assert!(matches!(
2043            store.lookup_request("request-1", hash(b"other")),
2044            Err(Error::RequestConflict(_))
2045        ));
2046    }
2047
2048    #[test]
2049    fn bulk_lookup_is_aligned_and_rejects_conflicts_or_duplicate_inputs() {
2050        let dir = tempfile::tempdir().unwrap();
2051        let store =
2052            ControlStore::create(dir.path().join("sqlite.control"), &identity("node-a")).unwrap();
2053        let pending = pending();
2054        let existing = receipt_for("request-a", hash(b"request-a"));
2055        store.begin_pending(&pending).unwrap();
2056        store
2057            .commit_applied(
2058                &pending,
2059                identity("node-a").configuration_state(),
2060                std::slice::from_ref(&existing),
2061            )
2062            .unwrap();
2063
2064        assert_eq!(
2065            store
2066                .lookup_requests(&[
2067                    ("request-a", hash(b"request-a")),
2068                    ("request-b", hash(b"request-b")),
2069                ])
2070                .unwrap(),
2071            vec![Ok(Some(existing.clone())), Ok(None)]
2072        );
2073        assert!(matches!(
2074            store
2075                .lookup_requests(&[("request-a", hash(b"different"))])
2076                .unwrap()
2077                .pop()
2078                .unwrap(),
2079            Err(Error::RequestConflict(_))
2080        ));
2081        assert!(matches!(
2082            store.lookup_requests(&[
2083                ("request-a", hash(b"request-a")),
2084                ("request-a", hash(b"request-a")),
2085            ]),
2086            Err(Error::InvalidCommand(message)) if message.contains("duplicate")
2087        ));
2088    }
2089
2090    #[test]
2091    fn capacity_sized_receipt_paths_reject_a_duplicate_at_the_tail() {
2092        let dir = tempfile::tempdir().unwrap();
2093        let store =
2094            ControlStore::create(dir.path().join("sqlite.control"), &identity("node-a")).unwrap();
2095        let pending = pending();
2096        let mut receipts = (0usize..super::super::MAX_QWAL_V3_RECEIPTS)
2097            .map(|index| receipt_for(&format!("request-{index:04}"), hash(&index.to_le_bytes())))
2098            .collect::<Vec<_>>();
2099        receipts.last_mut().unwrap().request_id = receipts[0].request_id.clone();
2100        store.begin_pending(&pending).unwrap();
2101
2102        assert!(matches!(
2103            store.commit_applied(
2104                &pending,
2105                identity("node-a").configuration_state(),
2106                &receipts,
2107            ),
2108            Err(Error::InvalidEntry(message)) if message.contains("unique")
2109        ));
2110        assert_eq!(store.pending().unwrap(), Some(pending));
2111
2112        let lookups = receipts
2113            .iter()
2114            .map(|receipt| (receipt.request_id(), receipt.request_digest()))
2115            .collect::<Vec<_>>();
2116        assert!(matches!(
2117            store.lookup_requests(&lookups),
2118            Err(Error::InvalidCommand(message)) if message.contains("duplicate")
2119        ));
2120    }
2121
2122    #[test]
2123    fn batch_receipts_commit_at_one_anchor_or_not_at_all() {
2124        let dir = tempfile::tempdir().unwrap();
2125        let store =
2126            ControlStore::create(dir.path().join("sqlite.control"), &identity("node-a")).unwrap();
2127        let pending = pending();
2128        let receipts = [
2129            receipt_for("request-a", hash(b"request-a")),
2130            receipt_for("request-b", hash(b"request-b")),
2131        ];
2132        store.begin_pending(&pending).unwrap();
2133        store
2134            .commit_applied(
2135                &pending,
2136                identity("node-a").configuration_state(),
2137                &receipts,
2138            )
2139            .unwrap();
2140        assert_eq!(store.pending().unwrap(), None);
2141        for receipt in &receipts {
2142            assert_eq!(
2143                store
2144                    .lookup_request(receipt.request_id(), receipt.request_digest())
2145                    .unwrap(),
2146                Some(receipt.clone())
2147            );
2148            assert_eq!(receipt.original_anchor(), pending.entry());
2149        }
2150
2151        let pending = PendingApply::new(
2152            pending.entry(),
2153            LogAnchor::new(2, hash(b"entry-2")),
2154            pending.target_state(),
2155            state(b"target-2"),
2156        );
2157        let conflicting = [
2158            RequestReceipt::new(
2159                "request-c",
2160                hash(b"request-c"),
2161                pending.entry(),
2162                receipts[0].result_blob().to_vec(),
2163            ),
2164            RequestReceipt::new(
2165                "request-a",
2166                hash(b"different"),
2167                pending.entry(),
2168                receipts[0].result_blob().to_vec(),
2169            ),
2170        ];
2171        store.begin_pending(&pending).unwrap();
2172        assert!(store
2173            .commit_applied(
2174                &pending,
2175                identity("node-a").configuration_state(),
2176                &conflicting,
2177            )
2178            .is_err());
2179        assert_eq!(store.pending().unwrap(), Some(pending));
2180        assert_eq!(
2181            store
2182                .lookup_request("request-c", hash(b"request-c"))
2183                .unwrap(),
2184            None
2185        );
2186    }
2187
2188    #[test]
2189    fn all_exact_receipts_across_chunks_finish_pending_recovery_without_reinsertion() {
2190        let dir = tempfile::tempdir().unwrap();
2191        let store =
2192            ControlStore::create(dir.path().join("sqlite.control"), &identity("node-a")).unwrap();
2193        let pending = pending();
2194        let receipts = (0usize..1024)
2195            .map(|index| receipt_for(&format!("request-{index:04}"), hash(&index.to_le_bytes())))
2196            .collect::<Vec<_>>();
2197        store.begin_pending(&pending).unwrap();
2198        for receipt in &receipts {
2199            insert_or_validate_receipt(&store.conn, receipt).unwrap();
2200        }
2201
2202        store
2203            .commit_applied(
2204                &pending,
2205                identity("node-a").configuration_state(),
2206                &receipts,
2207            )
2208            .unwrap();
2209
2210        assert_eq!(store.pending().unwrap(), None);
2211        assert_eq!(
2212            store.applied_tip().unwrap(),
2213            ApplyProgress::new(pending.entry().index(), pending.entry().hash())
2214        );
2215        assert_eq!(
2216            store
2217                .lookup_requests(
2218                    &receipts
2219                        .iter()
2220                        .map(|receipt| (receipt.request_id(), receipt.request_digest()))
2221                        .collect::<Vec<_>>(),
2222                )
2223                .unwrap(),
2224            receipts
2225                .into_iter()
2226                .map(|receipt| Ok(Some(receipt)))
2227                .collect::<Vec<_>>()
2228        );
2229    }
2230
2231    #[test]
2232    fn one_thousand_twenty_four_receipts_share_one_anchor_after_reopen() {
2233        let dir = tempfile::tempdir().unwrap();
2234        let path = dir.path().join("sqlite.control");
2235        let pending = pending();
2236        let receipts = (0usize..1024)
2237            .map(|index| receipt_for(&format!("request-{index:04}"), hash(&index.to_le_bytes())))
2238            .collect::<Vec<_>>();
2239        {
2240            let store = ControlStore::create(&path, &identity("node-a")).unwrap();
2241            store.begin_pending(&pending).unwrap();
2242            store
2243                .commit_applied(
2244                    &pending,
2245                    identity("node-a").configuration_state(),
2246                    &receipts,
2247                )
2248                .unwrap();
2249        }
2250        let reopened_identity = ControlIdentity::new(
2251            "cluster",
2252            "node-a",
2253            7,
2254            identity("node-a").configuration_state().clone(),
2255            11,
2256            hash(b"fingerprint"),
2257            pending.target_state(),
2258        );
2259        let store = ControlStore::open_existing(&path, &reopened_identity).unwrap();
2260        let lookups = receipts
2261            .iter()
2262            .map(|receipt| (receipt.request_id(), receipt.request_digest()))
2263            .collect::<Vec<_>>();
2264        let restored = store.lookup_requests(&lookups).unwrap();
2265        assert_eq!(restored.len(), 1024);
2266        assert!(restored.iter().all(|receipt| {
2267            receipt
2268                .as_ref()
2269                .ok()
2270                .and_then(Option::as_ref)
2271                .is_some_and(|receipt| receipt.original_anchor() == pending.entry())
2272        }));
2273    }
2274
2275    #[test]
2276    fn conflict_in_a_later_lookup_chunk_changes_nothing_and_retains_pending() {
2277        let dir = tempfile::tempdir().unwrap();
2278        let store =
2279            ControlStore::create(dir.path().join("sqlite.control"), &identity("node-a")).unwrap();
2280        let first = pending();
2281        let existing = receipt_for("request-1000", hash(b"original"));
2282        store.begin_pending(&first).unwrap();
2283        store
2284            .commit_applied(
2285                &first,
2286                identity("node-a").configuration_state(),
2287                std::slice::from_ref(&existing),
2288            )
2289            .unwrap();
2290        let pending = PendingApply::new(
2291            first.entry(),
2292            LogAnchor::new(2, hash(b"entry-2")),
2293            first.target_state(),
2294            state(b"target-2"),
2295        );
2296        let receipts = (0usize..1024)
2297            .map(|index| {
2298                RequestReceipt::new(
2299                    format!("request-{index:04}"),
2300                    hash(&index.to_le_bytes()),
2301                    pending.entry(),
2302                    existing.result_blob().to_vec(),
2303                )
2304            })
2305            .collect::<Vec<_>>();
2306        store.begin_pending(&pending).unwrap();
2307
2308        assert!(matches!(
2309            store.commit_applied(
2310                &pending,
2311                identity("node-a").configuration_state(),
2312                &receipts,
2313            ),
2314            Err(Error::RequestConflict(_))
2315        ));
2316        assert_eq!(store.pending().unwrap(), Some(pending));
2317        assert_eq!(
2318            store
2319                .lookup_request("request-0000", hash(&0usize.to_le_bytes()))
2320                .unwrap(),
2321            None
2322        );
2323        assert_eq!(
2324            store.applied_tip().unwrap(),
2325            ApplyProgress::new(first.entry().index(), first.entry().hash())
2326        );
2327    }
2328
2329    #[test]
2330    fn error_in_a_later_insert_chunk_rolls_back_earlier_chunks() {
2331        let dir = tempfile::tempdir().unwrap();
2332        let store =
2333            ControlStore::create(dir.path().join("sqlite.control"), &identity("node-a")).unwrap();
2334        let pending = pending();
2335        let receipts = (0usize..1024)
2336            .map(|index| receipt_for(&format!("request-{index:04}"), hash(&index.to_le_bytes())))
2337            .collect::<Vec<_>>();
2338        store
2339            .conn
2340            .execute_batch(
2341                "CREATE TRIGGER abort_later_receipt BEFORE INSERT ON request_receipts
2342                 WHEN NEW.request_id = 'request-0500'
2343                 BEGIN SELECT RAISE(ABORT, 'later insert failure'); END;",
2344            )
2345            .unwrap();
2346        store.begin_pending(&pending).unwrap();
2347
2348        assert!(matches!(
2349            store.commit_applied(
2350                &pending,
2351                identity("node-a").configuration_state(),
2352                &receipts,
2353            ),
2354            Err(Error::Sqlite(_))
2355        ));
2356        assert_eq!(store.pending().unwrap(), Some(pending));
2357        assert_eq!(
2358            store
2359                .lookup_request("request-0000", hash(&0usize.to_le_bytes()))
2360                .unwrap(),
2361            None
2362        );
2363        assert_eq!(
2364            store.applied_tip().unwrap(),
2365            ApplyProgress::new(0, LogHash::ZERO)
2366        );
2367    }
2368
2369    #[test]
2370    fn commit_rejects_result_larger_than_inline_qwal_limit_atomically() {
2371        let dir = tempfile::tempdir().unwrap();
2372        let store =
2373            ControlStore::create(dir.path().join("sqlite.control"), &identity("node-a")).unwrap();
2374        let pending = pending();
2375        let oversized = RequestReceipt::new(
2376            "request-1",
2377            hash(b"request"),
2378            pending.entry(),
2379            vec![0; MAX_RESULT_BLOB_BYTES + 1],
2380        );
2381        store.begin_pending(&pending).unwrap();
2382
2383        assert!(matches!(
2384            store.commit_applied(
2385                &pending,
2386                identity("node-a").configuration_state(),
2387                std::slice::from_ref(&oversized),
2388            ),
2389            Err(Error::ResourceExhausted(_))
2390        ));
2391        assert_eq!(store.pending().unwrap(), Some(pending));
2392        assert_eq!(
2393            store.applied_tip().unwrap(),
2394            ApplyProgress::new(0, LogHash::ZERO)
2395        );
2396    }
2397
2398    #[test]
2399    fn commit_rejects_noncanonical_sql_result_atomically() {
2400        let dir = tempfile::tempdir().unwrap();
2401        let store =
2402            ControlStore::create(dir.path().join("sqlite.control"), &identity("node-a")).unwrap();
2403        let pending = pending();
2404        let malformed = RequestReceipt::new(
2405            "request-1",
2406            hash(b"request"),
2407            pending.entry(),
2408            b"not-qres".to_vec(),
2409        );
2410        store.begin_pending(&pending).unwrap();
2411
2412        assert!(store
2413            .commit_applied(
2414                &pending,
2415                identity("node-a").configuration_state(),
2416                std::slice::from_ref(&malformed),
2417            )
2418            .is_err());
2419        assert_eq!(store.pending().unwrap(), Some(pending));
2420        assert_eq!(
2421            store.applied_tip().unwrap(),
2422            ApplyProgress::new(0, LogHash::ZERO)
2423        );
2424    }
2425
2426    #[test]
2427    fn pending_lifecycle_is_idempotent_and_guarded() {
2428        let dir = tempfile::tempdir().unwrap();
2429        let path = dir.path().join("sqlite.control");
2430        let store = ControlStore::create(&path, &identity("node-a")).unwrap();
2431        let pending = pending();
2432        store.begin_pending(&pending).unwrap();
2433        store.begin_pending(&pending).unwrap();
2434        drop(store);
2435        let store = ControlStore::open_existing(&path, &identity("node-a")).unwrap();
2436        assert_eq!(store.pending().unwrap(), Some(pending.clone()));
2437        let different = PendingApply::new(
2438            pending.base(),
2439            LogAnchor::new(1, hash(b"different")),
2440            pending.base_state(),
2441            pending.target_state(),
2442        );
2443        assert!(store.clear_pending(&different).is_err());
2444        store.clear_pending(&pending).unwrap();
2445        store.clear_pending(&pending).unwrap();
2446        assert_eq!(store.pending().unwrap(), None);
2447    }
2448
2449    #[test]
2450    fn metadata_only_commit_advances_exact_tip_without_pending_and_survives_restart() {
2451        let dir = tempfile::tempdir().unwrap();
2452        let path = dir.path().join("sqlite.control");
2453        let original = identity("node-a");
2454        let base = LogAnchor::new(0, LogHash::ZERO);
2455        let entry = LogAnchor::new(1, hash(b"noop"));
2456        {
2457            let store = ControlStore::create(&path, &original).unwrap();
2458            store
2459                .commit_metadata_only_entry(
2460                    base,
2461                    entry,
2462                    original.configuration_state(),
2463                    original.user_state(),
2464                )
2465                .unwrap();
2466            assert_eq!(store.pending().unwrap(), None);
2467        }
2468
2469        let store = ControlStore::open_existing_unchecked(&path).unwrap();
2470        assert_eq!(
2471            store.applied_tip().unwrap(),
2472            ApplyProgress::new(entry.index(), entry.hash())
2473        );
2474        assert_eq!(
2475            store.configuration_state().unwrap(),
2476            *original.configuration_state()
2477        );
2478        assert_eq!(store.user_state().unwrap(), original.user_state());
2479        assert_eq!(store.pending().unwrap(), None);
2480    }
2481
2482    #[test]
2483    fn metadata_only_commit_rejects_an_inexact_base_without_changing_state() {
2484        let dir = tempfile::tempdir().unwrap();
2485        let original = identity("node-a");
2486        let store = ControlStore::create(dir.path().join("sqlite.control"), &original).unwrap();
2487
2488        assert!(matches!(
2489            store.commit_metadata_only_entry(
2490                LogAnchor::new(0, hash(b"wrong-base")),
2491                LogAnchor::new(1, hash(b"noop")),
2492                original.configuration_state(),
2493                original.user_state(),
2494            ),
2495            Err(Error::InvalidEntry(_))
2496        ));
2497        assert_eq!(
2498            store.applied_tip().unwrap(),
2499            ApplyProgress::new(0, LogHash::ZERO)
2500        );
2501        assert_eq!(store.pending().unwrap(), None);
2502    }
2503
2504    #[test]
2505    fn metadata_only_commit_does_not_bypass_legacy_pending_recovery() {
2506        let dir = tempfile::tempdir().unwrap();
2507        let original = identity("node-a");
2508        let store = ControlStore::create(dir.path().join("sqlite.control"), &original).unwrap();
2509        let pending = pending();
2510        store.begin_pending(&pending).unwrap();
2511
2512        assert!(matches!(
2513            store.commit_metadata_only_entry(
2514                pending.base(),
2515                pending.entry(),
2516                original.configuration_state(),
2517                original.user_state(),
2518            ),
2519            Err(Error::InvalidEntry(_))
2520        ));
2521        assert_eq!(store.pending().unwrap(), Some(pending));
2522        assert_eq!(
2523            store.applied_tip().unwrap(),
2524            ApplyProgress::new(0, LogHash::ZERO)
2525        );
2526    }
2527
2528    #[test]
2529    fn committed_state_and_receipt_survive_restart() {
2530        let dir = tempfile::tempdir().unwrap();
2531        let path = dir.path().join("sqlite.control");
2532        let original = identity("node-a");
2533        let pending = pending();
2534        let receipt = receipt(hash(b"request"));
2535        let next_config = ConfigurationState::active(4, hash(b"next-config"));
2536        {
2537            let store = ControlStore::create(&path, &original).unwrap();
2538            store.begin_pending(&pending).unwrap();
2539            store
2540                .commit_applied(&pending, &next_config, std::slice::from_ref(&receipt))
2541                .unwrap();
2542        }
2543        let reopened_identity = ControlIdentity::new(
2544            "cluster",
2545            "node-a",
2546            7,
2547            next_config.clone(),
2548            11,
2549            hash(b"fingerprint"),
2550            state(b"target-db"),
2551        );
2552        let store = ControlStore::open_existing(&path, &reopened_identity).unwrap();
2553        assert_eq!(
2554            store.applied_tip().unwrap(),
2555            ApplyProgress::new(1, hash(b"entry"))
2556        );
2557        assert_eq!(store.configuration_state().unwrap(), next_config);
2558        assert_eq!(
2559            store.lookup_request("request-1", hash(b"request")).unwrap(),
2560            Some(receipt)
2561        );
2562    }
2563
2564    #[test]
2565    fn snapshot_import_keeps_destination_node_and_excludes_pending() {
2566        let dir = tempfile::tempdir().unwrap();
2567        let source =
2568            ControlStore::create(dir.path().join("source.control"), &identity("node-a")).unwrap();
2569        let source_pending = pending();
2570        source.begin_pending(&source_pending).unwrap();
2571        source
2572            .commit_applied(
2573                &source_pending,
2574                identity("node-a").configuration_state(),
2575                std::slice::from_ref(&receipt(hash(b"request"))),
2576            )
2577            .unwrap();
2578        let snapshot = source.export_replicated_snapshot().unwrap();
2579
2580        let destination =
2581            ControlStore::create(dir.path().join("destination.control"), &identity("node-b"))
2582                .unwrap();
2583        destination.begin_pending(&pending()).unwrap();
2584        destination
2585            .import_replicated_snapshot_with_recovery_generation(&snapshot, Some(42))
2586            .unwrap();
2587        assert_eq!(destination.identity().unwrap().node_id(), "node-b");
2588        assert_eq!(destination.recovery_generation().unwrap(), 42);
2589        assert_eq!(
2590            destination.user_state().unwrap(),
2591            source_pending.target_state()
2592        );
2593        assert_eq!(
2594            destination.applied_tip().unwrap(),
2595            ApplyProgress::new(1, hash(b"entry"))
2596        );
2597        assert_eq!(destination.pending().unwrap(), None);
2598    }
2599
2600    #[test]
2601    fn snapshot_import_rejects_result_larger_than_inline_qwal_limit() {
2602        let dir = tempfile::tempdir().unwrap();
2603        let destination =
2604            ControlStore::create(dir.path().join("destination.control"), &identity("node-b"))
2605                .unwrap();
2606        let snapshot = ReplicatedSnapshot {
2607            cluster_id: "cluster".into(),
2608            epoch: 7,
2609            configuration_state: ConfigurationState::active(3, hash(b"config")),
2610            recovery_generation: 11,
2611            materializer_fingerprint: hash(b"fingerprint"),
2612            user_state: state(b"base-db"),
2613            applied_tip: LogAnchor::new(1, hash(b"entry")),
2614            receipts: vec![RequestReceipt::new(
2615                "oversized",
2616                hash(b"request"),
2617                LogAnchor::new(1, hash(b"entry")),
2618                vec![0; MAX_RESULT_BLOB_BYTES + 1],
2619            )],
2620        };
2621        let encoded = encode_snapshot(&snapshot).unwrap();
2622
2623        assert!(matches!(
2624            destination.import_replicated_snapshot(&encoded),
2625            Err(Error::ResourceExhausted(_))
2626        ));
2627        assert_eq!(
2628            destination.applied_tip().unwrap(),
2629            ApplyProgress::new(0, LogHash::ZERO)
2630        );
2631    }
2632}