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