lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 004ed8387014bf25af04a75e9e35590105b895ef
parent 4261a1697aaab685d27689e581109bbbfe723376
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 03:51:59 +0000

storage-sqlite: finalize and recover restores

- quiesce pools while retaining exclusive restore-close authority
- persist exact-digest markers before WAL fencing and live renames
- atomically advance members and forward recover partial installations
- prove cross-database interruption concurrency sidecar and marker safety

Diffstat:
Acontracts/storage/restore_finalize_policy_v1.toml | 14++++++++++++++
Mcrates/storage_sqlite/README.md | 9+++++++++
Mcrates/storage_sqlite/src/backup.rs | 907++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/storage_sqlite/src/open.rs | 35++++++++++++++++++++++++++++++++++-
Mcrates/storage_sqlite/src/status.rs | 28+++++++++++++++++++++++++---
5 files changed, 986 insertions(+), 7 deletions(-)

diff --git a/contracts/storage/restore_finalize_policy_v1.toml b/contracts/storage/restore_finalize_policy_v1.toml @@ -0,0 +1,14 @@ +schema_version = 1 +authority = "open_writable_backend_with_exclusive_writer_lock" +quiescence = "close_all_owned_pools_before_live_rename" +wal_sidecars = "marker_precedes_and_fences_absence_before_live_rename" +marker = ".radroots-storage-restore-{backup_id_hex}.marker" +marker_encoding = "fixed_binary_v1_exact_member_lengths_and_sha256" +marker_durability = ["marker_file_fsync", "runtime_parent_fsync"] +previous_runtime = ".runtime.sqlite.restore-{backup_id_hex}.previous" +previous_protected = ".private.sqlite.restore-{backup_id_hex}.previous" +replacement = ["live_to_previous_atomic_rename", "staging_to_live_atomic_rename"] +recovery = "marker_driven_idempotent_forward_completion_before_open" +read_only_recovery = "reject" +cleanup_order = ["verify_all_live_members", "remove_previous_members", "remove_marker"] +backend_after_attempt = "closed_reopen_required" diff --git a/crates/storage_sqlite/README.md b/crates/storage_sqlite/README.md @@ -20,6 +20,15 @@ member, synchronizes captured files and directories, and returns exact lengths and SHA-256 digests in the backend-neutral manifest. Protected storage is included only when the plan requests it explicitly. +Restore first copies a finalized bundle into create-new files adjacent to the +live databases and verifies their exact digests, schema catalogs, SQLite +integrity, and foreign keys without changing live state. Finalization closes +the owned pools while retaining writer authority, persists a versioned recovery +marker, atomically replaces each policy-selected member, and requires callers +to reopen the closed backend. Writable open completes an interrupted marked +replacement before opening connections; read-only open fails closed until that +recovery is complete. + ```rust,no_run use radroots_storage::event::SourceGeneration; use radroots_storage_sqlite::{OpenMode, OpenOptions, Paths, SqliteStorage}; diff --git a/crates/storage_sqlite/src/backup.rs b/crates/storage_sqlite/src/backup.rs @@ -3,12 +3,12 @@ use std::{ collections::BTreeSet, fs::{self, File}, - io::Read, + io::{Read, Write}, path::{Component, Path, PathBuf}, }; use radroots_storage::backup::{ - BackupFormatVersion, BackupManifest, BackupMember, BackupMemberKind, BackupPlan, + BackupFormatVersion, BackupId, BackupManifest, BackupMember, BackupMemberKind, BackupPlan, BackupSecretPolicy, MemberDigest, MemberVerification, RestoreMemberStatus, RestorePlan, }; use radroots_storage::status::EventStoreMode; @@ -22,6 +22,8 @@ const RUNTIME_DATABASE: &str = "runtime.sqlite"; const PRIVATE_DATABASE: &str = "private.sqlite"; const RUNTIME_MEMBER: &str = "runtime/runtime.sqlite"; const PRIVATE_MEMBER: &str = "private/private.sqlite"; +const RESTORE_MARKER_MAGIC: &[u8; 8] = b"RDRSTR01"; +const RESTORE_MARKER_BYTES: usize = 105; impl SqliteStorage { /// Captures consistent SQLite snapshots into a new deterministic staging @@ -226,6 +228,41 @@ impl SqliteStorage { } Ok(statuses) } + + /// Quiesces this writable backend, records a durable interruption marker, + /// and installs every completely verified staged member. The backend is + /// closed after the attempt and must be reopened to observe restored state. + pub async fn finalize_restore(&self, plan: &RestorePlan) -> Result<(), Error> { + self.lifecycle + .require_open() + .map_err(|_| Error::BackupBackendUnavailable)?; + if self.mode != EventStoreMode::ReadWrite { + return Err(Error::RestoreRequiresWritableStorage); + } + let paths = self + .paths + .as_deref() + .ok_or(Error::BackupBackendUnavailable)?; + let marker = RestoreMarker::from_manifest(plan.manifest())?; + let layout = RestoreLayout::new(paths, marker.backup_id())?; + verify_staged_restore(&layout, &marker).await?; + layout.require_previous_absent(marker.secret_policy())?; + + self.lifecycle + .begin_restore_close() + .map_err(|_| Error::BackupBackendUnavailable)?; + self.pool.close().await; + self.private_pool.close().await; + let installation = async { + verify_staged_restore(&layout, &marker).await?; + write_restore_marker(&layout.marker, &marker)?; + recover_interrupted_restore(paths, OpenMode::ReadWriteExisting).await + } + .await; + let close = self.lifecycle.finish_restore_close(); + installation?; + close.map_err(|_| Error::BackupBackendUnavailable) + } } pub(crate) fn validate_backup_root(path: &Path) -> Result<(), Error> { @@ -365,12 +402,521 @@ impl RestoreStaging { } } +struct RestoreLayout { + runtime_live: PathBuf, + private_live: PathBuf, + runtime_staging: PathBuf, + private_staging: PathBuf, + runtime_previous: PathBuf, + private_previous: PathBuf, + marker: PathBuf, +} + +impl RestoreLayout { + fn new(paths: &crate::Paths, backup_id: BackupId) -> Result<Self, Error> { + let id = encode_id(backup_id.as_bytes()); + let runtime_parent = paths + .runtime() + .parent() + .ok_or_else(|| Error::InvalidPath(paths.runtime().to_path_buf()))?; + Ok(Self { + runtime_live: paths.runtime().to_path_buf(), + private_live: paths.private().to_path_buf(), + runtime_staging: restore_sidecar_path(paths.runtime(), &id, "staging")?, + private_staging: restore_sidecar_path(paths.private(), &id, "staging")?, + runtime_previous: restore_sidecar_path(paths.runtime(), &id, "previous")?, + private_previous: restore_sidecar_path(paths.private(), &id, "previous")?, + marker: runtime_parent.join(format!(".radroots-storage-restore-{id}.marker")), + }) + } + + fn require_previous_absent(&self, policy: BackupSecretPolicy) -> Result<(), Error> { + let paths = if policy == BackupSecretPolicy::IncludeProtectedStorage { + vec![&self.runtime_previous, &self.private_previous] + } else { + vec![&self.runtime_previous] + }; + for path in paths { + if restore_entry_kind(path)? != RestoreEntryKind::Missing { + return Err(Error::RestoreRecoveryConflict(path.clone())); + } + } + if restore_entry_kind(&self.marker)? != RestoreEntryKind::Missing { + return Err(Error::RestoreRecoveryConflict(self.marker.clone())); + } + Ok(()) + } +} + fn staged_restore_path(live: &Path, id: &str) -> Result<PathBuf, Error> { + restore_sidecar_path(live, id, "staging") +} + +fn restore_sidecar_path(live: &Path, id: &str, role: &str) -> Result<PathBuf, Error> { let name = live .file_name() .and_then(|value| value.to_str()) .ok_or_else(|| Error::InvalidPath(live.to_path_buf()))?; - Ok(live.with_file_name(format!(".{name}.restore-{id}.staging"))) + Ok(live.with_file_name(format!(".{name}.restore-{id}.{role}"))) +} + +#[derive(Clone, Copy)] +struct RestoreMemberExpectation { + byte_length: u64, + sha256: MemberDigest, +} + +impl RestoreMemberExpectation { + fn from_member(member: &BackupMember, expected_kind: BackupMemberKind) -> Result<Self, Error> { + if member.kind() != expected_kind { + return Err(Error::RestoreReplacementFailed { member: "manifest" }); + } + Ok(Self { + byte_length: member.byte_length(), + sha256: member.sha256(), + }) + } + + fn member( + self, + relative_path: &'static str, + kind: BackupMemberKind, + ) -> Result<BackupMember, Error> { + BackupMember::new(relative_path, kind, self.byte_length, self.sha256).map_err(|_| { + Error::RestoreReplacementFailed { + member: relative_path, + } + }) + } +} + +struct RestoreMarker { + backup_id: BackupId, + secret_policy: BackupSecretPolicy, + runtime: RestoreMemberExpectation, + private: Option<RestoreMemberExpectation>, +} + +impl RestoreMarker { + fn from_manifest(manifest: &BackupManifest) -> Result<Self, Error> { + if manifest.format_version() != BackupFormatVersion::V1 { + return Err(Error::UnsupportedBackupVersion); + } + let expected = if manifest.secret_policy() == BackupSecretPolicy::IncludeProtectedStorage { + BTreeSet::from([PRIVATE_MEMBER, RUNTIME_MEMBER]) + } else { + BTreeSet::from([RUNTIME_MEMBER]) + }; + let actual = manifest + .members() + .iter() + .map(BackupMember::relative_path) + .collect::<BTreeSet<_>>(); + if actual != expected { + return Err(Error::RestoreReplacementFailed { member: "manifest" }); + } + let runtime = RestoreMemberExpectation::from_member( + manifest + .member(RUNTIME_MEMBER) + .ok_or(Error::RestoreReplacementFailed { member: "manifest" })?, + BackupMemberKind::Runtime, + )?; + let private = manifest + .member(PRIVATE_MEMBER) + .map(|member| { + RestoreMemberExpectation::from_member(member, BackupMemberKind::Protected) + }) + .transpose()?; + Ok(Self { + backup_id: manifest.backup_id(), + secret_policy: manifest.secret_policy(), + runtime, + private, + }) + } + + const fn backup_id(&self) -> BackupId { + self.backup_id + } + + const fn secret_policy(&self) -> BackupSecretPolicy { + self.secret_policy + } + + fn encode(&self) -> [u8; RESTORE_MARKER_BYTES] { + let mut encoded = [0_u8; RESTORE_MARKER_BYTES]; + encoded[..8].copy_from_slice(RESTORE_MARKER_MAGIC); + encoded[8] = u8::from(self.private.is_some()); + encoded[9..25].copy_from_slice(self.backup_id.as_bytes()); + encoded[25..33].copy_from_slice(&self.runtime.byte_length.to_be_bytes()); + encoded[33..65].copy_from_slice(self.runtime.sha256.as_bytes()); + if let Some(private) = self.private { + encoded[65..73].copy_from_slice(&private.byte_length.to_be_bytes()); + encoded[73..105].copy_from_slice(private.sha256.as_bytes()); + } + encoded + } + + fn decode(path: &Path, encoded: &[u8]) -> Result<Self, Error> { + if encoded.len() != RESTORE_MARKER_BYTES || &encoded[..8] != RESTORE_MARKER_MAGIC { + return Err(Error::RestoreMarkerCorrupt(path.to_path_buf())); + } + let secret_policy = match encoded[8] { + 0 => BackupSecretPolicy::ExcludeProtectedStorage, + 1 => BackupSecretPolicy::IncludeProtectedStorage, + _ => return Err(Error::RestoreMarkerCorrupt(path.to_path_buf())), + }; + let backup_id = BackupId::new( + encoded[9..25] + .try_into() + .map_err(|_| Error::RestoreMarkerCorrupt(path.to_path_buf()))?, + ) + .map_err(|_| Error::RestoreMarkerCorrupt(path.to_path_buf()))?; + let runtime = RestoreMemberExpectation { + byte_length: u64::from_be_bytes( + encoded[25..33] + .try_into() + .map_err(|_| Error::RestoreMarkerCorrupt(path.to_path_buf()))?, + ), + sha256: MemberDigest::new( + encoded[33..65] + .try_into() + .map_err(|_| Error::RestoreMarkerCorrupt(path.to_path_buf()))?, + ), + }; + if runtime.byte_length == 0 { + return Err(Error::RestoreMarkerCorrupt(path.to_path_buf())); + } + let private_length = u64::from_be_bytes( + encoded[65..73] + .try_into() + .map_err(|_| Error::RestoreMarkerCorrupt(path.to_path_buf()))?, + ); + let private_digest: [u8; 32] = encoded[73..105] + .try_into() + .map_err(|_| Error::RestoreMarkerCorrupt(path.to_path_buf()))?; + let private = match secret_policy { + BackupSecretPolicy::ExcludeProtectedStorage + if private_length == 0 && private_digest == [0; 32] => + { + None + } + BackupSecretPolicy::IncludeProtectedStorage if private_length > 0 => { + Some(RestoreMemberExpectation { + byte_length: private_length, + sha256: MemberDigest::new(private_digest), + }) + } + _ => return Err(Error::RestoreMarkerCorrupt(path.to_path_buf())), + }; + Ok(Self { + backup_id, + secret_policy, + runtime, + private, + }) + } +} + +fn write_restore_marker(path: &Path, marker: &RestoreMarker) -> Result<(), Error> { + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(path) + .map_err(|source| Error::RestoreFilesystem { + operation: "create restore interruption marker", + source, + })?; + file.write_all(&marker.encode()) + .and_then(|()| file.sync_all()) + .map_err(|source| Error::RestoreFilesystem { + operation: "persist restore interruption marker", + source, + })?; + sync_parent(path, "sync restore marker parent") +} + +fn read_restore_marker(path: &Path) -> Result<RestoreMarker, Error> { + let metadata = fs::symlink_metadata(path).map_err(|source| Error::RestoreFilesystem { + operation: "inspect restore interruption marker", + source, + })?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() != RESTORE_MARKER_BYTES as u64 + { + return Err(Error::RestoreMarkerCorrupt(path.to_path_buf())); + } + let encoded = fs::read(path).map_err(|source| Error::RestoreFilesystem { + operation: "read restore interruption marker", + source, + })?; + RestoreMarker::decode(path, &encoded) +} + +pub(crate) async fn recover_interrupted_restore( + paths: &crate::Paths, + mode: OpenMode, +) -> Result<(), Error> { + let Some(marker_path) = discover_restore_marker(paths)? else { + return Ok(()); + }; + if !mode.is_writable() { + return Err(Error::RestoreRequiresWritableStorage); + } + let marker = read_restore_marker(&marker_path)?; + let layout = RestoreLayout::new(paths, marker.backup_id())?; + if layout.marker != marker_path { + return Err(Error::RestoreMarkerCorrupt(marker_path)); + } + require_sqlite_sidecars_absent(paths)?; + install_restore_member( + &layout.runtime_live, + &layout.runtime_staging, + &layout.runtime_previous, + marker.runtime, + BackupMemberKind::Runtime, + RUNTIME_MEMBER, + true, + ) + .await?; + if let Some(private) = marker.private { + install_restore_member( + &layout.private_live, + &layout.private_staging, + &layout.private_previous, + private, + BackupMemberKind::Protected, + PRIVATE_MEMBER, + false, + ) + .await?; + } + verify_installed_restore(&layout, &marker).await?; + remove_restore_file(&layout.runtime_previous, "remove previous runtime database")?; + if marker.private.is_some() { + remove_restore_file(&layout.private_previous, "remove previous private database")?; + } + remove_restore_file(&layout.marker, "remove restore interruption marker")?; + Ok(()) +} + +fn discover_restore_marker(paths: &crate::Paths) -> Result<Option<PathBuf>, Error> { + let parent = paths + .runtime() + .parent() + .ok_or_else(|| Error::InvalidPath(paths.runtime().to_path_buf()))?; + let parent_metadata = match fs::symlink_metadata(parent) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(Error::RestoreFilesystem { + operation: "inspect restore marker parent", + source, + }); + } + }; + if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { + return Ok(None); + } + let mut marker = None; + for entry in fs::read_dir(parent).map_err(|source| Error::RestoreFilesystem { + operation: "scan restore interruption markers", + source, + })? { + let entry = entry.map_err(|source| Error::RestoreFilesystem { + operation: "read restore interruption marker entry", + source, + })?; + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if !name.starts_with(".radroots-storage-restore-") || !name.ends_with(".marker") { + continue; + } + if marker.replace(entry.path()).is_some() { + return Err(Error::RestoreRecoveryConflict(parent.to_path_buf())); + } + } + Ok(marker) +} + +async fn verify_staged_restore( + layout: &RestoreLayout, + marker: &RestoreMarker, +) -> Result<(), Error> { + verify_restore_path( + &layout.runtime_staging, + marker.runtime, + BackupMemberKind::Runtime, + RUNTIME_MEMBER, + true, + ) + .await?; + if let Some(private) = marker.private { + verify_restore_path( + &layout.private_staging, + private, + BackupMemberKind::Protected, + PRIVATE_MEMBER, + false, + ) + .await?; + } + Ok(()) +} + +async fn verify_installed_restore( + layout: &RestoreLayout, + marker: &RestoreMarker, +) -> Result<(), Error> { + verify_restore_path( + &layout.runtime_live, + marker.runtime, + BackupMemberKind::Runtime, + RUNTIME_MEMBER, + true, + ) + .await?; + if let Some(private) = marker.private { + verify_restore_path( + &layout.private_live, + private, + BackupMemberKind::Protected, + PRIVATE_MEMBER, + false, + ) + .await?; + } + Ok(()) +} + +async fn verify_restore_path( + path: &Path, + expected: RestoreMemberExpectation, + kind: BackupMemberKind, + member_name: &'static str, + runtime: bool, +) -> Result<(), Error> { + let member = expected.member(member_name, kind)?; + verify_member(path, &member, kind, member_name, runtime) + .await + .map_err(|_| Error::RestoreReplacementFailed { + member: member_name, + }) +} + +async fn install_restore_member( + live: &Path, + staging: &Path, + previous: &Path, + expected: RestoreMemberExpectation, + kind: BackupMemberKind, + member_name: &'static str, + runtime: bool, +) -> Result<(), Error> { + let live_kind = restore_entry_kind(live)?; + let staging_kind = restore_entry_kind(staging)?; + let previous_kind = restore_entry_kind(previous)?; + if [live_kind, staging_kind, previous_kind] + .into_iter() + .any(|entry| entry == RestoreEntryKind::Other) + { + return Err(Error::RestoreRecoveryConflict(live.to_path_buf())); + } + + if live_kind == RestoreEntryKind::File && restore_member_matches(live, expected)? { + verify_restore_path(live, expected, kind, member_name, runtime).await?; + if staging_kind == RestoreEntryKind::File { + verify_restore_path(staging, expected, kind, member_name, runtime).await?; + remove_restore_file(staging, "remove redundant restore staging member")?; + } + return Ok(()); + } + if staging_kind != RestoreEntryKind::File { + return Err(Error::RestoreReplacementFailed { + member: member_name, + }); + } + verify_restore_path(staging, expected, kind, member_name, runtime).await?; + match (live_kind, previous_kind) { + (RestoreEntryKind::File, RestoreEntryKind::Missing) => { + fs::rename(live, previous).map_err(|source| Error::RestoreFilesystem { + operation: "rename live database to previous restore sidecar", + source, + })?; + sync_parent(live, "sync previous database rename")?; + } + (RestoreEntryKind::Missing, RestoreEntryKind::File) => {} + _ => return Err(Error::RestoreRecoveryConflict(live.to_path_buf())), + } + fs::rename(staging, live).map_err(|source| Error::RestoreFilesystem { + operation: "rename staged restore member into live path", + source, + })?; + sync_parent(live, "sync live restore replacement")?; + verify_restore_path(live, expected, kind, member_name, runtime).await +} + +fn restore_member_matches(path: &Path, expected: RestoreMemberExpectation) -> Result<bool, Error> { + let (length, digest) = fingerprint(path)?; + Ok(length == expected.byte_length && digest == expected.sha256) +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum RestoreEntryKind { + Missing, + File, + Other, +} + +fn restore_entry_kind(path: &Path) -> Result<RestoreEntryKind, Error> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => { + Ok(RestoreEntryKind::File) + } + Ok(_) => Ok(RestoreEntryKind::Other), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => { + Ok(RestoreEntryKind::Missing) + } + Err(source) => Err(Error::RestoreFilesystem { + operation: "inspect restore path", + source, + }), + } +} + +fn remove_restore_file(path: &Path, operation: &'static str) -> Result<(), Error> { + match restore_entry_kind(path)? { + RestoreEntryKind::Missing => Ok(()), + RestoreEntryKind::File => { + fs::remove_file(path) + .map_err(|source| Error::RestoreFilesystem { operation, source })?; + sync_parent(path, "sync restore cleanup") + } + RestoreEntryKind::Other => Err(Error::RestoreRecoveryConflict(path.to_path_buf())), + } +} + +fn require_sqlite_sidecars_absent(paths: &crate::Paths) -> Result<(), Error> { + for live in [paths.runtime(), paths.private()] { + let name = live + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| Error::InvalidPath(live.to_path_buf()))?; + for suffix in ["wal", "shm"] { + let sidecar = live.with_file_name(format!("{name}-{suffix}")); + if restore_entry_kind(&sidecar)? != RestoreEntryKind::Missing { + return Err(Error::RestoreRecoveryConflict(sidecar)); + } + } + } + Ok(()) } async fn copy_staged_member( @@ -714,6 +1260,7 @@ mod tests { MemberVerification, RestorePlan, }, event::SourceGeneration, + status::ShutdownState, }; use serde::Deserialize; use sqlx::{Connection, Row, SqliteConnection, sqlite::SqliteConnectOptions}; @@ -727,6 +1274,8 @@ mod tests { include_str!("../../../contracts/storage/backup_finalize_policy_v1.toml"); const RESTORE_POLICY: &str = include_str!("../../../contracts/storage/restore_staging_policy_v1.toml"); + const RESTORE_FINALIZE_POLICY: &str = + include_str!("../../../contracts/storage/restore_finalize_policy_v1.toml"); #[derive(Deserialize)] struct Policy { @@ -777,6 +1326,24 @@ mod tests { filesystem_sync: Vec<String>, } + #[derive(Deserialize)] + struct RestoreFinalizePolicy { + schema_version: u32, + authority: String, + quiescence: String, + wal_sidecars: String, + marker: String, + marker_encoding: String, + marker_durability: Vec<String>, + previous_runtime: String, + previous_protected: String, + replacement: Vec<String>, + recovery: String, + read_only_recovery: String, + cleanup_order: Vec<String>, + backend_after_attempt: String, + } + fn generation(byte: u8) -> SourceGeneration { SourceGeneration::new([byte; 32]).expect("source generation") } @@ -819,6 +1386,25 @@ mod tests { value } + async fn insert_private_artifact(store: &SqliteStorage, byte: u8) { + sqlx::query( + "INSERT INTO radroots_private_artifacts ( + artifact_id, artifact_kind, schema_id, commitment, + protected_size_bytes, secret_provider, secret_reference, + key_version, envelope_version, encrypted_envelope, + delete_not_before_unix_ms, expires_at_unix_ms, revision, stage, + created_at_unix_ms, updated_at_unix_ms, deleted_at_unix_ms, + deletion_reason, tombstone_commitment + ) VALUES (?, 'test', 'test.v1', ?, 1, 'test', 'ref', 1, + NULL, NULL, NULL, NULL, 1, 'active', 1, 1, NULL, NULL, NULL)", + ) + .bind(vec![byte; 16]) + .bind(vec![byte; 32]) + .execute(&store.private_pool) + .await + .expect("insert private artifact"); + } + #[test] fn implementation_matches_the_governed_backup_capture_policy() { let policy = toml::from_str::<Policy>(POLICY).expect("backup capture policy"); @@ -918,6 +1504,66 @@ mod tests { ); } + #[test] + fn implementation_matches_the_governed_restore_finalize_policy() { + let policy = toml::from_str::<RestoreFinalizePolicy>(RESTORE_FINALIZE_POLICY) + .expect("restore finalize policy"); + assert_eq!(policy.schema_version, 1); + assert_eq!( + policy.authority, + "open_writable_backend_with_exclusive_writer_lock" + ); + assert_eq!( + policy.quiescence, + "close_all_owned_pools_before_live_rename" + ); + assert_eq!( + policy.wal_sidecars, + "marker_precedes_and_fences_absence_before_live_rename" + ); + assert_eq!( + policy.marker, + ".radroots-storage-restore-{backup_id_hex}.marker" + ); + assert_eq!( + policy.marker_encoding, + "fixed_binary_v1_exact_member_lengths_and_sha256" + ); + assert_eq!( + policy.marker_durability, + ["marker_file_fsync", "runtime_parent_fsync"] + ); + assert_eq!( + policy.previous_runtime, + ".runtime.sqlite.restore-{backup_id_hex}.previous" + ); + assert_eq!( + policy.previous_protected, + ".private.sqlite.restore-{backup_id_hex}.previous" + ); + assert_eq!( + policy.replacement, + [ + "live_to_previous_atomic_rename", + "staging_to_live_atomic_rename" + ] + ); + assert_eq!( + policy.recovery, + "marker_driven_idempotent_forward_completion_before_open" + ); + assert_eq!(policy.read_only_recovery, "reject"); + assert_eq!( + policy.cleanup_order, + [ + "verify_all_live_members", + "remove_previous_members", + "remove_marker" + ] + ); + assert_eq!(policy.backend_after_attempt, "closed_reopen_required"); + } + #[tokio::test] async fn capture_excludes_protected_storage_and_includes_latest_wal_state() { let database_root = tempfile::tempdir().expect("database root"); @@ -1298,4 +1944,259 @@ mod tests { Err(Error::RestoreRequiresWritableStorage) )); } + + #[tokio::test] + async fn restore_finalization_atomically_replaces_every_selected_member_and_closes() { + let database_root = tempfile::tempdir().expect("database root"); + let backup_parent = tempfile::tempdir().expect("backup parent"); + let backup_root = backup_parent.path().join("backups"); + fs::create_dir(&backup_root).expect("backup root"); + let (paths, store) = create(database_root.path(), Some(&backup_root)).await; + sqlx::query( + "UPDATE radroots_runtime_source_generations SET sequence_head = 61 WHERE state = 'active'", + ) + .execute(&store.pool) + .await + .expect("initial runtime state"); + insert_private_artifact(&store, 1).await; + let backup_plan = plan(102, BackupSecretPolicy::IncludeProtectedStorage, 10_300); + let manifest = store + .capture_backup(&backup_plan) + .await + .expect("capture restore source"); + store + .finalize_backup(&backup_plan, &manifest) + .await + .expect("finalize restore source"); + sqlx::query( + "UPDATE radroots_runtime_source_generations SET sequence_head = 62 WHERE state = 'active'", + ) + .execute(&store.pool) + .await + .expect("advance runtime state"); + insert_private_artifact(&store, 2).await; + let restore = RestorePlan::new( + manifest.clone(), + BackupSecretPolicy::IncludeProtectedStorage, + 10_400, + ) + .expect("restore plan"); + store.stage_restore(&restore).await.expect("stage restore"); + let layout = RestoreLayout::new(&paths, manifest.backup_id()).expect("restore layout"); + let held_connection = store.pool.acquire().await.expect("held connection"); + let restoring = store.clone(); + let restore_plan = restore.clone(); + let finalization = tokio::spawn(async move { + restoring + .finalize_restore(&restore_plan) + .await + .expect("finalize restore") + }); + for _ in 0..10_000 { + if store + .storage_status() + .await + .expect("restoring status") + .shutdown() + == ShutdownState::Closing + { + break; + } + tokio::task::yield_now().await; + } + assert!(!finalization.is_finished()); + assert_eq!( + store + .storage_status() + .await + .expect("restoring status") + .shutdown(), + ShutdownState::Closing + ); + let concurrent_close_store = store.clone(); + let concurrent_close = tokio::spawn(async move { + concurrent_close_store + .close() + .await + .expect("concurrent close") + }); + tokio::task::yield_now().await; + assert!(!concurrent_close.is_finished()); + assert!(matches!( + SqliteStorage::open(OpenOptions::new(paths.clone(), OpenMode::ReadWriteExisting)).await, + Err(Error::WriterAlreadyActive { .. }) + )); + drop(held_connection); + let (finalization, concurrent_close) = tokio::join!(finalization, concurrent_close); + finalization.expect("restore finalization task"); + let close_status = concurrent_close.expect("concurrent close task"); + assert!(matches!( + close_status.shutdown(), + ShutdownState::Closing | ShutdownState::Closed + )); + assert_eq!( + store + .storage_status() + .await + .expect("closed restore status") + .shutdown(), + ShutdownState::Closed + ); + + let reopened = SqliteStorage::open( + OpenOptions::new(paths.clone(), OpenMode::ReadWriteExisting) + .with_backup_root(&backup_root) + .expect("backup root"), + ) + .await + .expect("reopen restored storage"); + assert_eq!( + scalar( + paths.runtime(), + "SELECT sequence_head FROM radroots_runtime_source_generations WHERE state = 'active'" + ) + .await, + 61 + ); + assert_eq!( + scalar( + paths.private(), + "SELECT COUNT(*) FROM radroots_private_artifacts" + ) + .await, + 1 + ); + for path in [ + layout.runtime_staging, + layout.private_staging, + layout.runtime_previous, + layout.private_previous, + layout.marker, + ] { + assert!( + !path.exists(), + "restore artifact remained: {}", + path.display() + ); + } + reopened.close().await.expect("close restored storage"); + } + + #[tokio::test] + async fn writable_open_completes_an_interrupted_restore_before_connections_open() { + let database_root = tempfile::tempdir().expect("database root"); + let backup_parent = tempfile::tempdir().expect("backup parent"); + let backup_root = backup_parent.path().join("backups"); + fs::create_dir(&backup_root).expect("backup root"); + let (paths, store) = create(database_root.path(), Some(&backup_root)).await; + sqlx::query( + "UPDATE radroots_runtime_source_generations SET sequence_head = 71 WHERE state = 'active'", + ) + .execute(&store.pool) + .await + .expect("initial runtime state"); + insert_private_artifact(&store, 3).await; + let backup_plan = plan(103, BackupSecretPolicy::IncludeProtectedStorage, 10_500); + let manifest = store + .capture_backup(&backup_plan) + .await + .expect("capture restore source"); + store + .finalize_backup(&backup_plan, &manifest) + .await + .expect("finalize restore source"); + sqlx::query( + "UPDATE radroots_runtime_source_generations SET sequence_head = 72 WHERE state = 'active'", + ) + .execute(&store.pool) + .await + .expect("advance runtime state"); + insert_private_artifact(&store, 4).await; + let restore = RestorePlan::new( + manifest.clone(), + BackupSecretPolicy::IncludeProtectedStorage, + 10_600, + ) + .expect("restore plan"); + store.stage_restore(&restore).await.expect("stage restore"); + store.close().await.expect("close before simulated crash"); + require_sqlite_sidecars_absent(&paths).expect("quiesced SQLite sidecars"); + let marker = RestoreMarker::from_manifest(&manifest).expect("restore marker"); + let layout = RestoreLayout::new(&paths, manifest.backup_id()).expect("restore layout"); + write_restore_marker(&layout.marker, &marker).expect("persist restore marker"); + fs::rename(&layout.runtime_live, &layout.runtime_previous) + .expect("simulate interrupted previous rename"); + fs::rename(&layout.runtime_staging, &layout.runtime_live) + .expect("simulate installed runtime member"); + sync_parent(&layout.runtime_live, "sync simulated interruption") + .expect("sync simulated interruption"); + + assert!(matches!( + SqliteStorage::open(OpenOptions::new(paths.clone(), OpenMode::ReadOnly)).await, + Err(Error::RestoreRequiresWritableStorage) + )); + let runtime_wal = paths.runtime().with_file_name("runtime.sqlite-wal"); + fs::write(&runtime_wal, b"simulated reader sidecar").expect("simulated WAL sidecar"); + assert!(matches!( + SqliteStorage::open(OpenOptions::new( + paths.clone(), + OpenMode::ReadWriteExisting + )) + .await, + Err(Error::RestoreRecoveryConflict(path)) if path == runtime_wal + )); + assert!(layout.marker.is_file()); + fs::remove_file(&runtime_wal).expect("remove simulated WAL sidecar"); + sync_parent(&runtime_wal, "sync simulated WAL cleanup") + .expect("sync simulated WAL cleanup"); + let recovered = + SqliteStorage::open(OpenOptions::new(paths.clone(), OpenMode::ReadWriteExisting)) + .await + .expect("recover interrupted restore"); + assert_eq!( + scalar( + paths.runtime(), + "SELECT sequence_head FROM radroots_runtime_source_generations WHERE state = 'active'" + ) + .await, + 71 + ); + assert_eq!( + scalar( + paths.private(), + "SELECT COUNT(*) FROM radroots_private_artifacts" + ) + .await, + 1 + ); + for path in [ + layout.runtime_staging, + layout.private_staging, + layout.runtime_previous, + layout.private_previous, + layout.marker, + ] { + assert!( + !path.exists(), + "recovery artifact remained: {}", + path.display() + ); + } + recovered.close().await.expect("close recovered storage"); + } + + #[tokio::test] + async fn corrupt_restore_markers_fail_closed_without_opening_live_state() { + let database_root = tempfile::tempdir().expect("database root"); + let (paths, store) = create(database_root.path(), None).await; + store.close().await.expect("close storage"); + let backup_id = BackupId::new([104; 16]).expect("backup id"); + let layout = RestoreLayout::new(&paths, backup_id).expect("restore layout"); + fs::write(&layout.marker, [0_u8; RESTORE_MARKER_BYTES]).expect("corrupt marker"); + sync_parent(&layout.marker, "sync corrupt marker").expect("sync corrupt marker"); + assert!(matches!( + SqliteStorage::open(OpenOptions::new(paths, OpenMode::ReadWriteExisting)).await, + Err(Error::RestoreMarkerCorrupt(_)) + )); + } } diff --git a/crates/storage_sqlite/src/open.rs b/crates/storage_sqlite/src/open.rs @@ -257,6 +257,15 @@ pub enum Error { RestoreStagingFailed { member: &'static str, }, + RestoreMarkerCorrupt(PathBuf), + RestoreRecoveryConflict(PathBuf), + RestoreReplacementFailed { + member: &'static str, + }, + RestoreFilesystem { + operation: &'static str, + source: std::io::Error, + }, BackupFilesystem { operation: &'static str, source: std::io::Error, @@ -465,6 +474,28 @@ impl fmt::Display for Error { Self::RestoreStagingFailed { member } => { write!(formatter, "failed to stage SQLite restore member {member}") } + Self::RestoreMarkerCorrupt(path) => write!( + formatter, + "SQLite restore interruption marker is corrupt: {}", + path.display() + ), + Self::RestoreRecoveryConflict(path) => write!( + formatter, + "SQLite restore recovery state conflicts at: {}", + path.display() + ), + Self::RestoreReplacementFailed { member } => { + write!( + formatter, + "failed to replace SQLite restore member {member}" + ) + } + Self::RestoreFilesystem { operation, .. } => { + write!( + formatter, + "SQLite restore filesystem operation failed: {operation}" + ) + } Self::BackupFilesystem { operation, .. } => { write!( formatter, @@ -479,6 +510,8 @@ impl SqliteStorage { /// Opens both governed databases, applying only authorized forward /// migrations and retaining the writer guard for the backend lifetime. pub async fn open(options: OpenOptions) -> Result<Self, Error> { + let writer_lock = WriterLock::acquire(options.paths(), options.mode())?; + crate::backup::recover_interrupted_restore(options.paths(), options.mode()).await?; options.validate_filesystem()?; let runtime_exists = options @@ -492,7 +525,6 @@ impl SqliteStorage { if options.mode().may_create() && !runtime_exists && options.source_generation().is_none() { return Err(Error::SourceGenerationRequired); } - let writer_lock = WriterLock::acquire(options.paths(), options.mode())?; options.validate_filesystem()?; let runtime_options = connect_options( @@ -730,6 +762,7 @@ impl StdError for Error { | Self::WriterLockOpen { source, .. } | Self::WriterLockFailed { source, .. } | Self::WriterUnlockFailed { source, .. } + | Self::RestoreFilesystem { source, .. } | Self::BackupFilesystem { source, .. } => Some(source), _ => None, } diff --git a/crates/storage_sqlite/src/status.rs b/crates/storage_sqlite/src/status.rs @@ -21,6 +21,7 @@ use crate::{OpenMode, SqliteStorage, integrity, lock::WriterLock}; const OPEN: u8 = 0; const CLOSING: u8 = 1; const CLOSED: u8 = 2; +const RESTORING: u8 = 3; pub(crate) struct StorageLifecycle { open_mode: StorageOpenMode, @@ -104,18 +105,39 @@ impl StorageLifecycle { fn shutdown(&self) -> ShutdownState { match self.shutdown.load(Ordering::Acquire) { OPEN => ShutdownState::Open, - CLOSING => ShutdownState::Closing, + CLOSING | RESTORING => ShutdownState::Closing, _ => ShutdownState::Closed, } } - fn begin_close(&self) { + pub(crate) fn begin_close(&self) { let _ = self .shutdown .compare_exchange(OPEN, CLOSING, Ordering::AcqRel, Ordering::Acquire); } - fn finish_close(&self) -> Result<(), Error> { + pub(crate) fn begin_restore_close(&self) -> Result<(), Error> { + self.shutdown + .compare_exchange(OPEN, RESTORING, Ordering::AcqRel, Ordering::Acquire) + .map(|_| ()) + .map_err(|_| Error::BackendUnavailable) + } + + pub(crate) fn finish_close(&self) -> Result<(), Error> { + if self.shutdown.load(Ordering::Acquire) == RESTORING { + return Ok(()); + } + self.release_writer_and_close() + } + + pub(crate) fn finish_restore_close(&self) -> Result<(), Error> { + if self.shutdown.load(Ordering::Acquire) != RESTORING { + return Err(Error::BackendUnavailable); + } + self.release_writer_and_close() + } + + fn release_writer_and_close(&self) -> Result<(), Error> { let mut writer_lock = self .writer_lock .lock()