lib

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

commit 14eaae2a4adfd86d12b93d901f024bed1738cfb5
parent 39eaa0304d8d64093b7851e964bfbe88139a9b61
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 17:48:36 +0000

storage-sqlite: port outbox and delivery evidence

- implement idempotent plans, expiring claims, and revision-bound SQLite updates
- persist bounded versioned requests, targets, and normalized outcome evidence
- validate complete attempt history and reject corrupt, stale, or read-only mutations
- verify partial success, recovery, conflicts, schema, and workspace conformance

Diffstat:
Mcontracts/storage/runtime_schema_v1.toml | 43++++++++++++++++++++++++++++++++++++++++---
Mcrates/storage/src/outbox.rs | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/storage_sqlite/src/lib.rs | 1+
Acrates/storage_sqlite/src/migration/runtime/0004_outbox_delivery_evidence.up.sql | 2++
Mcrates/storage_sqlite/src/migration/runtime/mod.rs | 51++++++++++++++++++++++++++++++++++++++++++++++-----
Acrates/storage_sqlite/src/outbox/mod.rs | 1258+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/src/policy.rs | 18++++++++++++++++++
7 files changed, 1560 insertions(+), 12 deletions(-)

diff --git a/contracts/storage/runtime_schema_v1.toml b/contracts/storage/runtime_schema_v1.toml @@ -1,9 +1,9 @@ schema_version = 1 database = "runtime.sqlite" minimum_version = 1 -current_version = 3 -migration_name = "operation_journal" -migration_sha256 = "4caa69a316777cabfc647e6d022007c1433a5793b2a55679629e8fac4d50a0f0" +current_version = 4 +migration_name = "outbox_delivery_evidence" +migration_sha256 = "435ad7590be7cd9d5b5ee3c9eb2d53419eba3ba12b0f0fc623f1787517524842" forward_only = true raw_sql_public = false @@ -131,3 +131,40 @@ owned_objects = [ "radroots_runtime_source_generations_identity_guard", "radroots_runtime_source_generations_sequence_guard", ] + +[[migrations]] +version = 4 +name = "outbox_delivery_evidence" +sha256 = "435ad7590be7cd9d5b5ee3c9eb2d53419eba3ba12b0f0fc623f1787517524842" + +owned_objects = [ + "radroots_runtime_atomic_commits", + "radroots_runtime_delivery_evidence", + "radroots_runtime_delivery_evidence_item_idx", + "radroots_runtime_event_index_checkpoints", + "radroots_runtime_event_index_manifests", + "radroots_runtime_event_index_shards", + "radroots_runtime_event_provenance", + "radroots_runtime_event_provenance_observed_idx", + "radroots_runtime_events", + "radroots_runtime_events_admission_idx", + "radroots_runtime_events_delete_guard", + "radroots_runtime_events_event_id_idx", + "radroots_runtime_events_raw_update_guard", + "radroots_runtime_journal_idempotency_idx", + "radroots_runtime_journal_operations", + "radroots_runtime_journal_recovery_idx", + "radroots_runtime_outbox_items", + "radroots_runtime_outbox_operation_idx", + "radroots_runtime_outbox_ready_idx", + "radroots_runtime_outbox_targets", + "radroots_runtime_projection_checkpoints", + "radroots_runtime_projection_invalidations", + "radroots_runtime_projection_rebuilds", + "radroots_runtime_projection_rebuilds_stage_idx", + "radroots_runtime_source_generations", + "radroots_runtime_source_generations_active_idx", + "radroots_runtime_source_generations_delete_guard", + "radroots_runtime_source_generations_identity_guard", + "radroots_runtime_source_generations_sequence_guard", +] diff --git a/crates/storage/src/outbox.rs b/crates/storage/src/outbox.rs @@ -4,10 +4,14 @@ //! owns no transport adapter and performs no transport I/O. use core::fmt; -use radroots_transport::{ - BoxFuture, DeliveryReceipt, DeliveryRequest, - outcome::{DeliveryOutcome, Retryability}, - target::TargetFingerprint, +pub use radroots_transport::{ + BoxFuture, DeliveryReceipt, DeliveryRequest, TransportId, + outcome::{DeliveryOutcome, DeliveryOutcomeKind, Retryability}, + policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, + sink::{DeliveryPayload, DeliveryTargetReceipt}, + target::{ + TARGET_SET_MAX_ITEMS, Target, TargetFingerprint, TargetLabel, TargetScope, TargetSet, + }, }; use crate::{Error, journal::OperationInstanceId}; @@ -254,6 +258,25 @@ pub struct TargetDeliveryEvidence { } impl TargetDeliveryEvidence { + pub fn new( + target: TargetFingerprint, + attempt: DeliveryAttempt, + attempted: bool, + outcome: DeliveryOutcome, + recorded_at_unix_ms: u64, + ) -> Result<Self, Error> { + if recorded_at_unix_ms == 0 { + return Err(Error::InvalidDeliveryEvidence); + } + Ok(Self { + target, + attempt, + attempted, + outcome, + recorded_at_unix_ms, + }) + } + pub const fn target(&self) -> &TargetFingerprint { &self.target } @@ -323,6 +346,60 @@ impl OutboxRecord { } } + /// Reconstructs and validates one record at a durable backend boundary. + #[allow(clippy::too_many_arguments)] + pub fn from_durable_parts( + enqueue: EnqueueOutboxItem, + revision: OutboxRevision, + stage: OutboxStage, + lease: Option<OutboxLease>, + last_attempt: Option<DeliveryAttempt>, + evidence: Vec<TargetDeliveryEvidence>, + satisfaction: SatisfactionResult, + retry_not_before_unix_ms: Option<u64>, + updated_at_unix_ms: u64, + ) -> Result<Self, Error> { + if updated_at_unix_ms < enqueue.created_at_unix_ms + || matches!(stage, OutboxStage::Leased) != lease.is_some() + || matches!(stage, OutboxStage::Retryable) && last_attempt.is_none() + || matches!(stage, OutboxStage::Satisfied) + != matches!(satisfaction, SatisfactionResult::Satisfied) + || matches!(stage, OutboxStage::Exhausted) + != matches!(satisfaction, SatisfactionResult::Exhausted) + || matches!( + stage, + OutboxStage::Pending | OutboxStage::Leased | OutboxStage::Retryable + ) && !matches!(satisfaction, SatisfactionResult::Pending) + || stage.is_terminal() && retry_not_before_unix_ms.is_some() + { + return Err(Error::CorruptOutboxRecord); + } + + validate_evidence( + enqueue.request(), + last_attempt, + evidence.as_slice(), + satisfaction, + enqueue.created_at_unix_ms, + updated_at_unix_ms, + )?; + Ok(Self { + item_id: enqueue.item_id, + operation_instance_id: enqueue.operation_instance_id, + plan_digest: enqueue.plan_digest, + request: enqueue.request, + revision, + stage, + lease, + last_attempt, + evidence, + satisfaction, + retry_not_before_unix_ms, + created_at_unix_ms: enqueue.created_at_unix_ms, + updated_at_unix_ms, + }) + } + pub const fn item_id(&self) -> OutboxItemId { self.item_id } @@ -378,6 +455,9 @@ impl OutboxRecord { if self.stage.is_terminal() { return Err(Error::OutboxItemTerminal); } + if lease.acquired_at_unix_ms() < self.updated_at_unix_ms { + return Err(Error::InvalidOutboxTimestamp); + } if matches!(self.retry_not_before_unix_ms, Some(not_before) if lease.acquired_at_unix_ms() < not_before) { return Err(Error::OutboxItemNotReady); @@ -690,6 +770,21 @@ impl DeliveryAttemptEvidence { pub const fn item_id(&self) -> OutboxItemId { self.item_id } + pub const fn lease_id(&self) -> LeaseId { + self.lease_id + } + pub const fn expected_revision(&self) -> OutboxRevision { + self.expected_revision + } + pub const fn attempt(&self) -> DeliveryAttempt { + self.attempt + } + pub const fn receipt(&self) -> &DeliveryReceipt { + &self.receipt + } + pub const fn recorded_at_unix_ms(&self) -> u64 { + self.recorded_at_unix_ms + } } /// Passive outbox state summary. @@ -746,3 +841,99 @@ const fn bytes_are_zero(bytes: &[u8; 16]) -> bool { } true } + +fn validate_evidence( + request: &DeliveryRequest, + last_attempt: Option<DeliveryAttempt>, + evidence: &[TargetDeliveryEvidence], + satisfaction: SatisfactionResult, + created_at_unix_ms: u64, + updated_at_unix_ms: u64, +) -> Result<(), Error> { + let Some(last_attempt) = last_attempt else { + return if evidence.is_empty() && matches!(satisfaction, SatisfactionResult::Pending) { + Ok(()) + } else { + Err(Error::CorruptOutboxRecord) + }; + }; + let target_count = request.target_set().len(); + if evidence.len() != target_count.saturating_mul(last_attempt.get() as usize) { + return Err(Error::CorruptOutboxRecord); + } + if evidence.iter().any(|entry| { + entry.recorded_at_unix_ms() < created_at_unix_ms + || entry.recorded_at_unix_ms() > updated_at_unix_ms + || !request + .target_set() + .targets() + .iter() + .any(|target| target.fingerprint() == entry.target()) + }) { + return Err(Error::CorruptOutboxRecord); + } + + let mut previous_recorded_at = created_at_unix_ms; + let mut latest_receipt = None; + for attempt in 1..=last_attempt.get() { + let mut recorded_at = None; + let receipts = request + .target_set() + .targets() + .iter() + .map(|target| { + let mut matches = evidence.iter().filter(|entry| { + entry.attempt().get() == attempt && entry.target() == target.fingerprint() + }); + let entry = matches.next().ok_or(Error::CorruptOutboxRecord)?; + if matches.next().is_some() { + return Err(Error::CorruptOutboxRecord); + } + if recorded_at + .replace(entry.recorded_at_unix_ms()) + .is_some_and(|prior| prior != entry.recorded_at_unix_ms()) + { + return Err(Error::CorruptOutboxRecord); + } + if entry.was_attempted() { + Ok(DeliveryTargetReceipt::attempted( + target.clone(), + entry.outcome().clone(), + )) + } else { + DeliveryTargetReceipt::skipped(target.clone(), entry.outcome().clone()) + .map_err(|_| Error::CorruptOutboxRecord) + } + }) + .collect::<Result<Vec<_>, _>>()?; + let recorded_at = recorded_at.ok_or(Error::CorruptOutboxRecord)?; + if recorded_at < previous_recorded_at { + return Err(Error::CorruptOutboxRecord); + } + previous_recorded_at = recorded_at; + latest_receipt = Some( + DeliveryReceipt::for_request(request, receipts) + .map_err(|_| Error::CorruptOutboxRecord)?, + ); + } + let receipt = latest_receipt.ok_or(Error::CorruptOutboxRecord)?; + let expected = if receipt + .is_satisfied(request) + .map_err(|_| Error::CorruptOutboxRecord)? + { + SatisfactionResult::Satisfied + } else if receipt + .target_receipts() + .iter() + .any(|receipt| receipt.outcome().is_retryable()) + { + SatisfactionResult::Pending + } else { + SatisfactionResult::Exhausted + }; + if expected == satisfaction { + Ok(()) + } else { + Err(Error::CorruptOutboxRecord) + } +} diff --git a/crates/storage_sqlite/src/lib.rs b/crates/storage_sqlite/src/lib.rs @@ -10,6 +10,7 @@ pub mod status; mod event; mod journal; +mod outbox; pub use config::OpenOptions; pub use event::SqliteStorage; diff --git a/crates/storage_sqlite/src/migration/runtime/0004_outbox_delivery_evidence.up.sql b/crates/storage_sqlite/src/migration/runtime/0004_outbox_delivery_evidence.up.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX radroots_runtime_outbox_operation_idx +ON radroots_runtime_outbox_items(operation_instance_id); diff --git a/crates/storage_sqlite/src/migration/runtime/mod.rs b/crates/storage_sqlite/src/migration/runtime/mod.rs @@ -6,7 +6,7 @@ /// Lowest runtime schema version this package can recognize. pub const MINIMUM_VERSION: u32 = 1; /// Current runtime schema version created by this package. -pub const CURRENT_VERSION: u32 = 3; +pub const CURRENT_VERSION: u32 = 4; #[allow(dead_code)] // Consumed by the migration executor introduced in its ordered RCL step. const RUNTIME_V1_SQL: &str = include_str!("0001_runtime.up.sql"); @@ -14,6 +14,8 @@ const RUNTIME_V1_SQL: &str = include_str!("0001_runtime.up.sql"); const CANONICAL_EVENT_STORAGE_V2_SQL: &str = include_str!("0002_canonical_event_storage.up.sql"); #[allow(dead_code)] // Consumed by the migration executor introduced in its ordered RCL step. const OPERATION_JOURNAL_V3_SQL: &str = include_str!("0003_operation_journal.up.sql"); +#[allow(dead_code)] // Consumed by the migration executor introduced in its ordered RCL step. +const OUTBOX_DELIVERY_EVIDENCE_V4_SQL: &str = include_str!("0004_outbox_delivery_evidence.up.sql"); /// Stable, non-SQL description of one forward runtime migration. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -106,6 +108,38 @@ const RUNTIME_V2_OBJECTS: &[&str] = &[ "radroots_runtime_source_generations_sequence_guard", ]; +const RUNTIME_V4_OBJECTS: &[&str] = &[ + "radroots_runtime_atomic_commits", + "radroots_runtime_delivery_evidence", + "radroots_runtime_delivery_evidence_item_idx", + "radroots_runtime_event_index_checkpoints", + "radroots_runtime_event_index_manifests", + "radroots_runtime_event_index_shards", + "radroots_runtime_event_provenance", + "radroots_runtime_event_provenance_observed_idx", + "radroots_runtime_events", + "radroots_runtime_events_admission_idx", + "radroots_runtime_events_delete_guard", + "radroots_runtime_events_event_id_idx", + "radroots_runtime_events_raw_update_guard", + "radroots_runtime_journal_idempotency_idx", + "radroots_runtime_journal_operations", + "radroots_runtime_journal_recovery_idx", + "radroots_runtime_outbox_items", + "radroots_runtime_outbox_operation_idx", + "radroots_runtime_outbox_ready_idx", + "radroots_runtime_outbox_targets", + "radroots_runtime_projection_checkpoints", + "radroots_runtime_projection_invalidations", + "radroots_runtime_projection_rebuilds", + "radroots_runtime_projection_rebuilds_stage_idx", + "radroots_runtime_source_generations", + "radroots_runtime_source_generations_active_idx", + "radroots_runtime_source_generations_delete_guard", + "radroots_runtime_source_generations_identity_guard", + "radroots_runtime_source_generations_sequence_guard", +]; + /// Ordered, immutable runtime migration plan. pub const MIGRATIONS: &[MigrationDescriptor] = &[ MigrationDescriptor { @@ -126,6 +160,12 @@ pub const MIGRATIONS: &[MigrationDescriptor] = &[ up_sha256: "4caa69a316777cabfc647e6d022007c1433a5793b2a55679629e8fac4d50a0f0", owned_objects: RUNTIME_V2_OBJECTS, }, + MigrationDescriptor { + version: 4, + name: "outbox_delivery_evidence", + up_sha256: "435ad7590be7cd9d5b5ee3c9eb2d53419eba3ba12b0f0fc623f1787517524842", + owned_objects: RUNTIME_V4_OBJECTS, + }, ]; #[allow(dead_code)] // Keeps raw SQL crate-private until the migration executor is installed. @@ -134,6 +174,7 @@ pub(crate) const fn migration_sql(version: u32) -> Option<&'static str> { 1 => Some(RUNTIME_V1_SQL), 2 => Some(CANONICAL_EVENT_STORAGE_V2_SQL), 3 => Some(OPERATION_JOURNAL_V3_SQL), + 4 => Some(OUTBOX_DELIVERY_EVIDENCE_V4_SQL), _ => None, } } @@ -175,9 +216,9 @@ mod tests { fn migration_plan_matches_governed_snapshot() { let snapshot = toml::from_str::<PlanSnapshot>(PLAN_SNAPSHOT).expect("valid snapshot"); assert_eq!(MINIMUM_VERSION, 1); - assert_eq!(CURRENT_VERSION, 3); - assert_eq!(MIGRATIONS.len(), 3); - let migration = MIGRATIONS[2]; + assert_eq!(CURRENT_VERSION, 4); + assert_eq!(MIGRATIONS.len(), 4); + let migration = MIGRATIONS[3]; assert_eq!(snapshot.schema_version, 1); assert_eq!(snapshot.database, "runtime.sqlite"); assert_eq!(snapshot.minimum_version, MINIMUM_VERSION); @@ -203,7 +244,7 @@ mod tests { let sql = migration_sql(migration.version()).expect("registered SQL"); assert_eq!(format!("{:x}", Sha256::digest(sql)), migration.up_sha256()); } - assert_eq!(migration_sql(4), None); + assert_eq!(migration_sql(5), None); } #[tokio::test] diff --git a/crates/storage_sqlite/src/outbox/mod.rs b/crates/storage_sqlite/src/outbox/mod.rs @@ -0,0 +1,1258 @@ +use crate::SqliteStorage; +use radroots_event_codec::Codec; +use radroots_storage::{ + Error, Outbox, + outbox::{ + BoxFuture, ClaimOutboxItems, ClaimedOutboxItem, DeliveryAttempt, DeliveryAttemptEvidence, + DeliveryOutcome, DeliveryOutcomeKind, DeliveryPayload, DeliveryPlanDigest, DeliveryRequest, + EnqueueDisposition, EnqueueOutboxItem, EnqueueReceipt, LeaseId, LeaseOwner, OutboxItemId, + OutboxLease, OutboxRecord, OutboxRevision, OutboxStage, OutboxStatus, Retryability, + SatisfactionClass, SatisfactionPolicy, SatisfactionResult, TARGET_SET_MAX_ITEMS, Target, + TargetDeliveryEvidence, TargetFingerprint, TargetLabel, TargetPolicy, TargetScope, + TargetSet, TransportId, + }, +}; +use sqlx::{Row, Sqlite, SqliteConnection}; + +impl Outbox for SqliteStorage { + fn enqueue(&self, item: EnqueueOutboxItem) -> BoxFuture<'_, Result<EnqueueReceipt, Error>> { + Box::pin(async move { + self.require_outbox_writer()?; + let mut transaction = self + .pool() + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(map_backend)?; + if let Some(record) = load_record(&mut transaction, item.item_id()).await? { + if record.operation_instance_id() != item.operation_instance_id() + || record.plan_digest() != item.plan_digest() + || record.request() != item.request() + || record.created_at_unix_ms() != item.created_at_unix_ms() + { + return Err(Error::OutboxPlanConflict); + } + transaction.commit().await.map_err(map_backend)?; + return Ok(EnqueueReceipt::new(EnqueueDisposition::Replay, record)); + } + if sqlx::query_scalar::<_, i64>( + "SELECT 1 FROM radroots_runtime_outbox_items WHERE operation_instance_id = ?", + ) + .bind(item.operation_instance_id().as_bytes().as_slice()) + .fetch_optional(&mut *transaction) + .await + .map_err(map_backend)? + .is_some() + { + return Err(Error::OutboxPlanConflict); + } + if sqlx::query_scalar::<_, i64>( + "SELECT 1 FROM radroots_runtime_journal_operations WHERE instance_id = ?", + ) + .bind(item.operation_instance_id().as_bytes().as_slice()) + .fetch_optional(&mut *transaction) + .await + .map_err(map_backend)? + .is_none() + { + return Err(Error::OperationNotFound); + } + + let record = item.into_record(); + insert_record(&mut transaction, &record).await?; + transaction.commit().await.map_err(map_backend)?; + Ok(EnqueueReceipt::new(EnqueueDisposition::Created, record)) + }) + } + + fn item(&self, item_id: OutboxItemId) -> BoxFuture<'_, Result<Option<OutboxRecord>, Error>> { + Box::pin(async move { + let mut connection = self.pool().acquire().await.map_err(map_backend)?; + load_record(&mut connection, item_id).await + }) + } + + fn claim( + &self, + request: ClaimOutboxItems, + ) -> BoxFuture<'_, Result<Vec<ClaimedOutboxItem>, Error>> { + Box::pin(async move { + self.require_outbox_writer()?; + let mut transaction = self + .pool() + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(map_backend)?; + let rows = sqlx::query( + "SELECT item_id FROM radroots_runtime_outbox_items + WHERE stage IN ('pending', 'leased', 'retryable') + AND (retry_not_before_unix_ms IS NULL OR retry_not_before_unix_ms <= ?) + AND (stage <> 'leased' OR lease_expires_at_unix_ms <= ?) + ORDER BY created_at_unix_ms, item_id LIMIT ?", + ) + .bind(i64_from_u64(request.now_unix_ms())?) + .bind(i64_from_u64(request.now_unix_ms())?) + .bind(i64::from(request.limit())) + .fetch_all(&mut *transaction) + .await + .map_err(map_backend)?; + let item_ids = rows + .iter() + .map(|row| { + OutboxItemId::new(array( + row.try_get::<Vec<u8>, _>("item_id").map_err(map_corrupt)?, + )?) + .map_err(|_| Error::CorruptOutboxRecord) + }) + .collect::<Result<Vec<_>, _>>()?; + + let mut claimed = Vec::with_capacity(item_ids.len()); + for item_id in item_ids { + let mut record = load_record(&mut transaction, item_id) + .await? + .ok_or(Error::CorruptOutboxRecord)?; + let prior_revision = record.revision(); + let lease = OutboxLease::new( + request.lease_id_for(item_id), + request.owner().clone(), + request.now_unix_ms(), + request.lease_expires_at_unix_ms(), + )?; + record.claim(lease.clone())?; + update_record(&mut transaction, &record, prior_revision).await?; + claimed.push(ClaimedOutboxItem::new(record, lease)); + } + transaction.commit().await.map_err(map_backend)?; + Ok(claimed) + }) + } + + fn record_attempt( + &self, + evidence: DeliveryAttemptEvidence, + ) -> BoxFuture<'_, Result<OutboxRecord, Error>> { + Box::pin(async move { + self.require_outbox_writer()?; + let mut transaction = self + .pool() + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(map_backend)?; + let mut record = load_record(&mut transaction, evidence.item_id()) + .await? + .ok_or(Error::OutboxItemNotFound)?; + let prior_revision = record.revision(); + let receipt = evidence.receipt().clone(); + let attempt = evidence.attempt(); + let recorded_at = evidence.recorded_at_unix_ms(); + record.record_attempt(evidence)?; + update_record(&mut transaction, &record, prior_revision).await?; + for target_receipt in receipt.target_receipts() { + let outcome = encode_outcome(target_receipt.outcome())?; + sqlx::query( + "INSERT INTO radroots_runtime_delivery_evidence ( + item_id, target_fingerprint, attempt, attempted, outcome, + retryability, recorded_at_unix_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(record.item_id().as_bytes().as_slice()) + .bind(target_receipt.target().fingerprint().as_str().as_bytes()) + .bind(i64::from(attempt.get())) + .bind(i64::from(target_receipt.was_attempted())) + .bind(outcome) + .bind(retryability_name(target_receipt.outcome().retryability())) + .bind(i64_from_u64(recorded_at)?) + .execute(&mut *transaction) + .await + .map_err(map_backend)?; + } + transaction.commit().await.map_err(map_backend)?; + Ok(record) + }) + } + + fn release( + &self, + item_id: OutboxItemId, + lease_id: LeaseId, + expected_revision: OutboxRevision, + released_at_unix_ms: u64, + retry_not_before_unix_ms: Option<u64>, + ) -> BoxFuture<'_, Result<OutboxRecord, Error>> { + Box::pin(async move { + self.require_outbox_writer()?; + let mut transaction = self + .pool() + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(map_backend)?; + let mut record = load_record(&mut transaction, item_id) + .await? + .ok_or(Error::OutboxItemNotFound)?; + let prior_revision = record.revision(); + record.release( + lease_id, + expected_revision, + released_at_unix_ms, + retry_not_before_unix_ms, + )?; + update_record(&mut transaction, &record, prior_revision).await?; + transaction.commit().await.map_err(map_backend)?; + Ok(record) + }) + } + + fn status(&self) -> BoxFuture<'_, Result<OutboxStatus, Error>> { + Box::pin(async move { + let row = sqlx::query( + "SELECT + COALESCE(SUM(CASE WHEN stage = 'pending' THEN 1 ELSE 0 END), 0) AS pending, + COALESCE(SUM(CASE WHEN stage = 'leased' THEN 1 ELSE 0 END), 0) AS leased, + COALESCE(SUM(CASE WHEN stage = 'retryable' THEN 1 ELSE 0 END), 0) AS retryable, + COALESCE(SUM(CASE WHEN stage = 'satisfied' THEN 1 ELSE 0 END), 0) AS satisfied, + COALESCE(SUM(CASE WHEN stage = 'exhausted' THEN 1 ELSE 0 END), 0) AS exhausted + FROM radroots_runtime_outbox_items", + ) + .fetch_one(self.pool()) + .await + .map_err(map_backend)?; + Ok(OutboxStatus { + pending: count(&row, "pending")?, + leased: count(&row, "leased")?, + retryable: count(&row, "retryable")?, + satisfied: count(&row, "satisfied")?, + exhausted: count(&row, "exhausted")?, + }) + }) + } +} + +impl SqliteStorage { + fn require_outbox_writer(&self) -> Result<(), Error> { + if self.event_mode() == radroots_storage::status::EventStoreMode::ReadOnly { + return Err(Error::BackendUnavailable); + } + Ok(()) + } +} + +async fn insert_record( + transaction: &mut sqlx::Transaction<'_, Sqlite>, + record: &OutboxRecord, +) -> Result<(), Error> { + let request = encode_request(record.request())?; + sqlx::query( + "INSERT INTO radroots_runtime_outbox_items ( + item_id, operation_instance_id, plan_digest, delivery_request, revision, stage, + satisfaction, created_at_unix_ms, updated_at_unix_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(record.item_id().as_bytes().as_slice()) + .bind(record.operation_instance_id().as_bytes().as_slice()) + .bind(record.plan_digest().as_bytes().as_slice()) + .bind(request) + .bind(i64_from_u64(record.revision().get())?) + .bind(stage_name(record.stage())) + .bind(satisfaction_name(record.satisfaction())) + .bind(i64_from_u64(record.created_at_unix_ms())?) + .bind(i64_from_u64(record.updated_at_unix_ms())?) + .execute(&mut **transaction) + .await + .map_err(map_backend)?; + + for (ordinal, target) in record.request().target_set().targets().iter().enumerate() { + sqlx::query( + "INSERT INTO radroots_runtime_outbox_targets ( + item_id, target_fingerprint, target_request, ordinal + ) VALUES (?, ?, ?, ?)", + ) + .bind(record.item_id().as_bytes().as_slice()) + .bind(target.fingerprint().as_str().as_bytes()) + .bind(encode_target(target)?) + .bind(i64::try_from(ordinal).map_err(|_| Error::CorruptOutboxRecord)?) + .execute(&mut **transaction) + .await + .map_err(map_backend)?; + } + Ok(()) +} + +async fn update_record( + transaction: &mut sqlx::Transaction<'_, Sqlite>, + record: &OutboxRecord, + prior_revision: OutboxRevision, +) -> Result<(), Error> { + let lease = record.lease(); + let result = sqlx::query( + "UPDATE radroots_runtime_outbox_items SET + revision = ?, stage = ?, lease_id = ?, lease_owner = ?, + lease_acquired_at_unix_ms = ?, lease_expires_at_unix_ms = ?, last_attempt = ?, + satisfaction = ?, retry_not_before_unix_ms = ?, updated_at_unix_ms = ? + WHERE item_id = ? AND revision = ?", + ) + .bind(i64_from_u64(record.revision().get())?) + .bind(stage_name(record.stage())) + .bind(lease.map(|lease| lease.id().as_bytes().to_vec())) + .bind(lease.map(|lease| lease.owner().as_str())) + .bind( + lease + .map(|lease| i64_from_u64(lease.acquired_at_unix_ms())) + .transpose()?, + ) + .bind( + lease + .map(|lease| i64_from_u64(lease.expires_at_unix_ms())) + .transpose()?, + ) + .bind( + record + .last_attempt() + .map(|attempt| i64::from(attempt.get())), + ) + .bind(satisfaction_name(record.satisfaction())) + .bind( + record + .retry_not_before_unix_ms() + .map(i64_from_u64) + .transpose()?, + ) + .bind(i64_from_u64(record.updated_at_unix_ms())?) + .bind(record.item_id().as_bytes().as_slice()) + .bind(i64_from_u64(prior_revision.get())?) + .execute(&mut **transaction) + .await + .map_err(map_backend)?; + if result.rows_affected() != 1 { + return Err(Error::OutboxRevisionConflict); + } + Ok(()) +} + +async fn load_record( + connection: &mut SqliteConnection, + item_id: OutboxItemId, +) -> Result<Option<OutboxRecord>, Error> { + let Some(row) = sqlx::query("SELECT * FROM radroots_runtime_outbox_items WHERE item_id = ?") + .bind(item_id.as_bytes().as_slice()) + .fetch_optional(&mut *connection) + .await + .map_err(map_backend)? + else { + return Ok(None); + }; + let request = decode_request( + row.try_get::<Vec<u8>, _>("delivery_request") + .map_err(map_corrupt)? + .as_slice(), + )?; + validate_targets(connection, item_id, &request).await?; + let evidence = load_evidence(connection, item_id).await?; + let operation_instance_id = radroots_storage::journal::OperationInstanceId::new(array( + row.try_get::<Vec<u8>, _>("operation_instance_id") + .map_err(map_corrupt)?, + )?) + .map_err(|_| Error::CorruptOutboxRecord)?; + let enqueue = EnqueueOutboxItem::new( + item_id, + operation_instance_id, + DeliveryPlanDigest::new(array( + row.try_get::<Vec<u8>, _>("plan_digest") + .map_err(map_corrupt)?, + )?), + request, + u64_from_i64(row.try_get("created_at_unix_ms").map_err(map_corrupt)?)?, + ) + .map_err(|_| Error::CorruptOutboxRecord)?; + let lease = decode_lease(&row)?; + let last_attempt = row + .try_get::<Option<i64>, _>("last_attempt") + .map_err(map_corrupt)? + .map(|value| { + DeliveryAttempt::new(u32::try_from(value).map_err(|_| Error::CorruptOutboxRecord)?) + .map_err(|_| Error::CorruptOutboxRecord) + }) + .transpose()?; + OutboxRecord::from_durable_parts( + enqueue, + OutboxRevision::new(u64_from_i64(row.try_get("revision").map_err(map_corrupt)?)?) + .map_err(|_| Error::CorruptOutboxRecord)?, + stage( + row.try_get::<String, _>("stage") + .map_err(map_corrupt)? + .as_str(), + )?, + lease, + last_attempt, + evidence, + satisfaction( + row.try_get::<String, _>("satisfaction") + .map_err(map_corrupt)? + .as_str(), + )?, + row.try_get::<Option<i64>, _>("retry_not_before_unix_ms") + .map_err(map_corrupt)? + .map(u64_from_i64) + .transpose()?, + u64_from_i64(row.try_get("updated_at_unix_ms").map_err(map_corrupt)?)?, + ) + .map(Some) +} + +async fn validate_targets( + connection: &mut SqliteConnection, + item_id: OutboxItemId, + request: &DeliveryRequest, +) -> Result<(), Error> { + let rows = sqlx::query( + "SELECT target_fingerprint, target_request, ordinal + FROM radroots_runtime_outbox_targets WHERE item_id = ? ORDER BY ordinal", + ) + .bind(item_id.as_bytes().as_slice()) + .fetch_all(&mut *connection) + .await + .map_err(map_backend)?; + if rows.len() != request.target_set().len() { + return Err(Error::CorruptOutboxRecord); + } + for (ordinal, (row, expected)) in rows.iter().zip(request.target_set().targets()).enumerate() { + let stored_ordinal = row.try_get::<i64, _>("ordinal").map_err(map_corrupt)?; + let fingerprint = String::from_utf8( + row.try_get::<Vec<u8>, _>("target_fingerprint") + .map_err(map_corrupt)?, + ) + .map_err(|_| Error::CorruptOutboxRecord)?; + let target = decode_target( + row.try_get::<Vec<u8>, _>("target_request") + .map_err(map_corrupt)? + .as_slice(), + )?; + if stored_ordinal != i64::try_from(ordinal).map_err(|_| Error::CorruptOutboxRecord)? + || fingerprint != expected.fingerprint().as_str() + || target != *expected + { + return Err(Error::CorruptOutboxRecord); + } + } + Ok(()) +} + +async fn load_evidence( + connection: &mut SqliteConnection, + item_id: OutboxItemId, +) -> Result<Vec<TargetDeliveryEvidence>, Error> { + sqlx::query( + "SELECT evidence.target_fingerprint, evidence.attempt, evidence.attempted, + evidence.outcome, evidence.retryability, evidence.recorded_at_unix_ms + FROM radroots_runtime_delivery_evidence AS evidence + JOIN radroots_runtime_outbox_targets AS target + ON target.item_id = evidence.item_id + AND target.target_fingerprint = evidence.target_fingerprint + WHERE evidence.item_id = ? + ORDER BY evidence.attempt, target.ordinal", + ) + .bind(item_id.as_bytes().as_slice()) + .fetch_all(&mut *connection) + .await + .map_err(map_backend)? + .iter() + .map(|row| { + let target = TargetFingerprint::parse( + String::from_utf8( + row.try_get::<Vec<u8>, _>("target_fingerprint") + .map_err(map_corrupt)?, + ) + .map_err(|_| Error::CorruptOutboxRecord)?, + ) + .map_err(|_| Error::CorruptOutboxRecord)?; + let outcome = decode_outcome( + row.try_get::<Vec<u8>, _>("outcome") + .map_err(map_corrupt)? + .as_slice(), + )?; + let retryability = row + .try_get::<String, _>("retryability") + .map_err(map_corrupt)?; + if retryability != retryability_name(outcome.retryability()) { + return Err(Error::CorruptOutboxRecord); + } + TargetDeliveryEvidence::new( + target, + DeliveryAttempt::new( + u32::try_from(row.try_get::<i64, _>("attempt").map_err(map_corrupt)?) + .map_err(|_| Error::CorruptOutboxRecord)?, + ) + .map_err(|_| Error::CorruptOutboxRecord)?, + match row.try_get::<i64, _>("attempted").map_err(map_corrupt)? { + 0 => false, + 1 => true, + _ => return Err(Error::CorruptOutboxRecord), + }, + outcome, + u64_from_i64(row.try_get("recorded_at_unix_ms").map_err(map_corrupt)?)?, + ) + .map_err(|_| Error::CorruptOutboxRecord) + }) + .collect() +} + +fn decode_lease(row: &sqlx::sqlite::SqliteRow) -> Result<Option<OutboxLease>, Error> { + let id = row + .try_get::<Option<Vec<u8>>, _>("lease_id") + .map_err(map_corrupt)?; + let owner = row + .try_get::<Option<String>, _>("lease_owner") + .map_err(map_corrupt)?; + let acquired = row + .try_get::<Option<i64>, _>("lease_acquired_at_unix_ms") + .map_err(map_corrupt)?; + let expires = row + .try_get::<Option<i64>, _>("lease_expires_at_unix_ms") + .map_err(map_corrupt)?; + match (id, owner, acquired, expires) { + (None, None, None, None) => Ok(None), + (Some(id), Some(owner), Some(acquired), Some(expires)) => OutboxLease::new( + LeaseId::new(array(id)?).map_err(|_| Error::CorruptOutboxRecord)?, + LeaseOwner::parse(owner).map_err(|_| Error::CorruptOutboxRecord)?, + u64_from_i64(acquired)?, + u64_from_i64(expires)?, + ) + .map(Some) + .map_err(|_| Error::CorruptOutboxRecord), + _ => Err(Error::CorruptOutboxRecord), + } +} + +fn encode_request(value: &DeliveryRequest) -> Result<Vec<u8>, Error> { + let mut bytes = vec![1]; + put_str(&mut bytes, value.request_id().as_str())?; + put_blob(&mut bytes, value.payload().event().raw_json().as_bytes())?; + put_u16( + &mut bytes, + u16::try_from(value.target_set().len()).map_err(|_| Error::CorruptOutboxRecord)?, + ); + for target in value.target_set().targets() { + put_blob(&mut bytes, encode_target(target)?.as_slice())?; + } + bytes.push(match value.satisfaction().class() { + SatisfactionClass::Accepted => 0, + SatisfactionClass::Delivered => 1, + }); + let policy = value.satisfaction().targets(); + if policy.is_any() { + bytes.push(0); + } else if policy.is_all() { + bytes.push(1); + } else if let Some(threshold) = policy.quorum_threshold() { + bytes.push(2); + put_u16(&mut bytes, threshold); + } else if let Some(required) = policy.required_targets() { + bytes.push(3); + put_u16( + &mut bytes, + u16::try_from(required.len()).map_err(|_| Error::CorruptOutboxRecord)?, + ); + for target in required { + put_str(&mut bytes, target.as_str())?; + } + } else { + return Err(Error::CorruptOutboxRecord); + } + bytes.extend_from_slice(&value.deadline_unix_ms().to_be_bytes()); + Ok(bytes) +} + +fn decode_request(bytes: &[u8]) -> Result<DeliveryRequest, Error> { + let mut cursor = Cursor::new(bytes); + if cursor.byte()? != 1 { + return Err(Error::CorruptOutboxRecord); + } + let request_id = cursor.string()?.to_owned(); + let raw_event = cursor.string_blob()?; + let event = Codec::decode_signed_event(raw_event).map_err(|_| Error::CorruptOutboxRecord)?; + let target_count = usize::from(cursor.u16()?); + if target_count == 0 || target_count > TARGET_SET_MAX_ITEMS { + return Err(Error::CorruptOutboxRecord); + } + let mut targets = Vec::with_capacity(target_count); + for _ in 0..target_count { + targets.push(decode_target(cursor.blob()?)?); + } + let class = match cursor.byte()? { + 0 => SatisfactionClass::Accepted, + 1 => SatisfactionClass::Delivered, + _ => return Err(Error::CorruptOutboxRecord), + }; + let target_policy = match cursor.byte()? { + 0 => TargetPolicy::any(), + 1 => TargetPolicy::all(), + 2 => TargetPolicy::quorum(cursor.u16()?).map_err(|_| Error::CorruptOutboxRecord)?, + 3 => { + let count = usize::from(cursor.u16()?); + let mut required = Vec::with_capacity(count); + for _ in 0..count { + required.push( + TargetFingerprint::parse(cursor.string()?) + .map_err(|_| Error::CorruptOutboxRecord)?, + ); + } + TargetPolicy::required(required).map_err(|_| Error::CorruptOutboxRecord)? + } + _ => return Err(Error::CorruptOutboxRecord), + }; + let deadline = cursor.u64()?; + cursor.finish()?; + DeliveryRequest::new( + request_id, + DeliveryPayload::new(event), + TargetSet::new(targets).map_err(|_| Error::CorruptOutboxRecord)?, + SatisfactionPolicy::new(class, target_policy), + deadline, + ) + .map_err(|_| Error::CorruptOutboxRecord) +} + +fn encode_target(value: &Target) -> Result<Vec<u8>, Error> { + let mut bytes = vec![1]; + put_str(&mut bytes, value.kind().as_str())?; + put_str(&mut bytes, value.uri().as_str())?; + put_optional_str(&mut bytes, value.scope().map(TargetScope::as_str))?; + put_optional_str(&mut bytes, value.label().map(TargetLabel::as_str))?; + Ok(bytes) +} + +fn decode_target(bytes: &[u8]) -> Result<Target, Error> { + let mut cursor = Cursor::new(bytes); + if cursor.byte()? != 1 { + return Err(Error::CorruptOutboxRecord); + } + let kind = TransportId::parse(cursor.string()?).map_err(|_| Error::CorruptOutboxRecord)?; + let uri = cursor.string()?.to_owned(); + let scope = cursor + .optional_string()? + .map(TargetScope::parse) + .transpose() + .map_err(|_| Error::CorruptOutboxRecord)?; + let label = cursor + .optional_string()? + .map(TargetLabel::parse) + .transpose() + .map_err(|_| Error::CorruptOutboxRecord)?; + cursor.finish()?; + Target::new_with_metadata(kind, uri, scope, label).map_err(|_| Error::CorruptOutboxRecord) +} + +fn encode_outcome(value: &DeliveryOutcome) -> Result<Vec<u8>, Error> { + let mut bytes = vec![1]; + bytes.push(match value.kind() { + DeliveryOutcomeKind::Accepted => 0, + DeliveryOutcomeKind::Delivered => 1, + DeliveryOutcomeKind::Rejected => 2, + DeliveryOutcomeKind::Unavailable => 3, + DeliveryOutcomeKind::Failed => 4, + }); + bytes.push(retryability_byte(value.retryability())); + match (value.code(), value.message()) { + (None, None) => bytes.push(0), + (Some(code), Some(message)) => { + bytes.push(1); + put_str(&mut bytes, code)?; + put_str(&mut bytes, message)?; + } + _ => return Err(Error::CorruptOutboxRecord), + } + Ok(bytes) +} + +fn decode_outcome(bytes: &[u8]) -> Result<DeliveryOutcome, Error> { + let mut cursor = Cursor::new(bytes); + if cursor.byte()? != 1 { + return Err(Error::CorruptOutboxRecord); + } + let kind = cursor.byte()?; + let retryability = retryability(cursor.byte()?)?; + let outcome = match kind { + 0 if retryability == Retryability::NotApplicable => DeliveryOutcome::accepted(), + 1 if retryability == Retryability::NotApplicable => DeliveryOutcome::delivered(), + 2 if retryability == Retryability::Terminal => DeliveryOutcome::rejected(), + 3 if retryability == Retryability::Retryable => DeliveryOutcome::unavailable(), + 4 => DeliveryOutcome::failed(retryability).map_err(|_| Error::CorruptOutboxRecord)?, + _ => return Err(Error::CorruptOutboxRecord), + }; + let outcome = match cursor.byte()? { + 0 => outcome, + 1 => outcome + .with_detail(cursor.string()?, cursor.string()?) + .map_err(|_| Error::CorruptOutboxRecord)?, + _ => return Err(Error::CorruptOutboxRecord), + }; + cursor.finish()?; + Ok(outcome) +} + +fn put_str(bytes: &mut Vec<u8>, value: &str) -> Result<(), Error> { + let length = u16::try_from(value.len()).map_err(|_| Error::CorruptOutboxRecord)?; + put_u16(bytes, length); + bytes.extend_from_slice(value.as_bytes()); + Ok(()) +} + +fn put_optional_str(bytes: &mut Vec<u8>, value: Option<&str>) -> Result<(), Error> { + match value { + Some(value) => { + bytes.push(1); + put_str(bytes, value) + } + None => { + bytes.push(0); + Ok(()) + } + } +} + +fn put_blob(bytes: &mut Vec<u8>, value: &[u8]) -> Result<(), Error> { + let length = u32::try_from(value.len()).map_err(|_| Error::CorruptOutboxRecord)?; + bytes.extend_from_slice(&length.to_be_bytes()); + bytes.extend_from_slice(value); + Ok(()) +} + +fn put_u16(bytes: &mut Vec<u8>, value: u16) { + bytes.extend_from_slice(&value.to_be_bytes()); +} + +struct Cursor<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Cursor<'a> { + const fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn byte(&mut self) -> Result<u8, Error> { + let value = self + .bytes + .get(self.offset) + .copied() + .ok_or(Error::CorruptOutboxRecord)?; + self.offset += 1; + Ok(value) + } + + fn u16(&mut self) -> Result<u16, Error> { + Ok(u16::from_be_bytes(self.array()?)) + } + + fn u32(&mut self) -> Result<u32, Error> { + Ok(u32::from_be_bytes(self.array()?)) + } + + fn u64(&mut self) -> Result<u64, Error> { + Ok(u64::from_be_bytes(self.array()?)) + } + + fn string(&mut self) -> Result<&'a str, Error> { + let length = usize::from(self.u16()?); + core::str::from_utf8(self.take(length)?).map_err(|_| Error::CorruptOutboxRecord) + } + + fn optional_string(&mut self) -> Result<Option<&'a str>, Error> { + match self.byte()? { + 0 => Ok(None), + 1 => self.string().map(Some), + _ => Err(Error::CorruptOutboxRecord), + } + } + + fn blob(&mut self) -> Result<&'a [u8], Error> { + let length = usize::try_from(self.u32()?).map_err(|_| Error::CorruptOutboxRecord)?; + self.take(length) + } + + fn string_blob(&mut self) -> Result<&'a str, Error> { + core::str::from_utf8(self.blob()?).map_err(|_| Error::CorruptOutboxRecord) + } + + fn array<const N: usize>(&mut self) -> Result<[u8; N], Error> { + self.take(N)? + .try_into() + .map_err(|_| Error::CorruptOutboxRecord) + } + + fn take(&mut self, length: usize) -> Result<&'a [u8], Error> { + let end = self + .offset + .checked_add(length) + .ok_or(Error::CorruptOutboxRecord)?; + let value = self + .bytes + .get(self.offset..end) + .ok_or(Error::CorruptOutboxRecord)?; + self.offset = end; + Ok(value) + } + + fn finish(self) -> Result<(), Error> { + if self.offset == self.bytes.len() { + Ok(()) + } else { + Err(Error::CorruptOutboxRecord) + } + } +} + +const fn stage_name(value: OutboxStage) -> &'static str { + match value { + OutboxStage::Pending => "pending", + OutboxStage::Leased => "leased", + OutboxStage::Retryable => "retryable", + OutboxStage::Satisfied => "satisfied", + OutboxStage::Exhausted => "exhausted", + } +} + +const fn stage(value: &str) -> Result<OutboxStage, Error> { + match value.as_bytes() { + b"pending" => Ok(OutboxStage::Pending), + b"leased" => Ok(OutboxStage::Leased), + b"retryable" => Ok(OutboxStage::Retryable), + b"satisfied" => Ok(OutboxStage::Satisfied), + b"exhausted" => Ok(OutboxStage::Exhausted), + _ => Err(Error::CorruptOutboxRecord), + } +} + +const fn satisfaction_name(value: SatisfactionResult) -> &'static str { + match value { + SatisfactionResult::Pending => "pending", + SatisfactionResult::Satisfied => "satisfied", + SatisfactionResult::Exhausted => "exhausted", + } +} + +const fn satisfaction(value: &str) -> Result<SatisfactionResult, Error> { + match value.as_bytes() { + b"pending" => Ok(SatisfactionResult::Pending), + b"satisfied" => Ok(SatisfactionResult::Satisfied), + b"exhausted" => Ok(SatisfactionResult::Exhausted), + _ => Err(Error::CorruptOutboxRecord), + } +} + +const fn retryability_name(value: Retryability) -> &'static str { + match value { + Retryability::Retryable => "retryable", + Retryability::Terminal => "terminal", + Retryability::NotApplicable => "not_applicable", + } +} + +const fn retryability_byte(value: Retryability) -> u8 { + match value { + Retryability::NotApplicable => 0, + Retryability::Retryable => 1, + Retryability::Terminal => 2, + } +} + +const fn retryability(value: u8) -> Result<Retryability, Error> { + match value { + 0 => Ok(Retryability::NotApplicable), + 1 => Ok(Retryability::Retryable), + 2 => Ok(Retryability::Terminal), + _ => Err(Error::CorruptOutboxRecord), + } +} + +fn count(row: &sqlx::sqlite::SqliteRow, column: &str) -> Result<u64, Error> { + u64_from_i64(row.try_get::<i64, _>(column).map_err(map_corrupt)?) +} + +fn array<const N: usize>(bytes: Vec<u8>) -> Result<[u8; N], Error> { + bytes.try_into().map_err(|_| Error::CorruptOutboxRecord) +} + +fn i64_from_u64(value: u64) -> Result<i64, Error> { + i64::try_from(value).map_err(|_| Error::CorruptOutboxRecord) +} + +fn u64_from_i64(value: i64) -> Result<u64, Error> { + u64::try_from(value).map_err(|_| Error::CorruptOutboxRecord) +} + +fn map_backend(_: sqlx::Error) -> Error { + Error::BackendUnavailable +} + +fn map_corrupt(_: sqlx::Error) -> Error { + Error::CorruptOutboxRecord +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::migration::runtime::{MIGRATIONS, migration_sql}; + use radroots_event::{SignedEvent, wire::Nip01EventWire}; + use radroots_storage::{ + Journal, Outbox, + event::SourceGeneration, + journal::{ + IdempotencyDigest, IdempotencyKey, OperationId, OperationInstanceId, PrepareOperation, + }, + outbox::{DeliveryReceipt, DeliveryTargetReceipt}, + status::EventStoreMode, + }; + use sqlx::sqlite::SqlitePoolOptions; + + async fn store(mode: EventStoreMode) -> SqliteStorage { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("memory SQLite"); + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&pool) + .await + .expect("foreign keys"); + for migration in MIGRATIONS { + sqlx::raw_sql(migration_sql(migration.version()).expect("registered SQL")) + .execute(&pool) + .await + .expect("runtime migration"); + } + SqliteStorage::new( + pool, + SourceGeneration::new([31; 32]).expect("generation"), + mode, + ) + } + + fn instance(byte: u8) -> OperationInstanceId { + OperationInstanceId::new([byte; 16]).expect("operation instance") + } + + async fn prepare(store: &SqliteStorage, byte: u8) { + Journal::prepare( + store, + PrepareOperation::new( + instance(byte), + OperationId::SyncPush, + IdempotencyKey::parse(format!("outbox-operation-{byte:02x}")) + .expect("idempotency key"), + IdempotencyDigest::new([byte; 32]), + 10, + ) + .expect("prepare operation"), + ) + .await + .expect("prepared journal operation"); + } + + fn signed_event() -> SignedEvent { + let mut wire = Nip01EventWire { + id: "0".repeat(64), + pubkey: "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df".to_owned(), + created_at: 1_800_000_100, + kind: 0, + tags: vec![], + content: "{\"display_name\":\"Moss Street Farm\",\"bot\":false}".to_owned(), + sig: "42".repeat(64), + extra: Default::default(), + }; + wire.id = wire + .computed_event_id() + .expect("canonical event id") + .to_hex(); + let raw_json = serde_json::json!({ + "id": &wire.id, + "pubkey": &wire.pubkey, + "created_at": wire.created_at, + "kind": wire.kind, + "tags": &wire.tags, + "content": &wire.content, + "sig": &wire.sig, + }) + .to_string(); + SignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event") + } + + fn request() -> DeliveryRequest { + DeliveryRequest::new( + "sqlite-outbox-request", + DeliveryPayload::new(signed_event()), + TargetSet::new(vec![ + Target::nostr_relay("wss://one.example").expect("first target"), + Target::nostr_relay("wss://two.example").expect("second target"), + ]) + .expect("target set"), + SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()), + 10_000, + ) + .expect("delivery request") + } + + fn enqueue(item: u8, operation: u8, digest: u8) -> EnqueueOutboxItem { + EnqueueOutboxItem::new( + OutboxItemId::new([item; 16]).expect("item id"), + instance(operation), + DeliveryPlanDigest::new([digest; 32]), + request(), + 20, + ) + .expect("enqueue") + } + + fn claim_request(now: u64, expires: u64, seed: u8) -> ClaimOutboxItems { + ClaimOutboxItems::new( + LeaseOwner::parse("sqlite-worker").expect("owner"), + LeaseId::new([seed; 16]).expect("lease seed"), + now, + expires, + 10, + ) + .expect("claim request") + } + + fn receipt(request: &DeliveryRequest, outcomes: [DeliveryOutcome; 2]) -> DeliveryReceipt { + DeliveryReceipt::for_request( + request, + request + .target_set() + .targets() + .iter() + .cloned() + .zip(outcomes) + .map(|(target, outcome)| DeliveryTargetReceipt::attempted(target, outcome)) + .collect(), + ) + .expect("delivery receipt") + } + + #[tokio::test] + async fn enqueue_replays_exact_plans_and_rejects_identity_conflicts() { + let store = store(EventStoreMode::ReadWrite).await; + prepare(&store, 1).await; + let item = enqueue(1, 1, 2); + let created = store.enqueue(item.clone()).await.expect("created"); + assert_eq!(created.disposition(), EnqueueDisposition::Created); + let replay = store.enqueue(item).await.expect("replay"); + assert_eq!(replay.disposition(), EnqueueDisposition::Replay); + assert_eq!(replay.record(), created.record()); + assert_eq!( + store.enqueue(enqueue(1, 1, 3)).await, + Err(Error::OutboxPlanConflict) + ); + assert_eq!( + store.enqueue(enqueue(2, 1, 2)).await, + Err(Error::OutboxPlanConflict) + ); + assert_eq!( + store + .item(OutboxItemId::new([1; 16]).expect("item id")) + .await + .expect("lookup") + .expect("record"), + *created.record() + ); + } + + #[tokio::test] + async fn claims_expire_release_and_honor_retry_deferral() { + let store = store(EventStoreMode::ReadWrite).await; + prepare(&store, 2).await; + store.enqueue(enqueue(2, 2, 2)).await.expect("enqueue"); + let first = store + .claim(claim_request(100, 200, 3)) + .await + .expect("claim") + .pop() + .expect("claimed"); + assert!( + store + .claim(claim_request(150, 250, 4)) + .await + .expect("concurrent claim") + .is_empty() + ); + let reclaimed = store + .claim(claim_request(200, 300, 5)) + .await + .expect("expired claim") + .pop() + .expect("reclaimed"); + assert_ne!(first.lease().id(), reclaimed.lease().id()); + let released = store + .release( + reclaimed.record().item_id(), + reclaimed.lease().id(), + reclaimed.record().revision(), + 210, + Some(250), + ) + .await + .expect("release"); + assert_eq!(released.stage(), OutboxStage::Pending); + assert!( + store + .claim(claim_request(249, 300, 6)) + .await + .expect("deferred claim") + .is_empty() + ); + assert_eq!( + store + .claim(claim_request(250, 350, 7)) + .await + .expect("ready claim") + .len(), + 1 + ); + } + + #[tokio::test] + async fn partial_evidence_survives_recovery_and_advances_to_satisfaction() { + let store = store(EventStoreMode::ReadWrite).await; + prepare(&store, 3).await; + store.enqueue(enqueue(3, 3, 3)).await.expect("enqueue"); + let first = store + .claim(claim_request(100, 200, 4)) + .await + .expect("claim") + .pop() + .expect("claimed"); + let partial = store + .record_attempt( + DeliveryAttemptEvidence::new( + first.record().item_id(), + first.lease().id(), + first.record().revision(), + DeliveryAttempt::FIRST, + receipt( + first.record().request(), + [DeliveryOutcome::accepted(), DeliveryOutcome::unavailable()], + ), + 150, + ) + .expect("partial evidence"), + ) + .await + .expect("partial attempt"); + assert_eq!(partial.stage(), OutboxStage::Retryable); + assert_eq!(partial.evidence().len(), 2); + + let recovered = SqliteStorage::new( + store.pool().clone(), + SourceGeneration::new([31; 32]).expect("generation"), + EventStoreMode::ReadWrite, + ); + let second = recovered + .claim(claim_request(250, 350, 5)) + .await + .expect("recovery claim") + .pop() + .expect("reclaimed"); + let satisfied = recovered + .record_attempt( + DeliveryAttemptEvidence::new( + second.record().item_id(), + second.lease().id(), + second.record().revision(), + DeliveryAttempt::new(2).expect("second attempt"), + receipt( + second.record().request(), + [DeliveryOutcome::accepted(), DeliveryOutcome::delivered()], + ), + 300, + ) + .expect("success evidence"), + ) + .await + .expect("successful attempt"); + assert_eq!(satisfied.stage(), OutboxStage::Satisfied); + assert_eq!(satisfied.evidence().len(), 4); + assert_eq!(recovered.status().await.expect("status").satisfied, 1); + + sqlx::query( + "UPDATE radroots_runtime_delivery_evidence SET outcome = X'FF' + WHERE item_id = ? AND attempt = 2", + ) + .bind(satisfied.item_id().as_bytes().as_slice()) + .execute(recovered.pool()) + .await + .expect("forge corrupt evidence"); + assert_eq!( + recovered.item(satisfied.item_id()).await, + Err(Error::CorruptOutboxRecord) + ); + let read_only = SqliteStorage::new( + recovered.pool().clone(), + SourceGeneration::new([31; 32]).expect("generation"), + EventStoreMode::ReadOnly, + ); + assert_eq!( + read_only.claim(claim_request(400, 500, 6)).await, + Err(Error::BackendUnavailable) + ); + } + + #[test] + fn versioned_codecs_round_trip_every_policy_target_and_outcome_shape() { + let targets = TargetSet::new(vec![ + Target::local_with_metadata( + "local://queue", + Some(TargetScope::parse("farm.one").expect("scope")), + Some(TargetLabel::parse("Farm queue").expect("label")), + ) + .expect("local target"), + Target::nostr_relay("wss://relay.example").expect("relay target"), + ]) + .expect("target set"); + let required = targets.targets()[0].fingerprint().clone(); + for policy in [ + TargetPolicy::any(), + TargetPolicy::all(), + TargetPolicy::quorum(1).expect("quorum"), + TargetPolicy::required(vec![required]).expect("required"), + ] { + let request = DeliveryRequest::new( + "codec-request", + DeliveryPayload::new(signed_event()), + targets.clone(), + SatisfactionPolicy::new(SatisfactionClass::Delivered, policy), + 20_000, + ) + .expect("request"); + assert_eq!( + decode_request(&encode_request(&request).expect("encode request")) + .expect("decode request"), + request + ); + } + + let outcomes = [ + DeliveryOutcome::accepted(), + DeliveryOutcome::delivered(), + DeliveryOutcome::rejected(), + DeliveryOutcome::unavailable(), + DeliveryOutcome::failed(Retryability::Retryable) + .expect("retryable failure") + .with_detail("relay_timeout", "relay request timed out") + .expect("failure detail"), + DeliveryOutcome::failed(Retryability::Terminal).expect("terminal failure"), + ]; + for outcome in outcomes { + assert_eq!( + decode_outcome(&encode_outcome(&outcome).expect("encode outcome")) + .expect("decode outcome"), + outcome + ); + } + for target in targets.targets() { + assert_eq!( + decode_target(&encode_target(target).expect("encode target")) + .expect("decode target"), + *target + ); + } + } +} diff --git a/crates/transport/src/policy.rs b/crates/transport/src/policy.rs @@ -82,6 +82,24 @@ impl TargetPolicy { } } + /// Returns whether any requested target may satisfy this policy. + pub const fn is_any(&self) -> bool { + matches!(self.kind, TargetPolicyKind::Any) + } + + /// Returns whether every requested target must satisfy this policy. + pub const fn is_all(&self) -> bool { + matches!(self.kind, TargetPolicyKind::All) + } + + /// Returns the threshold for a quorum policy. + pub const fn quorum_threshold(&self) -> Option<u16> { + match self.kind { + TargetPolicyKind::Quorum(threshold) => Some(threshold), + TargetPolicyKind::Any | TargetPolicyKind::All | TargetPolicyKind::Required(_) => None, + } + } + pub(crate) fn validate_for(&self, targets: &TargetSet) -> Result<(), Error> { match &self.kind { TargetPolicyKind::Any | TargetPolicyKind::All => Ok(()),