30 KiB
GRASP-02: Proactive Sync - Design & Implementation
Overview
This document explains the proactive sync system that synchronizes repository data from external relays based on relay URLs listed in 30617 repository announcements. Key principles:
- Triggers call compute_actions → sync_computed_filters - Self-subscriber batches and connect/reconnect events trigger this flow
- Clear separation of live vs historic sync - Two distinct primitives with different purposes
- Layer 1 on connect, Layer 2+3 via AddFilters - L1 handled at connection time, L2+L3 flow through compute_actions
- Always clear PendingSyncIndex first - Before any reconnect/consolidate operation
- NIP-77 negentropy for historical sync - Efficient set reconciliation, fallback to REQ if unsupported
Data Model
RepoSyncIndex (Source of Truth)
/// 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>>>;
#[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/1619/1621 - that reference this repo
pub root_events: HashSet<EventId>,
}
RelaySyncIndex (Confirmed State + Connection)
/// What we have CONFIRMED syncing - includes connection state for integrated lifecycle.
/// Key: relay URL
pub type RelaySyncIndex = Arc<RwLock<HashMap<String, RelayState>>>;
/// Connection status for a relay
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionStatus {
/// Not currently connected
Disconnected,
/// Connection attempt in progress
Connecting,
/// Successfully connected and subscribed
Connected,
}
/// Complete state for a single relay - combines sync needs with connection lifecycle
#[derive(Debug)]
pub struct RelayState {
/// Repos we have confirmed syncing from this relay
pub 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,
}
impl RelayState {
/// 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.root_events.clear();
}
}
PendingSyncIndex (In-Flight Batches)
/// 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,
}
/// 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>>>>;
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 until missing event ids identified
pub outstanding_subs: HashSet<SubscriptionId>,
/// The sync method used for this batch
pub sync_method: SyncMethod,
}
#[derive(Debug, Clone, Default)]
pub struct PendingItems {
pub repos: HashSet<String>,
pub root_events: HashSet<EventId>,
}
Connection Lifecycle
Object vs Connection Lifecycle
Key Principle: RelayConnection objects persist forever, WebSocket connections are transient.
- RelayConnection object: Created once via
register_relay(), stored in HashMap permanently - WebSocket connection: Transient, established via
try_connect_relay(), dies on disconnect - Event loop: Spawned by
handle_connect_or_reconnect(), must be respawned after every reconnection
Connection State Machine
stateDiagram-v2
[*] --> Disconnected: discover relay → register_relay()
Disconnected --> Connecting: retry_disconnected_relays → try_connect_relay
Connecting --> Connected: success → handle_connect_or_reconnect
Connecting --> Disconnected: failure + record in health tracker
Connected --> Disconnected: connection lost → handle_disconnect
Connected --> [*]: intentional disconnect via check_disconnects
note right of Disconnected: disconnected_at set for 15min rule<br/>RelayConnection kept in HashMap
note right of Connected: last_connected tracked for since filter<br/>Event loop spawned here
note right of Connecting: connection attempt with timeout
Connection Flow Methods
| Method | Purpose | When Called | Actions |
|---|---|---|---|
register_relay() |
Initialize relay tracking | Discovery via RepoSyncIndex | Creates RelayConnection, stores in HashMap, returns immediately |
try_connect_relay() |
Attempt connection | Periodic retry (500ms) | Calls connect_and_subscribe, sends notification on success |
handle_connect_or_reconnect() |
Setup after connection | ConnectNotification received | Spawns event loop, updates state, decides sync strategy |
handle_disconnect() |
Cleanup after disconnect | DisconnectNotification received | Updates state, clears pending, KEEPS RelayConnection |
retry_disconnected_relays() |
Periodic reconnection | Every 500ms | For each ready relay: try_connect_relay() |
Event Loop Lifecycle
Critical: Event loops die on disconnect and cannot be reused.
flowchart LR
CONN[Connection Success] --> SPAWN[handle_connect_or_reconnect<br/>spawns event loop]
SPAWN --> RUN[run_event_loop active]
RUN --> DISC[Disconnect detected]
DISC --> EXIT[Event loop breaks + task exits]
EXIT --> RETRY[retry_disconnected_relays]
RETRY --> RECONN[try_connect_relay]
RECONN --> |success| SPAWN
Why respawn is required:
run_event_loop()breaks on RelayStatus::Disconnected- The spawned task completely exits
- Cannot resume terminated task - must spawn fresh
- Happens for both initial connection AND every reconnect
Core Architecture: Live vs Historic Sync
The sync system is built on two fundamental primitives that are clearly separated:
Sync Primitives
| Primitive | Purpose | Filter Modifier | Tracking |
|---|---|---|---|
sync_live() |
Ongoing event stream | limit: 0 |
Not tracked |
historic_sync() |
Catch up on past events | Optional since |
PendingSyncIndex |
Why limit: 0 for Live Sync?
| Approach | Pros | Cons |
|---|---|---|
since: now |
Intuitive | Time-sensitive, clock skew issues |
limit: 0 |
Deterministic, mirrors filter structure | Less intuitive name |
limit: 0 is better because:
- No time dependency: Doesn't depend on synchronized clocks
- Mirrors historic filters: Same tag structure, just different limit
- State reconstruction: Can rebuild from repo/event lists without timestamps
Layer Strategy
| Layer | Content | When Subscribed | Managed By |
|---|---|---|---|
| Layer 1 | 30617 Announcements, 30618 Maintainers | On connect (any type) | Connection lifecycle |
| Layer 2 | Events tagging our repos (a/A/q tags) | Via AddFilters | compute_actions |
| Layer 3 | Events tagging root events (e/E/q tags) | Via AddFilters | compute_actions |
Key insight: Layer 1 is connection-level (handled at connect time), Layer 2+3 are item-level (flow through compute_actions → sync_computed_filters).
Triggers and Flow
What Triggers compute_actions → sync_computed_filters?
| Trigger | When | What Happens |
|---|---|---|
| Self-subscriber batch fires | New events discovered on own relay | Update RepoSyncIndex → compute_actions |
| fresh_start() | Initial connect, long_reconnect, daily | After L1 setup → compute_actions |
| quick_reconnect() | Reconnect < 15 minutes | After L1+L2+L3 catchup → compute_actions |
| consolidate() | Filter count > threshold | After live rebuild → compute_actions |
The Core Flow
flowchart TB
TRIGGER[Trigger fires] --> CA[compute_actions]
CA --> |derives from| RSI[RepoSyncIndex]
CA --> |subtracts| RLI[RelaySyncIndex]
CA --> |subtracts| PSI[PendingSyncIndex]
CA --> |produces| AF[AddFilters actions]
AF --> SFRE[sync_computed_filters]
SFRE --> LIVE[sync_live - L2+L3]
SFRE --> HIST[historic_sync - L2+L3]
HIST --> PSI_UPDATE[Update PendingSyncIndex]
PSI_UPDATE --> |EOSE received| CONFIRM[Move to RelaySyncIndex]
Flow Scenarios
Scenario 1: Fresh Start (Initial Connect / Long Reconnect / Daily Sync)
flowchart TB
DISC[Relay discovered via RepoSyncIndex] --> REG[register_relay]
REG --> CREATE[Create RelayConnection, store in HashMap]
CREATE --> RET[Returns immediately]
RET --> LOOP[retry_disconnected_relays - 500ms periodic]
LOOP --> CHECK[health_tracker.should_attempt_connection?]
CHECK --> |ready| TRY[try_connect_relay]
TRY --> CONN[connection.connect_and_subscribe]
CONN --> |success| NOTIFY[Send ConnectNotification]
NOTIFY --> HANDLE[handle_connect_or_reconnect called]
HANDLE --> UPD[Update state to Connected]
UPD --> SPAWN[Spawn event loop + processor]
SPAWN --> STRAT[Decide strategy: fresh_start]
STRAT --> CLEAR_PSI[Clear PendingSyncIndex]
CLEAR_PSI --> CLEAR_RSI[Clear RelaySyncIndex]
CLEAR_RSI --> L1_LIVE[L1: sync_live - announcements]
L1_LIVE --> L1_HIST[L1: historic_sync - no since]
L1_HIST --> NEG{NIP-77 supported?}
NEG --> |yes| NEGENTROPY[negentropy sync]
NEG --> |no| REQ[REQ+EOSE]
NEGENTROPY --> CA[compute_actions]
REQ --> CA
CA --> |empty RelaySyncIndex| AF[AddFilters for ALL repos]
AF --> SFRE[sync_computed_filters]
SFRE --> L23_LIVE[L2+L3: sync_live]
SFRE --> L23_HIST[L2+L3: historic_sync]
L23_HIST --> PB[Create PendingBatch]
PB --> EOSE[Wait for EOSE]
EOSE --> CONFIRM[Move items to RelaySyncIndex]
Key points:
- Always clear PendingSyncIndex first, then RelaySyncIndex
- L1 live + L1 historic (uses negentropy if available)
- Empty RelaySyncIndex means diff produces AddFilters for everything
- L2+L3 flow through sync_computed_filters with proper pending tracking
Scenario 2: Quick Reconnect (< 15 minutes)
flowchart TB
DISC[Connection lost detected] --> LOOP_EXIT[Event loop breaks]
LOOP_EXIT --> TASK_EXIT[Event processor task exits]
TASK_EXIT --> NOTIFY_DISC[Send DisconnectNotification]
NOTIFY_DISC --> HANDLE_DISC[handle_disconnect called]
HANDLE_DISC --> UPD_STATE[Update state to Disconnected]
UPD_STATE --> MARK[Set disconnected_at = now]
MARK --> CLEAR[Clear pending batches]
CLEAR --> KEEP[Keep RelayConnection in HashMap]
KEEP --> WAIT[Wait < 15min]
WAIT --> RETRY[retry_disconnected_relays - 500ms]
RETRY --> CHECK[health_tracker checks backoff]
CHECK --> |ready| TRY[try_connect_relay]
TRY --> CONN[connection.connect_and_subscribe]
CONN --> |success| NOTIFY[Send ConnectNotification]
NOTIFY --> RECONN[handle_connect_or_reconnect]
RECONN --> UPD_CONN[Update state to Connected]
UPD_CONN --> SPAWN[Spawn NEW event loop + processor]
SPAWN --> STRAT[Decide strategy: quick_reconnect]
STRAT --> CLEAR_PSI[Clear PendingSyncIndex]
CLEAR_PSI --> L1_LIVE[L1: sync_live - announcements]
L1_LIVE --> L1_HIST[L1: historic_sync WITH since]
L1_HIST --> RECON[reconstruct_filters from RelaySyncIndex]
RECON --> L23_LIVE[L2+L3: sync_live]
RECON --> L23_HIST[L2+L3: historic_sync WITH since]
L23_HIST --> CA[compute_actions]
CA --> |check for new items| AF{New items?}
AF --> |yes| SFRE[sync_computed_filters]
AF --> |no| DONE[Done]
SFRE --> PB[Create PendingBatch]
Key points:
- Clear PendingSyncIndex first (old subscriptions are dead)
- L1 live (always on any connection)
- L1 historic WITH since (catches up missed announcements)
- L2+L3 rebuilt from RelaySyncIndex (confirmed state preserved)
- compute_actions checks for any NEW items discovered during catchup
Scenario 3: Long Reconnect (> 15 minutes)
flowchart TB
RECONN[Connection restored > 15min] --> METRIC[Record disconnect/reconnect metric]
METRIC --> FRESH[fresh_start]
FRESH --> |same as initial connect| DONE[Full sync initiated]
Key points:
- Records disconnect/reconnect as a metric
- Delegates to fresh_start() - same as initial connect
- State too stale to trust, start fresh
Scenario 4: Consolidation (Filter Count > Threshold)
flowchart TB
CHECK[Filter count check] --> THRESHOLD{count > 70?}
THRESHOLD --> |yes| CLEAR_PSI[Clear PendingSyncIndex]
CLEAR_PSI --> UNSUB[unsubscribe_all]
UNSUB --> RECON[reconstruct_filters from RelaySyncIndex]
RECON --> L1_LIVE[L1: sync_live]
RECON --> L23_LIVE[L2+L3: sync_live]
L23_LIVE --> CA[compute_actions]
CA --> |check for new items| AF{New items?}
AF --> |yes| SFRE[sync_computed_filters]
AF --> |no| DONE[Done]
THRESHOLD --> |no| SKIP[Continue normally]
Key points:
- Clear PendingSyncIndex first
- NO historic sync needed - items already synced/syncing
- Only rebuilds live subscriptions from confirmed state
- compute_actions catches any new items that need syncing
Scenario 5: Daily Sync (23-25h Random Timer)
flowchart TB
TIMER[Daily timer fires] --> FRESH[fresh_start]
FRESH --> |NO disconnect metric| DONE[Full sync initiated]
Key points:
- Same as fresh_start() but WITHOUT recording disconnect/reconnect metric
- Ensures consistency, detects any drift accumulated over 24 hours
Scenario 6: Self-Subscriber Batch
flowchart TB
EVENTS[Events from own relay] --> QUEUE[Queue to pending batch]
QUEUE --> TIMER[Batch timer fires - 5 seconds]
TIMER --> UPDATE[Update RepoSyncIndex]
UPDATE --> CA[compute_actions]
CA --> |new repos/events discovered| AF[AddFilters]
AF --> SFRE[sync_computed_filters]
SFRE --> LIVE[sync_live - L2+L3]
SFRE --> HIST[historic_sync - L2+L3]
Key points:
- Self-subscriber monitors own relay for 30617, 1617, 1618, 1619, 1621
- Batches events (5 second window)
- Updates RepoSyncIndex, then compute_actions finds new work
- New items flow through sync_computed_filters
Core Algorithms
derive_relay_targets
Transforms the repo-centric RepoSyncIndex into a relay-centric view. For each relay URL mentioned in any repo's announcements, collects all the repos and root events that should be synced from that relay.
// Conceptual: inverts repo → relays to relay → repos
fn derive_relay_targets(repo_index: &HashMap<String, RepoSyncNeeds>)
-> HashMap<String, RelaySyncNeeds>
compute_actions (Three-Way Diff)
This is the ONLY decision point for what NEW subscriptions to create.
Performs a three-way diff: target - pending - confirmed = new
- targets: What we want (from derive_relay_targets)
- pending: What's already in-flight awaiting EOSE
- confirmed: What's already confirmed syncing
Only creates AddFilters actions for items not already pending or confirmed. Skips disconnected relays (they will get AddFilters on reconnect).
fn compute_actions(
targets: &HashMap<String, RelaySyncNeeds>,
pending: &PendingSyncIndex,
confirmed: &RelaySyncIndex,
) -> Vec<AddFilters>
Method Specifications
Primitives
sync_live() - Live Subscriptions
/// Set up live subscription (filters with limit: 0)
///
/// - Uses `limit: 0` to receive only new events
/// - NOT tracked in PendingSyncIndex (state reconstructable)
async fn sync_live(&self, relay_url: &str, filters: &[Filter])
historic_sync() - Historical Sync Dispatcher
/// Dispatch to appropriate historic sync method based on relay capabilities
///
/// Both paths update PendingSyncIndex to ensure consistent lifecycle tracking.
async fn historic_sync(
&mut self,
relay_url: &str,
filters: Vec<Filter>,
items: PendingItems,
since: Option<Timestamp>,
) -> Option<u64> // Returns batch_id
Dispatches to:
historic_sync_negentropy()- NIP-77 parallel sync (if supported)historic_sync_legacy()- REQ+EOSE fallback
Building Blocks
reconstruct_filters() - Rebuild from Confirmed State
/// Reconstruct filters from RelaySyncIndex (confirmed state ONLY)
///
/// Returns raw Vec<Filter> for L1+L2+L3.
/// Used by: quick_reconnect, consolidate
/// Does NOT include pending items - those flow through AddFilters path.
async fn reconstruct_filters(&self, relay_url: &str) -> Vec<Filter>
sync_computed_filters() - Handle New AddFilters
/// Process AddFilters action (from compute_actions)
///
/// Orchestrates both live and historic sync for NEW items:
/// 1. sync_live() - set up permanent L2+L3 subscriptions
/// 2. historic_sync() - catch up on past events
///
/// This is specifically for NEW filter discovery.
async fn sync_computed_filters(
&mut self,
action: AddFilters,
since: Option<Timestamp>,
) -> Option<u64>
Top-Level Entry Points
fresh_start() - Clean Slate Sync
/// Fresh start - clears state and does full sync
///
/// Called by: initial connect, long_reconnect, daily_sync
///
/// Flow:
/// 1. Clear PendingSyncIndex
/// 2. Clear RelaySyncIndex
/// 3. L1 live + L1 historic (negentropy if available)
/// 4. compute_actions → AddFilters → sync_computed_filters for L2+L3
async fn fresh_start(&mut self, relay_url: &str)
quick_reconnect() - Short Disconnection Recovery
/// Quick reconnect - for disconnections < 15 minutes
///
/// Flow:
/// 1. Clear PendingSyncIndex
/// 2. L1 live + L1 historic(since)
/// 3. reconstruct_filters → L2+L3 live + L2+L3 historic(since)
/// 4. compute_actions for any new items
async fn quick_reconnect(&mut self, relay_url: &str, since: Timestamp)
long_reconnect() - Extended Disconnection Recovery
/// Long reconnect - for disconnections > 15 minutes
///
/// Flow:
/// 1. Record disconnect/reconnect metric
/// 2. fresh_start()
async fn long_reconnect(&mut self, relay_url: &str)
daily_sync() - Scheduled Full Refresh
/// Daily sync - full refresh without disconnect metrics
///
/// Flow: fresh_start() (no disconnect metric recorded)
async fn daily_sync(&mut self, relay_url: &str)
consolidate() - Filter Count Reduction
/// Consolidate subscriptions when filter count exceeds threshold
///
/// Flow:
/// 1. Clear PendingSyncIndex
/// 2. unsubscribe_all
/// 3. reconstruct_filters → sync_live only (L1+L2+L3)
/// 4. compute_actions for any new items
///
/// NO historic sync - items already synced, just reducing subscriptions
async fn consolidate(&mut self, relay_url: &str)
handle_new_sync_filters() - New Filter Discovery
/// Handle AddFilters action from compute_actions
///
/// Flow:
/// 1. Check/spawn connection if needed
/// 2. maybe_consolidate (check filter threshold)
/// 3. sync_computed_filters
async fn handle_new_sync_filters(&mut self, action: AddFilters)
Method Relationships Summary
fresh_start(relay_url) // Initial/long_reconnect/daily
├──> Clear PendingSyncIndex
├──> Clear RelaySyncIndex
├──> L1: sync_live(announcement_filter)
├──> L1: historic_sync(announcement_filter, None)
└──> compute_actions → AddFilters → sync_computed_filters (L2+L3)
quick_reconnect(relay_url, since) // Disconnected < 15 min
├──> Clear PendingSyncIndex
├──> L1: sync_live(announcement_filter)
├──> L1: historic_sync(announcement_filter, since)
├──> reconstruct_filters() → L2+L3 filters
├──> L2+L3: sync_live(filters)
├──> L2+L3: historic_sync(filters, since)
└──> compute_actions → AddFilters → sync_computed_filters (new items only)
long_reconnect(relay_url) // Disconnected > 15 min
├──> Record disconnect/reconnect metric
└──> fresh_start()
daily_sync(relay_url) // Timer fires
└──> fresh_start() // No disconnect metric
consolidate(relay_url) // Filter count > threshold
├──> Clear PendingSyncIndex
├──> unsubscribe_all()
├──> reconstruct_filters() → L1+L2+L3 filters
├──> sync_live(filters) // Live only, NO historic
└──> compute_actions → AddFilters → sync_computed_filters (new items only)
handle_new_sync_filters(action) // New filter discovery
├──> Check/spawn connection
├──> maybe_consolidate()
└──> sync_computed_filters(action, None)
sync_computed_filters(action, since) // Process AddFilters
├──> sync_live(action.filters) // L2+L3 live
└──> historic_sync(action.filters, since) // L2+L3 historic
├── historic_sync_negentropy() // Parallel, updates Pending
└── historic_sync_legacy() // REQ+EOSE, updates Pending
Filter Building (Three-Layer Strategy)
Layer 1: Announcements
- Kinds: 30617 (Repository Announcements), 30618 (Maintainer Lists)
- When subscribed: On connect (any type) - handled by connection lifecycle
- Function:
build_announcement_filter(since: Option<Timestamp>) - 30618 is ONLY synced from remote relays, not self-subscribed
Layer 2: Events Tagging Our Repos
- Tags: lowercase
a, uppercaseA, andqtags for comprehensive coverage - Batching: Per 100 repo refs
- Function:
build_repo_tag_filters(repos, since)
Layer 3: Events Tagging Our Root Events
- Tags: lowercase
e, uppercaseE, andqtags for comprehensive coverage - Batching: Per 100 event IDs
- Function:
build_root_event_tag_filters(root_events, since)
Combined Layer 2+3
The build_layer2_and_layer3_filters() function combines both layers. Used by:
sync_computed_filtersfor new item subscriptionsreconstruct_filtersfor rebuilding from confirmed state
NIP-77 Negentropy Sync
What is Negentropy?
NIP-77 defines the negentropy protocol for efficient event set comparison. Instead of requesting all events matching a filter (REQ+EOSE), negentropy allows relays to compare fingerprints of their event sets and only transfer the differences.
When Negentropy is Used
Negentropy sync is attempted for:
- fresh_start() - Full sync without
since - daily_sync() - Periodic full refresh (via fresh_start)
- long_reconnect() - Via fresh_start
Negentropy is NOT used for:
- quick_reconnect() - Uses REQ with
since(more efficient for small gaps) - Live subscriptions - Always use REQ with
limit: 0
Fallback Behavior
If negentropy fails (relay doesn't support NIP-77, network error, etc.):
- A warning is logged (once per relay to avoid spam)
- The sync falls back to traditional REQ+EOSE
- No error is raised - fallback is automatic
State Flow Summary
flowchart TB
subgraph Input
SS[SelfSubscriber]
OWN[Own Relay]
end
subgraph RepoSyncIndex - What We Want
RSI[HashMap: Repo → Relays+Events]
end
subgraph Triggers
T1[Self-subscriber batch]
T2[fresh_start after L1]
T3[quick_reconnect after catchup]
T4[consolidate after live rebuild]
end
subgraph compute_actions - Decision Point
CA[Three-way diff: target - pending - confirmed]
end
subgraph PendingSyncIndex - In Flight
PSI[Vec PendingBatch per relay]
end
subgraph RelaySyncIndex - Confirmed State
RLI[RelayState per relay]
end
SS -->|subscribe| OWN
OWN -->|events| SS
SS -->|batch fires| RSI
RSI --> T1
T1 --> CA
T2 --> CA
T3 --> CA
T4 --> CA
PSI --> CA
RLI --> CA
CA -->|new items| AF[AddFilters]
AF --> SFRE[sync_computed_filters]
SFRE --> LIVE[sync_live L2+L3]
SFRE --> HIST[historic_sync L2+L3]
HIST --> PSI
PSI -->|EOSE| RLI
Key Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Live vs Historic separation | Two distinct primitives | Clear responsibilities, easier reasoning about state |
| Live sync method | limit: 0 not since: now |
No clock dependency, deterministic, mirrors filter structure |
| Layer 1 handling | On connect, separate from AddFilters | Connection-level concern, not item-level |
| Layer 2+3 handling | Via compute_actions → sync_computed_filters | Item-level, proper pending tracking |
| Clear PendingSyncIndex | Always first | Old subscriptions are dead, must clear before any operation |
| fresh_start vs long_reconnect | Same flow, different metrics | Reuse logic, distinguish intentional refresh from failure recovery |
| Consolidation | Live only, no historic | Items already synced, just reducing subscription count |
| compute_actions role | ONLY decision point for new work | Single place to reason about what needs syncing |
| NIP-77 negentropy | Try first on full sync, fallback | Efficient for large sets, graceful degradation |
Module Structure
src/sync/
├── mod.rs # SyncManager, main loop, data structures
├── algorithms.rs # derive_relay_targets(), compute_actions()
├── filters.rs # build_announcement_filter(), build_layer2_and_layer3_filters()
├── health.rs # RelayHealthTracker with exponential backoff
├── relay_connection.rs # RelayConnection, RelayEvent handling
├── self_subscriber.rs # SelfSubscriber with batching
└── metrics.rs # SyncMetrics for Prometheus
Health Tracking
The RelayHealthTracker manages connection health with exponential backoff:
- States: Healthy, Degraded, Dead
- Backoff:
base * 2^(failures-1), capped at max_backoff - Dead threshold: 24 hours of continuous failures
- Dead relay retry: Once per 24 hours
Bootstrap relays are never disconnected by the cleanup system, even if empty.
Self-Subscriber
The SelfSubscriber monitors our own relay for repository announcements and root events, updating the RepoSyncIndex.
Event Kinds Monitored
- 30617 - Repository Announcements (triggers discovery of repos listing our relay)
- 1617 - Patches (root events referencing repos)
- 1618 - Issues
- 1619 - Replies/Status
- 1621 - Pull Requests
Note: 30618 (Maintainer Lists) is NOT self-subscribed - only synced from remote relays.
Batching Flow
- Receive events from own relay subscription
- Queue to pending - announcements get repo ID + relay URLs; root events get repo ref + event ID
- Timer fires (configurable window, default 5 seconds) - does NOT reset on new events
- Process batch:
- Update
RepoSyncIndexwith discovered repos and root events - Call
compute_actions() - Send
AddFiltersactions to SyncManager →sync_computed_filters()
- Update
Disconnect Handling
The disconnect checker runs periodically (default: 60 seconds) to clean up empty relays:
- Finds relays with
repos.is_empty() && root_events.is_empty() - Skips bootstrap relays (
is_bootstrap == true) - Removes from relay_sync_index, pending_sync_index, and connections
- Disconnects the WebSocket connection
Also triggers reconnection attempts for disconnected relays that have pending work.