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 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 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 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 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 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 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 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 #[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 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 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 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}