lib

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

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

storage-sqlite: qualify durable failure recovery

- govern deterministic SQL and durable-state faults without globals
- cover atomic migration backup restore lock and close boundaries
- reconstruct every restore rename and cleanup crash state
- reopen committed migrations after lost responses and prove cleanup

Diffstat:
Acontracts/storage/failure_injection_policy_v1.toml | 11+++++++++++
Mcrates/storage_sqlite/src/backup.rs | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/storage_sqlite/src/migration.rs | 32++++++++++++++++++++++++++++++++
3 files changed, 310 insertions(+), 0 deletions(-)

diff --git a/contracts/storage/failure_injection_policy_v1.toml b/contracts/storage/failure_injection_policy_v1.toml @@ -0,0 +1,11 @@ +schema_version = 1 +strategy = "deterministic_state_construction_and_sql_faults" +runtime_global_hooks = false +accepted_reopen_outcomes = ["fully_committed_replayable", "typed_recoverable_no_partial_success"] +atomic_sql_points = ["source_sequence", "event", "provenance", "projection_checkpoint", "commit_receipt", "journal", "outbox_item", "outbox_target", "delivery_evidence"] +migration_points = ["application_identity", "each_pending_step", "user_version", "exact_catalog", "transaction_commit"] +backup_points = ["runtime_snapshot", "protected_snapshot", "member_hash", "manifest", "complete_verification", "final_directory_rename", "root_sync"] +restore_points = ["runtime_staging", "protected_staging", "staged_validation", "marker", "runtime_previous_rename", "runtime_replacement_rename", "protected_previous_rename", "protected_replacement_rename", "installed_validation", "runtime_previous_cleanup", "protected_previous_cleanup", "marker_cleanup"] +lock_close_points = ["lock_file_open", "exclusive_acquisition", "cross_process_contention", "pool_drain", "lock_release", "closed_status"] +restore_recovery = "idempotent_forward_completion_before_connection_open" +failure_reporting = "stable_typed_error_without_backend_details" diff --git a/crates/storage_sqlite/src/backup.rs b/crates/storage_sqlite/src/backup.rs @@ -1276,6 +1276,8 @@ mod tests { include_str!("../../../contracts/storage/restore_staging_policy_v1.toml"); const RESTORE_FINALIZE_POLICY: &str = include_str!("../../../contracts/storage/restore_finalize_policy_v1.toml"); + const FAILURE_POLICY: &str = + include_str!("../../../contracts/storage/failure_injection_policy_v1.toml"); #[derive(Deserialize)] struct Policy { @@ -1344,6 +1346,51 @@ mod tests { backend_after_attempt: String, } + #[derive(Deserialize)] + struct FailurePolicy { + schema_version: u32, + strategy: String, + runtime_global_hooks: bool, + accepted_reopen_outcomes: Vec<String>, + atomic_sql_points: Vec<String>, + migration_points: Vec<String>, + backup_points: Vec<String>, + restore_points: Vec<String>, + lock_close_points: Vec<String>, + restore_recovery: String, + failure_reporting: String, + } + + #[derive(Clone, Copy, Debug)] + #[repr(u8)] + enum RestoreCrashPoint { + MarkerPersisted = 0, + RuntimePreviousRenamed = 1, + RuntimeReplacementRenamed = 2, + ProtectedPreviousRenamed = 3, + ProtectedReplacementRenamed = 4, + RuntimePreviousCleaned = 5, + ProtectedPreviousCleaned = 6, + MarkerCleaned = 7, + } + + impl RestoreCrashPoint { + const ALL: [Self; 8] = [ + Self::MarkerPersisted, + Self::RuntimePreviousRenamed, + Self::RuntimeReplacementRenamed, + Self::ProtectedPreviousRenamed, + Self::ProtectedReplacementRenamed, + Self::RuntimePreviousCleaned, + Self::ProtectedPreviousCleaned, + Self::MarkerCleaned, + ]; + + const fn reached(self, point: Self) -> bool { + self as u8 >= point as u8 + } + } + fn generation(byte: u8) -> SourceGeneration { SourceGeneration::new([byte; 32]).expect("source generation") } @@ -1405,6 +1452,49 @@ mod tests { .expect("insert private artifact"); } + fn construct_restore_crash_state( + layout: &RestoreLayout, + marker: &RestoreMarker, + point: RestoreCrashPoint, + ) { + write_restore_marker(&layout.marker, marker).expect("persist interruption marker"); + if point.reached(RestoreCrashPoint::RuntimePreviousRenamed) { + fs::rename(&layout.runtime_live, &layout.runtime_previous) + .expect("rename runtime previous"); + sync_parent(&layout.runtime_live, "sync runtime previous") + .expect("sync runtime previous"); + } + if point.reached(RestoreCrashPoint::RuntimeReplacementRenamed) { + fs::rename(&layout.runtime_staging, &layout.runtime_live) + .expect("promote runtime replacement"); + sync_parent(&layout.runtime_live, "sync runtime replacement") + .expect("sync runtime replacement"); + } + if point.reached(RestoreCrashPoint::ProtectedPreviousRenamed) { + fs::rename(&layout.private_live, &layout.private_previous) + .expect("rename protected previous"); + sync_parent(&layout.private_live, "sync protected previous") + .expect("sync protected previous"); + } + if point.reached(RestoreCrashPoint::ProtectedReplacementRenamed) { + fs::rename(&layout.private_staging, &layout.private_live) + .expect("promote protected replacement"); + sync_parent(&layout.private_live, "sync protected replacement") + .expect("sync protected replacement"); + } + if point.reached(RestoreCrashPoint::RuntimePreviousCleaned) { + remove_restore_file(&layout.runtime_previous, "inject runtime cleanup") + .expect("clean runtime previous"); + } + if point.reached(RestoreCrashPoint::ProtectedPreviousCleaned) { + remove_restore_file(&layout.private_previous, "inject protected cleanup") + .expect("clean protected previous"); + } + if point.reached(RestoreCrashPoint::MarkerCleaned) { + remove_restore_file(&layout.marker, "inject marker cleanup").expect("clean marker"); + } + } + #[test] fn implementation_matches_the_governed_backup_capture_policy() { let policy = toml::from_str::<Policy>(POLICY).expect("backup capture policy"); @@ -1564,6 +1654,96 @@ mod tests { assert_eq!(policy.backend_after_attempt, "closed_reopen_required"); } + #[test] + fn implementation_matches_the_governed_failure_injection_policy() { + let policy = toml::from_str::<FailurePolicy>(FAILURE_POLICY).expect("failure policy"); + assert_eq!(policy.schema_version, 1); + assert_eq!( + policy.strategy, + "deterministic_state_construction_and_sql_faults" + ); + assert!(!policy.runtime_global_hooks); + assert_eq!( + policy.accepted_reopen_outcomes, + [ + "fully_committed_replayable", + "typed_recoverable_no_partial_success" + ] + ); + assert_eq!( + policy.atomic_sql_points, + [ + "source_sequence", + "event", + "provenance", + "projection_checkpoint", + "commit_receipt", + "journal", + "outbox_item", + "outbox_target", + "delivery_evidence" + ] + ); + assert_eq!( + policy.migration_points, + [ + "application_identity", + "each_pending_step", + "user_version", + "exact_catalog", + "transaction_commit" + ] + ); + assert_eq!( + policy.backup_points, + [ + "runtime_snapshot", + "protected_snapshot", + "member_hash", + "manifest", + "complete_verification", + "final_directory_rename", + "root_sync" + ] + ); + assert_eq!( + policy.restore_points, + [ + "runtime_staging", + "protected_staging", + "staged_validation", + "marker", + "runtime_previous_rename", + "runtime_replacement_rename", + "protected_previous_rename", + "protected_replacement_rename", + "installed_validation", + "runtime_previous_cleanup", + "protected_previous_cleanup", + "marker_cleanup" + ] + ); + assert_eq!( + policy.lock_close_points, + [ + "lock_file_open", + "exclusive_acquisition", + "cross_process_contention", + "pool_drain", + "lock_release", + "closed_status" + ] + ); + assert_eq!( + policy.restore_recovery, + "idempotent_forward_completion_before_connection_open" + ); + assert_eq!( + policy.failure_reporting, + "stable_typed_error_without_backend_details" + ); + } + #[tokio::test] async fn capture_excludes_protected_storage_and_includes_latest_wal_state() { let database_root = tempfile::tempdir().expect("database root"); @@ -2186,6 +2366,93 @@ mod tests { } #[tokio::test] + async fn every_durable_restore_crash_point_recovers_to_one_complete_installation() { + for (index, point) in RestoreCrashPoint::ALL.into_iter().enumerate() { + 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 = 81 WHERE state = 'active'", + ) + .execute(&store.pool) + .await + .expect("backup runtime state"); + insert_private_artifact(&store, 5).await; + let backup_plan = plan( + 110 + u8::try_from(index).expect("crash index"), + BackupSecretPolicy::IncludeProtectedStorage, + 11_000 + u64::try_from(index).expect("crash index"), + ); + let manifest = store + .capture_backup(&backup_plan) + .await + .expect("capture crash source"); + store + .finalize_backup(&backup_plan, &manifest) + .await + .expect("finalize crash source"); + sqlx::query( + "UPDATE radroots_runtime_source_generations SET sequence_head = 82 WHERE state = 'active'", + ) + .execute(&store.pool) + .await + .expect("advance runtime state"); + insert_private_artifact(&store, 6).await; + let restore = RestorePlan::new( + manifest.clone(), + BackupSecretPolicy::IncludeProtectedStorage, + 12_000 + u64::try_from(index).expect("crash index"), + ) + .expect("restore plan"); + store.stage_restore(&restore).await.expect("stage restore"); + store.close().await.expect("quiesce crash state"); + 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"); + construct_restore_crash_state(&layout, &marker, point); + + let recovered = + SqliteStorage::open(OpenOptions::new(paths.clone(), OpenMode::ReadWriteExisting)) + .await + .unwrap_or_else(|error| panic!("recover {point:?}: {error}")); + assert_eq!( + scalar( + paths.runtime(), + "SELECT sequence_head FROM radroots_runtime_source_generations WHERE state = 'active'" + ) + .await, + 81, + "runtime state after {point:?}" + ); + assert_eq!( + scalar( + paths.private(), + "SELECT COUNT(*) FROM radroots_private_artifacts" + ) + .await, + 1, + "protected state after {point:?}" + ); + for artifact in [ + &layout.runtime_staging, + &layout.private_staging, + &layout.runtime_previous, + &layout.private_previous, + &layout.marker, + ] { + assert!( + !artifact.exists(), + "artifact after {point:?}: {}", + artifact.display() + ); + } + recovered.close().await.expect("close recovered state"); + } + } + + #[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; diff --git a/crates/storage_sqlite/src/migration.rs b/crates/storage_sqlite/src/migration.rs @@ -394,6 +394,8 @@ const fn set_user_version_sql(version: u32) -> Option<&'static str> { #[cfg(test)] mod tests { + use sqlx::sqlite::SqliteConnectOptions; + use super::*; const TEST_V1_OBJECTS: &[&str] = &["radroots_test_one"]; @@ -513,6 +515,36 @@ mod tests { } #[tokio::test] + async fn committed_migration_reopens_as_current_after_a_lost_success_response() { + let directory = tempfile::tempdir().expect("database directory"); + let path = directory.path().join(RUNTIME_DATABASE); + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true), + ) + .await + .expect("open migration database"); + establish_runtime_version(&mut connection, 1).await; + let _lost_response = migrate_runtime(&mut connection, OpenMode::ReadWriteExisting) + .await + .expect("commit pending migration"); + connection.close().await.expect("simulate process exit"); + + let mut reopened = SqliteConnection::connect_with( + &SqliteConnectOptions::new().filename(&path).read_only(true), + ) + .await + .expect("reopen migrated database"); + let report = migrate_runtime(&mut reopened, OpenMode::ReadOnly) + .await + .expect("recognize committed migration"); + assert_eq!(report.initial_version(), runtime::CURRENT_VERSION); + assert_eq!(report.final_version(), runtime::CURRENT_VERSION); + assert_eq!(report.applied(), 0); + } + + #[tokio::test] async fn every_recognized_runtime_version_applies_exactly_the_pending_suffix() { for initial_version in 1..=runtime::CURRENT_VERSION { let mut connection = connection().await;