48 KiB
GRASP-02: Proactive Sync v4 - Health & Reconnection Design
Overview
This document presents v4 of the proactive sync design, refining the connection lifecycle and reconnection patterns. Key principles:
- Self-subscription as the only mechanism - No database initialization at startup
- compute_actions as single decision point - Determines what NEW subscriptions to create
- Two subscription paths on reconnect - Catch-up (retained, with since) vs new items (via compute_actions)
- Blank state = fresh sync - Empty confirmed state triggers full historical fetch
- Clear on disconnect, not reconnect - PendingSyncIndex cleared at event boundary
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>,
/// The active connection - None if disconnected
pub connection: Option<RelayConnection>,
}
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_u64().saturating_sub(disconnected.as_u64()) > 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)
/// 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>>>>;
#[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
pub outstanding_subs: HashSet<SubscriptionId>,
}
#[derive(Debug, Clone, Default)]
pub struct PendingItems {
pub repos: HashSet<String>,
pub root_events: HashSet<EventId>,
}
Connection Lifecycle State Machine
stateDiagram-v2
[*] --> Disconnected: discover relay via RepoSyncIndex
Disconnected --> Connecting: AddFilters triggers spawn_connection
Connecting --> Connected: success
Connecting --> Disconnected: failure + record in health tracker
Connected --> Disconnected: connection lost
Connected --> [*]: intentional disconnect via check_disconnects
note right of Disconnected: disconnected_at set for 15min rule
note right of Connected: last_connected tracked for since filter
Flow Scenarios
Scenario 1: Initial Connect via handle_connect_or_reconnect
flowchart TB
START[Startup] --> SS[Self-subscribe to own relay]
SS --> |no since filter| EVENTS[Receive historical events]
EVENTS --> RSI[Update RepoSyncIndex]
RSI --> DT[derive_relay_targets]
DT --> CA[compute_actions with targets and empty confirmed]
CA --> AF[AddFilters for each relay]
AF --> SPAWN{Relay connected?}
SPAWN --> |no| CONN[spawn_connection]
CONN --> HC[handle_connect_or_reconnect]
SPAWN --> |yes| SUB
subgraph handle_connect_or_reconnect - Fresh Sync
HC --> CHECK_FRESH{is_fresh_sync?}
CHECK_FRESH --> |yes - no last_connected| L1[build_announcement_filter - no since]
L1 --> RCA[recompute_actions_for_relay]
end
RCA --> SUB[Subscribe Layer 2+3 filters via AddFilters]
SUB --> PB[Create PendingBatch]
PB --> EOSE[Wait for EOSE]
EOSE --> CONFIRM[Move items to confirmed repos/root_events]
Key points:
- No
sincefilter on initial connect - get full history handle_connect_or_reconnectdetectsis_fresh_syncvialast_connected.is_none()- Layer 1:
build_announcement_filter(None)- subscribed immediately without since - Layer 2+3: handled via
recompute_actions_for_relay→compute_actionswith PendingBatch tracking
Scenario 2: Quick Reconnect via handle_connect_or_reconnect - less than 15 minutes
flowchart TB
DISC[Connection lost] --> MARK[Set disconnected_at = now]
MARK --> CLEAR_PEND[Clear PendingSyncIndex for relay]
CLEAR_PEND --> WAIT[Wait for reconnection]
WAIT --> RECONN[Connection restored]
RECONN --> HC[handle_connect_or_reconnect]
subgraph handle_connect_or_reconnect - Quick Reconnect
HC --> CHECK{is_fresh_sync?}
CHECK --> |no - last_connected exists AND <15min| SINCE[since = last_connected - 15min]
SINCE --> L1[build_announcement_filter - with since]
L1 --> L23[rebuild_layer2_and_layer3 - with since]
L23 --> RCA[recompute_actions_for_relay]
end
RCA --> AF[AddFilters for new items only]
AF --> SUB[Subscribe]
SUB --> PB[Create PendingBatch]
PB --> EOSE[Wait for EOSE]
EOSE --> EXTEND[Extend confirmed state]
Key points:
- PendingSyncIndex cleared on disconnect (not reconnect)
handle_connect_or_reconnect:build_announcement_filter(Some(since))- Layer 1 with sincerebuild_layer2_and_layer3(since)- Layer 2+3 with sincerecompute_actions_for_relay- check for new items
- since = last_connected - 15min ensures we catch events during disconnection
Scenario 3: Stale Reconnect via handle_connect_or_reconnect - greater than 15 minutes
flowchart TB
RECONN[Connection restored] --> HC[handle_connect_or_reconnect]
subgraph handle_connect_or_reconnect - Stale Reconnect
HC --> CHECK{is_fresh_sync?}
CHECK --> |yes - disconnected >15min| CLEAR[clear_sync_state]
CLEAR --> L1[build_announcement_filter - no since]
L1 --> RCA[recompute_actions_for_relay]
end
RCA --> CA[compute_actions with empty confirmed]
CA --> AF[AddFilters for everything]
AF --> SUB[Subscribe - no since filter]
SUB --> PB[Create PendingBatch]
PB --> EOSE[Wait for EOSE]
EOSE --> CONFIRM[Populate confirmed state fresh]
Key points:
should_clear_state()returns true → triggers fresh sync- Same path as initial connect after clearing state
- Layer 1:
build_announcement_filter(None)- full history - Layer 2+3: handled via empty confirmed state → compute_actions generates AddFilters for everything
Scenario 4: Consolidation - Triggered on Filter Add
flowchart TB
AF[handle_add_filters called] --> COUNT{current + new > 70?}
COUNT --> |yes| CONSOLIDATE[consolidate]
CONSOLIDATE --> WAIT_PEND[wait_pending_complete]
WAIT_PEND --> CLOSE[unsubscribe_all]
CLOSE --> SINCE[since = now - 15min]
SINCE --> L1[build_announcement_filter - with since]
L1 --> L23[rebuild_layer2_and_layer3 - with since]
COUNT --> |no| SUB[Subscribe new filters]
SUB --> PB[Create PendingBatch]
Key points:
- Consolidation checked in
handle_add_filtersBEFORE adding new filters - After closing all subscriptions, re-subscribe:
build_announcement_filter(Some(since))- Layer 1 stays active with sincerebuild_layer2_and_layer3(since)- Layer 2+3 with since
since = now - 15minprevents re-fetching old events- Keeps confirmed state, just reduces filter count
Scenario 5: Daily Timer - 23 to 25h Random
flowchart TB
DAILY[Daily timer fires] --> CLOSE[unsubscribe_all]
CLOSE --> CLEAR_PEND[Clear PendingSyncIndex for relay]
CLEAR_PEND --> CLEAR_STATE[clear_sync_state]
CLEAR_STATE --> L1[build_announcement_filter - no since]
L1 --> RCA[recompute_actions_for_relay]
RCA --> CA[compute_actions with empty confirmed]
CA --> AF[AddFilters for everything]
AF --> SUB[Subscribe - no since filter]
SUB --> PB[Create PendingBatch]
PB --> EOSE[Wait for EOSE]
EOSE --> CONFIRM[Repopulate confirmed state]
Key points:
- Daily timer is a full fresh sync, NOT consolidation
- Clears both PendingSyncIndex and confirmed state
- Layer 1:
build_announcement_filter(None)- full history - Layer 2+3: via compute_actions with empty confirmed - full history
- Detects any state drift accumulated over 24 hours
Core Algorithms
1. derive_relay_targets
Transform RepoSyncIndex into per-relay sync targets:
/// Inverts RepoSyncIndex to get per-relay view
fn derive_relay_targets(
repo_index: &HashMap<String, RepoSyncNeeds>
) -> HashMap<String, RelaySyncNeeds> {
let mut targets: HashMap<String, RelaySyncNeeds> = HashMap::new();
for (repo_ref, needs) in repo_index {
for relay_url in &needs.relays {
let target = targets.entry(relay_url.clone()).or_default();
target.repos.insert(repo_ref.clone());
target.root_events.extend(needs.root_events.iter().cloned());
}
}
targets
}
2. compute_actions (Three-Way Diff)
This is the ONLY decision point for what NEW subscriptions to create.
/// Computes AddFilters for items that are:
/// - In targets (what we want)
/// - NOT in pending (already in-flight)
/// - NOT in confirmed (already confirmed)
fn compute_actions(
targets: &HashMap<String, RelaySyncNeeds>,
pending: &HashMap<String, Vec<PendingBatch>>,
confirmed: &HashMap<String, RelayState>,
) -> Vec<AddFilters> {
let mut actions = Vec::new();
for (relay_url, target) in targets {
// Skip disconnected relays - they will get AddFilters on reconnect
if let Some(state) = confirmed.get(relay_url) {
if state.connection_status != ConnectionStatus::Connected {
continue;
}
}
// Collect all pending items for this relay
let pending_repos: HashSet<_> = pending.get(relay_url)
.map(|batches| batches.iter()
.flat_map(|b| b.items.repos.iter().cloned())
.collect())
.unwrap_or_default();
let pending_events: HashSet<_> = pending.get(relay_url)
.map(|batches| batches.iter()
.flat_map(|b| b.items.root_events.iter().cloned())
.collect())
.unwrap_or_default();
// Collect confirmed items for this relay
let confirmed_repos = confirmed.get(relay_url)
.map(|c| &c.repos)
.unwrap_or(&HashSet::new());
let confirmed_events = confirmed.get(relay_url)
.map(|c| &c.root_events)
.unwrap_or(&HashSet::new());
// New = target - pending - confirmed
let new_repos: HashSet<_> = target.repos.iter()
.filter(|r| !pending_repos.contains(*r) && !confirmed_repos.contains(*r))
.cloned()
.collect();
let new_events: HashSet<_> = target.root_events.iter()
.filter(|e| !pending_events.contains(*e) && !confirmed_events.contains(*e))
.cloned()
.collect();
if !new_repos.is_empty() || !new_events.is_empty() {
let filters = build_filters(&new_repos, &new_events);
actions.push(AddFilters {
relay_url: relay_url.clone(),
repos: new_repos,
root_events: new_events,
filters,
});
}
}
actions
}
3. Filter Building Functions (Three-Layer Strategy)
The filter strategy uses three layers:
- Layer 1: Announcements (30617/30618) - subscribed ONCE on connect, NOT rebuilt during consolidation
- Layer 2: Events tagging our repos
- Layer 3: Events tagging our root events
Key insight: Layer 1 is connection-level (subscribe once), Layer 2+3 are item-level (managed by compute_actions and PendingBatch).
/// Layer 1: Announcements filter (kinds 30617 + 30618)
/// Subscribed ONCE on connect - NOT included in consolidation rebuilds.
/// Note: 30618 is ONLY synced from remote relays, not self-subscribed.
fn build_announcement_filter(since: Option<Timestamp>) -> Filter {
let filter = Filter::new().kinds([
Kind::Custom(30617), // Repository announcements
Kind::Custom(30618), // Maintainer lists
]);
match since {
Some(ts) => filter.since(ts),
None => filter,
}
}
/// Layer 2: Events tagging one of our repos
/// Uses lowercase a, uppercase A, and q tags for comprehensive coverage.
/// Batched per 100 repo refs.
fn tagged_one_of_our_repo_event_filters(
repos: &HashSet<String>,
since: Option<Timestamp>,
) -> Vec<Filter> {
let mut filters = Vec::new();
let repo_refs: Vec<_> = repos.iter().collect();
for chunk in repo_refs.chunks(100) {
let chunk_vec: Vec<&str> = chunk.iter().map(|s| s.as_str()).collect();
// Lowercase 'a' tag - standard addressable reference
let mut f1 = Filter::new()
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), chunk_vec.clone());
// Uppercase 'A' tag - some clients use this
let mut f2 = Filter::new()
.custom_tag(SingleLetterTag::uppercase(Alphabet::A), chunk_vec.clone());
// Quote 'q' tag - NIP-10 quote references to addressable events
let mut f3 = Filter::new()
.custom_tag(SingleLetterTag::lowercase(Alphabet::Q), chunk_vec);
if let Some(ts) = since {
f1 = f1.since(ts);
f2 = f2.since(ts);
f3 = f3.since(ts);
}
filters.push(f1);
filters.push(f2);
filters.push(f3);
}
filters
}
/// Layer 3: Events tagging one of our root events
/// Uses lowercase e, uppercase E, and q tags for comprehensive coverage.
/// Batched per 100 event IDs.
fn tagged_one_of_our_root_event_filters(
root_events: &HashSet<EventId>,
since: Option<Timestamp>,
) -> Vec<Filter> {
let mut filters = Vec::new();
let event_ids: Vec<String> = root_events.iter().map(|id| id.to_hex()).collect();
for chunk in event_ids.chunks(100) {
let chunk_vec: Vec<&str> = chunk.iter().map(|s| s.as_str()).collect();
// Lowercase 'e' tag - standard event reference
let mut f1 = Filter::new()
.custom_tag(SingleLetterTag::lowercase(Alphabet::E), chunk_vec.clone());
// Uppercase 'E' tag - some clients use this
let mut f2 = Filter::new()
.custom_tag(SingleLetterTag::uppercase(Alphabet::E), chunk_vec.clone());
// Quote 'q' tag - NIP-10 quote references to events
let mut f3 = Filter::new()
.custom_tag(SingleLetterTag::lowercase(Alphabet::Q), chunk_vec);
if let Some(ts) = since {
f1 = f1.since(ts);
f2 = f2.since(ts);
f3 = f3.since(ts);
}
filters.push(f1);
filters.push(f2);
filters.push(f3);
}
filters
}
/// Builds Layer 2 + Layer 3 filters only (NOT Layer 1)
/// Used by:
/// - compute_actions for incremental subscriptions
/// - consolidation rebuilds (Layer 1 remains active)
fn build_layer2_and_layer3_filters(
repos: &HashSet<String>,
root_events: &HashSet<EventId>,
since: Option<Timestamp>,
) -> Vec<Filter> {
let mut filters = Vec::new();
filters.extend(tagged_one_of_our_repo_event_filters(repos, since));
filters.extend(tagged_one_of_our_root_event_filters(root_events, since));
filters
}
Note: There is no build_all_filters function. Layer 1 is subscribed separately on connect, and Layer 2+3 are managed independently.
4. handle_add_filters (SyncManager)
impl SyncManager {
async fn handle_add_filters(&mut self, action: AddFilters) {
let AddFilters { relay_url, repos, root_events, filters } = action;
// Auto-spawn connection if needed
let state = self.relay_sync_index.read().await.get(&relay_url).cloned();
match state {
None => {
// New relay discovered - create entry and spawn connection
self.relay_sync_index.write().await.insert(
relay_url.clone(),
RelayState {
repos: HashSet::new(),
root_events: HashSet::new(),
is_bootstrap: false,
connection_status: ConnectionStatus::Connecting,
last_connected: None,
disconnected_at: None,
connection: None,
}
);
self.spawn_connection(&relay_url).await;
return; // Subscriptions will happen on connection success
}
Some(state) if state.connection_status != ConnectionStatus::Connected => {
// Not connected - subscriptions will happen on connection success
return;
}
Some(_) => {
// Already connected - proceed with subscription
}
}
// Subscribe and collect subscription IDs
let conn = self.connections.get(&relay_url).unwrap();
let mut sub_ids = HashSet::new();
for filter in filters {
match conn.client.subscribe(filter, None).await {
Ok(output) => {
for sub_id in output.val {
sub_ids.insert(sub_id);
}
}
Err(e) => {
tracing::warn!("Failed to subscribe: {}", e);
}
}
}
// Create pending batch
let batch = PendingBatch {
batch_id: self.next_batch_id(),
items: PendingItems { repos, root_events },
outstanding_subs: sub_ids,
};
// Add to pending index
self.pending_sync_index.write().await
.entry(relay_url)
.or_default()
.push(batch);
}
}
5. handle_disconnect
impl SyncManager {
/// Called when connection to a relay is lost
async fn handle_disconnect(&mut 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;
state.disconnected_at = Some(Timestamp::now());
state.connection = None;
}
// Clear pending batches - these items were not confirmed
self.pending_sync_index.write().await.remove(relay_url);
// Remove from active connections map
self.connections.remove(relay_url);
// Health tracker records failure for backoff
self.health_tracker.record_failure(relay_url);
}
}
6. handle_connect_or_reconnect (Unified)
This method handles BOTH initial connection AND reconnection with unified logic:
impl SyncManager {
/// Called when connection to a relay succeeds - handles both initial connect and reconnect.
///
/// Decision tree:
/// - Fresh sync (no last_connected OR disconnected >15min): No since filter, full history
/// - Quick reconnect (<15min): since = last_connected - 15min
async fn handle_connect_or_reconnect(&mut self, relay_url: &str) {
let mut index = self.relay_sync_index.write().await;
let state = match index.get_mut(relay_url) {
Some(s) => s,
None => return, // Relay was removed while disconnected
};
// Determine if this is a fresh sync or quick reconnect
let is_fresh_sync = state.last_connected.is_none() || state.should_clear_state();
let last_connected = state.last_connected;
if is_fresh_sync && state.last_connected.is_some() {
// Stale reconnect (>15min) - clear state
tracing::info!("Reconnect after >15min for {}, clearing state for fresh sync", relay_url);
state.clear_sync_state();
}
// Update connection state
state.connection_status = ConnectionStatus::Connected;
state.last_connected = Some(Timestamp::now());
state.disconnected_at = None;
// Record success in health tracker
self.health_tracker.record_success(relay_url);
drop(index); // Release lock
let conn = match self.connections.get(relay_url) {
Some(c) => c,
None => return,
};
if is_fresh_sync {
// Fresh sync: Layer 1 without since, Layer 2+3 handled by compute_actions
// Step 1: Subscribe Layer 1 (announcements) without since
let layer1 = build_announcement_filter(None);
let _ = conn.client.subscribe(layer1, None).await;
// Step 2: compute_actions will handle Layer 2+3 (with since=None in build)
self.recompute_actions_for_relay(relay_url).await;
} else {
// Quick reconnect: Layer 1 with since, Layer 2+3 with since
let since = last_connected
.map(|ts| Timestamp::from(ts.as_u64().saturating_sub(900)))
.unwrap_or(Timestamp::from(0));
// Step 1: Subscribe Layer 1 (announcements) with since
let layer1 = build_announcement_filter(Some(since));
let _ = conn.client.subscribe(layer1, None).await;
// Step 2: Rebuild Layer 2+3 for confirmed items with since
self.rebuild_layer2_and_layer3(relay_url, Some(since)).await;
// Step 3: Check for NEW items via compute_actions
self.recompute_actions_for_relay(relay_url).await;
}
}
/// Rebuild Layer 2+3 subscriptions only (NOT Layer 1).
/// Used by:
/// - Quick reconnect: rebuild confirmed items with since filter
/// - Consolidation: close and rebuild with since filter
async fn rebuild_layer2_and_layer3(&mut self, relay_url: &str, since: Option<Timestamp>) {
let confirmed = self.relay_sync_index.read().await;
let state = match confirmed.get(relay_url) {
Some(s) => s,
None => return,
};
// Build Layer 2+3 filters WITH since
let filters = build_layer2_and_layer3_filters(&state.repos, &state.root_events, since);
drop(confirmed);
// Subscribe directly - no PendingBatch for catch-up (items already confirmed)
let conn = match self.connections.get(relay_url) {
Some(c) => c,
None => return,
};
for filter in filters {
let _ = conn.client.subscribe(filter, None).await;
}
}
/// Rerun compute_actions for a specific relay and process resulting AddFilters.
/// compute_actions builds Layer 2+3 filters for NEW items not yet in confirmed state.
async fn recompute_actions_for_relay(&mut self, relay_url: &str) {
let repo_index = self.repo_sync_index.read().await;
let targets = derive_relay_targets(&repo_index);
drop(repo_index);
// Filter to just this relay
let target = match targets.get(relay_url) {
Some(t) => t.clone(),
None => return, // No repos reference this relay anymore
};
let pending = self.pending_sync_index.read().await;
let confirmed = self.relay_sync_index.read().await;
let mut single_relay_targets = HashMap::new();
single_relay_targets.insert(relay_url.to_string(), target);
let actions = compute_actions(&single_relay_targets, &pending, &confirmed);
drop(pending);
drop(confirmed);
// Process AddFilters
for action in actions {
self.handle_add_filters(action).await;
}
}
}
7. Daily Timer
impl SyncManager {
async fn run_daily_timer(&self) {
loop {
// Random 23-25 hours
let hours = 23.0 + rand::random::<f64>() * 2.0;
tokio::time::sleep(Duration::from_secs_f64(hours * 3600.0)).await;
let relay_urls: Vec<_> = self.relay_sync_index.read().await
.keys()
.cloned()
.collect();
for relay_url in relay_urls {
self.daily_sync(&relay_url).await;
}
}
}
/// Perform daily fresh sync for a relay
async fn daily_sync(&mut self, relay_url: &str) {
tracing::info!("Daily sync triggered for {}", relay_url);
// Close all subscriptions
if let Some(conn) = self.connections.get(relay_url) {
conn.client.unsubscribe_all().await;
}
// Clear PendingSyncIndex
self.pending_sync_index.write().await.remove(relay_url);
// Clear confirmed state - triggers fresh sync
{
let mut index = self.relay_sync_index.write().await;
if let Some(state) = index.get_mut(relay_url) {
state.clear_sync_state();
}
}
// Recompute actions - will generate AddFilters for everything
self.recompute_actions_for_relay(relay_url).await;
}
}
8. Consolidation (Threshold-Based, Triggered on Add)
Consolidation is checked when adding new subscriptions, not periodically. Key insight: Consolidation only closes and rebuilds Layer 2+3 - Layer 1 remains active.
impl SyncManager {
/// Check filter count and consolidate if needed.
/// Called from handle_add_filters BEFORE adding new filters.
async fn maybe_consolidate(&mut self, relay_url: &str, new_filter_count: usize) {
let current_count = self.get_filter_count(relay_url).await;
if current_count + new_filter_count > 70 {
self.consolidate(relay_url).await;
}
}
/// Consolidate filters - only rebuilds Layer 2+3, Layer 1 stays active.
/// Does NOT clear state - just reduces filter count.
async fn consolidate(&mut self, relay_url: &str) {
tracing::info!("Consolidating filters for {} (count > 70)", relay_url);
// Wait for all pending batches to complete first
self.wait_pending_complete(relay_url).await;
// Close Layer 2+3 subscriptions only - Layer 1 remains active
// NOTE: In practice, we close all then re-add Layer 1, or track sub IDs separately
// For simplicity, we close all and re-add Layer 1
if let Some(conn) = self.connections.get(relay_url) {
conn.client.unsubscribe_all().await;
}
// Re-subscribe Layer 1 with since (maintains announcements stream)
let since = Timestamp::from(Timestamp::now().as_u64().saturating_sub(900));
let conn = self.connections.get(relay_url).unwrap();
let layer1 = build_announcement_filter(Some(since));
let _ = conn.client.subscribe(layer1, None).await;
// Rebuild Layer 2+3 only
self.rebuild_layer2_and_layer3(relay_url, Some(since)).await;
}
}
Updated handle_add_filters to check consolidation:
impl SyncManager {
async fn handle_add_filters(&mut self, action: AddFilters) {
let AddFilters { relay_url, repos, root_events, filters } = action;
// Auto-spawn connection if needed (unchanged)
let state = self.relay_sync_index.read().await.get(&relay_url).cloned();
match state {
None => {
// New relay discovered - create entry and spawn connection
self.relay_sync_index.write().await.insert(
relay_url.clone(),
RelayState {
repos: HashSet::new(),
root_events: HashSet::new(),
is_bootstrap: false,
connection_status: ConnectionStatus::Connecting,
last_connected: None,
disconnected_at: None,
connection: None,
}
);
self.spawn_connection(&relay_url).await;
return; // Subscriptions will happen on connection success
}
Some(state) if state.connection_status != ConnectionStatus::Connected => {
return; // Not connected - subscriptions will happen on connection success
}
Some(_) => {
// Already connected - proceed
}
}
// CHECK CONSOLIDATION BEFORE ADDING
self.maybe_consolidate(&relay_url, filters.len()).await;
// Subscribe and collect subscription IDs
let conn = self.connections.get(&relay_url).unwrap();
let mut sub_ids = HashSet::new();
for filter in filters {
match conn.client.subscribe(filter, None).await {
Ok(output) => {
for sub_id in output.val {
sub_ids.insert(sub_id);
}
}
Err(e) => {
tracing::warn!("Failed to subscribe: {}", e);
}
}
}
// Create pending batch (unchanged)
let batch = PendingBatch {
batch_id: self.next_batch_id(),
items: PendingItems { repos, root_events },
outstanding_subs: sub_ids,
};
self.pending_sync_index.write().await
.entry(relay_url)
.or_default()
.push(batch);
}
}
Disconnect (Relay Removal) Handling
impl SyncManager {
/// Periodically check for relays that should be disconnected
async fn check_disconnects(&mut self) {
let confirmed = self.relay_sync_index.read().await;
let relays_to_disconnect: Vec<_> = confirmed.iter()
.filter(|(_, state)| {
!state.is_bootstrap &&
state.repos.is_empty() &&
state.root_events.is_empty()
})
.map(|(url, _)| url.clone())
.collect();
drop(confirmed);
for relay_url in relays_to_disconnect {
self.disconnect_relay(&relay_url).await;
}
}
async fn disconnect_relay(&mut self, relay_url: &str) {
tracing::info!("Disconnecting relay {} (no repos)", relay_url);
self.relay_sync_index.write().await.remove(relay_url);
self.pending_sync_index.write().await.remove(relay_url);
if let Some(conn) = self.connections.remove(relay_url) {
let _ = conn.client.disconnect().await;
}
}
}
State Flow Summary
flowchart TB
subgraph Input
SS[SelfSubscriber]
OWN[Own Relay]
end
subgraph RepoSyncIndex - What We Want
RSI[HashMap: Repo to Relays+Events]
end
subgraph Derived Target
DT[derive_relay_targets fn]
TGT[Per-relay: repos + events we should sync]
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]
CONN[connection_status]
REPOS[repos + root_events]
TIMES[last_connected + disconnected_at]
end
SS -->|subscribe| OWN
OWN -->|events| SS
SS -->|batch fires| RSI
RSI --> DT
DT --> TGT
TGT --> CA
PSI --> CA
RLI --> CA
CA -->|Layer 2+3 new items| AF[AddFilters]
AF -->|check filter count| CONSOL{count + new > 70?}
CONSOL -->|yes| CONSOLIDATE[consolidate]
CONSOLIDATE --> L1_CONSOL[build_announcement_filter with since]
L1_CONSOL --> L23_CONSOL[rebuild_layer2_and_layer3 with since]
CONSOL -->|no| SUB[subscribe]
AF -->|spawn if needed| CONN
SUB --> PSI
PSI -->|EOSE| REPOS
CONN -->|disconnect| DISC[Clear PSI + set disconnected_at]
DISC -->|any reconnect| HC[handle_connect_or_reconnect]
subgraph handle_connect_or_reconnect
HC --> FRESH_CHECK{is_fresh_sync?}
FRESH_CHECK -->|yes: no last_connected OR >15min| L1_FRESH[build_announcement_filter - no since]
FRESH_CHECK -->|no: quick reconnect| L1_QUICK[build_announcement_filter - with since]
L1_FRESH --> RCA1[recompute_actions_for_relay]
L1_QUICK --> L23_QUICK[rebuild_layer2_and_layer3 - with since]
L23_QUICK --> RCA2[recompute_actions_for_relay]
end
Key Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Startup mechanism | Self-subscription only | Single code path, fresh DB behaves same as reconnect |
| Connect/reconnect handling | Unified handle_connect_or_reconnect | Single entry point for both initial and reconnect |
| Layer 1 handling | Separate build_announcement_filter | Connection-level: subscribe ONCE on connect, NOT rebuilt in consolidation |
| Layer 2+3 handling | Separate rebuild_layer2_and_layer3 | Item-level: managed by compute_actions, consolidated when filter count > 70 |
| Filter functions | since as Option parameter | Allows same functions for fresh sync and catch-up |
| Layer 2+3 tags | tagged_one_of_our_repo_event_filters, tagged_one_of_our_root_event_filters | Descriptive names, uses a/A/q for repos, e/E/q for events |
| Since filter | Only on catch-up paths | Initial/stale gets full history, quick reconnect catches up |
| compute_actions role | ONLY for new Layer 2+3 items | Does NOT handle Layer 1 or catch-up |
| Catch-up pending tracking | No PendingBatch | Items already confirmed, don't need re-confirmation |
| Consolidation trigger | On filter add, not periodic | Check in handle_add_filters before adding new filters |
| Consolidation Layer 1 | Re-subscribe with since after unsubscribe_all | Maintains announcement stream |
| Consolidation Layer 2+3 | rebuild_layer2_and_layer3 with since | Shared logic with quick_reconnect |
| Clear on disconnect | Clear PSI on disconnect | Cleanup at event boundary, simpler than on reconnect |
| 15-minute rule | Clear confirmed if disconnected >15min | Matches since filter buffer, prevents stale subscriptions |
| Daily timer | Fresh sync (clears state) | Ensures consistency, detects drift |
| Connection spawning | Via AddFilters handler | Single path for new relay discovery |
| Self-subscriber reconnect | Use since-15min filter | Simpler than immediate RepoSyncIndex updates |
Module Structure
src/sync/
├── mod.rs # SyncManager, main loop
├── state.rs # RepoSyncIndex, RelaySyncIndex, PendingSyncIndex types
├── actions.rs # AddFilters struct, compute_actions, build_filters
├── self_subscriber.rs # SelfSubscriber, batching logic
├── relay_connection.rs # Per-relay WebSocket connection
├── consolidation.rs # Consolidation logic, daily timer
├── health.rs # Health tracking (reuse from v2)
└── metrics.rs # Prometheus metrics (reuse from v2)
Comparison: v3 vs v4
| Aspect | v3 | v4 |
|---|---|---|
| Connect handling | Separate initial vs reconnect | Unified handle_connect_or_reconnect |
| Layer 1 handling | Mixed with other layers | Separate build_layer1_filter, always included |
| Layer 2+3 tags | Basic a/e tags | Comprehensive a/A/q and e/E/q per v2 |
| Rebuild logic | Duplicated in reconnect and consolidation | Shared rebuild_all_subscriptions method |
| Consolidation trigger | Maybe periodic | On filter add in handle_add_filters |
| Since filter application | Applied in handle_reconnect | build_all_filters with optional since |
| PSI clearing | On disconnect | On disconnect (confirmed) |
| Daily timer | Consolidation-style | Fresh sync (different from consolidation) |
Self-Subscriber Flow
The SelfSubscriber connects to the own relay and maintains a subscription to discover repos and events. It batches incoming events and triggers compute_actions.
State Tracking
pub struct SelfSubscriber {
own_relay_url: String,
relay_domain: String,
repo_sync_index: RepoSyncIndex,
pending_sync_index: PendingSyncIndex,
relay_sync_index: RelaySyncIndex,
action_tx: mpsc::Sender<AddFilters>,
/// Timestamp of last successful connection - used for since filter on reconnection
last_connected: Option<Timestamp>,
/// The active client connection
client: Option<Client>,
}
On Startup / Reconnect (Unified)
Both initial startup and reconnection use the same connect_and_subscribe method:
impl SelfSubscriber {
async fn run(mut self) {
loop {
// Connect or reconnect
if let Err(e) = self.connect_and_subscribe().await {
tracing::warn!("Connection failed: {}, will retry", e);
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
// Run event loop until disconnection
self.event_loop().await;
// Loop will retry connection
}
}
async fn connect_and_subscribe(&mut self) -> Result<(), Error> {
let client = Client::new(Keys::generate());
client.add_relay(&self.own_relay_url).await?;
client.connect().await;
// Build filter - add since only on reconnect
let filter = Filter::new().kinds([
Kind::Custom(30617), // Repository announcements
Kind::GitPatch, // 1617
Kind::Custom(1618), // PRs
Kind::Custom(1619), // PR updates
Kind::GitIssue, // 1621
]);
let filter = if let Some(ts) = self.last_connected {
// Reconnection: use since filter
let since = Timestamp::from(ts.as_u64().saturating_sub(900)); // -15 min buffer
filter.since(since)
} else {
// Initial connect: no since filter - get full history
filter
};
// Update last_connected AFTER computing since
self.last_connected = Some(Timestamp::now());
client.subscribe(filter, None).await?;
self.client = Some(client);
Ok(())
}
}
Event Loop with Batching
impl SelfSubscriber {
async fn event_loop(&mut self) {
let client = self.client.as_ref().unwrap();
let mut pending_events: Vec<Event> = Vec::new();
let mut batch_timer: Option<Instant> = None;
let batch_window = Duration::from_secs(5);
loop {
let timeout = batch_timer
.map(|t| batch_window.saturating_sub(t.elapsed()))
.unwrap_or(Duration::from_secs(60));
tokio::select! {
notification = client.notifications().recv() => {
match notification {
Ok(RelayPoolNotification::Event { event, .. }) => {
pending_events.push(*event);
// Start timer on first event - does NOT reset
if batch_timer.is_none() {
batch_timer = Some(Instant::now());
}
}
Ok(RelayPoolNotification::Shutdown) => {
// Connection lost
break;
}
_ => {}
}
}
_ = tokio::time::sleep(timeout), if batch_timer.is_some() => {
// Batch window elapsed
self.process_batch(pending_events.drain(..).collect()).await;
batch_timer = None;
}
}
}
}
async fn process_batch(&self, events: Vec<Event>) {
// 1. Update RepoSyncIndex
for event in events {
match event.kind.as_u16() {
30617 => self.handle_announcement(&event).await,
1617 | 1618 | 1619 | 1621 => self.handle_root_event(&event).await,
_ => {}
}
}
// 2. Derive targets and compute actions
let repo_index = self.repo_sync_index.read().await;
let targets = derive_relay_targets(&repo_index);
let pending = self.pending_sync_index.read().await;
let confirmed = self.relay_sync_index.read().await;
let actions = compute_actions(&targets, &pending, &confirmed);
drop(repo_index);
drop(pending);
drop(confirmed);
// 3. Send actions to SyncManager
for action in actions {
let _ = self.action_tx.send(action).await;
}
}
async fn handle_announcement(&self, event: &Event) {
// Extract repo_ref from event - 30617:pubkey:identifier
let d_tag = event.tags.iter()
.find_map(|tag| {
if tag.kind() == TagKind::D {
tag.content().map(|s| s.to_string())
} else {
None
}
})
.unwrap_or_default();
let repo_ref = format!("30617:{}:{}", event.pubkey, d_tag);
// Extract relay URLs from 'r' tags
let relays: HashSet<String> = event.tags.iter()
.filter_map(|tag| {
if tag.kind() == TagKind::Relay {
tag.content().map(|s| s.to_string())
} else {
None
}
})
.collect();
// Update RepoSyncIndex
let mut index = self.repo_sync_index.write().await;
let needs = index.entry(repo_ref).or_default();
needs.relays = relays;
}
async fn handle_root_event(&self, event: &Event) {
// Extract repo_ref from 'a' tag
let repo_ref = event.tags.iter()
.find_map(|tag| {
if tag.kind() == TagKind::A {
tag.content().map(|s| s.to_string())
} else {
None
}
});
if let Some(repo_ref) = repo_ref {
let mut index = self.repo_sync_index.write().await;
let needs = index.entry(repo_ref).or_default();
needs.root_events.insert(event.id);
}
}
}
Implementation Notes
This section documents the actual implementation details as of December 2024 (Phases 1-10 complete).
Architectural Decisions During Implementation
Phase 7 Refactoring: The SyncManager::run() method required refactoring to use Arc<Mutex<SyncManager>> for shared access. The daily timer and disconnect checker tasks need to access the manager, so self is wrapped after initial setup:
// 7. Wrap self in Arc<Mutex> for sharing with timer task
let sync_manager = Arc::new(Mutex::new(self));
This allows background tasks (daily timer, disconnect checker) to acquire the lock when needed while the main event loop handles actions from the self-subscriber.
Health Module: The health tracking module was adapted from the v3 implementation at work/sync-v3/health.rs. The implementation uses:
DashMapfor thread-safe concurrent access without external locking- Three states:
Healthy,Degraded,Dead - Exponential backoff:
base * 2^(failures-1), capped at max_backoff - Dead threshold: 24 hours of continuous failures
- Dead relay retry: Once per 24 hours
Implementation Constants
| Constant | Value | Purpose |
|---|---|---|
CONSOLIDATION_THRESHOLD |
70 filters | Maximum filters before triggering consolidation |
CONSOLIDATION_WAIT_TIMEOUT_SECS |
30 seconds | Timeout for pending batches during consolidation |
QUICK_RECONNECT_WINDOW_SECS |
15 minutes | Window for quick reconnect vs fresh sync |
DISCONNECT_CHECK_INTERVAL_SECS |
60 seconds | Interval for checking empty relays to disconnect |
DEAD_THRESHOLD_HOURS |
24 hours | Time before relay marked as dead |
BASE_BACKOFF_SECS |
5 seconds | Base duration for exponential backoff |
Daily Timer Randomization
The daily timer uses randomization between 23-25 hours to prevent thundering herd effects when multiple ngit-grasp instances are running:
let hours = 23.0 + rand::thread_rng().gen::<f64>() * 2.0;
Bootstrap Relay Protection
Bootstrap relays are never disconnected by the cleanup system. The check_disconnects() method explicitly filters them out:
.filter(|(_, state)| {
!state.is_bootstrap &&
state.repos.is_empty() &&
state.root_events.is_empty()
})
Graceful Shutdown
Shutdown uses a tokio broadcast channel for coordinated termination:
let (shutdown_tx, _shutdown_rx) = broadcast::channel(1);
Each background task (self-subscriber, daily timer, disconnect checker) receives its own broadcast::Receiver subscription and monitors for the shutdown signal in its main loop.
Actual Module Structure
The implemented module structure differs from the original spec:
src/sync/
├── mod.rs # SyncManager, main loop, index types, metrics
├── algorithms.rs # derive_relay_targets, compute_actions, AddFilters
├── filters.rs # build_announcement_filter, build_layer2_and_layer3_filters
├── health.rs # RelayHealthTracker, HealthState, exponential backoff
├── relay_connection.rs # RelayConnection, RelayEvent, WebSocket handling
└── self_subscriber.rs # SelfSubscriber, RelayAction, batching logic
Key differences from spec:
- No separate
state.rs- types are defined inmod.rs - No separate
actions.rs- moved toalgorithms.rs - No separate
consolidation.rs- consolidation logic inmod.rs - No separate
metrics.rs-SyncMetricsdefined inmod.rs
Deviations from Original v4 Spec
-
RelayState lacks
connectionfield: The spec showedconnection: Option<RelayConnection>inRelayState, but the implementation stores connections in a separateHashMap<String, RelayConnection>inSyncManager. -
SelfSubscriber simplified: The actual implementation uses
RelayActionenum (SpawnRelay/AddFilters) rather than directly usingAddFiltersstruct. -
Consolidation wait_pending_complete: The spec described a
wait_pending_complete()method, but the implementation uses a simpler timeout-based approach checking pending batches. -
Timestamp API: Uses
Timestamp::now().as_secs()instead of.as_u64()due to nostr-sdk 0.43 API.