1use std::{
2 collections::HashSet,
3 fs::{self, File, OpenOptions},
4 io::{self, Read, Seek, SeekFrom, Write},
5 path::{Path, PathBuf},
6 sync::{
7 atomic::{AtomicBool, Ordering},
8 Mutex, OnceLock,
9 },
10 time::{Duration, Instant},
11};
12
13use rhiza_core::{
14 ConfigurationState, EntryType, LogAnchor, LogEntry, LogHash, LogIndex, RecoveryAnchor,
15 Snapshot, SnapshotIdentity, SnapshotManifest,
16};
17use rusqlite::{
18 config::DbConfig,
19 hooks::{AuthAction, AuthContext, Authorization},
20 params, params_from_iter,
21 types::{ToSql, ToSqlOutput, Value, ValueRef},
22 Connection, OpenFlags, OptionalExtension, Transaction, TransactionBehavior,
23};
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26use tempfile::NamedTempFile;
27
28use crate::page_state::{PageStateCacheV3, PageStatePatchV3};
29use crate::wal_capture::{capture_wal, WalCapture, WalCommit};
30
31mod control;
32mod page_state;
33mod qwal;
34mod wal_capture;
35
36pub use control::{ControlIdentity, ControlStore, PendingApply, RequestReceipt};
37pub use page_state::StateIdentityV3;
38pub use qwal::{
39 decode_qwal_v3, encode_qwal_v3, sqlite_page_size, QwalEnvelopeV3, QwalPageV3, QwalReceiptV3,
40 MAX_QWAL_V3_BYTES, MAX_QWAL_V3_RECEIPTS, QWAL_V3_MAGIC,
41};
42const CREATE_KV_TABLE_SQL: &str = r#"
43CREATE TABLE IF NOT EXISTS __rhiza_kv (
44 key TEXT PRIMARY KEY,
45 value TEXT NOT NULL
46);
47"#;
48
49const SQL_COMMAND_V2_MAGIC: &[u8] = b"QSQL\0\x02";
50const SQL_RESULT_V1_MAGIC: &[u8] = b"QRES\0\x01";
51const QWAL_SNAPSHOT_V3_MAGIC: &[u8] = b"QSNP\0\x04";
52const SQL_EXECUTOR_POLICY_VERSION: &str = "rhiza-sql-qwal-batch-v3-policy-v8-compat";
53const SQL_CONNECTION_PROFILE: &str = "qwal_batch_v3;wal_autocheckpoint=0;canonical_synchronous=OFF;control_synchronous=OFF;page_state_cache=rebuildable;staging_synchronous=OFF;foreign_keys=ON;trusted_schema=OFF;temp=command_scoped;attach=denied;vtable=bundled";
54pub const MAX_SQL_STATEMENTS: usize = 64;
55pub const MAX_SQL_PARAMETERS: usize = 999;
56pub const MAX_SQL_TEXT_BYTES: usize = 64 * 1024;
57pub const MAX_RETURNING_ROWS: usize = 1_024;
58pub const MAX_RETURNING_BYTES: usize = 1024 * 1024;
59pub const MAX_SQL_EFFECT_BYTES: usize = 512 * 1024;
60pub const DEFAULT_SQL_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
61const SQL_PROGRESS_HANDLER_OPS: i32 = 1_000;
62
63#[derive(Deserialize, Serialize)]
64#[serde(deny_unknown_fields)]
65struct QwalSnapshotV3 {
66 user_db: Vec<u8>,
67 replicated_control: Vec<u8>,
68 user_state: StateIdentityV3,
69}
70
71#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
72#[serde(tag = "type", content = "value", rename_all = "snake_case")]
73pub enum SqlValue {
74 Null,
75 Integer(i64),
76 Real(f64),
77 Text(String),
78 Blob(Vec<u8>),
79}
80
81impl ToSql for SqlValue {
82 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
83 Ok(match self {
84 Self::Null => ToSqlOutput::Owned(Value::Null),
85 Self::Integer(value) => ToSqlOutput::Owned(Value::Integer(*value)),
86 Self::Real(value) => ToSqlOutput::Owned(Value::Real(*value)),
87 Self::Text(value) => ToSqlOutput::Borrowed(ValueRef::Text(value.as_bytes())),
88 Self::Blob(value) => ToSqlOutput::Borrowed(ValueRef::Blob(value)),
89 })
90 }
91}
92
93#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
94#[serde(deny_unknown_fields)]
95pub struct SqlStatement {
96 pub sql: String,
97 #[serde(default)]
98 pub parameters: Vec<SqlValue>,
99}
100
101#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
102#[serde(deny_unknown_fields)]
103pub struct SqlCommand {
104 pub request_id: String,
105 pub statements: Vec<SqlStatement>,
106}
107
108#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
109#[serde(deny_unknown_fields)]
110pub struct SqlQueryResult {
111 pub columns: Vec<String>,
112 pub rows: Vec<Vec<SqlValue>>,
113}
114
115#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
116#[serde(deny_unknown_fields)]
117pub struct SqlStatementResult {
118 pub rows_affected: u64,
119 pub returning: Option<SqlQueryResult>,
120}
121
122#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
123#[serde(deny_unknown_fields)]
124pub struct SqlCommandResult {
125 pub statement_results: Vec<SqlStatementResult>,
126}
127
128#[derive(Deserialize, Serialize)]
129#[serde(deny_unknown_fields)]
130struct SqlCommandV2Envelope {
131 executor_fingerprint: LogHash,
132 command: SqlCommand,
133}
134
135#[derive(Clone, Copy, Debug)]
136pub struct SqlBatchMember<'a> {
137 pub command: &'a SqlCommand,
138 pub request_payload: &'a [u8],
139}
140
141#[derive(Clone, Debug, PartialEq)]
142pub struct SqlBatchPreparation {
143 pub effect: Option<Vec<u8>>,
144 pub results: Vec<Result<SqlCommandResult>>,
145}
146
147struct PreparedQwalMutation {
148 receipts: Vec<QwalReceiptV3>,
149 results: Vec<Result<SqlCommandResult>>,
150}
151
152pub fn sql_executor_fingerprint() -> Result<LogHash> {
153 static FINGERPRINT: OnceLock<std::result::Result<LogHash, String>> = OnceLock::new();
154 FINGERPRINT
155 .get_or_init(compute_sql_executor_fingerprint)
156 .clone()
157 .map_err(Error::Sqlite)
158}
159
160fn compute_sql_executor_fingerprint() -> std::result::Result<LogHash, String> {
161 let conn = Connection::open_in_memory().map_err(|error| error.to_string())?;
162 let source_id: String = conn
163 .query_row("SELECT sqlite_source_id()", [], |row| row.get(0))
164 .map_err(|error| error.to_string())?;
165 let mut statement = conn
166 .prepare("PRAGMA compile_options")
167 .map_err(|error| error.to_string())?;
168 let mut compile_options = statement
169 .query_map([], |row| row.get::<_, String>(0))
170 .map_err(|error| error.to_string())?
171 .collect::<std::result::Result<Vec<_>, _>>()
172 .map_err(|error| error.to_string())?;
173 compile_options.sort_unstable();
174 let canonical = format!(
175 "{SQL_EXECUTOR_POLICY_VERSION}\n{SQL_CONNECTION_PROFILE}\n{}\n{}\n{}",
176 env!("CARGO_PKG_VERSION"),
177 source_id,
178 compile_options.join("\n")
179 );
180 Ok(LogHash::digest(&[canonical.as_bytes()]))
181}
182
183pub fn encode_sql_command(command: &SqlCommand) -> Result<Vec<u8>> {
184 validate_sql_command(command)?;
185 let encoded = serde_json::to_vec(&SqlCommandV2Envelope {
186 executor_fingerprint: sql_executor_fingerprint()?,
187 command: command.clone(),
188 })
189 .map_err(|error| Error::InvalidCommand(format!("cannot encode SQL command: {error}")))?;
190 let mut payload = Vec::with_capacity(SQL_COMMAND_V2_MAGIC.len() + encoded.len());
191 payload.extend_from_slice(SQL_COMMAND_V2_MAGIC);
192 payload.extend_from_slice(&encoded);
193 Ok(payload)
194}
195
196#[derive(Clone, Copy, Debug, Eq, PartialEq)]
197pub struct ApplyProgress {
198 applied_index: LogIndex,
199 applied_hash: LogHash,
200}
201
202impl ApplyProgress {
203 pub const fn new(applied_index: LogIndex, applied_hash: LogHash) -> Self {
204 Self {
205 applied_index,
206 applied_hash,
207 }
208 }
209
210 pub const fn applied_index(&self) -> LogIndex {
211 self.applied_index
212 }
213
214 pub const fn applied_hash(&self) -> LogHash {
215 self.applied_hash
216 }
217}
218
219#[derive(Clone, Debug, PartialEq)]
220pub struct ApplyOutcome {
221 progress: ApplyProgress,
222 sql_result: Option<SqlCommandResult>,
223}
224
225impl ApplyOutcome {
226 pub const fn progress(&self) -> ApplyProgress {
227 self.progress
228 }
229
230 pub const fn sql_result(&self) -> Option<&SqlCommandResult> {
231 self.sql_result.as_ref()
232 }
233}
234
235#[derive(Clone, Copy, Debug, Eq, PartialEq)]
236pub struct RequestOutcome {
237 original_log_index: LogIndex,
238 original_log_hash: LogHash,
239}
240
241impl RequestOutcome {
242 pub const fn new(original_log_index: LogIndex, original_log_hash: LogHash) -> Self {
243 Self {
244 original_log_index,
245 original_log_hash,
246 }
247 }
248
249 pub const fn original_log_index(&self) -> LogIndex {
250 self.original_log_index
251 }
252
253 pub const fn original_log_hash(&self) -> LogHash {
254 self.original_log_hash
255 }
256}
257
258#[derive(Clone, Debug, Eq, PartialEq)]
259pub struct RequestConflict {
260 request_id: String,
261 original_outcome: RequestOutcome,
262}
263
264impl RequestConflict {
265 pub fn request_id(&self) -> &str {
266 &self.request_id
267 }
268
269 pub const fn original_outcome(&self) -> RequestOutcome {
270 self.original_outcome
271 }
272}
273
274impl std::fmt::Display for RequestConflict {
275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276 write!(
277 f,
278 "request id reused with different payload: {}",
279 self.request_id
280 )
281 }
282}
283
284impl std::error::Error for RequestConflict {}
285
286pub type Result<T> = std::result::Result<T, Error>;
287pub type SqlRequestLookup = Result<Option<(RequestOutcome, Option<SqlCommandResult>)>>;
288
289#[derive(Clone, Debug, Eq, PartialEq)]
290pub enum Error {
291 ApplyFailed,
292 RestoreFailed,
293 Io(String),
294 Sqlite(String),
295 ResourceExhausted(String),
296 InvalidCommand(String),
297 IdentityMismatch(String),
298 InvalidEntry(String),
299 RequestConflict(RequestConflict),
300 InvalidSnapshot(String),
301}
302
303impl std::fmt::Display for Error {
304 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305 match self {
306 Self::ApplyFailed => write!(f, "SQLite apply failed"),
307 Self::RestoreFailed => write!(f, "SQLite restore failed"),
308 Self::Io(message) => write!(f, "SQLite io failed: {message}"),
309 Self::Sqlite(message) => write!(f, "SQLite error: {message}"),
310 Self::ResourceExhausted(message) => write!(f, "SQLite resource exhausted: {message}"),
311 Self::InvalidCommand(message) => write!(f, "invalid deterministic command: {message}"),
312 Self::IdentityMismatch(field) => {
313 write!(f, "SQLite database identity mismatch for {field}")
314 }
315 Self::InvalidEntry(message) => write!(f, "invalid log entry: {message}"),
316 Self::RequestConflict(conflict) => conflict.fmt(f),
317 Self::InvalidSnapshot(message) => write!(f, "invalid SQLite snapshot: {message}"),
318 }
319 }
320}
321
322impl std::error::Error for Error {}
323
324pub trait StateMachine {
325 fn applied_index(&self) -> Result<LogIndex>;
326 fn apply(&self, entry: &LogEntry) -> Result<ApplyProgress>;
327 fn create_snapshot(&self, target: LogIndex) -> Result<Snapshot>;
328}
329
330pub struct SqliteStateMachine {
331 path: PathBuf,
332 conn: Mutex<Option<Connection>>,
333 lifecycle: Mutex<()>,
334 control: ControlStore,
335 pending_fence: AtomicBool,
336 page_state: Mutex<CanonicalPageStateV3>,
337 uncommitted_effect: Mutex<Option<LogHash>>,
338 prepared_target: Mutex<Option<PreparedTarget>>,
339}
340
341#[derive(Clone, Debug, Eq, PartialEq)]
342struct CanonicalPageStateV3 {
343 cache: PageStateCacheV3,
344 seal: Option<PreparedBaseSeal>,
345}
346
347struct PreparedTarget {
348 artifact: NamedTempFile,
349 target_seal: Option<PreparedBaseSeal>,
350 base_file: File,
351 base_seal: Option<PreparedBaseSeal>,
352 cluster_id: String,
353 node_id: String,
354 epoch: u64,
355 configuration_id: u64,
356 recovery_generation: u64,
357 materializer_fingerprint: String,
358 base_index: LogIndex,
359 base_hash: LogHash,
360 base_state: StateIdentityV3,
361 target_state: StateIdentityV3,
362 effect_digest: LogHash,
363}
364
365#[derive(Clone, Copy, Debug, Eq, PartialEq)]
366struct PreparedBaseSeal {
367 dev: u64,
368 ino: u64,
369 len: u64,
370 mtime: i64,
371 mtime_nsec: i64,
372 ctime: i64,
373 ctime_nsec: i64,
374}
375
376impl PreparedTarget {
377 fn matches(
378 &self,
379 effect: &QwalEnvelopeV3,
380 effect_payload: &[u8],
381 identity: &ControlIdentity,
382 ) -> bool {
383 self.cluster_id == effect.cluster_id
384 && self.cluster_id == identity.cluster_id()
385 && self.node_id == identity.node_id()
386 && self.epoch == effect.epoch
387 && self.epoch == identity.epoch()
388 && self.configuration_id == effect.configuration_id
389 && self.recovery_generation == effect.recovery_generation
390 && self.materializer_fingerprint == effect.materializer_fingerprint
391 && self.base_index == effect.base_index
392 && self.base_hash == effect.base_hash
393 && self.base_state == effect.base_state
394 && self.target_state == effect.target_state
395 && self.effect_digest == LogHash::digest(&[effect_payload])
396 }
397}
398
399#[derive(Clone, Debug, Eq, PartialEq)]
400pub struct RecoverySnapshot {
401 snapshot: Snapshot,
402 anchor: RecoveryAnchor,
403}
404
405impl RecoverySnapshot {
406 pub const fn snapshot(&self) -> &Snapshot {
407 &self.snapshot
408 }
409
410 pub fn db_bytes(&self) -> &[u8] {
411 self.snapshot.db_bytes()
412 }
413
414 pub const fn anchor(&self) -> &RecoveryAnchor {
415 &self.anchor
416 }
417}
418
419impl SqliteStateMachine {
420 pub fn open(
421 path: impl AsRef<Path>,
422 cluster_id: &str,
423 node_id: &str,
424 epoch: u64,
425 config_id: u64,
426 ) -> Result<Self> {
427 Self::open_with_configuration(
428 path,
429 cluster_id,
430 node_id,
431 epoch,
432 ConfigurationState::active(config_id, LogHash::ZERO),
433 )
434 }
435
436 pub fn open_with_configuration(
437 path: impl AsRef<Path>,
438 cluster_id: &str,
439 node_id: &str,
440 epoch: u64,
441 configuration_state: ConfigurationState,
442 ) -> Result<Self> {
443 let path = path.as_ref().to_path_buf();
444 ensure_parent(&path)?;
445
446 let control_path = control_sidecar_path(&path);
447 match (path.exists(), control_path.exists()) {
448 (false, false) => {
449 Self::create_new(&path, cluster_id, node_id, epoch, configuration_state)
450 }
451 (true, true) => {
452 let db = Self::open_existing_file(&path)?;
453 db.validate_control_identity(cluster_id, node_id, epoch)?;
454 Ok(db)
455 }
456 (true, false) => Err(Error::IdentityMismatch(
457 "QWAL control sidecar is missing; install a QWAL snapshot instead of auto-migrating"
458 .into(),
459 )),
460 (false, true) => Err(Error::IdentityMismatch(
461 "canonical SQLite database is missing beside its QWAL control sidecar".into(),
462 )),
463 }
464 }
465
466 pub fn open_existing(path: impl AsRef<Path>) -> Result<Self> {
467 Self::open_existing_file(path.as_ref())
468 }
469
470 fn create_new(
471 path: &Path,
472 cluster_id: &str,
473 node_id: &str,
474 epoch: u64,
475 configuration_state: ConfigurationState,
476 ) -> Result<Self> {
477 OpenOptions::new()
478 .write(true)
479 .create_new(true)
480 .open(path)
481 .map_err(|err| Error::Io(err.to_string()))?;
482
483 let control_path = control_sidecar_path(path);
484 let created = (|| {
485 let conn = open_connection(path)?;
486 conn.execute_batch(CREATE_KV_TABLE_SQL)
487 .map_err(sqlite_error)?;
488 conn.execute_batch("VACUUM; PRAGMA wal_checkpoint(TRUNCATE);")
489 .map_err(sqlite_error)?;
490 conn.close()
491 .map_err(|(_, error)| Error::Sqlite(error.to_string()))?;
492 let page_state = rebuild_sealed_page_state(path)?;
493 let identity = ControlIdentity::new(
494 cluster_id,
495 node_id,
496 epoch,
497 configuration_state,
498 1,
499 sql_executor_fingerprint()?,
500 page_state.cache.identity(),
501 );
502 let control = ControlStore::create(&control_path, &identity)?;
503 let conn = open_connection(path)?;
504 Ok(Self {
505 path: path.to_path_buf(),
506 conn: Mutex::new(Some(conn)),
507 lifecycle: Mutex::new(()),
508 control,
509 pending_fence: AtomicBool::new(false),
510 page_state: Mutex::new(page_state),
511 uncommitted_effect: Mutex::new(None),
512 prepared_target: Mutex::new(None),
513 })
514 })();
515 if created.is_err() {
516 let _ = fs::remove_file(path);
517 let _ = fs::remove_file(&control_path);
518 }
519 created
520 }
521
522 fn open_existing_file(path: &Path) -> Result<Self> {
523 let control_path = control_sidecar_path(path);
524 if !path.exists() || !control_path.exists() {
525 return Err(Error::IdentityMismatch(
526 "QWAL database and control sidecar must both exist".into(),
527 ));
528 }
529 let control = ControlStore::open_existing_unchecked(&control_path)?;
530 let (page_state, pending) = validate_control_database_pair(path, &control)?;
531 reject_legacy_user_database(path)?;
532 let conn = open_connection(path)?;
533 Ok(Self {
534 path: path.to_path_buf(),
535 conn: Mutex::new(Some(conn)),
536 lifecycle: Mutex::new(()),
537 control,
538 pending_fence: AtomicBool::new(pending),
539 page_state: Mutex::new(page_state),
540 uncommitted_effect: Mutex::new(None),
541 prepared_target: Mutex::new(None),
542 })
543 }
544
545 fn validate_control_identity(&self, cluster_id: &str, node_id: &str, epoch: u64) -> Result<()> {
546 let identity = self.control.identity()?;
547 if identity.cluster_id() != cluster_id {
548 return Err(Error::IdentityMismatch("cluster_id".into()));
549 }
550 if identity.node_id() != node_id {
551 return Err(Error::IdentityMismatch("node_id".into()));
552 }
553 if identity.epoch() != epoch {
554 return Err(Error::IdentityMismatch("epoch".into()));
555 }
556 if identity.materializer_fingerprint() != sql_executor_fingerprint()? {
557 return Err(Error::IdentityMismatch(
558 "SQLite QWAL materializer fingerprint".into(),
559 ));
560 }
561 Ok(())
562 }
563
564 fn with_connection<T>(&self, operation: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
565 let guard = self
566 .conn
567 .lock()
568 .map_err(|_| Error::Sqlite("SQLite connection lock is poisoned".into()))?;
569 let conn = guard
570 .as_ref()
571 .ok_or_else(|| Error::Sqlite("SQLite connection is closed".into()))?;
572 operation(conn)
573 }
574
575 fn lock_lifecycle(&self) -> Result<std::sync::MutexGuard<'_, ()>> {
576 self.lifecycle
577 .lock()
578 .map_err(|_| Error::Sqlite("SQLite lifecycle lock is poisoned".into()))
579 }
580
581 fn ensure_no_pending_apply(&self) -> Result<()> {
582 if self.pending_fence.load(Ordering::Acquire) {
583 return Err(Error::InvalidEntry(
584 "canonical SQLite state is unavailable while a QWAL apply is pending".into(),
585 ));
586 }
587 Ok(())
588 }
589
590 fn close_connection(&self) -> Result<()> {
591 let conn = self
592 .conn
593 .lock()
594 .map_err(|_| Error::Sqlite("SQLite connection lock is poisoned".into()))?
595 .take();
596 if let Some(conn) = conn {
597 conn.close()
598 .map_err(|(_, error)| Error::Sqlite(error.to_string()))?;
599 }
600 Ok(())
601 }
602
603 fn reopen_connection(&self) -> Result<()> {
604 let reopened = open_connection(&self.path)?;
605 let mut guard = self
606 .conn
607 .lock()
608 .map_err(|_| Error::Sqlite("SQLite connection lock is poisoned".into()))?;
609 if guard.is_some() {
610 return Err(Error::Sqlite(
611 "refusing to replace an open SQLite connection".into(),
612 ));
613 }
614 *guard = Some(reopened);
615 Ok(())
616 }
617
618 pub fn apply_entry(&self, entry: &LogEntry) -> Result<ApplyProgress> {
619 Ok(self.apply_entry_with_result(entry)?.progress())
620 }
621
622 pub fn apply_entry_with_result(&self, entry: &LogEntry) -> Result<ApplyOutcome> {
623 let _lifecycle = self.lock_lifecycle()?;
624 self.ensure_page_state_sealed()?;
625 if entry.recompute_hash() != entry.hash {
626 return Err(Error::InvalidEntry(
627 "hash does not match entry contents".into(),
628 ));
629 }
630 let installed = *self
631 .uncommitted_effect
632 .lock()
633 .map_err(|_| Error::Sqlite("uncommitted QWAL effect lock is poisoned".into()))?;
634 if let Some(installed) = installed {
635 if entry.entry_type != EntryType::Command
636 || installed != LogHash::digest(&[&entry.payload])
637 {
638 return Err(Error::InvalidEntry(
639 "only the exact installed QWAL effect may retry after control failure".into(),
640 ));
641 }
642 } else if self.pending_fence.load(Ordering::Acquire) {
643 let incoming = LogAnchor::new(entry.index, entry.hash);
644 if self.control.pending()?.map(|pending| pending.entry()) != Some(incoming) {
645 return Err(Error::InvalidEntry(
646 "only the exact durable pending entry may retry physical apply".into(),
647 ));
648 }
649 }
650 self.apply_qwal_entry(entry)
651 }
652
653 fn apply_qwal_entry(&self, entry: &LogEntry) -> Result<ApplyOutcome> {
654 let identity = self.control.identity()?;
655 if entry.cluster_id != identity.cluster_id() {
656 return Err(Error::InvalidEntry(
657 "cluster_id does not match sidecar".into(),
658 ));
659 }
660 if entry.epoch != identity.epoch() {
661 return Err(Error::InvalidEntry("epoch does not match sidecar".into()));
662 }
663 let tip = self.control.applied_tip()?;
664 if entry.index == tip.applied_index() {
665 if entry.hash != tip.applied_hash() {
666 return Err(Error::InvalidEntry(
667 "current index was reapplied with a different hash".into(),
668 ));
669 }
670 let sql_result = if entry.entry_type == EntryType::Command {
671 let effect = decode_qwal_command(&entry.payload)?;
672 if let [effect_receipt] = effect.receipts.as_slice() {
673 self.control
674 .lookup_request(&effect_receipt.request_id, effect_receipt.request_digest)?
675 .map(|receipt| decode_sql_result(receipt.result_blob()))
676 .transpose()?
677 } else {
678 None
679 }
680 } else {
681 None
682 };
683 return Ok(ApplyOutcome {
684 progress: tip,
685 sql_result,
686 });
687 }
688 let next_index = tip
689 .applied_index()
690 .checked_add(1)
691 .ok_or_else(|| Error::InvalidEntry("applied index is exhausted".into()))?;
692 if entry.index != next_index || entry.prev_hash != tip.applied_hash() {
693 return Err(Error::InvalidEntry(
694 "entry does not extend the QWAL applied tip".into(),
695 ));
696 }
697 let current_configuration = self.control.configuration_state()?;
698 let next_configuration = current_configuration
699 .validate_entry(entry)
700 .map_err(|error| Error::InvalidEntry(error.to_string()))?;
701 let base_anchor = LogAnchor::new(tip.applied_index(), tip.applied_hash());
702 let entry_anchor = LogAnchor::new(entry.index, entry.hash);
703
704 if entry.entry_type != EntryType::Command {
705 self.discard_prepared_target()?;
706 match entry.entry_type {
707 EntryType::Noop if entry.payload.is_empty() => {}
708 EntryType::ConfigChange => {}
709 EntryType::Noop => {
710 return Err(Error::InvalidEntry("Noop payload must be empty".into()))
711 }
712 _ => {
713 return Err(Error::InvalidEntry(format!(
714 "entry type {:?} is unsupported by QWAL",
715 entry.entry_type
716 )))
717 }
718 }
719 let user_state = self.control.user_state()?;
720 if entry.entry_type == EntryType::Noop && next_configuration == current_configuration {
721 self.control.commit_metadata_only_entry_with_log(
722 base_anchor,
723 entry,
724 ¤t_configuration,
725 user_state,
726 )?;
727 } else {
728 let pending = PendingApply::new(base_anchor, entry_anchor, user_state, user_state);
729 self.pending_fence.store(true, Ordering::Release);
730 self.control.begin_pending_with_entry(&pending, entry)?;
731 #[cfg(test)]
732 inject_pending_commit_fault(&self.path)?;
733 self.control
734 .commit_applied(&pending, &next_configuration, &[])?;
735 self.pending_fence.store(false, Ordering::Release);
736 }
737 return Ok(ApplyOutcome {
738 progress: ApplyProgress::new(entry.index, entry.hash),
739 sql_result: None,
740 });
741 }
742
743 let effect = match decode_qwal_command(&entry.payload) {
744 Ok(effect) => effect,
745 Err(error) => {
746 self.discard_prepared_target()?;
747 return Err(error);
748 }
749 };
750 self.discard_prepared_target_unless(&effect, &entry.payload, &identity)?;
751 validate_qwal_identity(&effect, &identity, ¤t_configuration)?;
752 if effect.base_index != tip.applied_index() || effect.base_hash != tip.applied_hash() {
753 return Err(Error::InvalidEntry(
754 "QWAL effect base does not match the applied tip".into(),
755 ));
756 }
757 let mut results = Vec::with_capacity(effect.receipts.len());
758 let mut receipts = Vec::with_capacity(effect.receipts.len());
759 for effect_receipt in &effect.receipts {
760 let result = decode_sql_result(&effect_receipt.result_blob)?;
761 if encode_sql_result(&result)? != effect_receipt.result_blob {
762 return Err(Error::InvalidEntry(
763 "QWAL result is not canonically encoded".into(),
764 ));
765 }
766 results.push(result);
767 receipts.push(RequestReceipt::new(
768 effect_receipt.request_id.clone(),
769 effect_receipt.request_digest,
770 entry_anchor,
771 effect_receipt.result_blob.clone(),
772 ));
773 }
774 let lookup_keys = effect
775 .receipts
776 .iter()
777 .map(|receipt| (receipt.request_id.as_str(), receipt.request_digest))
778 .collect::<Vec<_>>();
779 let existing = self.control.lookup_requests(&lookup_keys)?;
780 for (expected, existing) in receipts.iter().zip(existing) {
781 match existing? {
782 None => {}
783 Some(existing) if existing == *expected => {}
784 Some(_) => {
785 return Err(Error::InvalidEntry(
786 "QWAL request receipt already belongs to another entry or result".into(),
787 ));
788 }
789 }
790 }
791 let pending = PendingApply::new(
792 base_anchor,
793 entry_anchor,
794 effect.base_state,
795 effect.target_state,
796 );
797 self.pending_fence.store(true, Ordering::Release);
798 self.install_qwal_effect(&effect, &entry.payload, entry_anchor)?;
799 #[cfg(test)]
800 let control_commit = inject_qwal_control_fault(&self.path).and_then(|()| {
801 self.control
802 .commit_rebuildable_apply(&pending, entry, &next_configuration, &receipts)
803 });
804 #[cfg(not(test))]
805 let control_commit =
806 self.control
807 .commit_rebuildable_apply(&pending, entry, &next_configuration, &receipts);
808 if let Err(error) = control_commit {
809 let _ = self.close_connection();
810 return Err(error);
811 }
812 self.pending_fence.store(false, Ordering::Release);
813 self.uncommitted_effect
814 .lock()
815 .map_err(|_| Error::Sqlite("uncommitted QWAL effect lock is poisoned".into()))?
816 .take();
817 Ok(ApplyOutcome {
818 progress: ApplyProgress::new(entry.index, entry.hash),
819 sql_result: if results.len() == 1 {
820 results.pop()
821 } else {
822 None
823 },
824 })
825 }
826
827 fn install_qwal_effect(
828 &self,
829 effect: &QwalEnvelopeV3,
830 effect_payload: &[u8],
831 entry_anchor: LogAnchor,
832 ) -> Result<()> {
833 if !self.preverify_page_state_transition(effect, effect_payload, entry_anchor)? {
834 self.remember_uncommitted_effect(effect_payload)?;
835 if self
836 .conn
837 .lock()
838 .map_err(|_| Error::Sqlite("SQLite connection lock is poisoned".into()))?
839 .is_none()
840 {
841 self.reopen_connection()?;
842 }
843 return Ok(());
844 };
845 #[cfg(test)]
846 begin_prepared_base_reuse_audit(&self.path);
847 if let Some(prepared) = self.take_matching_prepared_target(effect, effect_payload)? {
848 self.close_connection()?;
849 match self.promote_prepared_target(&prepared, effect) {
850 Ok(true) => {
851 let mut installed = OpenOptions::new()
852 .read(true)
853 .write(true)
854 .open(&self.path)
855 .map_err(io_error)?;
856 qwal::verify_installed_pages(&mut installed, effect)?;
857 self.publish_page_state_after_install(effect, &installed)?;
858 self.remember_uncommitted_effect(effect_payload)?;
859 #[cfg(test)]
860 note_prepared_install(&self.path, PreparedInstallPath::Promoted);
861 return self.reopen_connection();
862 }
863 Ok(false) => self.reopen_connection()?,
864 Err(error) => {
865 let _ = self.reopen_connection();
866 return Err(error);
867 }
868 }
869 }
870 #[cfg(test)]
871 note_second_checkpoint(&self.path);
872 self.with_connection(checkpoint_truncate)?;
873 self.close_connection()?;
874 let mut canonical = self.open_bound_canonical()?;
875 qwal::apply_preverified_qwal_in_place(&mut canonical, effect, |page_no| {
876 #[cfg(test)]
877 inject_qwal_apply_fault(&self.path, page_no)?;
878 #[cfg(not(test))]
879 let _ = page_no;
880 Ok(())
881 })?;
882 self.publish_page_state_after_install(effect, &canonical)?;
883 self.remember_uncommitted_effect(effect_payload)?;
884 #[cfg(test)]
885 note_prepared_install(&self.path, PreparedInstallPath::Patched);
886 self.reopen_connection()
887 }
888
889 fn preverify_page_state_transition(
890 &self,
891 effect: &QwalEnvelopeV3,
892 effect_payload: &[u8],
893 entry_anchor: LogAnchor,
894 ) -> Result<bool> {
895 let page_state = self
896 .page_state
897 .lock()
898 .map_err(|_| Error::Sqlite("SQLite page-state cache lock is poisoned".into()))?;
899 verify_bound_canonical(&self.path, &page_state)?;
900 let current = page_state.cache.identity();
901 if current == effect.target_state && current != effect.base_state {
902 let digest = LogHash::digest(&[effect_payload]);
903 let installed = self
904 .uncommitted_effect
905 .lock()
906 .map_err(|_| Error::Sqlite("uncommitted QWAL effect lock is poisoned".into()))?;
907 let exact_in_process_replay = *installed == Some(digest);
908 drop(installed);
909 let durable_replay =
910 if exact_in_process_replay || !self.pending_fence.load(Ordering::Acquire) {
911 false
912 } else {
913 self.control.pending()?.is_some_and(|pending| {
914 pending.base_state() == effect.base_state
915 && pending.target_state() == effect.target_state
916 && pending.entry() == entry_anchor
917 })
918 };
919 if !exact_in_process_replay && !durable_replay {
920 return Err(Error::InvalidEntry(
921 "QWAL target is installed without an exact in-process replay seal".into(),
922 ));
923 }
924 return Ok(false);
925 }
926 if current != effect.base_state {
927 return Err(Error::InvalidEntry(
928 "QWAL base page state does not match the canonical cache".into(),
929 ));
930 }
931 let patches = effect
932 .pages
933 .iter()
934 .map(|page| PageStatePatchV3::new(page.page_no, &page.after_image))
935 .collect::<Vec<_>>();
936 let target = page_state
937 .cache
938 .overlay(effect.target_state.page_count, &patches)?;
939 if target != effect.target_state {
940 return Err(Error::InvalidEntry(
941 "QWAL target page state mismatch".into(),
942 ));
943 }
944 Ok(current != effect.target_state)
945 }
946
947 fn ensure_page_state_sealed(&self) -> Result<()> {
948 let page_state = self
949 .page_state
950 .lock()
951 .map_err(|_| Error::Sqlite("SQLite page-state cache lock is poisoned".into()))?;
952 verify_bound_canonical(&self.path, &page_state)
953 }
954
955 fn open_bound_canonical(&self) -> Result<File> {
956 if !sqlite_sidecars_absent(&self.path)? {
957 return Err(Error::InvalidEntry(
958 "closed canonical SQLite database still has WAL sidecars".into(),
959 ));
960 }
961 let page_state = self
962 .page_state
963 .lock()
964 .map_err(|_| Error::Sqlite("SQLite page-state cache lock is poisoned".into()))?;
965 open_bound_canonical(&self.path, &page_state)
966 }
967
968 fn publish_page_state_after_install(
969 &self,
970 effect: &QwalEnvelopeV3,
971 canonical: &File,
972 ) -> Result<()> {
973 let seal = seal_held_canonical(&self.path, canonical)?;
974 let mut page_state = self
975 .page_state
976 .lock()
977 .map_err(|_| Error::Sqlite("SQLite page-state cache lock is poisoned".into()))?;
978 let patches = effect
979 .pages
980 .iter()
981 .map(|page| PageStatePatchV3::new(page.page_no, &page.after_image))
982 .collect::<Vec<_>>();
983 let target = page_state
984 .cache
985 .apply_patch(effect.target_state.page_count, &patches)?;
986 if target != effect.target_state {
987 return Err(Error::InvalidEntry(
988 "installed QWAL target page state invariant failed".into(),
989 ));
990 }
991 page_state.seal = seal;
992 Ok(())
993 }
994
995 fn remember_uncommitted_effect(&self, effect_payload: &[u8]) -> Result<()> {
996 *self
997 .uncommitted_effect
998 .lock()
999 .map_err(|_| Error::Sqlite("uncommitted QWAL effect lock is poisoned".into()))? =
1000 Some(LogHash::digest(&[effect_payload]));
1001 Ok(())
1002 }
1003
1004 fn discard_prepared_target(&self) -> Result<()> {
1005 self.prepared_target
1006 .lock()
1007 .map_err(|_| Error::Sqlite("prepared SQLite target lock is poisoned".into()))?
1008 .take();
1009 Ok(())
1010 }
1011
1012 fn discard_prepared_target_unless(
1013 &self,
1014 effect: &QwalEnvelopeV3,
1015 effect_payload: &[u8],
1016 identity: &ControlIdentity,
1017 ) -> Result<()> {
1018 let mut prepared = self
1019 .prepared_target
1020 .lock()
1021 .map_err(|_| Error::Sqlite("prepared SQLite target lock is poisoned".into()))?;
1022 if prepared
1023 .as_ref()
1024 .is_some_and(|prepared| !prepared.matches(effect, effect_payload, identity))
1025 {
1026 prepared.take();
1027 }
1028 Ok(())
1029 }
1030
1031 fn take_matching_prepared_target(
1032 &self,
1033 effect: &QwalEnvelopeV3,
1034 effect_payload: &[u8],
1035 ) -> Result<Option<PreparedTarget>> {
1036 let identity = self.control.identity()?;
1037 let mut prepared = self
1038 .prepared_target
1039 .lock()
1040 .map_err(|_| Error::Sqlite("prepared SQLite target lock is poisoned".into()))?;
1041 Ok(prepared
1042 .take()
1043 .filter(|prepared| prepared.matches(effect, effect_payload, &identity)))
1044 }
1045
1046 fn promote_prepared_target(
1047 &self,
1048 prepared: &PreparedTarget,
1049 effect: &QwalEnvelopeV3,
1050 ) -> Result<bool> {
1051 if !prepared_base_still_sealed(&self.path, prepared)?
1052 || !sqlite_sidecars_absent(prepared.artifact.path())?
1053 {
1054 return Ok(false);
1055 }
1056 let owned_metadata = prepared.artifact.as_file().metadata().map_err(io_error)?;
1057 let Some(named_metadata) = symlink_metadata_if_exists(prepared.artifact.path())? else {
1058 return Ok(false);
1059 };
1060 if !prepared_base_metadata_matches(
1061 prepared.target_seal.as_ref(),
1062 &owned_metadata,
1063 &named_metadata,
1064 ) {
1065 return Ok(false);
1066 }
1067 if owned_metadata.len()
1068 != u64::from(effect.target_state.page_size) * u64::from(effect.target_state.page_count)
1069 {
1070 return Ok(false);
1071 }
1072 let Some(rename_metadata) = symlink_metadata_if_exists(prepared.artifact.path())? else {
1073 return Ok(false);
1074 };
1075 if !prepared_base_metadata_matches(
1076 prepared.target_seal.as_ref(),
1077 &owned_metadata,
1078 &rename_metadata,
1079 ) || !prepared_base_still_sealed(&self.path, prepared)?
1080 {
1081 return Ok(false);
1082 }
1083 if let Err(error) = fs::rename(prepared.artifact.path(), &self.path) {
1087 if error.kind() == std::io::ErrorKind::NotFound {
1088 return Ok(false);
1089 }
1090 return Err(io_error(error));
1091 }
1092 Ok(true)
1093 }
1094
1095 pub fn get_value(&self, key: &str) -> Result<Option<String>> {
1096 let _lifecycle = self.lock_lifecycle()?;
1097 self.ensure_no_pending_apply()?;
1098 self.with_connection(|conn| {
1099 conn.query_row(
1100 "SELECT value FROM __rhiza_kv WHERE key = ?1",
1101 params![key],
1102 |row| row.get(0),
1103 )
1104 .optional()
1105 .map_err(sqlite_error)
1106 })
1107 }
1108
1109 pub fn query_sql(
1110 &self,
1111 query: &SqlStatement,
1112 max_rows: usize,
1113 max_bytes: usize,
1114 ) -> Result<SqlQueryResult> {
1115 self.query_sql_with_timeout(query, max_rows, max_bytes, DEFAULT_SQL_QUERY_TIMEOUT)
1116 }
1117
1118 pub fn query_sql_with_timeout(
1119 &self,
1120 query: &SqlStatement,
1121 max_rows: usize,
1122 max_bytes: usize,
1123 timeout: Duration,
1124 ) -> Result<SqlQueryResult> {
1125 let _lifecycle = self.lock_lifecycle()?;
1126 validate_sql_statement(query)?;
1127 self.ensure_no_pending_apply()?;
1128 if max_rows == 0 || max_bytes == 0 {
1129 return Err(Error::InvalidCommand(
1130 "SQL query limits must be positive".into(),
1131 ));
1132 }
1133 let deadline = Instant::now()
1134 .checked_add(timeout)
1135 .unwrap_or_else(Instant::now);
1136 self.with_connection(|conn| {
1137 conn.progress_handler(
1138 SQL_PROGRESS_HANDLER_OPS,
1139 Some(move || Instant::now() >= deadline),
1140 )
1141 .map_err(sqlite_error)?;
1142 let result = with_sql_authorizer(conn, SqlAuthorizationMode::ReadOnly, || {
1143 let mut statement = conn.prepare(&query.sql).map_err(sql_query_error)?;
1144 if !statement.readonly() {
1145 return Err(Error::InvalidCommand("SQL query must be read-only".into()));
1146 }
1147 let columns = statement
1148 .column_names()
1149 .into_iter()
1150 .map(str::to_owned)
1151 .collect::<Vec<_>>();
1152 let column_count = columns.len();
1153 let mut rows = statement
1154 .query(params_from_iter(query.parameters.iter()))
1155 .map_err(sql_query_error)?;
1156 let mut result_rows = Vec::new();
1157 let mut result_bytes = columns.iter().map(String::len).sum::<usize>();
1158 while let Some(row) = rows.next().map_err(sql_query_error)? {
1159 if result_rows.len() == max_rows {
1160 return Err(Error::InvalidCommand(format!(
1161 "SQL query exceeds {max_rows} rows"
1162 )));
1163 }
1164 let mut values = Vec::with_capacity(column_count);
1165 for column in 0..column_count {
1166 let value = sql_value(row.get_ref(column).map_err(sql_query_error)?)?;
1167 result_bytes = result_bytes
1168 .checked_add(sql_value_size(&value))
1169 .ok_or_else(|| {
1170 Error::InvalidCommand("SQL result size overflow".into())
1171 })?;
1172 if result_bytes > max_bytes {
1173 return Err(Error::InvalidCommand(format!(
1174 "SQL query exceeds {max_bytes} result bytes"
1175 )));
1176 }
1177 values.push(value);
1178 }
1179 result_rows.push(values);
1180 }
1181 Ok(SqlQueryResult {
1182 columns,
1183 rows: result_rows,
1184 })
1185 });
1186 let clear_result = conn
1187 .progress_handler(0, None::<fn() -> bool>)
1188 .map_err(sqlite_error);
1189 match (result, clear_result) {
1190 (Err(error), _) => Err(error),
1191 (Ok(_), Err(error)) => Err(error),
1192 (Ok(result), Ok(())) => Ok(result),
1193 }
1194 })
1195 }
1196
1197 pub fn validate_sql_write(&self, command: &SqlCommand) -> Result<()> {
1198 let request = encode_sql_command(command)?;
1199 let tip = self.control.applied_tip()?;
1200 let preparation = self.prepare_sql_batch_effect(
1201 &[SqlBatchMember {
1202 command,
1203 request_payload: &request,
1204 }],
1205 tip.applied_index(),
1206 tip.applied_hash(),
1207 )?;
1208 preparation
1209 .results
1210 .into_iter()
1211 .next()
1212 .expect("one-member batch returns one result")
1213 .map(|_| ())
1214 }
1215
1216 pub fn prepare_sql_batch_effect(
1225 &self,
1226 members: &[SqlBatchMember<'_>],
1227 base_index: LogIndex,
1228 base_hash: LogHash,
1229 ) -> Result<SqlBatchPreparation> {
1230 if members.is_empty() || members.len() > MAX_QWAL_V3_RECEIPTS {
1231 return Err(Error::InvalidCommand(format!(
1232 "SQL batch must contain 1..={MAX_QWAL_V3_RECEIPTS} members"
1233 )));
1234 }
1235 self.prepare_qwal_effect(base_index, base_hash, |staging| {
1236 let mut preflight = std::iter::repeat_with(|| None)
1237 .take(members.len())
1238 .collect::<Vec<Option<Result<Option<RequestReceipt>>>>>();
1239 let mut lookup_members: Vec<usize> = Vec::with_capacity(members.len());
1240 let mut lookup_keys = Vec::with_capacity(members.len());
1241 let mut seen_request_ids = HashSet::with_capacity(members.len());
1242 for (index, member) in members.iter().enumerate() {
1243 let request_digest = LogHash::digest(&[member.request_payload]);
1244 let validation = validate_sql_command(member.command).and_then(|()| {
1245 if decode_sql_command(member.request_payload)? != *member.command {
1246 return Err(Error::InvalidCommand(
1247 "SQL batch member is not the canonical QSQL v2 command".into(),
1248 ));
1249 }
1250 if !seen_request_ids.insert(member.command.request_id.as_str()) {
1251 return Err(Error::InvalidCommand(
1252 "SQL batch member repeats a request_id".into(),
1253 ));
1254 }
1255 Ok(())
1256 });
1257 if let Err(error) = validation {
1258 preflight[index] = Some(Err(error));
1259 continue;
1260 }
1261 lookup_members.push(index);
1262 lookup_keys.push((member.command.request_id.as_str(), request_digest));
1263 }
1264 for (member_index, lookup) in lookup_members
1265 .iter()
1266 .copied()
1267 .zip(self.control.lookup_requests(&lookup_keys)?)
1268 {
1269 preflight[member_index] = Some(lookup);
1270 }
1271
1272 let mut tx = Transaction::new_unchecked(staging, TransactionBehavior::Immediate)
1273 .map_err(sqlite_error)?;
1274 let mut receipts = Vec::with_capacity(members.len());
1275 let mut results = Vec::with_capacity(members.len());
1276 for (member, preflight) in members.iter().zip(preflight) {
1277 let request_digest = LogHash::digest(&[member.request_payload]);
1278 match preflight.expect("every SQL batch member has one preflight result") {
1279 Err(error) => {
1280 results.push(Err(error));
1281 continue;
1282 }
1283 Ok(Some(_)) => {
1284 results.push(Err(Error::InvalidCommand(
1285 "request was already materialized; return its stored receipt".into(),
1286 )));
1287 continue;
1288 }
1289 Ok(None) => {}
1290 }
1291
1292 let savepoint = tx.savepoint().map_err(sqlite_error)?;
1293 match execute_sql_statements(&savepoint, &member.command.statements)
1294 .and_then(|result| encode_sql_result(&result).map(|blob| (result, blob)))
1295 {
1296 Ok((result, result_blob)) => {
1297 savepoint.commit().map_err(sqlite_error)?;
1298 receipts.push(QwalReceiptV3 {
1299 request_id: member.command.request_id.clone(),
1300 request_digest,
1301 result_blob,
1302 });
1303 results.push(Ok(result));
1304 }
1305 Err(error) => {
1306 savepoint.finish().map_err(sqlite_error)?;
1307 results.push(Err(error));
1308 }
1309 }
1310 }
1311 if receipts.is_empty() {
1312 tx.rollback().map_err(sqlite_error)?;
1313 } else {
1314 tx.commit().map_err(sqlite_error)?;
1315 }
1316 Ok(PreparedQwalMutation { receipts, results })
1317 })
1318 }
1319
1320 pub fn prepare_put_effect(
1321 &self,
1322 request_id: &str,
1323 key: &str,
1324 value: &str,
1325 request_payload: &[u8],
1326 base_index: LogIndex,
1327 base_hash: LogHash,
1328 ) -> Result<Vec<u8>> {
1329 let canonical_request = encode_put_request(request_id, key, value)?;
1330 if request_payload != canonical_request {
1331 return Err(Error::InvalidCommand(
1332 "put effect request is not the canonical put command".into(),
1333 ));
1334 }
1335 let preparation = self.prepare_qwal_effect(base_index, base_hash, |staging| {
1336 let request_digest = LogHash::digest(&[request_payload]);
1337 if self
1338 .control
1339 .lookup_request(request_id, request_digest)?
1340 .is_some()
1341 {
1342 return Err(Error::InvalidCommand(
1343 "request was already materialized; return its stored receipt".into(),
1344 ));
1345 }
1346 let tx = Transaction::new_unchecked(staging, TransactionBehavior::Immediate)
1347 .map_err(sqlite_error)?;
1348 tx.execute(
1349 "INSERT INTO __rhiza_kv(key, value) VALUES (?1, ?2)\n ON CONFLICT(key) DO UPDATE SET value = excluded.value",
1350 params![key, value],
1351 )
1352 .map_err(sqlite_error)?;
1353 tx.commit().map_err(sqlite_error)?;
1354 let result = SqlCommandResult {
1355 statement_results: Vec::new(),
1356 };
1357 Ok(PreparedQwalMutation {
1358 receipts: vec![QwalReceiptV3 {
1359 request_id: request_id.to_owned(),
1360 request_digest,
1361 result_blob: encode_sql_result(&result)?,
1362 }],
1363 results: vec![Ok(result)],
1364 })
1365 })?;
1366 preparation.effect.ok_or_else(|| {
1367 Error::InvalidCommand("put effect unexpectedly produced no successful member".into())
1368 })
1369 }
1370
1371 fn prepare_qwal_effect(
1372 &self,
1373 base_index: LogIndex,
1374 base_hash: LogHash,
1375 mutation: impl FnOnce(&mut Connection) -> Result<PreparedQwalMutation>,
1376 ) -> Result<SqlBatchPreparation> {
1377 let _lifecycle = self.lock_lifecycle()?;
1378 self.ensure_page_state_sealed()?;
1379 self.ensure_no_pending_apply()?;
1380 let tip = self.control.applied_tip()?;
1381 if tip != ApplyProgress::new(base_index, base_hash) {
1382 return Err(Error::InvalidEntry(
1383 "QWAL effect base does not match the materialized SQLite tip".into(),
1384 ));
1385 }
1386 let identity = self.control.identity()?;
1387 let base_state = self
1388 .page_state
1389 .lock()
1390 .map_err(|_| Error::Sqlite("SQLite page-state cache lock is poisoned".into()))?
1391 .cache
1392 .identity();
1393 if base_state != identity.user_state() {
1394 return Err(Error::InvalidEntry(
1395 "cached SQLite base state does not match the control sidecar".into(),
1396 ));
1397 }
1398
1399 let prepare_result = (|| {
1400 self.close_connection()?;
1401
1402 let (base_file, base_seal) = open_sealed_prepared_base(&self.path)?;
1403 {
1404 let page_state = self.page_state.lock().map_err(|_| {
1405 Error::Sqlite("SQLite page-state cache lock is poisoned".into())
1406 })?;
1407 if !prepared_base_metadata_matches(
1408 page_state.seal.as_ref(),
1409 &base_file.metadata().map_err(io_error)?,
1410 &fs::symlink_metadata(&self.path).map_err(io_error)?,
1411 ) {
1412 return Err(Error::InvalidEntry(
1413 "closed SQLite base no longer matches the cached page-state seal".into(),
1414 ));
1415 }
1416 }
1417 let base_file_bytes = fs::metadata(&self.path).map_err(io_error)?.len();
1418 let staging_artifact = clone_or_copy_to_temp(&self.path)?;
1419 let staging_path = staging_artifact.path();
1420 let copied = fs::metadata(staging_path).map_err(io_error)?.len();
1421 if copied != base_file_bytes {
1422 return Err(Error::Io(
1423 "speculative SQLite clone did not reproduce the closed base size".into(),
1424 ));
1425 }
1426 #[cfg(test)]
1427 note_speculative_copy(&self.path);
1428 let page_size = base_state.page_size;
1429 let base_db_pages = u32::try_from(base_file_bytes / u64::from(page_size))
1430 .map_err(|_| Error::ResourceExhausted("SQLite base page count overflows".into()))?;
1431 if !sqlite_sidecars_absent(staging_path)? {
1432 return Err(Error::InvalidEntry(
1433 "fresh speculative SQLite clone has inherited sidecars".into(),
1434 ));
1435 }
1436 let sidecar_cleanup = StagingSidecarCleanup::new(staging_path);
1437 let mut staging = open_connection(staging_path)?;
1438 if !staging
1439 .set_db_config(DbConfig::SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, true)
1440 .map_err(sqlite_error)?
1441 || !staging
1442 .db_config(DbConfig::SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE)
1443 .map_err(sqlite_error)?
1444 {
1445 return Err(Error::Sqlite(
1446 "SQLite refused to disable checkpoint-on-close for QWAL capture".into(),
1447 ));
1448 }
1449 #[cfg(test)]
1450 note_native_wal_capture(&self.path);
1451 staging
1452 .pragma_update(None, "synchronous", "OFF")
1453 .map_err(sqlite_error)?;
1454 #[cfg(test)]
1455 note_speculative_synchronous(&self.path, &staging)?;
1456 let mutation = mutation(&mut staging)?;
1457 if mutation.receipts.is_empty() {
1458 staging
1459 .close()
1460 .map_err(|(_, error)| Error::Sqlite(error.to_string()))?;
1461 sidecar_cleanup.cleanup()?;
1462 return Ok((None, mutation.results, None, None, None));
1463 }
1464 let held_wal = open_fresh_staging_wal(staging_path)?;
1465 staging
1466 .close()
1467 .map_err(|(_, error)| Error::Sqlite(error.to_string()))?;
1468 #[cfg(test)]
1469 inject_wal_capture_fault(&self.path, held_wal.as_ref())?;
1470 let capture = match held_wal {
1471 Some((mut wal, seal)) => {
1472 verify_staging_wal_seal(&wal, seal)?;
1473 let capture = capture_wal(&mut wal, base_db_pages, MAX_QWAL_V3_BYTES)?;
1474 verify_staging_wal_seal(&wal, seal)?;
1475 capture
1476 }
1477 None => WalCapture::NoChange,
1478 };
1479 let pages = materialize_wal_capture(
1480 &base_file,
1481 staging_path,
1482 page_size,
1483 base_file_bytes,
1484 capture,
1485 )?;
1486 sidecar_cleanup.cleanup()?;
1487 let target_file_bytes = fs::metadata(staging_path).map_err(io_error)?.len();
1488 let target_page_count = u32::try_from(target_file_bytes / u64::from(page_size))
1489 .map_err(|_| {
1490 Error::ResourceExhausted("SQLite target page count overflows".into())
1491 })?;
1492 let patches = pages
1493 .iter()
1494 .map(|page| PageStatePatchV3::new(page.page_no, &page.after_image))
1495 .collect::<Vec<_>>();
1496 let target_state = self
1497 .page_state
1498 .lock()
1499 .map_err(|_| Error::Sqlite("SQLite page-state cache lock is poisoned".into()))?
1500 .cache
1501 .overlay(target_page_count, &patches)?;
1502
1503 let effect = QwalEnvelopeV3 {
1504 cluster_id: identity.cluster_id().to_owned(),
1505 epoch: identity.epoch(),
1506 configuration_id: identity.configuration_state().config_id(),
1507 recovery_generation: identity.recovery_generation(),
1508 base_index,
1509 base_hash,
1510 base_state,
1511 target_state,
1512 materializer_fingerprint: identity.materializer_fingerprint().to_hex(),
1513 receipts: mutation.receipts,
1514 pages,
1515 };
1516 let encoded = encode_qwal_v3(&effect)?;
1517 Ok((
1518 Some(encoded),
1519 mutation.results,
1520 Some(effect),
1521 Some((base_file, base_seal)),
1522 Some(staging_artifact),
1523 ))
1524 })();
1525
1526 if self
1527 .conn
1528 .lock()
1529 .map_err(|_| Error::Sqlite("SQLite connection lock is poisoned".into()))?
1530 .is_none()
1531 {
1532 let reopen_result = self.reopen_connection();
1533 if prepare_result.is_ok() {
1534 reopen_result?;
1535 }
1536 }
1537 let (encoded, results, effect, prepared_base, staging_artifact) = prepare_result?;
1538 let (Some(encoded), Some(effect)) = (encoded, effect) else {
1539 self.discard_prepared_target()?;
1540 return Ok(SqlBatchPreparation {
1541 effect: None,
1542 results,
1543 });
1544 };
1545 let (base_file, base_seal) =
1546 prepared_base.expect("a prepared QWAL effect retains its sealed canonical base");
1547 let staging_artifact =
1548 staging_artifact.expect("a prepared QWAL effect retains its speculative target");
1549 let target_owned = staging_artifact.as_file().metadata().map_err(io_error)?;
1550 let target_named = fs::symlink_metadata(staging_artifact.path()).map_err(io_error)?;
1551 let target_seal = prepared_base_seal(&target_owned, &target_named)?;
1552 let prepared = PreparedTarget {
1553 artifact: staging_artifact,
1554 target_seal,
1555 base_file,
1556 base_seal,
1557 cluster_id: effect.cluster_id.clone(),
1558 node_id: identity.node_id().to_owned(),
1559 epoch: effect.epoch,
1560 configuration_id: effect.configuration_id,
1561 recovery_generation: effect.recovery_generation,
1562 materializer_fingerprint: effect.materializer_fingerprint.clone(),
1563 base_index: effect.base_index,
1564 base_hash: effect.base_hash,
1565 base_state: effect.base_state,
1566 target_state: effect.target_state,
1567 effect_digest: LogHash::digest(&[&encoded]),
1568 };
1569 *self
1570 .prepared_target
1571 .lock()
1572 .map_err(|_| Error::Sqlite("prepared SQLite target lock is poisoned".into()))? =
1573 Some(prepared);
1574 Ok(SqlBatchPreparation {
1575 effect: Some(encoded),
1576 results,
1577 })
1578 }
1579
1580 pub fn check_request(
1581 &self,
1582 request_id: &str,
1583 command_payload: &[u8],
1584 ) -> Result<Option<RequestOutcome>> {
1585 let _lifecycle = self.lock_lifecycle()?;
1586 self.with_connection(|_| Ok(()))?;
1587 self.ensure_no_pending_apply()?;
1588 let Some(receipt) = self
1589 .control
1590 .lookup_request(request_id, LogHash::digest(&[command_payload]))?
1591 else {
1592 return Ok(None);
1593 };
1594 Ok(Some(RequestOutcome::new(
1595 receipt.original_anchor().index(),
1596 receipt.original_anchor().hash(),
1597 )))
1598 }
1599
1600 pub fn connection_pragmas(&self) -> Result<(String, i64)> {
1601 let _lifecycle = self.lock_lifecycle()?;
1602 self.ensure_no_pending_apply()?;
1603 self.with_connection(|conn| {
1604 let journal_mode = conn
1605 .query_row("PRAGMA journal_mode;", [], |row| row.get(0))
1606 .map_err(sqlite_error)?;
1607 let synchronous = conn
1608 .query_row("PRAGMA synchronous;", [], |row| row.get(0))
1609 .map_err(sqlite_error)?;
1610 Ok((journal_mode, synchronous))
1611 })
1612 }
1613
1614 pub fn check_sql_request(
1615 &self,
1616 request_id: &str,
1617 command_payload: &[u8],
1618 ) -> Result<Option<(RequestOutcome, Option<SqlCommandResult>)>> {
1619 self.check_sql_requests(&[(request_id, command_payload)])?
1620 .pop()
1621 .expect("one SQL request produces one aligned lookup")
1622 }
1623
1624 pub fn check_sql_requests(&self, requests: &[(&str, &[u8])]) -> Result<Vec<SqlRequestLookup>> {
1632 let _lifecycle = self.lock_lifecycle()?;
1633 self.with_connection(|_| Ok(()))?;
1634 self.ensure_no_pending_apply()?;
1635 let mut validations = Vec::with_capacity(requests.len());
1636 let mut lookup_keys = Vec::with_capacity(requests.len());
1637 for (request_id, command_payload) in requests {
1638 let validation = decode_sql_command(command_payload).and_then(|command| {
1639 if command.request_id != *request_id {
1640 return Err(Error::InvalidCommand(
1641 "SQL payload request_id does not match lookup request_id".into(),
1642 ));
1643 }
1644 Ok(())
1645 });
1646 validations.push(validation);
1647 lookup_keys.push((*request_id, LogHash::digest(&[command_payload])));
1648 }
1649 let receipts = self.control.lookup_requests(&lookup_keys)?;
1650 let mut aligned = Vec::with_capacity(requests.len());
1651 for (validation, receipt) in validations.into_iter().zip(receipts) {
1652 let checked = match validation {
1653 Err(error) => Err(error),
1654 Ok(()) => match receipt {
1655 Err(error) => Err(error),
1656 Ok(None) => Ok(None),
1657 Ok(Some(receipt)) => decode_sql_result(receipt.result_blob()).map(|result| {
1658 Some((
1659 RequestOutcome::new(
1660 receipt.original_anchor().index(),
1661 receipt.original_anchor().hash(),
1662 ),
1663 Some(result),
1664 ))
1665 }),
1666 },
1667 };
1668 aligned.push(checked);
1669 }
1670 Ok(aligned)
1671 }
1672
1673 pub fn applied_index_value(&self) -> Result<LogIndex> {
1674 Ok(self.control.applied_tip()?.applied_index())
1675 }
1676
1677 pub fn applied_hash_value(&self) -> Result<LogHash> {
1678 Ok(self.control.applied_tip()?.applied_hash())
1679 }
1680
1681 pub fn applied_tip(&self) -> Result<ApplyProgress> {
1683 self.control.applied_tip()
1684 }
1685
1686 pub fn applied_tip_value(&self) -> Result<(LogIndex, LogHash)> {
1687 let tip = self.applied_tip()?;
1688 Ok((tip.applied_index(), tip.applied_hash()))
1689 }
1690
1691 pub fn configuration_state_value(&self) -> Result<ConfigurationState> {
1692 self.control.configuration_state()
1693 }
1694
1695 pub fn embedded_log_entries(
1696 &self,
1697 from_index: LogIndex,
1698 through_index: LogIndex,
1699 ) -> Result<Vec<LogEntry>> {
1700 self.control.embedded_log_entries(from_index, through_index)
1701 }
1702
1703 pub fn compact_embedded_log_before(&self, anchor_index: LogIndex) -> Result<()> {
1704 self.control.compact_embedded_log_before(anchor_index)
1705 }
1706
1707 pub fn canonical_db_digest(&self) -> Result<LogHash> {
1708 let _lifecycle = self.lock_lifecycle()?;
1709 self.ensure_no_pending_apply()?;
1710 self.with_connection(checkpoint_truncate)?;
1711 self.close_connection()?;
1712 let digest = file_digest(&self.path);
1713 let reopen = self.reopen_connection();
1714 match (digest, reopen) {
1715 (Err(error), _) => Err(error),
1716 (Ok(_), Err(error)) => Err(error),
1717 (Ok(digest), Ok(())) => Ok(digest),
1718 }
1719 }
1720
1721 pub fn create_snapshot(&self, target: LogIndex) -> Result<Snapshot> {
1722 let _lifecycle = self.lock_lifecycle()?;
1723 self.ensure_page_state_sealed()?;
1724 self.ensure_no_pending_apply()?;
1725 let tip = self.control.applied_tip()?;
1726 if tip.applied_index() != target {
1727 return Err(Error::InvalidSnapshot(format!(
1728 "snapshot target {target} does not match applied index {}",
1729 tip.applied_index()
1730 )));
1731 }
1732 let identity = self.control.identity()?;
1733 let manifest = SnapshotManifest::new_with_configuration(
1734 identity.cluster_id(),
1735 identity.configuration_state().clone(),
1736 identity.epoch(),
1737 target,
1738 tip.applied_hash(),
1739 1,
1740 identity.node_id(),
1741 )
1742 .with_executor_fingerprint(sql_executor_fingerprint()?);
1743 self.with_connection(checkpoint_truncate)?;
1744 self.close_connection()?;
1745 let snapshot = (|| {
1746 let mut canonical = self.open_bound_canonical()?;
1747 canonical.seek(SeekFrom::Start(0)).map_err(io_error)?;
1748 let mut user_db = Vec::new();
1749 canonical.read_to_end(&mut user_db).map_err(io_error)?;
1750 seal_held_canonical(&self.path, &canonical)?;
1751 let page_state = page_state_from_database_bytes(&user_db)?;
1752 if page_state.identity() != identity.user_state() {
1753 return Err(Error::InvalidSnapshot(
1754 "canonical database page state does not match control sidecar".into(),
1755 ));
1756 }
1757 let container = QwalSnapshotV3 {
1758 user_db,
1759 replicated_control: self.control.export_replicated_snapshot()?,
1760 user_state: page_state.identity(),
1761 };
1762 encode_qwal_snapshot(&container).map(|bytes| Snapshot::new(manifest, bytes))
1763 })();
1764 let reopen = self.reopen_connection();
1765 match (snapshot, reopen) {
1766 (Err(error), _) => Err(error),
1767 (Ok(_), Err(error)) => Err(error),
1768 (Ok(snapshot), Ok(())) => Ok(snapshot),
1769 }
1770 }
1771
1772 pub fn create_recovery_snapshot(&self, recovery_generation: u64) -> Result<RecoverySnapshot> {
1773 if recovery_generation == 0 {
1774 return Err(Error::InvalidSnapshot(
1775 "recovery_generation must be positive".into(),
1776 ));
1777 }
1778 let target = self.applied_index_value()?;
1779 if target == 0 {
1780 return Err(Error::InvalidSnapshot(
1781 "recovery snapshot requires an applied entry".into(),
1782 ));
1783 }
1784 let snapshot = self.create_snapshot(target)?;
1785 let manifest = snapshot.manifest();
1786 let size_bytes = u64::try_from(snapshot.db_bytes().len())
1787 .map_err(|_| Error::InvalidSnapshot("snapshot size exceeds u64".into()))?;
1788 let anchor = RecoveryAnchor::new_with_configuration(
1789 manifest.cluster_id(),
1790 manifest.epoch(),
1791 manifest.configuration_state().clone(),
1792 recovery_generation,
1793 LogAnchor::new(manifest.index(), manifest.applied_hash()),
1794 SnapshotIdentity::new(
1795 manifest.snapshot_id(),
1796 LogHash::digest(&[snapshot.db_bytes()]),
1797 size_bytes,
1798 )
1799 .with_executor_fingerprint(
1800 manifest
1801 .executor_fingerprint()
1802 .expect("new snapshots always bind the executor fingerprint"),
1803 ),
1804 );
1805 Ok(RecoverySnapshot { snapshot, anchor })
1806 }
1807}
1808
1809pub fn encode_put_request(request_id: &str, key: &str, value: &str) -> Result<Vec<u8>> {
1810 if request_id.is_empty() || request_id.len() > 256 || key.is_empty() {
1811 return Err(Error::InvalidCommand(
1812 "put request_id and key must be non-empty and request_id at most 256 bytes".into(),
1813 ));
1814 }
1815 if [request_id, key, value]
1816 .iter()
1817 .any(|field| field.as_bytes().contains(&b'\t'))
1818 {
1819 return Err(Error::InvalidCommand(
1820 "put request fields must not contain a tab".into(),
1821 ));
1822 }
1823 Ok(format!("put\t{request_id}\t{key}\t{value}").into_bytes())
1824}
1825
1826impl StateMachine for SqliteStateMachine {
1827 fn applied_index(&self) -> Result<LogIndex> {
1828 self.applied_index_value()
1829 }
1830
1831 fn apply(&self, entry: &LogEntry) -> Result<ApplyProgress> {
1832 self.apply_entry(entry)
1833 }
1834
1835 fn create_snapshot(&self, target: LogIndex) -> Result<Snapshot> {
1836 self.create_snapshot(target)
1837 }
1838}
1839
1840pub fn restore_snapshot_file(
1841 path: impl AsRef<Path>,
1842 snapshot: &Snapshot,
1843 target_node_id: &str,
1844) -> Result<()> {
1845 restore_snapshot_file_with_recovery_generation(path, snapshot, target_node_id, None)
1846}
1847
1848fn restore_snapshot_file_with_recovery_generation(
1849 path: impl AsRef<Path>,
1850 snapshot: &Snapshot,
1851 target_node_id: &str,
1852 recovery_generation: Option<u64>,
1853) -> Result<()> {
1854 if target_node_id.is_empty() {
1855 return Err(Error::InvalidSnapshot("target node_id is empty".into()));
1856 }
1857 let path = path.as_ref();
1858 ensure_parent(path)?;
1859 let parent = parent_dir(path);
1860 let control_path = control_sidecar_path(path);
1861 let mut wal_path = path.as_os_str().to_os_string();
1862 wal_path.push("-wal");
1863 let mut shm_path = path.as_os_str().to_os_string();
1864 shm_path.push("-shm");
1865 if path.exists()
1866 || control_path.exists()
1867 || Path::new(&wal_path).exists()
1868 || Path::new(&shm_path).exists()
1869 {
1870 return Err(Error::InvalidSnapshot(
1871 "QWAL restore destination and SQLite sidecars must not exist".into(),
1872 ));
1873 }
1874 let container = decode_qwal_snapshot(snapshot.db_bytes())?;
1875 if snapshot.manifest().executor_fingerprint() != Some(sql_executor_fingerprint()?) {
1876 return Err(Error::InvalidSnapshot(
1877 "QWAL snapshot materializer fingerprint does not match local SQLite".into(),
1878 ));
1879 }
1880 let mut restore_file = NamedTempFile::new_in(parent).map_err(io_error)?;
1881 restore_file
1882 .write_all(&container.user_db)
1883 .map_err(io_error)?;
1884 restore_file.as_file().sync_all().map_err(io_error)?;
1885
1886 {
1887 let restore_conn = Connection::open_with_flags(
1888 restore_file.path(),
1889 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1890 )
1891 .map_err(|err| Error::InvalidSnapshot(err.to_string()))?;
1892 integrity_check(&restore_conn)?;
1893 }
1894
1895 let page_state = page_state_from_database_bytes(&container.user_db)?;
1896 if page_state.identity() != container.user_state {
1897 return Err(Error::InvalidSnapshot(
1898 "QWAL snapshot database does not match its page state".into(),
1899 ));
1900 }
1901 let control_temp_dir = tempfile::tempdir_in(parent).map_err(io_error)?;
1902 let control_temp_path = control_temp_dir.path().join("control.sqlite");
1903 let control_identity = ControlIdentity::new(
1904 snapshot.manifest().cluster_id(),
1905 target_node_id,
1906 snapshot.manifest().epoch(),
1907 snapshot.manifest().configuration_state().clone(),
1908 1,
1909 sql_executor_fingerprint()?,
1910 container.user_state,
1911 );
1912 let control = ControlStore::create(&control_temp_path, &control_identity)?;
1913 control.import_replicated_snapshot_with_recovery_generation(
1914 &container.replicated_control,
1915 recovery_generation,
1916 )?;
1917 let imported_tip = control.applied_tip()?;
1918 if imported_tip.applied_index() != snapshot.manifest().index()
1919 || imported_tip.applied_hash() != snapshot.manifest().applied_hash()
1920 || control.configuration_state()? != *snapshot.manifest().configuration_state()
1921 || control.user_state()? != container.user_state
1922 || recovery_generation
1923 .is_some_and(|expected| control.recovery_generation().ok() != Some(expected))
1924 {
1925 return Err(Error::InvalidSnapshot(
1926 "QWAL snapshot manifest does not match replicated control state".into(),
1927 ));
1928 }
1929 drop(control);
1930 File::open(&control_temp_path)
1931 .and_then(|file| file.sync_all())
1932 .map_err(io_error)?;
1933
1934 let restored = restore_file
1935 .persist_noclobber(path)
1936 .map_err(|err| Error::Io(err.error.to_string()))?;
1937 restored.sync_all().map_err(io_error)?;
1938 if let Err(error) = fs::hard_link(&control_temp_path, &control_path) {
1939 drop(restored);
1940 let cleanup = fs::remove_file(path);
1941 let synced = sync_parent(parent);
1942 if let Err(cleanup_error) = cleanup {
1943 return Err(Error::Io(format!(
1944 "control publish failed ({error}); database cleanup failed ({cleanup_error})"
1945 )));
1946 }
1947 synced?;
1948 return Err(io_error(error));
1949 }
1950 sync_parent(parent)
1951}
1952
1953pub fn restore_recovery_snapshot_file(
1954 path: impl AsRef<Path>,
1955 db_bytes: &[u8],
1956 anchor: &RecoveryAnchor,
1957 target_node_id: &str,
1958) -> Result<()> {
1959 if target_node_id.is_empty() {
1960 return Err(Error::InvalidSnapshot("target node_id is empty".into()));
1961 }
1962 if anchor.snapshot().size_bytes() != db_bytes.len() as u64
1963 || anchor.snapshot().digest() != LogHash::digest(&[db_bytes])
1964 {
1965 return Err(Error::InvalidSnapshot(
1966 "recovery anchor does not match snapshot bytes".into(),
1967 ));
1968 }
1969 if let Some(fingerprint) = anchor.executor_fingerprint() {
1970 let expected = sql_executor_fingerprint()?;
1971 if fingerprint != expected {
1972 return Err(Error::InvalidSnapshot(format!(
1973 "recovery snapshot executor fingerprint {} does not match local {}",
1974 fingerprint.to_hex(),
1975 expected.to_hex()
1976 )));
1977 }
1978 } else {
1979 return Err(Error::InvalidSnapshot(
1980 "QWAL recovery snapshot is missing a materializer fingerprint".into(),
1981 ));
1982 }
1983 let manifest = SnapshotManifest::new_with_configuration(
1984 anchor.cluster_id(),
1985 anchor.configuration_state().clone(),
1986 anchor.epoch(),
1987 anchor.compacted().index(),
1988 anchor.compacted().hash(),
1989 1,
1990 target_node_id,
1991 )
1992 .with_executor_fingerprint(sql_executor_fingerprint()?);
1993 if manifest.snapshot_id() != anchor.snapshot().snapshot_id() {
1994 return Err(Error::InvalidSnapshot(
1995 "recovery snapshot id does not match compacted index".into(),
1996 ));
1997 }
1998 restore_snapshot_file_with_recovery_generation(
1999 path,
2000 &Snapshot::new(manifest, db_bytes.to_vec()),
2001 target_node_id,
2002 Some(anchor.recovery_generation()),
2003 )
2004}
2005
2006fn open_connection(path: &Path) -> Result<Connection> {
2007 let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX;
2008 let conn = Connection::open_with_flags(path, flags).map_err(sqlite_error)?;
2009 let journal_mode: String = conn
2010 .query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))
2011 .map_err(sqlite_error)?;
2012 if !journal_mode.eq_ignore_ascii_case("wal") {
2013 return Err(Error::Sqlite(format!(
2014 "SQLite refused WAL journal mode: {journal_mode}"
2015 )));
2016 }
2017 conn.pragma_update(None, "synchronous", "OFF")
2018 .map_err(sqlite_error)?;
2019 conn.pragma_update(None, "foreign_keys", "ON")
2020 .map_err(sqlite_error)?;
2021 conn.pragma_update(None, "trusted_schema", "OFF")
2022 .map_err(sqlite_error)?;
2023 conn.pragma_update(None, "wal_autocheckpoint", 0)
2024 .map_err(sqlite_error)?;
2025 Ok(conn)
2026}
2027
2028fn checkpoint_truncate(conn: &Connection) -> Result<()> {
2029 let (busy, _, _): (i64, i64, i64) = conn
2030 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
2031 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
2032 })
2033 .map_err(sqlite_error)?;
2034 if busy != 0 {
2035 return Err(Error::Sqlite("SQLite WAL checkpoint is busy".into()));
2036 }
2037 Ok(())
2038}
2039
2040#[cfg(test)]
2041#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2042struct SpeculativePrepareAudit {
2043 copy_count: usize,
2044 synchronous: i64,
2045 native_vfs: bool,
2046 no_checkpoint_on_close: bool,
2047}
2048
2049#[cfg(test)]
2050fn speculative_prepare_audits() -> &'static Mutex<Vec<(PathBuf, SpeculativePrepareAudit)>> {
2051 static AUDITS: OnceLock<Mutex<Vec<(PathBuf, SpeculativePrepareAudit)>>> = OnceLock::new();
2052 AUDITS.get_or_init(|| Mutex::new(Vec::new()))
2053}
2054
2055#[cfg(test)]
2056fn note_speculative_copy(path: &Path) {
2057 if let Ok(mut audits) = speculative_prepare_audits().lock() {
2058 audits.push((
2059 path.to_path_buf(),
2060 SpeculativePrepareAudit {
2061 copy_count: 1,
2062 synchronous: -1,
2063 native_vfs: false,
2064 no_checkpoint_on_close: false,
2065 },
2066 ));
2067 }
2068}
2069
2070#[cfg(test)]
2071fn note_native_wal_capture(path: &Path) {
2072 if let Ok(mut audits) = speculative_prepare_audits().lock() {
2073 if let Some((_, audit)) = audits.iter_mut().rev().find(|(audited, _)| audited == path) {
2074 audit.native_vfs = true;
2075 audit.no_checkpoint_on_close = true;
2076 }
2077 }
2078}
2079
2080#[cfg(test)]
2081fn note_speculative_synchronous(path: &Path, staging: &Connection) -> Result<()> {
2082 let synchronous = staging
2083 .query_row("PRAGMA synchronous", [], |row| row.get(0))
2084 .map_err(sqlite_error)?;
2085 if let Ok(mut audits) = speculative_prepare_audits().lock() {
2086 let Some((_, audit)) = audits.iter_mut().rev().find(|(audited, _)| audited == path) else {
2087 return Err(Error::Sqlite(
2088 "speculative SQLite synchronous audit is missing its copy".into(),
2089 ));
2090 };
2091 audit.synchronous = synchronous;
2092 }
2093 Ok(())
2094}
2095
2096#[cfg(test)]
2097fn speculative_prepare_audit(path: &Path) -> Option<SpeculativePrepareAudit> {
2098 speculative_prepare_audits().lock().ok().and_then(|audits| {
2099 audits
2100 .iter()
2101 .rev()
2102 .find_map(|(audited, audit)| (audited == path).then_some(*audit))
2103 })
2104}
2105
2106#[cfg(test)]
2107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2108enum PreparedInstallPath {
2109 Promoted,
2110 Patched,
2111}
2112
2113#[cfg(test)]
2114fn prepared_installs() -> &'static Mutex<Vec<(PathBuf, PreparedInstallPath)>> {
2115 static INSTALLS: OnceLock<Mutex<Vec<(PathBuf, PreparedInstallPath)>>> = OnceLock::new();
2116 INSTALLS.get_or_init(|| Mutex::new(Vec::new()))
2117}
2118
2119#[cfg(test)]
2120fn note_prepared_install(path: &Path, install: PreparedInstallPath) {
2121 if let Ok(mut installs) = prepared_installs().lock() {
2122 installs.push((path.to_path_buf(), install));
2123 }
2124}
2125
2126#[cfg(test)]
2127fn prepared_install_path(path: &Path) -> Option<PreparedInstallPath> {
2128 prepared_installs().lock().ok().and_then(|installs| {
2129 installs
2130 .iter()
2131 .rev()
2132 .find_map(|(installed, method)| (installed == path).then_some(*method))
2133 })
2134}
2135
2136#[cfg(test)]
2137#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
2138struct PreparedBaseReuseAudit {
2139 second_checkpoint_count: usize,
2140}
2141
2142#[cfg(test)]
2143fn prepared_base_reuse_audits() -> &'static Mutex<Vec<(PathBuf, PreparedBaseReuseAudit)>> {
2144 static AUDITS: OnceLock<Mutex<Vec<(PathBuf, PreparedBaseReuseAudit)>>> = OnceLock::new();
2145 AUDITS.get_or_init(|| Mutex::new(Vec::new()))
2146}
2147
2148#[cfg(test)]
2149fn begin_prepared_base_reuse_audit(path: &Path) {
2150 if let Ok(mut audits) = prepared_base_reuse_audits().lock() {
2151 audits.push((path.to_path_buf(), PreparedBaseReuseAudit::default()));
2152 }
2153}
2154
2155#[cfg(test)]
2156fn note_second_checkpoint(path: &Path) {
2157 if let Ok(mut audits) = prepared_base_reuse_audits().lock() {
2158 if let Some((_, audit)) = audits.iter_mut().rev().find(|(audited, _)| audited == path) {
2159 audit.second_checkpoint_count += 1;
2160 }
2161 }
2162}
2163
2164#[cfg(test)]
2165fn prepared_base_reuse_audit(path: &Path) -> Option<PreparedBaseReuseAudit> {
2166 prepared_base_reuse_audits().lock().ok().and_then(|audits| {
2167 audits
2168 .iter()
2169 .rev()
2170 .find_map(|(audited, audit)| (audited == path).then_some(*audit))
2171 })
2172}
2173
2174#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2175struct StagingWalSeal {
2176 len: u64,
2177 modified: Option<std::time::SystemTime>,
2178 #[cfg(unix)]
2179 unix: PreparedBaseSeal,
2180}
2181
2182impl StagingWalSeal {
2183 fn from_metadata(metadata: &fs::Metadata) -> Self {
2184 Self {
2185 len: metadata.len(),
2186 modified: metadata.modified().ok(),
2187 #[cfg(unix)]
2188 unix: unix_metadata_seal(metadata),
2189 }
2190 }
2191}
2192
2193struct StagingSidecarCleanup {
2194 path: PathBuf,
2195}
2196
2197impl StagingSidecarCleanup {
2198 fn new(path: &Path) -> Self {
2199 Self {
2200 path: path.to_path_buf(),
2201 }
2202 }
2203
2204 fn cleanup(&self) -> Result<()> {
2205 for suffix in ["-wal", "-shm"] {
2206 let sidecar = sqlite_sidecar_path(&self.path, suffix);
2207 match fs::symlink_metadata(&sidecar) {
2208 Ok(metadata) if metadata.file_type().is_file() => {
2209 fs::remove_file(&sidecar).map_err(io_error)?;
2210 }
2211 Ok(_) => {
2212 return Err(Error::InvalidEntry(format!(
2213 "owned speculative SQLite sidecar {} is not a regular file",
2214 sidecar.display()
2215 )));
2216 }
2217 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2218 Err(error) => return Err(io_error(error)),
2219 }
2220 }
2221 Ok(())
2222 }
2223}
2224
2225impl Drop for StagingSidecarCleanup {
2226 fn drop(&mut self) {
2227 for suffix in ["-wal", "-shm"] {
2228 let _ = fs::remove_file(sqlite_sidecar_path(&self.path, suffix));
2229 }
2230 }
2231}
2232
2233fn sqlite_sidecar_path(path: &Path, suffix: &str) -> PathBuf {
2234 let mut sidecar = path.as_os_str().to_os_string();
2235 sidecar.push(suffix);
2236 PathBuf::from(sidecar)
2237}
2238
2239fn sqlite_sidecars_absent(path: &Path) -> Result<bool> {
2240 for suffix in ["-wal", "-shm"] {
2241 if sqlite_sidecar_path(path, suffix)
2242 .try_exists()
2243 .map_err(io_error)?
2244 {
2245 return Ok(false);
2246 }
2247 }
2248 Ok(true)
2249}
2250
2251fn open_fresh_staging_wal(path: &Path) -> Result<Option<(File, StagingWalSeal)>> {
2252 let wal_path = sqlite_sidecar_path(path, "-wal");
2253 let mut options = OpenOptions::new();
2254 options.read(true);
2255 #[cfg(test)]
2256 options.write(true);
2257 let wal = match options.open(&wal_path) {
2258 Ok(wal) => wal,
2259 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
2260 Err(error) => return Err(io_error(error)),
2261 };
2262 let owned = wal.metadata().map_err(io_error)?;
2263 let named = fs::symlink_metadata(&wal_path).map_err(io_error)?;
2264 if !owned.file_type().is_file() || !named.file_type().is_file() {
2265 return Err(Error::InvalidEntry(
2266 "fresh speculative SQLite WAL is not a regular file".into(),
2267 ));
2268 }
2269 #[cfg(unix)]
2270 if !same_file(&owned, &named) {
2271 return Err(Error::InvalidEntry(
2272 "held speculative SQLite WAL inode is no longer named by its path".into(),
2273 ));
2274 }
2275 let seal = StagingWalSeal::from_metadata(&owned);
2276 if StagingWalSeal::from_metadata(&named) != seal {
2277 return Err(Error::InvalidEntry(
2278 "fresh speculative SQLite WAL metadata is unstable".into(),
2279 ));
2280 }
2281 Ok(Some((wal, seal)))
2282}
2283
2284fn verify_staging_wal_seal(wal: &File, seal: StagingWalSeal) -> Result<()> {
2285 if StagingWalSeal::from_metadata(&wal.metadata().map_err(io_error)?) != seal {
2286 return Err(Error::InvalidEntry(
2287 "held speculative SQLite WAL changed after capture was sealed".into(),
2288 ));
2289 }
2290 Ok(())
2291}
2292
2293#[cfg(test)]
2294#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2295enum WalCaptureFault {
2296 ChangeHeldWalAfterSeal,
2297}
2298
2299#[cfg(test)]
2300#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2301enum QwalApplyFault {
2302 AfterPage(u32),
2303 BeforeControlCommit,
2304 BeforePendingCommit,
2305}
2306
2307#[cfg(test)]
2308fn qwal_apply_faults() -> &'static Mutex<Vec<(PathBuf, QwalApplyFault)>> {
2309 static FAULTS: OnceLock<Mutex<Vec<(PathBuf, QwalApplyFault)>>> = OnceLock::new();
2310 FAULTS.get_or_init(|| Mutex::new(Vec::new()))
2311}
2312
2313#[cfg(test)]
2314fn arm_qwal_apply_fault(path: &Path, fault: QwalApplyFault) {
2315 qwal_apply_faults()
2316 .lock()
2317 .unwrap()
2318 .push((path.to_path_buf(), fault));
2319}
2320
2321#[cfg(test)]
2322fn take_qwal_apply_fault(path: &Path, matches: impl Fn(QwalApplyFault) -> bool) -> Result<bool> {
2323 let mut faults = qwal_apply_faults()
2324 .lock()
2325 .map_err(|_| Error::Sqlite("QWAL apply fault lock is poisoned".into()))?;
2326 Ok(faults
2327 .iter()
2328 .position(|(armed, fault)| armed == path && matches(*fault))
2329 .map(|position| faults.swap_remove(position))
2330 .is_some())
2331}
2332
2333#[cfg(test)]
2334fn inject_qwal_apply_fault(path: &Path, page_no: u32) -> Result<()> {
2335 if take_qwal_apply_fault(
2336 path,
2337 |fault| matches!(fault, QwalApplyFault::AfterPage(expected) if expected == page_no),
2338 )? {
2339 return Err(Error::Io(
2340 "injected failure during canonical QWAL page writes".into(),
2341 ));
2342 }
2343 Ok(())
2344}
2345
2346#[cfg(test)]
2347fn inject_qwal_control_fault(path: &Path) -> Result<()> {
2348 if take_qwal_apply_fault(path, |fault| fault == QwalApplyFault::BeforeControlCommit)? {
2349 return Err(Error::Sqlite(
2350 "injected post-install control commit failure".into(),
2351 ));
2352 }
2353 Ok(())
2354}
2355
2356#[cfg(test)]
2357fn inject_pending_commit_fault(path: &Path) -> Result<()> {
2358 if take_qwal_apply_fault(path, |fault| fault == QwalApplyFault::BeforePendingCommit)? {
2359 return Err(Error::Sqlite(
2360 "injected pending control commit failure".into(),
2361 ));
2362 }
2363 Ok(())
2364}
2365
2366#[cfg(test)]
2367fn wal_capture_faults() -> &'static Mutex<Vec<(PathBuf, WalCaptureFault)>> {
2368 static FAULTS: OnceLock<Mutex<Vec<(PathBuf, WalCaptureFault)>>> = OnceLock::new();
2369 FAULTS.get_or_init(|| Mutex::new(Vec::new()))
2370}
2371
2372#[cfg(test)]
2373fn arm_wal_capture_fault(path: &Path, fault: WalCaptureFault) {
2374 wal_capture_faults()
2375 .lock()
2376 .unwrap()
2377 .push((path.to_path_buf(), fault));
2378}
2379
2380#[cfg(test)]
2381fn inject_wal_capture_fault(path: &Path, held_wal: Option<&(File, StagingWalSeal)>) -> Result<()> {
2382 let fault = {
2383 let mut faults = wal_capture_faults()
2384 .lock()
2385 .map_err(|_| Error::Sqlite("WAL capture fault lock is poisoned".into()))?;
2386 faults
2387 .iter()
2388 .position(|(armed, _)| armed == path)
2389 .map(|position| faults.swap_remove(position).1)
2390 };
2391 match (fault, held_wal) {
2392 (Some(WalCaptureFault::ChangeHeldWalAfterSeal), Some((wal, seal))) => wal
2393 .set_len(
2394 seal.len
2395 .checked_add(1)
2396 .ok_or_else(|| Error::ResourceExhausted("test WAL length overflow".into()))?,
2397 )
2398 .map_err(io_error),
2399 (Some(_), None) => Err(Error::InvalidEntry(
2400 "test expected a held speculative SQLite WAL".into(),
2401 )),
2402 (None, _) => Ok(()),
2403 }
2404}
2405
2406fn materialize_wal_capture(
2407 base: &File,
2408 target_path: &Path,
2409 expected_page_size: u32,
2410 base_file_bytes: u64,
2411 capture: WalCapture,
2412) -> Result<Vec<QwalPageV3>> {
2413 let WalCapture::Committed(WalCommit {
2414 page_size,
2415 target_db_pages,
2416 target_file_bytes,
2417 pages: captured_pages,
2418 }) = capture
2419 else {
2420 if fs::metadata(target_path).map_err(io_error)?.len() != base_file_bytes
2421 || sqlite_page_size(target_path)? != expected_page_size
2422 {
2423 return Err(Error::InvalidEntry(
2424 "no-change SQLite WAL capture does not reproduce its closed base".into(),
2425 ));
2426 }
2427 return Ok(Vec::new());
2428 };
2429 if page_size != expected_page_size {
2430 return Err(Error::InvalidEntry(
2431 "SQLite WAL page size differs from its closed base".into(),
2432 ));
2433 }
2434 let expected_target_bytes = u64::from(target_db_pages)
2435 .checked_mul(u64::from(page_size))
2436 .ok_or_else(|| Error::InvalidEntry("SQLite WAL target size overflows".into()))?;
2437 if target_file_bytes != expected_target_bytes {
2438 return Err(Error::InvalidEntry(
2439 "SQLite WAL target size does not match its commit page count".into(),
2440 ));
2441 }
2442
2443 let page_bytes = u64::from(page_size);
2444 let base_pages = base_file_bytes / page_bytes;
2445 let mut base = base.try_clone().map_err(io_error)?;
2446 let mut target = OpenOptions::new()
2447 .read(true)
2448 .write(true)
2449 .open(target_path)
2450 .map_err(io_error)?;
2451 target.set_len(target_file_bytes).map_err(io_error)?;
2452 let mut changed = Vec::with_capacity(captured_pages.len());
2453 let mut base_page = vec![0; page_size as usize];
2454 for page in captured_pages {
2455 let page_no = u64::from(page.page_no);
2456 let offset = page_no
2457 .checked_sub(1)
2458 .and_then(|index| index.checked_mul(page_bytes))
2459 .ok_or_else(|| Error::InvalidEntry("SQLite WAL page offset overflows".into()))?;
2460 let differs_from_base = if page_no <= base_pages {
2461 base.seek(SeekFrom::Start(offset)).map_err(io_error)?;
2462 base.read_exact(&mut base_page).map_err(io_error)?;
2463 base_page != page.after_image
2464 } else {
2465 true
2466 };
2467 target.seek(SeekFrom::Start(offset)).map_err(io_error)?;
2468 target.write_all(&page.after_image).map_err(io_error)?;
2469 if differs_from_base {
2470 changed.push(QwalPageV3 {
2471 page_no: u32::try_from(page_no)
2472 .map_err(|_| Error::ResourceExhausted("QWAL page count exceeds u32".into()))?,
2473 after_image: page.after_image,
2474 });
2475 }
2476 }
2477 drop(target);
2478
2479 if fs::metadata(target_path).map_err(io_error)?.len() != target_file_bytes
2482 || sqlite_page_size(target_path)? != page_size
2483 {
2484 return Err(Error::InvalidEntry(
2485 "materialized SQLite WAL does not match its committed header and target size".into(),
2486 ));
2487 }
2488 Ok(changed)
2489}
2490
2491fn open_file_digest(file: &File) -> Result<LogHash> {
2492 let mut file = file.try_clone().map_err(io_error)?;
2493 file.seek(SeekFrom::Start(0)).map_err(io_error)?;
2494 let mut hasher = Sha256::new();
2495 let mut buffer = [0u8; 64 * 1024];
2496 loop {
2497 let read = file.read(&mut buffer).map_err(io_error)?;
2498 if read == 0 {
2499 break;
2500 }
2501 hasher.update(&buffer[..read]);
2502 }
2503 Ok(LogHash::from_bytes(hasher.finalize().into()))
2504}
2505
2506fn file_digest(path: impl AsRef<Path>) -> Result<LogHash> {
2507 open_file_digest(&File::open(path.as_ref()).map_err(io_error)?)
2508}
2509
2510fn page_state_from_database_bytes(bytes: &[u8]) -> Result<PageStateCacheV3> {
2511 let header = bytes
2512 .get(..100)
2513 .ok_or_else(|| Error::InvalidEntry("SQLite database header is truncated".into()))?;
2514 let file_bytes = u64::try_from(bytes.len())
2515 .map_err(|_| Error::ResourceExhausted("SQLite database size exceeds u64".into()))?;
2516 let page_size = qwal::sqlite_page_size_from_header(header, file_bytes)?;
2517 PageStateCacheV3::from_pages(page_size, bytes.chunks_exact(page_size as usize))
2518}
2519
2520#[cfg(test)]
2521fn rebuild_page_state(path: &Path) -> Result<PageStateCacheV3> {
2522 page_state_from_database_bytes(&fs::read(path).map_err(io_error)?)
2523}
2524
2525fn rebuild_sealed_page_state(path: &Path) -> Result<CanonicalPageStateV3> {
2526 if !sqlite_sidecars_absent(path)? {
2527 return Err(Error::InvalidEntry(
2528 "closed canonical SQLite database has WAL sidecars during page-state rebuild".into(),
2529 ));
2530 }
2531 let named_before = fs::symlink_metadata(path).map_err(io_error)?;
2532 let mut file = File::open(path).map_err(io_error)?;
2533 let owned_before = file.metadata().map_err(io_error)?;
2534 let seal = prepared_base_seal(&owned_before, &named_before)?;
2535 let expected_bytes = owned_before.len();
2536 let cache = rebuild_page_state_from_file(&mut file, expected_bytes)?;
2537 let owned_after = file.metadata().map_err(io_error)?;
2538 let named_after = fs::symlink_metadata(path).map_err(io_error)?;
2539 if !prepared_base_metadata_matches(seal.as_ref(), &owned_after, &named_after)
2540 || owned_after.len() != expected_bytes
2541 || !sqlite_sidecars_absent(path)?
2542 {
2543 return Err(Error::InvalidEntry(
2544 "SQLite database changed while rebuilding page state".into(),
2545 ));
2546 }
2547 Ok(CanonicalPageStateV3 { cache, seal })
2548}
2549
2550fn rebuild_page_state_from_file(file: &mut File, expected_bytes: u64) -> Result<PageStateCacheV3> {
2551 let mut header = [0_u8; 100];
2552 file.read_exact(&mut header).map_err(io_error)?;
2553 let page_size = qwal::sqlite_page_size_from_header(&header, expected_bytes)?;
2554 file.seek(SeekFrom::Start(0)).map_err(io_error)?;
2555 let mut remaining = expected_bytes / u64::from(page_size);
2556 let mut stream_error = None;
2557 let cache = PageStateCacheV3::from_pages(
2558 page_size,
2559 std::iter::from_fn(|| {
2560 if remaining == 0 || stream_error.is_some() {
2561 return None;
2562 }
2563 let mut page = vec![0; page_size as usize];
2564 match file.read_exact(&mut page) {
2565 Ok(()) => {
2566 remaining -= 1;
2567 Some(page)
2568 }
2569 Err(error) => {
2570 stream_error = Some(io_error(error));
2571 None
2572 }
2573 }
2574 }),
2575 )?;
2576 if let Some(error) = stream_error {
2577 return Err(error);
2578 }
2579 Ok(cache)
2580}
2581
2582fn open_bound_canonical(path: &Path, page_state: &CanonicalPageStateV3) -> Result<File> {
2583 let file = OpenOptions::new()
2584 .read(true)
2585 .write(true)
2586 .open(path)
2587 .map_err(io_error)?;
2588 let owned = file.metadata().map_err(io_error)?;
2589 let named = fs::symlink_metadata(path).map_err(io_error)?;
2590 if !prepared_base_metadata_matches(page_state.seal.as_ref(), &owned, &named) {
2591 return Err(Error::InvalidEntry(
2592 "canonical SQLite file no longer matches the cached page-state seal".into(),
2593 ));
2594 }
2595 Ok(file)
2596}
2597
2598fn verify_bound_canonical(path: &Path, page_state: &CanonicalPageStateV3) -> Result<()> {
2599 open_bound_canonical(path, page_state).map(drop)
2600}
2601
2602fn seal_held_canonical(path: &Path, file: &File) -> Result<Option<PreparedBaseSeal>> {
2603 if !sqlite_sidecars_absent(path)? {
2604 return Err(Error::InvalidEntry(
2605 "closed canonical SQLite database has WAL sidecars while refreshing its seal".into(),
2606 ));
2607 }
2608 let owned_before = file.metadata().map_err(io_error)?;
2609 let named_before = fs::symlink_metadata(path).map_err(io_error)?;
2610 let seal = prepared_base_seal(&owned_before, &named_before)?;
2611 let owned_after = file.metadata().map_err(io_error)?;
2612 let named_after = fs::symlink_metadata(path).map_err(io_error)?;
2613 if !prepared_base_metadata_matches(seal.as_ref(), &owned_after, &named_after)
2614 || !sqlite_sidecars_absent(path)?
2615 {
2616 return Err(Error::InvalidEntry(
2617 "canonical SQLite file changed while refreshing its page-state seal".into(),
2618 ));
2619 }
2620 Ok(seal)
2621}
2622
2623fn clone_or_copy_to_temp(base: &Path) -> Result<NamedTempFile> {
2624 const COW_CLONE_MIN_BYTES: u64 = 256 * 1024;
2625 let placeholder = NamedTempFile::new_in(parent_dir(base)).map_err(io_error)?;
2626 let (placeholder_file, temp_path) = placeholder.into_parts();
2627 drop(placeholder_file);
2628 fs::remove_file(&temp_path).map_err(io_error)?;
2629
2630 let clone_result = if fs::metadata(base).map_err(io_error)?.len() >= COW_CLONE_MIN_BYTES {
2631 try_platform_clone(base, &temp_path)
2632 } else {
2633 Err(io::Error::new(
2634 io::ErrorKind::Unsupported,
2635 "small SQLite bases are cheaper to copy than clone",
2636 ))
2637 };
2638 let file = match clone_result {
2639 Ok(file) => file,
2640 Err(_) => {
2641 if temp_path.exists() {
2642 fs::remove_file(&temp_path).map_err(io_error)?;
2643 }
2644 fs::copy(base, &temp_path).map_err(io_error)?;
2645 OpenOptions::new()
2646 .read(true)
2647 .write(true)
2648 .open(&temp_path)
2649 .map_err(io_error)?
2650 }
2651 };
2652 Ok(NamedTempFile::from_parts(file, temp_path))
2653}
2654
2655#[cfg(target_os = "macos")]
2656fn try_platform_clone(base: &Path, target: &Path) -> io::Result<File> {
2657 use std::{ffi::CString, os::unix::ffi::OsStrExt};
2658
2659 unsafe extern "C" {
2660 fn clonefile(
2661 source: *const std::os::raw::c_char,
2662 target: *const std::os::raw::c_char,
2663 flags: u32,
2664 ) -> i32;
2665 }
2666
2667 let source = CString::new(base.as_os_str().as_bytes())
2668 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "source path contains NUL"))?;
2669 let target_c = CString::new(target.as_os_str().as_bytes())
2670 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "target path contains NUL"))?;
2671 if unsafe { clonefile(source.as_ptr(), target_c.as_ptr(), 0) } != 0 {
2673 return Err(io::Error::last_os_error());
2674 }
2675 OpenOptions::new().read(true).write(true).open(target)
2676}
2677
2678#[cfg(target_os = "linux")]
2679fn try_platform_clone(base: &Path, target: &Path) -> io::Result<File> {
2680 use std::os::{fd::AsRawFd, raw::c_ulong};
2681
2682 unsafe extern "C" {
2683 fn ioctl(fd: std::os::raw::c_int, request: c_ulong, ...) -> std::os::raw::c_int;
2684 }
2685
2686 const FICLONE: c_ulong = 0x4004_9409;
2687 let source = File::open(base)?;
2688 let cloned = OpenOptions::new()
2689 .read(true)
2690 .write(true)
2691 .create_new(true)
2692 .open(target)?;
2693 if unsafe { ioctl(cloned.as_raw_fd(), FICLONE, source.as_raw_fd()) } == 0 {
2695 return Ok(cloned);
2696 }
2697 let error = io::Error::last_os_error();
2698 drop(cloned);
2699 let _ = fs::remove_file(target);
2700 Err(error)
2701}
2702
2703#[cfg(not(any(target_os = "macos", target_os = "linux")))]
2704fn try_platform_clone(_base: &Path, _target: &Path) -> io::Result<File> {
2705 Err(io::Error::new(
2706 io::ErrorKind::Unsupported,
2707 "copy-on-write cloning is not supported on this platform",
2708 ))
2709}
2710
2711fn open_sealed_prepared_base(path: &Path) -> Result<(File, Option<PreparedBaseSeal>)> {
2712 let named_before = fs::symlink_metadata(path).map_err(io_error)?;
2713 if !named_before.file_type().is_file() {
2714 return Err(Error::InvalidEntry(
2715 "closed SQLite base is not a regular file".into(),
2716 ));
2717 }
2718 let file = File::open(path).map_err(io_error)?;
2719 let owned_before = file.metadata().map_err(io_error)?;
2720 if !owned_before.file_type().is_file() {
2721 return Err(Error::InvalidEntry(
2722 "owned SQLite base is not a regular file".into(),
2723 ));
2724 }
2725 let seal = prepared_base_seal(&owned_before, &named_before)?;
2726 if !sqlite_sidecars_absent(path)? {
2727 return Err(Error::InvalidEntry(
2728 "closed SQLite base still has WAL sidecars".into(),
2729 ));
2730 }
2731 let owned_after = file.metadata().map_err(io_error)?;
2732 let named_after = fs::symlink_metadata(path).map_err(io_error)?;
2733 if !prepared_base_metadata_matches(seal.as_ref(), &owned_after, &named_after)
2734 || !sqlite_sidecars_absent(path)?
2735 {
2736 return Err(Error::InvalidEntry(
2737 "closed SQLite base changed while it was sealed".into(),
2738 ));
2739 }
2740 Ok((file, seal))
2741}
2742
2743#[cfg(unix)]
2744fn prepared_base_seal(
2745 owned: &fs::Metadata,
2746 named: &fs::Metadata,
2747) -> Result<Option<PreparedBaseSeal>> {
2748 if !owned_regular_file_still_named(owned, named) {
2749 return Err(Error::InvalidEntry(
2750 "owned SQLite base inode is no longer named by its path".into(),
2751 ));
2752 }
2753 let seal = unix_metadata_seal(owned);
2754 if unix_metadata_seal(named) != seal {
2755 return Err(Error::InvalidEntry(
2756 "owned SQLite base metadata is not stable".into(),
2757 ));
2758 }
2759 Ok(Some(seal))
2760}
2761
2762#[cfg(not(unix))]
2763fn prepared_base_seal(
2764 _owned: &fs::Metadata,
2765 _named: &fs::Metadata,
2766) -> Result<Option<PreparedBaseSeal>> {
2767 Ok(None)
2768}
2769
2770#[cfg(unix)]
2771fn unix_metadata_seal(metadata: &fs::Metadata) -> PreparedBaseSeal {
2772 use std::os::unix::fs::MetadataExt;
2773
2774 PreparedBaseSeal {
2775 dev: metadata.dev(),
2776 ino: metadata.ino(),
2777 len: metadata.len(),
2778 mtime: metadata.mtime(),
2779 mtime_nsec: metadata.mtime_nsec(),
2780 ctime: metadata.ctime(),
2781 ctime_nsec: metadata.ctime_nsec(),
2782 }
2783}
2784
2785#[cfg(unix)]
2786fn prepared_base_metadata_matches(
2787 seal: Option<&PreparedBaseSeal>,
2788 owned: &fs::Metadata,
2789 named: &fs::Metadata,
2790) -> bool {
2791 seal.is_some_and(|seal| {
2792 owned_regular_file_still_named(owned, named)
2793 && unix_metadata_seal(owned) == *seal
2794 && unix_metadata_seal(named) == *seal
2795 })
2796}
2797
2798#[cfg(not(unix))]
2799fn prepared_base_metadata_matches(
2800 _seal: Option<&PreparedBaseSeal>,
2801 _owned: &fs::Metadata,
2802 _named: &fs::Metadata,
2803) -> bool {
2804 false
2805}
2806
2807fn prepared_base_still_sealed(path: &Path, prepared: &PreparedTarget) -> Result<bool> {
2808 let Some(named) = symlink_metadata_if_exists(path)? else {
2809 return Ok(false);
2810 };
2811 let owned = prepared.base_file.metadata().map_err(io_error)?;
2812 Ok(
2813 prepared_base_metadata_matches(prepared.base_seal.as_ref(), &owned, &named)
2814 && sqlite_sidecars_absent(path)?,
2815 )
2816}
2817
2818fn symlink_metadata_if_exists(path: &Path) -> Result<Option<fs::Metadata>> {
2819 match fs::symlink_metadata(path) {
2820 Ok(metadata) => Ok(Some(metadata)),
2821 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2822 Err(error) => Err(io_error(error)),
2823 }
2824}
2825
2826#[cfg(unix)]
2827fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
2828 use std::os::unix::fs::MetadataExt;
2829
2830 left.dev() == right.dev() && left.ino() == right.ino()
2831}
2832
2833#[cfg(not(unix))]
2834fn same_file(_left: &fs::Metadata, _right: &fs::Metadata) -> bool {
2835 false
2838}
2839
2840fn owned_regular_file_still_named(owned: &fs::Metadata, named: &fs::Metadata) -> bool {
2841 owned.file_type().is_file() && named.file_type().is_file() && same_file(owned, named)
2842}
2843
2844fn control_sidecar_path(path: &Path) -> PathBuf {
2845 let mut sidecar = path.as_os_str().to_os_string();
2846 sidecar.push(".control");
2847 PathBuf::from(sidecar)
2848}
2849
2850fn reject_legacy_user_database(path: &Path) -> Result<()> {
2851 let conn = Connection::open_with_flags(
2852 path,
2853 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
2854 )
2855 .map_err(sqlite_error)?;
2856 let legacy: Option<String> = conn
2857 .query_row(
2858 "SELECT name FROM sqlite_schema WHERE name IN ('__rhiza_meta', '__rhiza_requests') LIMIT 1",
2859 [],
2860 |row| row.get(0),
2861 )
2862 .optional()
2863 .map_err(sqlite_error)?;
2864 if let Some(table) = legacy {
2865 return Err(Error::IdentityMismatch(format!(
2866 "legacy table {table} requires snapshot bootstrap into QWAL storage"
2867 )));
2868 }
2869 Ok(())
2870}
2871
2872fn validate_control_database_pair(
2873 path: &Path,
2874 control: &ControlStore,
2875) -> Result<(CanonicalPageStateV3, bool)> {
2876 let page_state = rebuild_sealed_page_state(path)?;
2877 let actual = page_state.cache.identity();
2878 let pending = control.pending()?;
2879 if let Some(pending) = pending.as_ref() {
2880 let tip = control.applied_tip()?;
2881 let expected_entry_index = tip
2882 .applied_index()
2883 .checked_add(1)
2884 .ok_or_else(|| Error::InvalidEntry("pending QWAL entry index is exhausted".into()))?;
2885 if pending.base() != LogAnchor::new(tip.applied_index(), tip.applied_hash())
2886 || pending.base_state() != control.user_state()?
2887 || pending.entry().index() != expected_entry_index
2888 {
2889 return Err(Error::InvalidEntry(
2890 "pending QWAL intent does not extend the committed control state".into(),
2891 ));
2892 }
2893 if actual == pending.base_state() {
2894 return Ok((page_state, true));
2895 }
2896 if actual == pending.target_state() {
2897 let verify = Connection::open_with_flags(
2898 path,
2899 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
2900 )
2901 .map_err(sqlite_error)?;
2902 integrity_check(&verify)?;
2903 return Ok((page_state, true));
2904 }
2905 return Err(Error::InvalidEntry(
2906 "pending QWAL database state matches neither base nor target".into(),
2907 ));
2908 }
2909 if actual != control.user_state()? {
2910 return Err(Error::InvalidEntry(
2911 "canonical SQLite page state does not match the control sidecar".into(),
2912 ));
2913 }
2914 Ok((page_state, false))
2915}
2916
2917fn decode_qwal_command(payload: &[u8]) -> Result<QwalEnvelopeV3> {
2918 if !payload.starts_with(QWAL_V3_MAGIC) {
2919 return Err(Error::InvalidCommand(
2920 "QWAL-only SQLite apply requires a QWAL v3 payload".into(),
2921 ));
2922 }
2923 decode_qwal_v3(payload)
2924}
2925
2926fn validate_qwal_identity(
2927 effect: &QwalEnvelopeV3,
2928 identity: &ControlIdentity,
2929 configuration: &ConfigurationState,
2930) -> Result<()> {
2931 if effect.cluster_id != identity.cluster_id()
2932 || effect.epoch != identity.epoch()
2933 || effect.configuration_id != configuration.config_id()
2934 || effect.recovery_generation != identity.recovery_generation()
2935 || effect.materializer_fingerprint != identity.materializer_fingerprint().to_hex()
2936 || effect.base_state != identity.user_state()
2937 {
2938 return Err(Error::InvalidEntry(
2939 "QWAL effect identity or materializer fingerprint mismatch".into(),
2940 ));
2941 }
2942 Ok(())
2943}
2944
2945fn encode_qwal_snapshot(snapshot: &QwalSnapshotV3) -> Result<Vec<u8>> {
2946 let body = postcard::to_allocvec(snapshot)
2947 .map_err(|error| Error::InvalidSnapshot(format!("QSNP encode failed: {error}")))?;
2948 let mut encoded = Vec::with_capacity(QWAL_SNAPSHOT_V3_MAGIC.len() + body.len());
2949 encoded.extend_from_slice(QWAL_SNAPSHOT_V3_MAGIC);
2950 encoded.extend_from_slice(&body);
2951 Ok(encoded)
2952}
2953
2954fn decode_qwal_snapshot(encoded: &[u8]) -> Result<QwalSnapshotV3> {
2955 let body = encoded
2956 .strip_prefix(QWAL_SNAPSHOT_V3_MAGIC)
2957 .ok_or_else(|| Error::InvalidSnapshot("QWAL snapshot magic is missing".into()))?;
2958 let snapshot: QwalSnapshotV3 = postcard::from_bytes(body)
2959 .map_err(|error| Error::InvalidSnapshot(format!("QSNP decode failed: {error}")))?;
2960 if postcard::to_allocvec(&snapshot)
2961 .map_err(|error| Error::InvalidSnapshot(format!("QSNP re-encode failed: {error}")))?
2962 != body
2963 {
2964 return Err(Error::InvalidSnapshot(
2965 "QWAL snapshot is not canonically encoded".into(),
2966 ));
2967 }
2968 Ok(snapshot)
2969}
2970
2971#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2972enum SqlAuthorizationMode {
2973 ReadOnly,
2974 PhysicalWrite,
2975}
2976
2977fn execute_sql_statements(
2978 conn: &Connection,
2979 statements: &[SqlStatement],
2980) -> Result<SqlCommandResult> {
2981 let result = with_sql_authorizer(conn, SqlAuthorizationMode::PhysicalWrite, || {
2982 let mut statement_results = Vec::with_capacity(statements.len());
2983 let mut returning_rows = 0usize;
2984 let mut returning_bytes = 0usize;
2985 for operation in statements {
2986 let mut statement = conn.prepare(&operation.sql).map_err(sqlite_error)?;
2987 let column_count = statement.column_count();
2988 let total_changes_before = conn.total_changes();
2989 let returning = if column_count == 0 {
2990 statement
2991 .execute(params_from_iter(operation.parameters.iter()))
2992 .map_err(sqlite_error)?;
2993 None
2994 } else {
2995 let columns = statement
2996 .column_names()
2997 .into_iter()
2998 .map(str::to_owned)
2999 .collect::<Vec<_>>();
3000 for column in &columns {
3001 add_returning_bytes(&mut returning_bytes, column.len())?;
3002 }
3003 let mut result_rows = Vec::new();
3004 {
3005 let mut rows = statement
3006 .query(params_from_iter(operation.parameters.iter()))
3007 .map_err(sqlite_error)?;
3008 while let Some(row) = rows.next().map_err(sqlite_error)? {
3009 returning_rows = returning_rows.checked_add(1).ok_or_else(|| {
3010 Error::InvalidCommand("SQL RETURNING row count overflow".into())
3011 })?;
3012 if returning_rows > MAX_RETURNING_ROWS {
3013 return Err(Error::InvalidCommand(format!(
3014 "SQL RETURNING exceeds {MAX_RETURNING_ROWS} rows"
3015 )));
3016 }
3017 let mut values = Vec::with_capacity(column_count);
3018 for column in 0..column_count {
3019 let value = sql_value(row.get_ref(column).map_err(sqlite_error)?)?;
3020 add_returning_bytes(&mut returning_bytes, sql_value_size(&value))?;
3021 values.push(value);
3022 }
3023 result_rows.push(values);
3024 }
3025 }
3026 Some(SqlQueryResult {
3027 columns,
3028 rows: result_rows,
3029 })
3030 };
3031 let total_changes = conn
3032 .total_changes()
3033 .checked_sub(total_changes_before)
3034 .ok_or_else(|| Error::Sqlite("SQLite total_changes moved backwards".into()))?;
3035 let rows_affected = if total_changes == 0 {
3036 0
3037 } else {
3038 conn.changes()
3039 };
3040 statement_results.push(SqlStatementResult {
3041 rows_affected,
3042 returning,
3043 });
3044 }
3045 validate_temp_schema_empty(conn)?;
3046 Ok(SqlCommandResult { statement_results })
3047 })?;
3048 validate_reserved_schema(conn)?;
3049 Ok(result)
3050}
3051
3052fn add_returning_bytes(total: &mut usize, bytes: usize) -> Result<()> {
3053 *total = total
3054 .checked_add(bytes)
3055 .ok_or_else(|| Error::InvalidCommand("SQL RETURNING result size overflow".into()))?;
3056 if *total > MAX_RETURNING_BYTES {
3057 return Err(Error::InvalidCommand(format!(
3058 "SQL RETURNING exceeds {MAX_RETURNING_BYTES} result bytes"
3059 )));
3060 }
3061 Ok(())
3062}
3063
3064fn decode_sql_command(payload: &[u8]) -> Result<SqlCommand> {
3065 let encoded = payload
3066 .strip_prefix(SQL_COMMAND_V2_MAGIC)
3067 .ok_or_else(|| Error::InvalidCommand("canonical QSQL v2 magic is missing".into()))?;
3068 let envelope: SqlCommandV2Envelope = serde_json::from_slice(encoded)
3069 .map_err(|error| Error::InvalidCommand(format!("invalid SQL command: {error}")))?;
3070 let expected = sql_executor_fingerprint()?;
3071 if envelope.executor_fingerprint != expected {
3072 return Err(Error::InvalidCommand(format!(
3073 "SQL executor fingerprint {} does not match local {}",
3074 envelope.executor_fingerprint.to_hex(),
3075 expected.to_hex()
3076 )));
3077 }
3078 let command = envelope.command;
3079 validate_sql_command(&command)?;
3080 if encode_sql_command(&command)? != payload {
3081 return Err(Error::InvalidCommand(
3082 "QSQL v2 command is not canonically encoded".into(),
3083 ));
3084 }
3085 Ok(command)
3086}
3087
3088fn validate_sql_command(command: &SqlCommand) -> Result<()> {
3089 if command.request_id.is_empty() || command.request_id.len() > 256 {
3090 return Err(Error::InvalidCommand(
3091 "SQL request_id must contain 1..=256 bytes".into(),
3092 ));
3093 }
3094 if command.statements.is_empty() || command.statements.len() > MAX_SQL_STATEMENTS {
3095 return Err(Error::InvalidCommand(format!(
3096 "SQL command must contain 1..={MAX_SQL_STATEMENTS} statements"
3097 )));
3098 }
3099 for statement in &command.statements {
3100 validate_sql_statement(statement)?;
3101 }
3102 Ok(())
3103}
3104
3105fn validate_sql_statement(statement: &SqlStatement) -> Result<()> {
3106 if statement.sql.trim().is_empty() || statement.sql.len() > MAX_SQL_TEXT_BYTES {
3107 return Err(Error::InvalidCommand(format!(
3108 "SQL text must contain 1..={MAX_SQL_TEXT_BYTES} bytes"
3109 )));
3110 }
3111 if statement.sql.contains('\0') {
3112 return Err(Error::InvalidCommand(
3113 "SQL text must not contain NUL".into(),
3114 ));
3115 }
3116 if statement.parameters.len() > MAX_SQL_PARAMETERS {
3117 return Err(Error::InvalidCommand(format!(
3118 "SQL statement exceeds {MAX_SQL_PARAMETERS} parameters"
3119 )));
3120 }
3121 if statement
3122 .parameters
3123 .iter()
3124 .any(|value| matches!(value, SqlValue::Real(number) if !number.is_finite()))
3125 {
3126 return Err(Error::InvalidCommand(
3127 "SQL real parameters must be finite".into(),
3128 ));
3129 }
3130 Ok(())
3131}
3132
3133fn with_sql_authorizer<T>(
3134 conn: &Connection,
3135 mode: SqlAuthorizationMode,
3136 operation: impl FnOnce() -> Result<T>,
3137) -> Result<T> {
3138 conn.authorizer(Some(move |context: AuthContext<'_>| {
3139 authorize_sql(context, mode)
3140 }))
3141 .map_err(sqlite_error)?;
3142 let result = operation();
3143 let cleared = conn
3144 .authorizer(None::<fn(AuthContext<'_>) -> Authorization>)
3145 .map_err(sqlite_error);
3146 match (result, cleared) {
3147 (Ok(value), Ok(())) => Ok(value),
3148 (Err(error), _) => Err(error),
3149 (Ok(_), Err(error)) => Err(error),
3150 }
3151}
3152
3153fn authorize_sql(context: AuthContext<'_>, mode: SqlAuthorizationMode) -> Authorization {
3154 if context.database_name.is_some_and(|name| {
3155 name != "main" && !(mode == SqlAuthorizationMode::PhysicalWrite && name == "temp")
3156 }) {
3157 return Authorization::Deny;
3158 }
3159 match context.action {
3160 AuthAction::Unknown { .. }
3161 | AuthAction::Transaction { .. }
3162 | AuthAction::Attach { .. }
3163 | AuthAction::Detach { .. }
3164 | AuthAction::Savepoint { .. } => Authorization::Deny,
3165 AuthAction::CreateTempIndex { .. }
3166 | AuthAction::CreateTempTable { .. }
3167 | AuthAction::CreateTempTrigger { .. }
3168 | AuthAction::CreateTempView { .. }
3169 | AuthAction::DropTempIndex { .. }
3170 | AuthAction::DropTempTable { .. }
3171 | AuthAction::DropTempTrigger { .. }
3172 | AuthAction::DropTempView { .. }
3173 if mode == SqlAuthorizationMode::PhysicalWrite =>
3174 {
3175 Authorization::Allow
3176 }
3177 AuthAction::Pragma {
3178 pragma_name,
3179 pragma_value,
3180 } if authorized_pragma(mode, pragma_name, pragma_value) => Authorization::Allow,
3181 AuthAction::Pragma { .. } => Authorization::Deny,
3182 AuthAction::CreateVtable { module_name, .. }
3183 | AuthAction::DropVtable { module_name, .. }
3184 if mode == SqlAuthorizationMode::PhysicalWrite && bundled_vtable(module_name) =>
3185 {
3186 Authorization::Allow
3187 }
3188 AuthAction::CreateVtable { .. } | AuthAction::DropVtable { .. } => Authorization::Deny,
3189 AuthAction::Function { function_name } if unsafe_sql_function(function_name) => {
3190 Authorization::Deny
3191 }
3192 AuthAction::CreateIndex {
3193 index_name,
3194 table_name,
3195 }
3196 | AuthAction::DropIndex {
3197 index_name,
3198 table_name,
3199 } if reserved_name(index_name) || reserved_name(table_name) => Authorization::Deny,
3200 AuthAction::CreateTrigger {
3201 trigger_name,
3202 table_name,
3203 }
3204 | AuthAction::DropTrigger {
3205 trigger_name,
3206 table_name,
3207 } if reserved_name(trigger_name) || reserved_name(table_name) => Authorization::Deny,
3208 AuthAction::CreateTable { table_name }
3209 | AuthAction::Delete { table_name }
3210 | AuthAction::DropTable { table_name }
3211 | AuthAction::Insert { table_name }
3212 | AuthAction::Read { table_name, .. }
3213 | AuthAction::Update { table_name, .. }
3214 | AuthAction::AlterTable { table_name, .. }
3215 | AuthAction::Analyze { table_name }
3216 if reserved_name(table_name) =>
3217 {
3218 Authorization::Deny
3219 }
3220 AuthAction::CreateView { view_name } | AuthAction::DropView { view_name }
3221 if reserved_name(view_name) =>
3222 {
3223 Authorization::Deny
3224 }
3225 AuthAction::Reindex { index_name } if reserved_name(index_name) => Authorization::Deny,
3226 _ => Authorization::Allow,
3227 }
3228}
3229
3230fn authorized_pragma(mode: SqlAuthorizationMode, name: &str, value: Option<&str>) -> bool {
3231 if observational_pragma(name, value) {
3232 return true;
3233 }
3234 mode == SqlAuthorizationMode::PhysicalWrite
3235 && ((matches_ignore_ascii_case(name, &["application_id", "user_version"])
3236 && value.is_some())
3237 || (name.eq_ignore_ascii_case("optimize")
3238 && value.is_none_or(|value| {
3239 value.parse::<u32>().is_ok()
3240 || value
3241 .strip_prefix("0x")
3242 .or_else(|| value.strip_prefix("0X"))
3243 .is_some_and(|hex| u32::from_str_radix(hex, 16).is_ok())
3244 })))
3245}
3246
3247fn observational_pragma(name: &str, value: Option<&str>) -> bool {
3248 const ARGUMENT_SAFE: &[&str] = &[
3249 "foreign_key_check",
3250 "foreign_key_list",
3251 "index_info",
3252 "index_list",
3253 "index_xinfo",
3254 "integrity_check",
3255 "quick_check",
3256 "table_info",
3257 "table_list",
3258 "table_xinfo",
3259 ];
3260 const NO_ARGUMENT_ONLY: &[&str] = &[
3261 "analysis_limit",
3262 "application_id",
3263 "auto_vacuum",
3264 "automatic_index",
3265 "busy_timeout",
3266 "cache_size",
3267 "cache_spill",
3268 "case_sensitive_like",
3269 "cell_size_check",
3270 "checkpoint_fullfsync",
3271 "collation_list",
3272 "compile_options",
3273 "count_changes",
3274 "data_version",
3275 "default_cache_size",
3276 "defer_foreign_keys",
3277 "empty_result_callbacks",
3278 "encoding",
3279 "freelist_count",
3280 "foreign_keys",
3281 "full_column_names",
3282 "fullfsync",
3283 "function_list",
3284 "hard_heap_limit",
3285 "ignore_check_constraints",
3286 "journal_size_limit",
3287 "legacy_alter_table",
3288 "locking_mode",
3289 "max_page_count",
3290 "mmap_size",
3291 "module_list",
3292 "page_count",
3293 "page_size",
3294 "pragma_list",
3295 "query_only",
3296 "read_uncommitted",
3297 "recursive_triggers",
3298 "reverse_unordered_selects",
3299 "schema_version",
3300 "secure_delete",
3301 "short_column_names",
3302 "soft_heap_limit",
3303 "synchronous",
3304 "temp_store",
3305 "threads",
3306 "trusted_schema",
3307 "user_version",
3308 ];
3309
3310 if value.is_some_and(reserved_name) {
3311 return false;
3312 }
3313 ARGUMENT_SAFE
3314 .iter()
3315 .any(|allowed| name.eq_ignore_ascii_case(allowed))
3316 || value.is_none()
3317 && NO_ARGUMENT_ONLY
3318 .iter()
3319 .any(|allowed| name.eq_ignore_ascii_case(allowed))
3320}
3321
3322fn reserved_name(name: &str) -> bool {
3323 const TABLES: &[&str] = &["__rhiza_kv", "__rhiza_meta", "__rhiza_requests"];
3324 reserved_table_name(name)
3325 || strip_ascii_prefix_ignore_case(name, "sqlite_autoindex_").is_some_and(|suffix| {
3326 TABLES.iter().any(|table| {
3327 suffix
3328 .get(..table.len())
3329 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(table))
3330 && suffix.as_bytes().get(table.len()) == Some(&b'_')
3331 })
3332 })
3333}
3334
3335fn reserved_table_name(name: &str) -> bool {
3336 matches_ignore_ascii_case(name, &["__rhiza_kv", "__rhiza_meta", "__rhiza_requests"])
3337}
3338
3339fn validate_reserved_schema(conn: &Connection) -> Result<()> {
3340 let unexpected: Option<String> = conn
3341 .query_row(
3342 "SELECT name
3343 FROM sqlite_schema
3344 WHERE
3345 ((lower(name) IN ('__rhiza_meta', '__rhiza_requests')
3346 OR lower(tbl_name) IN ('__rhiza_meta', '__rhiza_requests')))
3347 OR
3348 ((lower(name) = '__rhiza_kv' OR lower(tbl_name) = '__rhiza_kv')
3349 AND NOT (
3350 (type = 'table' AND name = '__rhiza_kv' AND tbl_name = '__rhiza_kv')
3351 OR
3352 (type = 'index'
3353 AND name GLOB 'sqlite_autoindex___rhiza_kv_*'
3354 AND tbl_name = '__rhiza_kv')
3355 ))
3356 LIMIT 1",
3357 [],
3358 |row| row.get(0),
3359 )
3360 .optional()
3361 .map_err(sqlite_error)?;
3362 if let Some(name) = unexpected {
3363 return Err(Error::InvalidCommand(format!(
3364 "SQL object uses reserved rhiza namespace: {name}"
3365 )));
3366 }
3367 let mut statement = conn
3368 .prepare(
3369 "SELECT name, sql FROM sqlite_schema
3370 WHERE type = 'table' AND lower(sql) LIKE 'create virtual table%'",
3371 )
3372 .map_err(sqlite_error)?;
3373 let definitions = statement
3374 .query_map([], |row| {
3375 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3376 })
3377 .map_err(sqlite_error)?
3378 .collect::<std::result::Result<Vec<_>, _>>()
3379 .map_err(sqlite_error)?;
3380 if let Some((name, _)) = definitions
3381 .into_iter()
3382 .find(|(_, sql)| virtual_table_uses_reserved_content(sql))
3383 {
3384 return Err(Error::InvalidCommand(format!(
3385 "SQL virtual table uses reserved rhiza content: {name}"
3386 )));
3387 }
3388 Ok(())
3389}
3390
3391fn virtual_table_uses_reserved_content(sql: &str) -> bool {
3392 let bytes = sql.as_bytes();
3393 let Some(mut index) = unquoted_open_paren(bytes) else {
3394 return false;
3395 };
3396 index += 1;
3397 let mut depth = 1usize;
3398 let mut argument = Vec::new();
3399 while index < bytes.len() {
3400 match bytes[index] {
3401 b'-' if bytes.get(index + 1) == Some(&b'-') => {
3402 index += 2;
3403 while bytes.get(index).is_some_and(|byte| *byte != b'\n') {
3404 index += 1;
3405 }
3406 argument.push(b' ');
3407 }
3408 b'/' if bytes.get(index + 1) == Some(&b'*') => {
3409 index += 2;
3410 while index + 1 < bytes.len() && (bytes[index] != b'*' || bytes[index + 1] != b'/')
3411 {
3412 index += 1;
3413 }
3414 index = (index + 2).min(bytes.len());
3415 argument.push(b' ');
3416 }
3417 quote @ (b'\'' | b'"' | b'`' | b'[') => {
3418 let close = if quote == b'[' { b']' } else { quote };
3419 argument.push(quote);
3420 index += 1;
3421 while let Some(byte) = bytes.get(index).copied() {
3422 argument.push(byte);
3423 index += 1;
3424 if byte == close {
3425 if bytes.get(index) == Some(&close) {
3426 argument.push(close);
3427 index += 1;
3428 } else {
3429 break;
3430 }
3431 }
3432 }
3433 }
3434 b'(' => {
3435 depth += 1;
3436 argument.push(b'(');
3437 index += 1;
3438 }
3439 b')' if depth == 1 => {
3440 return content_argument_uses_reserved_table(&argument);
3441 }
3442 b')' => {
3443 depth -= 1;
3444 argument.push(b')');
3445 index += 1;
3446 }
3447 b',' if depth == 1 => {
3448 if content_argument_uses_reserved_table(&argument) {
3449 return true;
3450 }
3451 argument.clear();
3452 index += 1;
3453 }
3454 byte => {
3455 argument.push(byte);
3456 index += 1;
3457 }
3458 }
3459 }
3460 content_argument_uses_reserved_table(&argument)
3461}
3462
3463fn unquoted_open_paren(bytes: &[u8]) -> Option<usize> {
3464 let mut index = 0;
3465 while index < bytes.len() {
3466 match bytes[index] {
3467 b'-' if bytes.get(index + 1) == Some(&b'-') => {
3468 index += 2;
3469 while bytes.get(index).is_some_and(|byte| *byte != b'\n') {
3470 index += 1;
3471 }
3472 }
3473 b'/' if bytes.get(index + 1) == Some(&b'*') => {
3474 index += 2;
3475 while index + 1 < bytes.len() && (bytes[index] != b'*' || bytes[index + 1] != b'/')
3476 {
3477 index += 1;
3478 }
3479 index = (index + 2).min(bytes.len());
3480 }
3481 quote @ (b'\'' | b'"' | b'`' | b'[') => {
3482 let close = if quote == b'[' { b']' } else { quote };
3483 index += 1;
3484 while let Some(byte) = bytes.get(index).copied() {
3485 index += 1;
3486 if byte == close {
3487 if bytes.get(index) == Some(&close) {
3488 index += 1;
3489 } else {
3490 break;
3491 }
3492 }
3493 }
3494 }
3495 b'(' => return Some(index),
3496 _ => index += 1,
3497 }
3498 }
3499 None
3500}
3501
3502fn content_argument_uses_reserved_table(argument: &[u8]) -> bool {
3503 let argument = trim_ascii(argument);
3504 let Some(equal) = unquoted_equal(argument) else {
3505 return false;
3506 };
3507 let key = dequote_whole_sql_token(trim_ascii(&argument[..equal]));
3508 if !key.eq_ignore_ascii_case(b"content") {
3509 return false;
3510 }
3511 let value = dequote_whole_sql_token(trim_ascii(&argument[equal + 1..]));
3512 std::str::from_utf8(&value).is_ok_and(reserved_table_name)
3513}
3514
3515fn unquoted_equal(token: &[u8]) -> Option<usize> {
3516 let mut index = 0;
3517 let mut depth = 0usize;
3518 while index < token.len() {
3519 match token[index] {
3520 quote @ (b'\'' | b'"' | b'`' | b'[') => {
3521 let close = if quote == b'[' { b']' } else { quote };
3522 index += 1;
3523 while let Some(byte) = token.get(index).copied() {
3524 index += 1;
3525 if byte == close {
3526 if token.get(index) == Some(&close) {
3527 index += 1;
3528 } else {
3529 break;
3530 }
3531 }
3532 }
3533 }
3534 b'(' => {
3535 depth += 1;
3536 index += 1;
3537 }
3538 b')' => {
3539 depth = depth.saturating_sub(1);
3540 index += 1;
3541 }
3542 b'=' if depth == 0 => return Some(index),
3543 _ => index += 1,
3544 }
3545 }
3546 None
3547}
3548
3549fn dequote_whole_sql_token(token: &[u8]) -> Vec<u8> {
3550 let Some(open) = token.first().copied() else {
3551 return Vec::new();
3552 };
3553 let close = match open {
3554 b'\'' | b'"' | b'`' => open,
3555 b'[' => b']',
3556 _ => return token.to_vec(),
3557 };
3558 if token.last() != Some(&close) || token.len() < 2 {
3559 return token.to_vec();
3560 }
3561 let mut dequoted = Vec::with_capacity(token.len() - 2);
3562 let mut index = 1;
3563 while index + 1 < token.len() {
3564 let byte = token[index];
3565 dequoted.push(byte);
3566 index += 1;
3567 if byte == close && token.get(index) == Some(&close) {
3568 index += 1;
3569 }
3570 }
3571 dequoted
3572}
3573
3574fn trim_ascii(mut value: &[u8]) -> &[u8] {
3575 while value.first().is_some_and(u8::is_ascii_whitespace) {
3576 value = &value[1..];
3577 }
3578 while value.last().is_some_and(u8::is_ascii_whitespace) {
3579 value = &value[..value.len() - 1];
3580 }
3581 value
3582}
3583
3584fn strip_ascii_prefix_ignore_case<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
3585 value
3586 .get(..prefix.len())
3587 .filter(|candidate| candidate.eq_ignore_ascii_case(prefix))
3588 .map(|_| &value[prefix.len()..])
3589}
3590
3591fn validate_temp_schema_empty(conn: &Connection) -> Result<()> {
3592 let name = conn
3593 .query_row("SELECT name FROM sqlite_temp_schema LIMIT 1", [], |row| {
3594 row.get::<_, String>(0)
3595 })
3596 .optional()
3597 .map_err(sqlite_error)?;
3598 if let Some(name) = name {
3599 return Err(Error::InvalidCommand(format!(
3600 "replicated SQL member left TEMP object {name} behind"
3601 )));
3602 }
3603 Ok(())
3604}
3605
3606fn bundled_vtable(module_name: &str) -> bool {
3607 matches_ignore_ascii_case(
3608 module_name,
3609 &[
3610 "fts3",
3611 "fts3tokenize",
3612 "fts4",
3613 "fts4aux",
3614 "fts5",
3615 "fts5vocab",
3616 "dbstat",
3617 "rtree",
3618 "rtree_i32",
3619 ],
3620 )
3621}
3622
3623fn matches_ignore_ascii_case(value: &str, choices: &[&str]) -> bool {
3624 choices
3625 .iter()
3626 .any(|choice| value.eq_ignore_ascii_case(choice))
3627}
3628
3629fn unsafe_sql_function(name: &str) -> bool {
3630 name.eq_ignore_ascii_case("load_extension")
3631}
3632
3633fn sql_value(value: ValueRef<'_>) -> Result<SqlValue> {
3634 Ok(match value {
3635 ValueRef::Null => SqlValue::Null,
3636 ValueRef::Integer(value) => SqlValue::Integer(value),
3637 ValueRef::Real(value) if value.is_finite() => SqlValue::Real(value),
3638 ValueRef::Real(_) => {
3639 return Err(Error::InvalidCommand(
3640 "SQL real result must be finite".into(),
3641 ));
3642 }
3643 ValueRef::Text(value) => SqlValue::Text(
3644 String::from_utf8(value.to_vec())
3645 .map_err(|_| Error::InvalidCommand("SQL TEXT result is not valid UTF-8".into()))?,
3646 ),
3647 ValueRef::Blob(value) => SqlValue::Blob(value.to_vec()),
3648 })
3649}
3650
3651fn sql_value_size(value: &SqlValue) -> usize {
3652 match value {
3653 SqlValue::Null => 0,
3654 SqlValue::Integer(_) | SqlValue::Real(_) => 8,
3655 SqlValue::Text(value) => value.len(),
3656 SqlValue::Blob(value) => value.len(),
3657 }
3658}
3659
3660fn encode_sql_result(result: &SqlCommandResult) -> Result<Vec<u8>> {
3661 validate_sql_result_bounds(result)?;
3662 let encoded = serde_json::to_vec(result)
3663 .map_err(|error| Error::InvalidCommand(format!("cannot encode SQL result: {error}")))?;
3664 let mut blob = Vec::with_capacity(SQL_RESULT_V1_MAGIC.len() + encoded.len());
3665 blob.extend_from_slice(SQL_RESULT_V1_MAGIC);
3666 blob.extend_from_slice(&encoded);
3667 Ok(blob)
3668}
3669
3670fn decode_sql_result(blob: &[u8]) -> Result<SqlCommandResult> {
3671 let encoded = blob
3672 .strip_prefix(SQL_RESULT_V1_MAGIC)
3673 .ok_or_else(|| Error::Sqlite("unsupported SQL result encoding".into()))?;
3674 let result: SqlCommandResult = serde_json::from_slice(encoded)
3675 .map_err(|error| Error::Sqlite(format!("invalid SQL result: {error}")))?;
3676 validate_sql_result_bounds(&result)?;
3677 if encode_sql_result(&result)? != blob {
3678 return Err(Error::Sqlite("SQL result blob is not canonical".into()));
3679 }
3680 Ok(result)
3681}
3682
3683pub(crate) fn validate_sql_result_blob_bounds(blob: &[u8]) -> Result<()> {
3684 decode_sql_result(blob).map(|_| ())
3685}
3686
3687fn validate_sql_result_bounds(result: &SqlCommandResult) -> Result<()> {
3688 let mut row_count = 0usize;
3689 let mut bytes = 0usize;
3690 for statement in &result.statement_results {
3691 let Some(returning) = &statement.returning else {
3692 continue;
3693 };
3694 row_count = row_count
3695 .checked_add(returning.rows.len())
3696 .ok_or_else(|| Error::InvalidCommand("SQL RETURNING row count overflow".into()))?;
3697 if row_count > MAX_RETURNING_ROWS {
3698 return Err(Error::InvalidCommand(format!(
3699 "SQL RETURNING exceeds {MAX_RETURNING_ROWS} rows"
3700 )));
3701 }
3702 for column in &returning.columns {
3703 add_returning_bytes(&mut bytes, column.len())?;
3704 }
3705 for row in &returning.rows {
3706 if row.len() != returning.columns.len() {
3707 return Err(Error::InvalidCommand(
3708 "SQL RETURNING row width does not match its columns".into(),
3709 ));
3710 }
3711 for value in row {
3712 add_returning_bytes(&mut bytes, sql_value_size(value))?;
3713 }
3714 }
3715 }
3716 Ok(())
3717}
3718
3719fn integrity_check(conn: &Connection) -> Result<()> {
3720 let mut statement = conn
3721 .prepare("PRAGMA integrity_check;")
3722 .map_err(|err| Error::InvalidSnapshot(err.to_string()))?;
3723 let mut rows = statement
3724 .query([])
3725 .map_err(|err| Error::InvalidSnapshot(err.to_string()))?;
3726 let mut messages = Vec::new();
3727 while let Some(row) = rows
3728 .next()
3729 .map_err(|err| Error::InvalidSnapshot(err.to_string()))?
3730 {
3731 messages.push(
3732 row.get::<_, String>(0)
3733 .map_err(|err| Error::InvalidSnapshot(err.to_string()))?,
3734 );
3735 }
3736 if messages == ["ok"] {
3737 return Ok(());
3738 }
3739 Err(Error::InvalidSnapshot(if messages.is_empty() {
3740 "integrity_check returned no result".into()
3741 } else {
3742 messages.join("; ")
3743 }))
3744}
3745
3746fn ensure_parent(path: &Path) -> Result<()> {
3747 let parent = parent_dir(path);
3748 fs::create_dir_all(parent).map_err(io_error)
3749}
3750
3751fn parent_dir(path: &Path) -> &Path {
3752 path.parent()
3753 .filter(|parent| !parent.as_os_str().is_empty())
3754 .unwrap_or_else(|| Path::new("."))
3755}
3756
3757fn sync_parent(parent: &Path) -> Result<()> {
3758 File::open(parent)
3759 .and_then(|directory| directory.sync_all())
3760 .map_err(io_error)
3761}
3762
3763fn sqlite_error(error: rusqlite::Error) -> Error {
3764 Error::Sqlite(error.to_string())
3765}
3766
3767fn sql_query_error(error: rusqlite::Error) -> Error {
3768 match &error {
3769 rusqlite::Error::SqliteFailure(code, _)
3770 if code.code == rusqlite::ffi::ErrorCode::OperationInterrupted =>
3771 {
3772 Error::ResourceExhausted("SQL query execution timed out".into())
3773 }
3774 _ => sqlite_error(error),
3775 }
3776}
3777
3778fn io_error(error: std::io::Error) -> Error {
3779 Error::Io(error.to_string())
3780}
3781
3782#[cfg(test)]
3783mod query_policy_tests {
3784 use super::*;
3785
3786 #[test]
3787 fn reserved_name_and_vtable_content_checks_match_only_structural_sentinels() {
3788 for name in [
3789 "__rhiza_kv",
3790 "__RHIZA_META",
3791 "__rhiza_requests",
3792 "sqlite_autoindex___rhiza_kv_1",
3793 ] {
3794 assert!(reserved_name(name));
3795 }
3796 for name in [
3797 "__rhiza_user_table",
3798 "x__rhiza_kv",
3799 "sqlite_autoindex_user_1",
3800 ] {
3801 assert!(!reserved_name(name));
3802 }
3803 for sql in [
3804 "CREATE VIRTUAL TABLE x USING fts5(body, content='__rhiza_kv')",
3805 "CREATE VIRTUAL TABLE x USING fts5(body, CONTENT = [__RHIZA_META])",
3806 "CREATE VIRTUAL TABLE x USING fts5(body, content=__rhiza_requests)",
3807 "CREATE VIRTUAL TABLE x USING fts5(body, content=\"__rhiza_meta\")",
3808 "CREATE VIRTUAL TABLE x USING fts5(body, content=`__rhiza_requests`)",
3809 "CREATE VIRTUAL TABLE x USING fts5(prefix=(a,b), CoNtEnT /* option */ = '__rhiza_kv')",
3810 ] {
3811 assert!(virtual_table_uses_reserved_content(sql));
3812 }
3813 for sql in [
3814 "CREATE VIRTUAL TABLE x USING fts5(__rhiza_kv)",
3815 "CREATE VIRTUAL TABLE x USING fts5(body, tokenize='__rhiza_kv')",
3816 "CREATE VIRTUAL TABLE x USING fts5(body, content='__rhiza_kv_user')",
3817 "CREATE VIRTUAL TABLE x USING fts5(body, 'content=__rhiza_kv')",
3818 "CREATE VIRTUAL TABLE x USING fts5(body, \"content=__rhiza_meta\")",
3819 "CREATE VIRTUAL TABLE x USING fts5(body, `content=__rhiza_requests`)",
3820 "CREATE VIRTUAL TABLE x USING fts5(body, [content=__rhiza_kv])",
3821 "CREATE VIRTUAL TABLE x USING fts5(body, 'content=''__rhiza_kv''')",
3822 "CREATE VIRTUAL TABLE x USING fts5(body, /* content='__rhiza_kv' */ tokenize='porter')",
3823 "CREATE VIRTUAL TABLE x USING fts5(body, -- content='__rhiza_kv'\n tokenize='porter')",
3824 ] {
3825 assert!(!virtual_table_uses_reserved_content(sql));
3826 }
3827 }
3828
3829 fn prepare_single_sql_effect(
3830 database: &SqliteStateMachine,
3831 command: &SqlCommand,
3832 request: &[u8],
3833 base_index: LogIndex,
3834 base_hash: LogHash,
3835 ) -> Result<Vec<u8>> {
3836 let preparation = database.prepare_sql_batch_effect(
3837 &[SqlBatchMember {
3838 command,
3839 request_payload: request,
3840 }],
3841 base_index,
3842 base_hash,
3843 )?;
3844 preparation
3845 .results
3846 .into_iter()
3847 .next()
3848 .expect("one-member batch returns one result")?;
3849 preparation
3850 .effect
3851 .ok_or_else(|| Error::InvalidCommand("successful batch produced no effect".into()))
3852 }
3853
3854 #[test]
3855 fn speculative_prepare_uses_one_copy_and_non_durable_sqlite_without_weakening_canonical() {
3856 let dir = tempfile::tempdir().unwrap();
3857 let path = dir.path().join("state.sqlite");
3858 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
3859 let command = SqlCommand {
3860 request_id: "speculative-durability".into(),
3861 statements: vec![SqlStatement {
3862 sql: "CREATE TABLE speculative(value TEXT NOT NULL)".into(),
3863 parameters: vec![],
3864 }],
3865 };
3866 let request = encode_sql_command(&command).unwrap();
3867 let base_digest = database.canonical_db_digest().unwrap();
3868
3869 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
3870
3871 let audit = speculative_prepare_audit(&path).expect("prepare records its test audit");
3872 assert_eq!(audit.copy_count, 1);
3873 assert_eq!(audit.synchronous, 0);
3874 assert_eq!(database.connection_pragmas().unwrap(), ("wal".into(), 0));
3875 assert_eq!(database.canonical_db_digest().unwrap(), base_digest);
3876 let prepared = database.prepared_target.lock().unwrap();
3877 assert!(
3878 prepared
3879 .as_ref()
3880 .unwrap()
3881 .artifact
3882 .as_file()
3883 .metadata()
3884 .unwrap()
3885 .len()
3886 > 0
3887 );
3888 }
3889
3890 #[test]
3891 fn batch_capacity_rejects_1025_members_before_speculative_io() {
3892 let dir = tempfile::tempdir().unwrap();
3893 let path = dir.path().join("state.sqlite");
3894 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
3895 let command = SqlCommand {
3896 request_id: "over-capacity".into(),
3897 statements: vec![SqlStatement {
3898 sql: "CREATE TABLE must_not_run(value INTEGER NOT NULL)".into(),
3899 parameters: vec![],
3900 }],
3901 };
3902 let payload = encode_sql_command(&command).unwrap();
3903 let member = SqlBatchMember {
3904 command: &command,
3905 request_payload: &payload,
3906 };
3907
3908 assert!(matches!(
3909 database.prepare_sql_batch_effect(&vec![member; 1025], 0, LogHash::ZERO),
3910 Err(Error::InvalidCommand(_))
3911 ));
3912 assert_eq!(speculative_prepare_audit(&path), None);
3913 assert_eq!(database.applied_tip_value().unwrap(), (0, LogHash::ZERO));
3914 }
3915
3916 #[test]
3917 fn capacity_sized_batch_rejects_a_duplicate_request_id_at_the_tail() {
3918 let dir = tempfile::tempdir().unwrap();
3919 let database =
3920 SqliteStateMachine::open(dir.path().join("state.sqlite"), "cluster-a", "node-1", 1, 1)
3921 .unwrap();
3922 let commands = (0usize..MAX_QWAL_V3_RECEIPTS)
3923 .map(|index| SqlCommand {
3924 request_id: if index + 1 == MAX_QWAL_V3_RECEIPTS {
3925 "request-0000".into()
3926 } else {
3927 format!("request-{index:04}")
3928 },
3929 statements: vec![SqlStatement {
3930 sql: "INSERT INTO absent_table(value) VALUES (?1)".into(),
3931 parameters: vec![SqlValue::Integer(index as i64)],
3932 }],
3933 })
3934 .collect::<Vec<_>>();
3935 let payloads = commands
3936 .iter()
3937 .map(|command| encode_sql_command(command).unwrap())
3938 .collect::<Vec<_>>();
3939 let members = commands
3940 .iter()
3941 .zip(&payloads)
3942 .map(|(command, request_payload)| SqlBatchMember {
3943 command,
3944 request_payload,
3945 })
3946 .collect::<Vec<_>>();
3947
3948 let preparation = database
3949 .prepare_sql_batch_effect(&members, 0, LogHash::ZERO)
3950 .unwrap();
3951
3952 assert!(preparation.effect.is_none());
3953 assert_eq!(preparation.results.len(), MAX_QWAL_V3_RECEIPTS);
3954 assert!(matches!(
3955 preparation.results.last().unwrap(),
3956 Err(Error::InvalidCommand(message)) if message.contains("repeats a request_id")
3957 ));
3958 assert_eq!(database.applied_tip_value().unwrap(), (0, LogHash::ZERO));
3959 }
3960
3961 #[test]
3962 fn applied_tip_returns_index_and_hash_from_the_same_database_state() {
3963 let dir = tempfile::tempdir().unwrap();
3964 let database =
3965 SqliteStateMachine::open(dir.path().join("state.sqlite"), "cluster-a", "node-1", 1, 1)
3966 .unwrap();
3967 let request = b"put\trequest-1\tkey-1\tvalue-1";
3968 let payload = database
3969 .prepare_put_effect("request-1", "key-1", "value-1", request, 0, LogHash::ZERO)
3970 .unwrap();
3971 let hash = LogEntry::calculate_hash(
3972 "cluster-a",
3973 1,
3974 1,
3975 1,
3976 EntryType::Command,
3977 LogHash::ZERO,
3978 &payload,
3979 );
3980 database
3981 .apply_entry(&LogEntry {
3982 cluster_id: "cluster-a".into(),
3983 epoch: 1,
3984 config_id: 1,
3985 index: 1,
3986 entry_type: EntryType::Command,
3987 payload: payload.to_vec(),
3988 prev_hash: LogHash::ZERO,
3989 hash,
3990 })
3991 .unwrap();
3992
3993 assert_eq!(database.applied_tip().unwrap(), ApplyProgress::new(1, hash));
3994 assert_eq!(database.applied_tip_value().unwrap(), (1, hash));
3995 }
3996
3997 #[test]
3998 fn bulk_sql_request_check_aligns_exact_absent_and_conflict_results() {
3999 let dir = tempfile::tempdir().unwrap();
4000 let path = dir.path().join("state.sqlite");
4001 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4002 let committed = SqlCommand {
4003 request_id: "committed".into(),
4004 statements: vec![SqlStatement {
4005 sql: "CREATE TABLE committed(value INTEGER NOT NULL)".into(),
4006 parameters: vec![],
4007 }],
4008 };
4009 let committed_payload = encode_sql_command(&committed).unwrap();
4010 let effect =
4011 prepare_single_sql_effect(&database, &committed, &committed_payload, 0, LogHash::ZERO)
4012 .unwrap();
4013 let entry = command_entry(1, LogHash::ZERO, effect);
4014 database.apply_entry(&entry).unwrap();
4015 let absent = SqlCommand {
4016 request_id: "absent".into(),
4017 statements: vec![SqlStatement {
4018 sql: "SELECT 1".into(),
4019 parameters: vec![],
4020 }],
4021 };
4022 let absent_payload = encode_sql_command(&absent).unwrap();
4023
4024 let aligned = database
4025 .check_sql_requests(&[
4026 ("committed", committed_payload.as_slice()),
4027 ("absent", absent_payload.as_slice()),
4028 ])
4029 .unwrap();
4030 assert!(matches!(
4031 aligned.as_slice(),
4032 [Ok(Some((outcome, Some(_)))), Ok(None)]
4033 if *outcome == RequestOutcome::new(1, entry.hash)
4034 ));
4035
4036 let conflicting = SqlCommand {
4037 request_id: "committed".into(),
4038 statements: vec![SqlStatement {
4039 sql: "SELECT 2".into(),
4040 parameters: vec![],
4041 }],
4042 };
4043 let conflicting_payload = encode_sql_command(&conflicting).unwrap();
4044 assert!(matches!(
4045 database
4046 .check_sql_requests(&[("committed", conflicting_payload.as_slice())])
4047 .unwrap()
4048 .pop()
4049 .unwrap(),
4050 Err(Error::RequestConflict(_))
4051 ));
4052 assert!(matches!(
4053 database.check_sql_requests(&[
4054 ("committed", committed_payload.as_slice()),
4055 ("committed", committed_payload.as_slice()),
4056 ]),
4057 Err(Error::InvalidCommand(message)) if message.contains("duplicate")
4058 ));
4059 }
4060
4061 #[test]
4062 fn read_query_timeout_interrupts_work_and_releases_the_connection() {
4063 let dir = tempfile::tempdir().unwrap();
4064 let database =
4065 SqliteStateMachine::open(dir.path().join("state.sqlite"), "cluster-a", "node-1", 1, 1)
4066 .unwrap();
4067 let expensive = SqlStatement {
4068 sql: "WITH RECURSIVE count(value) AS (VALUES(0) UNION ALL SELECT value + 1 FROM count WHERE value < 100000000) SELECT sum(value) FROM count".into(),
4069 parameters: vec![],
4070 };
4071
4072 assert_eq!(
4073 database
4074 .query_sql_with_timeout(&expensive, 1, 1024, Duration::ZERO)
4075 .unwrap_err(),
4076 Error::ResourceExhausted("SQL query execution timed out".into())
4077 );
4078
4079 let quick = database
4080 .query_sql(
4081 &SqlStatement {
4082 sql: "SELECT 1".into(),
4083 parameters: vec![],
4084 },
4085 1,
4086 1024,
4087 )
4088 .unwrap();
4089 assert_eq!(quick.rows, vec![vec![SqlValue::Integer(1)]]);
4090 }
4091
4092 #[test]
4093 fn read_query_allows_nondeterministic_and_runtime_introspection_functions() {
4094 let dir = tempfile::tempdir().unwrap();
4095 let database =
4096 SqliteStateMachine::open(dir.path().join("state.sqlite"), "cluster-a", "node-1", 1, 1)
4097 .unwrap();
4098
4099 let result = database
4100 .query_sql(
4101 &SqlStatement {
4102 sql: "SELECT random(), datetime('now'), sqlite_version()".into(),
4103 parameters: vec![],
4104 },
4105 1,
4106 4096,
4107 )
4108 .unwrap();
4109
4110 assert_eq!(result.rows.len(), 1);
4111 assert_eq!(result.rows[0].len(), 3);
4112 }
4113
4114 #[test]
4115 fn normal_reads_do_not_query_the_control_pending_table() {
4116 let dir = tempfile::tempdir().unwrap();
4117 let path = dir.path().join("state.sqlite");
4118 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4119 let control_path = control_sidecar_path(&path);
4120 control::begin_pending_query_audit(&control_path);
4121 let command = SqlCommand {
4122 request_id: "pending-audit".into(),
4123 statements: vec![SqlStatement {
4124 sql: "SELECT 1".into(),
4125 parameters: vec![],
4126 }],
4127 };
4128 let request = encode_sql_command(&command).unwrap();
4129
4130 assert_eq!(database.get_value("absent").unwrap(), None);
4131 database
4132 .query_sql(
4133 &SqlStatement {
4134 sql: "SELECT 1".into(),
4135 parameters: vec![],
4136 },
4137 1,
4138 64,
4139 )
4140 .unwrap();
4141 assert_eq!(
4142 database.check_request("pending-audit", &request).unwrap(),
4143 None
4144 );
4145 assert!(matches!(
4146 database
4147 .check_sql_requests(&[("pending-audit", request.as_slice())])
4148 .unwrap()
4149 .as_slice(),
4150 [Ok(None)]
4151 ));
4152
4153 assert_eq!(control::pending_query_count(&control_path), Some(0));
4154 }
4155
4156 #[test]
4157 fn physical_effect_preparation_accepts_nondeterministic_functions() {
4158 let dir = tempfile::tempdir().unwrap();
4159 let database =
4160 SqliteStateMachine::open(dir.path().join("state.sqlite"), "cluster-a", "node-1", 1, 1)
4161 .unwrap();
4162 let command = SqlCommand {
4163 request_id: "nondeterministic-write".into(),
4164 statements: vec![
4165 SqlStatement {
4166 sql: "CREATE TABLE generated(value INTEGER DEFAULT (random()))".into(),
4167 parameters: vec![],
4168 },
4169 SqlStatement {
4170 sql: "INSERT INTO generated DEFAULT VALUES".into(),
4171 parameters: vec![],
4172 },
4173 ],
4174 };
4175
4176 let payload = encode_sql_command(&command).unwrap();
4177 let effect =
4178 prepare_single_sql_effect(&database, &command, &payload, 0, LogHash::ZERO).unwrap();
4179 assert!(effect.starts_with(QWAL_V3_MAGIC));
4180 assert_eq!(database.applied_index_value().unwrap(), 0);
4181 }
4182
4183 #[test]
4184 fn non_durable_staging_uses_native_wal_capture_without_checkpoint_or_full_diff() {
4185 let dir = tempfile::tempdir().unwrap();
4186 let path = dir.path().join("state.sqlite");
4187 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4188 let command = SqlCommand {
4189 request_id: "recorded-write".into(),
4190 statements: vec![
4191 SqlStatement {
4192 sql: "CREATE TABLE recorded(value TEXT NOT NULL)".into(),
4193 parameters: vec![],
4194 },
4195 SqlStatement {
4196 sql: "INSERT INTO recorded(value) VALUES ('shadow')".into(),
4197 parameters: vec![],
4198 },
4199 ],
4200 };
4201 let request = encode_sql_command(&command).unwrap();
4202 let payload =
4203 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4204
4205 let audit = speculative_prepare_audit(&path).unwrap();
4206 assert!(audit.native_vfs);
4207 assert!(audit.no_checkpoint_on_close);
4208 {
4209 let prepared = database.prepared_target.lock().unwrap();
4210 assert!(sqlite_sidecars_absent(prepared.as_ref().unwrap().artifact.path()).unwrap());
4211 }
4212 let effect = decode_qwal_v3(&payload).unwrap();
4213 let hash = LogEntry::calculate_hash(
4214 "cluster-a",
4215 1,
4216 1,
4217 1,
4218 EntryType::Command,
4219 LogHash::ZERO,
4220 &payload,
4221 );
4222 database
4223 .apply_entry(&LogEntry {
4224 cluster_id: "cluster-a".into(),
4225 epoch: 1,
4226 config_id: 1,
4227 index: 1,
4228 entry_type: EntryType::Command,
4229 payload,
4230 prev_hash: LogHash::ZERO,
4231 hash,
4232 })
4233 .unwrap();
4234 assert_eq!(
4235 rebuild_page_state(&path).unwrap().identity(),
4236 effect.target_state
4237 );
4238 }
4239
4240 #[test]
4241 fn prepare_fails_closed_without_changing_canonical_or_control_when_held_wal_changes() {
4242 let dir = tempfile::tempdir().unwrap();
4243 let path = dir.path().join("state.sqlite");
4244 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4245 let command = SqlCommand {
4246 request_id: "unstable-held-wal".into(),
4247 statements: vec![SqlStatement {
4248 sql: "CREATE TABLE unstable(value INTEGER NOT NULL)".into(),
4249 parameters: vec![],
4250 }],
4251 };
4252 let request = encode_sql_command(&command).unwrap();
4253 let base_digest = database.canonical_db_digest().unwrap();
4254 arm_wal_capture_fault(&path, WalCaptureFault::ChangeHeldWalAfterSeal);
4255
4256 let error =
4257 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap_err();
4258
4259 assert!(
4260 matches!(
4261 &error,
4262 Error::InvalidEntry(message) if message.contains("changed after capture was sealed")
4263 ),
4264 "{error:?}"
4265 );
4266 assert_eq!(database.canonical_db_digest().unwrap(), base_digest);
4267 assert_eq!(database.applied_tip_value().unwrap(), (0, LogHash::ZERO));
4268 assert_eq!(
4269 database
4270 .check_sql_request("unstable-held-wal", &request)
4271 .unwrap(),
4272 None
4273 );
4274 assert!(database.prepared_target.lock().unwrap().is_none());
4275 assert!(fs::read_dir(dir.path()).unwrap().all(|entry| {
4276 entry
4277 .unwrap()
4278 .file_name()
4279 .to_string_lossy()
4280 .starts_with("state.sqlite")
4281 }));
4282 }
4283
4284 #[test]
4285 fn successful_noop_uses_explicit_no_change_effect_and_still_replays_its_receipt() {
4286 let dir = tempfile::tempdir().unwrap();
4287 let path = dir.path().join("state.sqlite");
4288 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4289 let setup = SqlCommand {
4290 request_id: "no-physical-change-setup".into(),
4291 statements: vec![SqlStatement {
4292 sql: "CREATE TABLE noop_target(value TEXT NOT NULL)".into(),
4293 parameters: vec![],
4294 }],
4295 };
4296 let setup_request = encode_sql_command(&setup).unwrap();
4297 let setup_payload =
4298 prepare_single_sql_effect(&database, &setup, &setup_request, 0, LogHash::ZERO).unwrap();
4299 let setup_entry = command_entry(1, LogHash::ZERO, setup_payload);
4300 database.apply_entry(&setup_entry).unwrap();
4301 let command = SqlCommand {
4302 request_id: "no-physical-change".into(),
4303 statements: vec![SqlStatement {
4304 sql: "UPDATE noop_target SET value = 'unused' WHERE rowid = -1".into(),
4305 parameters: vec![],
4306 }],
4307 };
4308 let request = encode_sql_command(&command).unwrap();
4309 let base_digest = database.canonical_db_digest().unwrap();
4310 let payload =
4311 prepare_single_sql_effect(&database, &command, &request, 1, setup_entry.hash).unwrap();
4312 let effect = decode_qwal_v3(&payload).unwrap();
4313 assert!(effect.pages.is_empty());
4314 assert_eq!(effect.base_state, effect.target_state);
4315
4316 let entry = command_entry(2, setup_entry.hash, payload);
4317 database.apply_entry(&entry).unwrap();
4318
4319 assert_eq!(database.canonical_db_digest().unwrap(), base_digest);
4320 assert!(database
4321 .check_sql_request("no-physical-change", &request)
4322 .unwrap()
4323 .is_some());
4324 }
4325
4326 #[test]
4327 fn no_change_control_failure_blocks_everything_except_exact_replay() {
4328 let dir = tempfile::tempdir().unwrap();
4329 let path = dir.path().join("state.sqlite");
4330 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4331 let setup = SqlCommand {
4332 request_id: "no-change-fault-setup".into(),
4333 statements: vec![SqlStatement {
4334 sql: "CREATE TABLE no_change_fault(value TEXT NOT NULL)".into(),
4335 parameters: vec![],
4336 }],
4337 };
4338 let setup_request = encode_sql_command(&setup).unwrap();
4339 let setup_payload =
4340 prepare_single_sql_effect(&database, &setup, &setup_request, 0, LogHash::ZERO).unwrap();
4341 let setup_entry = command_entry(1, LogHash::ZERO, setup_payload);
4342 database.apply_entry(&setup_entry).unwrap();
4343
4344 let command = SqlCommand {
4345 request_id: "no-change-fault".into(),
4346 statements: vec![SqlStatement {
4347 sql: "UPDATE no_change_fault SET value = 'unused' WHERE rowid = -1".into(),
4348 parameters: vec![],
4349 }],
4350 };
4351 let request = encode_sql_command(&command).unwrap();
4352 let payload =
4353 prepare_single_sql_effect(&database, &command, &request, 1, setup_entry.hash).unwrap();
4354 let effect = decode_qwal_v3(&payload).unwrap();
4355 assert!(effect.pages.is_empty());
4356 assert_eq!(effect.base_state, effect.target_state);
4357 let entry = command_entry(2, setup_entry.hash, payload);
4358 arm_qwal_apply_fault(&path, QwalApplyFault::BeforeControlCommit);
4359
4360 assert!(database.apply_entry(&entry).is_err());
4361 assert!(database.pending_fence.load(Ordering::Acquire));
4362 assert!(database.get_value("blocked").is_err());
4363 assert!(database
4364 .query_sql(
4365 &SqlStatement {
4366 sql: "SELECT 1".into(),
4367 parameters: vec![],
4368 },
4369 1,
4370 64,
4371 )
4372 .is_err());
4373 assert!(database.check_request("no-change-fault", &request).is_err());
4374 assert!(database
4375 .prepare_sql_batch_effect(
4376 &[SqlBatchMember {
4377 command: &command,
4378 request_payload: &request,
4379 }],
4380 1,
4381 setup_entry.hash,
4382 )
4383 .is_err());
4384 let different = LogEntry {
4385 cluster_id: "cluster-a".into(),
4386 epoch: 1,
4387 config_id: 1,
4388 index: 2,
4389 entry_type: EntryType::Noop,
4390 payload: Vec::new(),
4391 prev_hash: setup_entry.hash,
4392 hash: LogEntry::calculate_hash(
4393 "cluster-a",
4394 2,
4395 1,
4396 1,
4397 EntryType::Noop,
4398 setup_entry.hash,
4399 &[],
4400 ),
4401 };
4402 assert!(database.apply_entry(&different).is_err());
4403
4404 database.apply_entry(&entry).unwrap();
4405 assert!(!database.pending_fence.load(Ordering::Acquire));
4406 assert_eq!(database.applied_tip_value().unwrap(), (2, entry.hash));
4407 assert_eq!(database.get_value("unblocked").unwrap(), None);
4408 assert!(database
4409 .check_sql_request("no-change-fault", &request)
4410 .unwrap()
4411 .is_some());
4412 }
4413
4414 #[test]
4415 fn exact_consensus_winner_promotes_the_prepared_target_without_rebuilding() {
4416 let dir = tempfile::tempdir().unwrap();
4417 let path = dir.path().join("state.sqlite");
4418 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4419 let command = SqlCommand {
4420 request_id: "prepared-winner".into(),
4421 statements: vec![SqlStatement {
4422 sql: "CREATE TABLE promoted(value TEXT NOT NULL)".into(),
4423 parameters: vec![],
4424 }],
4425 };
4426 let request = encode_sql_command(&command).unwrap();
4427 let payload =
4428 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4429 assert_eq!(
4430 database
4431 .query_sql(
4432 &SqlStatement {
4433 sql: "SELECT 1".into(),
4434 parameters: vec![],
4435 },
4436 1,
4437 1024,
4438 )
4439 .unwrap()
4440 .rows,
4441 vec![vec![SqlValue::Integer(1)]]
4442 );
4443 let entry = command_entry(1, LogHash::ZERO, payload);
4444
4445 let outcome = database.apply_entry_with_result(&entry).unwrap();
4446
4447 assert_eq!(
4448 prepared_install_path(&path),
4449 Some(PreparedInstallPath::Promoted)
4450 );
4451 assert_eq!(
4452 prepared_base_reuse_audit(&path),
4453 Some(PreparedBaseReuseAudit {
4454 second_checkpoint_count: 0,
4455 })
4456 );
4457 assert_eq!(outcome.progress(), ApplyProgress::new(1, entry.hash));
4458 assert_eq!(
4459 database
4460 .check_sql_request("prepared-winner", &request)
4461 .unwrap()
4462 .unwrap()
4463 .0,
4464 RequestOutcome::new(1, entry.hash)
4465 );
4466 }
4467
4468 #[test]
4469 fn foreign_consensus_winner_discards_the_prepared_target_and_patches_in_place() {
4470 let dir = tempfile::tempdir().unwrap();
4471 let local_path = dir.path().join("local.sqlite");
4472 let foreign_path = dir.path().join("foreign.sqlite");
4473 let local = SqliteStateMachine::open(&local_path, "cluster-a", "node-1", 1, 1).unwrap();
4474 let foreign = SqliteStateMachine::open(&foreign_path, "cluster-a", "node-2", 1, 1).unwrap();
4475 let local_command = SqlCommand {
4476 request_id: "local-loser".into(),
4477 statements: vec![SqlStatement {
4478 sql: "CREATE TABLE local_only(value TEXT NOT NULL)".into(),
4479 parameters: vec![],
4480 }],
4481 };
4482 let local_request = encode_sql_command(&local_command).unwrap();
4483 let _ = prepare_single_sql_effect(&local, &local_command, &local_request, 0, LogHash::ZERO)
4484 .unwrap();
4485 let foreign_command = SqlCommand {
4486 request_id: "foreign-winner".into(),
4487 statements: vec![SqlStatement {
4488 sql: "CREATE TABLE foreign_only(value TEXT NOT NULL)".into(),
4489 parameters: vec![],
4490 }],
4491 };
4492 let foreign_request = encode_sql_command(&foreign_command).unwrap();
4493 let foreign_effect = prepare_single_sql_effect(
4494 &foreign,
4495 &foreign_command,
4496 &foreign_request,
4497 0,
4498 LogHash::ZERO,
4499 )
4500 .unwrap();
4501
4502 local
4503 .apply_entry(&command_entry(1, LogHash::ZERO, foreign_effect))
4504 .unwrap();
4505
4506 assert_eq!(
4507 prepared_install_path(&local_path),
4508 Some(PreparedInstallPath::Patched)
4509 );
4510 assert_eq!(
4511 prepared_base_reuse_audit(&local_path),
4512 Some(PreparedBaseReuseAudit {
4513 second_checkpoint_count: 1,
4514 })
4515 );
4516 assert_eq!(
4517 local
4518 .query_sql(
4519 &SqlStatement {
4520 sql: "SELECT name FROM sqlite_schema WHERE name = 'foreign_only'".into(),
4521 parameters: vec![],
4522 },
4523 1,
4524 1024,
4525 )
4526 .unwrap()
4527 .rows,
4528 vec![vec![SqlValue::Text("foreign_only".into())]]
4529 );
4530 }
4531
4532 #[cfg(unix)]
4533 #[test]
4534 fn same_size_canonical_mutation_is_rejected_before_qwal_writes() {
4535 let dir = tempfile::tempdir().unwrap();
4536 let path = dir.path().join("state.sqlite");
4537 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4538 let command = SqlCommand {
4539 request_id: "sealed-base-mutation".into(),
4540 statements: vec![SqlStatement {
4541 sql: "CREATE TABLE must_not_be_installed(value INTEGER NOT NULL)".into(),
4542 parameters: vec![],
4543 }],
4544 };
4545 let request = encode_sql_command(&command).unwrap();
4546 let payload =
4547 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4548 let entry = command_entry(1, LogHash::ZERO, payload);
4549 {
4550 let _lifecycle = database.lock_lifecycle().unwrap();
4551 database.close_connection().unwrap();
4552 let mut file = OpenOptions::new()
4553 .read(true)
4554 .write(true)
4555 .open(&path)
4556 .unwrap();
4557 let offset = file.metadata().unwrap().len() - 1;
4558 file.seek(SeekFrom::Start(offset)).unwrap();
4559 let mut byte = [0_u8; 1];
4560 file.read_exact(&mut byte).unwrap();
4561 byte[0] ^= 0xff;
4562 file.seek(SeekFrom::Start(offset)).unwrap();
4563 file.write_all(&byte).unwrap();
4564 }
4565 let mutated = fs::read(&path).unwrap();
4566
4567 assert!(matches!(
4568 database.apply_entry(&entry),
4569 Err(Error::InvalidEntry(message)) if message.contains("page-state seal")
4570 ));
4571 assert_eq!(fs::read(&path).unwrap(), mutated);
4572 assert_eq!(database.applied_tip_value().unwrap(), (0, LogHash::ZERO));
4573 }
4574
4575 #[test]
4576 fn interrupted_in_place_apply_is_rejected_on_reopen_for_recorder_rebuild() {
4577 let dir = tempfile::tempdir().unwrap();
4578 let proposer_path = dir.path().join("proposer.sqlite");
4579 let follower_path = dir.path().join("follower.sqlite");
4580 let proposer =
4581 SqliteStateMachine::open(&proposer_path, "cluster-a", "node-1", 1, 1).unwrap();
4582 let follower =
4583 SqliteStateMachine::open(&follower_path, "cluster-a", "node-2", 1, 1).unwrap();
4584 let command = SqlCommand {
4585 request_id: "interrupt-in-place".into(),
4586 statements: vec![
4587 SqlStatement {
4588 sql: "CREATE TABLE interrupted(value BLOB NOT NULL)".into(),
4589 parameters: vec![],
4590 },
4591 SqlStatement {
4592 sql: "INSERT INTO interrupted VALUES (zeroblob(20000))".into(),
4593 parameters: vec![],
4594 },
4595 ],
4596 };
4597 let request = encode_sql_command(&command).unwrap();
4598 let payload =
4599 prepare_single_sql_effect(&proposer, &command, &request, 0, LogHash::ZERO).unwrap();
4600 let effect = decode_qwal_v3(&payload).unwrap();
4601 assert!(effect.pages.len() > 1);
4602 arm_qwal_apply_fault(
4603 &follower_path,
4604 QwalApplyFault::AfterPage(effect.pages[0].page_no),
4605 );
4606
4607 assert!(follower
4608 .apply_entry(&command_entry(1, LogHash::ZERO, payload))
4609 .is_err());
4610 assert!(matches!(
4611 follower.query_sql(&SqlStatement { sql: "SELECT 1".into(), parameters: vec![] }, 1, 64),
4612 Err(Error::InvalidEntry(message)) if message.contains("pending")
4613 ));
4614 drop(follower);
4615 assert!(matches!(
4616 SqliteStateMachine::open_existing(&follower_path),
4617 Err(Error::InvalidEntry(message)) if message.contains("page state")
4618 ));
4619 }
4620
4621 #[test]
4622 fn startup_rejects_untracked_committed_wal_before_exposing_reads() {
4623 let dir = tempfile::tempdir().unwrap();
4624 let path = dir.path().join("state.sqlite");
4625 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4626 drop(database);
4627
4628 let external = Connection::open(&path).unwrap();
4629 external
4630 .pragma_update(None, "wal_autocheckpoint", 0)
4631 .unwrap();
4632 external
4633 .execute_batch(
4634 "CREATE TABLE untracked_wal(value TEXT NOT NULL);
4635 INSERT INTO untracked_wal VALUES ('must-not-be-visible');",
4636 )
4637 .unwrap();
4638 assert!(
4639 fs::metadata(sqlite_sidecar_path(&path, "-wal"))
4640 .unwrap()
4641 .len()
4642 > 0
4643 );
4644
4645 assert!(matches!(
4646 SqliteStateMachine::open_existing(&path),
4647 Err(Error::InvalidEntry(message)) if message.contains("sidecars")
4648 ));
4649 drop(external);
4650 }
4651
4652 #[test]
4653 fn post_install_control_failure_closes_reads_until_exact_replay_commits() {
4654 let dir = tempfile::tempdir().unwrap();
4655 let path = dir.path().join("state.sqlite");
4656 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4657 let command = SqlCommand {
4658 request_id: "control-failure".into(),
4659 statements: vec![SqlStatement {
4660 sql: "CREATE TABLE installed_before_control(value INTEGER NOT NULL)".into(),
4661 parameters: vec![],
4662 }],
4663 };
4664 let request = encode_sql_command(&command).unwrap();
4665 let payload =
4666 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4667 let entry = command_entry(1, LogHash::ZERO, payload);
4668 arm_qwal_apply_fault(&path, QwalApplyFault::BeforeControlCommit);
4669
4670 assert!(database.apply_entry(&entry).is_err());
4671 assert_eq!(database.applied_tip_value().unwrap(), (0, LogHash::ZERO));
4672 assert!(matches!(
4673 database.query_sql(&SqlStatement { sql: "SELECT 1".into(), parameters: vec![] }, 1, 64),
4674 Err(Error::InvalidEntry(message)) if message.contains("pending")
4675 ));
4676 assert!(matches!(
4677 database.check_request("control-failure", &request),
4678 Err(Error::Sqlite(message)) if message.contains("closed")
4679 ));
4680 assert!(matches!(
4681 database.check_sql_requests(&[("control-failure", request.as_slice())]),
4682 Err(Error::Sqlite(message)) if message.contains("closed")
4683 ));
4684
4685 let control_path = control_sidecar_path(&path);
4686 control::begin_pending_query_audit(&control_path);
4687 database.apply_entry(&entry).unwrap();
4688 assert_eq!(control::pending_query_count(&control_path), Some(0));
4689 assert_eq!(database.applied_tip_value().unwrap(), (1, entry.hash));
4690 assert_eq!(
4691 database
4692 .query_sql(
4693 &SqlStatement {
4694 sql:
4695 "SELECT name FROM sqlite_schema WHERE name = 'installed_before_control'"
4696 .into(),
4697 parameters: vec![],
4698 },
4699 1,
4700 256,
4701 )
4702 .unwrap()
4703 .rows,
4704 vec![vec![SqlValue::Text("installed_before_control".into())]]
4705 );
4706 }
4707
4708 #[test]
4709 fn pending_commit_failure_keeps_reads_fenced_until_exact_replay_clears_it() {
4710 let dir = tempfile::tempdir().unwrap();
4711 let path = dir.path().join("state.sqlite");
4712 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4713 let change = rhiza_core::ConfigChange::stop(1, LogHash::ZERO).to_stored_command();
4714 let hash = LogEntry::calculate_hash(
4715 "cluster-a",
4716 1,
4717 1,
4718 1,
4719 change.entry_type,
4720 LogHash::ZERO,
4721 &change.payload,
4722 );
4723 let entry = LogEntry {
4724 cluster_id: "cluster-a".into(),
4725 epoch: 1,
4726 config_id: 1,
4727 index: 1,
4728 entry_type: change.entry_type,
4729 payload: change.payload,
4730 prev_hash: LogHash::ZERO,
4731 hash,
4732 };
4733 arm_qwal_apply_fault(&path, QwalApplyFault::BeforePendingCommit);
4734
4735 assert!(database.apply_entry(&entry).is_err());
4736 assert!(database.pending_fence.load(Ordering::Acquire));
4737 assert!(database.control.pending().unwrap().is_some());
4738 assert!(matches!(
4739 database.get_value("blocked"),
4740 Err(Error::InvalidEntry(message)) if message.contains("pending")
4741 ));
4742
4743 database.apply_entry(&entry).unwrap();
4744 assert!(!database.pending_fence.load(Ordering::Acquire));
4745 assert_eq!(database.control.pending().unwrap(), None);
4746 assert_eq!(database.get_value("unblocked").unwrap(), None);
4747 }
4748
4749 #[cfg(unix)]
4750 #[test]
4751 fn prepared_target_promotion_rejects_a_symlink_to_the_owned_inode() {
4752 use std::os::unix::fs::symlink;
4753
4754 let dir = tempfile::tempdir().unwrap();
4755 let path = dir.path().join("state.sqlite");
4756 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4757 let command = SqlCommand {
4758 request_id: "symlinked-target".into(),
4759 statements: vec![SqlStatement {
4760 sql: "CREATE TABLE safe_fallback(value TEXT NOT NULL)".into(),
4761 parameters: vec![],
4762 }],
4763 };
4764 let request = encode_sql_command(&command).unwrap();
4765 let payload =
4766 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4767 let staging_path = database
4768 .prepared_target
4769 .lock()
4770 .unwrap()
4771 .as_ref()
4772 .unwrap()
4773 .artifact
4774 .path()
4775 .to_path_buf();
4776 let backing_path = staging_path.with_extension("owned-inode");
4777 fs::rename(&staging_path, &backing_path).unwrap();
4778 symlink(&backing_path, &staging_path).unwrap();
4779
4780 database
4781 .apply_entry(&command_entry(1, LogHash::ZERO, payload))
4782 .unwrap();
4783
4784 assert_eq!(
4785 prepared_install_path(&path),
4786 Some(PreparedInstallPath::Patched)
4787 );
4788 assert_eq!(
4789 prepared_base_reuse_audit(&path),
4790 Some(PreparedBaseReuseAudit {
4791 second_checkpoint_count: 1,
4792 })
4793 );
4794 assert!(!fs::symlink_metadata(&path)
4795 .unwrap()
4796 .file_type()
4797 .is_symlink());
4798 assert_eq!(
4799 database
4800 .query_sql(
4801 &SqlStatement {
4802 sql: "SELECT name FROM sqlite_schema WHERE name = 'safe_fallback'".into(),
4803 parameters: vec![],
4804 },
4805 1,
4806 1024,
4807 )
4808 .unwrap()
4809 .rows,
4810 vec![vec![SqlValue::Text("safe_fallback".into())]]
4811 );
4812 }
4813
4814 #[cfg(unix)]
4815 #[test]
4816 fn prepared_base_new_inode_is_rejected_by_the_page_state_seal() {
4817 use std::os::unix::fs::MetadataExt;
4818
4819 let dir = tempfile::tempdir().unwrap();
4820 let path = dir.path().join("state.sqlite");
4821 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4822 let command = SqlCommand {
4823 request_id: "new-base-inode".into(),
4824 statements: vec![SqlStatement {
4825 sql: "CREATE TABLE rebuilt_from_new_inode(value INTEGER NOT NULL)".into(),
4826 parameters: vec![],
4827 }],
4828 };
4829 let request = encode_sql_command(&command).unwrap();
4830 let payload =
4831 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4832 let original_inode = fs::metadata(&path).unwrap().ino();
4833 {
4834 let _lifecycle = database.lock_lifecycle().unwrap();
4835 database.close_connection().unwrap();
4836 let replacement = path.with_extension("replacement");
4837 fs::copy(&path, &replacement).unwrap();
4838 fs::rename(&replacement, &path).unwrap();
4839 }
4840 assert_ne!(fs::metadata(&path).unwrap().ino(), original_inode);
4841
4842 assert!(database
4843 .apply_entry(&command_entry(1, LogHash::ZERO, payload))
4844 .is_err());
4845 assert_eq!(prepared_install_path(&path), None);
4846 }
4847
4848 #[cfg(unix)]
4849 #[test]
4850 fn prepared_base_symlink_falls_back_without_promoting_through_it() {
4851 use std::os::unix::fs::symlink;
4852
4853 let dir = tempfile::tempdir().unwrap();
4854 let path = dir.path().join("state.sqlite");
4855 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4856 let command = SqlCommand {
4857 request_id: "symlinked-base".into(),
4858 statements: vec![SqlStatement {
4859 sql: "CREATE TABLE rebuilt_from_symlink(value INTEGER NOT NULL)".into(),
4860 parameters: vec![],
4861 }],
4862 };
4863 let request = encode_sql_command(&command).unwrap();
4864 let payload =
4865 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4866 let backing = path.with_extension("backing");
4867 {
4868 let _lifecycle = database.lock_lifecycle().unwrap();
4869 database.close_connection().unwrap();
4870 fs::rename(&path, &backing).unwrap();
4871 symlink(&backing, &path).unwrap();
4872 }
4873
4874 assert!(database
4875 .apply_entry(&command_entry(1, LogHash::ZERO, payload))
4876 .is_err());
4877 assert_eq!(prepared_install_path(&path), None);
4878 assert!(fs::symlink_metadata(&path)
4879 .unwrap()
4880 .file_type()
4881 .is_symlink());
4882 }
4883
4884 #[cfg(unix)]
4885 #[test]
4886 fn prepared_base_same_inode_mutation_rejects_rebuildable_apply() {
4887 use std::os::unix::fs::MetadataExt;
4888
4889 let dir = tempfile::tempdir().unwrap();
4890 let path = dir.path().join("state.sqlite");
4891 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4892 let command = SqlCommand {
4893 request_id: "mutated-base".into(),
4894 statements: vec![SqlStatement {
4895 sql: "CREATE TABLE should_not_promote(value INTEGER NOT NULL)".into(),
4896 parameters: vec![],
4897 }],
4898 };
4899 let request = encode_sql_command(&command).unwrap();
4900 let payload =
4901 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4902 let entry = command_entry(1, LogHash::ZERO, payload);
4903 let original_inode = fs::metadata(&path).unwrap().ino();
4904 {
4905 let _lifecycle = database.lock_lifecycle().unwrap();
4906 database.close_connection().unwrap();
4907 let mutation = open_connection(&path).unwrap();
4908 mutation
4909 .execute_batch(
4910 "CREATE TABLE external_mutation(value INTEGER NOT NULL); PRAGMA wal_checkpoint(TRUNCATE);",
4911 )
4912 .unwrap();
4913 mutation.close().unwrap();
4914 }
4915 assert_eq!(fs::metadata(&path).unwrap().ino(), original_inode);
4916
4917 assert!(database.apply_entry(&entry).is_err());
4918
4919 assert_eq!(database.control.pending().unwrap(), None);
4920 assert_ne!(
4921 prepared_install_path(&path),
4922 Some(PreparedInstallPath::Promoted)
4923 );
4924 }
4925
4926 #[cfg(unix)]
4927 #[test]
4928 fn missing_prepared_target_falls_back_to_an_in_place_patch() {
4929 let dir = tempfile::tempdir().unwrap();
4930 let path = dir.path().join("state.sqlite");
4931 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4932 let command = SqlCommand {
4933 request_id: "missing-target".into(),
4934 statements: vec![SqlStatement {
4935 sql: "CREATE TABLE rebuilt_missing_target(value INTEGER NOT NULL)".into(),
4936 parameters: vec![],
4937 }],
4938 };
4939 let request = encode_sql_command(&command).unwrap();
4940 let payload =
4941 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4942 let staging_path = database
4943 .prepared_target
4944 .lock()
4945 .unwrap()
4946 .as_ref()
4947 .unwrap()
4948 .artifact
4949 .path()
4950 .to_path_buf();
4951 fs::remove_file(staging_path).unwrap();
4952
4953 database
4954 .apply_entry(&command_entry(1, LogHash::ZERO, payload))
4955 .unwrap();
4956
4957 assert_eq!(
4958 prepared_install_path(&path),
4959 Some(PreparedInstallPath::Patched)
4960 );
4961 }
4962
4963 #[cfg(unix)]
4964 #[test]
4965 fn mutated_prepared_target_falls_back_to_an_in_place_patch() {
4966 let dir = tempfile::tempdir().unwrap();
4967 let path = dir.path().join("state.sqlite");
4968 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
4969 let command = SqlCommand {
4970 request_id: "mutated-target".into(),
4971 statements: vec![SqlStatement {
4972 sql: "CREATE TABLE rebuilt_mutated_target(value INTEGER NOT NULL)".into(),
4973 parameters: vec![],
4974 }],
4975 };
4976 let request = encode_sql_command(&command).unwrap();
4977 let payload =
4978 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
4979 let effect = decode_qwal_v3(&payload).unwrap();
4980 let staging_path = database
4981 .prepared_target
4982 .lock()
4983 .unwrap()
4984 .as_ref()
4985 .unwrap()
4986 .artifact
4987 .path()
4988 .to_path_buf();
4989 let mutation = open_connection(&staging_path).unwrap();
4990 mutation
4991 .execute_batch(
4992 "CREATE TABLE injected_target(value INTEGER NOT NULL); PRAGMA wal_checkpoint(TRUNCATE);",
4993 )
4994 .unwrap();
4995 mutation.close().unwrap();
4996 assert_ne!(
4997 rebuild_page_state(&staging_path).unwrap().identity(),
4998 effect.target_state
4999 );
5000
5001 database
5002 .apply_entry(&command_entry(1, LogHash::ZERO, payload))
5003 .unwrap();
5004
5005 assert_eq!(
5006 prepared_install_path(&path),
5007 Some(PreparedInstallPath::Patched)
5008 );
5009 assert!(database
5010 .query_sql(
5011 &SqlStatement {
5012 sql: "SELECT name FROM sqlite_schema WHERE name = 'injected_target'".into(),
5013 parameters: vec![],
5014 },
5015 1,
5016 1024,
5017 )
5018 .unwrap()
5019 .rows
5020 .is_empty());
5021 }
5022
5023 #[test]
5024 fn stale_prepared_entry_cannot_promote_after_the_tip_moves() {
5025 let dir = tempfile::tempdir().unwrap();
5026 let path = dir.path().join("state.sqlite");
5027 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
5028 let command = SqlCommand {
5029 request_id: "stale-prepared".into(),
5030 statements: vec![SqlStatement {
5031 sql: "CREATE TABLE stale_target(value INTEGER NOT NULL)".into(),
5032 parameters: vec![],
5033 }],
5034 };
5035 let request = encode_sql_command(&command).unwrap();
5036 let payload =
5037 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
5038 let stale = command_entry(1, LogHash::ZERO, payload);
5039 let noop_hash =
5040 LogEntry::calculate_hash("cluster-a", 1, 1, 1, EntryType::Noop, LogHash::ZERO, &[]);
5041 database
5042 .apply_entry(&LogEntry {
5043 cluster_id: "cluster-a".into(),
5044 epoch: 1,
5045 config_id: 1,
5046 index: 1,
5047 entry_type: EntryType::Noop,
5048 payload: vec![],
5049 prev_hash: LogHash::ZERO,
5050 hash: noop_hash,
5051 })
5052 .unwrap();
5053
5054 assert!(database.apply_entry(&stale).is_err());
5055 assert_ne!(
5056 prepared_install_path(&path),
5057 Some(PreparedInstallPath::Promoted)
5058 );
5059 }
5060
5061 #[test]
5062 fn reopening_forgets_the_prepared_target_and_preserves_fallback_receipts() {
5063 let dir = tempfile::tempdir().unwrap();
5064 let path = dir.path().join("state.sqlite");
5065 let command = SqlCommand {
5066 request_id: "restart-fallback".into(),
5067 statements: vec![SqlStatement {
5068 sql: "CREATE TABLE restarted(value INTEGER NOT NULL)".into(),
5069 parameters: vec![],
5070 }],
5071 };
5072 let request = encode_sql_command(&command).unwrap();
5073 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
5074 let payload =
5075 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
5076 drop(database);
5077 let database = SqliteStateMachine::open_existing(&path).unwrap();
5078 let entry = command_entry(1, LogHash::ZERO, payload);
5079
5080 let outcome = database.apply_entry_with_result(&entry).unwrap();
5081
5082 assert_eq!(
5083 prepared_install_path(&path),
5084 Some(PreparedInstallPath::Patched)
5085 );
5086 assert_eq!(
5087 prepared_base_reuse_audit(&path),
5088 Some(PreparedBaseReuseAudit {
5089 second_checkpoint_count: 1,
5090 })
5091 );
5092 assert_eq!(
5093 database
5094 .check_sql_request("restart-fallback", &request)
5095 .unwrap()
5096 .unwrap(),
5097 (
5098 RequestOutcome::new(1, entry.hash),
5099 outcome.sql_result().cloned()
5100 )
5101 );
5102 }
5103
5104 #[test]
5105 fn replay_after_legacy_pending_with_canonical_base_patches_target_and_receipt() {
5106 let dir = tempfile::tempdir().unwrap();
5107 let path = dir.path().join("state.sqlite");
5108 let command = SqlCommand {
5109 request_id: "pending-base-replay".into(),
5110 statements: vec![
5111 SqlStatement {
5112 sql: "CREATE TABLE base_recovery(value TEXT NOT NULL)".into(),
5113 parameters: vec![],
5114 },
5115 SqlStatement {
5116 sql: "INSERT INTO base_recovery VALUES ('recovered') RETURNING value".into(),
5117 parameters: vec![],
5118 },
5119 ],
5120 };
5121 let request = encode_sql_command(&command).unwrap();
5122 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
5123 let payload =
5124 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
5125 let effect = decode_qwal_v3(&payload).unwrap();
5126 let expected_result = decode_sql_result(&effect.receipts[0].result_blob).unwrap();
5127 let entry = command_entry(1, LogHash::ZERO, payload);
5128 let different_command = SqlCommand {
5129 request_id: "different-pending-base".into(),
5130 statements: vec![SqlStatement {
5131 sql: "CREATE TABLE wrong_pending_winner(value INTEGER NOT NULL)".into(),
5132 parameters: vec![],
5133 }],
5134 };
5135 let different_request = encode_sql_command(&different_command).unwrap();
5136 let different_payload = prepare_single_sql_effect(
5137 &database,
5138 &different_command,
5139 &different_request,
5140 0,
5141 LogHash::ZERO,
5142 )
5143 .unwrap();
5144 let different_entry = command_entry(1, LogHash::ZERO, different_payload);
5145 let pending = pending_for(&entry, &effect);
5146 database.control.begin_pending(&pending).unwrap();
5147 let canonical_before_replay = fs::read(&path).unwrap();
5148 drop(database);
5149
5150 let database = SqliteStateMachine::open_existing(&path).unwrap();
5151 assert!(database.pending_fence.load(Ordering::Acquire));
5152 assert!(matches!(
5153 database.get_value("blocked"),
5154 Err(Error::InvalidEntry(message)) if message.contains("pending")
5155 ));
5156 assert!(database
5157 .query_sql(
5158 &SqlStatement {
5159 sql: "SELECT 1".into(),
5160 parameters: vec![],
5161 },
5162 1,
5163 64,
5164 )
5165 .is_err());
5166 assert!(database
5167 .check_request("pending-base-replay", &request)
5168 .is_err());
5169 assert!(database.canonical_db_digest().is_err());
5170 assert!(database.create_snapshot(0).is_err());
5171 assert!(database
5172 .prepare_sql_batch_effect(
5173 &[SqlBatchMember {
5174 command: &command,
5175 request_payload: &request,
5176 }],
5177 0,
5178 LogHash::ZERO,
5179 )
5180 .is_err());
5181 assert!(matches!(
5182 database.apply_entry_with_result(&different_entry),
5183 Err(Error::InvalidEntry(_))
5184 ));
5185 assert_eq!(fs::read(&path).unwrap(), canonical_before_replay);
5186 assert_eq!(
5187 rebuild_page_state(&path).unwrap().identity(),
5188 effect.base_state
5189 );
5190 assert!(database.pending_fence.load(Ordering::Acquire));
5191 assert!(database.get_value("still-blocked").is_err());
5192 let outcome = database.apply_entry_with_result(&entry).unwrap();
5193
5194 assert!(!database.pending_fence.load(Ordering::Acquire));
5195 assert_eq!(outcome.sql_result(), Some(&expected_result));
5196 assert_eq!(
5197 rebuild_page_state(&path).unwrap().identity(),
5198 effect.target_state
5199 );
5200 assert_eq!(
5201 prepared_install_path(&path),
5202 Some(PreparedInstallPath::Patched)
5203 );
5204 assert_eq!(
5205 prepared_base_reuse_audit(&path),
5206 Some(PreparedBaseReuseAudit {
5207 second_checkpoint_count: 1,
5208 })
5209 );
5210 assert_eq!(
5211 database
5212 .check_sql_request("pending-base-replay", &request)
5213 .unwrap()
5214 .unwrap(),
5215 (RequestOutcome::new(1, entry.hash), Some(expected_result))
5216 );
5217 }
5218
5219 #[test]
5220 fn open_rejects_pending_that_no_longer_extends_the_committed_tip() {
5221 let dir = tempfile::tempdir().unwrap();
5222 let path = dir.path().join("state.sqlite");
5223 let command = SqlCommand {
5224 request_id: "corrupt-pending".into(),
5225 statements: vec![SqlStatement {
5226 sql: "CREATE TABLE corrupt_pending(id INTEGER PRIMARY KEY)".into(),
5227 parameters: vec![],
5228 }],
5229 };
5230 let request = encode_sql_command(&command).unwrap();
5231 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
5232 let payload =
5233 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
5234 let effect = decode_qwal_v3(&payload).unwrap();
5235 let entry = command_entry(1, LogHash::ZERO, payload);
5236 database
5237 .control
5238 .begin_pending(&pending_for(&entry, &effect))
5239 .unwrap();
5240 drop(database);
5241
5242 Connection::open(control_sidecar_path(&path))
5243 .unwrap()
5244 .execute(
5245 "UPDATE pending_apply SET base_index = 1 WHERE singleton = 1",
5246 [],
5247 )
5248 .unwrap();
5249
5250 assert!(matches!(
5251 SqliteStateMachine::open_existing(&path),
5252 Err(Error::InvalidEntry(_))
5253 ));
5254 }
5255
5256 #[test]
5257 fn replay_after_promoted_target_without_receipt_commits_idempotently() {
5258 let dir = tempfile::tempdir().unwrap();
5259 let path = dir.path().join("state.sqlite");
5260 let command = SqlCommand {
5261 request_id: "pending-target-replay".into(),
5262 statements: vec![
5263 SqlStatement {
5264 sql: "CREATE TABLE target_recovery(value TEXT NOT NULL)".into(),
5265 parameters: vec![],
5266 },
5267 SqlStatement {
5268 sql: "INSERT INTO target_recovery VALUES ('already-promoted') RETURNING value"
5269 .into(),
5270 parameters: vec![],
5271 },
5272 ],
5273 };
5274 let request = encode_sql_command(&command).unwrap();
5275 let database = SqliteStateMachine::open(&path, "cluster-a", "node-1", 1, 1).unwrap();
5276 let payload =
5277 prepare_single_sql_effect(&database, &command, &request, 0, LogHash::ZERO).unwrap();
5278 let effect = decode_qwal_v3(&payload).unwrap();
5279 let expected_result = decode_sql_result(&effect.receipts[0].result_blob).unwrap();
5280 let entry = command_entry(1, LogHash::ZERO, payload);
5281 let pending = pending_for(&entry, &effect);
5282 database.control.begin_pending(&pending).unwrap();
5283 {
5284 let _lifecycle = database.lock_lifecycle().unwrap();
5285 database.with_connection(checkpoint_truncate).unwrap();
5286 database.close_connection().unwrap();
5287 let prepared = database
5288 .take_matching_prepared_target(&effect, &entry.payload)
5289 .unwrap()
5290 .unwrap();
5291 assert!(database
5292 .promote_prepared_target(&prepared, &effect)
5293 .unwrap());
5294 }
5295 drop(database);
5296
5297 let database = SqliteStateMachine::open_existing(&path).unwrap();
5298 let outcome = database.apply_entry_with_result(&entry).unwrap();
5299
5300 assert_eq!(outcome.sql_result(), Some(&expected_result));
5301 assert_eq!(
5302 rebuild_page_state(&path).unwrap().identity(),
5303 effect.target_state
5304 );
5305 assert_eq!(prepared_install_path(&path), None);
5306 assert_eq!(
5307 database
5308 .check_sql_request("pending-target-replay", &request)
5309 .unwrap()
5310 .unwrap(),
5311 (RequestOutcome::new(1, entry.hash), Some(expected_result))
5312 );
5313 }
5314
5315 fn pending_for(entry: &LogEntry, effect: &QwalEnvelopeV3) -> PendingApply {
5316 PendingApply::new(
5317 LogAnchor::new(effect.base_index, effect.base_hash),
5318 LogAnchor::new(entry.index, entry.hash),
5319 effect.base_state,
5320 effect.target_state,
5321 )
5322 }
5323
5324 fn command_entry(index: u64, prev_hash: LogHash, payload: Vec<u8>) -> LogEntry {
5325 let hash = LogEntry::calculate_hash(
5326 "cluster-a",
5327 index,
5328 1,
5329 1,
5330 EntryType::Command,
5331 prev_hash,
5332 &payload,
5333 );
5334 LogEntry {
5335 cluster_id: "cluster-a".into(),
5336 epoch: 1,
5337 config_id: 1,
5338 index,
5339 entry_type: EntryType::Command,
5340 payload,
5341 prev_hash,
5342 hash,
5343 }
5344 }
5345
5346 #[test]
5347 fn result_decoder_rejects_semantically_oversized_returning_rows() {
5348 let result = SqlCommandResult {
5349 statement_results: vec![SqlStatementResult {
5350 rows_affected: 0,
5351 returning: Some(SqlQueryResult {
5352 columns: vec!["value".into()],
5353 rows: (0..=MAX_RETURNING_ROWS)
5354 .map(|_| vec![SqlValue::Integer(1)])
5355 .collect(),
5356 }),
5357 }],
5358 };
5359 let mut blob = SQL_RESULT_V1_MAGIC.to_vec();
5360 blob.extend_from_slice(&serde_json::to_vec(&result).unwrap());
5361
5362 assert!(matches!(
5363 decode_sql_result(&blob),
5364 Err(Error::InvalidCommand(_))
5365 ));
5366 }
5367}