lib

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

commit 5d623f6498b57923b7e4ee64c8b7e7e4e4abbe10
parent c9ea54fa108d5f86db3e02973286a78945dc58bb
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Jul 2026 23:21:20 +0000

sync: recover expired outbox claims before push

Diffstat:
Mcrates/sdk/src/sync_runtime.rs | 29+++++++++++++++++++++++++----
Mcrates/sdk/tests/sync_runtime.rs | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/unit/sync_runtime_tests.rs | 10++--------
3 files changed, 202 insertions(+), 12 deletions(-)

diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -608,9 +608,15 @@ impl<'sdk> SyncClient<'sdk> { A: RadrootsRelayPublishAdapter, { request.validate()?; + let recovery_now_ms = sdk_now_ms(self.sdk)?; + recover_expired_outbox_claims_for_push(self.sdk, recovery_now_ms).await?; let mut receipt = PushOutboxReceipt::default(); - for _ in 0..request.limit { - let claim_now_ms = sdk_now_ms(self.sdk)?; + for index in 0..request.limit { + let claim_now_ms = if index == 0 { + recovery_now_ms + } else { + sdk_now_ms(self.sdk)? + }; let claim_token = push_outbox_claim_token(); let Some(claimed) = claim_ready_signed_event_for_push( self.sdk, @@ -653,9 +659,15 @@ impl<'sdk> SyncClient<'sdk> { request: PushOutboxRequest, ) -> Result<PushOutboxReceipt, RadrootsSdkError> { request.validate()?; + let recovery_now_ms = sdk_now_ms(self.sdk)?; + recover_expired_outbox_claims_for_push(self.sdk, recovery_now_ms).await?; let mut receipt = PushOutboxReceipt::default(); - for _ in 0..request.limit { - let claim_now_ms = sdk_now_ms(self.sdk)?; + for index in 0..request.limit { + let claim_now_ms = if index == 0 { + recovery_now_ms + } else { + sdk_now_ms(self.sdk)? + }; let claim_token = push_outbox_claim_token(); let Some(claimed) = claim_ready_signed_event_for_push( self.sdk, @@ -694,6 +706,15 @@ fn radrootsd_proxy_config_from_profile(profile: &ProxyProfile) -> RadrootsdProxy } #[cfg(feature = "runtime")] +async fn recover_expired_outbox_claims_for_push( + sdk: &RadrootsClient, + now_ms: i64, +) -> Result<(), RadrootsSdkError> { + sdk._outbox.recover_expired_claims(now_ms).await?; + Ok(()) +} + +#[cfg(feature = "runtime")] async fn claim_ready_signed_event_for_push( sdk: &RadrootsClient, request: &PushOutboxRequest, diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs @@ -1572,6 +1572,103 @@ async fn product_push_outbox_uses_radrootsd_proxy_transport_with_daemon_resolved #[cfg(feature = "radrootsd-proxy")] #[tokio::test] +async fn product_push_outbox_radrootsd_proxy_recovers_expired_publishing_claim_before_selecting_work() + { + let (endpoint, handle) = spawn_transport_publish_server(); + let tempdir = tempfile::tempdir().expect("tempdir"); + let storage = tempdir.path().join("sdk"); + let sdk = RadrootsClient::builder() + .directory_storage(storage.clone()) + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) + .transport_profile(TransportProfile::proxy(ProxyProfile::new(endpoint.clone()))) + .build() + .await + .expect("sdk"); + let enqueue = sdk + .listings() + .enqueue_publish_with_explicit_signer( + ListingEnqueuePublishRequest::new( + actor(), + listing(LISTING_A_D_TAG, "Recovered Proxy Coffee"), + TargetPolicy::use_transport_profile(), + ), + &FixtureSigner::new(SELLER), + ) + .await + .expect("enqueue"); + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + .await + .expect("outbox"); + let stale_claim = outbox + .claim_ready_signed_event( + enqueue.outbox_event_id, + "stalled-proxy-publisher", + "expired-proxy-publish", + 1_700_000_000_500, + 1_700_000_000_000, + ) + .await + .expect("stale proxy claim") + .expect("stale proxy claim"); + let active_delivery_plan_id = stale_claim + .active_delivery_plan_id + .expect("active delivery plan id"); + let stored_before = outbox + .get_event(enqueue.outbox_event_id) + .await + .expect("stored before") + .expect("stored before"); + assert_eq!(stored_before.state, RadrootsOutboxEventState::Publishing); + assert_eq!( + stored_before.claim_token.as_deref(), + Some("expired-proxy-publish") + ); + drop(sdk); + let sdk = RadrootsClient::builder() + .directory_storage(storage) + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_001)) + .transport_profile(TransportProfile::proxy(ProxyProfile::new(endpoint))) + .build() + .await + .expect("reopened sdk"); + + let receipt = sdk + .sync() + .push_outbox(PushOutboxRequest::new().with_limit(1)) + .await + .expect("recovered transport publish push"); + + assert_eq!(receipt.attempted_events, 1); + assert_eq!(receipt.published_events, 1); + assert_eq!(receipt.events[0].outbox_event_id, enqueue.outbox_event_id); + assert_eq!( + receipt.events[0].final_state, + PushOutboxEventState::Published + ); + let stored_after = outbox + .get_event(enqueue.outbox_event_id) + .await + .expect("stored after") + .expect("stored after"); + assert_eq!(stored_after.state, RadrootsOutboxEventState::Published); + assert_eq!(stored_after.claim_token, None); + + let recorded = handle.join().expect("transport publish request"); + let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body"); + let idempotency_key = body["params"]["idempotency_key"] + .as_str() + .expect("idempotency key"); + assert!( + idempotency_key + .starts_with(format!("radroots-sdk-outbox-{}-2-", enqueue.outbox_event_id).as_str()) + ); + assert!(idempotency_key.ends_with(format!("-{active_delivery_plan_id}").as_str())); + assert_eq!(body["params"]["target_policy"]["kind"], "nostr"); + assert_eq!(body["params"]["delivery_policy"]["mode"], "all"); +} + +#[cfg(feature = "radrootsd-proxy")] +#[tokio::test] async fn product_push_outbox_radrootsd_proxy_idempotency_is_attempt_scoped() { let (endpoint, handle) = spawn_transport_publish_sequence_server(vec![ TransportPublishResponseMode::Retryable, @@ -2081,6 +2178,84 @@ async fn push_outbox_with_adapter_uses_queued_targets_without_builder_relays() { } #[tokio::test] +async fn push_outbox_with_adapter_recovers_expired_publishing_claim_before_selecting_work() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let storage = tempdir.path().join("sdk"); + let sdk = RadrootsClient::builder() + .directory_storage(storage.clone()) + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) + .build() + .await + .expect("sdk"); + let outbox_event_id = + enqueue_listing(&sdk, LISTING_A_D_TAG, "Recovered Coffee", &[RELAY_A]).await; + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + .await + .expect("outbox"); + let stale_claim = outbox + .claim_ready_signed_event( + outbox_event_id, + "stalled-publisher", + "expired-publish", + 1_700_000_000_500, + 1_700_000_000_000, + ) + .await + .expect("stale claim") + .expect("stale claim"); + assert_eq!(stale_claim.state, RadrootsOutboxEventState::Publishing); + let active_delivery_plan_id = stale_claim + .active_delivery_plan_id + .expect("active delivery plan id"); + let stored_before = outbox + .get_event(outbox_event_id) + .await + .expect("stored before") + .expect("stored before"); + assert_eq!(stored_before.state, RadrootsOutboxEventState::Publishing); + assert_eq!( + stored_before.claim_token.as_deref(), + Some("expired-publish") + ); + let adapter = RecordingPublishAdapter::new(Duration::ZERO); + drop(sdk); + let sdk = RadrootsClient::builder() + .directory_storage(storage) + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_001)) + .build() + .await + .expect("reopened sdk"); + + let receipt = sdk + .sync() + .push_outbox_with_adapter(&adapter, PushOutboxRequest::new().with_limit(1)) + .await + .expect("recovered push"); + + assert_eq!(receipt.attempted_events, 1); + assert_eq!(receipt.published_events, 1); + assert_eq!(receipt.events[0].outbox_event_id, outbox_event_id); + assert_eq!( + receipt.events[0].final_state, + PushOutboxEventState::Published + ); + assert_eq!(adapter.captured_raw_events().len(), 1); + let idempotency_keys = adapter.idempotency_keys(); + let idempotency_key = idempotency_keys[0].as_deref().expect("idempotency key"); + assert!( + idempotency_key.starts_with(format!("radroots-nostr-outbox-{outbox_event_id}-2-").as_str()) + ); + assert!(idempotency_key.ends_with(format!("-{active_delivery_plan_id}").as_str())); + let stored_after = outbox + .get_event(outbox_event_id) + .await + .expect("stored after") + .expect("stored after"); + assert_eq!(stored_after.state, RadrootsOutboxEventState::Published); + assert_eq!(stored_after.claim_token, None); +} + +#[tokio::test] async fn push_outbox_with_adapter_scopes_duplicate_endpoint_sibling_plans() { let tempdir = tempfile::tempdir().expect("tempdir"); let storage = tempdir.path().join("sdk"); diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs @@ -251,20 +251,14 @@ async fn claimed_uningested_proxy_event( assert_eq!(stored.state, RadrootsOutboxEventState::Signed); assert!(!stored.event_store_ingested); assert_eq!(stored.event_store_ingested_at_ms, None); - assert_eq!( - sdk._outbox - .recover_expired_claims(1_700_000_000_501) - .await - .expect("recover signing claim"), - 1 - ); + assert_eq!(stored.claim_token, None); let claimed = sdk ._outbox .claim_next_ready_signed_event( CLAIM_OWNER, "proxy-unit-publish", 1_700_000_060_000, - 1_700_000_001_000, + 1_700_000_000_100, ) .await .expect("publish claim")