lib

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

commit 061bf15d7fcab4834a0c87740f43f2fef6589228
parent 2152d9bca992a1dbddeccc573f1b5b0f43e719ea
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Jul 2026 23:29:28 +0000

outbox: scope terminal failure to active plan

Diffstat:
Mcrates/outbox/src/store.rs | 328++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 285 insertions(+), 43 deletions(-)

diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -849,50 +849,92 @@ impl RadrootsOutbox { } let evaluation = evaluate_delivery_plans(&mut tx, outbox_event_id, now_ms).await?; let (event_state, operation_status, last_error, next_attempt_after_ms) = - if evaluation.all_complete { - ( - RadrootsOutboxEventState::Published, - Some(RadrootsOutboxOperationStatus::Complete), - None, - now_ms, - ) - } else if evaluation.any_failed_terminal { - ( - RadrootsOutboxEventState::FailedTerminal, - Some(RadrootsOutboxOperationStatus::FailedTerminal), - Some(terminal_error.as_ref()), - now_ms, - ) - } else if evaluation.any_ready { - ( - RadrootsOutboxEventState::PublishRetryable, - None, - Some(retryable_error.as_ref()), - next_attempt_after_ms, - ) - } else if evaluation.any_preview_unavailable { - ( - RadrootsOutboxEventState::PreviewUnavailable, - Some(RadrootsOutboxOperationStatus::PreviewUnavailable), - None, - now_ms, - ) - } else if evaluation.any_deferred_until_implemented { - ( - RadrootsOutboxEventState::DeferredUntilImplemented, - Some(RadrootsOutboxOperationStatus::DeferredUntilImplemented), - None, - now_ms, - ) - } else { - ( - RadrootsOutboxEventState::FailedTerminal, - Some(RadrootsOutboxOperationStatus::FailedTerminal), - Some(terminal_error.as_ref()), - now_ms, - ) - }; + publish_lifecycle_from_plan_evaluation( + &evaluation, + retryable_error.as_ref(), + terminal_error.as_ref(), + next_attempt_after_ms, + now_ms, + ); + + let changed = sqlx::query( + "UPDATE outbox_event SET state = ?, claim_token = NULL, claim_owner = NULL, claim_expires_at_ms = NULL, active_delivery_plan_id = NULL, last_error = ?, next_attempt_after_ms = ?, updated_at_ms = ? WHERE outbox_event_id = ? AND claim_token = ?", + ) + .bind(event_state.as_str()) + .bind(last_error) + .bind(next_attempt_after_ms) + .bind(now_ms) + .bind(outbox_event_id) + .bind(claim_token) + .execute(&mut *tx) + .await?; + if changed.rows_affected() == 0 { + return Err(RadrootsOutboxError::ClaimTokenMismatch { outbox_event_id }); + } + if let Some(operation_status) = operation_status { + sqlx::query( + "UPDATE outbox_operations SET status = ?, updated_at_ms = ? WHERE operation_id = ?", + ) + .bind(operation_status.as_str()) + .bind(now_ms) + .bind(row.operation_id) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(event_state) + } + + pub async fn mark_active_delivery_plan_failed_terminal( + &self, + outbox_event_id: i64, + claim_token: &str, + error: impl AsRef<str>, + now_ms: i64, + ) -> Result<RadrootsOutboxEventState, RadrootsOutboxError> { + let mut tx = self.pool.begin().await?; + let row = claimed_event_identity_tx(&mut tx, outbox_event_id, claim_token).await?; + let Some(active_delivery_plan_id) = row.active_delivery_plan_id else { + return Err(RadrootsOutboxError::MissingActiveDeliveryPlan { outbox_event_id }); + }; + let targets = delivery_targets_for_plan_tx(&mut tx, active_delivery_plan_id).await?; + for target in targets + .iter() + .filter(|target| target.status.is_ready_for_attempt()) + { + 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 = ?", + ) + .bind(RadrootsOutboxDeliveryTargetStatus::FailedTerminal.as_str()) + .bind(now_ms) + .bind(now_ms) + .bind(error.as_ref()) + .bind(target.delivery_target_id) + .bind(active_delivery_plan_id) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO outbox_delivery_attempt(delivery_plan_id, delivery_target_id, status, attempted_at_ms, message) VALUES (?, ?, ?, ?, ?)", + ) + .bind(active_delivery_plan_id) + .bind(target.delivery_target_id) + .bind(RadrootsOutboxDeliveryTargetStatus::FailedTerminal.as_str()) + .bind(now_ms) + .bind(error.as_ref()) + .execute(&mut *tx) + .await?; + } + let evaluation = evaluate_delivery_plans(&mut tx, outbox_event_id, now_ms).await?; + let (event_state, operation_status, last_error, next_attempt_after_ms) = + publish_lifecycle_from_plan_evaluation( + &evaluation, + error.as_ref(), + error.as_ref(), + now_ms, + now_ms, + ); let changed = sqlx::query( "UPDATE outbox_event SET state = ?, claim_token = NULL, claim_owner = NULL, claim_expires_at_ms = NULL, active_delivery_plan_id = NULL, last_error = ?, next_attempt_after_ms = ?, updated_at_ms = ? WHERE outbox_event_id = ? AND claim_token = ?", ) @@ -1156,6 +1198,63 @@ struct PlanEvaluation { any_deferred_until_implemented: bool, } +fn publish_lifecycle_from_plan_evaluation<'a>( + evaluation: &PlanEvaluation, + retryable_error: &'a str, + terminal_error: &'a str, + next_attempt_after_ms: i64, + now_ms: i64, +) -> ( + RadrootsOutboxEventState, + Option<RadrootsOutboxOperationStatus>, + Option<&'a str>, + i64, +) { + if evaluation.all_complete { + ( + RadrootsOutboxEventState::Published, + Some(RadrootsOutboxOperationStatus::Complete), + None, + now_ms, + ) + } else if evaluation.any_ready { + ( + RadrootsOutboxEventState::PublishRetryable, + None, + Some(retryable_error), + next_attempt_after_ms, + ) + } else if evaluation.any_failed_terminal { + ( + RadrootsOutboxEventState::FailedTerminal, + Some(RadrootsOutboxOperationStatus::FailedTerminal), + Some(terminal_error), + now_ms, + ) + } else if evaluation.any_preview_unavailable { + ( + RadrootsOutboxEventState::PreviewUnavailable, + Some(RadrootsOutboxOperationStatus::PreviewUnavailable), + None, + now_ms, + ) + } else if evaluation.any_deferred_until_implemented { + ( + RadrootsOutboxEventState::DeferredUntilImplemented, + Some(RadrootsOutboxOperationStatus::DeferredUntilImplemented), + None, + now_ms, + ) + } else { + ( + RadrootsOutboxEventState::FailedTerminal, + Some(RadrootsOutboxOperationStatus::FailedTerminal), + Some(terminal_error), + now_ms, + ) + } +} + async fn configure_connection( pool: &SqlitePool, file_backed: bool, @@ -2666,6 +2765,149 @@ mod tests { } #[tokio::test] + async fn active_plan_terminal_failure_leaves_sibling_claimable() { + let outbox = RadrootsOutbox::open_memory().await.expect("open"); + let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "plan scoped terminal"); + let signed_event = + radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); + let first = outbox + .enqueue_signed_operation( + RadrootsOutboxSignedOperationInput::new( + "publish_post", + draft.clone(), + signed_event.clone(), + RadrootsOutboxDeliveryPlanInput::new( + "transport.proxy.invalid", + 1, + RadrootsTransportSatisfactionPolicy::all_accepted(), + vec![nostr_target(NOSTR_PRIMARY_WSS)], + ), + true, + 1_007, + 1_000, + ) + .with_idempotency_key("idem-plan-terminal"), + ) + .await + .expect("first plan"); + let second = outbox + .enqueue_signed_operation( + RadrootsOutboxSignedOperationInput::new( + "publish_post", + draft, + signed_event, + RadrootsOutboxDeliveryPlanInput::new( + "transport.nostr.sibling", + 1, + RadrootsTransportSatisfactionPolicy::all_accepted(), + vec![nostr_target(NOSTR_SECONDARY_WSS)], + ), + true, + 1_017, + 1_010, + ) + .with_idempotency_key("idem-plan-terminal"), + ) + .await + .expect("second plan"); + assert_eq!(first.outbox_event_id, second.outbox_event_id); + + let first_claim = outbox + .claim_next_ready_signed_event("publisher", "claim-a", 2_000, 1_020) + .await + .expect("claim") + .expect("claimed"); + let failed_plan_id = first_claim.active_delivery_plan_id.expect("active plan"); + let failed_target_id = first_claim.delivery_targets[0].delivery_target_id; + let first_state = outbox + .mark_active_delivery_plan_failed_terminal( + first.outbox_event_id, + "claim-a", + "local proxy validation failed", + 1_100, + ) + .await + .expect("active plan terminal"); + assert_eq!(first_state, RadrootsOutboxEventState::PublishRetryable); + let event_after_first = outbox + .get_event(first.outbox_event_id) + .await + .expect("event") + .expect("event"); + assert_eq!( + event_after_first.state, + RadrootsOutboxEventState::PublishRetryable + ); + assert_eq!(event_after_first.claim_token, None); + + let targets_after_first = outbox + .delivery_targets(first.outbox_event_id) + .await + .expect("targets"); + assert_eq!( + targets_after_first + .iter() + .find(|target| target.delivery_target_id == failed_target_id) + .expect("failed target") + .status, + RadrootsOutboxDeliveryTargetStatus::FailedTerminal + ); + assert!( + targets_after_first + .iter() + .filter(|target| target.delivery_plan_id != failed_plan_id) + .all(|target| target.status == RadrootsOutboxDeliveryTargetStatus::Pending) + ); + let plans_after_first = outbox + .delivery_plans(first.outbox_event_id) + .await + .expect("plans"); + assert_eq!( + plans_after_first + .iter() + .find(|plan| plan.delivery_plan_id == failed_plan_id) + .expect("failed plan") + .status, + RadrootsOutboxDeliveryPlanStatus::FailedTerminal + ); + assert!( + plans_after_first + .iter() + .filter(|plan| plan.delivery_plan_id != failed_plan_id) + .all(|plan| plan.status == RadrootsOutboxDeliveryPlanStatus::Queued) + ); + + let sibling_claim = outbox + .claim_next_ready_signed_event("publisher", "claim-b", 3_000, 1_100) + .await + .expect("sibling claim") + .expect("sibling claim"); + assert_ne!( + sibling_claim.active_delivery_plan_id, + first_claim.active_delivery_plan_id + ); + let final_state = outbox + .mark_active_delivery_plan_failed_terminal( + first.outbox_event_id, + "claim-b", + "sibling terminal", + 1_200, + ) + .await + .expect("sibling terminal"); + assert_eq!(final_state, RadrootsOutboxEventState::FailedTerminal); + let operation = outbox + .get_operation(first.operation_id) + .await + .expect("operation") + .expect("operation"); + assert_eq!( + operation.status, + RadrootsOutboxOperationStatus::FailedTerminal + ); + } + + #[tokio::test] async fn quorum_accepted_delivery_plan_round_trips_and_completes_after_required_target() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "at least");