lib

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

commit 2f13ade316eaeaabcd1a21d44b36fe669bb3fe34
parent 8423186f10df8d86c868eaebb1623bd9fa27ee8b
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 10:23:54 +0000

storage-sqlite: satisfy aggregate storage reliability

- implement the backend-neutral reliability lifecycle SPI
- share idempotent backup and restore operation state across clones
- route status integrity and close through the SQLite lifecycle
- prove aggregate Storage conformance and terminal behavior

Diffstat:
Mcrates/storage_sqlite/src/backup.rs | 191++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/storage_sqlite/src/event/mod.rs | 9++++++++-
Mcrates/storage_sqlite/tests/package_boundary.rs | 3+++
3 files changed, 200 insertions(+), 3 deletions(-)

diff --git a/crates/storage_sqlite/src/backup.rs b/crates/storage_sqlite/src/backup.rs @@ -8,10 +8,13 @@ use std::{ }; use radroots_storage::backup::{ - BackupFormatVersion, BackupId, BackupManifest, BackupMember, BackupMemberKind, BackupPlan, - BackupSecretPolicy, MemberDigest, MemberVerification, RestoreMemberStatus, RestorePlan, + BackupFormatVersion, BackupId, BackupManifest, BackupMember, BackupMemberKind, BackupOperation, + BackupPlan, BackupSecretPolicy, BackupTransition, MemberDigest, MemberVerification, + ReliabilityRevision, RestoreMemberStatus, RestoreOperation, RestorePlan, RestoreTransition, + StorageReliability, }; use radroots_storage::status::EventStoreMode; +use radroots_storage::{Error as StorageError, outbox::BoxFuture}; use sha2::{Digest, Sha256}; use sqlx::SqlitePool; use sqlx::{Connection, SqliteConnection, sqlite::SqliteConnectOptions}; @@ -25,6 +28,126 @@ const PRIVATE_MEMBER: &str = "private/private.sqlite"; const RESTORE_MARKER_MAGIC: &[u8; 8] = b"RDRSTR01"; const RESTORE_MARKER_BYTES: usize = 105; +#[derive(Default)] +pub(crate) struct ReliabilityState { + backups: Vec<BackupOperation>, + restores: Vec<RestoreOperation>, +} + +impl SqliteStorage { + fn reliability_state( + &self, + ) -> Result<std::sync::MutexGuard<'_, ReliabilityState>, StorageError> { + self.lifecycle.require_open()?; + self.reliability + .lock() + .map_err(|_| StorageError::BackendUnavailable) + } +} + +impl StorageReliability for SqliteStorage { + fn begin_backup( + &self, + plan: BackupPlan, + ) -> BoxFuture<'_, Result<BackupOperation, StorageError>> { + Box::pin(async move { + let mut state = self.reliability_state()?; + if let Some(existing) = state + .backups + .iter() + .find(|operation| operation.plan().backup_id() == plan.backup_id()) + { + return if existing.plan() == &plan { + Ok(existing.clone()) + } else { + Err(StorageError::ReliabilityRevisionConflict) + }; + } + let operation = BackupOperation::planned(plan); + state.backups.push(operation.clone()); + Ok(operation) + }) + } + + fn transition_backup( + &self, + backup_id: BackupId, + expected_revision: ReliabilityRevision, + transition: BackupTransition, + at_unix_ms: u64, + ) -> BoxFuture<'_, Result<BackupOperation, StorageError>> { + Box::pin(async move { + let mut state = self.reliability_state()?; + let operation = state + .backups + .iter_mut() + .find(|operation| operation.plan().backup_id() == backup_id) + .ok_or(StorageError::CorruptReliabilityOperation)?; + let next = operation.transition(expected_revision, transition, at_unix_ms)?; + *operation = next.clone(); + Ok(next) + }) + } + + fn begin_restore( + &self, + plan: RestorePlan, + ) -> BoxFuture<'_, Result<RestoreOperation, StorageError>> { + Box::pin(async move { + let mut state = self.reliability_state()?; + let backup_id = plan.manifest().backup_id(); + if let Some(existing) = state + .restores + .iter() + .find(|operation| operation.plan().manifest().backup_id() == backup_id) + { + return if existing.plan() == &plan { + Ok(existing.clone()) + } else { + Err(StorageError::ReliabilityRevisionConflict) + }; + } + let operation = RestoreOperation::staging(plan); + state.restores.push(operation.clone()); + Ok(operation) + }) + } + + fn transition_restore( + &self, + backup_id: BackupId, + expected_revision: ReliabilityRevision, + transition: RestoreTransition, + at_unix_ms: u64, + ) -> BoxFuture<'_, Result<RestoreOperation, StorageError>> { + Box::pin(async move { + let mut state = self.reliability_state()?; + let operation = state + .restores + .iter_mut() + .find(|operation| operation.plan().manifest().backup_id() == backup_id) + .ok_or(StorageError::CorruptReliabilityOperation)?; + let next = operation.transition(expected_revision, transition, at_unix_ms)?; + *operation = next.clone(); + Ok(next) + }) + } + + fn integrity( + &self, + ) -> BoxFuture<'_, Result<radroots_storage::status::IntegrityStatus, StorageError>> { + Box::pin(async move { SqliteStorage::integrity(self).await }) + } + + fn status(&self) -> BoxFuture<'_, Result<radroots_storage::StorageStatus, StorageError>> { + Box::pin(async move { SqliteStorage::storage_status(self).await }) + } + + fn close(&self) -> BoxFuture<'_, Result<radroots_storage::StorageStatus, StorageError>> { + Box::pin(async move { SqliteStorage::close(self).await }) + } +} + impl SqliteStorage { /// Captures consistent SQLite snapshots into a new deterministic staging /// bundle under the configured host-owned backup root. @@ -1433,6 +1556,70 @@ mod tests { value } + #[tokio::test] + async fn aggregate_reliability_state_is_idempotent_conflict_safe_and_close_aware() { + let database_root = tempfile::tempdir().expect("database root"); + let (_paths, store) = create(database_root.path(), None).await; + let backup = plan(44, BackupSecretPolicy::ExcludeProtectedStorage, 4_400); + + let planned = StorageReliability::begin_backup(&store, backup.clone()) + .await + .expect("planned backup"); + assert_eq!( + StorageReliability::begin_backup(&store, backup.clone()) + .await + .expect("idempotent backup"), + planned + ); + let conflicting = plan(44, BackupSecretPolicy::IncludeProtectedStorage, 4_400); + assert_eq!( + StorageReliability::begin_backup(&store, conflicting).await, + Err(StorageError::ReliabilityRevisionConflict) + ); + + let failed = StorageReliability::transition_backup( + &store, + backup.backup_id(), + planned.revision(), + BackupTransition::Fail, + 4_401, + ) + .await + .expect("failed transition"); + assert_eq!( + failed.stage(), + radroots_storage::backup::BackupStage::Failed + ); + assert_eq!( + StorageReliability::transition_backup( + &store, + backup.backup_id(), + failed.revision(), + BackupTransition::Fail, + 4_402, + ) + .await, + Err(StorageError::ReliabilityOperationTerminal) + ); + + let status = StorageReliability::status(&store) + .await + .expect("open status"); + assert_eq!(status.shutdown(), ShutdownState::Open); + let closed = StorageReliability::close(&store) + .await + .expect("close storage"); + assert_eq!(closed.shutdown(), ShutdownState::Closed); + assert_eq!( + StorageReliability::begin_backup( + &store, + plan(45, BackupSecretPolicy::ExcludeProtectedStorage, 4_500) + ) + .await, + Err(StorageError::BackendUnavailable) + ); + } + async fn insert_private_artifact(store: &SqliteStorage, byte: u8) { sqlx::query( "INSERT INTO radroots_private_artifacts ( diff --git a/crates/storage_sqlite/src/event/mod.rs b/crates/storage_sqlite/src/event/mod.rs @@ -10,7 +10,10 @@ use radroots_storage::{ status::{EventStoreHealth, EventStoreMode, EventStoreStatus}, }; use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool}; -use std::{path::PathBuf, sync::Arc}; +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, +}; use crate::lock::WriterLock; use crate::status::StorageLifecycle; @@ -24,6 +27,7 @@ pub struct SqliteStorage { pub(crate) lifecycle: Arc<StorageLifecycle>, pub(crate) backup_root: Option<Arc<PathBuf>>, pub(crate) paths: Option<Arc<crate::Paths>>, + pub(crate) reliability: Arc<Mutex<crate::backup::ReliabilityState>>, } struct StoredEventRow { @@ -47,6 +51,7 @@ impl SqliteStorage { lifecycle: Arc::new(StorageLifecycle::scaffold(mode)), backup_root: None, paths: None, + reliability: Arc::new(Mutex::new(crate::backup::ReliabilityState::default())), } } @@ -65,6 +70,7 @@ impl SqliteStorage { lifecycle: Arc::new(StorageLifecycle::scaffold(mode)), backup_root: None, paths: None, + reliability: Arc::new(Mutex::new(crate::backup::ReliabilityState::default())), } } @@ -94,6 +100,7 @@ impl SqliteStorage { .backup_root() .map(|path| Arc::new(path.to_path_buf())), paths: Some(Arc::new(options.paths().clone())), + reliability: Arc::new(Mutex::new(crate::backup::ReliabilityState::default())), } } diff --git a/crates/storage_sqlite/tests/package_boundary.rs b/crates/storage_sqlite/tests/package_boundary.rs @@ -5,6 +5,9 @@ const ROOT: &str = include_str!("../src/lib.rs"); #[test] fn sqlite_storage_declares_the_final_backend_boundaries() { + fn assert_complete_storage<T: radroots_storage::Storage>() {} + assert_complete_storage::<radroots_storage_sqlite::SqliteStorage>(); + for required in [ "name = \"radroots_storage_sqlite\"", "version = \"0.1.0-alpha\"",