lib

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

commit 21b11e7a5120ea949f7ad0838c746873fc73aac2
parent 79d7818c8fe22a425f9524b884ddf59d25f0ef89
Author: triesap <tyson@radroots.org>
Date:   Mon, 24 Aug 2026 04:00:24 +0000

transport-nostr: require EOSE for fetch completion

- observe exact subscription EOSE before reporting completion
- classify deadlines and bounded-result exhaustion without success
- preserve admissible partial events under explicit terminal states
- document and test the normalized fetch completion contract

Diffstat:
Mcrates/transport_nostr/README.md | 4++++
Mcrates/transport_nostr/src/source.rs | 326++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/transport_nostr/tests/package_boundary.rs | 3+++
3 files changed, 271 insertions(+), 62 deletions(-)

diff --git a/crates/transport_nostr/README.md b/crates/transport_nostr/README.md @@ -137,6 +137,10 @@ provenance, and emits an opaque versioned cursor bound to the exact target set and selector when more results remain. Equal timestamps are ordered by event ID so overlap-safe reconnect pagination cannot skip peers. Malformed relay events are ignored and reported as a partial target outcome rather than admitted. +Fetch reports `Complete` only after the exact subscription receives EOSE +strictly before its absolute deadline. Deadline expiry is `Cancelled`, and a +relay result that exceeds the bounded inventory is `Partial`; neither state is +rewritten as completion even when it carries admissible events. Live subscriptions use the same explicit readable targets and selector translation. A caller checkpoint is scoped to one exact target and selector; diff --git a/crates/transport_nostr/src/source.rs b/crates/transport_nostr/src/source.rs @@ -4,7 +4,10 @@ use crate::{NostrTransport, RelayCursor, RelayUrl, status}; use core::cmp::Ordering; use core::time::Duration; use futures::{StreamExt, stream}; -use nostr_sdk::prelude::{Filter, JsonUtil, Kind, SingleLetterTag, Timestamp}; +use nostr_sdk::prelude::{ + Filter, JsonUtil, Kind, RelayMessage, RelayNotification, ReqExitPolicy, SingleLetterTag, + SubscribeAutoCloseOptions, SubscribeOptions, SubscriptionId, Timestamp, +}; use radroots_transport::{ BoxFuture, EventSource, FetchPage, FetchRequest, outcome::{FetchTargetOutcome, FetchTargetState}, @@ -31,7 +34,15 @@ pub(crate) struct SourceQuery { #[derive(Clone, Debug)] pub(crate) struct RelayFetchBatch { relay: RelayUrl, - result: Result<Vec<String>, String>, + result: RelayFetchResult, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum RelayFetchResult { + Complete(Vec<String>), + Timeout(Vec<String>), + ResourceLimit(Vec<String>), + Failed(String), } pub(crate) trait RelaySourceClient: Send + Sync { @@ -74,6 +85,7 @@ impl RelaySourceClient for LiveRelaySourceClient { let selector = selector.clone(); async move { let url = relay.as_str().to_owned(); + let started_at = tokio::time::Instant::now(); let result = async { let kinds = selector .kinds() @@ -82,7 +94,7 @@ impl RelaySourceClient for LiveRelaySourceClient { .map(Kind::from) .collect::<Vec<_>>(); if !selector.kinds().is_empty() && kinds.is_empty() { - return Ok(Vec::new()); + return Ok(RelayFetchResult::Complete(Vec::new())); } let authors = selector .authors() @@ -92,7 +104,7 @@ impl RelaySourceClient for LiveRelaySourceClient { }) .collect::<Vec<_>>(); if authors.len() != selector.authors().len() { - return Ok(Vec::new()); + return Ok(RelayFetchResult::Complete(Vec::new())); } let mut filter = Filter::new().limit(UPSTREAM_FETCH_LIMIT); if !kinds.is_empty() { @@ -118,14 +130,37 @@ impl RelaySourceClient for LiveRelaySourceClient { .try_connect_relay(url.as_str(), connect_timeout) .await .map_err(|error| error.to_string())?; - self.client - .fetch_events_from([url.as_str()], filter, timeout) + let remaining = timeout.saturating_sub(started_at.elapsed()); + if remaining.is_zero() { + return Ok(RelayFetchResult::Timeout(Vec::new())); + } + let relay = self + .client + .relay(url.as_str()) .await - .map(|events| events.iter().map(JsonUtil::as_json).collect()) - .map_err(|error| error.to_string()) + .map_err(|error| error.to_string())?; + let mut notifications = relay.notifications(); + let subscription_id = SubscriptionId::generate(); + let eose_deadline = tokio::time::Instant::now() + remaining; + let options = SubscribeOptions::default().close_on(Some( + SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(remaining)), + )); + relay + .subscribe_with_id(subscription_id.clone(), filter, options) + .await + .map_err(|error| error.to_string())?; + Ok( + collect_until_eose(&mut notifications, &subscription_id, eose_deadline) + .await, + ) } .await; - RelayFetchBatch { relay, result } + RelayFetchBatch { + relay, + result: result.unwrap_or_else(RelayFetchResult::Failed), + } } })) .buffered(max_connections) @@ -135,6 +170,71 @@ impl RelaySourceClient for LiveRelaySourceClient { } } +async fn collect_until_eose( + notifications: &mut tokio::sync::broadcast::Receiver<RelayNotification>, + subscription_id: &SubscriptionId, + deadline: tokio::time::Instant, +) -> RelayFetchResult { + let mut events = Vec::new(); + loop { + if tokio::time::Instant::now() >= deadline { + return RelayFetchResult::Timeout(events); + } + let notification = match tokio::time::timeout_at(deadline, notifications.recv()).await { + Ok(Ok(notification)) => notification, + Ok(Err(error)) => return RelayFetchResult::Failed(error.to_string()), + Err(_) => return RelayFetchResult::Timeout(events), + }; + match notification { + RelayNotification::Message { + message: + RelayMessage::Event { + subscription_id: observed_subscription, + event, + }, + } if observed_subscription.as_ref() == subscription_id => { + if events.len() >= UPSTREAM_FETCH_LIMIT { + return RelayFetchResult::ResourceLimit(events); + } + events.push(event.as_ref().as_json()); + } + RelayNotification::Message { + message: RelayMessage::EndOfStoredEvents(observed_subscription), + } if observed_subscription.as_ref() == subscription_id => { + return if tokio::time::Instant::now() < deadline { + RelayFetchResult::Complete(events) + } else { + RelayFetchResult::Timeout(events) + }; + } + RelayNotification::Message { + message: + RelayMessage::Closed { + subscription_id: observed_subscription, + message, + }, + } if observed_subscription.as_ref() == subscription_id => { + return RelayFetchResult::Failed(message.into_owned()); + } + RelayNotification::RelayStatus { + status: + nostr_sdk::prelude::RelayStatus::Disconnected + | nostr_sdk::prelude::RelayStatus::Terminated + | nostr_sdk::prelude::RelayStatus::Banned, + } => { + return RelayFetchResult::Failed(String::from("relay disconnected")); + } + RelayNotification::AuthenticationFailed => { + return RelayFetchResult::Failed(String::from("relay authentication failed")); + } + RelayNotification::Shutdown => { + return RelayFetchResult::Failed(String::from("relay shutdown")); + } + _ => {} + } + } +} + #[derive(Debug)] struct Candidate { relay: RelayUrl, @@ -198,7 +298,7 @@ impl EventSource for NostrTransport { outcomes.extend(targets.values().map(|target| { FetchTargetOutcome::new( target.fingerprint().clone(), - FetchTargetState::FailedRetryable, + FetchTargetState::Cancelled, ) .with_message("fetch deadline elapsed before relay access") })); @@ -231,55 +331,21 @@ impl EventSource for NostrTransport { let mut reported = BTreeSet::new(); let observed_at_unix_ms = unix_time_ms().max(now_ms); for batch in batches { - let Some(target) = targets.get(&batch.relay) else { + let RelayFetchBatch { relay, result } = batch; + let Some(target) = targets.get(&relay) else { return Err(radroots_transport::Error::UnexpectedFetchTargetOutcome); }; - if !reported.insert(batch.relay.clone()) { + if !reported.insert(relay.clone()) { return Err(radroots_transport::Error::DuplicateFetchTargetOutcome); } - match batch.result { - Ok(raw_events) => { - self.status - .record_read(&batch.relay, true, false, observed_at_unix_ms); - for raw in raw_events { - match radroots_event_codec::decode::signed_event(raw.as_str()) { - Ok(event) if request.selector().matches(&event) => { - candidates.push(Candidate { - relay: batch.relay.clone(), - created_at: event.created_at(), - event_id: event.id_str().to_owned(), - raw, - }) - } - Ok(_) => {} - Err(_) => { - *malformed_by_relay.entry(batch.relay.clone()).or_default() += - 1; - } - } - } - let malformed = malformed_by_relay - .get(&batch.relay) - .copied() - .unwrap_or_default(); - let outcome = if malformed == 0 { - FetchTargetOutcome::new( - target.fingerprint().clone(), - FetchTargetState::Complete, - ) - } else { - FetchTargetOutcome::new( - target.fingerprint().clone(), - FetchTargetState::Partial, - ) - .with_message(format!("ignored {malformed} malformed relay event(s)")) - }; - outcomes.push(outcome); - } - Err(message) => { + let (raw_events, terminal) = match result { + RelayFetchResult::Complete(events) => (events, FetchTargetState::Complete), + RelayFetchResult::Timeout(events) => (events, FetchTargetState::Cancelled), + RelayFetchResult::ResourceLimit(events) => (events, FetchTargetState::Partial), + RelayFetchResult::Failed(message) => { let (state, safe) = status::fetch_failure(message.as_str()); self.status.record_read( - &batch.relay, + &relay, false, state.is_retryable(), observed_at_unix_ms, @@ -288,8 +354,51 @@ impl EventSource for NostrTransport { FetchTargetOutcome::new(target.fingerprint().clone(), state) .with_message(safe), ); + continue; + } + }; + self.status.record_read( + &relay, + terminal == FetchTargetState::Complete, + terminal != FetchTargetState::Complete, + observed_at_unix_ms, + ); + for raw in raw_events { + match radroots_event_codec::decode::signed_event(raw.as_str()) { + Ok(event) if request.selector().matches(&event) => { + candidates.push(Candidate { + relay: relay.clone(), + created_at: event.created_at(), + event_id: event.id_str().to_owned(), + raw, + }) + } + Ok(_) => {} + Err(_) => { + *malformed_by_relay.entry(relay.clone()).or_default() += 1; + } } } + let malformed = malformed_by_relay.get(&relay).copied().unwrap_or_default(); + let outcome = if terminal == FetchTargetState::Cancelled { + FetchTargetOutcome::new( + target.fingerprint().clone(), + FetchTargetState::Cancelled, + ) + .with_message("relay fetch deadline elapsed before EOSE") + } else if terminal == FetchTargetState::Partial { + FetchTargetOutcome::new(target.fingerprint().clone(), FetchTargetState::Partial) + .with_message("relay result exceeded the bounded fetch inventory") + } else if malformed == 0 { + FetchTargetOutcome::new( + target.fingerprint().clone(), + FetchTargetState::Complete, + ) + } else { + FetchTargetOutcome::new(target.fingerprint().clone(), FetchTargetState::Partial) + .with_message(format!("ignored {malformed} malformed relay event(s)")) + }; + outcomes.push(outcome); } for (relay, target) in &targets { if !reported.contains(relay) { @@ -475,6 +584,7 @@ mod tests { FetchRequest, Target, TargetSet, source::{FetchBounds, FetchSelector, NextPage}, }; + use std::borrow::Cow; use std::sync::{ Arc, atomic::{AtomicUsize, Ordering as AtomicOrdering}, @@ -494,7 +604,11 @@ mod tests { .into_iter() .map(|relay| RelayFetchBatch { relay, - result: Ok(vec![FIRST.to_owned(), SECOND.to_owned(), "{".to_owned()]), + result: RelayFetchResult::Complete(vec![ + FIRST.to_owned(), + SECOND.to_owned(), + "{".to_owned(), + ]), }) .collect() }) @@ -526,6 +640,16 @@ mod tests { .expect("request") } + fn single_request(limit: u16) -> FetchRequest { + FetchRequest::new( + "nostr-fetch-one", + TargetSet::new(vec![Target::nostr_relay("wss://one.example").expect("one")]) + .expect("targets"), + FetchBounds::new(limit, u64::MAX).expect("bounds"), + ) + .expect("request") + } + #[test] fn source_deduplicates_relays_reports_malformed_and_paginates() { let transport = transport(); @@ -665,7 +789,7 @@ mod tests { let failed = futures::executor::block_on( scripted(vec![RelayFetchBatch { relay: one.clone(), - result: Err("connection timeout".into()), + result: RelayFetchResult::Failed("connection timeout".into()), }]) .fetch(request(10)), ) @@ -676,11 +800,11 @@ mod tests { let duplicate = scripted(vec![ RelayFetchBatch { relay: one.clone(), - result: Ok(vec![]), + result: RelayFetchResult::Complete(vec![]), }, RelayFetchBatch { relay: one, - result: Ok(vec![]), + result: RelayFetchResult::Complete(vec![]), }, ]); assert_eq!( @@ -691,7 +815,7 @@ mod tests { let other = RelayUrl::parse("wss://other.example", RelayUrlPolicy::Public).expect("other"); let unexpected = scripted(vec![RelayFetchBatch { relay: other, - result: Ok(vec![]), + result: RelayFetchResult::Complete(vec![]), }]); assert_eq!( futures::executor::block_on(unexpected.fetch(request(10))), @@ -701,7 +825,7 @@ mod tests { let complete = futures::executor::block_on( scripted(vec![RelayFetchBatch { relay: two, - result: Ok(vec![]), + result: RelayFetchResult::Complete(vec![]), }]) .fetch(request(10)), ) @@ -710,20 +834,98 @@ mod tests { } #[test] + fn eose_timeout_and_resource_exhaustion_remain_distinct() { + let relay = RelayUrl::parse("wss://one.example", RelayUrlPolicy::Public).expect("relay"); + let fetch = |result| { + futures::executor::block_on( + scripted(vec![RelayFetchBatch { + relay: relay.clone(), + result, + }]) + .fetch(single_request(10)), + ) + .expect("page") + }; + + let complete = fetch(RelayFetchResult::Complete(vec![FIRST.to_owned()])); + assert_eq!( + complete.target_outcomes()[0].state(), + FetchTargetState::Complete + ); + assert!(matches!(complete.next_page(), NextPage::Complete)); + + let timeout = fetch(RelayFetchResult::Timeout(vec![FIRST.to_owned()])); + assert_eq!(timeout.events().len(), 1); + assert_eq!( + timeout.target_outcomes()[0].state(), + FetchTargetState::Cancelled + ); + + let exhausted = fetch(RelayFetchResult::ResourceLimit(vec![FIRST.to_owned()])); + assert_eq!(exhausted.events().len(), 1); + assert_eq!( + exhausted.target_outcomes()[0].state(), + FetchTargetState::Partial + ); + } + + #[tokio::test] + async fn live_completion_requires_eose_strictly_before_deadline() { + let subscription_id = SubscriptionId::generate(); + let (sender, mut receiver) = tokio::sync::broadcast::channel(2); + sender + .send(RelayNotification::Message { + message: RelayMessage::EndOfStoredEvents(Cow::Owned(subscription_id.clone())), + }) + .expect("EOSE"); + assert_eq!( + collect_until_eose( + &mut receiver, + &subscription_id, + tokio::time::Instant::now() + Duration::from_secs(1), + ) + .await, + RelayFetchResult::Complete(Vec::new()) + ); + + let (_sender, mut receiver) = tokio::sync::broadcast::channel(2); + assert_eq!( + collect_until_eose(&mut receiver, &subscription_id, tokio::time::Instant::now(),).await, + RelayFetchResult::Timeout(Vec::new()) + ); + } + + #[test] fn source_rejects_unconfigured_targets_and_expired_deadlines() { - let target_set = TargetSet::new(vec![ + let unconfigured_targets = TargetSet::new(vec![ Target::nostr_relay("wss://other.example").expect("other"), ]) .expect("targets"); + let unconfigured = FetchRequest::new( + "unconfigured", + unconfigured_targets, + FetchBounds::new(1, u64::MAX).expect("bounds"), + ) + .expect("request"); + let page = futures::executor::block_on(transport().fetch(unconfigured)).expect("page"); + assert_eq!( + page.target_outcomes()[0].state(), + FetchTargetState::FailedTerminal + ); + let expired = FetchRequest::new( "expired", - target_set, + single_request(1).target_set().clone(), FetchBounds::new(1, 1).expect("bounds"), ) .expect("request"); let page = futures::executor::block_on(transport().fetch(expired)).expect("page"); assert!(page.events().is_empty()); assert_eq!(page.target_outcomes().len(), 1); + assert_eq!( + page.target_outcomes()[0].state(), + FetchTargetState::Cancelled + ); assert!(futures::executor::block_on(transport().status()).is_ok()); } @@ -850,6 +1052,6 @@ mod tests { max_connections: 1, })); assert_eq!(batches.len(), 1); - assert_eq!(batches[0].result, Ok(vec![])); + assert_eq!(batches[0].result, RelayFetchResult::Complete(vec![])); } } diff --git a/crates/transport_nostr/tests/package_boundary.rs b/crates/transport_nostr/tests/package_boundary.rs @@ -79,6 +79,9 @@ fn documentation_example_and_reviewed_api_baseline_are_complete() { "contracts/api_baselines/radroots_transport_nostr.txt", "Live subscriptions use the same explicit readable targets", "exact indexed single-letter tag", + "Fetch reports `Complete` only after the exact subscription receives EOSE", + "Deadline expiry is `Cancelled`", + "exceeds the bounded inventory is `Partial`", "inclusive `since` timestamp", "event-ID tie breaker", "upstream auto-close deadline",