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