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