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