27 KiB
GRASP-02: Proactive Sync - Design Document
Overview
GRASP-02 Proactive Sync enables ngit-grasp to maintain live WebSocket connections to other relays listed in repository announcement events, synchronizing NIP-34 related events using both live sync (real-time subscriptions) and negentropy catchup (NIP-77 set reconciliation).
This document covers event syncing only. Git data syncing is out of scope for this phase.
Goals
- Data Availability: Ensure we have all relevant events for repositories we host
- Resilience: Handle relay failures gracefully with backoff and health tracking
- Efficiency: Minimize connections and bandwidth through filter consolidation
- Consistency: Use unified filters for both live sync and negentropy catchup
Architecture Overview
flowchart TB
subgraph ngit-grasp
SM[SyncManager]
FS[FilterService]
RH[RelayHealthTracker]
DB[(Database)]
AP[AcceptancePolicy]
MET[Prometheus Metrics]
end
subgraph External Relays
R1[relay.example.com]
R2[other-grasp.io]
R3[nostr.land]
end
SM -->|builds filters| FS
SM -->|tracks health| RH
SM -->|stores events| DB
SM -->|validates| AP
SM <-->|WebSocket + NEG| R1
SM <-->|WebSocket + NEG| R2
SM <-->|WebSocket + NEG| R3
RH -->|exposes state| MET
Connection Management
Relay Discovery
Relays to connect to are discovered from all stored repository announcements:
// Pseudocode for relay discovery
fn discover_relays(database: &Database) -> HashSet<RelayUrl> {
let announcements = database.query(Filter::new().kind(30617));
let mut relays = HashSet::new();
for announcement in announcements {
for relay_url in announcement.relays_tags() {
if relay_url != our_domain { // Exclude ourselves
relays.insert(relay_url);
}
}
}
relays
}
Connection Lifecycle
stateDiagram-v2
[*] --> Connecting: startup/new relay
Connecting --> Connected: success
Connecting --> Backoff: failure
Connected --> Disconnected: connection lost
Disconnected --> Backoff: reconnect failed
Backoff --> Connecting: backoff timer expires
Backoff --> Dead: 24h continuous failures
Dead --> Connecting: daily retry timer
Connected --> Updating: filter change
Updating --> Connected: complete
Health Tracking & Backoff
| State | Behavior |
|---|---|
| Healthy | Normal operation, immediate reconnect on disconnect |
| Backoff | Exponential backoff: 5s → 10s → 20s → ... → 1h max |
| Dead | 24h of continuous failures, retry once per day |
Health state is kept in-memory using a DashMap for lock-free concurrent access:
/// In-memory relay health tracking (NOT persisted to database)
///
/// Design rationale: For <100 relays, persistence adds complexity without
/// significant benefit. Conservative initial backoff on restart avoids
/// thundering herd issues.
struct RelayHealthTracker {
health: DashMap<RelayUrl, RelayHealth>,
metrics: SyncMetrics, // Prometheus metrics for operator visibility
}
struct RelayHealth {
url: RelayUrl,
status: RelayStatus, // Healthy, Backoff, Dead
consecutive_failures: u32,
last_failure_at: Option<Instant>,
last_success_at: Option<Instant>,
next_retry_at: Instant,
}
enum RelayStatus {
Healthy,
Backoff { attempt: u32 }, // backoff = min(5 * 2^attempt, 3600) seconds
Dead, // retry in 24h
}
Restart Behavior (Graceful Degradation)
On restart, all relay health state is reset. To avoid thundering herd:
- Conservative initial backoff: Start with 5s delay (not immediate) for all relays
- Staggered connection attempts: Add random jitter (0-2s) per relay
- Health rebuilds organically: Relays prove themselves healthy through successful connections
impl RelayHealthTracker {
fn new(metrics: SyncMetrics) -> Self {
Self {
health: DashMap::new(),
metrics,
}
}
/// Called on startup for each discovered relay
fn initialize_relay(&self, url: RelayUrl) {
self.health.insert(url.clone(), RelayHealth {
url,
status: RelayStatus::Backoff { attempt: 0 }, // Start conservative
consecutive_failures: 0,
last_failure_at: None,
last_success_at: None,
next_retry_at: Instant::now() + Self::initial_backoff_with_jitter(),
});
}
fn initial_backoff_with_jitter() -> Duration {
Duration::from_secs(5) + Duration::from_millis(rand::random::<u64>() % 2000)
}
}
Trade-off: We lose knowledge of chronically failing relays across restarts. This is acceptable because:
- Scale is small (<100 relays)
- Conservative initial backoff prevents hammering bad relays
- Prometheus metrics preserve historical health data for operators
Filter Strategy
Unified Filters for Live Sync and Negentropy
The same filter logic is used for both live subscriptions and negentropy reconciliation:
flowchart LR
subgraph Filter Layers
F1[Layer 1: All 30617+30618]
F2[Layer 2: Events tagging repos via A/a/q]
F3[Layer 3: Events tagging PRs/Issues via E/e/q]
end
F1 -->|client-side| AP[Acceptance Policy]
F2 -->|server-side| Relay
F3 -->|server-side| Relay
Layer 1: Repository Announcements & States
Get ALL kind 30617 and 30618 events with unified since timestamp, then filter client-side through acceptance policy:
// Use same since filter as other layers for consistency
let layer1_filter = Filter::new()
.kinds([Kind::from(30617), Kind::from(30618)])
.since(since_timestamp); // Unified with Layer 2/3
Client-side validation: Only store events that pass our Nip34WritePolicy.
Layer 2: Events Tagging Repositories
For repo announcements that list BOTH this relay AND our service:
// Build addressable references: 30617:<pubkey>:<identifier>
let repo_refs: Vec<String> = announcements
.iter()
.filter(|a| a.relays.contains(&this_relay) && a.lists_service(&our_domain))
.map(|a| format!("30617:{}:{}", a.pubkey.to_hex(), a.identifier))
.collect();
let layer2_filter = Filter::new()
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), repo_refs.clone())
.or(Filter::new().custom_tag(SingleLetterTag::lowercase(Alphabet::Q), repo_refs));
Layer 3: Events Tagging Issues/PRs/Patches
For events that reference PRs, Patches, or Issues from repos we track:
// Collect event IDs of PRs, Patches, Issues we've stored
let tagged_event_ids: Vec<EventId> = database
.query(Filter::new().kinds([1618, 1619, 1621, 1622, 1630])) // PR, PR Update, Issue, Patch, etc.
.iter()
.filter(|e| references_tracked_repo(e, &announcements))
.map(|e| e.id)
.collect();
let layer3_filter = Filter::new()
.custom_tag(SingleLetterTag::lowercase(Alphabet::E), tagged_event_ids.clone())
.or(Filter::new().custom_tag(SingleLetterTag::lowercase(Alphabet::Q), tagged_event_ids));
Filter Size Management
When the tag list exceeds a threshold, split into batches:
const MAX_TAGS_PER_FILTER: usize = 100;
fn build_filters(tag_values: Vec<String>) -> Vec<Filter> {
tag_values
.chunks(MAX_TAGS_PER_FILTER)
.map(|chunk| Filter::new().custom_tag(tag, chunk.to_vec()))
.collect()
}
Consolidation: When total filter count exceeds ~150 across a connection, consolidate by rebuilding from scratch.
Filter Generation vs. Policy Validation
The filter strategy and acceptance policies serve different purposes even though they share conceptual knowledge:
| Concern | Filters | Policies |
|---|---|---|
| Direction | What to request FROM remote relays | What to accept INTO local database |
| Input | Stored events (announcements, PRs, etc.) | Single incoming event |
| Output | Filter specification | Accept/Reject decision |
The modular sub-policies (AnnouncementPolicy, RelatedEventPolicy, etc.) encode knowledge about event kinds and tag types, but this knowledge is applied differently:
- In filters: We enumerate all addressable refs (
30617:pubkey:id) from stored announcements - In policies:
RelatedEventPolicy::check_references()checks if incoming event references any accepted event
Because of this fundamental difference, filter generation logic stays in src/sync/filter.rs rather than being delegated to policy modules. Both share the understanding of NIP-34 event relationships, but they answer different questions.
Subscription Updates
Dynamic Subscription Management
When new events arrive that affect our filter criteria:
sequenceDiagram
participant LocalRelay
participant SyncManager
participant RemoteRelay
LocalRelay->>SyncManager: New PR event accepted
SyncManager->>SyncManager: Extract event ID
SyncManager->>SyncManager: Build new filter for E/e/q tags
SyncManager->>RemoteRelay: REQ with new filter
RemoteRelay-->>SyncManager: Events matching new filter
Events that trigger subscription updates:
- New repository announcement accepted (adds to Layer 2)
- New PR/Issue/Patch accepted (adds to Layer 3)
When to Consolidate
Track subscription count per connection:
struct ConnectionState {
relay_url: RelayUrl,
subscriptions: Vec<SubscriptionId>,
total_filter_count: usize,
}
impl ConnectionState {
fn should_consolidate(&self) -> bool {
self.total_filter_count > 150
}
async fn consolidate(&mut self) {
// Close all subscriptions
// Rebuild from scratch with current database state
}
}
Negentropy Catchup
NIP-77 Reconciliation Protocol
Negentropy enables efficient set reconciliation - discovering which events we're missing without transferring full event lists.
Timing
| Trigger | Behavior |
|---|---|
| Initial startup | Warm-up delay, staggered if many filters, initializes daily schedule |
| After reconnect | Delay to avoid rate limiting, limited to events from last 3 days |
| Daily | Staggered batches, max 100 tagged events per filter |
Startup Flow
sequenceDiagram
participant Server
participant SyncManager
participant Relay
Server->>SyncManager: Start
SyncManager->>SyncManager: Wait warm-up delay
SyncManager->>SyncManager: Build batched filters
loop For each relay with stagger delay
SyncManager->>Relay: NEG-OPEN with filter batch 1
Relay-->>SyncManager: NEG-MSG with differences
SyncManager->>Relay: NEG-MSG response
Note over SyncManager,Relay: Reconciliation rounds
Relay-->>SyncManager: NEG-CLOSE or events
SyncManager->>SyncManager: Validate + store events
alt More batches
SyncManager->>SyncManager: Wait stagger delay
SyncManager->>Relay: NEG-OPEN with next batch
end
end
SyncManager->>SyncManager: Schedule daily catchup
Reconnection Catchup
After connection reestablished:
async fn catchup_after_reconnect(&self, relay: &RelayUrl) {
// Delay to avoid immediate disconnect for too many requests
tokio::time::sleep(RECONNECT_CATCHUP_DELAY).await;
// Only catch up on recent events (last 3 days)
let since = Timestamp::now() - Duration::from_secs(3 * 24 * 60 * 60);
let filters = self.build_filters_for_relay(relay)
.into_iter()
.map(|f| f.since(since))
.collect();
self.run_negentropy(relay, filters).await;
}
Daily Catchup Schedule
// Daily catchup runs at consistent time, staggered across relays
async fn schedule_daily_catchup(&self) {
let mut interval = tokio::time::interval(Duration::from_secs(24 * 60 * 60));
loop {
interval.tick().await;
for (i, relay) in self.healthy_relays().enumerate() {
// Stagger: 5 minute delay between relays
tokio::time::sleep(Duration::from_secs(i as u64 * 300)).await;
// Batch filters to max 100 tagged events each
let batches = self.build_batched_filters(&relay, 100);
for batch in batches {
self.run_negentropy(&relay, batch).await;
tokio::time::sleep(Duration::from_secs(60)).await; // 1 min between batches
}
}
}
}
Event Processing
Acceptance Policy
All synced events go through our acceptance policy, reusing the same Nip34WritePolicy validation logic used for direct client submissions.
Design: Reusing admit_event()
The WritePolicy::admit_event() trait method takes a SocketAddr parameter designed for client connections:
// From nostr-relay-builder WritePolicy trait
fn admit_event<'a>(
&'a self,
event: &'a Event,
_addr: &'a SocketAddr, // Unused in our implementation
) -> BoxedFuture<'a, PolicyResult>;
For synced events from remote relays, we pass a synthetic localhost address since:
- The
_addrparameter is currently unused in ourNip34WritePolicy - All meaningful validation is done by the modular sub-policies (see below)
- This allows reusing 100% of the existing validation logic
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// Synthetic address for synced events (not from a direct client connection)
const SYNC_SOURCE_ADDR: SocketAddr = SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
0
);
async fn process_synced_event(&self, event: Event, source_relay: &RelayUrl) -> Result<()> {
// Apply our Nip34WritePolicy using synthetic address
// The SocketAddr is unused - all validation is by the modular sub-policies
let result = self.acceptance_policy
.admit_event(&event, &SYNC_SOURCE_ADDR)
.await;
match result {
PolicyResult::Accept => {
self.database.save_event(&event).await?;
tracing::debug!(
"Accepted synced event {} from {}",
event.id.to_hex(),
source_relay
);
self.trigger_subscription_updates(&event).await;
}
PolicyResult::Reject(reason) => {
tracing::debug!(
"Rejected synced event {} from {}: {}",
event.id.to_hex(),
source_relay,
reason
);
}
}
Ok(())
}
Modular Sub-Policies
The Nip34WritePolicy delegates to specialized sub-policies in src/nostr/policy/:
| Sub-Policy | Kinds | Responsibility |
|---|---|---|
AnnouncementPolicy |
30617 | Validates service listing, maintainer exception, creates bare repos |
StatePolicy |
30618 | Validates state structure, aligns git refs with authorized state |
PrEventPolicy |
1618, 1619 | Validates PR/PR Update events, manages refs/nostr/* |
RelatedEventPolicy |
All others | Checks forward/backward references to accepted repos/events |
All sub-policies share a common PolicyContext containing:
domain: Our service domain for validationdatabase: For querying existing eventsgit_data_path: For git operations
Why Not Call Sub-Policies Directly?
While we could bypass admit_event() and call sub-policies directly:
// Alternative: Direct sub-policy calls (NOT recommended)
match event.kind.as_u16() {
30617 => self.announcement_policy.validate(&event).await,
30618 => self.state_policy.validate(&event),
1618 | 1619 => self.pr_event_policy.validate_nostr_ref(&event).await,
_ => self.related_event_policy.check_references(&event).await,
}
This is not recommended because:
- Duplicates the kind-routing logic from
admit_event() - Misses important post-validation steps (e.g.,
handle_announcement()also callsensure_bare_repository()) - Creates maintenance burden when policy logic changes
Module Structure
New src/sync/ Module
src/
├── sync/
│ ├── mod.rs # Module exports
│ ├── manager.rs # SyncManager - main coordinator
│ ├── connection.rs # Per-relay connection handling
│ ├── filter.rs # Filter building and batching
│ ├── health.rs # RelayHealth tracking
│ ├── negentropy.rs # NIP-77 reconciliation logic
│ └── subscription.rs # Dynamic subscription management
├── nostr/
│ └── ... (existing)
└── ...
Integration with Main Binary
// In main.rs
async fn main() -> Result<()> {
// ... existing setup ...
// Start sync manager as background task
let sync_manager = SyncManager::new(
database.clone(),
config.domain.clone(),
);
tokio::spawn(async move {
sync_manager.run().await
});
// ... rest of server startup ...
}
Metrics & Observability
All sync metrics are exposed via Prometheus at /metrics. For <100 relays, per-relay labels are acceptable cardinality.
Prometheus Metrics
/// Sync module metrics registered with the global Prometheus registry
pub struct SyncMetrics {
// === Connection Metrics (per relay) ===
/// Active outbound connections: ngit_sync_relay_connected{relay="wss://..."}
relay_connected: IntGaugeVec, // labels: [relay]
/// Connection attempts: ngit_sync_connection_attempts_total{relay="wss://...", result="success|failure"}
connection_attempts: CounterVec, // labels: [relay, result]
// === Relay Health Status ===
/// Current status: ngit_sync_relay_status{relay="wss://...", status="healthy|backoff|dead"}
relay_status: IntGaugeVec, // labels: [relay, status]
/// Consecutive failures: ngit_sync_relay_failures{relay="wss://..."}
relay_failures: IntGaugeVec, // labels: [relay]
// === Event Source Tracking ===
/// Events received by source: ngit_sync_events_total{source="direct|live_sync|catchup|daily_catchup"}
events_total: CounterVec, // labels: [source]
/// Sync gap events (should have been live synced): ngit_sync_gap_events_total{relay="wss://..."}
sync_gap_events: CounterVec, // labels: [relay]
// === Aggregate Metrics ===
/// Total relays being tracked
relays_tracked_total: IntGauge,
/// Relays currently connected
relays_connected_total: IntGauge,
/// Relays in dead state
relays_dead_total: IntGauge,
}
Metric Definitions
| Metric | Type | Labels | Description |
|---|---|---|---|
ngit_sync_relay_connected |
Gauge | relay | 1 if connected, 0 if not |
ngit_sync_connection_attempts_total |
Counter | relay, result | Connection attempt outcomes |
ngit_sync_relay_status |
Gauge | relay, status | 1 for current status, 0 otherwise |
ngit_sync_relay_failures |
Gauge | relay | Current consecutive failure count |
ngit_sync_events_total |
Counter | source | Events received by source type |
ngit_sync_gap_events_total |
Counter | relay | Events found during catchup that should have been live |
ngit_sync_relays_tracked_total |
Gauge | - | Total relays discovered from announcements |
ngit_sync_relays_connected_total |
Gauge | - | Currently connected relay count |
ngit_sync_relays_dead_total |
Gauge | - | Relays marked as dead |
Key insight: Events discovered during catchup or daily reconciliation represent live sync failures - we should have received them in real-time. The ngit_sync_gap_events_total metric tracks this per relay.
Observability Integration
impl SyncManager {
fn record_event_received(&self, event: &Event, source: EventSource) {
match source {
EventSource::DirectSubmission => {
self.metrics.events_total.with_label_values(&["direct"]).inc();
}
EventSource::LiveSync(relay) => {
self.metrics.events_total.with_label_values(&["live_sync"]).inc();
}
EventSource::Catchup(relay) => {
// This is a sync gap - we should have gotten it via live sync
self.metrics.events_total.with_label_values(&["catchup"]).inc();
self.metrics.sync_gap_events.with_label_values(&[relay.as_str()]).inc();
tracing::warn!(
relay = %relay,
event_id = %event.id.to_hex(),
"Sync gap detected: event found during catchup"
);
}
EventSource::DailyCatchup(relay) => {
// Sustained sync gap - missed by both live sync and initial catchup
self.metrics.events_total.with_label_values(&["daily_catchup"]).inc();
self.metrics.sync_gap_events.with_label_values(&[relay.as_str()]).inc();
tracing::error!(
relay = %relay,
event_id = %event.id.to_hex(),
"Sustained sync gap: event found during daily catchup"
);
}
}
}
fn record_connection_attempt(&self, relay: &RelayUrl, success: bool) {
let result = if success { "success" } else { "failure" };
self.metrics.connection_attempts
.with_label_values(&[relay.as_str(), result])
.inc();
}
fn update_relay_status(&self, relay: &RelayUrl, status: &RelayStatus) {
// Reset all status labels for this relay
for s in ["healthy", "backoff", "dead"] {
self.metrics.relay_status
.with_label_values(&[relay.as_str(), s])
.set(0);
}
// Set current status
let status_label = match status {
RelayStatus::Healthy => "healthy",
RelayStatus::Backoff { .. } => "backoff",
RelayStatus::Dead => "dead",
};
self.metrics.relay_status
.with_label_values(&[relay.as_str(), status_label])
.set(1);
}
}
Example Grafana Queries
# Relay health overview - count by status
sum by (status) (ngit_sync_relay_status == 1)
# Connection success rate over last hour
sum(rate(ngit_sync_connection_attempts_total{result="success"}[1h]))
/ sum(rate(ngit_sync_connection_attempts_total[1h]))
# Sync gap detection - events that should have been live synced
sum(rate(ngit_sync_gap_events_total[1h])) by (relay)
# Live sync effectiveness (lower is better - fewer gaps)
sum(rate(ngit_sync_events_total{source=~"catchup|daily_catchup"}[1h]))
/ sum(rate(ngit_sync_events_total[1h]))
# Relays with high failure counts (potential issues)
topk(10, ngit_sync_relay_failures)
# Alert: relay stuck in dead state
ngit_sync_relay_status{status="dead"} == 1
Log Levels for Sync Events
| Event | Level | Context |
|---|---|---|
| Event via live sync | DEBUG | Normal operation |
| Event via catchup | WARN | Sync gap detected |
| Event via daily catchup | ERROR | Sustained gap |
| Connection established | INFO | Relay URL |
| Connection failed | WARN | Relay URL, attempt #, backoff |
| Relay marked dead | ERROR | Relay URL, failure duration |
| Peer missing events | WARN | Relay URL, repo, count |
Configuration
pub struct SyncConfig {
/// Warm-up delay before starting initial catchup
pub startup_delay: Duration, // Default: 30s
/// Delay between filter batches during catchup
pub batch_delay: Duration, // Default: 60s
/// Delay after reconnect before catchup
pub reconnect_delay: Duration, // Default: 10s
/// Maximum events in last N days for reconnect catchup
pub reconnect_lookback_days: u32, // Default: 3
/// Maximum tagged event IDs per filter
pub max_tags_per_filter: usize, // Default: 100
/// Consolidate subscriptions when count exceeds
pub max_subscriptions: usize, // Default: 150
/// Backoff configuration
pub max_backoff: Duration, // Default: 1h
pub dead_threshold: Duration, // Default: 24h
pub dead_retry_interval: Duration, // Default: 24h
}
Summary
| Component | Responsibility |
|---|---|
| SyncManager | Orchestrates connections, triggers catchup, processes events |
| FilterService | Builds unified filters from database state |
| RelayHealthTracker | Manages backoff, dead relay detection (in-memory + Prometheus) |
| ConnectionState | Per-relay WebSocket + subscription management |
| SyncMetrics | Prometheus metrics for operator visibility |
Key Design Decisions
- Unified filters for live sync and negentropy - same criteria, different delivery mechanism
- Exclude ourselves from relay list to prevent loops
- One connection per relay with combined filters for efficiency
- In-memory health state with Prometheus metrics for visibility (no database persistence needed for <100 relays)
- Graceful degradation on restart - conservative initial backoff with jitter avoids thundering herd
- Staggered catchup to avoid overwhelming relays - runs immediately at startup after warm-up
- Client-side filtering for 30617/30618, server-side for Layer 2/3
- Dynamic subscription addition with periodic consolidation
- Custom acceptance policy excluding rate limiting defaults
- Catchup as failure signal - events found during catchup/daily indicate live sync gaps, tracked in Prometheus