mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 23:18:24 +00:00
The design doc lives at docs/explanation/grasp-02-proactive-sync.md; the -v4 filename referenced from the sync module headers and architecture doc never existed in the tree. Reference-only change.
5946 lines
244 KiB
Rust
5946 lines
244 KiB
Rust
//! Proactive Sync Module - GRASP-02 v4 Implementation
|
|
//!
|
|
//! This module implements proactive synchronization of repository data from external
|
|
//! relays based on relay URLs listed in 30617 repository announcements.
|
|
//!
|
|
//! ## Architecture
|
|
//!
|
|
//! The sync system uses three index structures:
|
|
//! - `RepoSyncIndex` - What we WANT to sync (source of truth from self-subscription)
|
|
//! - `RelaySyncIndex` - What we have CONFIRMED syncing + connection state
|
|
//! - `PendingSyncIndex` - In-flight batches awaiting EOSE confirmation
|
|
//!
|
|
//! See `docs/explanation/grasp-02-proactive-sync.md` for full design details.
|
|
|
|
pub mod algorithms;
|
|
pub mod filters;
|
|
pub mod health;
|
|
pub mod metrics;
|
|
pub mod missing_events;
|
|
pub mod naughty_list;
|
|
pub mod rejected_index;
|
|
pub mod relay_connection;
|
|
pub mod self_subscriber;
|
|
|
|
// Re-export core algorithm types
|
|
pub use algorithms::{AddFilters, RelaySyncNeeds};
|
|
|
|
// Re-export metrics types
|
|
pub use metrics::SyncMetrics;
|
|
|
|
// Re-export rejected index types
|
|
pub use rejected_index::{EventType, RejectionReason};
|
|
// Note: RejectedEventsIndex struct exists in rejected_index.rs but not yet used
|
|
// Current code still uses the simple HashSet type alias below
|
|
|
|
// Re-export relay connection types
|
|
pub use relay_connection::{NegentropySyncResult, RelayConnection, RelayEvent};
|
|
|
|
// Re-export self-subscriber types
|
|
pub use self_subscriber::SelfSubscriber;
|
|
|
|
// Re-export health tracking types
|
|
pub use health::RelayHealthTracker;
|
|
use std::collections::{HashMap, HashSet, VecDeque};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use futures_util::future::join_all;
|
|
use nostr_sdk::prelude::*;
|
|
use tokio::sync::{broadcast, Mutex, RwLock, Semaphore};
|
|
|
|
use crate::config::Config;
|
|
use crate::nostr::builder::Nip34WritePolicy;
|
|
use crate::nostr::SharedDatabase;
|
|
use crate::outbound::{OutboundTargetKind, OutboundTargetPolicy, RelayTargetSource};
|
|
use nostr_sdk::prelude::LocalRelay;
|
|
|
|
const MAX_PURGATORY_DEPENDENCY_EVENTS_PER_TICK: usize = 32;
|
|
const MAX_PURGATORY_FILTER_ACTIONS_PER_TICK: usize = 1;
|
|
|
|
fn purgatory_dependency_retry_after() -> Duration {
|
|
if std::env::var("NGIT_TEST").as_deref() == Ok("1") {
|
|
Duration::from_secs(2)
|
|
} else {
|
|
Duration::from_secs(30)
|
|
}
|
|
}
|
|
|
|
fn dependency_relay_retention() -> Duration {
|
|
if std::env::var("NGIT_TEST").as_deref() == Ok("1") {
|
|
Duration::from_secs(10)
|
|
} else {
|
|
Duration::from_secs(60)
|
|
}
|
|
}
|
|
|
|
fn select_purgatory_dependency_events(
|
|
mut events: Vec<Event>,
|
|
attempts: &mut HashMap<EventId, Instant>,
|
|
now: Instant,
|
|
retry_after: Duration,
|
|
limit: usize,
|
|
) -> Vec<Event> {
|
|
let current_ids: HashSet<EventId> = events.iter().map(|event| event.id).collect();
|
|
attempts.retain(|event_id, _| current_ids.contains(event_id));
|
|
|
|
events.sort_by(|left, right| {
|
|
let left_is_new = !attempts.contains_key(&left.id);
|
|
let right_is_new = !attempts.contains_key(&right.id);
|
|
right_is_new
|
|
.cmp(&left_is_new)
|
|
.then_with(|| right.created_at.cmp(&left.created_at))
|
|
.then_with(|| right.id.cmp(&left.id))
|
|
});
|
|
|
|
let selected: Vec<Event> = events
|
|
.into_iter()
|
|
.filter(|event| {
|
|
attempts
|
|
.get(&event.id)
|
|
.is_none_or(|attempted_at| now.duration_since(*attempted_at) >= retry_after)
|
|
})
|
|
.take(limit)
|
|
.collect();
|
|
|
|
for event in &selected {
|
|
attempts.insert(event.id, now);
|
|
}
|
|
|
|
selected
|
|
}
|
|
|
|
/// Return one stable identity for a relay URL throughout all sync indexes.
|
|
///
|
|
/// URL parsers represent a root path as `/`, so announcements that alternate
|
|
/// between `wss://relay.example` and `wss://relay.example/` must not create
|
|
/// separate connections and duplicate subscription work. Non-root paths remain
|
|
/// distinct relay endpoints.
|
|
pub(crate) fn canonical_relay_key(relay_url: &str) -> Result<String, String> {
|
|
let normalized = if relay_url.starts_with("wss://") || relay_url.starts_with("ws://") {
|
|
relay_url.to_string()
|
|
} else {
|
|
format!("wss://{relay_url}")
|
|
};
|
|
let relay = RelayUrl::parse(&normalized).map_err(|error| error.to_string())?;
|
|
let mut canonical = relay.to_string();
|
|
let after_scheme = canonical
|
|
.split_once("://")
|
|
.map(|(_, value)| value)
|
|
.unwrap_or(canonical.as_str());
|
|
if after_scheme.ends_with('/') && after_scheme.matches('/').count() == 1 {
|
|
canonical.pop();
|
|
}
|
|
Ok(canonical)
|
|
}
|
|
|
|
fn connections_for_relay_urls<T: Clone>(
|
|
connections: &HashMap<String, T>,
|
|
relay_urls: &[String],
|
|
) -> Vec<(String, T)> {
|
|
let mut seen = HashSet::new();
|
|
relay_urls
|
|
.iter()
|
|
.filter_map(|relay_url| canonical_relay_key(relay_url).ok())
|
|
.filter(|relay_url| seen.insert(relay_url.clone()))
|
|
.filter_map(|relay_url| {
|
|
connections
|
|
.get(&relay_url)
|
|
.cloned()
|
|
.map(|connection| (relay_url, connection))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// =============================================================================
|
|
// Type Aliases for Index Structures
|
|
// =============================================================================
|
|
|
|
/// What we WANT to sync - derived from events received via self-subscription.
|
|
/// Updated immediately when self-subscriber batch fires.
|
|
/// Key: repo addressable ref - 30617:pubkey:identifier
|
|
pub type RepoSyncIndex = Arc<RwLock<HashMap<String, RepoSyncNeeds>>>;
|
|
|
|
/// What we have CONFIRMED syncing - includes connection state for integrated lifecycle.
|
|
/// Key: relay URL
|
|
pub type RelaySyncIndex = Arc<RwLock<HashMap<String, RelayState>>>;
|
|
|
|
/// Tracks batches of subscriptions that are in-flight, awaiting EOSE.
|
|
/// Each batch has its own ID and can confirm independently.
|
|
/// Key: relay URL
|
|
pub type PendingSyncIndex = Arc<RwLock<HashMap<String, Vec<PendingBatch>>>>;
|
|
|
|
/// Tracks EventIds of announcement events (30617/30618) that were rejected during sync.
|
|
/// These events are excluded from negentropy sync and skipped during REQ+EOSE processing
|
|
/// to avoid repeatedly fetching and rejecting the same events.
|
|
///
|
|
/// Uses the two-tier RejectedEventsIndex from rejected_index.rs:
|
|
/// - Hot cache: Full events for 2 minutes (enables immediate re-processing)
|
|
/// - Cold index: Metadata for 7 days (prevents repeated downloads)
|
|
use rejected_index::RejectedEventsIndex;
|
|
|
|
// =============================================================================
|
|
// Supporting Data Structures
|
|
// =============================================================================
|
|
|
|
/// Level of sync needed for a repository
|
|
///
|
|
/// Purgatory announcements only need state events synced (to validate git data).
|
|
/// Promoted repos need full L2/L3 sync (patches, issues, PRs, etc.).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum SyncLevel {
|
|
/// Full L2 + L3 sync (promoted repos with git data)
|
|
#[default]
|
|
Full,
|
|
/// Only state events (kind 30618) - for purgatory announcements
|
|
StateOnly,
|
|
}
|
|
|
|
/// What repos and root events need to be synced
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct RepoSyncNeeds {
|
|
/// Relay URLs listed in this repo's 30617 announcement
|
|
pub relays: HashSet<String>,
|
|
/// Root event IDs - 1617/1618/1621 - that reference this repo
|
|
pub root_events: HashSet<EventId>,
|
|
/// Sync level - StateOnly for purgatory, Full for promoted repos
|
|
pub sync_level: SyncLevel,
|
|
}
|
|
|
|
/// Connection status for a relay
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum ConnectionStatus {
|
|
/// Not currently connected
|
|
#[default]
|
|
Disconnected,
|
|
/// Connection attempt in progress
|
|
Connecting,
|
|
/// Successfully connected, historic sync in progress
|
|
Syncing,
|
|
/// Successfully connected, historic sync completed
|
|
Connected,
|
|
/// Successfully connected, historic sync had failures but live sync active
|
|
ConnectedHistoricSyncFailures,
|
|
/// Disconnection initiated, waiting for event loop to terminate
|
|
/// State is retained to process remaining queued events
|
|
Disconnecting,
|
|
}
|
|
|
|
impl ConnectionStatus {
|
|
/// Returns true if live sync is active (can accept new filters)
|
|
pub fn is_live_sync_active(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
ConnectionStatus::Syncing
|
|
| ConnectionStatus::Connected
|
|
| ConnectionStatus::ConnectedHistoricSyncFailures
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Complete state for a single relay - combines sync needs with connection lifecycle
|
|
#[derive(Debug)]
|
|
pub struct RelayState {
|
|
/// Repos we have confirmed full L2/L3 syncing from this relay
|
|
pub repos: HashSet<String>,
|
|
/// Repos we have confirmed state-only syncing from this relay
|
|
pub state_only_repos: HashSet<String>,
|
|
/// Root events we have confirmed tracking
|
|
pub root_events: HashSet<EventId>,
|
|
/// If true, never disconnect this relay
|
|
pub is_bootstrap: bool,
|
|
/// Current connection status
|
|
pub connection_status: ConnectionStatus,
|
|
/// When we last successfully connected - used for since filter on reconnect
|
|
pub last_connected: Option<Timestamp>,
|
|
/// When we disconnected - for 15-minute state retention rule
|
|
pub disconnected_at: Option<Timestamp>,
|
|
/// Whether announcement filter historic sync has completed for this relay
|
|
/// Used to determine if we can use `since` filter on reconnect for Layer 1
|
|
pub announcements_synced: bool,
|
|
/// Whether initial historic sync has fully completed (all layers)
|
|
/// Used to transition from Syncing -> Connected status
|
|
pub historic_sync_completed: bool,
|
|
/// When historic sync completed (None if never completed or cleared on fresh_start)
|
|
pub historic_sync_completed_at: Option<Timestamp>,
|
|
/// Whether any batch failed during historic sync
|
|
/// Set to true when retry protection triggers or other failures occur
|
|
/// Used to transition to ConnectedDegraded instead of Connected
|
|
pub historic_sync_had_failures: bool,
|
|
}
|
|
|
|
impl Default for RelayState {
|
|
fn default() -> Self {
|
|
Self {
|
|
repos: HashSet::new(),
|
|
state_only_repos: HashSet::new(),
|
|
root_events: HashSet::new(),
|
|
is_bootstrap: false,
|
|
connection_status: ConnectionStatus::Disconnected,
|
|
last_connected: None,
|
|
disconnected_at: None,
|
|
announcements_synced: false,
|
|
historic_sync_completed: false,
|
|
historic_sync_completed_at: None,
|
|
historic_sync_had_failures: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RelayState {
|
|
/// Whether this relay has no remaining sync work and can be disconnected.
|
|
///
|
|
/// A newly connected relay has no *confirmed* repos until its historic
|
|
/// subscriptions complete. Treating that temporary state as empty races
|
|
/// the two-second disconnect checker against the historic sync (whose
|
|
/// completion is deliberately delayed to cover the subscriber batch
|
|
/// window). Pending batches and an active incomplete historic sync must
|
|
/// therefore keep the relay connected.
|
|
fn is_disconnect_candidate(&self, has_pending_batches: bool, has_desired_work: bool) -> bool {
|
|
if self.is_bootstrap
|
|
|| self.connection_status == ConnectionStatus::Connecting
|
|
|| self.connection_status == ConnectionStatus::Disconnecting
|
|
|| !self.repos.is_empty()
|
|
|| !self.state_only_repos.is_empty()
|
|
|| !self.root_events.is_empty()
|
|
|| has_pending_batches
|
|
|| has_desired_work
|
|
{
|
|
return false;
|
|
}
|
|
|
|
self.connection_status == ConnectionStatus::Disconnected || self.historic_sync_completed
|
|
}
|
|
|
|
/// Check if state should be cleared based on 15-minute rule
|
|
pub fn should_clear_state(&self) -> bool {
|
|
match self.disconnected_at {
|
|
Some(disconnected) => {
|
|
let now = Timestamp::now();
|
|
now.as_secs().saturating_sub(disconnected.as_secs()) > 900 // 15 minutes
|
|
}
|
|
None => false, // Still connected or never connected
|
|
}
|
|
}
|
|
|
|
/// Clear repos and root_events - called when reconnect takes > 15 minutes
|
|
pub fn clear_sync_state(&mut self) {
|
|
self.repos.clear();
|
|
self.state_only_repos.clear();
|
|
self.root_events.clear();
|
|
self.announcements_synced = false;
|
|
self.historic_sync_completed = false;
|
|
self.historic_sync_completed_at = None;
|
|
self.historic_sync_had_failures = false;
|
|
}
|
|
}
|
|
|
|
/// Method used for synchronization
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SyncMethod {
|
|
/// Traditional REQ+EOSE flow - waits for EOSE on subscriptions
|
|
ReqEose,
|
|
/// NIP-77 negentropy sync - confirms immediately after sync completes
|
|
Negentropy,
|
|
}
|
|
|
|
/// Result of processing an event from sync
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ProcessResult {
|
|
/// Event was new and saved to database
|
|
Saved,
|
|
/// Event already existed in database
|
|
Duplicate,
|
|
/// Event added to Purgatory
|
|
Purgatory,
|
|
/// Event rejected by write policy
|
|
Rejected,
|
|
}
|
|
|
|
/// Statistics from re-processing events from hot cache
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ReprocessingStats {
|
|
/// Number of events successfully saved
|
|
pub saved: usize,
|
|
/// Number of events that were duplicates
|
|
pub duplicate: usize,
|
|
/// Number of events added to purgatory
|
|
pub purgatory: usize,
|
|
/// Number of events still rejected
|
|
pub rejected: usize,
|
|
}
|
|
|
|
/// Pagination state for a subscription in non-Negentropy historic sync
|
|
#[derive(Debug, Clone)]
|
|
pub struct FilterPaginationState {
|
|
/// Number of events received for this filter
|
|
pub event_count: usize,
|
|
/// Smallest created_at timestamp seen for this filter
|
|
pub min_created_at: Option<Timestamp>,
|
|
/// Original filter to reconstruct for next page
|
|
pub original_filter: Filter,
|
|
}
|
|
|
|
/// Pagination state for every OR filter carried by one subscription.
|
|
#[derive(Debug, Clone)]
|
|
pub struct PaginationState {
|
|
pub filters: Vec<FilterPaginationState>,
|
|
}
|
|
|
|
impl PaginationState {
|
|
fn new(filters: Vec<Filter>) -> Self {
|
|
Self {
|
|
filters: filters
|
|
.into_iter()
|
|
.map(|original_filter| FilterPaginationState {
|
|
event_count: 0,
|
|
min_created_at: None,
|
|
original_filter,
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn record_event(&mut self, event: &Event) {
|
|
for state in &mut self.filters {
|
|
if state
|
|
.original_filter
|
|
.match_event(event, MatchEventOptions::new())
|
|
{
|
|
state.event_count += 1;
|
|
match state.min_created_at {
|
|
None => state.min_created_at = Some(event.created_at),
|
|
Some(min) if event.created_at < min => {
|
|
state.min_created_at = Some(event.created_at);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn next_page_filters(self) -> Vec<Filter> {
|
|
self.filters
|
|
.into_iter()
|
|
.filter_map(|state| {
|
|
(state.event_count >= PAGINATION_THRESHOLD)
|
|
.then_some(state.min_created_at)
|
|
.flatten()
|
|
.map(|min_created_at| {
|
|
state
|
|
.original_filter
|
|
.until(Timestamp::from(min_created_at.as_secs()))
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// A batch of items pending confirmation
|
|
#[derive(Debug, Clone)]
|
|
pub struct PendingBatch {
|
|
/// Unique ID for this batch - for debugging/logging
|
|
pub batch_id: u64,
|
|
/// The items this batch is syncing
|
|
pub items: PendingItems,
|
|
/// Subscription IDs that must ALL receive EOSE before confirming (for ReqEose)
|
|
/// Empty for Negentropy sync method
|
|
pub outstanding_subs: HashSet<SubscriptionId>,
|
|
/// The sync method used for this batch
|
|
pub sync_method: SyncMethod,
|
|
/// Pagination tracking for REQ+EOSE subscriptions (empty for Negentropy)
|
|
/// Maps subscription ID to its pagination state
|
|
pub pagination_state: HashMap<SubscriptionId, PaginationState>,
|
|
/// Event IDs requested via negentropy ID-based fetch (None for REQ+EOSE)
|
|
/// Used to validate that all requested events were received
|
|
pub requested_event_ids: Option<HashSet<EventId>>,
|
|
/// Event IDs actually received for this batch (None for REQ+EOSE)
|
|
/// Compared against requested_event_ids to detect missing events
|
|
pub received_event_ids: Option<HashSet<EventId>>,
|
|
/// Number of retry attempts for missing events (Negentropy only)
|
|
/// Used to prevent infinite retry loops when relay consistently fails
|
|
pub retry_count: usize,
|
|
/// Whether this batch failed (completed with missing data)
|
|
/// Set to true when retry protection triggers or other failures occur
|
|
pub failed: bool,
|
|
}
|
|
|
|
/// Items included in a pending batch
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct PendingItems {
|
|
/// Repos receiving full L2/L3 sync in this batch
|
|
pub repos: HashSet<String>,
|
|
/// Repos receiving state-only sync in this batch
|
|
pub state_only_repos: HashSet<String>,
|
|
/// Root events being synced in this batch
|
|
pub root_events: HashSet<EventId>,
|
|
}
|
|
|
|
fn take_drained_batch_as_failed(
|
|
pending: &mut HashMap<String, Vec<PendingBatch>>,
|
|
relay_url: &str,
|
|
batch_id: u64,
|
|
) -> Option<PendingBatch> {
|
|
let (batch_index, remove_relay) = {
|
|
let batches = pending.get(relay_url)?;
|
|
let batch_index = batches
|
|
.iter()
|
|
.position(|batch| batch.batch_id == batch_id)?;
|
|
if !batches[batch_index].outstanding_subs.is_empty() {
|
|
return None;
|
|
}
|
|
(batch_index, batches.len() == 1)
|
|
};
|
|
|
|
let mut batch = pending.get_mut(relay_url)?.remove(batch_index);
|
|
batch.failed = true;
|
|
if remove_relay {
|
|
pending.remove(relay_url);
|
|
}
|
|
Some(batch)
|
|
}
|
|
|
|
fn mark_deferred_pagination(
|
|
batch: &mut PendingBatch,
|
|
completed_sub_id: &SubscriptionId,
|
|
) -> SubscriptionId {
|
|
let deferred_sub_id = SubscriptionId::new(format!(
|
|
"deferred-pagination-{}-{completed_sub_id}",
|
|
batch.batch_id
|
|
));
|
|
batch.outstanding_subs.insert(deferred_sub_id.clone());
|
|
deferred_sub_id
|
|
}
|
|
|
|
// =============================================================================
|
|
// SyncManager - Main Entry Point
|
|
// =============================================================================
|
|
|
|
/// Notification from spawned tasks about relay disconnections
|
|
#[derive(Debug)]
|
|
pub struct DisconnectNotification {
|
|
/// The relay URL that disconnected
|
|
pub relay_url: String,
|
|
}
|
|
|
|
/// Notification from spawned tasks about EOSE (End Of Stored Events)
|
|
#[derive(Debug)]
|
|
pub struct EoseNotification {
|
|
/// The relay URL that sent EOSE
|
|
pub relay_url: String,
|
|
/// The subscription ID that completed
|
|
pub sub_id: SubscriptionId,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct ConnectAttemptToken(u64);
|
|
|
|
#[derive(Debug)]
|
|
enum ConnectAttemptOutcome {
|
|
Connected,
|
|
Failed(String),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ConnectAttemptResult {
|
|
relay_url: String,
|
|
token: ConnectAttemptToken,
|
|
outcome: ConnectAttemptOutcome,
|
|
}
|
|
|
|
/// Quick reconnect window in seconds (15 minutes)
|
|
const QUICK_RECONNECT_WINDOW_SECS: u64 = 15 * 60;
|
|
|
|
/// Maximum incremental filter fragmentation above the desired live baseline
|
|
/// before triggering consolidation.
|
|
const CONSOLIDATION_THRESHOLD: usize = 70;
|
|
|
|
/// Bound concurrent DNS and websocket handshakes so a large relay list cannot
|
|
/// exhaust network resources while keeping the sync actor responsive.
|
|
const MAX_CONCURRENT_CONNECT_ATTEMPTS: usize = 8;
|
|
|
|
/// Per-filter threshold for historic REQ+EOSE pagination.
|
|
///
|
|
/// Grouped pagination assumes that relays apply result limits independently to
|
|
/// each filter, return at least this many events for a non-exhausted filter,
|
|
/// and do not impose an additional total-result cap across the whole REQ. This
|
|
/// matches NIP-01's per-filter `limit` model and ngit-grasp's relay behavior.
|
|
/// Relays that violate these assumptions can make one filter appear exhausted
|
|
/// after another filter consumes the combined result allowance.
|
|
const PAGINATION_THRESHOLD: usize = 75;
|
|
|
|
/// Conservative number of OR filters carried by one NIP-01 REQ.
|
|
///
|
|
/// This keeps active subscription counts low without producing unusually
|
|
/// large REQ messages for relays that enforce their own per-REQ filter limits.
|
|
const MAX_FILTERS_PER_REQ: usize = 10;
|
|
|
|
fn reserve_connect_attempt(
|
|
in_flight: &mut HashMap<String, ConnectAttemptToken>,
|
|
next_token: &mut u64,
|
|
relay_url: &str,
|
|
) -> Option<ConnectAttemptToken> {
|
|
if in_flight.contains_key(relay_url) {
|
|
return None;
|
|
}
|
|
*next_token = next_token
|
|
.checked_add(1)
|
|
.expect("connect attempt token exhausted");
|
|
let token = ConnectAttemptToken(*next_token);
|
|
in_flight.insert(relay_url.to_string(), token);
|
|
Some(token)
|
|
}
|
|
|
|
fn take_connect_attempt(
|
|
in_flight: &mut HashMap<String, ConnectAttemptToken>,
|
|
relay_url: &str,
|
|
token: ConnectAttemptToken,
|
|
) -> bool {
|
|
if in_flight.get(relay_url) != Some(&token) {
|
|
return false;
|
|
}
|
|
in_flight.remove(relay_url);
|
|
true
|
|
}
|
|
|
|
async fn begin_connect_attempt(
|
|
semaphore: Arc<Semaphore>,
|
|
health_tracker: Arc<RelayHealthTracker>,
|
|
relay_url: &str,
|
|
) -> Option<tokio::sync::OwnedSemaphorePermit> {
|
|
let permit = semaphore.acquire_owned().await.ok()?;
|
|
health_tracker.record_attempt(relay_url);
|
|
Some(permit)
|
|
}
|
|
|
|
fn consolidation_fragmentation(
|
|
current_count: usize,
|
|
new_count: usize,
|
|
desired_baseline: usize,
|
|
) -> usize {
|
|
current_count
|
|
.saturating_add(new_count)
|
|
.saturating_sub(desired_baseline)
|
|
}
|
|
|
|
fn should_consolidate(current_count: usize, new_count: usize, desired_baseline: usize) -> bool {
|
|
consolidation_fragmentation(current_count, new_count, desired_baseline)
|
|
> CONSOLIDATION_THRESHOLD
|
|
}
|
|
|
|
fn grouped_subscription_count(filter_count: usize) -> usize {
|
|
filter_count.div_ceil(MAX_FILTERS_PER_REQ)
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct DeferredConsolidations {
|
|
relays: HashSet<String>,
|
|
}
|
|
|
|
impl DeferredConsolidations {
|
|
fn request(&mut self, relay_url: &str, has_pending_batches: bool) -> bool {
|
|
if has_pending_batches {
|
|
self.relays.insert(relay_url.to_string());
|
|
false
|
|
} else {
|
|
self.relays.remove(relay_url);
|
|
true
|
|
}
|
|
}
|
|
|
|
fn take_ready(&mut self, relay_url: &str, has_pending_batches: bool) -> bool {
|
|
!has_pending_batches && self.relays.remove(relay_url)
|
|
}
|
|
|
|
fn contains(&self, relay_url: &str) -> bool {
|
|
self.relays.contains(relay_url)
|
|
}
|
|
|
|
fn cancel(&mut self, relay_url: &str) -> bool {
|
|
self.relays.remove(relay_url)
|
|
}
|
|
|
|
fn clear(&mut self) {
|
|
self.relays.clear();
|
|
}
|
|
}
|
|
|
|
fn notify_deferred_consolidation_after_batch_completion(
|
|
deferred: &DeferredConsolidations,
|
|
sender: Option<&tokio::sync::mpsc::UnboundedSender<String>>,
|
|
relay_url: &str,
|
|
) {
|
|
if deferred.contains(relay_url) {
|
|
if let Some(sender) = sender {
|
|
let _ = sender.send(relay_url.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Daily Timer
|
|
// =============================================================================
|
|
|
|
/// Run the daily timer for periodic fresh syncs
|
|
///
|
|
/// This function runs in a loop, sleeping for a random interval between
|
|
/// 23-25 hours, then triggering a daily sync for all relays. The random
|
|
/// interval prevents thundering herd effects across multiple ngit-grasp instances.
|
|
///
|
|
/// The daily sync:
|
|
/// - Unsubscribes from all current subscriptions
|
|
/// - Clears pending batches and sync state
|
|
/// - Re-discovers all repos and events from scratch
|
|
///
|
|
/// This detects state drift over time that might occur from missed events.
|
|
async fn run_daily_timer(
|
|
sync_manager: Arc<Mutex<SyncManager>>,
|
|
mut shutdown_rx: broadcast::Receiver<()>,
|
|
) {
|
|
use ::rand::RngExt;
|
|
|
|
loop {
|
|
// Random interval between 23-25 hours
|
|
let hours = 23.0 + ::rand::rng().random::<f64>() * 2.0;
|
|
let seconds = (hours * 3600.0) as u64;
|
|
|
|
tracing::info!(
|
|
hours = format!("{:.1}", hours),
|
|
"Daily timer scheduled to fire in {:.1} hours",
|
|
hours
|
|
);
|
|
|
|
tokio::select! {
|
|
_ = tokio::time::sleep(Duration::from_secs(seconds)) => {
|
|
// Timer fired - do daily sync
|
|
// Get list of relays
|
|
let relay_urls: Vec<String> = {
|
|
let manager = sync_manager.lock().await;
|
|
let index = manager.relay_sync_index.read().await;
|
|
let urls: Vec<String> = index.keys().cloned().collect();
|
|
drop(index);
|
|
urls
|
|
};
|
|
|
|
tracing::info!(
|
|
relay_count = relay_urls.len(),
|
|
"Daily timer fired, starting daily sync for all relays"
|
|
);
|
|
|
|
// Trigger daily sync for each relay
|
|
for relay_url in relay_urls {
|
|
let mut manager = sync_manager.lock().await;
|
|
manager.daily_sync(&relay_url).await;
|
|
}
|
|
}
|
|
_ = shutdown_rx.recv() => {
|
|
tracing::info!("Daily timer received shutdown signal");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Background task that periodically syncs purgatory announcements into repo_sync_index.
|
|
///
|
|
/// Runs every 5 seconds by default (200ms when `NGIT_TEST=1`).
|
|
/// For each announcement currently in purgatory, ensures there is a `StateOnly` entry in
|
|
/// `repo_sync_index`. New entries trigger `handle_new_sync_filters` which connects to the
|
|
/// relay URLs listed in the announcement and subscribes to state events (kind 30618).
|
|
///
|
|
/// This is the sole registration path for purgatory announcements:
|
|
/// - Sync-path announcements: registered here within one interval of arriving.
|
|
/// - User-submitted purgatory announcements: the SelfSubscriber never sees them
|
|
/// (they're rejected from DB), so this timer is the only registration path.
|
|
///
|
|
/// The same tick also drives bounded recovery of events that relays reported
|
|
/// during negentropy reconciliation but failed to deliver on exact-ID fetches
|
|
/// (see [`missing_events`]). Recovery attempts are backed off per relay, so
|
|
/// the tick itself stays cheap when nothing is due.
|
|
async fn run_purgatory_announcement_sync(
|
|
sync_manager: Arc<Mutex<SyncManager>>,
|
|
mut shutdown_rx: broadcast::Receiver<()>,
|
|
) {
|
|
let interval = if std::env::var("NGIT_TEST").as_deref() == Ok("1") {
|
|
Duration::from_millis(200)
|
|
} else {
|
|
Duration::from_secs(5)
|
|
};
|
|
loop {
|
|
tokio::select! {
|
|
_ = tokio::time::sleep(interval) => {
|
|
let mut manager = sync_manager.lock().await;
|
|
manager.sync_purgatory_announcements_to_index().await;
|
|
manager.tick_missing_event_recovery().await;
|
|
}
|
|
_ = shutdown_rx.recv() => {
|
|
tracing::debug!("Purgatory announcement sync timer received shutdown signal");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Combined Health and Metrics Checker
|
|
|
|
/// Background task for cleaning up expired entries from the rejected events index
|
|
///
|
|
/// This task runs two cleanup operations at different intervals:
|
|
/// 1. **Hot cache cleanup (60s)**: Remove events older than 2 minutes from hot cache
|
|
/// 2. **Cold index cleanup (daily)**: Remove metadata older than 7 days from cold index
|
|
///
|
|
/// A single `RejectedEventsIndex` handles both announcement and state events,
|
|
/// differentiated by `EventType`. Each cleanup pass processes both types.
|
|
///
|
|
/// The hot cache cleanup runs frequently to keep memory usage low (events expire quickly).
|
|
/// The cold index cleanup runs daily since metadata is small and expires slowly.
|
|
async fn run_rejected_index_cleanup(
|
|
sync_manager: Arc<Mutex<SyncManager>>,
|
|
mut shutdown_rx: broadcast::Receiver<()>,
|
|
) {
|
|
let hot_cache_interval = Duration::from_secs(60);
|
|
let cold_index_interval = Duration::from_secs(86400); // 24 hours
|
|
|
|
tracing::info!("Rejected index cleanup started (hot cache: 60s, cold index: daily)");
|
|
|
|
let mut hot_cache_timer = tokio::time::interval(hot_cache_interval);
|
|
let mut cold_index_timer = tokio::time::interval(cold_index_interval);
|
|
|
|
// Tick immediately to set the initial delay
|
|
hot_cache_timer.tick().await;
|
|
cold_index_timer.tick().await;
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = hot_cache_timer.tick() => {
|
|
let manager = sync_manager.lock().await;
|
|
|
|
// Clean up hot cache for both event types (single index handles both)
|
|
// Note: cleanup_expired_for_type updates metrics with type label
|
|
let (ann_hot_expired, _) = manager.rejected_events_index.cleanup_expired_for_type("announcement");
|
|
let (state_hot_expired, _) = manager.rejected_events_index.cleanup_expired_for_type("state");
|
|
|
|
if ann_hot_expired + state_hot_expired > 0 {
|
|
tracing::debug!(
|
|
announcements = ann_hot_expired,
|
|
states = state_hot_expired,
|
|
"Cleaned up expired entries from rejected events hot cache"
|
|
);
|
|
}
|
|
}
|
|
_ = cold_index_timer.tick() => {
|
|
let manager = sync_manager.lock().await;
|
|
|
|
// Clean up cold index for both event types (single index handles both)
|
|
let (_, ann_cold_expired) = manager.rejected_events_index.cleanup_expired_for_type("announcement");
|
|
let (_, state_cold_expired) = manager.rejected_events_index.cleanup_expired_for_type("state");
|
|
let unrecoverable_expired = manager.rejected_events_index.cleanup_expired_unrecoverable();
|
|
|
|
if ann_cold_expired + state_cold_expired + unrecoverable_expired > 0 {
|
|
tracing::info!(
|
|
announcements = ann_cold_expired,
|
|
states = state_cold_expired,
|
|
unrecoverable = unrecoverable_expired,
|
|
"Cleaned up expired entries from rejected events cold index"
|
|
);
|
|
}
|
|
}
|
|
_ = shutdown_rx.recv() => {
|
|
tracing::info!("Rejected index cleanup received shutdown signal");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Background task for checking relay health and updating metrics
|
|
///
|
|
/// This task runs every 2 seconds and performs three operations:
|
|
///
|
|
/// 1. **Disconnect checking**: Check for empty relays and disconnect non-bootstrap ones
|
|
/// 2. **Rate limit recovery**: Check for relays whose rate limit cooldown has expired
|
|
/// 3. **Metrics update**: Update Prometheus metrics with current health states from health_tracker
|
|
///
|
|
/// The metrics update ensures that health states are kept current in metrics even when
|
|
/// they change due to timeouts, cooldowns expiring, or stability periods completing.
|
|
///
|
|
/// The 2-second interval provides a good balance between responsiveness and overhead.
|
|
/// While disconnect checking traditionally ran at 60s intervals, the faster cadence here
|
|
/// is acceptable since the operations are lightweight (just index checks, no I/O).
|
|
async fn run_health_and_metrics_checker(
|
|
sync_manager: Arc<Mutex<SyncManager>>,
|
|
mut shutdown_rx: broadcast::Receiver<()>,
|
|
) {
|
|
let interval = Duration::from_secs(2);
|
|
tracing::info!("Health and metrics checker started with 2s interval");
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = tokio::time::sleep(interval) => {
|
|
let mut manager = sync_manager.lock().await;
|
|
|
|
// 1. Check for disconnects and retry disconnected relays
|
|
manager.check_disconnects().await;
|
|
manager.retry_disconnected_relays().await;
|
|
|
|
// 2. Check for rate limit recovery
|
|
manager.check_rate_limit_recovery().await;
|
|
|
|
// 3. Check for naughty list expiration
|
|
if let Some(naughty_list) = manager.health_tracker.naughty_list() {
|
|
let recovered = naughty_list.expire_old_entries();
|
|
for url in recovered {
|
|
tracing::info!(
|
|
relay = %url,
|
|
"Relay removed from naughty list after expiration, will retry"
|
|
);
|
|
}
|
|
}
|
|
|
|
// 4. Update metrics with current health states and naughty list
|
|
if let Some(ref metrics) = manager.metrics {
|
|
// Get all tracked relay URLs
|
|
let relay_urls: Vec<String> = {
|
|
let index = manager.relay_sync_index.read().await;
|
|
index.keys().cloned().collect()
|
|
};
|
|
|
|
// Update health state for each relay
|
|
for relay_url in relay_urls {
|
|
let state = manager.health_tracker.get_state(&relay_url);
|
|
metrics.record_health_state(&relay_url, state);
|
|
}
|
|
|
|
// Update naughty list metrics
|
|
if let Some(naughty_list) = manager.health_tracker.naughty_list() {
|
|
let entries = naughty_list.get_all();
|
|
metrics.update_naughty_list(entries);
|
|
}
|
|
}
|
|
}
|
|
_ = shutdown_rx.recv() => {
|
|
tracing::info!("Health and metrics checker received shutdown signal");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Manages proactive synchronization with external relays
|
|
///
|
|
/// The SyncManager runs as a background task, subscribing to repository
|
|
/// announcements on the local relay and syncing data from external relays
|
|
/// listed in those announcements.
|
|
pub struct SyncManager {
|
|
/// Bootstrap relay URL for initial sync (optional)
|
|
bootstrap_relay_url: Option<String>,
|
|
/// Our service domain - used for filtering relevant repos
|
|
service_domain: String,
|
|
/// Database for event storage and queries
|
|
database: SharedDatabase,
|
|
/// Write policy for validating incoming events
|
|
write_policy: Nip34WritePolicy,
|
|
/// Purgatory for read-only access to events awaiting git data
|
|
purgatory: Arc<crate::purgatory::Purgatory>,
|
|
/// Local relay for submitting synced events (enables broadcast to WebSocket subscribers)
|
|
local_relay: LocalRelay,
|
|
/// Configuration reference for sync settings
|
|
config: Config,
|
|
/// What we want to sync (source of truth)
|
|
repo_sync_index: RepoSyncIndex,
|
|
/// What we've confirmed syncing + connection state
|
|
relay_sync_index: RelaySyncIndex,
|
|
/// In-flight subscription batches
|
|
pending_sync_index: PendingSyncIndex,
|
|
/// Rejected events (30617/30618) - two-tier storage for re-processing
|
|
/// Handles both announcement and state events via EventType discriminator
|
|
rejected_events_index: Arc<RejectedEventsIndex>,
|
|
/// Active relay connections - keyed by relay URL
|
|
connections: HashMap<String, RelayConnection>,
|
|
/// Event-directed relay targets rejected by the outbound target policy.
|
|
///
|
|
/// Rejected URLs stay in `repo_sync_index` (they come from stored events),
|
|
/// so without this memo every sync pass would re-derive, re-reject, and
|
|
/// re-log the same forbidden target. Bounded by the set of distinct relay
|
|
/// URLs in stored/purgatory events, which the indexes already carry.
|
|
rejected_relay_targets: HashSet<String>,
|
|
/// Last exact-ID dependency recovery attempt, used to bound retries.
|
|
dependency_refetch_attempts: Arc<std::sync::Mutex<HashMap<EventId, Instant>>>,
|
|
/// Events relays reported during negentropy reconciliation but failed to
|
|
/// deliver on exact-ID fetches. Retried with bounded backoff by the sync
|
|
/// maintenance timer instead of being dropped with their failed batch.
|
|
missing_event_recovery: Arc<std::sync::Mutex<missing_events::MissingEventRecoveryIndex>>,
|
|
/// Last dependency pass for each purgatory announcement.
|
|
purgatory_dependency_attempts: HashMap<EventId, Instant>,
|
|
/// Temporary source relays retained while rejected dependencies are recovered.
|
|
dependency_relay_deadlines: HashMap<String, Instant>,
|
|
/// Health tracker for relay connection state
|
|
health_tracker: Arc<RelayHealthTracker>,
|
|
/// Counter for generating unique batch IDs
|
|
next_batch_id: u64,
|
|
/// Monotonic identity for rejecting stale connection results.
|
|
next_connect_attempt_token: u64,
|
|
/// One active or queued connection attempt per canonical relay URL.
|
|
in_flight_connect_attempts: HashMap<String, ConnectAttemptToken>,
|
|
/// Shared bound for DNS and websocket handshakes.
|
|
connect_attempt_semaphore: Arc<Semaphore>,
|
|
/// Relays whose subscription consolidation waits for in-flight batches to drain.
|
|
deferred_consolidations: DeferredConsolidations,
|
|
/// Channel for disconnect notifications (set during run)
|
|
disconnect_tx: Option<tokio::sync::mpsc::Sender<DisconnectNotification>>,
|
|
/// Channel for EOSE notifications (set during run)
|
|
eose_tx: Option<tokio::sync::mpsc::Sender<EoseNotification>>,
|
|
/// Returns connection outcomes to the sync actor for serialized state changes.
|
|
connect_attempt_result_tx: Option<tokio::sync::mpsc::UnboundedSender<ConnectAttemptResult>>,
|
|
/// Wakes the actor when a batch completion may unblock consolidation.
|
|
deferred_consolidation_tx: Option<tokio::sync::mpsc::UnboundedSender<String>>,
|
|
/// Channel for broadcasting shutdown signal to all background tasks
|
|
shutdown_tx: Option<broadcast::Sender<()>>,
|
|
/// Prometheus metrics for sync operations (None if metrics disabled)
|
|
metrics: Option<SyncMetrics>,
|
|
}
|
|
|
|
impl SyncManager {
|
|
/// Create a new SyncManager
|
|
///
|
|
/// # Arguments
|
|
/// * `bootstrap_relay_url` - Optional relay URL for initial historical sync
|
|
/// * `service_domain` - The domain this relay serves (for filtering repos)
|
|
/// * `database` - Shared database for event storage
|
|
/// * `write_policy` - Policy for validating events before storage
|
|
/// * `local_relay` - Local relay for submitting synced events (enables WebSocket broadcast)
|
|
/// * `config` - Configuration for sync settings
|
|
/// * `data_path` - Path to git data directory (for persistence)
|
|
/// * `sync_metrics` - Optional pre-registered SyncMetrics (passed from Metrics if metrics are enabled)
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
bootstrap_relay_url: Option<String>,
|
|
service_domain: String,
|
|
database: SharedDatabase,
|
|
write_policy: Nip34WritePolicy,
|
|
local_relay: LocalRelay,
|
|
config: &Config,
|
|
data_path: PathBuf,
|
|
sync_metrics: Option<SyncMetrics>,
|
|
) -> Self {
|
|
// Extract purgatory from write_policy for read-only access
|
|
let purgatory = write_policy.purgatory().clone();
|
|
|
|
// Create rejected events index
|
|
let rejected_events_index = Arc::new(if let Some(ref metrics) = sync_metrics {
|
|
RejectedEventsIndex::with_metrics(
|
|
Duration::from_secs(config.rejected_hot_cache_duration_secs),
|
|
Duration::from_secs(config.rejected_cold_index_expiry_secs),
|
|
metrics.clone(),
|
|
)
|
|
} else {
|
|
RejectedEventsIndex::new(
|
|
Duration::from_secs(config.rejected_hot_cache_duration_secs),
|
|
Duration::from_secs(config.rejected_cold_index_expiry_secs),
|
|
)
|
|
});
|
|
|
|
// Attempt to restore rejected events index from disk
|
|
let rejected_index_path = data_path.join("rejected-events-cache.json");
|
|
if rejected_index_path.exists() {
|
|
match rejected_events_index.restore_from_disk(&rejected_index_path) {
|
|
Ok(()) => {
|
|
tracing::info!("Restored rejected events index from disk");
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Failed to restore rejected events index: {}, starting empty",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Self {
|
|
bootstrap_relay_url,
|
|
service_domain,
|
|
database,
|
|
write_policy,
|
|
purgatory,
|
|
local_relay,
|
|
config: config.clone(),
|
|
repo_sync_index: Arc::new(RwLock::new(HashMap::new())),
|
|
relay_sync_index: Arc::new(RwLock::new(HashMap::new())),
|
|
pending_sync_index: Arc::new(RwLock::new(HashMap::new())),
|
|
rejected_events_index,
|
|
connections: HashMap::new(),
|
|
rejected_relay_targets: HashSet::new(),
|
|
dependency_refetch_attempts: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
|
missing_event_recovery: Arc::new(std::sync::Mutex::new(
|
|
missing_events::MissingEventRecoveryIndex::default(),
|
|
)),
|
|
purgatory_dependency_attempts: HashMap::new(),
|
|
dependency_relay_deadlines: HashMap::new(),
|
|
health_tracker: Arc::new(RelayHealthTracker::new(config)),
|
|
next_batch_id: 0,
|
|
next_connect_attempt_token: 0,
|
|
in_flight_connect_attempts: HashMap::new(),
|
|
connect_attempt_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_CONNECT_ATTEMPTS)),
|
|
deferred_consolidations: DeferredConsolidations::default(),
|
|
disconnect_tx: None,
|
|
eose_tx: None,
|
|
connect_attempt_result_tx: None,
|
|
deferred_consolidation_tx: None,
|
|
shutdown_tx: None,
|
|
metrics: sync_metrics,
|
|
}
|
|
}
|
|
|
|
/// Generate a unique batch ID
|
|
///
|
|
/// Increments the internal counter and returns the new value.
|
|
/// Used for tracking pending batches and debugging/logging.
|
|
fn next_batch_id(&mut self) -> u64 {
|
|
self.next_batch_id += 1;
|
|
self.next_batch_id
|
|
}
|
|
|
|
/// Get a clone of the rejected events index Arc.
|
|
///
|
|
/// This allows access to the rejected events index for persistence
|
|
/// even after the SyncManager has been moved into a task.
|
|
///
|
|
/// # Returns
|
|
/// Arc clone of the rejected events index
|
|
pub fn rejected_events_index(&self) -> Arc<RejectedEventsIndex> {
|
|
self.rejected_events_index.clone()
|
|
}
|
|
|
|
/// Save rejected events index to disk.
|
|
///
|
|
/// This is called during shutdown to persist the rejected events cache,
|
|
/// allowing us to avoid re-downloading rejected events after restart.
|
|
///
|
|
/// # Arguments
|
|
/// * `path` - Path to save the rejected index file
|
|
///
|
|
/// # Returns
|
|
/// Ok(()) on success, Err if save fails
|
|
pub fn save_rejected_index(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
|
self.rejected_events_index.save_to_disk(path)
|
|
}
|
|
|
|
/// Handle EOSE (End Of Stored Events) for a subscription
|
|
///
|
|
/// This method:
|
|
/// - Finds the PendingBatch containing this subscription ID
|
|
/// - Removes the subscription from outstanding_subs
|
|
/// - When all subscriptions complete (outstanding_subs empty):
|
|
/// - Calls confirm_batch to move items to confirmed state
|
|
async fn handle_eose(&mut self, relay_url: &str, sub_id: SubscriptionId) {
|
|
// Check if relay is in Disconnecting state
|
|
let is_disconnecting = {
|
|
let index = self.relay_sync_index.read().await;
|
|
index
|
|
.get(relay_url)
|
|
.map(|s| s.connection_status == ConnectionStatus::Disconnecting)
|
|
.unwrap_or(false)
|
|
};
|
|
|
|
// 1. Find and update the pending batch
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
|
|
let Some(batches) = pending.get_mut(relay_url) else {
|
|
// This can happen when EOSE arrives after batch has already been confirmed/removed.
|
|
// Common causes:
|
|
// 1. During intentional disconnect (cleanup in progress)
|
|
// 2. Duplicate/late EOSE from relay (e.g., live_sync REQ subscriptions may send
|
|
// multiple EOSE messages - some relays do this)
|
|
// 3. Race condition between batch confirmation and EOSE arrival
|
|
//
|
|
// NOTE: If we wanted to investigate whether these are truly duplicate EOSEs,
|
|
// we could track recently-completed subscription IDs (with timestamps) and
|
|
// check if this sub_id was recently confirmed. This would distinguish between:
|
|
// - Duplicate EOSE (sub_id was recently in outstanding_subs)
|
|
// - Truly unknown subscription (sub_id never tracked)
|
|
if is_disconnecting {
|
|
// Expected during intentional disconnect - suppress noisy log
|
|
tracing::trace!(
|
|
relay = %relay_url,
|
|
sub_id = %sub_id,
|
|
"EOSE received during disconnect cleanup - ignoring"
|
|
);
|
|
} else {
|
|
// Expected when batch completes before late/duplicate EOSE arrives
|
|
tracing::trace!(
|
|
relay = %relay_url,
|
|
sub_id = %sub_id,
|
|
"EOSE received after batch already completed (late or duplicate EOSE)"
|
|
);
|
|
}
|
|
return;
|
|
};
|
|
|
|
// Find the batch containing this subscription
|
|
let batch_index = batches
|
|
.iter()
|
|
.position(|b| b.outstanding_subs.contains(&sub_id));
|
|
|
|
let Some(batch_idx) = batch_index else {
|
|
// Live subscriptions (limit:0, no auto-close) are not tracked in PendingBatch.
|
|
// They complete immediately with EOSE (no historic events) and stay open for new events.
|
|
// Observed in production: sync_live() subscriptions trigger this path (expected).
|
|
// Also possible: duplicate/late EOSE from relay after batch already completed.
|
|
tracing::trace!(
|
|
relay = %relay_url,
|
|
sub_id = %sub_id,
|
|
"EOSE received for subscription not tracked in batch (live subscription or late EOSE)"
|
|
);
|
|
return;
|
|
};
|
|
|
|
// Remove the subscription from outstanding_subs
|
|
let batch = &mut batches[batch_idx];
|
|
batch.outstanding_subs.remove(&sub_id);
|
|
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
sub_id = %sub_id,
|
|
batch_id = batch.batch_id,
|
|
remaining_subs = batch.outstanding_subs.len(),
|
|
"EOSE processed for subscription"
|
|
);
|
|
|
|
// Check for pagination: if this subscription hit the threshold, fetch next page
|
|
if let Some(pagination_state) = batch.pagination_state.remove(&sub_id) {
|
|
let next_filters = pagination_state.next_page_filters();
|
|
if !next_filters.is_empty() {
|
|
let relay_url_for_pagination = relay_url.to_string();
|
|
let batch_id = batch.batch_id;
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
sub_id = %sub_id,
|
|
batch_id,
|
|
filter_count = next_filters.len(),
|
|
"Grouped subscription hit pagination threshold, fetching next page"
|
|
);
|
|
|
|
// A NOTICE can arrive immediately before this page's EOSE.
|
|
// Keep a sentinel in the batch and let a detached worker
|
|
// resume the exact grouped page after the cooldown.
|
|
if self.health_tracker.is_rate_limited(relay_url) {
|
|
let deferred_sub_id = mark_deferred_pagination(batch, &sub_id);
|
|
drop(pending);
|
|
|
|
let Some(connection) = self.connections.get(&relay_url_for_pagination).cloned()
|
|
else {
|
|
tracing::error!(
|
|
relay = %relay_url_for_pagination,
|
|
batch_id,
|
|
"Cannot defer rate-limited pagination without a relay connection"
|
|
);
|
|
return;
|
|
};
|
|
Self::spawn_deferred_pagination(
|
|
connection,
|
|
self.health_tracker.clone(),
|
|
self.pending_sync_index.clone(),
|
|
relay_url_for_pagination,
|
|
batch_id,
|
|
deferred_sub_id,
|
|
next_filters,
|
|
);
|
|
return;
|
|
}
|
|
|
|
drop(pending);
|
|
|
|
let mut next_page_started = false;
|
|
if let Some(conn) = self.connections.get(&relay_url_for_pagination) {
|
|
match conn.subscribe_filters(next_filters.clone(), true).await {
|
|
Ok(new_sub_id) => {
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
if let Some(batches) = pending.get_mut(&relay_url_for_pagination) {
|
|
if let Some(batch) =
|
|
batches.iter_mut().find(|b| b.batch_id == batch_id)
|
|
{
|
|
batch.outstanding_subs.insert(new_sub_id.clone());
|
|
next_page_started = true;
|
|
batch.pagination_state.insert(
|
|
new_sub_id.clone(),
|
|
PaginationState::new(next_filters),
|
|
);
|
|
tracing::info!(
|
|
relay = %relay_url_for_pagination,
|
|
new_sub_id = %new_sub_id,
|
|
batch_id,
|
|
"Next grouped page subscription created"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Err(error) => {
|
|
tracing::error!(
|
|
relay = %relay_url_for_pagination,
|
|
batch_id,
|
|
error = %error,
|
|
"Failed to create grouped pagination subscription"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if !next_page_started {
|
|
let completed_batch = {
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
take_drained_batch_as_failed(
|
|
&mut pending,
|
|
&relay_url_for_pagination,
|
|
batch_id,
|
|
)
|
|
};
|
|
if let Some(batch) = completed_batch {
|
|
tracing::warn!(
|
|
relay = %relay_url_for_pagination,
|
|
batch_id,
|
|
"Pagination could not continue; completing drained batch as failed"
|
|
);
|
|
self.confirm_batch(&relay_url_for_pagination, batch).await;
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check if batch is complete
|
|
if !batch.outstanding_subs.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// 2. Batch complete - validate negentropy ID fetches before confirming
|
|
// For negentropy batches, check if all requested events were received
|
|
if batch.sync_method == SyncMethod::Negentropy {
|
|
if let (Some(requested), Some(received)) =
|
|
(&batch.requested_event_ids, &batch.received_event_ids)
|
|
{
|
|
let missing: Vec<EventId> = requested.difference(received).cloned().collect();
|
|
|
|
if !missing.is_empty() {
|
|
let requested_count = requested.len();
|
|
let received_count = received.len();
|
|
let retry_count = batch.retry_count;
|
|
|
|
// Check if we made any progress (received ANY events we requested)
|
|
// If received_count is 0, relay returned nothing useful - abort retry
|
|
//
|
|
// NOTE: Some relays (e.g., azzamo.net, snort.social) have been observed
|
|
// returning zero events during negentropy retry even though manual queries
|
|
// (REQ by ID) show they DO have these events. This appears to be relay-
|
|
// specific behavior where the relay refuses to serve events via negentropy
|
|
// retry for unknown reasons (rate limiting, negentropy implementation bugs,
|
|
// or other internal logic). When retry returns zero, fall back to REQ+EOSE.
|
|
if retry_count > 0 && received_count == 0 {
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
batch_id = batch.batch_id,
|
|
retry_count = retry_count,
|
|
requested_count = requested_count,
|
|
missing_count = missing.len(),
|
|
missing_ids_sample = ?missing.iter().take(5).map(|id| id.to_hex()).collect::<Vec<_>>(),
|
|
"Negentropy retry made no progress - relay returned zero requested events. \
|
|
Marking relay as not supporting negentropy and falling back to REQ+EOSE."
|
|
);
|
|
|
|
// Mark relay as not supporting negentropy so future batches skip it
|
|
if let Some(conn) = self.connections.get(relay_url) {
|
|
conn.mark_negentropy_unsupported();
|
|
}
|
|
|
|
// Prepare for REQ+EOSE fallback using semantic filters
|
|
// (not ID-based queries which already failed)
|
|
let relay_url_for_fallback = relay_url.to_string();
|
|
let batch_id = batch.batch_id;
|
|
let batch_full_repos = batch.items.repos.clone();
|
|
let batch_state_only_repos = batch.items.state_only_repos.clone();
|
|
let batch_root_events = batch.items.root_events.clone();
|
|
let missing_count = missing.len();
|
|
|
|
// Drop the lock before async operations
|
|
drop(pending);
|
|
|
|
// Create REQ+EOSE subscriptions using original semantic filters
|
|
// This queries by kind/author/tags instead of by ID, which may
|
|
// succeed even when ID-based queries fail.
|
|
// Preserve the level this batch was created with. The desired
|
|
// level may have advanced from StateOnly to Full while the
|
|
// historic request was in flight.
|
|
let fallback_filters = filters::build_sync_level_aware_filters(
|
|
&batch_full_repos,
|
|
&batch_state_only_repos,
|
|
&batch_root_events,
|
|
None,
|
|
);
|
|
|
|
if fallback_filters.is_empty() {
|
|
tracing::warn!(
|
|
relay = %relay_url_for_fallback,
|
|
batch_id = batch_id,
|
|
full_repos = batch_full_repos.len(),
|
|
state_only_repos = batch_state_only_repos.len(),
|
|
root_events = batch_root_events.len(),
|
|
"Cannot create semantic fallback filters - no repos or root_events in batch"
|
|
);
|
|
// Fall through to ID-based fallback as last resort
|
|
}
|
|
|
|
let mut new_sub_ids = HashSet::new();
|
|
if let Some(conn) = self.connections.get(&relay_url_for_fallback) {
|
|
for filter_group in fallback_filters.chunks(MAX_FILTERS_PER_REQ) {
|
|
match conn.subscribe_filters(filter_group.to_vec(), true).await {
|
|
Ok(sub_id) => {
|
|
new_sub_ids.insert(sub_id);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
relay = %relay_url_for_fallback,
|
|
batch_id = batch_id,
|
|
error = %e,
|
|
"Failed to create REQ+EOSE fallback subscription"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !new_sub_ids.is_empty() {
|
|
// Re-acquire lock and update batch to use REQ+EOSE
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
if let Some(batches) = pending.get_mut(&relay_url_for_fallback) {
|
|
if let Some(batch) =
|
|
batches.iter_mut().find(|b| b.batch_id == batch_id)
|
|
{
|
|
// Switch to REQ+EOSE sync method
|
|
batch.sync_method = SyncMethod::ReqEose;
|
|
// Clear negentropy-specific tracking
|
|
batch.requested_event_ids = None;
|
|
batch.received_event_ids = None;
|
|
// Reset retry count for REQ+EOSE flow
|
|
batch.retry_count = 0;
|
|
// Add new subscriptions to outstanding_subs
|
|
batch.outstanding_subs.extend(new_sub_ids.clone());
|
|
|
|
tracing::info!(
|
|
relay = %relay_url_for_fallback,
|
|
batch_id = batch_id,
|
|
fallback_subs = new_sub_ids.len(),
|
|
missing_events = missing_count,
|
|
"Switched batch to REQ+EOSE fallback, waiting for EOSE"
|
|
);
|
|
}
|
|
}
|
|
// Early return - batch not complete yet, waiting for REQ+EOSE EOSE
|
|
return;
|
|
} else {
|
|
// No fallback subscriptions could be created (for
|
|
// announcement batches there is no semantic metadata
|
|
// at all). Finalize the batch as failed but keep the
|
|
// missing IDs represented so the maintenance timer
|
|
// retries them with bounded backoff instead of
|
|
// forgetting them until the next daily sync.
|
|
let relay_already_degraded = {
|
|
let index = self.relay_sync_index.read().await;
|
|
index
|
|
.get(&relay_url_for_fallback)
|
|
.map(|state| state.historic_sync_had_failures)
|
|
.unwrap_or(false)
|
|
};
|
|
let register_outcome =
|
|
self.missing_event_recovery.lock().unwrap().register(
|
|
&relay_url_for_fallback,
|
|
batch_id,
|
|
missing.iter().copied(),
|
|
relay_already_degraded,
|
|
Instant::now(),
|
|
);
|
|
tracing::error!(
|
|
relay = %relay_url_for_fallback,
|
|
batch_id = batch_id,
|
|
missing_count = missing_count,
|
|
pending_recovery = register_outcome.pending_total,
|
|
"Failed to create REQ+EOSE fallback subscriptions - completing batch with partial results; bounded missing-event recovery scheduled"
|
|
);
|
|
|
|
// Re-acquire lock to extract the batch
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
if let Some(batches) = pending.get_mut(&relay_url_for_fallback) {
|
|
if let Some(idx) =
|
|
batches.iter().position(|b| b.batch_id == batch_id)
|
|
{
|
|
let mut completed_batch = batches.remove(idx);
|
|
completed_batch.failed = true; // Mark as failed
|
|
if batches.is_empty() {
|
|
pending.remove(&relay_url_for_fallback);
|
|
}
|
|
drop(pending);
|
|
self.confirm_batch(&relay_url_for_fallback, completed_batch)
|
|
.await;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
batch_id = batch.batch_id,
|
|
retry_count = retry_count,
|
|
requested_count = requested_count,
|
|
received_count = received_count,
|
|
missing_count = missing.len(),
|
|
missing_ids_sample = ?missing.iter().take(5).map(|id| id.to_hex()).collect::<Vec<_>>(),
|
|
"Negentropy sync incomplete - relay returned fewer events than requested. \
|
|
This may indicate a relay limit on ID-based queries. \
|
|
Retrying missing events."
|
|
);
|
|
|
|
// Create retry subscription for missing events
|
|
// Chunk by 300 to avoid overly large filters
|
|
let relay_url_for_retry = relay_url.to_string();
|
|
let batch_id = batch.batch_id;
|
|
|
|
// Drop the lock before async operations
|
|
drop(pending);
|
|
|
|
// Create new subscriptions for missing events
|
|
let retry_filters: Vec<_> = missing
|
|
.chunks(300)
|
|
.map(|c| Filter::new().ids(c.iter().copied()))
|
|
.collect();
|
|
|
|
let mut new_sub_ids = HashSet::new();
|
|
if let Some(conn) = self.connections.get(&relay_url_for_retry) {
|
|
for filter in retry_filters {
|
|
match conn.subscribe_filter(filter, true).await {
|
|
Ok(sub_id) => {
|
|
new_sub_ids.insert(sub_id);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
relay = %relay_url_for_retry,
|
|
batch_id = batch_id,
|
|
error = %e,
|
|
"Failed to create retry subscription for missing events"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !new_sub_ids.is_empty() {
|
|
// Re-acquire lock and update batch with new subscriptions
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
if let Some(batches) = pending.get_mut(&relay_url_for_retry) {
|
|
if let Some(batch) = batches.iter_mut().find(|b| b.batch_id == batch_id)
|
|
{
|
|
batch.outstanding_subs.extend(new_sub_ids.clone());
|
|
// Update requested_event_ids to only include missing ones
|
|
batch.requested_event_ids = Some(missing.iter().cloned().collect());
|
|
// Clear received_event_ids for fresh tracking
|
|
batch.received_event_ids = Some(HashSet::new());
|
|
// Increment retry counter
|
|
batch.retry_count += 1;
|
|
|
|
tracing::info!(
|
|
relay = %relay_url_for_retry,
|
|
batch_id = batch_id,
|
|
retry_subs = new_sub_ids.len(),
|
|
missing_events = missing.len(),
|
|
retry_attempt = batch.retry_count,
|
|
"Created retry subscriptions for missing negentropy events"
|
|
);
|
|
}
|
|
}
|
|
// Early return - batch not complete yet, waiting for retry EOSE
|
|
return;
|
|
} else {
|
|
// Failed to create retry subscriptions. Finalize the
|
|
// batch as failed (an incomplete batch must not be
|
|
// reported as successfully complete) and keep the
|
|
// missing IDs represented for bounded recovery.
|
|
let relay_already_degraded = {
|
|
let index = self.relay_sync_index.read().await;
|
|
index
|
|
.get(&relay_url_for_retry)
|
|
.map(|state| state.historic_sync_had_failures)
|
|
.unwrap_or(false)
|
|
};
|
|
let register_outcome =
|
|
self.missing_event_recovery.lock().unwrap().register(
|
|
&relay_url_for_retry,
|
|
batch_id,
|
|
missing.iter().copied(),
|
|
relay_already_degraded,
|
|
Instant::now(),
|
|
);
|
|
tracing::error!(
|
|
relay = %relay_url_for_retry,
|
|
batch_id = batch_id,
|
|
missing_count = missing.len(),
|
|
pending_recovery = register_outcome.pending_total,
|
|
"Failed to retry missing events - confirming batch with partial results; bounded missing-event recovery scheduled"
|
|
);
|
|
|
|
// Re-acquire lock to extract the batch
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
if let Some(batches) = pending.get_mut(&relay_url_for_retry) {
|
|
if let Some(idx) = batches.iter().position(|b| b.batch_id == batch_id) {
|
|
let mut completed_batch = batches.remove(idx);
|
|
completed_batch.failed = true;
|
|
if batches.is_empty() {
|
|
pending.remove(&relay_url_for_retry);
|
|
}
|
|
drop(pending);
|
|
self.confirm_batch(&relay_url_for_retry, completed_batch)
|
|
.await;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Batch complete - extract and remove
|
|
let completed_batch = batches.remove(batch_idx);
|
|
|
|
// Clean up empty relay entry
|
|
if batches.is_empty() {
|
|
pending.remove(relay_url);
|
|
}
|
|
|
|
// Drop the pending lock before confirm_batch
|
|
drop(pending);
|
|
|
|
// 4. Confirm the batch (moves items to RelayState)
|
|
self.confirm_batch(relay_url, completed_batch).await;
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn spawn_deferred_pagination(
|
|
connection: RelayConnection,
|
|
health_tracker: Arc<RelayHealthTracker>,
|
|
pending_sync_index: PendingSyncIndex,
|
|
relay_url: String,
|
|
batch_id: u64,
|
|
deferred_sub_id: SubscriptionId,
|
|
next_filters: Vec<Filter>,
|
|
) {
|
|
tokio::spawn(async move {
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
batch_id,
|
|
filter_count = next_filters.len(),
|
|
"Rate limited during historic pagination; deferring the exact next page without blocking the sync actor"
|
|
);
|
|
|
|
loop {
|
|
if let Some(remaining) = health_tracker.get_remaining_backoff(&relay_url) {
|
|
tokio::time::sleep(remaining + Duration::from_millis(10)).await;
|
|
continue;
|
|
}
|
|
|
|
// Hold only the pending-index lock while subscribing. This
|
|
// prevents a very fast EOSE from reaching the actor before its
|
|
// new subscription ID is registered, without blocking the
|
|
// SyncManager mutex or the purgatory timer.
|
|
let mut pending = pending_sync_index.write().await;
|
|
let Some(batch) = pending.get_mut(&relay_url).and_then(|batches| {
|
|
batches.iter_mut().find(|batch| batch.batch_id == batch_id)
|
|
}) else {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
batch_id,
|
|
"Deferred pagination batch no longer exists"
|
|
);
|
|
return;
|
|
};
|
|
if !batch.outstanding_subs.contains(&deferred_sub_id) {
|
|
return;
|
|
}
|
|
|
|
match connection
|
|
.subscribe_filters(next_filters.clone(), true)
|
|
.await
|
|
{
|
|
Ok(new_sub_id) => {
|
|
batch.outstanding_subs.remove(&deferred_sub_id);
|
|
batch.outstanding_subs.insert(new_sub_id.clone());
|
|
batch
|
|
.pagination_state
|
|
.insert(new_sub_id.clone(), PaginationState::new(next_filters));
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
new_sub_id = %new_sub_id,
|
|
batch_id,
|
|
"Deferred pagination resumed after rate-limit cooldown"
|
|
);
|
|
return;
|
|
}
|
|
Err(error) => {
|
|
drop(pending);
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
batch_id,
|
|
error = %error,
|
|
"Deferred pagination subscription failed; retrying"
|
|
);
|
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Drive bounded recovery of events relays reported during negentropy
|
|
/// reconciliation but failed to deliver on exact-ID fetches.
|
|
///
|
|
/// Runs on the sync maintenance timer. For each relay with pending IDs:
|
|
/// 1. IDs already present locally (live sync, user submission) are
|
|
/// cleared promptly without consuming attempt budget.
|
|
/// 2. When the relay's backoff deadline has passed and it has a live
|
|
/// connection, one bounded exact-ID fetch is spawned. Network I/O runs
|
|
/// outside the sync actor lock, and per-relay single-flight keeps one
|
|
/// persistently incomplete relay from starving other relays or later
|
|
/// batches.
|
|
async fn tick_missing_event_recovery(&mut self) {
|
|
let idle_relays = self.missing_event_recovery.lock().unwrap().idle_relays();
|
|
if idle_relays.is_empty() {
|
|
return;
|
|
}
|
|
|
|
for relay_url in idle_relays {
|
|
let Some(pending) = self
|
|
.missing_event_recovery
|
|
.lock()
|
|
.unwrap()
|
|
.pending_ids(&relay_url)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
// 1. Clear IDs satisfied by other means.
|
|
let satisfied: Vec<EventId> = match self
|
|
.database
|
|
.query(Filter::new().ids(pending.iter().copied()))
|
|
.await
|
|
{
|
|
Ok(events) => events.into_iter().map(|event| event.id).collect(),
|
|
Err(_) => Vec::new(),
|
|
};
|
|
if !satisfied.is_empty() {
|
|
let outcome = self
|
|
.missing_event_recovery
|
|
.lock()
|
|
.unwrap()
|
|
.clear_satisfied(&relay_url, &satisfied);
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
satisfied = satisfied.len(),
|
|
"Pending missing events satisfied by local arrivals"
|
|
);
|
|
if let Some(outcome) = outcome {
|
|
Self::apply_recovery_outcome(
|
|
&relay_url,
|
|
outcome,
|
|
&self.relay_sync_index,
|
|
self.metrics.as_ref(),
|
|
)
|
|
.await;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// 2. Issue a due attempt over a live connection only.
|
|
let connection_ready = {
|
|
let index = self.relay_sync_index.read().await;
|
|
index
|
|
.get(&relay_url)
|
|
.map(|state| {
|
|
matches!(
|
|
state.connection_status,
|
|
ConnectionStatus::Syncing
|
|
| ConnectionStatus::Connected
|
|
| ConnectionStatus::ConnectedHistoricSyncFailures
|
|
)
|
|
})
|
|
.unwrap_or(false)
|
|
};
|
|
let connection = self.connections.get(&relay_url).cloned();
|
|
let (Some(connection), true) = (connection, connection_ready) else {
|
|
// No usable connection: postpone without consuming the
|
|
// zero-progress budget so an unavailable relay can neither
|
|
// expire its pending IDs nor spin in a tight loop.
|
|
self.missing_event_recovery
|
|
.lock()
|
|
.unwrap()
|
|
.defer_attempt(&relay_url, Instant::now());
|
|
continue;
|
|
};
|
|
|
|
let Some(attempt) = self
|
|
.missing_event_recovery
|
|
.lock()
|
|
.unwrap()
|
|
.begin_attempt(&relay_url, Instant::now())
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
tokio::spawn(Self::run_missing_event_recovery_attempt(
|
|
relay_url,
|
|
connection,
|
|
attempt,
|
|
Arc::clone(&self.database),
|
|
self.write_policy.clone(),
|
|
self.local_relay.clone(),
|
|
Arc::clone(&self.rejected_events_index),
|
|
Arc::clone(&self.missing_event_recovery),
|
|
self.relay_sync_index.clone(),
|
|
self.metrics.clone(),
|
|
));
|
|
}
|
|
}
|
|
|
|
/// One bounded exact-ID recovery fetch against a single relay.
|
|
///
|
|
/// Runs outside the sync actor lock. Every event the relay returns is
|
|
/// passed through the normal write policy; an ID counts as recovered once
|
|
/// the relay has delivered the event, regardless of the policy verdict
|
|
/// (rejected events have their own re-processing machinery).
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn run_missing_event_recovery_attempt(
|
|
relay_url: String,
|
|
connection: RelayConnection,
|
|
attempt: missing_events::RecoveryAttempt,
|
|
database: SharedDatabase,
|
|
write_policy: Nip34WritePolicy,
|
|
local_relay: LocalRelay,
|
|
rejected_events_index: Arc<RejectedEventsIndex>,
|
|
missing_event_recovery: Arc<std::sync::Mutex<missing_events::MissingEventRecoveryIndex>>,
|
|
relay_index: RelaySyncIndex,
|
|
metrics: Option<SyncMetrics>,
|
|
) {
|
|
let requested: HashSet<EventId> = attempt.ids.iter().copied().collect();
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
attempt = attempt.attempt_number,
|
|
requested = requested.len(),
|
|
"Retrying events missing from an incomplete historic sync batch"
|
|
);
|
|
|
|
let events = match connection
|
|
.fetch_events(
|
|
Filter::new().ids(attempt.ids.iter().copied()),
|
|
Duration::from_secs(10),
|
|
)
|
|
.await
|
|
{
|
|
Ok(events) => events,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
attempt = attempt.attempt_number,
|
|
requested = requested.len(),
|
|
error = %error,
|
|
"Missing-event recovery fetch failed"
|
|
);
|
|
Vec::new()
|
|
}
|
|
};
|
|
|
|
let mut recovered = HashSet::new();
|
|
for event in events {
|
|
if !requested.contains(&event.id) {
|
|
continue;
|
|
}
|
|
// Events already tracked as rejected count as recovered without
|
|
// re-processing: re-validation is owned by the rejected-index
|
|
// re-processing machinery, and unrecoverable IDs never revalidate.
|
|
if rejected_events_index.contains(&event.id) {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
event_id = %event.id,
|
|
"Recovered missing event already tracked as rejected, skipping re-processing"
|
|
);
|
|
recovered.insert(event.id);
|
|
continue;
|
|
}
|
|
let result = Self::process_event_static(
|
|
&event,
|
|
&relay_url,
|
|
&database,
|
|
&write_policy,
|
|
&local_relay,
|
|
&rejected_events_index,
|
|
crate::nostr::persistence::SaveContext::RelaySync,
|
|
)
|
|
.await;
|
|
if result == ProcessResult::Rejected {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
event_id = %event.id,
|
|
"Recovered missing event was rejected by the write policy"
|
|
);
|
|
}
|
|
recovered.insert(event.id);
|
|
}
|
|
|
|
let outcome = missing_event_recovery.lock().unwrap().complete_attempt(
|
|
&relay_url,
|
|
&recovered,
|
|
Instant::now(),
|
|
);
|
|
let Some(outcome) = outcome else {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"Recovery attempt finished after the relay's pending work was reset"
|
|
);
|
|
return;
|
|
};
|
|
Self::apply_recovery_outcome(&relay_url, outcome, &relay_index, metrics.as_ref()).await;
|
|
}
|
|
|
|
/// Log a recovery outcome and, on full recovery, restore relay health.
|
|
///
|
|
/// A relay is only promoted from `ConnectedHistoricSyncFailures` back to
|
|
/// `Connected` when every registered missing ID was recovered and no
|
|
/// unrelated batch failure was observed for the relay.
|
|
async fn apply_recovery_outcome(
|
|
relay_url: &str,
|
|
outcome: missing_events::AttemptOutcome,
|
|
relay_index: &RelaySyncIndex,
|
|
metrics: Option<&SyncMetrics>,
|
|
) {
|
|
use missing_events::AttemptOutcome;
|
|
|
|
match outcome {
|
|
AttemptOutcome::FullyRecovered {
|
|
recovered,
|
|
total_recovered,
|
|
can_restore_health,
|
|
} => {
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
recovered,
|
|
total_recovered,
|
|
can_restore_health,
|
|
"All events missing from historic sync fully recovered"
|
|
);
|
|
if !can_restore_health {
|
|
return;
|
|
}
|
|
let mut index = relay_index.write().await;
|
|
if let Some(state) = index.get_mut(relay_url) {
|
|
state.historic_sync_had_failures = false;
|
|
if state.connection_status == ConnectionStatus::ConnectedHistoricSyncFailures {
|
|
state.connection_status = ConnectionStatus::Connected;
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
"Historic sync failures resolved - relay promoted to Connected"
|
|
);
|
|
if let Some(metrics) = metrics {
|
|
metrics
|
|
.record_connection_status(relay_url, ConnectionStatus::Connected);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
AttemptOutcome::PartiallyRecovered {
|
|
recovered,
|
|
remaining,
|
|
next_attempt_in,
|
|
} => {
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
recovered,
|
|
remaining,
|
|
next_attempt_in_secs = next_attempt_in.as_secs_f64(),
|
|
"Partially recovered events missing from historic sync - retry scheduled"
|
|
);
|
|
}
|
|
AttemptOutcome::RetryScheduled {
|
|
attempt,
|
|
remaining,
|
|
next_attempt_in,
|
|
} => {
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
attempt,
|
|
remaining,
|
|
next_attempt_in_secs = next_attempt_in.as_secs_f64(),
|
|
"No missing events recovered - retry scheduled with backoff"
|
|
);
|
|
}
|
|
AttemptOutcome::Expired {
|
|
remaining,
|
|
attempts,
|
|
} => {
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
remaining,
|
|
attempts,
|
|
"Missing-event recovery expired by policy after repeated zero-progress attempts - relay remains ConnectedHistoricSyncFailures until daily sync"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Confirm a completed batch by moving items to RelayState
|
|
///
|
|
/// This method is used by both sync paths (REQ+EOSE and Negentropy) to
|
|
/// move repos and root_events from pending to confirmed state. This unified
|
|
/// flow ensures consistent state tracking regardless of sync method.
|
|
///
|
|
/// For generic filter batches (identified by empty repos and root_events),
|
|
/// this sets the announcements_synced flag to enable incremental sync on reconnect.
|
|
///
|
|
/// # Arguments
|
|
/// * `relay_url` - The relay URL the batch belongs to
|
|
/// * `batch` - The completed batch to confirm
|
|
async fn confirm_batch(&self, relay_url: &str, batch: PendingBatch) {
|
|
let batch_id = batch.batch_id;
|
|
let full_repos_count = batch.items.repos.len();
|
|
let state_only_repos_count = batch.items.state_only_repos.len();
|
|
let events_count = batch.items.root_events.len();
|
|
let sync_method = batch.sync_method;
|
|
let is_generic_filter =
|
|
full_repos_count == 0 && state_only_repos_count == 0 && events_count == 0;
|
|
|
|
let mut relay_index = self.relay_sync_index.write().await;
|
|
|
|
if let Some(state) = relay_index.get_mut(relay_url) {
|
|
// A completed Full batch upgrades any earlier StateOnly
|
|
// confirmation. A late StateOnly completion must not downgrade a
|
|
// repository whose Full batch already completed.
|
|
state
|
|
.state_only_repos
|
|
.retain(|repo| !batch.items.repos.contains(repo));
|
|
state.repos.extend(batch.items.repos);
|
|
state.state_only_repos.extend(
|
|
batch
|
|
.items
|
|
.state_only_repos
|
|
.into_iter()
|
|
.filter(|repo| !state.repos.contains(repo)),
|
|
);
|
|
// Move root_events to confirmed
|
|
state.root_events.extend(batch.items.root_events.clone());
|
|
|
|
// Set announcements_synced flag for generic filter batches
|
|
if is_generic_filter {
|
|
state.announcements_synced = true;
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
batch_id = batch_id,
|
|
sync_method = ?sync_method,
|
|
"Generic filter (announcements) historic sync complete - announcements_synced set to true"
|
|
);
|
|
|
|
// Provide helpful feedback for bootstrap relay
|
|
if state.is_bootstrap {
|
|
let announcement_count = events_count;
|
|
if announcement_count == 0 {
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
domain = %self.config.domain,
|
|
"Bootstrap sync found no announcements for domain - verify domain is correct or try different bootstrap relay"
|
|
);
|
|
} else {
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
domain = %self.config.domain,
|
|
announcement_count,
|
|
"Bootstrap sync discovered announcements for domain"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Track if this batch failed (for ConnectedDegraded transition)
|
|
if batch.failed {
|
|
state.historic_sync_had_failures = true;
|
|
// Failures unrelated to a relay's pending missing-event
|
|
// recovery mean full recovery must not restore its health.
|
|
self.missing_event_recovery
|
|
.lock()
|
|
.unwrap()
|
|
.note_failed_batch(relay_url, batch_id);
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
batch_id = batch_id,
|
|
"Batch failed - will transition to ConnectedHistoricSyncFailures instead of Connected"
|
|
);
|
|
}
|
|
|
|
// DEBUG TRACING: Log the root events being confirmed
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
batch_id = batch_id,
|
|
sync_method = ?sync_method,
|
|
full_repos_confirmed = full_repos_count,
|
|
state_only_repos_confirmed = state_only_repos_count,
|
|
root_events_confirmed = events_count,
|
|
root_events_ids = ?batch.items.root_events.iter().map(|id| id.to_hex()).collect::<Vec<_>>(),
|
|
total_full_repos = state.repos.len(),
|
|
total_state_only_repos = state.state_only_repos.len(),
|
|
total_root_events = state.root_events.len(),
|
|
all_root_events = ?state.root_events.iter().map(|id| id.to_hex()).collect::<Vec<_>>(),
|
|
is_generic_filter = is_generic_filter,
|
|
announcements_synced = state.announcements_synced,
|
|
had_failures = state.historic_sync_had_failures,
|
|
"Batch confirmed - items moved from pending to confirmed"
|
|
);
|
|
} else {
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
batch_id = batch_id,
|
|
"Batch completed but no RelayState found for relay"
|
|
);
|
|
}
|
|
|
|
// Release lock before checking if historic sync is complete
|
|
drop(relay_index);
|
|
|
|
notify_deferred_consolidation_after_batch_completion(
|
|
&self.deferred_consolidations,
|
|
self.deferred_consolidation_tx.as_ref(),
|
|
relay_url,
|
|
);
|
|
|
|
// Spawn background task to check if historic sync is complete
|
|
// This avoids blocking the confirm_batch flow for 6 seconds
|
|
let relay_url = relay_url.to_string();
|
|
let pending_index = self.pending_sync_index.clone();
|
|
let relay_index = self.relay_sync_index.clone();
|
|
let metrics = self.metrics.clone();
|
|
|
|
tokio::spawn(async move {
|
|
Self::check_and_complete_historic_sync_impl(
|
|
&relay_url,
|
|
pending_index,
|
|
relay_index,
|
|
metrics,
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
|
|
/// Check if historic sync is complete and transition to Connected status
|
|
///
|
|
/// This method uses a double-check pattern to avoid race conditions with
|
|
/// the self-subscriber's batching window. The sequence is:
|
|
///
|
|
/// 1. First check: Are there pending batches?
|
|
/// 2. Wait for batch window + buffer (6 seconds)
|
|
/// 3. Second check: Are there still no pending batches?
|
|
/// 4. If still no pending batches, transition to Connected
|
|
///
|
|
/// This ensures that events received just before the first check have time
|
|
/// to be batched and create Layer 2/3 filters before we mark sync complete.
|
|
///
|
|
/// The 6-second delay is based on:
|
|
/// - Self-subscriber batch window: 5 seconds (200ms when `NGIT_TEST=1`)
|
|
/// - Buffer for processing: 1 second
|
|
///
|
|
/// Called after each batch is confirmed to detect completion.
|
|
/// Spawned as a background task to avoid blocking the confirm_batch flow.
|
|
async fn check_and_complete_historic_sync_impl(
|
|
relay_url: &str,
|
|
pending_index: PendingSyncIndex,
|
|
relay_index: RelaySyncIndex,
|
|
metrics: Option<SyncMetrics>,
|
|
) {
|
|
// First check: Are there any pending batches?
|
|
let has_pending = {
|
|
let pending = pending_index.read().await;
|
|
pending
|
|
.get(relay_url)
|
|
.is_some_and(|batches| !batches.is_empty())
|
|
};
|
|
|
|
if has_pending {
|
|
// Still syncing, don't transition yet
|
|
return;
|
|
}
|
|
|
|
// Wait for self-subscriber batch window + buffer to catch any in-flight events
|
|
// that might create new Layer 2/3 filters
|
|
tokio::time::sleep(Duration::from_millis(6000)).await;
|
|
|
|
// Second check: Are there still no pending batches?
|
|
let has_pending = {
|
|
let pending = pending_index.read().await;
|
|
pending
|
|
.get(relay_url)
|
|
.is_some_and(|batches| !batches.is_empty())
|
|
};
|
|
|
|
if has_pending {
|
|
// New batches appeared during the wait - still syncing
|
|
return;
|
|
}
|
|
|
|
// No pending batches after waiting - safe to transition to Connected or ConnectedDegraded
|
|
let mut relay_index_guard = relay_index.write().await;
|
|
if let Some(state) = relay_index_guard.get_mut(relay_url) {
|
|
if state.connection_status == ConnectionStatus::Syncing {
|
|
// Check if any batches failed during historic sync
|
|
let new_status = if state.historic_sync_had_failures {
|
|
ConnectionStatus::ConnectedHistoricSyncFailures
|
|
} else {
|
|
ConnectionStatus::Connected
|
|
};
|
|
|
|
state.connection_status = new_status;
|
|
state.historic_sync_completed = true;
|
|
state.historic_sync_completed_at = Some(Timestamp::now());
|
|
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
full_repos_synced = state.repos.len(),
|
|
state_only_repos_synced = state.state_only_repos.len(),
|
|
root_events_synced = state.root_events.len(),
|
|
had_failures = state.historic_sync_had_failures,
|
|
status = ?new_status,
|
|
"Historic sync complete - transitioned to {} status",
|
|
if state.historic_sync_had_failures { "ConnectedHistoricSyncFailures" } else { "Connected" }
|
|
);
|
|
|
|
// Update metrics
|
|
if let Some(ref metrics) = metrics {
|
|
metrics.record_connection_status(relay_url, new_status);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Perform a daily sync for a specific relay
|
|
///
|
|
/// This method:
|
|
/// - Unsubscribes from all current subscriptions on the relay
|
|
/// - Clears pending batches for this relay
|
|
/// - Clears sync state (repos and root_events) in RelayState
|
|
/// - Recomputes actions to re-discover all repos/events
|
|
///
|
|
/// This is triggered by the daily timer to detect state drift over time.
|
|
async fn daily_sync(&mut self, relay_url: &str) {
|
|
tracing::info!(relay = %relay_url, "Starting daily sync");
|
|
self.cancel_deferred_consolidation(relay_url, "daily sync reset");
|
|
|
|
// Get connection
|
|
let connection = match self.connections.get(relay_url) {
|
|
Some(conn) => conn,
|
|
None => {
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
"No connection for relay, skipping daily sync"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Unsubscribe all current subscriptions
|
|
connection.unsubscribe_all().await;
|
|
|
|
// Daily sync re-discovers everything from scratch, superseding any
|
|
// pending missing-event recovery for this relay.
|
|
let dropped_recovery_ids = self
|
|
.missing_event_recovery
|
|
.lock()
|
|
.unwrap()
|
|
.clear_relay(relay_url);
|
|
if dropped_recovery_ids > 0 {
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
dropped_missing_ids = dropped_recovery_ids,
|
|
"Daily sync reset pending missing-event recovery"
|
|
);
|
|
}
|
|
|
|
// Clear pending batches for this relay
|
|
{
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
pending.remove(relay_url);
|
|
}
|
|
|
|
// Get relay state and clear sync state (repos and root_events)
|
|
{
|
|
let mut index = self.relay_sync_index.write().await;
|
|
if let Some(state) = index.get_mut(relay_url) {
|
|
let repos_cleared = state.repos.len() + state.state_only_repos.len();
|
|
let events_cleared = state.root_events.len();
|
|
state.clear_sync_state();
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
repos_cleared = repos_cleared,
|
|
events_cleared = events_cleared,
|
|
"Cleared sync state for daily sync"
|
|
);
|
|
}
|
|
}
|
|
|
|
// maybe we just run start fresh with a daily flag? make sture so start layer 1 filters
|
|
self.fresh_start(relay_url).await;
|
|
|
|
// if let Some(ref metrics) = self.metrics {
|
|
// metrics.record_event(event_source::DAILY);
|
|
// }
|
|
|
|
// tracing::info!(relay = %relay_url, "Daily sync complete");
|
|
}
|
|
|
|
/// Run the sync manager
|
|
///
|
|
/// Coordinates all sync components:
|
|
/// 1. Spawns self-subscriber to monitor own relay for announcements
|
|
/// 2. Spawns daily timer for periodic fresh syncs
|
|
/// 3. Connects to bootstrap relay if configured
|
|
/// 4. Handles relay actions from self-subscriber
|
|
/// 5. Handles disconnect/EOSE notifications and connection-worker results
|
|
pub async fn run(mut self) {
|
|
use tokio::sync::mpsc;
|
|
|
|
tracing::info!(
|
|
bootstrap_relay = ?self.bootstrap_relay_url,
|
|
service_domain = %self.service_domain,
|
|
"SyncManager starting"
|
|
);
|
|
|
|
// 1. Create action channel for self-subscriber -> manager communication
|
|
let (action_tx, mut action_rx) = mpsc::channel::<AddFilters>(100);
|
|
|
|
// 2. Create disconnect channel for spawned tasks -> manager communication
|
|
let (disconnect_tx, mut disconnect_rx) = mpsc::channel::<DisconnectNotification>(100);
|
|
|
|
// 3. Create EOSE channel for spawned tasks -> manager communication
|
|
let (eose_tx, mut eose_rx) = mpsc::channel::<EoseNotification>(100);
|
|
|
|
// 4. Connection workers never mutate manager state. Their unbounded
|
|
// result channel cannot make a completed worker wait behind the actor.
|
|
let (connect_attempt_result_tx, mut connect_attempt_result_rx) =
|
|
mpsc::unbounded_channel::<ConnectAttemptResult>();
|
|
|
|
// Batch completion can be synchronous, so use a non-blocking wakeup
|
|
// instead of waiting for pending work while holding the actor lock.
|
|
let (deferred_consolidation_tx, mut deferred_consolidation_rx) =
|
|
mpsc::unbounded_channel::<String>();
|
|
|
|
// 4b. Create shutdown broadcast channel for graceful shutdown
|
|
let (shutdown_tx, _shutdown_rx) = broadcast::channel(1);
|
|
|
|
// 5. Spawn self-subscriber with shutdown receiver
|
|
let self_subscriber = SelfSubscriber::new(
|
|
format!("ws://{}", self.config.bind_address),
|
|
self.service_domain.clone(),
|
|
Arc::clone(&self.repo_sync_index),
|
|
action_tx,
|
|
self.database.clone(),
|
|
);
|
|
let subscriber_shutdown = shutdown_tx.subscribe();
|
|
tokio::spawn(async move { self_subscriber.run(Some(subscriber_shutdown)).await });
|
|
|
|
// 5b. Store channel senders for use by handlers
|
|
self.disconnect_tx = Some(disconnect_tx.clone());
|
|
self.eose_tx = Some(eose_tx.clone());
|
|
self.connect_attempt_result_tx = Some(connect_attempt_result_tx);
|
|
self.deferred_consolidation_tx = Some(deferred_consolidation_tx);
|
|
self.shutdown_tx = Some(shutdown_tx.clone());
|
|
|
|
// 6. Connect to bootstrap relay if configured
|
|
if let Some(ref bootstrap_url) = self.bootstrap_relay_url.clone() {
|
|
match canonical_relay_key(bootstrap_url) {
|
|
Ok(relay_url) => {
|
|
if self.register_relay(relay_url.clone(), true).await {
|
|
self.schedule_connect_relay(&relay_url).await;
|
|
}
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
relay = %bootstrap_url,
|
|
error = %error,
|
|
"Rejecting invalid bootstrap relay target"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 7. Wrap self in Arc<Mutex> for sharing with timer task
|
|
let sync_manager = Arc::new(Mutex::new(self));
|
|
|
|
// 8. Spawn daily timer task with shutdown receiver
|
|
let timer_manager = Arc::clone(&sync_manager);
|
|
let timer_shutdown = shutdown_tx.subscribe();
|
|
tokio::spawn(async move {
|
|
run_daily_timer(timer_manager, timer_shutdown).await;
|
|
});
|
|
|
|
// 9. Spawn health and metrics checker task with shutdown receiver
|
|
// This combines disconnect checking, rate limit recovery, and metrics updates
|
|
let checker_manager = Arc::clone(&sync_manager);
|
|
let checker_shutdown = shutdown_tx.subscribe();
|
|
tokio::spawn(async move {
|
|
run_health_and_metrics_checker(checker_manager, checker_shutdown).await;
|
|
});
|
|
|
|
// 10. Spawn rejected events index cleanup task
|
|
// Hot cache cleanup every 60s, cold index cleanup daily
|
|
let cleanup_manager = Arc::clone(&sync_manager);
|
|
let cleanup_shutdown = shutdown_tx.subscribe();
|
|
tokio::spawn(async move {
|
|
run_rejected_index_cleanup(cleanup_manager, cleanup_shutdown).await;
|
|
});
|
|
|
|
// 11. Spawn purgatory announcement sync timer (every 5s)
|
|
// Ensures purgatory announcements (including user-submitted ones that never
|
|
// touch the DB) are registered in repo_sync_index as StateOnly so that
|
|
// state event subscriptions are established on their listed relay URLs.
|
|
let purgatory_sync_manager = Arc::clone(&sync_manager);
|
|
let purgatory_sync_shutdown = shutdown_tx.subscribe();
|
|
tokio::spawn(async move {
|
|
run_purgatory_announcement_sync(purgatory_sync_manager, purgatory_sync_shutdown).await;
|
|
});
|
|
|
|
// 12. Main loop - handle actions, lifecycle notifications, and worker results
|
|
loop {
|
|
// Wait for an event without holding the lock
|
|
tokio::select! {
|
|
action = action_rx.recv() => {
|
|
match action {
|
|
Some(add_filters) => {
|
|
// SelfSubscriber actions are dirty-relay signals.
|
|
// Recompute against current pending and confirmed
|
|
// state instead of trusting a queued full-index
|
|
// snapshot that may already be stale.
|
|
let mut manager = sync_manager.lock().await;
|
|
manager
|
|
.recompute_new_sync_filters_for_relay(&add_filters.relay_url)
|
|
.await;
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
disconnect = disconnect_rx.recv() => {
|
|
match disconnect {
|
|
Some(notification) => {
|
|
// Acquire lock to process disconnect
|
|
let mut manager = sync_manager.lock().await;
|
|
manager.handle_disconnect(¬ification.relay_url).await;
|
|
}
|
|
None => {
|
|
// All disconnect senders dropped - unlikely but handle gracefully
|
|
tracing::debug!("Disconnect channel closed");
|
|
}
|
|
}
|
|
}
|
|
eose = eose_rx.recv() => {
|
|
match eose {
|
|
Some(notification) => {
|
|
// Acquire lock to process EOSE
|
|
let mut manager = sync_manager.lock().await;
|
|
manager.handle_eose(¬ification.relay_url, notification.sub_id).await;
|
|
}
|
|
None => {
|
|
// All EOSE senders dropped - unlikely but handle gracefully
|
|
tracing::debug!("EOSE channel closed");
|
|
}
|
|
}
|
|
}
|
|
result = connect_attempt_result_rx.recv() => {
|
|
match result {
|
|
Some(result) => {
|
|
// Connection state changes remain serialized through the actor.
|
|
let mut manager = sync_manager.lock().await;
|
|
manager.handle_connect_attempt_result(result).await;
|
|
}
|
|
None => {
|
|
tracing::debug!("Connect attempt result channel closed");
|
|
}
|
|
}
|
|
}
|
|
relay_url = deferred_consolidation_rx.recv() => {
|
|
if let Some(relay_url) = relay_url {
|
|
let mut manager = sync_manager.lock().await;
|
|
manager.process_deferred_consolidation(&relay_url).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle AddFilters action - subscribe to filters on a relay
|
|
///
|
|
/// This method handles all filter additions:
|
|
/// - For new relays: creates entry with Connecting status, spawns connection
|
|
/// - For existing connected relays: subscribes to filters, creates PendingBatch
|
|
/// - For disconnected/connecting relays: returns (will be handled on connection)
|
|
async fn handle_new_sync_filters(&mut self, mut action: AddFilters) {
|
|
action.relay_url = match canonical_relay_key(&action.relay_url) {
|
|
Ok(relay_url) => relay_url,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
relay = %action.relay_url,
|
|
error = %error,
|
|
"Rejecting invalid sync relay target"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Targets already rejected by the outbound policy stay in the sync
|
|
// indexes (they come from stored events); skip them quietly instead
|
|
// of re-rejecting on every recomputation.
|
|
if self.rejected_relay_targets.contains(&action.relay_url) {
|
|
tracing::debug!(
|
|
relay = %action.relay_url,
|
|
"Skipping relay target rejected by outbound policy"
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Step 1: Check if relay exists in relay_sync_index
|
|
let connection_status = {
|
|
let index = self.relay_sync_index.read().await;
|
|
index.get(&action.relay_url).map(|s| s.connection_status)
|
|
};
|
|
|
|
match connection_status {
|
|
None => {
|
|
// New relay - register and connect
|
|
tracing::info!(
|
|
relay = %action.relay_url,
|
|
full_repos = action.items.repos.len(),
|
|
state_only_repos = action.items.state_only_repos.len(),
|
|
"Registering and connecting to new relay"
|
|
);
|
|
|
|
// Register relay (creates RelayConnection, initializes RelayState, updates metrics)
|
|
if self.register_relay(action.relay_url.clone(), false).await {
|
|
self.schedule_connect_relay(&action.relay_url).await;
|
|
}
|
|
// Connection will trigger handle_connect_or_reconnect which will process items
|
|
return;
|
|
}
|
|
Some(ConnectionStatus::Disconnected)
|
|
| Some(ConnectionStatus::Connecting)
|
|
| Some(ConnectionStatus::Disconnecting) => {
|
|
// Will be handled when connection succeeds (or ignored if disconnecting)
|
|
tracing::debug!(
|
|
relay = %action.relay_url,
|
|
status = ?connection_status,
|
|
"Relay not connected, action will be processed on connection"
|
|
);
|
|
return;
|
|
}
|
|
Some(ConnectionStatus::Syncing)
|
|
| Some(ConnectionStatus::Connected)
|
|
| Some(ConnectionStatus::ConnectedHistoricSyncFailures) => {
|
|
// Continue to subscribe - live sync is active, can accept new filters
|
|
}
|
|
}
|
|
|
|
// Step 2: Check if relay is rate-limited before creating new pending items
|
|
if self.health_tracker.is_rate_limited(&action.relay_url) {
|
|
tracing::debug!(
|
|
relay = %action.relay_url,
|
|
full_repos = action.items.repos.len(),
|
|
state_only_repos = action.items.state_only_repos.len(),
|
|
root_events = action.items.root_events.len(),
|
|
"Skipping AddFilters for rate-limited relay, will recompute after cooldown"
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Step 3: Check if consolidation is needed BEFORE adding new filters
|
|
self.maybe_consolidate(
|
|
&action.relay_url,
|
|
grouped_subscription_count(action.filters.len()),
|
|
)
|
|
.await;
|
|
|
|
// Subscribe to each filter and collect subscription IDs
|
|
tracing::info!(
|
|
relay = %action.relay_url,
|
|
filter_count = action.filters.len(),
|
|
full_repo_count = action.items.repos.len(),
|
|
state_only_repo_count = action.items.state_only_repos.len(),
|
|
root_event_count = action.items.root_events.len(),
|
|
"handle_add_filters: calling sync_live and historic_sync"
|
|
);
|
|
|
|
self.sync_live(&action.relay_url, &action.filters).await;
|
|
self.historic_sync(&action.relay_url, action.filters, action.items, None)
|
|
.await;
|
|
}
|
|
|
|
/// Handle a connection success (called when a relay connects or reconnects)
|
|
///
|
|
/// This method:
|
|
/// 1. Updates RelayState to Connected
|
|
/// 2. Spawns event loop (MUST happen on every connection/reconnect)
|
|
/// 3. Dispatches to appropriate reconnection strategy based on disconnect time
|
|
async fn handle_connect_or_reconnect(&mut self, relay_url: &str) {
|
|
use tokio::sync::mpsc;
|
|
|
|
// 1. Capture old last_connected BEFORE updating state
|
|
// This is critical for correct first-connection detection
|
|
let old_last_connected = {
|
|
let index = self.relay_sync_index.read().await;
|
|
index.get(relay_url).and_then(|s| s.last_connected)
|
|
};
|
|
|
|
// 2. Update state to Syncing (will transition to Connected after historic sync completes)
|
|
{
|
|
let mut index = self.relay_sync_index.write().await;
|
|
let state = index.entry(relay_url.to_string()).or_default();
|
|
state.connection_status = ConnectionStatus::Syncing;
|
|
state.last_connected = Some(Timestamp::now());
|
|
state.disconnected_at = None;
|
|
}
|
|
|
|
// Update metrics - record as syncing initially
|
|
if let Some(ref metrics) = self.metrics {
|
|
metrics.record_connection_status(relay_url, ConnectionStatus::Syncing);
|
|
metrics.inc_connected_count();
|
|
}
|
|
|
|
// 2. SPAWN EVENT LOOP (moved from spawn_relay_connection)
|
|
// This MUST happen on every connection (initial or reconnect)
|
|
// because event loops die on disconnect and cannot be reused
|
|
let connection = match self.connections.get(relay_url) {
|
|
Some(c) => c.clone(),
|
|
None => {
|
|
tracing::error!(relay = %relay_url, "No RelayConnection found for connected relay");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let (event_tx, mut event_rx) = mpsc::channel::<RelayEvent>(1000);
|
|
|
|
// Spawn event loop task
|
|
let relay_url_for_loop = relay_url.to_string();
|
|
tokio::spawn(async move {
|
|
connection.run_event_loop(event_tx).await;
|
|
tracing::debug!(relay = %relay_url_for_loop, "Event loop terminated");
|
|
});
|
|
|
|
// Spawn event processor task
|
|
let relay_url_clone = relay_url.to_string();
|
|
let database = Arc::clone(&self.database);
|
|
let write_policy = self.write_policy.clone();
|
|
let local_relay = self.local_relay.clone();
|
|
let disconnect_tx = self.disconnect_tx.as_ref().unwrap().clone();
|
|
let eose_tx = self.eose_tx.as_ref().unwrap().clone();
|
|
let metrics_clone = self.metrics.clone();
|
|
let pending_sync_index = Arc::clone(&self.pending_sync_index);
|
|
let health_tracker = Arc::clone(&self.health_tracker);
|
|
let rejected_events_index = Arc::clone(&self.rejected_events_index);
|
|
|
|
tokio::spawn(async move {
|
|
let mut disconnect_sent = false;
|
|
|
|
while let Some(relay_event) = event_rx.recv().await {
|
|
match relay_event {
|
|
RelayEvent::Event(event, subscription_id) => {
|
|
// Skip events we've already rejected (announcements only)
|
|
if (event.kind == Kind::GitRepoAnnouncement
|
|
|| event.kind == Kind::RepoState)
|
|
&& rejected_events_index.contains(&event.id)
|
|
{
|
|
tracing::trace!(
|
|
event_id = %event.id,
|
|
kind = %event.kind.as_u16(),
|
|
relay = %relay_url_clone,
|
|
"Skipping previously rejected announcement event"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let result = Self::process_event_static(
|
|
&event,
|
|
&relay_url_clone,
|
|
&database,
|
|
&write_policy,
|
|
&local_relay,
|
|
&rejected_events_index,
|
|
crate::nostr::persistence::SaveContext::RelaySync,
|
|
)
|
|
.await;
|
|
// Only record metric when event is actually saved
|
|
if result == ProcessResult::Saved {
|
|
if let Some(ref metrics) = metrics_clone {
|
|
metrics.record_synced_event();
|
|
}
|
|
}
|
|
|
|
// For sync-triggered events that go to purgatory, trigger immediate sync
|
|
// (instead of the default 3-minute delay for user-submitted events)
|
|
//
|
|
// Note: announcement events (kind 30617) are registered in repo_sync_index
|
|
// by the purgatory announcement sync timer (run_purgatory_announcement_sync)
|
|
// rather than inline here.
|
|
if result == ProcessResult::Purgatory {
|
|
// State events (kind 30618) - extract identifier and trigger immediate sync
|
|
if event.kind.as_u16() == 30618 {
|
|
if let Some(identifier) = event.tags.iter().find_map(|tag| {
|
|
let tag_vec = tag.clone().to_vec();
|
|
if tag_vec.len() >= 2 && tag_vec[0] == "d" {
|
|
Some(tag_vec[1].clone())
|
|
} else {
|
|
None
|
|
}
|
|
}) {
|
|
tracing::debug!(
|
|
event_id = %event.id,
|
|
identifier = %identifier,
|
|
"Triggering immediate sync for synced state event in purgatory"
|
|
);
|
|
write_policy.purgatory().enqueue_sync_immediate(&identifier);
|
|
}
|
|
}
|
|
// PR events (kind 1617/1618) - extract identifier from 'a' tag
|
|
else if event.kind.as_u16() == 1617 || event.kind.as_u16() == 1618 {
|
|
if let Some(identifier) =
|
|
crate::git::sync::extract_identifier_from_pr_event(&event)
|
|
{
|
|
tracing::debug!(
|
|
event_id = %event.id,
|
|
identifier = %identifier,
|
|
"Triggering immediate sync for synced PR event in purgatory"
|
|
);
|
|
write_policy.purgatory().enqueue_sync_immediate(&identifier);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Track pagination state for this subscription (REQ+EOSE)
|
|
// and received event IDs for negentropy batches
|
|
if result == ProcessResult::Saved || result == ProcessResult::Duplicate {
|
|
let mut pending = pending_sync_index.write().await;
|
|
if let Some(batches) = pending.get_mut(&relay_url_clone) {
|
|
for batch in batches.iter_mut() {
|
|
// Track pagination state (REQ+EOSE path)
|
|
if let Some(state) =
|
|
batch.pagination_state.get_mut(&subscription_id)
|
|
{
|
|
state.record_event(&event);
|
|
}
|
|
|
|
// Track received event IDs (negentropy path)
|
|
// Only track if this batch has requested_event_ids set
|
|
// and the subscription is one we're waiting on
|
|
if batch.requested_event_ids.is_some()
|
|
&& batch.outstanding_subs.contains(&subscription_id)
|
|
{
|
|
if let Some(ref mut received) = batch.received_event_ids {
|
|
received.insert(event.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
RelayEvent::EndOfStoredEvents(sub_id) => {
|
|
tracing::debug!(
|
|
relay = %relay_url_clone,
|
|
sub_id = %sub_id,
|
|
"EOSE received, notifying SyncManager"
|
|
);
|
|
let _ = eose_tx
|
|
.send(EoseNotification {
|
|
relay_url: relay_url_clone.clone(),
|
|
sub_id,
|
|
})
|
|
.await;
|
|
}
|
|
RelayEvent::Notice(notice) => {
|
|
// Check for rate limiting indicators
|
|
let notice_lower = notice.to_lowercase();
|
|
let is_rate_limit = (notice_lower.contains("rate")
|
|
&& notice_lower.contains("limit"))
|
|
|| notice_lower.contains("too many")
|
|
|| notice_lower.contains("slow down")
|
|
|| notice_lower.contains("throttl");
|
|
|
|
if is_rate_limit {
|
|
tracing::warn!(
|
|
relay = %relay_url_clone,
|
|
notice = %notice,
|
|
"Rate limiting NOTICE detected from relay"
|
|
);
|
|
|
|
// Mark relay as rate limited
|
|
health_tracker.record_rate_limit(&relay_url_clone);
|
|
|
|
// Update metrics with new health state
|
|
if let Some(ref metrics) = metrics_clone {
|
|
let state = health_tracker.get_state(&relay_url_clone);
|
|
metrics.record_health_state(&relay_url_clone, state);
|
|
}
|
|
} else {
|
|
// Log at TRACE level to avoid duplicate with nostr_relay_pool's DEBUG log
|
|
// (nostr-sdk already logs all NOTICE messages at DEBUG level)
|
|
tracing::trace!(
|
|
relay = %relay_url_clone,
|
|
notice = %notice,
|
|
"Relay issued notice"
|
|
);
|
|
}
|
|
}
|
|
RelayEvent::Closed(reason) => {
|
|
// CLOSED message means one subscription was closed, not the whole connection
|
|
// This is normal behavior (e.g., when historic_sync completes)
|
|
tracing::debug!(
|
|
relay = %relay_url_clone,
|
|
reason = %reason,
|
|
"Relay closed a subscription (not a connection close)"
|
|
);
|
|
// Don't break - other subscriptions remain active
|
|
// Don't send disconnect - connection is still alive
|
|
}
|
|
RelayEvent::Shutdown => {
|
|
tracing::info!(relay = %relay_url_clone, "Relay shutdown detected");
|
|
if !disconnect_sent {
|
|
let _ = disconnect_tx
|
|
.send(DisconnectNotification {
|
|
relay_url: relay_url_clone.clone(),
|
|
})
|
|
.await;
|
|
disconnect_sent = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If the event channel closed without a Closed/Shutdown event
|
|
if !disconnect_sent {
|
|
tracing::info!(
|
|
relay = %relay_url_clone,
|
|
"Event channel closed, notifying SyncManager of disconnect"
|
|
);
|
|
let _ = disconnect_tx
|
|
.send(DisconnectNotification {
|
|
relay_url: relay_url_clone,
|
|
})
|
|
.await;
|
|
}
|
|
});
|
|
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
"Event loop and processor spawned for connected relay"
|
|
);
|
|
|
|
// 3. Decide reconnection strategy based on OLD last_connected time
|
|
// Use the value captured BEFORE the update to correctly detect first connections
|
|
if let Some(last) = old_last_connected {
|
|
let elapsed = Timestamp::now().as_secs().saturating_sub(last.as_secs());
|
|
if elapsed < QUICK_RECONNECT_WINDOW_SECS {
|
|
// Short disconnect - quick reconnect
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
disconnect_secs = elapsed,
|
|
"Short disconnection - initiating quick_reconnect"
|
|
);
|
|
self.quick_reconnect(relay_url, Timestamp::from(elapsed))
|
|
.await;
|
|
} else {
|
|
// Long disconnect - fresh start
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
disconnect_secs = elapsed,
|
|
"Long disconnection - initiating fresh_start"
|
|
);
|
|
self.fresh_start(relay_url).await;
|
|
}
|
|
} else {
|
|
// First connection - fresh start
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
"First connection - initiating fresh_start"
|
|
);
|
|
self.fresh_start(relay_url).await;
|
|
}
|
|
}
|
|
|
|
/// Fresh start - clears state and does full sync
|
|
///
|
|
/// Called by: initial connect, long_reconnect, daily_sync
|
|
///
|
|
/// Flow:
|
|
/// 1. Clear PendingSyncIndex for this relay
|
|
/// 2. Clear RelaySyncIndex sync state (repos/root_events)
|
|
/// 3. Update connection state to Connected
|
|
/// 4. L1 live + L1 historic (negentropy if available)
|
|
/// 5. compute_actions → AddFilters → sync_computed_filters for L2+L3
|
|
async fn fresh_start(&mut self, relay_url: &str) {
|
|
let _now = Timestamp::now();
|
|
|
|
tracing::info!(relay = %relay_url, "Starting fresh_start");
|
|
self.cancel_deferred_consolidation(relay_url, "fresh start");
|
|
|
|
// Step 1: Clear PendingSyncIndex for this relay
|
|
{
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
if pending.remove(relay_url).is_some() {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"Cleared pending batches in fresh_start"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Step 2: Clear RelaySyncIndex sync state (but preserve connection metadata)
|
|
{
|
|
let mut index = self.relay_sync_index.write().await;
|
|
if let Some(state) = index.get_mut(relay_url) {
|
|
let repos_cleared = state.repos.len() + state.state_only_repos.len();
|
|
let events_cleared = state.root_events.len();
|
|
state.clear_sync_state();
|
|
if repos_cleared > 0 || events_cleared > 0 {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
repos_cleared = repos_cleared,
|
|
events_cleared = events_cleared,
|
|
"Cleared sync state in fresh_start"
|
|
);
|
|
}
|
|
// Only sync if we're connected (live sync active)
|
|
if state.connection_status.is_live_sync_active() {
|
|
drop(index);
|
|
self.sync_generic_filters(relay_url, None).await;
|
|
// Step 5: compute_actions for L2+L3 (will be triggered by EOSE)
|
|
self.recompute_new_sync_filters_for_relay(relay_url).await;
|
|
}
|
|
} else {
|
|
drop(index);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn sync_generic_filters(&mut self, relay_url: &str, since: Option<Timestamp>) {
|
|
let filters = vec![filters::build_announcement_filter(None)];
|
|
|
|
// Create live subscription for ongoing announcements
|
|
let _sub_ids = self.sync_live(relay_url, &filters).await;
|
|
|
|
// Use historic_sync with empty PendingItems for generic filters
|
|
// Generic filters (announcements) don't have associated repos or root_events
|
|
let items = PendingItems::default();
|
|
let _batch_id = self.historic_sync(relay_url, filters, items, since).await;
|
|
}
|
|
|
|
/// Quick reconnect - for disconnections < 15 minutes
|
|
///
|
|
/// Re-establishes subscriptions after a brief disconnection by:
|
|
/// 1. Clearing stale PendingSyncIndex entries
|
|
/// 2. Syncing L1 filters with since timestamp (announcements)
|
|
/// 3. Rebuilding L2+L3 from preserved RelaySyncIndex state
|
|
/// 4. Computing actions for new items discovered during catchup
|
|
///
|
|
/// Basic connection state and metrics are managed by handle_connect_or_reconnect.
|
|
/// This method handles reconnect-specific concerns (health tracking, reconnect metrics).
|
|
async fn quick_reconnect(&mut self, relay_url: &str, since: Timestamp) {
|
|
self.cancel_deferred_consolidation(relay_url, "quick reconnect");
|
|
|
|
// Step 1: Clear PendingSyncIndex for this relay
|
|
// Old subscriptions are dead after disconnect
|
|
{
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
pending.remove(relay_url);
|
|
}
|
|
|
|
// Record successful reconnection in health tracker
|
|
self.health_tracker.record_success(relay_url);
|
|
|
|
// Record reconnect-specific metrics (not basic connection metrics)
|
|
if let Some(ref metrics) = self.metrics {
|
|
metrics.record_health_state(relay_url, self.health_tracker.get_state(relay_url));
|
|
}
|
|
|
|
// Step 2: L1 live + L1 historic with since filter (or full sync if announcements never completed)
|
|
let announcement_since = {
|
|
let index = self.relay_sync_index.read().await;
|
|
if let Some(state) = index.get(relay_url) {
|
|
if state.announcements_synced {
|
|
Some(since) // Can use incremental sync
|
|
} else {
|
|
None // Need full sync - announcements never completed
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
self.sync_generic_filters(relay_url, announcement_since)
|
|
.await;
|
|
|
|
// Step 3: Rebuild L2+L3 from confirmed state with since filter
|
|
// This uses the preserved repos/root_events from RelaySyncIndex
|
|
self.rebuild_layer2_and_layer3(relay_url, Some(since)).await;
|
|
|
|
// Step 4: compute_actions for any NEW items discovered while disconnected
|
|
self.recompute_new_sync_filters_for_relay(relay_url).await;
|
|
}
|
|
|
|
/// Rebuild level-aware repository subscriptions for a relay
|
|
///
|
|
/// Uses the confirmed repos and root_events from RelayState to build filters.
|
|
/// If since is provided, applies it to all filters for incremental sync.
|
|
///
|
|
/// CRITICAL: This method now creates a PendingBatch to track subscriptions,
|
|
/// ensuring EOSE handling works correctly for live sync scenarios.
|
|
async fn rebuild_layer2_and_layer3(&mut self, relay_url: &str, since: Option<Timestamp>) {
|
|
// Get confirmed state from relay_sync_index
|
|
let (full_repos, state_only_repos, root_events) = {
|
|
let index = self.relay_sync_index.read().await;
|
|
match index.get(relay_url) {
|
|
Some(state) => (
|
|
state.repos.clone(),
|
|
state.state_only_repos.clone(),
|
|
state.root_events.clone(),
|
|
),
|
|
None => {
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
"No RelayState found for rebuild_layer2_and_layer3"
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Nothing to rebuild if no confirmed items
|
|
if full_repos.is_empty() && state_only_repos.is_empty() && root_events.is_empty() {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"No confirmed items to rebuild Layer 2/3 for"
|
|
);
|
|
return;
|
|
}
|
|
|
|
let filters = filters::build_sync_level_aware_filters(
|
|
&full_repos,
|
|
&state_only_repos,
|
|
&root_events,
|
|
since,
|
|
);
|
|
|
|
if filters.is_empty() {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"No filters generated for Layer 2/3 rebuild"
|
|
);
|
|
return;
|
|
}
|
|
self.sync_live(relay_url, &filters).await;
|
|
}
|
|
|
|
/// Register a relay for managed connection/reconnection
|
|
///
|
|
/// Creates a RelayConnection object and stores it in the connections HashMap.
|
|
/// Also initializes RelayState if it doesn't exist.
|
|
/// Does NOT connect - connection happens via the bounded scheduler.
|
|
/// The RelayConnection persists forever and is reused on reconnects.
|
|
///
|
|
/// Returns `false` when the target was rejected and no connection exists,
|
|
/// so callers must not schedule a connection attempt. Event-directed URLs
|
|
/// are vetted by the outbound target policy; the operator-configured
|
|
/// bootstrap relay is exempt. An event URL identical to an already
|
|
/// registered connection (e.g. the bootstrap relay itself) reuses that
|
|
/// connection rather than being re-authorized, which keeps the configured
|
|
/// exception exact: only URLs that canonicalize to the same key share it.
|
|
async fn register_relay(&mut self, relay_url: String, is_bootstrap: bool) -> bool {
|
|
let relay_url = match canonical_relay_key(&relay_url) {
|
|
Ok(relay_url) => relay_url,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
error = %error,
|
|
"Rejecting invalid relay registration"
|
|
);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Create RelayConnection if not exists
|
|
if !self.connections.contains_key(&relay_url) {
|
|
let policy = self.outbound_target_policy();
|
|
let source = if is_bootstrap {
|
|
RelayTargetSource::OperatorConfigured
|
|
} else {
|
|
RelayTargetSource::EventDirected
|
|
};
|
|
|
|
// Refuse to even register unauthorized event-directed targets so
|
|
// they never enter the reconnect lifecycle. The connection worker
|
|
// re-checks (with DNS vetting) immediately before every dial.
|
|
if source == RelayTargetSource::EventDirected {
|
|
if let Err(reason) = policy.authorize(OutboundTargetKind::EventRelay, &relay_url) {
|
|
// Warn once per target; sync passes keep re-deriving the
|
|
// same URLs from stored events.
|
|
if self.rejected_relay_targets.insert(relay_url.clone()) {
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
reason = %reason,
|
|
"Rejecting event-directed sync relay target"
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Get relay owner keys for NIP-42 authentication
|
|
let keys = self
|
|
.config
|
|
.relay_owner_keys()
|
|
.expect("relay_owner_keys should be available");
|
|
|
|
let connection = RelayConnection::new_with_database(
|
|
relay_url.clone(),
|
|
Arc::clone(&self.database),
|
|
keys,
|
|
source,
|
|
policy,
|
|
);
|
|
self.connections.insert(relay_url.clone(), connection);
|
|
tracing::debug!(relay = %relay_url, "Registered new relay connection");
|
|
}
|
|
|
|
// Initialize RelayState if not exists
|
|
let is_new = {
|
|
let mut index = self.relay_sync_index.write().await;
|
|
if !index.contains_key(&relay_url) {
|
|
let new_state = RelayState {
|
|
connection_status: ConnectionStatus::Disconnected,
|
|
is_bootstrap,
|
|
last_connected: None,
|
|
disconnected_at: None,
|
|
repos: HashSet::new(),
|
|
state_only_repos: HashSet::new(),
|
|
root_events: HashSet::new(),
|
|
announcements_synced: false,
|
|
historic_sync_completed: false,
|
|
historic_sync_completed_at: None,
|
|
historic_sync_had_failures: false,
|
|
};
|
|
index.insert(relay_url.clone(), new_state);
|
|
true
|
|
} else {
|
|
// If relay already exists and is_bootstrap is true, update the flag
|
|
if is_bootstrap {
|
|
if let Some(state) = index.get_mut(&relay_url) {
|
|
state.is_bootstrap = true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
};
|
|
|
|
// Track new relay in metrics
|
|
if is_new {
|
|
if let Some(ref metrics) = self.metrics {
|
|
metrics.inc_tracked_count();
|
|
// Initialize connection status to disconnected
|
|
metrics.set_relay_connected(&relay_url, false);
|
|
}
|
|
tracing::info!(relay = %relay_url, "Registered new relay for tracking");
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
/// Outbound target policy derived from operator configuration.
|
|
fn outbound_target_policy(&self) -> OutboundTargetPolicy {
|
|
OutboundTargetPolicy {
|
|
allow_non_global: self.config.sync_allow_non_global_targets,
|
|
}
|
|
}
|
|
|
|
/// Queue one connection attempt without blocking the sync actor.
|
|
///
|
|
/// DNS and the websocket handshake run in a spawned worker, bounded globally
|
|
/// by `MAX_CONCURRENT_CONNECT_ATTEMPTS`. The actor owns every lifecycle
|
|
/// transition when it receives the worker result.
|
|
async fn schedule_connect_relay(&mut self, relay_url: &str) {
|
|
let relay_url = match canonical_relay_key(relay_url) {
|
|
Ok(relay_url) => relay_url,
|
|
Err(error) => {
|
|
tracing::warn!(relay = %relay_url, error = %error, "Rejecting invalid sync relay target");
|
|
return;
|
|
}
|
|
};
|
|
let Some(result_tx) = self.connect_attempt_result_tx.clone() else {
|
|
tracing::error!(relay = %relay_url, "Connection scheduler is not running");
|
|
return;
|
|
};
|
|
let Some(connection) = self.connections.get(&relay_url).cloned() else {
|
|
tracing::error!(relay = %relay_url, "No RelayConnection registered");
|
|
return;
|
|
};
|
|
|
|
let can_schedule = {
|
|
let mut index = self.relay_sync_index.write().await;
|
|
let can_schedule = index
|
|
.get(&relay_url)
|
|
.is_some_and(|state| state.connection_status == ConnectionStatus::Disconnected);
|
|
if can_schedule {
|
|
index
|
|
.get_mut(&relay_url)
|
|
.expect("relay state checked immediately above")
|
|
.connection_status = ConnectionStatus::Connecting;
|
|
}
|
|
can_schedule
|
|
};
|
|
if !can_schedule {
|
|
tracing::trace!(relay = %relay_url, "Relay does not need another connection attempt");
|
|
return;
|
|
}
|
|
|
|
let token = match reserve_connect_attempt(
|
|
&mut self.in_flight_connect_attempts,
|
|
&mut self.next_connect_attempt_token,
|
|
&relay_url,
|
|
) {
|
|
Some(token) => token,
|
|
None => {
|
|
tracing::trace!(relay = %relay_url, "Connection attempt already queued");
|
|
return;
|
|
}
|
|
};
|
|
|
|
if let Some(ref metrics) = self.metrics {
|
|
metrics.record_connection_status(&relay_url, ConnectionStatus::Connecting);
|
|
}
|
|
|
|
let health_tracker = Arc::clone(&self.health_tracker);
|
|
let semaphore = Arc::clone(&self.connect_attempt_semaphore);
|
|
let timeout = self.health_tracker.base_backoff_secs();
|
|
let Some(mut shutdown_rx) = self.shutdown_tx.as_ref().map(|sender| sender.subscribe())
|
|
else {
|
|
tracing::error!(relay = %relay_url, "Connection scheduler has no shutdown signal");
|
|
return;
|
|
};
|
|
tokio::spawn(async move {
|
|
let permit = tokio::select! {
|
|
permit = begin_connect_attempt(semaphore, health_tracker, &relay_url) => permit,
|
|
_ = shutdown_rx.recv() => return,
|
|
};
|
|
let Some(_permit) = permit else {
|
|
return;
|
|
};
|
|
let outcome = tokio::select! {
|
|
result = connection.connect(timeout) => match result {
|
|
Ok(()) => ConnectAttemptOutcome::Connected,
|
|
Err(error) => ConnectAttemptOutcome::Failed(error),
|
|
},
|
|
_ = shutdown_rx.recv() => {
|
|
connection.disconnect().await;
|
|
return;
|
|
}
|
|
};
|
|
if result_tx
|
|
.send(ConnectAttemptResult {
|
|
relay_url,
|
|
token,
|
|
outcome,
|
|
})
|
|
.is_err()
|
|
{
|
|
connection.disconnect().await;
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn handle_connect_attempt_result(&mut self, result: ConnectAttemptResult) {
|
|
if !take_connect_attempt(
|
|
&mut self.in_flight_connect_attempts,
|
|
&result.relay_url,
|
|
result.token,
|
|
) {
|
|
tracing::debug!(
|
|
relay = %result.relay_url,
|
|
token = result.token.0,
|
|
"Ignoring stale connection attempt result"
|
|
);
|
|
return;
|
|
}
|
|
|
|
let still_connecting = self
|
|
.relay_sync_index
|
|
.read()
|
|
.await
|
|
.get(&result.relay_url)
|
|
.is_some_and(|state| state.connection_status == ConnectionStatus::Connecting);
|
|
if !still_connecting {
|
|
tracing::debug!(
|
|
relay = %result.relay_url,
|
|
token = result.token.0,
|
|
"Ignoring connection result after lifecycle state changed"
|
|
);
|
|
return;
|
|
}
|
|
|
|
match result.outcome {
|
|
ConnectAttemptOutcome::Connected => {
|
|
self.health_tracker.record_success(&result.relay_url);
|
|
if let Some(ref metrics) = self.metrics {
|
|
metrics.record_connection_attempt(&result.relay_url, true);
|
|
}
|
|
self.handle_connect_or_reconnect(&result.relay_url).await;
|
|
}
|
|
ConnectAttemptOutcome::Failed(error) => {
|
|
if let Some(category) = naughty_list::NaughtyListTracker::classify_error(&error) {
|
|
if let Some(ref naughty_list) = self.health_tracker.naughty_list() {
|
|
let is_new =
|
|
naughty_list.record(&result.relay_url, category, error.clone());
|
|
if is_new {
|
|
tracing::warn!(
|
|
relay = %result.relay_url,
|
|
category = ?category,
|
|
error = %error,
|
|
"Relay has persistent configuration issue, added to naughty list"
|
|
);
|
|
} else {
|
|
tracing::debug!(
|
|
relay = %result.relay_url,
|
|
category = ?category,
|
|
"Naughty relay failure (already tracked)"
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
tracing::debug!(
|
|
relay = %result.relay_url,
|
|
error = %error,
|
|
"Connection failed (transient issue, backoff active)"
|
|
);
|
|
}
|
|
self.record_connection_attempt_failure(&result.relay_url)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn record_connection_attempt_failure(&self, relay_url: &str) {
|
|
{
|
|
let mut index = self.relay_sync_index.write().await;
|
|
if let Some(state) = index.get_mut(relay_url) {
|
|
state.connection_status = ConnectionStatus::Disconnected;
|
|
}
|
|
}
|
|
self.health_tracker.record_failure(relay_url);
|
|
if let Some(ref metrics) = self.metrics {
|
|
metrics.record_connection_attempt(relay_url, false);
|
|
metrics.record_connection_status(relay_url, ConnectionStatus::Disconnected);
|
|
metrics.record_health_state(relay_url, self.health_tracker.get_state(relay_url));
|
|
}
|
|
}
|
|
|
|
/// Recompute sync actions for a specific relay
|
|
///
|
|
/// Uses derive_relay_targets and compute_actions to find new items
|
|
/// that need to be synced. Processes AddFilters actions for new items.
|
|
async fn recompute_new_sync_filters_for_relay(&mut self, relay_url: &str) {
|
|
use crate::sync::algorithms::{compute_actions, derive_relay_targets};
|
|
|
|
let relay_url = match canonical_relay_key(relay_url) {
|
|
Ok(relay_url) => relay_url,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
error = %error,
|
|
"Ignoring invalid dirty relay signal"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Get current state from indexes (need to collect to avoid holding locks)
|
|
let all_targets = {
|
|
let repo_index = self.repo_sync_index.read().await;
|
|
derive_relay_targets(&repo_index)
|
|
};
|
|
|
|
// Filter to only targets for this specific relay
|
|
let relay_target = match all_targets.get(&relay_url) {
|
|
Some(target) => target.clone(),
|
|
None => {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"No sync targets found for relay"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Build single-relay targets map for compute_actions
|
|
let mut single_relay_targets = std::collections::HashMap::new();
|
|
single_relay_targets.insert(relay_url.clone(), relay_target);
|
|
|
|
// Compute actions for new items
|
|
let actions = {
|
|
let pending_index = self.pending_sync_index.read().await;
|
|
let relay_index = self.relay_sync_index.read().await;
|
|
compute_actions(&single_relay_targets, &pending_index, &relay_index)
|
|
};
|
|
|
|
if actions.is_empty() {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"No new items to sync for relay"
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Process each action
|
|
for action in actions {
|
|
tracing::info!(
|
|
relay = %action.relay_url,
|
|
new_full_repos = action.items.repos.len(),
|
|
new_state_only_repos = action.items.state_only_repos.len(),
|
|
new_root_events = action.items.root_events.len(),
|
|
filters = action.filters.len(),
|
|
"Processing AddFilters for new items"
|
|
);
|
|
self.handle_new_sync_filters(action).await;
|
|
}
|
|
}
|
|
|
|
/// Sync purgatory announcements into repo_sync_index as StateOnly entries.
|
|
///
|
|
/// Called periodically by the purgatory announcement sync timer (every 5s).
|
|
/// For each announcement currently in purgatory, ensures a `StateOnly` entry
|
|
/// exists in `repo_sync_index`. New entries are then picked up by
|
|
/// `handle_new_sync_filters` which connects to listed relay URLs and subscribes
|
|
/// to state events for that repo.
|
|
///
|
|
/// Idempotent: existing entries are not downgraded (a promoted Full entry stays Full).
|
|
async fn sync_purgatory_announcements_to_index(&mut self) {
|
|
use crate::sync::algorithms::{compute_actions, derive_relay_targets};
|
|
|
|
// Collect all purgatory announcements (snapshot - no async holds)
|
|
let announcements = self.purgatory.announcements_for_sync();
|
|
let announcement_events = self.purgatory.announcement_events_for_sync();
|
|
|
|
if announcements.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Register any new entries in repo_sync_index as StateOnly.
|
|
{
|
|
let mut index = self.repo_sync_index.write().await;
|
|
for (repo_id, relays) in &announcements {
|
|
let entry = index.entry(repo_id.clone()).or_insert_with(|| {
|
|
tracing::debug!(
|
|
repo_id = %repo_id,
|
|
"Registering purgatory announcement in repo_sync_index as StateOnly"
|
|
);
|
|
RepoSyncNeeds {
|
|
relays: std::collections::HashSet::new(),
|
|
root_events: std::collections::HashSet::new(),
|
|
sync_level: SyncLevel::StateOnly,
|
|
}
|
|
});
|
|
// Don't downgrade an already-Full entry
|
|
// Add any new relay URLs
|
|
for relay in relays {
|
|
entry.relays.insert(relay.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
// A maintainer announcement or state may have been fetched and rejected
|
|
// before the reciprocal owner announcement reached this relay. Retry those
|
|
// dependencies after connecting the owner's relay hints so cold-cache
|
|
// misses can be fetched directly by event ID.
|
|
let dependency_events = select_purgatory_dependency_events(
|
|
announcement_events,
|
|
&mut self.purgatory_dependency_attempts,
|
|
Instant::now(),
|
|
purgatory_dependency_retry_after(),
|
|
MAX_PURGATORY_DEPENDENCY_EVENTS_PER_TICK,
|
|
);
|
|
for event in dependency_events {
|
|
self.reprocess_purgatory_announcement_dependencies(&event)
|
|
.await;
|
|
}
|
|
|
|
// Recompute all outstanding actions on every pass. A bounded pass may
|
|
// intentionally defer some relays, so restricting this to newly seen
|
|
// URLs would lose that work on the next tick.
|
|
let all_targets = {
|
|
let repo_index = self.repo_sync_index.read().await;
|
|
derive_relay_targets(&repo_index)
|
|
};
|
|
let mut actions = {
|
|
let pending_index = self.pending_sync_index.read().await;
|
|
let relay_index = self.relay_sync_index.read().await;
|
|
compute_actions(&all_targets, &pending_index, &relay_index)
|
|
};
|
|
// Policy-rejected targets must not consume the bounded per-tick
|
|
// budget, or a stored event listing forbidden relays could starve
|
|
// legitimate ones. Compare canonical keys: the rejected set stores
|
|
// them, while actions still carry raw event-supplied URLs.
|
|
actions.retain(|action| match canonical_relay_key(&action.relay_url) {
|
|
Ok(key) => !self.rejected_relay_targets.contains(&key),
|
|
Err(_) => false,
|
|
});
|
|
actions.sort_by_key(|action| {
|
|
!self
|
|
.dependency_relay_deadlines
|
|
.contains_key(&action.relay_url)
|
|
});
|
|
|
|
for action in actions
|
|
.into_iter()
|
|
.take(MAX_PURGATORY_FILTER_ACTIONS_PER_TICK)
|
|
{
|
|
tracing::info!(
|
|
relay = %action.relay_url,
|
|
full_repos = action.items.repos.len(),
|
|
state_only_repos = action.items.state_only_repos.len(),
|
|
"Purgatory sync timer: processing one bounded relay action"
|
|
);
|
|
self.handle_new_sync_filters(action).await;
|
|
}
|
|
}
|
|
|
|
/// Retry events whose authorization depends on a newly admitted owner announcement.
|
|
///
|
|
/// This mirrors the accepted-announcement dependency handling in
|
|
/// `process_event_static`, but runs while the owner announcement is still in
|
|
/// purgatory. Announcement policy already treats purgatory announcements as
|
|
/// maintainer authority; doing the retry here makes arrival order irrelevant.
|
|
async fn reprocess_purgatory_announcement_dependencies(&mut self, event: &Event) {
|
|
let announcement =
|
|
match crate::nostr::events::RepositoryAnnouncement::from_event(event.clone()) {
|
|
Ok(announcement) => announcement,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
event_id = %event.id,
|
|
error = %error,
|
|
"Failed to parse purgatory announcement for dependency retry"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let relay_url = format!("ws://{}", self.service_domain);
|
|
let mut dependency_refetch_ids = HashSet::new();
|
|
let mut hot_dependency_events = Vec::new();
|
|
let mut maintainer_queue: VecDeque<String> =
|
|
announcement.maintainers.iter().cloned().collect();
|
|
let mut visited_maintainers = HashSet::new();
|
|
let mut dependency_relay_urls: HashSet<String> =
|
|
announcement.relays.iter().cloned().collect();
|
|
|
|
while let Some(maintainer_hex) = maintainer_queue.pop_front() {
|
|
let maintainer_pubkey = match PublicKey::from_hex(&maintainer_hex) {
|
|
Ok(pubkey) => pubkey,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
maintainer_hex = %maintainer_hex,
|
|
error = %error,
|
|
"Invalid maintainer public key in purgatory announcement"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
if !visited_maintainers.insert(maintainer_pubkey) {
|
|
continue;
|
|
}
|
|
|
|
for event_type in [
|
|
rejected_index::EventType::Announcement,
|
|
rejected_index::EventType::State,
|
|
] {
|
|
dependency_relay_urls.extend(self.rejected_events_index.dependency_relay_hints(
|
|
&maintainer_pubkey,
|
|
&announcement.identifier,
|
|
Some(event_type),
|
|
));
|
|
let (event_ids, hot_events) = self.rejected_events_index.dependency_candidates(
|
|
&maintainer_pubkey,
|
|
&announcement.identifier,
|
|
Some(event_type),
|
|
);
|
|
let hot_event_ids: HashSet<EventId> =
|
|
hot_events.iter().map(|candidate| candidate.id).collect();
|
|
|
|
dependency_refetch_ids.extend(
|
|
event_ids
|
|
.into_iter()
|
|
.filter(|event_id| !hot_event_ids.contains(event_id)),
|
|
);
|
|
hot_dependency_events.extend(
|
|
hot_events
|
|
.into_iter()
|
|
.map(|candidate| (relay_url.clone(), candidate)),
|
|
);
|
|
}
|
|
|
|
// Follow already accepted announcements so expired dependencies in
|
|
// a transitive maintainer chain are recovered on subsequent ticks.
|
|
match self
|
|
.database
|
|
.query(
|
|
Filter::new()
|
|
.kind(Kind::GitRepoAnnouncement)
|
|
.author(maintainer_pubkey)
|
|
.identifier(&announcement.identifier),
|
|
)
|
|
.await
|
|
{
|
|
Ok(events) => {
|
|
for accepted_event in events {
|
|
if let Ok(accepted_announcement) =
|
|
crate::nostr::events::RepositoryAnnouncement::from_event(accepted_event)
|
|
{
|
|
dependency_relay_urls.extend(accepted_announcement.relays);
|
|
maintainer_queue.extend(accepted_announcement.maintainers);
|
|
}
|
|
}
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
maintainer = %maintainer_hex,
|
|
identifier = %announcement.identifier,
|
|
error = %error,
|
|
"Failed to resolve accepted transitive maintainer announcements"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The owner's state may also have arrived before their announcement.
|
|
dependency_relay_urls.extend(self.rejected_events_index.dependency_relay_hints(
|
|
&event.pubkey,
|
|
&announcement.identifier,
|
|
Some(rejected_index::EventType::State),
|
|
));
|
|
let (event_ids, hot_events) = self.rejected_events_index.dependency_candidates(
|
|
&event.pubkey,
|
|
&announcement.identifier,
|
|
Some(rejected_index::EventType::State),
|
|
);
|
|
let hot_event_ids: HashSet<EventId> =
|
|
hot_events.iter().map(|candidate| candidate.id).collect();
|
|
dependency_refetch_ids.extend(
|
|
event_ids
|
|
.into_iter()
|
|
.filter(|event_id| !hot_event_ids.contains(event_id)),
|
|
);
|
|
hot_dependency_events.extend(
|
|
hot_events
|
|
.into_iter()
|
|
.map(|candidate| (relay_url.clone(), candidate)),
|
|
);
|
|
|
|
let reserved_hot_ids = self.reserve_dependency_refetch_attempts(
|
|
hot_dependency_events
|
|
.iter()
|
|
.map(|(_, candidate)| candidate.id),
|
|
);
|
|
hot_dependency_events.retain(|(_, candidate)| reserved_hot_ids.contains(&candidate.id));
|
|
|
|
if !hot_dependency_events.is_empty() {
|
|
Self::process_purgatory_dependency_events(
|
|
hot_dependency_events,
|
|
&announcement.identifier,
|
|
&self.database,
|
|
&self.write_policy,
|
|
&self.local_relay,
|
|
&self.rejected_events_index,
|
|
&self.dependency_refetch_attempts,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
if !dependency_refetch_ids.is_empty() {
|
|
let mut canonical_dependency_relays = Vec::new();
|
|
for relay_url in dependency_relay_urls {
|
|
let relay_url = match canonical_relay_key(&relay_url) {
|
|
Ok(relay_url) => relay_url,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
error = %error,
|
|
"Ignoring invalid rejected dependency source relay"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
self.dependency_relay_deadlines.insert(
|
|
relay_url.clone(),
|
|
Instant::now() + dependency_relay_retention(),
|
|
);
|
|
if !self.connections.contains_key(&relay_url) {
|
|
if !self.register_relay(relay_url.clone(), false).await {
|
|
continue;
|
|
}
|
|
self.schedule_connect_relay(&relay_url).await;
|
|
}
|
|
canonical_dependency_relays.push(relay_url);
|
|
}
|
|
|
|
let connected_dependency_relays: Vec<String> = {
|
|
let relay_index = self.relay_sync_index.read().await;
|
|
canonical_dependency_relays
|
|
.into_iter()
|
|
.filter(|relay_url| {
|
|
relay_index
|
|
.get(relay_url)
|
|
.is_some_and(|state| state.connection_status.is_live_sync_active())
|
|
})
|
|
.collect()
|
|
};
|
|
self.spawn_purgatory_dependency_refetch(
|
|
&connected_dependency_relays,
|
|
dependency_refetch_ids,
|
|
&announcement.identifier,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Fetch cold-cache dependency IDs without blocking the sync manager.
|
|
///
|
|
/// Candidate IDs stay in the rejected index until policy processing
|
|
/// succeeds. Failed or empty requests are retried on a bounded cadence, and
|
|
/// all relay requests for one repository run in parallel.
|
|
fn spawn_purgatory_dependency_refetch(
|
|
&self,
|
|
relay_urls: &[String],
|
|
event_ids: HashSet<EventId>,
|
|
identifier: &str,
|
|
) {
|
|
let connections = connections_for_relay_urls(&self.connections, relay_urls);
|
|
|
|
if connections.is_empty() {
|
|
tracing::debug!(
|
|
identifier = %identifier,
|
|
event_count = event_ids.len(),
|
|
"Cannot refetch purgatory dependencies before a relay connection is available"
|
|
);
|
|
return;
|
|
}
|
|
|
|
let event_ids: Vec<EventId> = self
|
|
.reserve_dependency_refetch_attempts(event_ids)
|
|
.into_iter()
|
|
.collect();
|
|
|
|
if event_ids.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let identifier = identifier.to_string();
|
|
let database = self.database.clone();
|
|
let write_policy = self.write_policy.clone();
|
|
let local_relay = self.local_relay.clone();
|
|
let rejected_events_index = self.rejected_events_index.clone();
|
|
let dependency_refetch_attempts = self.dependency_refetch_attempts.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let fetches = connections.into_iter().map(|(relay_url, connection)| {
|
|
let filter = Filter::new().ids(event_ids.clone());
|
|
async move {
|
|
let result = connection
|
|
.fetch_events(filter, Duration::from_secs(5))
|
|
.await;
|
|
(relay_url, result)
|
|
}
|
|
});
|
|
|
|
let mut fetched_events = Vec::new();
|
|
for (relay_url, result) in join_all(fetches).await {
|
|
match result {
|
|
Ok(events) => {
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
identifier = %identifier,
|
|
requested_count = event_ids.len(),
|
|
fetched_count = events.len(),
|
|
"Fetched purgatory dependencies by exact event ID"
|
|
);
|
|
fetched_events.extend(
|
|
events
|
|
.into_iter()
|
|
.map(|candidate| (relay_url.clone(), candidate)),
|
|
);
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
identifier = %identifier,
|
|
event_count = event_ids.len(),
|
|
error = %error,
|
|
"Failed to refetch purgatory dependencies"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Self::process_purgatory_dependency_events(
|
|
fetched_events,
|
|
&identifier,
|
|
&database,
|
|
&write_policy,
|
|
&local_relay,
|
|
&rejected_events_index,
|
|
&dependency_refetch_attempts,
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
|
|
/// Reserve due dependency IDs so timer ticks cannot create retry storms.
|
|
fn reserve_dependency_refetch_attempts(
|
|
&self,
|
|
event_ids: impl IntoIterator<Item = EventId>,
|
|
) -> HashSet<EventId> {
|
|
let retry_after = if std::env::var("NGIT_TEST").as_deref() == Ok("1") {
|
|
Duration::from_secs(6)
|
|
} else {
|
|
Duration::from_secs(30)
|
|
};
|
|
let now = Instant::now();
|
|
let mut attempts = self.dependency_refetch_attempts.lock().unwrap();
|
|
attempts.retain(|_, attempted_at| {
|
|
now.duration_since(*attempted_at) < Duration::from_secs(3600)
|
|
});
|
|
|
|
event_ids
|
|
.into_iter()
|
|
.filter(|event_id| {
|
|
let eligible = attempts
|
|
.get(event_id)
|
|
.is_none_or(|attempted_at| now.duration_since(*attempted_at) >= retry_after);
|
|
if eligible {
|
|
attempts.insert(*event_id, now);
|
|
}
|
|
eligible
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Apply dependency events in authorization order and clear only successes.
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn process_purgatory_dependency_events(
|
|
mut events: Vec<(String, Event)>,
|
|
identifier: &str,
|
|
database: &SharedDatabase,
|
|
write_policy: &Nip34WritePolicy,
|
|
local_relay: &LocalRelay,
|
|
rejected_events_index: &Arc<RejectedEventsIndex>,
|
|
dependency_refetch_attempts: &Arc<std::sync::Mutex<HashMap<EventId, Instant>>>,
|
|
) {
|
|
// Authorization for a state can depend on the reciprocal announcement
|
|
// returned by the same request. Relays may return matches in any order.
|
|
events.sort_by_key(|(_, candidate)| candidate.kind != Kind::GitRepoAnnouncement);
|
|
|
|
let mut processed_event_ids = HashSet::new();
|
|
for (relay_url, event) in events {
|
|
if !processed_event_ids.insert(event.id) {
|
|
continue;
|
|
}
|
|
|
|
let result = Self::process_event_static(
|
|
&event,
|
|
&relay_url,
|
|
database,
|
|
write_policy,
|
|
local_relay,
|
|
rejected_events_index,
|
|
crate::nostr::persistence::SaveContext::RelaySync,
|
|
)
|
|
.await;
|
|
|
|
if result != ProcessResult::Rejected {
|
|
rejected_events_index.remove(&event.id);
|
|
dependency_refetch_attempts
|
|
.lock()
|
|
.unwrap()
|
|
.remove(&event.id);
|
|
}
|
|
|
|
if result == ProcessResult::Purgatory && event.kind == Kind::RepoState {
|
|
write_policy.purgatory().enqueue_sync_immediate(identifier);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle a relay disconnection
|
|
///
|
|
/// This method is called when the event loop terminates and sends a disconnect notification.
|
|
/// It handles two cases:
|
|
/// - Unexpected disconnects: Updates state to Disconnected, keeps RelayConnection for reconnect
|
|
/// - Intentional disconnects: Completes cleanup of Disconnecting relays (removes from indices)
|
|
async fn handle_disconnect(&mut self, relay_url: &str) {
|
|
// Check if this was an intentional disconnect (Disconnecting status)
|
|
let was_intentional = {
|
|
let index = self.relay_sync_index.read().await;
|
|
index
|
|
.get(relay_url)
|
|
.map(|s| s.connection_status == ConnectionStatus::Disconnecting)
|
|
.unwrap_or(false)
|
|
};
|
|
self.cancel_deferred_consolidation(relay_url, "relay disconnect");
|
|
|
|
if was_intentional {
|
|
// Intentional disconnect - complete cleanup by removing state
|
|
tracing::info!(relay = %relay_url, "Event loop terminated for intentional disconnect, completing cleanup");
|
|
|
|
// Update metrics to Disconnected before cleanup
|
|
if let Some(ref metrics) = self.metrics {
|
|
metrics.record_connection_status(relay_url, ConnectionStatus::Disconnected);
|
|
}
|
|
|
|
// Remove from relay_sync_index
|
|
{
|
|
let mut index = self.relay_sync_index.write().await;
|
|
if index.remove(relay_url).is_some() {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"Removed relay from relay_sync_index"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Remove from pending_sync_index
|
|
{
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
if pending.remove(relay_url).is_some() {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"Removed relay from pending_sync_index"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Remove the connection object (won't reconnect)
|
|
if self.connections.remove(relay_url).is_some() {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"Removed connection from connections map"
|
|
);
|
|
}
|
|
|
|
// Drop pending missing-event recovery along with the relay.
|
|
self.missing_event_recovery
|
|
.lock()
|
|
.unwrap()
|
|
.clear_relay(relay_url);
|
|
|
|
// Update metrics - decrement connected count
|
|
if let Some(ref metrics) = self.metrics {
|
|
metrics.dec_connected_count();
|
|
}
|
|
|
|
tracing::info!(relay = %relay_url, "Intentional disconnect cleanup complete");
|
|
} else {
|
|
// Unexpected disconnect - update state but keep for reconnection
|
|
tracing::warn!(relay = %relay_url, "Unexpected relay disconnect detected");
|
|
|
|
// Update RelayState in relay_sync_index
|
|
{
|
|
let mut index = self.relay_sync_index.write().await;
|
|
if let Some(state) = index.get_mut(relay_url) {
|
|
state.connection_status = ConnectionStatus::Disconnected;
|
|
state.disconnected_at = Some(Timestamp::now());
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
full_repos_tracked = state.repos.len(),
|
|
state_only_repos_tracked = state.state_only_repos.len(),
|
|
"Relay state updated to disconnected"
|
|
);
|
|
} else {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"No RelayState found for disconnected relay"
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Clear pending sync batches for this relay
|
|
{
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
if pending.remove(relay_url).is_some() {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"Cleared pending sync batches for disconnected relay"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Keep RelayConnection in HashMap for reuse on reconnect
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"Keeping RelayConnection in HashMap for reconnection"
|
|
);
|
|
|
|
// Record failure in health tracker
|
|
self.health_tracker.record_failure(relay_url);
|
|
|
|
// Update metrics
|
|
if let Some(ref metrics) = self.metrics {
|
|
metrics.record_connection_status(relay_url, ConnectionStatus::Disconnected);
|
|
metrics.dec_connected_count();
|
|
metrics.record_health_state(relay_url, self.health_tracker.get_state(relay_url));
|
|
}
|
|
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
health_state = %self.health_tracker.get_state(relay_url),
|
|
"Unexpected disconnect handling complete"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Re-process events from hot cache after their dependencies become available
|
|
///
|
|
/// This helper consolidates the common pattern of re-processing rejected events
|
|
/// when their missing dependencies (owner announcements, git data, etc.) arrive.
|
|
///
|
|
/// # Arguments
|
|
/// * `events` - Events to re-process from hot cache
|
|
/// * `context` - Description for logging (e.g., "maintainer announcement", "state event")
|
|
/// * `pubkey` - Public key for logging context
|
|
/// * `identifier` - Repository identifier for logging context
|
|
/// * `relay_url` - Relay URL for process_event_static
|
|
/// * `database` - Shared database for event storage
|
|
/// * `write_policy` - Policy for validating events
|
|
/// * `local_relay` - Local relay for broadcasting events
|
|
/// * `rejected_events_index` - Index for tracking rejected events
|
|
///
|
|
/// # Returns
|
|
/// Statistics about re-processing outcomes
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn reprocess_events_from_hot_cache(
|
|
events: Vec<Event>,
|
|
context: &str,
|
|
pubkey: &PublicKey,
|
|
identifier: &str,
|
|
relay_url: &str,
|
|
database: &SharedDatabase,
|
|
write_policy: &Nip34WritePolicy,
|
|
local_relay: &LocalRelay,
|
|
rejected_events_index: &Arc<RejectedEventsIndex>,
|
|
) -> ReprocessingStats {
|
|
let mut stats = ReprocessingStats::default();
|
|
|
|
for event in events {
|
|
tracing::info!(
|
|
event_id = %event.id,
|
|
pubkey = %pubkey,
|
|
identifier = %identifier,
|
|
context = %context,
|
|
"Re-processing {} from hot cache",
|
|
context
|
|
);
|
|
|
|
// Recursive call to process_event_static
|
|
// This is safe because:
|
|
// 1. The caller has observed a newly available dependency
|
|
// 2. Second attempt uses new context (different code path)
|
|
// 3. A failed retry remains indexed for a later bounded attempt
|
|
// Use Box::pin to avoid infinitely sized future
|
|
let reprocess_result = Box::pin(Self::process_event_static(
|
|
&event,
|
|
relay_url,
|
|
database,
|
|
write_policy,
|
|
local_relay,
|
|
rejected_events_index,
|
|
crate::nostr::persistence::SaveContext::HotCacheReprocess,
|
|
))
|
|
.await;
|
|
|
|
match reprocess_result {
|
|
ProcessResult::Saved => {
|
|
stats.saved += 1;
|
|
tracing::info!(
|
|
event_id = %event.id,
|
|
pubkey = %pubkey,
|
|
identifier = %identifier,
|
|
"{} accepted on re-processing",
|
|
context
|
|
);
|
|
}
|
|
ProcessResult::Duplicate => {
|
|
stats.duplicate += 1;
|
|
tracing::debug!(
|
|
event_id = %event.id,
|
|
"{} already exists (duplicate)",
|
|
context
|
|
);
|
|
}
|
|
ProcessResult::Purgatory => {
|
|
stats.purgatory += 1;
|
|
tracing::debug!(
|
|
event_id = %event.id,
|
|
"{} added to purgatory (waiting for git data)",
|
|
context
|
|
);
|
|
|
|
// Hot-cache retries are sync-originated events. Reset any
|
|
// existing backoff so newly available announcement/clone
|
|
// metadata is acted on immediately rather than inheriting
|
|
// the direct-submission three-minute delay.
|
|
let identifier = if event.kind == Kind::RepoState {
|
|
event
|
|
.tags
|
|
.iter()
|
|
.find(|tag| tag.kind() == "d")
|
|
.and_then(|tag| tag.content())
|
|
.map(str::to_owned)
|
|
} else if event.kind == Kind::GitPullRequest
|
|
|| event.kind == Kind::GitPullRequestUpdate
|
|
{
|
|
crate::git::sync::extract_identifier_from_pr_event(&event)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some(identifier) = identifier {
|
|
write_policy.purgatory().enqueue_sync_immediate(&identifier);
|
|
}
|
|
}
|
|
ProcessResult::Rejected => {
|
|
stats.rejected += 1;
|
|
tracing::warn!(
|
|
event_id = %event.id,
|
|
pubkey = %pubkey,
|
|
identifier = %identifier,
|
|
"{} still rejected on re-processing",
|
|
context
|
|
);
|
|
}
|
|
}
|
|
|
|
if reprocess_result != ProcessResult::Rejected {
|
|
rejected_events_index.remove(&event.id);
|
|
}
|
|
}
|
|
|
|
stats
|
|
}
|
|
|
|
/// Process a single event from a relay (static version for spawned tasks)
|
|
///
|
|
/// Processes events with dedup, policy check, database save, and broadcast:
|
|
/// - Deduplication (skips if event already exists)
|
|
/// - Write policy validation
|
|
/// - Database save
|
|
/// - Broadcast to WebSocket subscribers via notify_event (enables recursive relay discovery)
|
|
///
|
|
/// Returns `ProcessResult` to indicate whether the event was saved, duplicate, or rejected.
|
|
async fn process_event_static(
|
|
event: &Event,
|
|
relay_url: &str,
|
|
database: &SharedDatabase,
|
|
write_policy: &Nip34WritePolicy,
|
|
local_relay: &LocalRelay,
|
|
rejected_events_index: &Arc<RejectedEventsIndex>,
|
|
save_context: crate::nostr::persistence::SaveContext,
|
|
) -> ProcessResult {
|
|
use nostr_sdk::prelude::{WritePolicy, WritePolicyResult};
|
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
|
// Check if event already exists
|
|
match database.event_by_id(&event.id).await {
|
|
Ok(Some(_)) => {
|
|
tracing::trace!(event_id = %event.id, "Event already exists, skipping");
|
|
return ProcessResult::Duplicate;
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(event_id = %event.id, error = %e, "Database error checking event");
|
|
return ProcessResult::Rejected;
|
|
}
|
|
Ok(None) => {} // Continue processing
|
|
}
|
|
|
|
// Apply write policy using a dummy address (sync events aren't from network clients)
|
|
let dummy_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
|
|
let result = write_policy.admit_event(event, &dummy_addr).await;
|
|
|
|
match result {
|
|
WritePolicyResult::Accept => {
|
|
// Save event to database
|
|
|
|
if let Err(e) = write_policy.save_accepted_event(event, save_context).await {
|
|
tracing::error!(
|
|
event_id = %event.id,
|
|
relay = %relay_url,
|
|
error = %e,
|
|
"Failed to save synced event"
|
|
);
|
|
return ProcessResult::Rejected;
|
|
}
|
|
|
|
// Broadcast to WebSocket subscribers (enables recursive relay discovery)
|
|
// This allows SelfSubscriber to receive synced 30617 announcements
|
|
let broadcast_success = local_relay.notify_event(event.clone());
|
|
|
|
tracing::debug!(
|
|
event_id = %event.id,
|
|
relay = %relay_url,
|
|
kind = %event.kind.as_u16(),
|
|
broadcast = broadcast_success,
|
|
"Synced event saved and broadcast"
|
|
);
|
|
|
|
// When a repository announcement is accepted, re-process any rejected events:
|
|
// 1. Maintainer announcements that were rejected because the owner announcement didn't exist yet
|
|
// 2. State events that were rejected because no announcement existed
|
|
// This handles race conditions where events arrive before their dependencies during relay sync.
|
|
if event.kind == Kind::GitRepoAnnouncement {
|
|
use crate::nostr::events::RepositoryAnnouncement;
|
|
|
|
match RepositoryAnnouncement::from_event(event.clone()) {
|
|
Ok(announcement) => {
|
|
// Re-process rejected maintainer announcements
|
|
if !announcement.maintainers.is_empty() {
|
|
tracing::debug!(
|
|
event_id = %event.id,
|
|
identifier = %announcement.identifier,
|
|
maintainer_count = announcement.maintainers.len(),
|
|
"Owner announcement accepted, checking for rejected maintainer announcements"
|
|
);
|
|
|
|
// For each maintainer, invalidate and get their events
|
|
for maintainer_hex in &announcement.maintainers {
|
|
// Parse maintainer public key
|
|
match PublicKey::from_hex(maintainer_hex) {
|
|
Ok(maintainer_pubkey) => {
|
|
let (event_ids, hot_events) = rejected_events_index
|
|
.dependency_candidates(
|
|
&maintainer_pubkey,
|
|
&announcement.identifier,
|
|
Some(rejected_index::EventType::Announcement),
|
|
);
|
|
|
|
if !event_ids.is_empty() {
|
|
tracing::info!(
|
|
maintainer = %maintainer_hex,
|
|
identifier = %announcement.identifier,
|
|
dependency_event_count = event_ids.len(),
|
|
hot_cache_events = hot_events.len(),
|
|
"Found rejected maintainer announcement dependencies"
|
|
);
|
|
}
|
|
|
|
// Re-process events from hot cache immediately
|
|
if !hot_events.is_empty() {
|
|
let _stats = Self::reprocess_events_from_hot_cache(
|
|
hot_events,
|
|
"maintainer announcement",
|
|
&maintainer_pubkey,
|
|
&announcement.identifier,
|
|
relay_url,
|
|
database,
|
|
write_policy,
|
|
local_relay,
|
|
rejected_events_index,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
maintainer_hex = %maintainer_hex,
|
|
error = %e,
|
|
"Invalid maintainer public key in announcement"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-process rejected state events for this announcement
|
|
let (event_ids, hot_events) = rejected_events_index
|
|
.dependency_candidates(
|
|
&event.pubkey,
|
|
&announcement.identifier,
|
|
Some(rejected_index::EventType::State),
|
|
);
|
|
|
|
if !event_ids.is_empty() {
|
|
tracing::info!(
|
|
pubkey = %event.pubkey,
|
|
identifier = %announcement.identifier,
|
|
dependency_event_count = event_ids.len(),
|
|
hot_cache_events = hot_events.len(),
|
|
"Found rejected state dependencies after announcement acceptance"
|
|
);
|
|
}
|
|
|
|
// Re-process state events from hot cache immediately
|
|
if !hot_events.is_empty() {
|
|
let _stats = Self::reprocess_events_from_hot_cache(
|
|
hot_events,
|
|
"state event",
|
|
&event.pubkey,
|
|
&announcement.identifier,
|
|
relay_url,
|
|
database,
|
|
write_policy,
|
|
local_relay,
|
|
rejected_events_index,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
event_id = %event.id,
|
|
error = %e,
|
|
"Failed to parse repository announcement for rejected event invalidation"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// When a state event is accepted (git data arrived), re-process any other
|
|
// rejected state events for the same repository. This handles the case where
|
|
// multiple state events arrive but only one has git data initially.
|
|
// Events in the hot cache are re-processed immediately now that git data is available.
|
|
if event.kind == Kind::RepoState {
|
|
// Extract identifier from 'd' tag
|
|
if let Some(identifier) = event
|
|
.tags
|
|
.iter()
|
|
.find(|t| t.kind() == "d")
|
|
.and_then(|t| t.content())
|
|
{
|
|
// Get rejected state events for this pubkey + identifier
|
|
let (event_ids, hot_events) = rejected_events_index.dependency_candidates(
|
|
&event.pubkey,
|
|
identifier,
|
|
Some(rejected_index::EventType::State),
|
|
);
|
|
|
|
if !event_ids.is_empty() {
|
|
tracing::info!(
|
|
pubkey = %event.pubkey,
|
|
identifier = %identifier,
|
|
dependency_event_count = event_ids.len(),
|
|
hot_cache_events = hot_events.len(),
|
|
"Found rejected state dependencies after git data became available"
|
|
);
|
|
}
|
|
|
|
// Re-process events from hot cache immediately
|
|
if !hot_events.is_empty() {
|
|
let _stats = Self::reprocess_events_from_hot_cache(
|
|
hot_events,
|
|
"state event",
|
|
&event.pubkey,
|
|
identifier,
|
|
relay_url,
|
|
database,
|
|
write_policy,
|
|
local_relay,
|
|
rejected_events_index,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
ProcessResult::Saved
|
|
}
|
|
WritePolicyResult::Reject {
|
|
message, status, ..
|
|
} => {
|
|
if status {
|
|
tracing::debug!(
|
|
event_id = %event.id,
|
|
kind = %event.kind.as_u16(),
|
|
reason = %message,
|
|
"Event added to purgatory"
|
|
);
|
|
// Note: git data sync for state events is triggered by the policy
|
|
// layer when adding to purgatory (via start_state_sync)
|
|
ProcessResult::Purgatory
|
|
} else {
|
|
tracing::debug!(
|
|
event_id = %event.id,
|
|
relay = %relay_url,
|
|
kind = %event.kind.as_u16(),
|
|
reason = %message,
|
|
"Event rejected by write policy"
|
|
);
|
|
|
|
// Track rejected announcement and state events to avoid re-fetching them
|
|
if event.kind == Kind::GitRepoAnnouncement || event.kind == Kind::RepoState {
|
|
// Extract identifier from 'd' tag
|
|
if let Some(identifier) = event
|
|
.tags
|
|
.iter()
|
|
.find(|t| t.kind() == "d")
|
|
.and_then(|t| t.content())
|
|
{
|
|
// Determine rejection reason based on message
|
|
let reason = if message.contains("doesn't list this service")
|
|
|| message.contains("Announcement must list service")
|
|
{
|
|
rejected_index::RejectionReason::DoesNotListService
|
|
} else if message.contains("maintainer")
|
|
|| message.contains("no announcement exists")
|
|
|| message.contains("not authorized")
|
|
{
|
|
rejected_index::RejectionReason::MaintainerNotYetValid
|
|
} else {
|
|
rejected_index::RejectionReason::Other
|
|
};
|
|
|
|
// Use appropriate method based on event kind
|
|
if event.kind == Kind::RepoState {
|
|
rejected_events_index.add_state_from_relay(
|
|
event.clone(),
|
|
event.pubkey,
|
|
identifier.to_string(),
|
|
reason,
|
|
Some(relay_url.to_string()),
|
|
);
|
|
tracing::debug!(
|
|
event_id = %event.id,
|
|
kind = %event.kind.as_u16(),
|
|
identifier = %identifier,
|
|
"Added rejected state event to two-tier index"
|
|
);
|
|
} else {
|
|
rejected_events_index.add_announcement_from_relay(
|
|
event.clone(),
|
|
event.pubkey,
|
|
identifier.to_string(),
|
|
reason,
|
|
Some(relay_url.to_string()),
|
|
);
|
|
tracing::debug!(
|
|
event_id = %event.id,
|
|
kind = %event.kind.as_u16(),
|
|
identifier = %identifier,
|
|
pubkey = %event.pubkey,
|
|
"Added rejected announcement to two-tier index"
|
|
);
|
|
}
|
|
} else {
|
|
// No 'd' tag: structurally malformed, permanently
|
|
// invalid, and without a pubkey+identifier key for
|
|
// the two-tier index. Remember the exact ID so
|
|
// historic sync stops re-downloading and
|
|
// revalidating it. Direct live submissions never
|
|
// reach this sync-only path; their rejections are
|
|
// logged by the write policy.
|
|
rejected_events_index.add_unrecoverable(event.id, event.kind.as_u16());
|
|
tracing::warn!(
|
|
event_id = %event.id,
|
|
kind = %event.kind.as_u16(),
|
|
relay = %relay_url,
|
|
"Synced event missing 'd' tag, tracked as unrecoverable by ID"
|
|
);
|
|
}
|
|
}
|
|
|
|
ProcessResult::Rejected
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Consolidation System
|
|
// =========================================================================
|
|
|
|
async fn has_pending_batches(&self, relay_url: &str) -> bool {
|
|
self.pending_sync_index
|
|
.read()
|
|
.await
|
|
.get(relay_url)
|
|
.is_some_and(|batches| !batches.is_empty())
|
|
}
|
|
|
|
async fn desired_live_filter_count(&self, relay_url: &str) -> usize {
|
|
let target = {
|
|
let repo_index = self.repo_sync_index.read().await;
|
|
algorithms::derive_relay_targets(&repo_index).remove(relay_url)
|
|
};
|
|
let desired_repo_filters = target.map_or(0, |target| {
|
|
filters::build_sync_level_aware_filters(
|
|
&target.repos,
|
|
&target.state_only_repos,
|
|
&target.root_events,
|
|
None,
|
|
)
|
|
.len()
|
|
});
|
|
|
|
// Every connected relay carries one consolidated generic announcement
|
|
// subscription in addition to its repository-specific desired filters.
|
|
1 + grouped_subscription_count(desired_repo_filters)
|
|
}
|
|
|
|
/// Check if incremental fragmentation exceeds the consolidation threshold.
|
|
///
|
|
/// The desired live set is an irreducible baseline, so a repository whose
|
|
/// consolidated filters already exceed 70 must remain stable.
|
|
async fn maybe_consolidate(&mut self, relay_url: &str, new_count: usize) {
|
|
let current_count = if let Some(connection) = self.connections.get(relay_url) {
|
|
connection.subscription_count().await
|
|
} else {
|
|
0
|
|
};
|
|
let desired_baseline = self.desired_live_filter_count(relay_url).await;
|
|
let fragmentation = consolidation_fragmentation(current_count, new_count, desired_baseline);
|
|
|
|
if should_consolidate(current_count, new_count, desired_baseline) {
|
|
let has_pending_batches = self.has_pending_batches(relay_url).await;
|
|
if !self
|
|
.deferred_consolidations
|
|
.request(relay_url, has_pending_batches)
|
|
{
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
current_count,
|
|
new_count,
|
|
desired_baseline,
|
|
fragmentation,
|
|
threshold = CONSOLIDATION_THRESHOLD,
|
|
"Incremental filter fragmentation exceeds threshold; deferring consolidation until pending batches drain"
|
|
);
|
|
return;
|
|
}
|
|
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
current_count = current_count,
|
|
new_count = new_count,
|
|
desired_baseline,
|
|
fragmentation,
|
|
threshold = CONSOLIDATION_THRESHOLD,
|
|
"Incremental filter fragmentation exceeds threshold, consolidating"
|
|
);
|
|
|
|
self.consolidate(relay_url).await;
|
|
}
|
|
}
|
|
|
|
async fn process_deferred_consolidation(&mut self, relay_url: &str) {
|
|
let has_pending_batches = self.has_pending_batches(relay_url).await;
|
|
if !self
|
|
.deferred_consolidations
|
|
.take_ready(relay_url, has_pending_batches)
|
|
{
|
|
return;
|
|
}
|
|
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
"Pending batches drained; running deferred consolidation"
|
|
);
|
|
self.consolidate(relay_url).await;
|
|
}
|
|
|
|
fn cancel_deferred_consolidation(&mut self, relay_url: &str, reason: &'static str) {
|
|
if self.deferred_consolidations.cancel(relay_url) {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
reason,
|
|
"Cancelled deferred consolidation"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Consolidate all subscriptions for a relay
|
|
///
|
|
/// The caller must ensure pending batches have drained so EOSE processing
|
|
/// never waits behind the sync actor lock.
|
|
///
|
|
/// Layer 1 (announcements) remains active and is NOT unsubscribed.
|
|
async fn consolidate(&mut self, relay_url: &str) {
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
"Starting consolidation"
|
|
);
|
|
|
|
// Get connection and unsubscribe all
|
|
let connection = match self.connections.get(relay_url) {
|
|
Some(conn) => conn,
|
|
None => {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"No connection found, skipping consolidation"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
connection.unsubscribe_all().await;
|
|
|
|
// Rebuild all subscriptions with since filter
|
|
let now = Timestamp::now();
|
|
let since = Timestamp::from(now.as_secs().saturating_sub(QUICK_RECONNECT_WINDOW_SECS));
|
|
|
|
// Re-subscribe to Layer 1 with since filter
|
|
self.sync_generic_filters(relay_url, Some(since)).await;
|
|
// Rebuild Layer 2 and Layer 3 with since filter
|
|
self.rebuild_layer2_and_layer3(relay_url, Some(since)).await;
|
|
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
since = %since,
|
|
"Consolidation complete - filter count reset"
|
|
);
|
|
}
|
|
|
|
/// Check for relays that should be disconnected
|
|
///
|
|
/// This method is called periodically by run_disconnect_checker.
|
|
/// It identifies non-bootstrap relays that have no repos or root events
|
|
/// to sync and disconnects them to free up resources.
|
|
///
|
|
/// Bootstrap relays are NEVER disconnected, even if empty.
|
|
async fn check_disconnects(&mut self) {
|
|
let now = Instant::now();
|
|
self.dependency_relay_deadlines
|
|
.retain(|_, deadline| *deadline > now);
|
|
|
|
let mut desired_relays: HashSet<String> = {
|
|
let repo_index = self.repo_sync_index.read().await;
|
|
algorithms::derive_relay_targets(&repo_index)
|
|
.into_keys()
|
|
.collect()
|
|
};
|
|
desired_relays.extend(self.dependency_relay_deadlines.keys().cloned());
|
|
|
|
// Collect relays to disconnect
|
|
let to_disconnect: Vec<String> = {
|
|
let pending = self.pending_sync_index.read().await;
|
|
let index = self.relay_sync_index.read().await;
|
|
index
|
|
.iter()
|
|
.filter_map(|(relay_url, state)| {
|
|
let has_pending_batches = pending
|
|
.get(relay_url)
|
|
.is_some_and(|batches| !batches.is_empty());
|
|
|
|
if state.is_disconnect_candidate(
|
|
has_pending_batches,
|
|
desired_relays.contains(relay_url),
|
|
) {
|
|
Some(relay_url.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
};
|
|
|
|
if to_disconnect.is_empty() {
|
|
tracing::trace!("No empty relays to disconnect");
|
|
return;
|
|
}
|
|
|
|
tracing::info!(
|
|
count = to_disconnect.len(),
|
|
relays = ?to_disconnect,
|
|
"Found empty non-bootstrap relays to disconnect"
|
|
);
|
|
|
|
// Disconnect empty relays
|
|
for relay_url in to_disconnect {
|
|
self.disconnect_relay(&relay_url).await;
|
|
}
|
|
}
|
|
|
|
/// Disconnect a relay and mark it for cleanup
|
|
///
|
|
/// This method:
|
|
/// - Marks the relay as Disconnecting in relay_sync_index
|
|
/// - Initiates the connection disconnect
|
|
/// - Final cleanup happens in handle_disconnect when event loop terminates
|
|
///
|
|
/// Used by check_disconnects for cleanup of empty relays.
|
|
async fn disconnect_relay(&mut self, relay_url: &str) {
|
|
tracing::info!(relay = %relay_url, "Initiating disconnect for empty relay");
|
|
|
|
// Mark relay as Disconnecting (keep state for event loop to drain)
|
|
{
|
|
let mut index = self.relay_sync_index.write().await;
|
|
if let Some(state) = index.get_mut(relay_url) {
|
|
state.connection_status = ConnectionStatus::Disconnecting;
|
|
state.disconnected_at = Some(Timestamp::now());
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"Marked relay as Disconnecting"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Initiate disconnect - event loop will drain and send disconnect notification
|
|
if let Some(connection) = self.connections.get(relay_url) {
|
|
connection.disconnect().await;
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"Initiated connection disconnect"
|
|
);
|
|
}
|
|
|
|
// Update metrics
|
|
if let Some(ref metrics) = self.metrics {
|
|
metrics.record_connection_status(relay_url, ConnectionStatus::Disconnecting);
|
|
}
|
|
|
|
tracing::info!(relay = %relay_url, "Disconnect initiated, waiting for event loop termination");
|
|
}
|
|
|
|
/// Retry disconnected relays that are ready for reconnection
|
|
///
|
|
/// This method is called periodically by run_disconnect_checker.
|
|
/// It identifies relays that:
|
|
/// - Are currently disconnected
|
|
/// - Have repos or root events to sync (not empty)
|
|
/// - Have passed the exponential backoff period (respects health tracker)
|
|
///
|
|
/// For each eligible relay, a reconnection is queued via schedule_connect_relay.
|
|
async fn retry_disconnected_relays(&mut self) {
|
|
let desired_relays: HashSet<String> = {
|
|
let repo_index = self.repo_sync_index.read().await;
|
|
algorithms::derive_relay_targets(&repo_index)
|
|
.into_keys()
|
|
.collect()
|
|
};
|
|
|
|
// Collect relays to reconnect
|
|
let to_reconnect: Vec<String> = {
|
|
let index = self.relay_sync_index.read().await;
|
|
index
|
|
.iter()
|
|
.filter_map(|(relay_url, state)| {
|
|
// Only consider disconnected relays
|
|
if state.connection_status != ConnectionStatus::Disconnected {
|
|
return None;
|
|
}
|
|
|
|
// A source can have desired StateOnly invitation work before
|
|
// its first successful historic batch confirms anything.
|
|
if state.repos.is_empty()
|
|
&& state.state_only_repos.is_empty()
|
|
&& state.root_events.is_empty()
|
|
&& !desired_relays.contains(relay_url)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
// Check if backoff period has elapsed
|
|
if self.health_tracker.should_attempt_connection(relay_url) {
|
|
Some(relay_url.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
};
|
|
|
|
if to_reconnect.is_empty() {
|
|
tracing::trace!("No disconnected relays ready for reconnection");
|
|
return;
|
|
}
|
|
|
|
tracing::info!(
|
|
count = to_reconnect.len(),
|
|
relays = ?to_reconnect,
|
|
"Attempting reconnection for disconnected relays"
|
|
);
|
|
|
|
// Reconnect eligible relays
|
|
for relay_url in to_reconnect {
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
health_state = %self.health_tracker.get_state(&relay_url),
|
|
"Attempting reconnection"
|
|
);
|
|
self.schedule_connect_relay(&relay_url).await;
|
|
}
|
|
}
|
|
|
|
/// Check for rate-limited relays that have exceeded cooldown
|
|
///
|
|
/// This method is called by the health and metrics checker every 2 seconds.
|
|
/// For each relay in RateLimited state that has exceeded the 65-second cooldown:
|
|
/// 1. Clears the rate limit state (sets to Healthy)
|
|
/// 2. Recomputes required actions for that relay
|
|
/// 3. Submits those actions
|
|
async fn check_rate_limit_recovery(&mut self) {
|
|
use crate::sync::algorithms::{compute_actions, derive_relay_targets};
|
|
|
|
// Exit rate limiting for relays whose cooldown has expired
|
|
let relays_to_recover: Vec<String> = self.health_tracker.exit_expired_rate_limits();
|
|
|
|
if relays_to_recover.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Recompute actions - could optimise by adding relays: Option<&[]> to derive_relay_targets
|
|
let repo_index = self.repo_sync_index.read().await;
|
|
let targets = derive_relay_targets(&repo_index);
|
|
drop(repo_index);
|
|
|
|
for relay_url in relays_to_recover {
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
"Rate limit cooldown expired, recovering"
|
|
);
|
|
|
|
// Clear rate limit state
|
|
self.health_tracker.clear_rate_limit(&relay_url);
|
|
|
|
// Only compute actions for this specific relay
|
|
if let Some(relay_needs) = targets.get(&relay_url) {
|
|
let mut single_relay_targets = std::collections::HashMap::new();
|
|
single_relay_targets.insert(relay_url.clone(), relay_needs.clone());
|
|
|
|
let pending = self.pending_sync_index.read().await;
|
|
let confirmed = self.relay_sync_index.read().await;
|
|
|
|
let actions = compute_actions(&single_relay_targets, &pending, &confirmed);
|
|
drop(pending);
|
|
drop(confirmed);
|
|
|
|
// Submit each action
|
|
for action in actions {
|
|
tracing::info!(
|
|
relay = %action.relay_url,
|
|
full_repo_count = action.items.repos.len(),
|
|
state_only_repo_count = action.items.state_only_repos.len(),
|
|
event_count = action.items.root_events.len(),
|
|
"Submitting recovered actions after rate limit"
|
|
);
|
|
self.handle_new_sync_filters(action).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Subscribe to filters for live (ongoing) events - NOT tracked in PendingSyncIndex
|
|
///
|
|
/// This method applies limit(0) to all filters to receive ONLY new events.
|
|
/// Per NIP-01, limit 0 means "send no stored events, only future events", which
|
|
/// ensures EOSE is received immediately and all subsequent events are tagged as "live"
|
|
/// in metrics (not "startup").
|
|
///
|
|
/// **Important**: Callers pass the SAME filters to both sync_live() and historic_sync().
|
|
/// This method applies limit(0) to prevent fetching historic events.
|
|
///
|
|
/// Live subscriptions are NOT tracked in PendingSyncIndex because they don't have
|
|
/// a definite "completion" - they stay open indefinitely.
|
|
///
|
|
/// Used for:
|
|
/// - Layer 1 live subscription (new announcements after initial sync)
|
|
/// - Layer 2+3 live subscriptions (new events after initial sync)
|
|
///
|
|
/// # Arguments
|
|
/// * `relay_url` - The relay URL to subscribe on
|
|
/// * `filters` - Filters to subscribe to (limit(0) will be applied)
|
|
///
|
|
/// # Returns
|
|
/// Vec of subscription IDs for the live subscriptions, or empty if connection not found
|
|
async fn sync_live(&self, relay_url: &str, filters: &[Filter]) -> Vec<SubscriptionId> {
|
|
if filters.is_empty() {
|
|
return vec![];
|
|
}
|
|
|
|
let connection = match self.connections.get(relay_url) {
|
|
Some(conn) => conn,
|
|
None => {
|
|
tracing::debug!(relay = %relay_url, "No connection found for live sync");
|
|
return vec![];
|
|
}
|
|
};
|
|
|
|
let mut sub_ids = Vec::new();
|
|
|
|
for filter_group in filters.chunks(MAX_FILTERS_PER_REQ) {
|
|
// Live subscriptions MUST use limit(0) to receive ONLY new events
|
|
// This prevents fetching historic events that would be miscounted as "live" in metrics
|
|
// The caller passes the same filters to both sync_live() and historic_sync()
|
|
// Live subscriptions do NOT auto-close - we want them to stay open for new events
|
|
let grouped_filters = filter_group
|
|
.iter()
|
|
.cloned()
|
|
.map(|filter| filter.limit(0))
|
|
.collect();
|
|
match connection.subscribe_filters(grouped_filters, false).await {
|
|
Ok(sub_id) => {
|
|
sub_ids.push(sub_id);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(relay = %relay_url, error = %e, "Failed to create live subscription");
|
|
}
|
|
}
|
|
}
|
|
|
|
sub_ids
|
|
}
|
|
|
|
/// Sync historical events and track in PendingSyncIndex
|
|
///
|
|
/// This method handles historical synchronization for a set of filters,
|
|
/// creating a PendingBatch to track completion. It dispatches to either
|
|
/// negentropy sync or traditional REQ+EOSE based on relay capability and config.
|
|
///
|
|
/// Used for:
|
|
/// - Initial sync (no since filter)
|
|
/// - Reconnect sync (with since filter)
|
|
/// - Daily sync (no since filter, full re-sync)
|
|
///
|
|
/// # Arguments
|
|
/// * `relay_url` - The relay URL to sync from
|
|
/// * `filters` - Filters to sync (will have `since` applied if provided)
|
|
/// * `items` - Items being synced (for tracking in PendingBatch)
|
|
/// * `since` - Optional timestamp for incremental sync
|
|
///
|
|
/// # Returns
|
|
/// * `Some(batch_id)` - Batch was created and sync initiated
|
|
/// * `None` - No connection or sync failed to start
|
|
async fn historic_sync(
|
|
&mut self,
|
|
relay_url: &str,
|
|
filters: Vec<Filter>,
|
|
items: PendingItems,
|
|
since: Option<Timestamp>,
|
|
) -> Option<u64> {
|
|
// DEBUG TRACING: Log all filters being passed to historic_sync
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
filter_count = filters.len(),
|
|
filters = ?filters,
|
|
repos_count = items.repos.len(),
|
|
root_events_count = items.root_events.len(),
|
|
since = ?since,
|
|
"historic_sync called"
|
|
);
|
|
|
|
if filters.is_empty() && items.repos.is_empty() && items.root_events.is_empty() {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
"historic_sync called with empty filters and items, skipping"
|
|
);
|
|
return None;
|
|
}
|
|
|
|
// Check connection exists and clone for async usage
|
|
let connection = match self.connections.get(relay_url) {
|
|
Some(conn) => conn.clone(),
|
|
None => {
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
"No connection found for historic_sync"
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
// Apply since filter if provided
|
|
let filters_with_since: Vec<Filter> = if let Some(ts) = since {
|
|
filters.into_iter().map(|f| f.since(ts)).collect()
|
|
} else {
|
|
filters
|
|
};
|
|
|
|
// Check if we should use negentropy
|
|
let use_negentropy =
|
|
!self.config.sync_disable_negentropy && connection.supports_negentropy().await;
|
|
|
|
// Generate batch ID
|
|
let batch_id = self.next_batch_id();
|
|
|
|
// Track whether negentropy succeeded (for fallback logic)
|
|
let mut negentropy_succeeded = false;
|
|
|
|
if use_negentropy && !filters_with_since.is_empty() {
|
|
// NIP-77 negentropy path
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
batch_id = batch_id,
|
|
filter_count = filters_with_since.len(),
|
|
repos = items.repos.len(),
|
|
root_events = items.root_events.len(),
|
|
"Starting historic_sync with negentropy"
|
|
);
|
|
|
|
// Create PendingBatch for negentropy (empty outstanding_subs and pagination_state)
|
|
let batch = PendingBatch {
|
|
batch_id,
|
|
items: items.clone(),
|
|
outstanding_subs: HashSet::new(),
|
|
sync_method: SyncMethod::Negentropy,
|
|
pagination_state: HashMap::new(), // Negentropy doesn't use pagination
|
|
requested_event_ids: None, // Will be set after negentropy diff
|
|
received_event_ids: None, // Will be set after negentropy diff
|
|
retry_count: 0,
|
|
failed: false,
|
|
};
|
|
|
|
// Add to pending_sync_index
|
|
{
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
pending
|
|
.entry(relay_url.to_string())
|
|
.or_insert_with(Vec::new)
|
|
.push(batch);
|
|
}
|
|
|
|
// Perform negentropy sync for all filters concurrently
|
|
// Note: We sync each filter separately because negentropy works on a single filter
|
|
let diff_futures: Vec<_> = filters_with_since
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(idx, filter)| {
|
|
let filter = filter.clone();
|
|
let conn = connection.clone();
|
|
async move { (idx, conn.negentropy_sync_diff(filter).await) }
|
|
})
|
|
.collect();
|
|
|
|
let diff_results = futures_util::future::join_all(diff_futures).await;
|
|
|
|
// Process results - collect all event IDs we need to fetch
|
|
let mut all_remote_ids = Vec::new();
|
|
let mut failed_count = 0;
|
|
|
|
// Get event IDs to exclude: purgatory + rejected announcements
|
|
let purgatory_ids = self.purgatory.event_ids();
|
|
let rejected_ids = self.rejected_events_index.get_all_event_ids();
|
|
let excluded_ids: HashSet<EventId> =
|
|
purgatory_ids.union(&rejected_ids).cloned().collect();
|
|
|
|
for (idx, result) in diff_results {
|
|
match result {
|
|
Ok(reconciliation) => {
|
|
let remote_excluding_ids: HashSet<EventId> = reconciliation
|
|
.remote
|
|
.keys()
|
|
.filter(|id| !excluded_ids.contains(id))
|
|
.copied()
|
|
.collect();
|
|
let remote_count = remote_excluding_ids.len();
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
filter_idx = idx,
|
|
remote_count = remote_count,
|
|
local_count = reconciliation.local.len(),
|
|
"Negentropy diff completed for filter"
|
|
);
|
|
if remote_count > 0 {
|
|
all_remote_ids.extend(remote_excluding_ids);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
failed_count += 1;
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
filter_idx = idx,
|
|
error = %e,
|
|
"Negentropy diff failed for filter in historic_sync"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Require ALL filters to succeed to confirm the batch
|
|
if failed_count > 0 {
|
|
// Remove failed negentropy batch and fall back to REQ+EOSE
|
|
{
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
if let Some(batches) = pending.get_mut(relay_url) {
|
|
let batch_idx = batches.iter().position(|b| b.batch_id == batch_id);
|
|
if let Some(idx) = batch_idx {
|
|
batches.remove(idx);
|
|
if batches.is_empty() {
|
|
pending.remove(relay_url);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
batch_id = batch_id,
|
|
failed_count = failed_count,
|
|
total_filters = filters_with_since.len(),
|
|
"historic_sync (negentropy) failed - falling back to REQ+EOSE"
|
|
);
|
|
|
|
// Fall through to REQ+EOSE path below
|
|
} else {
|
|
// Negentropy succeeded - mark success and process results
|
|
negentropy_succeeded = true;
|
|
|
|
if all_remote_ids.is_empty() {
|
|
// Remove batch from pending and confirm it (no items to download)
|
|
let completed_batch = {
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
if let Some(batches) = pending.get_mut(relay_url) {
|
|
let batch_idx = batches.iter().position(|b| b.batch_id == batch_id);
|
|
if let Some(idx) = batch_idx {
|
|
let batch = batches.remove(idx);
|
|
if batches.is_empty() {
|
|
pending.remove(relay_url);
|
|
}
|
|
Some(batch)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
if let Some(batch) = completed_batch {
|
|
self.confirm_batch(relay_url, batch).await;
|
|
}
|
|
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
batch_id = batch_id,
|
|
total_received = 0,
|
|
"historic_sync (negentropy) completed - already up-to-date"
|
|
);
|
|
|
|
// Batch already confirmed, nothing more to do
|
|
return Some(batch_id);
|
|
}
|
|
|
|
// launch subscriptions to fetch missing events by id
|
|
let ids_filters: Vec<_> = all_remote_ids
|
|
.chunks(300)
|
|
.map(|c| Filter::new().ids(c.iter().copied()))
|
|
.collect();
|
|
|
|
tracing::info!(
|
|
relay = %relay_url,
|
|
batch_id = batch_id,
|
|
total_event_ids = all_remote_ids.len(),
|
|
filter_chunks = ids_filters.len(),
|
|
"Creating subscriptions to fetch missing events by ID"
|
|
);
|
|
|
|
let mut subscription_ids = HashSet::new();
|
|
for (idx, filter) in ids_filters.iter().enumerate() {
|
|
if let Some(conn) = self.connections.get(relay_url) {
|
|
match conn.subscribe_filter(filter.clone(), true).await {
|
|
Ok(sub_id) => {
|
|
subscription_ids.insert(sub_id);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
relay = %relay_url,
|
|
batch_id = batch_id,
|
|
chunk_idx = idx,
|
|
error = %e,
|
|
"Failed to subscribe to ID filter chunk"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
{
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
if let Some(relay_batches) = pending.get_mut(relay_url) {
|
|
if let Some(batch) =
|
|
relay_batches.iter_mut().find(|b| b.batch_id == batch_id)
|
|
{
|
|
batch.outstanding_subs.extend(subscription_ids.clone());
|
|
// Store requested event IDs for validation after EOSE
|
|
batch.requested_event_ids =
|
|
Some(all_remote_ids.iter().cloned().collect());
|
|
batch.received_event_ids = Some(HashSet::new());
|
|
}
|
|
}
|
|
}
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
batch_id = batch_id,
|
|
subscription_ids = subscription_ids.len(),
|
|
events = all_remote_ids.len(),
|
|
"historic_sync (Negentropy) created subscriptions to fetch missing events by id, awaiting EOSE"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Use REQ+EOSE if negentropy was not attempted or failed
|
|
if !negentropy_succeeded {
|
|
// Traditional REQ+EOSE path
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
batch_id = batch_id,
|
|
filter_count = filters_with_since.len(),
|
|
repos = items.repos.len(),
|
|
root_events = items.root_events.len(),
|
|
use_negentropy = use_negentropy,
|
|
"Starting historic_sync with REQ+EOSE"
|
|
);
|
|
|
|
// Subscribe to each filter and collect subscription IDs
|
|
let mut subscription_ids = HashSet::new();
|
|
let mut pagination_state = HashMap::new();
|
|
|
|
// Keep several OR filters under each relay-visible subscription.
|
|
for (idx, filter_group) in filters_with_since.chunks(MAX_FILTERS_PER_REQ).enumerate() {
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
batch_id = batch_id,
|
|
group_idx = idx,
|
|
filter_count = filter_group.len(),
|
|
filters = ?filter_group,
|
|
"Subscribing to grouped filters in REQ+EOSE path"
|
|
);
|
|
|
|
if let Some(conn) = self.connections.get(relay_url) {
|
|
let grouped_filters = filter_group.to_vec();
|
|
match conn.subscribe_filters(grouped_filters.clone(), true).await {
|
|
Ok(sub_id) => {
|
|
subscription_ids.insert(sub_id.clone());
|
|
pagination_state.insert(sub_id, PaginationState::new(grouped_filters));
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
relay = %relay_url,
|
|
error = %e,
|
|
"Failed to subscribe to filter in historic_sync"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if subscription_ids.is_empty() && !filters_with_since.is_empty() {
|
|
tracing::warn!(
|
|
relay = %relay_url,
|
|
"All filter subscriptions failed in historic_sync"
|
|
);
|
|
return None;
|
|
}
|
|
|
|
// Create PendingBatch for REQ+EOSE
|
|
let batch = PendingBatch {
|
|
batch_id,
|
|
items,
|
|
outstanding_subs: subscription_ids,
|
|
sync_method: SyncMethod::ReqEose,
|
|
pagination_state,
|
|
requested_event_ids: None, // Not used for REQ+EOSE
|
|
received_event_ids: None, // Not used for REQ+EOSE
|
|
retry_count: 0, // Not used for REQ+EOSE
|
|
failed: false,
|
|
};
|
|
|
|
// Add to pending_sync_index
|
|
{
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
pending
|
|
.entry(relay_url.to_string())
|
|
.or_insert_with(Vec::new)
|
|
.push(batch);
|
|
}
|
|
|
|
tracing::debug!(
|
|
relay = %relay_url,
|
|
batch_id = batch_id,
|
|
"historic_sync (REQ+EOSE) batch created, awaiting EOSE"
|
|
);
|
|
}
|
|
|
|
Some(batch_id)
|
|
}
|
|
|
|
/// Gracefully shutdown the SyncManager
|
|
///
|
|
/// This method:
|
|
/// - Sends shutdown signal to all background tasks (daily timer, disconnect checker)
|
|
/// - Disconnects all relay connections
|
|
/// - Clears all indices (relay_sync_index, pending_sync_index)
|
|
///
|
|
/// After calling this method, the SyncManager is no longer usable.
|
|
pub async fn shutdown(&mut self) {
|
|
tracing::info!("Starting SyncManager shutdown");
|
|
|
|
// 1. Send shutdown signal to all background tasks
|
|
if let Some(tx) = &self.shutdown_tx {
|
|
let _ = tx.send(());
|
|
tracing::debug!("Sent shutdown signal to background tasks");
|
|
}
|
|
|
|
// 2. Disconnect all relay connections
|
|
let relay_urls: Vec<String> = self.connections.keys().cloned().collect();
|
|
for relay_url in relay_urls {
|
|
if let Some(connection) = self.connections.remove(&relay_url) {
|
|
tracing::debug!(relay = %relay_url, "Disconnecting relay");
|
|
connection.disconnect().await;
|
|
}
|
|
}
|
|
|
|
// 3. Clear all indices
|
|
{
|
|
let mut index = self.relay_sync_index.write().await;
|
|
let count = index.len();
|
|
index.clear();
|
|
tracing::debug!(count = count, "Cleared relay_sync_index");
|
|
}
|
|
|
|
{
|
|
let mut pending = self.pending_sync_index.write().await;
|
|
let count = pending.len();
|
|
pending.clear();
|
|
tracing::debug!(count = count, "Cleared pending_sync_index");
|
|
}
|
|
self.deferred_consolidations.clear();
|
|
|
|
tracing::info!("SyncManager shutdown complete");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn purgatory_dependency_budget_prioritizes_a_fresh_announcement() {
|
|
let keys = Keys::generate();
|
|
let now = Instant::now();
|
|
let retry_after = Duration::from_secs(30);
|
|
let mut attempts = HashMap::new();
|
|
let mut events = Vec::new();
|
|
|
|
for created_at in 1..=1000 {
|
|
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
|
.tag(Tag::identifier(format!("old-{created_at}")))
|
|
.custom_created_at(Timestamp::from_secs(created_at))
|
|
.finalize(&keys)
|
|
.expect("Failed to create old purgatory announcement");
|
|
attempts.insert(event.id, now);
|
|
events.push(event);
|
|
}
|
|
|
|
let fresh = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
|
.tag(Tag::identifier("fresh"))
|
|
.custom_created_at(Timestamp::from_secs(1001))
|
|
.finalize(&keys)
|
|
.expect("Failed to create fresh purgatory announcement");
|
|
events.push(fresh.clone());
|
|
|
|
let selected = select_purgatory_dependency_events(
|
|
events,
|
|
&mut attempts,
|
|
now,
|
|
retry_after,
|
|
MAX_PURGATORY_DEPENDENCY_EVENTS_PER_TICK,
|
|
);
|
|
|
|
assert_eq!(selected.len(), 1);
|
|
assert_eq!(selected[0].id, fresh.id);
|
|
assert!(
|
|
select_purgatory_dependency_events(
|
|
vec![fresh],
|
|
&mut attempts,
|
|
now,
|
|
retry_after,
|
|
MAX_PURGATORY_DEPENDENCY_EVENTS_PER_TICK,
|
|
)
|
|
.is_empty(),
|
|
"an attempted announcement must wait for its bounded retry deadline"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn connect_attempt_tokens_deduplicate_and_reject_stale_results() {
|
|
let relay = "wss://relay.example";
|
|
let mut in_flight = HashMap::new();
|
|
let mut next_token = 0;
|
|
|
|
let first = reserve_connect_attempt(&mut in_flight, &mut next_token, relay)
|
|
.expect("first attempt should be reserved");
|
|
assert!(
|
|
reserve_connect_attempt(&mut in_flight, &mut next_token, relay).is_none(),
|
|
"a queued or running relay must not be scheduled twice"
|
|
);
|
|
assert!(
|
|
!take_connect_attempt(&mut in_flight, relay, ConnectAttemptToken(first.0 + 1)),
|
|
"a stale worker result must not consume the active attempt"
|
|
);
|
|
assert_eq!(in_flight.get(relay), Some(&first));
|
|
assert!(take_connect_attempt(&mut in_flight, relay, first));
|
|
|
|
let second = reserve_connect_attempt(&mut in_flight, &mut next_token, relay)
|
|
.expect("relay should be schedulable after completion");
|
|
assert_ne!(first, second, "attempt identities must not be reused");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connect_attempt_semaphore_caps_parallel_workers() {
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
const WORKERS: usize = MAX_CONCURRENT_CONNECT_ATTEMPTS * 3;
|
|
let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNECT_ATTEMPTS));
|
|
let release = Arc::new(Semaphore::new(0));
|
|
let active = Arc::new(AtomicUsize::new(0));
|
|
let maximum = Arc::new(AtomicUsize::new(0));
|
|
let (started_tx, mut started_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut workers = Vec::new();
|
|
|
|
for _ in 0..WORKERS {
|
|
let semaphore = Arc::clone(&semaphore);
|
|
let release = Arc::clone(&release);
|
|
let active = Arc::clone(&active);
|
|
let maximum = Arc::clone(&maximum);
|
|
let started_tx = started_tx.clone();
|
|
workers.push(tokio::spawn(async move {
|
|
let _permit = semaphore.acquire_owned().await.unwrap();
|
|
let now_active = active.fetch_add(1, Ordering::SeqCst) + 1;
|
|
maximum.fetch_max(now_active, Ordering::SeqCst);
|
|
started_tx.send(()).unwrap();
|
|
let _release = release.acquire().await.unwrap();
|
|
active.fetch_sub(1, Ordering::SeqCst);
|
|
}));
|
|
}
|
|
drop(started_tx);
|
|
|
|
for _ in 0..MAX_CONCURRENT_CONNECT_ATTEMPTS {
|
|
started_rx.recv().await.unwrap();
|
|
}
|
|
assert!(
|
|
tokio::time::timeout(Duration::from_millis(25), started_rx.recv())
|
|
.await
|
|
.is_err(),
|
|
"a ninth worker started while all eight permits were occupied"
|
|
);
|
|
|
|
release.add_permits(WORKERS);
|
|
for worker in workers {
|
|
worker.await.unwrap();
|
|
}
|
|
assert_eq!(
|
|
maximum.load(Ordering::SeqCst),
|
|
MAX_CONCURRENT_CONNECT_ATTEMPTS
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queued_connect_attempt_records_health_only_when_worker_starts() {
|
|
let relay = "wss://queued.example";
|
|
let semaphore = Arc::new(Semaphore::new(0));
|
|
let health_tracker = Arc::new(RelayHealthTracker::with_defaults());
|
|
let worker_semaphore = Arc::clone(&semaphore);
|
|
let worker_health = Arc::clone(&health_tracker);
|
|
|
|
let worker = tokio::spawn(async move {
|
|
begin_connect_attempt(worker_semaphore, worker_health, relay).await
|
|
});
|
|
tokio::task::yield_now().await;
|
|
assert!(
|
|
health_tracker.get_health(relay).is_none(),
|
|
"waiting for scheduler capacity must not start backoff"
|
|
);
|
|
|
|
semaphore.add_permits(1);
|
|
let _permit = worker
|
|
.await
|
|
.unwrap()
|
|
.expect("worker should start after a permit becomes available");
|
|
assert!(
|
|
health_tracker
|
|
.get_health(relay)
|
|
.is_some_and(|health| health.last_attempt_time.is_some()),
|
|
"health attempt time must be recorded when the worker starts"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_relay_keys_dedupe_only_the_root_slash() {
|
|
assert_eq!(
|
|
canonical_relay_key("wss://relay.example").unwrap(),
|
|
"wss://relay.example"
|
|
);
|
|
assert_eq!(
|
|
canonical_relay_key("wss://relay.example/").unwrap(),
|
|
"wss://relay.example"
|
|
);
|
|
assert_eq!(
|
|
canonical_relay_key("wss://relay.example/nostr/").unwrap(),
|
|
"wss://relay.example/nostr/"
|
|
);
|
|
assert_eq!(
|
|
canonical_relay_key("relay.example/").unwrap(),
|
|
"wss://relay.example"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn connection_lookup_deduplicates_relay_url_variants() {
|
|
let canonical = canonical_relay_key("wss://relay.example").unwrap();
|
|
let connections = HashMap::from([(canonical.clone(), 7_u8)]);
|
|
let candidates = vec![
|
|
"wss://relay.example".to_string(),
|
|
"wss://relay.example/".to_string(),
|
|
];
|
|
|
|
assert_eq!(
|
|
connections_for_relay_urls(&connections, &candidates),
|
|
vec![(canonical, 7)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn desired_baseline_above_threshold_does_not_reconsolidate() {
|
|
let desired_baseline = 178;
|
|
|
|
assert!(
|
|
!should_consolidate(desired_baseline, 1, desired_baseline),
|
|
"the irreducible desired set must be a stable post-rebuild baseline"
|
|
);
|
|
assert!(
|
|
!should_consolidate(
|
|
desired_baseline + CONSOLIDATION_THRESHOLD - 1,
|
|
1,
|
|
desired_baseline,
|
|
),
|
|
"the configured fragmentation headroom is allowed above the baseline"
|
|
);
|
|
assert!(should_consolidate(
|
|
desired_baseline + CONSOLIDATION_THRESHOLD,
|
|
1,
|
|
desired_baseline,
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn grouped_pagination_advances_only_filters_that_fill_a_page() {
|
|
let keys = Keys::generate();
|
|
let metadata_filter = Filter::new().kind(Kind::Metadata);
|
|
let note_filter = Filter::new().kind(Kind::TextNote);
|
|
let mut pagination =
|
|
PaginationState::new(vec![metadata_filter.clone(), note_filter.clone()]);
|
|
|
|
for created_at in 1..=PAGINATION_THRESHOLD {
|
|
let event = EventBuilder::new(Kind::Metadata, created_at.to_string())
|
|
.custom_created_at(Timestamp::from_secs(created_at as u64))
|
|
.finalize(&keys)
|
|
.expect("build metadata event");
|
|
pagination.record_event(&event);
|
|
}
|
|
let note = EventBuilder::new(Kind::TextNote, "one note")
|
|
.custom_created_at(Timestamp::from_secs(100))
|
|
.finalize(&keys)
|
|
.expect("build text note");
|
|
pagination.record_event(¬e);
|
|
|
|
let next_filters = pagination.next_page_filters();
|
|
assert_eq!(next_filters.len(), 1);
|
|
assert_eq!(next_filters[0].until, Some(Timestamp::from_secs(1)));
|
|
assert!(
|
|
next_filters[0]
|
|
.kinds
|
|
.as_ref()
|
|
.unwrap()
|
|
.contains(&Kind::Metadata),
|
|
"the full metadata filter should advance"
|
|
);
|
|
assert!(
|
|
!next_filters[0]
|
|
.kinds
|
|
.as_ref()
|
|
.unwrap()
|
|
.contains(&Kind::TextNote),
|
|
"the partial text-note filter should not advance"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deferred_consolidation_runs_only_after_final_batch_completion() {
|
|
let relay_url = "wss://relay.example";
|
|
let mut deferred = DeferredConsolidations::default();
|
|
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
|
|
|
|
assert!(
|
|
!deferred.request(relay_url, true),
|
|
"an in-flight batch must defer instead of waiting in the sync actor"
|
|
);
|
|
notify_deferred_consolidation_after_batch_completion(&deferred, Some(&sender), relay_url);
|
|
assert_eq!(
|
|
receiver.try_recv().unwrap(),
|
|
relay_url,
|
|
"batch completion must wake the actor without awaiting under its lock"
|
|
);
|
|
assert!(
|
|
!deferred.take_ready(relay_url, true),
|
|
"consolidation must remain queued while another batch is pending"
|
|
);
|
|
|
|
notify_deferred_consolidation_after_batch_completion(&deferred, Some(&sender), relay_url);
|
|
assert_eq!(receiver.try_recv().unwrap(), relay_url);
|
|
assert!(
|
|
deferred.take_ready(relay_url, false),
|
|
"the final batch completion must make consolidation runnable"
|
|
);
|
|
assert!(
|
|
!deferred.take_ready(relay_url, false),
|
|
"taking deferred work must be idempotent"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reset_or_disconnect_cancels_deferred_consolidation_and_stale_wakeup() {
|
|
for reason in ["reset", "disconnect"] {
|
|
let relay_url = format!("wss://{reason}.example");
|
|
let mut deferred = DeferredConsolidations::default();
|
|
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
|
|
|
|
assert!(!deferred.request(&relay_url, true));
|
|
notify_deferred_consolidation_after_batch_completion(
|
|
&deferred,
|
|
Some(&sender),
|
|
&relay_url,
|
|
);
|
|
assert!(deferred.cancel(&relay_url));
|
|
assert_eq!(receiver.try_recv().unwrap(), relay_url);
|
|
assert!(
|
|
!deferred.take_ready(&relay_url, false),
|
|
"a queued wakeup must not resurrect consolidation after {reason}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn failed_pagination_completes_only_a_fully_drained_batch() {
|
|
let relay_url = "wss://pagination.example";
|
|
let still_pending = SubscriptionId::new("still-pending");
|
|
let make_batch = |batch_id, outstanding_subs| PendingBatch {
|
|
batch_id,
|
|
items: PendingItems::default(),
|
|
outstanding_subs,
|
|
sync_method: SyncMethod::ReqEose,
|
|
pagination_state: HashMap::new(),
|
|
requested_event_ids: None,
|
|
received_event_ids: None,
|
|
retry_count: 0,
|
|
failed: false,
|
|
};
|
|
let mut pending = HashMap::from([(
|
|
relay_url.to_string(),
|
|
vec![
|
|
make_batch(41, HashSet::new()),
|
|
make_batch(42, HashSet::from([still_pending])),
|
|
],
|
|
)]);
|
|
|
|
let completed = take_drained_batch_as_failed(&mut pending, relay_url, 41)
|
|
.expect("failed pagination must complete a drained batch");
|
|
assert!(completed.failed);
|
|
assert!(take_drained_batch_as_failed(&mut pending, relay_url, 42).is_none());
|
|
assert_eq!(pending[relay_url].len(), 1);
|
|
assert!(!pending[relay_url][0].failed);
|
|
}
|
|
|
|
#[test]
|
|
fn rate_limited_pagination_keeps_batch_pending_without_actor_wait() {
|
|
let relay_url = "wss://pagination.example";
|
|
let completed_sub = SubscriptionId::new("completed-page");
|
|
let mut batch = PendingBatch {
|
|
batch_id: 73,
|
|
items: PendingItems::default(),
|
|
outstanding_subs: HashSet::new(),
|
|
sync_method: SyncMethod::ReqEose,
|
|
pagination_state: HashMap::new(),
|
|
requested_event_ids: None,
|
|
received_event_ids: None,
|
|
retry_count: 0,
|
|
failed: false,
|
|
};
|
|
|
|
let deferred = mark_deferred_pagination(&mut batch, &completed_sub);
|
|
assert!(batch.outstanding_subs.contains(&deferred));
|
|
|
|
let mut pending = HashMap::from([(relay_url.to_string(), vec![batch])]);
|
|
assert!(
|
|
take_drained_batch_as_failed(&mut pending, relay_url, 73).is_none(),
|
|
"the deferred exact page must prevent a generic historic batch from being confirmed early"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn relay_disconnect_waits_for_pending_and_historic_sync_work() {
|
|
let mut source = RelayState {
|
|
connection_status: ConnectionStatus::Syncing,
|
|
..RelayState::default()
|
|
};
|
|
|
|
assert!(
|
|
!source.is_disconnect_candidate(true, false),
|
|
"a source with missing-ID subscriptions in flight must stay connected"
|
|
);
|
|
assert!(
|
|
!source.is_disconnect_candidate(false, false),
|
|
"the historic-sync batch window must stay connected even between batches"
|
|
);
|
|
|
|
source.connection_status = ConnectionStatus::Connected;
|
|
assert!(
|
|
!source.is_disconnect_candidate(false, false),
|
|
"an active relay cannot be disconnected before historic sync settles"
|
|
);
|
|
|
|
source.historic_sync_completed = true;
|
|
assert!(
|
|
source.is_disconnect_candidate(false, false),
|
|
"an empty relay can be released after historic sync settles"
|
|
);
|
|
assert!(
|
|
!source.is_disconnect_candidate(true, false),
|
|
"a later pending batch still keeps an otherwise empty relay connected"
|
|
);
|
|
assert!(
|
|
!source.is_disconnect_candidate(false, true),
|
|
"unconfirmed desired invitation work keeps its source connected"
|
|
);
|
|
|
|
source
|
|
.repos
|
|
.insert("30617:maintainer:repository".to_string());
|
|
assert!(
|
|
!source.is_disconnect_candidate(false, false),
|
|
"confirmed repository work keeps the source connected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn disconnected_empty_relay_can_still_be_cleaned_up() {
|
|
let mut source = RelayState::default();
|
|
|
|
assert!(source.is_disconnect_candidate(false, false));
|
|
assert!(!source.is_disconnect_candidate(false, true));
|
|
|
|
source.historic_sync_completed = true;
|
|
source.connection_status = ConnectionStatus::Connecting;
|
|
assert!(
|
|
!source.is_disconnect_candidate(false, false),
|
|
"cleanup must not race an in-flight connection worker"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rejected_events_index_tracks_announcements() {
|
|
// Create a rejected events index with 2 minute hot cache, 7 day cold index
|
|
let rejected_index = Arc::new(RejectedEventsIndex::new(
|
|
Duration::from_secs(120),
|
|
Duration::from_secs(604800),
|
|
));
|
|
|
|
// Create test announcement event (kind 30617) with 'd' tag
|
|
let keys = Keys::generate();
|
|
let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "test content")
|
|
.tag(nostr_sdk::prelude::Tag::custom("d", vec!["test-repo"]))
|
|
.finalize(&keys)
|
|
.unwrap();
|
|
|
|
// Verify index is empty
|
|
assert_eq!(rejected_index.hot_cache_len(), 0);
|
|
assert_eq!(rejected_index.cold_index_len(), 0);
|
|
|
|
// Simulate rejection by adding to index
|
|
rejected_index.add_announcement(
|
|
announcement.clone(),
|
|
announcement.pubkey,
|
|
"test-repo".to_string(),
|
|
rejected_index::RejectionReason::DoesNotListService,
|
|
);
|
|
|
|
// Verify event is tracked in both tiers
|
|
assert!(rejected_index.contains(&announcement.id));
|
|
assert_eq!(rejected_index.hot_cache_len(), 1);
|
|
assert_eq!(rejected_index.cold_index_len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rejected_events_excluded_from_negentropy() {
|
|
// Create indices
|
|
let purgatory_ids: HashSet<EventId> = HashSet::new();
|
|
let rejected_index =
|
|
RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800));
|
|
|
|
// Create test event IDs
|
|
let _rejected_id =
|
|
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
|
.unwrap();
|
|
let valid_id =
|
|
EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
|
|
.unwrap();
|
|
|
|
// Add rejected event to index
|
|
let keys = Keys::generate();
|
|
let rejected_event = EventBuilder::new(Kind::GitRepoAnnouncement, "rejected")
|
|
.tag(nostr_sdk::prelude::Tag::custom("d", vec!["rejected-repo"]))
|
|
.finalize(&keys)
|
|
.unwrap();
|
|
|
|
// Override the event ID for testing (we need a specific ID)
|
|
// Since we can't override the ID, let's use the actual event ID
|
|
let rejected_id = rejected_event.id;
|
|
rejected_index.add_announcement(
|
|
rejected_event,
|
|
keys.public_key(),
|
|
"rejected-repo".to_string(),
|
|
rejected_index::RejectionReason::DoesNotListService,
|
|
);
|
|
|
|
// Get rejected IDs from index
|
|
let rejected_ids = rejected_index.get_all_event_ids();
|
|
|
|
// Simulate negentropy reconciliation result
|
|
let mut remote_ids = HashSet::new();
|
|
remote_ids.insert(rejected_id);
|
|
remote_ids.insert(valid_id);
|
|
|
|
// Exclude rejected and purgatory events
|
|
let excluded_ids: HashSet<EventId> = purgatory_ids.union(&rejected_ids).cloned().collect();
|
|
let filtered_ids: HashSet<EventId> =
|
|
remote_ids.difference(&excluded_ids).cloned().collect();
|
|
|
|
// Verify rejected event is excluded
|
|
assert!(!filtered_ids.contains(&rejected_id));
|
|
assert!(filtered_ids.contains(&valid_id));
|
|
assert_eq!(filtered_ids.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_missing_d_tag_event_excluded_from_refetch_after_rejection() {
|
|
let purgatory_ids: HashSet<EventId> = HashSet::new();
|
|
let rejected_index =
|
|
RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800));
|
|
|
|
// Announcement without a 'd' tag: structurally malformed, no
|
|
// repository identifier exists to key the two-tier index with
|
|
let keys = Keys::generate();
|
|
let malformed = EventBuilder::new(Kind::GitRepoAnnouncement, "no d tag")
|
|
.finalize(&keys)
|
|
.unwrap();
|
|
assert!(!malformed.tags.iter().any(|t| t.kind() == "d"));
|
|
|
|
// Rejection path tracks it by exact ID
|
|
rejected_index.add_unrecoverable(malformed.id, malformed.kind.as_u16());
|
|
|
|
// Live/REQ path consults contains() before processing
|
|
assert!(rejected_index.contains(&malformed.id));
|
|
|
|
// Historic negentropy path excludes it from re-download
|
|
let rejected_ids = rejected_index.get_all_event_ids();
|
|
let excluded_ids: HashSet<EventId> = purgatory_ids.union(&rejected_ids).cloned().collect();
|
|
assert!(excluded_ids.contains(&malformed.id));
|
|
}
|
|
|
|
#[test]
|
|
fn test_negentropy_missing_event_detection() {
|
|
// Simulate scenario where relay returns fewer events than requested
|
|
// This tests the core logic for detecting missing events
|
|
|
|
// Requested 5 events from negentropy diff
|
|
let mut requested: HashSet<EventId> = HashSet::new();
|
|
for i in 1u8..=5 {
|
|
let id = EventId::from_hex(&format!("{:0>64}", format!("{:x}", i))).unwrap();
|
|
requested.insert(id);
|
|
}
|
|
|
|
// Only received 3 events (simulating relay limit)
|
|
let mut received: HashSet<EventId> = HashSet::new();
|
|
for i in 1u8..=3 {
|
|
let id = EventId::from_hex(&format!("{:0>64}", format!("{:x}", i))).unwrap();
|
|
received.insert(id);
|
|
}
|
|
|
|
// Calculate missing events
|
|
let missing: Vec<EventId> = requested.difference(&received).cloned().collect();
|
|
|
|
// Should have 2 missing events (IDs 4 and 5)
|
|
assert_eq!(missing.len(), 2);
|
|
assert_eq!(requested.len(), 5);
|
|
assert_eq!(received.len(), 3);
|
|
|
|
// Verify the specific missing IDs
|
|
let id_4 = EventId::from_hex(&format!("{:0>64}", format!("{:x}", 4u8))).unwrap();
|
|
let id_5 = EventId::from_hex(&format!("{:0>64}", format!("{:x}", 5u8))).unwrap();
|
|
assert!(missing.contains(&id_4));
|
|
assert!(missing.contains(&id_5));
|
|
}
|
|
|
|
#[test]
|
|
fn test_negentropy_all_events_received() {
|
|
// Simulate scenario where all requested events are received
|
|
let mut requested: HashSet<EventId> = HashSet::new();
|
|
for i in 1u8..=3 {
|
|
let id = EventId::from_hex(&format!("{:0>64}", format!("{:x}", i))).unwrap();
|
|
requested.insert(id);
|
|
}
|
|
|
|
// Received all 3 events
|
|
let received = requested.clone();
|
|
|
|
// Calculate missing events
|
|
let missing: Vec<EventId> = requested.difference(&received).cloned().collect();
|
|
|
|
// Should have no missing events
|
|
assert!(missing.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_pending_batch_negentropy_fields() {
|
|
// Test that PendingBatch properly tracks negentropy-specific fields
|
|
let batch = PendingBatch {
|
|
batch_id: 1,
|
|
items: PendingItems::default(),
|
|
outstanding_subs: HashSet::new(),
|
|
sync_method: SyncMethod::Negentropy,
|
|
pagination_state: HashMap::new(),
|
|
requested_event_ids: Some(HashSet::new()),
|
|
received_event_ids: Some(HashSet::new()),
|
|
retry_count: 0,
|
|
failed: false,
|
|
};
|
|
|
|
assert!(batch.requested_event_ids.is_some());
|
|
assert!(batch.received_event_ids.is_some());
|
|
assert_eq!(batch.sync_method, SyncMethod::Negentropy);
|
|
assert_eq!(batch.retry_count, 0);
|
|
assert!(!batch.failed);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pending_batch_req_eose_fields() {
|
|
// Test that REQ+EOSE batches don't use negentropy fields
|
|
let batch = PendingBatch {
|
|
batch_id: 1,
|
|
items: PendingItems::default(),
|
|
outstanding_subs: HashSet::new(),
|
|
sync_method: SyncMethod::ReqEose,
|
|
pagination_state: HashMap::new(),
|
|
requested_event_ids: None,
|
|
received_event_ids: None,
|
|
retry_count: 0,
|
|
failed: false,
|
|
};
|
|
|
|
assert!(batch.requested_event_ids.is_none());
|
|
assert!(batch.received_event_ids.is_none());
|
|
assert_eq!(batch.sync_method, SyncMethod::ReqEose);
|
|
assert!(!batch.failed);
|
|
}
|
|
}
|