diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-08 00:41:02 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-08 00:41:02 +0000 |
| commit | 5833c9bdf815699838a0445f750b99b26fd4a3bd (patch) | |
| tree | bd148e548e5621872615627cdbd88ba577d072ce /src/nostr/builder.rs | |
| parent | ac3e00a7e102d7ae341f554563646e05aed7edac (diff) | |
feat(purgatory): track expired events to prevent infinite re-sync loops
Adds expired event tracking to prevent proactive sync from repeatedly
fetching and re-adding events that expired from purgatory without
finding git data.
Key features:
- Track expired events for 7 days to prevent re-sync loops
- Distinguish synced vs user-submitted events (via socket address)
- Allow users to retry expired events (git data might now be available)
- Reject synced expired events (prevents infinite loop)
- Daily cleanup of expired event records older than 7 days
Implementation:
- Added expired_events: DashMap<EventId, Instant> to Purgatory
- Updated event_ids() to include both purgatory + expired events
- Added is_expired(), mark_expired(), cleanup_expired_events()
- Updated cleanup() to mark expired events automatically
- Added is_synced detection in WritePolicy (localhost:0 = synced)
- Policy layer checks is_synced && is_expired() before rejecting
Behavior:
- Negentropy: Filters expired events before fetching (optimal)
- REQ+EOSE: Rejects synced expired events at policy layer
- User submissions: Always allowed to retry (skip expired check)
Testing:
- Added 5 new tests for expired event tracking
- All 222 tests passing
Fixes the infinite re-sync loop where events without git data would
expire, get synced again, expire again, repeat forever.
Diffstat (limited to 'src/nostr/builder.rs')
| -rw-r--r-- | src/nostr/builder.rs | 37 |
1 files changed, 31 insertions, 6 deletions
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index db2f59b..0e5c18a 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs | |||
| @@ -152,13 +152,17 @@ impl Nip34WritePolicy { | |||
| 152 | } | 152 | } |
| 153 | 153 | ||
| 154 | /// Handle repository state event | 154 | /// Handle repository state event |
| 155 | async fn handle_state(&self, event: &Event) -> WritePolicyResult { | 155 | /// |
| 156 | /// # Arguments | ||
| 157 | /// * `event` - The state event to validate | ||
| 158 | /// * `is_synced` - True if this event came from proactive sync (vs user-submitted) | ||
| 159 | async fn handle_state(&self, event: &Event, is_synced: bool) -> WritePolicyResult { | ||
| 156 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); | 160 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); |
| 157 | 161 | ||
| 158 | match self.state_policy.validate(event) { | 162 | match self.state_policy.validate(event) { |
| 159 | StateResult::Accept => { | 163 | StateResult::Accept => { |
| 160 | // Process state alignment asynchronously | 164 | // Process state alignment asynchronously |
| 161 | match self.state_policy.process_state_event(event).await { | 165 | match self.state_policy.process_state_event(event, is_synced).await { |
| 162 | Ok(poilicy_result) => poilicy_result, | 166 | Ok(poilicy_result) => poilicy_result, |
| 163 | Err(e) => { | 167 | Err(e) => { |
| 164 | tracing::warn!("Failed to process state event {}: {}", event_id_str, e); | 168 | tracing::warn!("Failed to process state event {}: {}", event_id_str, e); |
| @@ -178,7 +182,11 @@ impl Nip34WritePolicy { | |||
| 178 | } | 182 | } |
| 179 | 183 | ||
| 180 | /// Handle PR or PR Update event | 184 | /// Handle PR or PR Update event |
| 181 | async fn handle_pr_event(&self, event: &Event) -> WritePolicyResult { | 185 | /// |
| 186 | /// # Arguments | ||
| 187 | /// * `event` - The PR event to validate | ||
| 188 | /// * `is_synced` - True if this event came from proactive sync (vs user-submitted) | ||
| 189 | async fn handle_pr_event(&self, event: &Event, is_synced: bool) -> WritePolicyResult { | ||
| 182 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); | 190 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); |
| 183 | 191 | ||
| 184 | // duplicate check in purgatory | 192 | // duplicate check in purgatory |
| @@ -230,6 +238,19 @@ impl Nip34WritePolicy { | |||
| 230 | // Check if git data exists (delete any incorrect commits at refs/nostr/<event-id>, copies correct data to relivant repositories) | 238 | // Check if git data exists (delete any incorrect commits at refs/nostr/<event-id>, copies correct data to relivant repositories) |
| 231 | match self.pr_event_policy.git_data_check(event).await { | 239 | match self.pr_event_policy.git_data_check(event).await { |
| 232 | Ok(false) => { | 240 | Ok(false) => { |
| 241 | // Only reject expired events if they're from sync (not user-submitted) | ||
| 242 | // User-submitted events should be allowed to retry in case git data became available | ||
| 243 | if is_synced && self.ctx.purgatory.is_expired(&event.id) { | ||
| 244 | tracing::debug!( | ||
| 245 | event_id = %event_id_str, | ||
| 246 | "PR event previously expired from purgatory (synced), rejecting to prevent re-sync loop" | ||
| 247 | ); | ||
| 248 | return WritePolicyResult::Reject { | ||
| 249 | status: false, | ||
| 250 | message: "invalid: previously expired from purgatory without git data".into(), | ||
| 251 | }; | ||
| 252 | } | ||
| 253 | |||
| 233 | // No git data exists - add to purgatory | 254 | // No git data exists - add to purgatory |
| 234 | let commit = event | 255 | let commit = event |
| 235 | .tags | 256 | .tags |
| @@ -344,13 +365,17 @@ impl WritePolicy for Nip34WritePolicy { | |||
| 344 | fn admit_event<'a>( | 365 | fn admit_event<'a>( |
| 345 | &'a self, | 366 | &'a self, |
| 346 | event: &'a nostr_relay_builder::prelude::Event, | 367 | event: &'a nostr_relay_builder::prelude::Event, |
| 347 | _addr: &'a SocketAddr, | 368 | addr: &'a SocketAddr, |
| 348 | ) -> BoxedFuture<'a, WritePolicyResult> { | 369 | ) -> BoxedFuture<'a, WritePolicyResult> { |
| 349 | Box::pin(async move { | 370 | Box::pin(async move { |
| 371 | // Detect if this is a synced event (from proactive sync) vs user-submitted | ||
| 372 | // Sync uses localhost:0 as a dummy address | ||
| 373 | let is_synced = addr.ip().is_loopback() && addr.port() == 0; | ||
| 374 | |||
| 350 | match event.kind.as_u16() { | 375 | match event.kind.as_u16() { |
| 351 | KIND_REPOSITORY_ANNOUNCEMENT => self.handle_announcement(event).await, | 376 | KIND_REPOSITORY_ANNOUNCEMENT => self.handle_announcement(event).await, |
| 352 | KIND_REPOSITORY_STATE => self.handle_state(event).await, | 377 | KIND_REPOSITORY_STATE => self.handle_state(event, is_synced).await, |
| 353 | KIND_PR | KIND_PR_UPDATE => self.handle_pr_event(event).await, | 378 | KIND_PR | KIND_PR_UPDATE => self.handle_pr_event(event, is_synced).await, |
| 354 | KIND_USER_GRASP_LIST => { | 379 | KIND_USER_GRASP_LIST => { |
| 355 | // Accept all kind 10317 (User Grasp List) events | 380 | // Accept all kind 10317 (User Grasp List) events |
| 356 | // for better GRASP repository discovery | 381 | // for better GRASP repository discovery |