lib

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

commit 7ddbcd3a94895aad6936ccacb6373c71153f5bb8
parent 8d6062bc9ecc71d724c767e85d45f918c18fa33e
Author: triesap <tyson@radroots.org>
Date:   Tue, 22 Sep 2026 07:21:12 +0000

storage: settle admitted writes before backup inventory

- Drain both canonical SQLite connection pools through the owner
- Expose a narrow settling capability through storage and SDK
- Preserve cancellation recovery and unsupported-owner refusal
- Qualify pool barriers, API compatibility and unchanged coverage

Diffstat:
Mcontracts/api_baselines/radroots_sdk.txt | 1+
Mcontracts/api_baselines/radroots_storage.txt | 5+++++
Mcontracts/api_baselines/radroots_storage_sqlite.txt | 1+
Mcontracts/architecture/decisions/storage_backup_capability.v1.json | 1+
Mcontracts/architecture/deviations.toml | 20++++++++++++++++++++
Mcrates/sdk/README.md | 5+++++
Mcrates/sdk/src/storage.rs | 7+++++++
Mcrates/sdk/src/storage/backup_tests.rs | 5+++++
Mcrates/storage/README.md | 5+++++
Mcrates/storage/src/backup.rs | 8++++++++
Mcrates/storage_sqlite/src/backup.rs | 5+++++
Acrates/storage_sqlite/src/backup/settling.rs | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 201 insertions(+), 0 deletions(-)

diff --git a/contracts/api_baselines/radroots_sdk.txt b/contracts/api_baselines/radroots_sdk.txt @@ -342,6 +342,7 @@ pub async fn radroots_sdk::storage::Operations<'a>::begin_restore(&self, radroot pub async fn radroots_sdk::storage::Operations<'a>::capture_backup(&self, radroots_storage::backup::BackupPlan) -> core::result::Result<radroots_storage::backup::BackupManifest, radroots_storage::backup::capability::BackupCapabilityError> pub async fn radroots_sdk::storage::Operations<'a>::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> core::result::Result<(), radroots_storage::backup::capability::BackupCapabilityError> pub async fn radroots_sdk::storage::Operations<'a>::integrity(&self) -> core::result::Result<radroots_sdk::storage::IntegrityStatus, radroots_storage::error::Error> +pub async fn radroots_sdk::storage::Operations<'a>::settle_backup_writes(&self) -> core::result::Result<(), radroots_storage::backup::capability::BackupCapabilityError> pub async fn radroots_sdk::storage::Operations<'a>::status(&self) -> core::result::Result<radroots_sdk::storage::Status, radroots_storage::error::Error> pub async fn radroots_sdk::storage::Operations<'a>::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::error::Error> pub async fn radroots_sdk::storage::Operations<'a>::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::error::Error> diff --git a/contracts/api_baselines/radroots_storage.txt b/contracts/api_baselines/radroots_storage.txt @@ -724,6 +724,7 @@ pub fn radroots_storage::backup::StorageReliability::capture_backup(&self, radro pub fn radroots_storage::backup::StorageReliability::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::backup::StorageReliability::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::backup::StorageReliability::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> +pub fn radroots_storage::backup::StorageReliability::settle_backup_writes(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::backup::StorageReliability::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::backup::StorageReliability::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::backup::StorageReliability::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> @@ -735,6 +736,7 @@ pub fn radroots_storage::memory::MemoryStorage::capture_backup(&self, radroots_s pub fn radroots_storage::memory::MemoryStorage::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::settle_backup_writes(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> @@ -998,6 +1000,7 @@ pub fn radroots_storage::memory::MemoryStorage::capture_backup(&self, radroots_s pub fn radroots_storage::memory::MemoryStorage::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::settle_backup_writes(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> @@ -1842,6 +1845,7 @@ pub fn radroots_storage::BackupSource::capture_backup(&self, radroots_storage::b pub fn radroots_storage::BackupSource::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::BackupSource::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::BackupSource::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> +pub fn radroots_storage::BackupSource::settle_backup_writes(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::BackupSource::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::BackupSource::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::BackupSource::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> @@ -1853,6 +1857,7 @@ pub fn radroots_storage::memory::MemoryStorage::capture_backup(&self, radroots_s pub fn radroots_storage::memory::MemoryStorage::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::Error>> +pub fn radroots_storage::memory::MemoryStorage::settle_backup_writes(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::BackupCapabilityError>> pub fn radroots_storage::memory::MemoryStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::Error>> pub fn radroots_storage::memory::MemoryStorage::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::Error>> diff --git a/contracts/api_baselines/radroots_storage_sqlite.txt b/contracts/api_baselines/radroots_storage_sqlite.txt @@ -570,6 +570,7 @@ pub fn radroots_storage_sqlite::SqliteStorage::capture_backup(&self, radroots_st pub fn radroots_storage_sqlite::SqliteStorage::close(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::error::Error>> pub fn radroots_storage_sqlite::SqliteStorage::finalize_backup(&self, radroots_storage::backup::BackupPlan, radroots_storage::backup::BackupManifest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::capability::BackupCapabilityError>> pub fn radroots_storage_sqlite::SqliteStorage::integrity(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::IntegrityStatus, radroots_storage::error::Error>> +pub fn radroots_storage_sqlite::SqliteStorage::settle_backup_writes(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<(), radroots_storage::backup::capability::BackupCapabilityError>> pub fn radroots_storage_sqlite::SqliteStorage::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::status::StorageStatus, radroots_storage::error::Error>> pub fn radroots_storage_sqlite::SqliteStorage::transition_backup(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::BackupTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::BackupOperation, radroots_storage::error::Error>> pub fn radroots_storage_sqlite::SqliteStorage::transition_restore(&self, radroots_storage::backup::BackupId, radroots_storage::backup::ReliabilityRevision, radroots_storage::backup::RestoreTransition, u64) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_storage::backup::RestoreOperation, radroots_storage::error::Error>> diff --git a/contracts/architecture/decisions/storage_backup_capability.v1.json b/contracts/architecture/decisions/storage_backup_capability.v1.json @@ -7,6 +7,7 @@ "compatibility": "Existing backup manifests, formats, member names, schemas and concrete-owner operations remain unchanged. Default unsupported methods preserve other backend implementations.", "error_boundary": "Typed bounded errors omit filesystem paths, raw database details and nested I/O sources.", "consistency": "The capability preserves existing per-member snapshot semantics. Application and media coordination, identity binding and cross-member consistency require explicit host orchestration; no global transaction is implied.", + "settling": "Before related inventory/capture, the host excludes new writes and requests settle_backup_writes. SQLite retains every runtime/protected pool permit and drains their worker handshakes, including asynchronous return after caller cancellation. The host retains its write exclusion after settling returns. Unsupported backends refuse. This is not an ongoing reservation or global transaction.", "lifecycle": "Unpolled operations have no effects. Owner close refuses later capture, verification and finalization. Failed or interrupted capture may retain staging for explicit reconciliation.", "excluded": ["application backup formats", "automatic recovery", "restore orchestration", "secret export consent", "new filesystem owner", "coverage exclusions"] } diff --git a/contracts/architecture/deviations.toml b/contracts/architecture/deviations.toml @@ -2,6 +2,26 @@ schema_version = 1 architecture_id = "radroots.crates.release.v1" [[deviation]] +id = "RCRV1-DEV-020" +date = "2026-09-22" +status = "closed" +approval = "Explicit user authorization covers required shared-owner repairs, verified checkpoints and non-force integration publication." +affected_steps = ["158", "163", "178"] +spec_anchors = ["contracts/crates/release_v1/radroots_crates_release_v1.toml#package.radroots_storage", "contracts/crates/release_v1/radroots_crates_release_v1.toml#package.radroots_storage_sqlite", "contracts/crates/release_v1/radroots_crates_release_v1.toml#package.radroots_sdk"] +source_evidence = ["SQLx 0.9 returns dropped pooled connections asynchronously, pinging the worker before returning its permit.", "The canonical SQLite owner has four connections per member, so a different connection can inspect or snapshot before earlier cancelled work settles.", "The existing backup SPI cannot establish that all earlier owner work has settled before related host inventory."] +replacement_action = "Add the bounded owner-settling capability governed by storage_backup_capability.v1.json; retain all runtime and protected pool permits before returning, while the host separately excludes new commands." +verification = ["Held connections prevent early success across both members; cancelled settling releases permits and can retry.", "Abandoned transactions settle their owner rollback before later inventory; committed state remains observable.", "Qualify SDK delegation, unsupported and closed owners, API compatibility, unchanged coverage thresholds and complete workspace/preflight."] +unresolved_risk = "Owner, SDK, additive API, unchanged coverage, full workspace, portability and preflight qualification passed. The host must retain its own write exclusion; settling is not a snapshot or an ongoing reservation." +normative_architecture_change = false +adr_required = false +closure_evidence = [ + "Canonical SQLite settles all runtime and protected connections before related host inventory. Cancellation releases acquired permits, and a remaining busy protected connection prevents success. Abandoned transactions finish rollback and committed state remains observable.", + "SDK delegation, unsupported memory and closed owners pass affected all-feature and minimal checks. The public API review contains only additive settling methods and no removals; backup schemas, manifests, dependencies and pool configuration are unchanged.", + "All 45 required package coverage gates pass unchanged 90 percent thresholds. The three affected packages were freshly measured; 42 unchanged package-source measurements retain prior provenance.", + "Full workspace check, tests and all-feature Clippy, documentation, catalog, contracts, architecture, DTO/API boundaries, dependency graph, portability, release preflight and both explicitly selected SDK generators pass. Final contract checks revalidate this closure metadata before publication.", +] + +[[deviation]] id = "RCRV1-DEV-019" date = "2026-09-12" status = "closed" diff --git a/crates/sdk/README.md b/crates/sdk/README.md @@ -100,6 +100,11 @@ state, identity binding, media leases, and export consent remain host concerns; these operations expose no paths and do not create an application-wide snapshot transaction. Failed capture may retain staging for explicit reconciliation. +Before inventory and capture, hosts exclude new writes and await +`settle_backup_writes()` to drain earlier owner work, including operations whose +caller was cancelled. Keep that host exclusion until capture completes; the +settling call does not grant a continuing reservation or stop new commands. + Native mobile hosts can retain one client while changing host-owned identity and relay selection. The host injects one opaque implementation of the canonical `radroots_signing::Signer` SPI; the SDK has no mutable secret slot and diff --git a/crates/sdk/src/storage.rs b/crates/sdk/src/storage.rs @@ -34,6 +34,13 @@ impl<'a> Operations<'a> { Self { storage } } + /// Waits for earlier owner writes, including cancelled caller work. Hold + /// application write exclusion before this call until inventory/capture + /// completes; this method does not itself stop new application commands. + pub async fn settle_backup_writes(&self) -> Result<(), BackupCapabilityError> { + StorageReliability::settle_backup_writes(self.storage).await + } + /// Captures actual members through the canonical owner. Related application /// state and media still require host coordination; no metadata transition /// is accepted as evidence of a snapshot. diff --git a/crates/sdk/src/storage/backup_tests.rs b/crates/sdk/src/storage/backup_tests.rs @@ -17,6 +17,10 @@ async fn reliability_metadata_never_substitutes_for_actual_backup_capability() { use radroots_storage::backup::{BackupMember, BackupMemberKind, MemberDigest}; let client = crate::ClientBuilder::memory_default().build().unwrap(); let operations = client.storage_operations().unwrap(); + assert_eq!( + operations.settle_backup_writes().await, + Err(BackupCapabilityError::Unsupported) + ); let plan = plan(); operations.begin_backup(plan.clone()).await.unwrap(); let manifest = BackupManifest::new( @@ -93,6 +97,7 @@ async fn sdk_delegates_real_backup_and_close_to_one_canonical_owner() { let operations = client.storage_operations().unwrap(); let plan = plan(); drop(operations.capture_backup(plan.clone())); + operations.settle_backup_writes().await.unwrap(); assert_eq!(std::fs::read_dir(backup.path()).unwrap().count(), 0); let manifest = operations.capture_backup(plan.clone()).await.unwrap(); assert_eq!(manifest.backup_id(), plan.backup_id()); diff --git a/crates/storage/README.md b/crates/storage/README.md @@ -75,6 +75,11 @@ Their methods return boxed `Future + Send` values, allowing the host to choose the async executor. Implementations must not install an executor, spawn hidden workers, read a clock, generate identities, or perform implicit retries. +`BackupSource::settle_backup_writes` waits for earlier owner writes, including +work whose caller was cancelled. The host excludes new writes before settling +and retains that exclusion through related inventory and capture. Settling does +not create a snapshot or an ongoing reservation. + `BackupSource::capture_backup`, `verify_backup`, and `finalize_backup` invoke actual owner operations. Their default implementation returns typed `BackupCapabilityError::Unsupported`; reliability metadata transitions cannot diff --git a/crates/storage/src/backup.rs b/crates/storage/src/backup.rs @@ -688,6 +688,14 @@ pub enum RestoreTransition { /// Backend-neutral reliability operations. Implementations own staging and /// atomic filesystem replacement; callers receive only typed state. pub trait StorageReliability: Send + Sync { + /// Settles writes already admitted by this owner, including work whose + /// caller was cancelled. The host must exclude new writes before calling + /// this and retain that exclusion through related inventory and capture. + /// This is neither a snapshot nor an ongoing maintenance reservation. + fn settle_backup_writes(&self) -> BoxFuture<'_, Result<(), BackupCapabilityError>> { + Box::pin(async { Err(BackupCapabilityError::Unsupported) }) + } + /// Captures actual owner-produced members. Metadata transitions alone do /// not implement this capability. Hosts must separately coordinate related /// application state and files; a member snapshot is not a global transaction. diff --git a/crates/storage_sqlite/src/backup.rs b/crates/storage_sqlite/src/backup.rs @@ -31,6 +31,7 @@ const RESTORE_MARKER_BYTES: usize = 105; mod capability; #[cfg(test)] mod capability_tests; +mod settling; #[derive(Default)] pub(crate) struct ReliabilityState { @@ -50,6 +51,10 @@ impl SqliteStorage { } impl StorageReliability for SqliteStorage { + fn settle_backup_writes(&self) -> BoxFuture<'_, Result<(), BackupCapabilityError>> { + Box::pin(settling::settle(self)) + } + fn capture_backup( &self, plan: BackupPlan, diff --git a/crates/storage_sqlite/src/backup/settling.rs b/crates/storage_sqlite/src/backup/settling.rs @@ -0,0 +1,138 @@ +use radroots_storage::backup::BackupCapabilityError as Error; +use sqlx::{Connection, Sqlite, SqlitePool, pool::PoolConnection}; + +use crate::SqliteStorage; + +/// PoolConnection's asynchronous return pings its worker before releasing the +/// permit. Holding every configured permit therefore also waits for cancelled +/// executor work on connections other than the next snapshot connection. +pub(super) async fn settle(store: &SqliteStorage) -> Result<(), Error> { + store + .lifecycle + .require_open() + .map_err(|_| Error::Unavailable)?; + let runtime = settle_pool(&store.pool).await?; + let protected = settle_pool(&store.private_pool).await?; + store + .lifecycle + .require_open() + .map_err(|_| Error::Unavailable)?; + // Retain both sets simultaneously. Dropping a cancelled attempt returns + // every acquired permit through the same owner, without inventing success. + drop((runtime, protected)); + Ok(()) +} + +async fn settle_pool(pool: &SqlitePool) -> Result<Vec<PoolConnection<Sqlite>>, Error> { + let capacity = pool.options().get_max_connections(); + let mut connections = Vec::with_capacity(capacity as usize); + for _ in 0..capacity { + let mut connection = pool.acquire().await.map_err(|_| Error::Unavailable)?; + connection.ping().await.map_err(|_| Error::Unavailable)?; + connections.push(connection); + } + Ok(connections) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_storage::backup::StorageReliability; + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + + async fn fixture() -> (tempfile::TempDir, SqliteStorage) { + let root = tempfile::tempdir().unwrap(); + let store = SqliteStorage::open( + crate::OpenOptions::new( + crate::Paths::from_directory(root.path()).unwrap(), + crate::OpenMode::Create, + ) + .with_source_generation( + radroots_storage::event::SourceGeneration::new([7; 32]).unwrap(), + 100, + ) + .unwrap(), + ) + .await + .unwrap(); + (root, store) + } + + fn poll<F: Future + ?Sized>(future: std::pin::Pin<&mut F>) -> Poll<F::Output> { + future.poll(&mut Context::from_waker(Waker::noop())) + } + + #[tokio::test] + async fn settling_waits_for_both_full_pools_and_cancelled_attempt_can_retry() { + let (_root, store) = fixture().await; + let runtime = settle_pool(&store.pool).await.unwrap(); + let mut protected = settle_pool(&store.private_pool).await.unwrap(); + let mut attempt = store.settle_backup_writes(); + assert!(poll(attempt.as_mut()).is_pending()); + drop(runtime); + for _ in 0..32 { + tokio::task::yield_now().await; + assert!(poll(attempt.as_mut()).is_pending()); + } + // An idle connection in one pool is not enough. The last admitted + // protected member must finish before a cross-member inventory begins. + let last_protected = protected.pop().unwrap(); + drop(protected); + for _ in 0..32 { + tokio::task::yield_now().await; + assert!(poll(attempt.as_mut()).is_pending()); + } + drop(attempt); + drop(last_protected); + store.settle_backup_writes().await.unwrap(); + assert_eq!( + settle_pool(&store.pool).await.unwrap().len(), + store.pool.options().get_max_connections() as usize + ); + store.close().await.unwrap(); + assert_eq!(store.settle_backup_writes().await, Err(Error::Unavailable)); + } + + #[tokio::test] + async fn settled_owner_observes_committed_and_abandoned_transactions_without_late_changes() { + let (_root, store) = fixture().await; + sqlx::query("CREATE TABLE backup_settling_fixture(value INTEGER NOT NULL)") + .execute(&store.pool) + .await + .unwrap(); + let mut pending = store.pool.begin().await.unwrap(); + sqlx::query("INSERT INTO backup_settling_fixture VALUES (1)") + .execute(&mut *pending) + .await + .unwrap(); + let mut attempt = store.settle_backup_writes(); + assert!(poll(attempt.as_mut()).is_pending()); + // Dropping a transaction schedules its rollback. No application future + // remains to represent that work, but the owner must still settle it. + drop(pending); + attempt.await.unwrap(); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM backup_settling_fixture") + .fetch_one(&store.pool) + .await + .unwrap(); + assert_eq!(count, 0); + let mut committed = store.pool.begin().await.unwrap(); + sqlx::query("INSERT INTO backup_settling_fixture VALUES (2)") + .execute(&mut *committed) + .await + .unwrap(); + committed.commit().await.unwrap(); + store.settle_backup_writes().await.unwrap(); + for _ in 0..2 { + let values: Vec<i64> = sqlx::query_scalar("SELECT value FROM backup_settling_fixture") + .fetch_all(&store.pool) + .await + .unwrap(); + assert_eq!(values, [2]); + } + store.close().await.unwrap(); + } +}