lib

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

commit 9efa42f9f42afae1f30965d724433f3abdebf9fe
parent 33630bb261d12e3d1a87ae48e03df6d2f7ba0b58
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Jul 2026 06:22:32 +0000

outbox: make target lifecycle idempotent

Diffstat:
Mcrates/outbox/src/error.rs | 9+++++++++
Mcrates/outbox/src/store.rs | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 256 insertions(+), 13 deletions(-)

diff --git a/crates/outbox/src/error.rs b/crates/outbox/src/error.rs @@ -47,6 +47,15 @@ pub enum RadrootsOutboxError { #[error("Outbox delivery target not found: {0}")] DeliveryTargetNotFound(i64), + #[error( + "Outbox delivery target {delivery_target_id} already completed as {current_status}; requested {requested_status}" + )] + DeliveryTargetStatusConflict { + delivery_target_id: i64, + current_status: &'static str, + requested_status: &'static str, + }, + #[error("Claim token mismatch for outbox event {outbox_event_id}")] ClaimTokenMismatch { outbox_event_id: i64 }, diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -13,7 +13,10 @@ use crate::model::{ RadrootsOutboxReticulumPreviewBehavior, RadrootsOutboxSignedOperationInput, RadrootsOutboxStatusSummary, }; -use radroots_event_store::{RadrootsEventIngest, RadrootsEventStore}; +use radroots_event_store::{ + RadrootsEventIngest, RadrootsEventStore, RadrootsTransportObservation, + RadrootsTransportObservationType, +}; use radroots_events::RadrootsNostrEvent; use radroots_events::draft::{ RadrootsFrozenEventDraft, RadrootsSignedNostrEvent, validate_signed_nostr_event_matches_draft, @@ -697,8 +700,15 @@ impl RadrootsOutbox { .signed_event .ok_or(RadrootsOutboxError::MissingSignedEvent(outbox_event_id))?; let event = event_from_signed(&signed_event); + let observation = RadrootsTransportObservation::new( + RadrootsTransportKind::Local, + "local:outbox", + RadrootsTransportObservationType::LocalImport, + observed_at_ms, + )?; let ingest = RadrootsEventIngest::new(event, observed_at_ms) - .with_raw_json(signed_event.raw_json.clone()); + .with_raw_json(signed_event.raw_json.clone()) + .with_observation(observation); let receipt = event_store.ingest_event(ingest).await?; let changed = sqlx::query( "UPDATE outbox_event SET event_store_ingested = 1, event_store_inserted = ?, event_store_ingested_at_ms = ?, state = ?, updated_at_ms = ? WHERE outbox_event_id = ? AND claim_token = ?", @@ -1069,9 +1079,37 @@ impl RadrootsOutbox { let Some(active_delivery_plan_id) = identity.active_delivery_plan_id else { return Err(RadrootsOutboxError::MissingActiveDeliveryPlan { outbox_event_id }); }; + let target_row = sqlx::query( + "SELECT delivery_plan_id, status FROM outbox_delivery_target WHERE delivery_target_id = ? AND delivery_plan_id = ? AND delivery_plan_id IN (SELECT delivery_plan_id FROM outbox_delivery_plan WHERE outbox_event_id = ?)", + ) + .bind(delivery_target_id) + .bind(active_delivery_plan_id) + .bind(outbox_event_id) + .fetch_optional(&mut *tx) + .await?; + let Some(target_row) = target_row else { + return Err(RadrootsOutboxError::DeliveryTargetNotFound( + delivery_target_id, + )); + }; + let delivery_plan_id: i64 = target_row.try_get("delivery_plan_id")?; + let current_status = RadrootsOutboxDeliveryTargetStatus::parse( + target_row.try_get::<String, _>("status")?.as_str(), + )?; + if current_status.is_completed() { + if current_status == status { + tx.commit().await?; + return Ok(()); + } + return Err(RadrootsOutboxError::DeliveryTargetStatusConflict { + delivery_target_id, + current_status: current_status.as_str(), + requested_status: status.as_str(), + }); + } let completed_at_ms = status.is_completed().then_some(attempted_at_ms); let changed = sqlx::query( - "UPDATE outbox_delivery_target SET status = ?, attempt_count = attempt_count + 1, last_attempt_at_ms = ?, completed_at_ms = ?, last_error = ? WHERE delivery_target_id = ? AND delivery_plan_id = ? AND delivery_plan_id IN (SELECT delivery_plan_id FROM outbox_delivery_plan WHERE outbox_event_id = ?)", + "UPDATE outbox_delivery_target SET status = ?, attempt_count = attempt_count + 1, last_attempt_at_ms = ?, completed_at_ms = ?, last_error = ? WHERE delivery_target_id = ? AND delivery_plan_id = ?", ) .bind(status.as_str()) .bind(attempted_at_ms) @@ -1079,7 +1117,6 @@ impl RadrootsOutbox { .bind(message) .bind(delivery_target_id) .bind(active_delivery_plan_id) - .bind(outbox_event_id) .execute(&mut *tx) .await?; if changed.rows_affected() == 0 { @@ -1087,13 +1124,6 @@ impl RadrootsOutbox { delivery_target_id, )); } - let delivery_plan_id: i64 = sqlx::query( - "SELECT delivery_plan_id FROM outbox_delivery_target WHERE delivery_target_id = ?", - ) - .bind(delivery_target_id) - .fetch_one(&mut *tx) - .await? - .try_get("delivery_plan_id")?; sqlx::query( "INSERT INTO outbox_delivery_attempt(delivery_plan_id, delivery_target_id, status, attempted_at_ms, message) VALUES (?, ?, ?, ?, ?)", ) @@ -2306,6 +2336,88 @@ mod tests { .expect("table count") } + async fn mark_target_status_for_test( + outbox: &RadrootsOutbox, + outbox_event_id: i64, + claim_token: &str, + delivery_target_id: i64, + status: RadrootsOutboxDeliveryTargetStatus, + attempted_at_ms: i64, + ) -> Result<(), RadrootsOutboxError> { + match status { + RadrootsOutboxDeliveryTargetStatus::Accepted => { + outbox + .mark_delivery_target_accepted( + outbox_event_id, + claim_token, + delivery_target_id, + attempted_at_ms, + ) + .await + } + RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented => { + outbox + .mark_delivery_target_deferred_until_implemented( + outbox_event_id, + claim_token, + delivery_target_id, + "transport deferred", + attempted_at_ms, + ) + .await + } + RadrootsOutboxDeliveryTargetStatus::PreviewUnavailable => { + outbox + .mark_delivery_target_preview_unavailable( + outbox_event_id, + claim_token, + delivery_target_id, + "transport preview unavailable", + attempted_at_ms, + ) + .await + } + RadrootsOutboxDeliveryTargetStatus::SkippedPolicyDenied => { + outbox + .mark_delivery_target_skipped_policy_denied( + outbox_event_id, + claim_token, + delivery_target_id, + "policy denied", + attempted_at_ms, + ) + .await + } + RadrootsOutboxDeliveryTargetStatus::FailedTerminal => { + outbox + .mark_delivery_target_failed_terminal( + outbox_event_id, + claim_token, + delivery_target_id, + "terminal failure", + attempted_at_ms, + ) + .await + } + RadrootsOutboxDeliveryTargetStatus::FailedRetryable => { + outbox + .mark_delivery_target_failed_retryable( + outbox_event_id, + claim_token, + delivery_target_id, + "retryable failure", + attempted_at_ms, + ) + .await + } + RadrootsOutboxDeliveryTargetStatus::Pending + | RadrootsOutboxDeliveryTargetStatus::Delivered + | RadrootsOutboxDeliveryTargetStatus::Forwarded + | RadrootsOutboxDeliveryTargetStatus::StoredByGateway + | RadrootsOutboxDeliveryTargetStatus::Seen => unreachable!(), + } + } + #[test] fn event_wide_publish_failure_helper_names_stay_removed() { let source = include_str!("store.rs"); @@ -2612,6 +2724,119 @@ mod tests { } #[tokio::test] + async fn terminal_delivery_target_updates_are_idempotent_and_conflicting_rewrites_fail() { + for terminal_status in [ + RadrootsOutboxDeliveryTargetStatus::Accepted, + RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented, + RadrootsOutboxDeliveryTargetStatus::PreviewUnavailable, + RadrootsOutboxDeliveryTargetStatus::SkippedPolicyDenied, + RadrootsOutboxDeliveryTargetStatus::FailedTerminal, + ] { + let outbox = RadrootsOutbox::open_memory().await.expect("open"); + let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, terminal_status.as_str()); + let signed_event = + radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); + let receipt = outbox + .enqueue_signed_operation(RadrootsOutboxSignedOperationInput::new( + "publish_post", + draft, + signed_event, + RadrootsOutboxDeliveryPlanInput::new( + "transport.nostr.local", + 1, + RadrootsTransportSatisfactionPolicy::all_accepted(), + vec![nostr_target(NOSTR_PRIMARY_WSS)], + ), + true, + 1_007, + 1_000, + )) + .await + .expect("enqueue"); + let claimed = outbox + .claim_next_ready_signed_event("publisher", "claim-a", 2_000, 1_000) + .await + .expect("claim") + .expect("claimed"); + let target_id = claimed.delivery_targets[0].delivery_target_id; + + mark_target_status_for_test( + &outbox, + receipt.outbox_event_id, + "claim-a", + target_id, + terminal_status, + 1_100, + ) + .await + .expect("first terminal update"); + mark_target_status_for_test( + &outbox, + receipt.outbox_event_id, + "claim-a", + target_id, + terminal_status, + 1_200, + ) + .await + .expect("idempotent terminal update"); + + let targets = outbox + .delivery_targets(receipt.outbox_event_id) + .await + .expect("targets"); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].status, terminal_status); + assert_eq!(targets[0].attempt_count, 1); + assert_eq!(targets[0].last_attempt_at_ms, Some(1_100)); + assert_eq!(targets[0].completed_at_ms, Some(1_100)); + let attempts = outbox.delivery_attempts(target_id).await.expect("attempts"); + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].status, terminal_status); + assert_eq!(attempts[0].attempted_at_ms, 1_100); + + let requested_status = + if terminal_status == RadrootsOutboxDeliveryTargetStatus::Accepted { + RadrootsOutboxDeliveryTargetStatus::FailedTerminal + } else { + RadrootsOutboxDeliveryTargetStatus::Accepted + }; + let err = mark_target_status_for_test( + &outbox, + receipt.outbox_event_id, + "claim-a", + target_id, + requested_status, + 1_300, + ) + .await + .expect_err("conflicting terminal rewrite"); + assert!(matches!( + err, + RadrootsOutboxError::DeliveryTargetStatusConflict { + delivery_target_id, + current_status, + requested_status: observed_requested_status, + } if delivery_target_id == target_id + && current_status == terminal_status.as_str() + && observed_requested_status == requested_status.as_str() + )); + let targets = outbox + .delivery_targets(receipt.outbox_event_id) + .await + .expect("targets after conflict"); + assert_eq!(targets[0].status, terminal_status); + assert_eq!(targets[0].attempt_count, 1); + assert_eq!(targets[0].completed_at_ms, Some(1_100)); + let attempts = outbox + .delivery_attempts(target_id) + .await + .expect("attempts after conflict"); + assert_eq!(attempts.len(), 1); + } + } + + #[tokio::test] async fn publish_claims_one_delivery_plan_and_rejects_sibling_target_outcomes() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "duplicate endpoint plans"); @@ -3520,7 +3745,7 @@ mod tests { } #[tokio::test] - async fn local_signed_event_ingest_remains_idempotent_without_transport_observation() { + async fn local_signed_event_ingest_records_one_idempotent_local_import_observation() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); let event_store = RadrootsEventStore::open_memory() .await @@ -3568,6 +3793,15 @@ mod tests { .observations_for_event(signed.id.as_str()) .await .expect("observations"); - assert!(observations.is_empty()); + assert_eq!(observations.len(), 1); + assert_eq!(observations[0].transport_kind, RadrootsTransportKind::Local); + assert_eq!(observations[0].endpoint_uri.as_str(), "local:outbox"); + assert_eq!( + observations[0].observation_type, + RadrootsTransportObservationType::LocalImport + ); + assert_eq!(observations[0].observation_count, 1); + assert_eq!(observations[0].first_observed_at_ms, 2_200); + assert_eq!(observations[0].last_observed_at_ms, 2_200); } }