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:
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\"",