upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-01-08 00:41:02 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-08 00:41:02 +0000
commit5833c9bdf815699838a0445f750b99b26fd4a3bd (patch)
treebd148e548e5621872615627cdbd88ba577d072ce
parentac3e00a7e102d7ae341f554563646e05aed7edac (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.
-rw-r--r--src/main.rs22
-rw-r--r--src/nostr/builder.rs37
-rw-r--r--src/nostr/policy/state.rs20
-rw-r--r--src/purgatory/mod.rs344
4 files changed, 404 insertions, 19 deletions
diff --git a/src/main.rs b/src/main.rs
index b4a42af..8b870dc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -94,7 +94,7 @@ async fn main() -> Result<()> {
94 sync_manager.run().await; 94 sync_manager.run().await;
95 }); 95 });
96 96
97 // Spawn background cleanup task 97 // Spawn background cleanup task for purgatory entries (60s interval)
98 let cleanup_purgatory = purgatory.clone(); 98 let cleanup_purgatory = purgatory.clone();
99 tokio::spawn(async move { 99 tokio::spawn(async move {
100 let mut interval = tokio::time::interval(Duration::from_secs(60)); 100 let mut interval = tokio::time::interval(Duration::from_secs(60));
@@ -111,6 +111,26 @@ async fn main() -> Result<()> {
111 }); 111 });
112 info!("Purgatory cleanup task started (60s interval)"); 112 info!("Purgatory cleanup task started (60s interval)");
113 113
114 // Spawn daily cleanup task for old expired event records (prevent unbounded growth)
115 let expired_cleanup_purgatory = purgatory.clone();
116 tokio::spawn(async move {
117 // Run immediately on startup, then every 24 hours
118 let mut interval = tokio::time::interval(Duration::from_secs(24 * 3600));
119 loop {
120 interval.tick().await;
121 // Remove expired event records older than 7 days
122 let removed = expired_cleanup_purgatory
123 .cleanup_expired_events(Duration::from_secs(7 * 24 * 3600));
124 if removed > 0 {
125 info!(
126 "Expired event cleanup: removed {} old expired event records (>7 days)",
127 removed
128 );
129 }
130 }
131 });
132 info!("Expired event cleanup task started (24h interval, keeps 7 days)");
133
114 // Start purgatory sync loop for background git data fetching 134 // Start purgatory sync loop for background git data fetching
115 let sync_ctx = Arc::new(RealSyncContext::new( 135 let sync_ctx = Arc::new(RealSyncContext::new(
116 purgatory.clone(), 136 purgatory.clone(),
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
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs
index 68b1e97..7bbb379 100644
--- a/src/nostr/policy/state.rs
+++ b/src/nostr/policy/state.rs
@@ -43,8 +43,12 @@ impl StatePolicy {
43 43
44 /// Process a state event: validate and align owner repositories 44 /// Process a state event: validate and align owner repositories
45 /// 45 ///
46 /// # Arguments
47 /// * `event` - The state event to process
48 /// * `is_synced` - True if this event came from proactive sync (vs user-submitted)
49 ///
46 /// Returns the true if git data already availale or false if added to purgatory 50 /// Returns the true if git data already availale or false if added to purgatory
47 pub async fn process_state_event(&self, event: &Event) -> Result<WritePolicyResult> { 51 pub async fn process_state_event(&self, event: &Event, is_synced: bool) -> Result<WritePolicyResult> {
48 // Parse state to get HEAD and branch info 52 // Parse state to get HEAD and branch info
49 let state = 53 let state =
50 RepositoryState::from_event(event.clone()).context("Failed to parse state event")?; 54 RepositoryState::from_event(event.clone()).context("Failed to parse state event")?;
@@ -120,6 +124,20 @@ impl StatePolicy {
120 // Event will be saved and broadcast by relay builder 124 // Event will be saved and broadcast by relay builder
121 Ok(WritePolicyResult::Accept) 125 Ok(WritePolicyResult::Accept)
122 } else { 126 } else {
127 // Only reject expired events if they're from sync (not user-submitted)
128 // User-submitted events should be allowed to retry in case git data became available
129 if is_synced && self.ctx.purgatory.is_expired(&event.id) {
130 tracing::debug!(
131 event_id = %event.id,
132 identifier = %state.identifier,
133 "State event previously expired from purgatory (synced), rejecting to prevent re-sync loop"
134 );
135 return Ok(WritePolicyResult::Reject {
136 status: false,
137 message: "invalid: previously expired from purgatory without git data".into(),
138 });
139 }
140
123 // If no git data - add to purgatory 141 // If no git data - add to purgatory
124 // (add_state automatically enqueues for background sync) 142 // (add_state automatically enqueues for background sync)
125 self.ctx 143 self.ctx
diff --git a/src/purgatory/mod.rs b/src/purgatory/mod.rs
index 9427c71..fe0a439 100644
--- a/src/purgatory/mod.rs
+++ b/src/purgatory/mod.rs
@@ -47,6 +47,18 @@ const IMMEDIATE_SYNC_DELAY: Duration = Duration::from_millis(500);
47/// Also manages a sync queue for background git data fetching: 47/// Also manages a sync queue for background git data fetching:
48/// - Tracks identifiers that need syncing with backoff/debouncing 48/// - Tracks identifiers that need syncing with backoff/debouncing
49/// - Supports both user-submitted events (3min delay) and sync-triggered (500ms delay) 49/// - Supports both user-submitted events (3min delay) and sync-triggered (500ms delay)
50///
51/// ## Expired Event Tracking
52///
53/// Events that expire from purgatory without finding git data are tracked in
54/// `expired_events` to prevent infinite re-sync loops. When proactive sync
55/// fetches events from relays, we filter out expired events using:
56/// - `event_ids()` - Returns both active purgatory events AND expired events
57/// - `is_expired()` - Check if an event has expired before
58/// - `mark_expired()` - Called during cleanup to track newly expired events
59///
60/// This prevents the sync system from repeatedly fetching and re-adding events
61/// that we've already determined have no git data available.
50#[derive(Clone)] 62#[derive(Clone)]
51pub struct Purgatory { 63pub struct Purgatory {
52 /// State events (kind 30618) indexed by repository identifier. 64 /// State events (kind 30618) indexed by repository identifier.
@@ -61,6 +73,11 @@ pub struct Purgatory {
61 /// Maps repository identifier to sync queue entry with timing/backoff state. 73 /// Maps repository identifier to sync queue entry with timing/backoff state.
62 sync_queue: Arc<DashMap<String, SyncQueueEntry>>, 74 sync_queue: Arc<DashMap<String, SyncQueueEntry>>,
63 75
76 /// Events that expired from purgatory without finding git data.
77 /// Prevents infinite re-sync loops by filtering these out during negentropy/REQ sync.
78 /// Stored as EventId (hex string) for efficient lookup.
79 expired_events: Arc<DashMap<EventId, Instant>>,
80
64 _git_data_path: PathBuf, 81 _git_data_path: PathBuf,
65} 82}
66 83
@@ -71,6 +88,7 @@ impl Purgatory {
71 state_events: Arc::new(DashMap::new()), 88 state_events: Arc::new(DashMap::new()),
72 pr_events: Arc::new(DashMap::new()), 89 pr_events: Arc::new(DashMap::new()),
73 sync_queue: Arc::new(DashMap::new()), 90 sync_queue: Arc::new(DashMap::new()),
91 expired_events: Arc::new(DashMap::new()),
74 _git_data_path: git_data_path.into(), 92 _git_data_path: git_data_path.into(),
75 } 93 }
76 } 94 }
@@ -435,14 +453,20 @@ impl Purgatory {
435 self.pr_events.remove(event_id); 453 self.pr_events.remove(event_id);
436 } 454 }
437 455
438 /// Get all event IDs currently stored in purgatory. 456 /// Get all event IDs currently stored in purgatory AND previously expired events.
457 ///
458 /// Returns a HashSet of all event IDs for:
459 /// - State events currently held in purgatory
460 /// - PR events currently held in purgatory
461 /// - Events that previously expired from purgatory without finding git data
439 /// 462 ///
440 /// Returns a HashSet of all event IDs for both state events and PR events 463 /// This is used by negentropy sync and REQ+EOSE to avoid fetching events
441 /// held in purgatory. Useful for negentropy sync to avoid fetching events 464 /// that are either:
442 /// that are already in purgatory awaiting git data. 465 /// 1. Already in purgatory awaiting git data
466 /// 2. Previously expired without finding git data (prevents infinite re-sync)
443 /// 467 ///
444 /// # Returns 468 /// # Returns
445 /// HashSet of event IDs (as EventId) for all events in purgatory 469 /// HashSet of event IDs (as EventId) for all events in purgatory + expired events
446 pub fn event_ids(&self) -> HashSet<EventId> { 470 pub fn event_ids(&self) -> HashSet<EventId> {
447 let mut ids = HashSet::new(); 471 let mut ids = HashSet::new();
448 472
@@ -460,9 +484,40 @@ impl Purgatory {
460 } 484 }
461 } 485 }
462 486
487 // Collect expired event IDs
488 for entry in self.expired_events.iter() {
489 ids.insert(*entry.key());
490 }
491
463 ids 492 ids
464 } 493 }
465 494
495 /// Check if an event has previously expired from purgatory.
496 ///
497 /// Returns true if this event was previously held in purgatory and expired
498 /// without finding git data. This prevents re-adding the event during sync.
499 ///
500 /// # Arguments
501 /// * `event_id` - The event ID to check
502 ///
503 /// # Returns
504 /// true if the event has expired before, false otherwise
505 pub fn is_expired(&self, event_id: &EventId) -> bool {
506 self.expired_events.contains_key(event_id)
507 }
508
509 /// Mark an event as expired (called during cleanup).
510 ///
511 /// Tracks events that expired from purgatory without finding git data.
512 /// This prevents infinite re-sync loops by filtering these events during
513 /// negentropy and REQ+EOSE sync.
514 ///
515 /// # Arguments
516 /// * `event_id` - The event ID to mark as expired
517 fn mark_expired(&self, event_id: EventId) {
518 self.expired_events.insert(event_id, Instant::now());
519 }
520
466 /// Get all PR placeholder event IDs (git-data-first entries without events). 521 /// Get all PR placeholder event IDs (git-data-first entries without events).
467 /// 522 ///
468 /// Returns event IDs for entries where git data arrived before the PR event. 523 /// Returns event IDs for entries where git data arrived before the PR event.
@@ -489,31 +544,55 @@ impl Purgatory {
489 /// Should be called periodically (every 60 seconds) by background task to clean up 544 /// Should be called periodically (every 60 seconds) by background task to clean up
490 /// entries that have exceeded their expiry deadline. 545 /// entries that have exceeded their expiry deadline.
491 /// 546 ///
547 /// **Important**: This method also marks expired events in `expired_events` to
548 /// prevent infinite re-sync loops. Events that expire without finding git data
549 /// will be filtered out during future negentropy/REQ sync operations.
550 ///
492 /// # Returns 551 /// # Returns
493 /// Tuple of (num_state_removed, num_pr_removed) 552 /// Tuple of (num_state_removed, num_pr_removed)
494 pub fn cleanup(&self) -> (usize, usize) { 553 pub fn cleanup(&self) -> (usize, usize) {
495 let now = Instant::now(); 554 let now = Instant::now();
496 let mut state_removed = 0; 555 let mut state_removed = 0;
497 556
498 // Remove expired state events 557 // Remove expired state events and mark them as expired
499 self.state_events.retain(|_, entries| { 558 self.state_events.retain(|_, entries| {
500 let original_len = entries.len(); 559 let original_len = entries.len();
560 // Collect event IDs before removing
561 let expired_ids: Vec<EventId> = entries
562 .iter()
563 .filter(|entry| entry.expires_at <= now)
564 .map(|entry| entry.event.id)
565 .collect();
566
567 // Mark as expired to prevent re-sync
568 for event_id in expired_ids {
569 self.mark_expired(event_id);
570 }
571
572 // Remove expired entries
501 entries.retain(|entry| entry.expires_at > now); 573 entries.retain(|entry| entry.expires_at > now);
502 state_removed += original_len - entries.len(); 574 state_removed += original_len - entries.len();
503 !entries.is_empty() 575 !entries.is_empty()
504 }); 576 });
505 577
506 // Remove expired PR events 578 // Remove expired PR events and mark them as expired
507 let expired_prs: Vec<String> = self 579 let expired_prs: Vec<(String, Option<EventId>)> = self
508 .pr_events 580 .pr_events
509 .iter() 581 .iter()
510 .filter(|entry| entry.value().expires_at <= now) 582 .filter(|entry| entry.value().expires_at <= now)
511 .map(|entry| entry.key().clone()) 583 .map(|entry| {
584 let event_id = entry.value().event.as_ref().map(|e| e.id);
585 (entry.key().clone(), event_id)
586 })
512 .collect(); 587 .collect();
513 588
514 let pr_removed = expired_prs.len(); 589 let pr_removed = expired_prs.len();
515 for event_id in expired_prs { 590 for (event_id_str, event_id_opt) in expired_prs {
516 self.pr_events.remove(&event_id); 591 // Mark actual PR events as expired (not placeholders)
592 if let Some(event_id) = event_id_opt {
593 self.mark_expired(event_id);
594 }
595 self.pr_events.remove(&event_id_str);
517 } 596 }
518 597
519 (state_removed, pr_removed) 598 (state_removed, pr_removed)
@@ -529,6 +608,34 @@ impl Purgatory {
529 state + pr 608 state + pr
530 } 609 }
531 610
611 /// Remove old expired event records.
612 ///
613 /// Expired events are tracked to prevent infinite re-sync loops, but they
614 /// shouldn't be kept forever. This method removes expired event records
615 /// older than the specified duration.
616 ///
617 /// Should be called periodically (e.g., daily) to prevent unbounded growth.
618 ///
619 /// # Arguments
620 /// * `older_than` - Remove expired events older than this duration (default: 7 days)
621 ///
622 /// # Returns
623 /// Number of expired event records removed
624 pub fn cleanup_expired_events(&self, older_than: Duration) -> usize {
625 let cutoff = Instant::now() - older_than;
626 let mut removed = 0;
627
628 self.expired_events.retain(|_, &mut expired_at| {
629 let keep = expired_at > cutoff;
630 if !keep {
631 removed += 1;
632 }
633 keep
634 });
635
636 removed
637 }
638
532 /// Get current count of entries in purgatory. 639 /// Get current count of entries in purgatory.
533 /// 640 ///
534 /// # Returns 641 /// # Returns
@@ -539,12 +646,21 @@ impl Purgatory {
539 (state_count, pr_count) 646 (state_count, pr_count)
540 } 647 }
541 648
649 /// Get count of expired events being tracked.
650 ///
651 /// # Returns
652 /// Number of expired events in the tracking set
653 pub fn expired_count(&self) -> usize {
654 self.expired_events.len()
655 }
656
542 /// Clear all entries from purgatory (for testing). 657 /// Clear all entries from purgatory (for testing).
543 #[cfg(test)] 658 #[cfg(test)]
544 pub fn clear(&self) { 659 pub fn clear(&self) {
545 self.state_events.clear(); 660 self.state_events.clear();
546 self.pr_events.clear(); 661 self.pr_events.clear();
547 self.sync_queue.clear(); 662 self.sync_queue.clear();
663 self.expired_events.clear();
548 } 664 }
549 665
550 /// Get the current size of the sync queue (for testing/metrics). 666 /// Get the current size of the sync queue (for testing/metrics).
@@ -926,3 +1042,209 @@ fn test_remove_expired_legacy_method() {
926 let total = purgatory.remove_expired(); 1042 let total = purgatory.remove_expired();
927 assert_eq!(total, 2); // 1 state + 1 PR 1043 assert_eq!(total, 2); // 1 state + 1 PR
928} 1044}
1045
1046#[test]
1047fn test_expired_event_tracking() {
1048 use std::time::Duration;
1049
1050 let purgatory = Purgatory::new(PathBuf::new());
1051 let keys = Keys::generate();
1052
1053 let state_event = EventBuilder::text_note("state")
1054 .sign_with_keys(&keys)
1055 .unwrap();
1056 let pr_event = EventBuilder::text_note("pr").sign_with_keys(&keys).unwrap();
1057
1058 let state_event_id = state_event.id;
1059 let pr_event_id = pr_event.id;
1060
1061 // Add events to purgatory
1062 purgatory.add_state(state_event, "repo".to_string(), keys.public_key());
1063 purgatory.add_pr(pr_event, "pr-id".to_string(), "commit".to_string());
1064
1065 // Events should not be marked as expired yet
1066 assert!(!purgatory.is_expired(&state_event_id));
1067 assert!(!purgatory.is_expired(&pr_event_id));
1068
1069 // Expire both events
1070 if let Some(mut entries) = purgatory.state_events.get_mut("repo") {
1071 for entry in entries.iter_mut() {
1072 entry.expires_at = Instant::now() - Duration::from_secs(1);
1073 }
1074 }
1075 for mut entry in purgatory.pr_events.iter_mut() {
1076 entry.value_mut().expires_at = Instant::now() - Duration::from_secs(1);
1077 }
1078
1079 // Run cleanup
1080 let (state_removed, pr_removed) = purgatory.cleanup();
1081 assert_eq!(state_removed, 1);
1082 assert_eq!(pr_removed, 1);
1083
1084 // Events should now be marked as expired
1085 assert!(purgatory.is_expired(&state_event_id));
1086 assert!(purgatory.is_expired(&pr_event_id));
1087
1088 // event_ids() should include expired events
1089 let ids = purgatory.event_ids();
1090 assert!(ids.contains(&state_event_id));
1091 assert!(ids.contains(&pr_event_id));
1092
1093 // Expired count should be 2
1094 assert_eq!(purgatory.expired_count(), 2);
1095}
1096
1097#[test]
1098fn test_cleanup_expired_events() {
1099 use std::time::Duration;
1100
1101 let purgatory = Purgatory::new(PathBuf::new());
1102 let keys = Keys::generate();
1103
1104 let event1 = EventBuilder::text_note("event1")
1105 .sign_with_keys(&keys)
1106 .unwrap();
1107 let event2 = EventBuilder::text_note("event2")
1108 .sign_with_keys(&keys)
1109 .unwrap();
1110
1111 let event1_id = event1.id;
1112 let event2_id = event2.id;
1113
1114 // Add and immediately expire event1
1115 purgatory.add_state(event1, "repo1".to_string(), keys.public_key());
1116 if let Some(mut entries) = purgatory.state_events.get_mut("repo1") {
1117 for entry in entries.iter_mut() {
1118 entry.expires_at = Instant::now() - Duration::from_secs(1);
1119 }
1120 }
1121 purgatory.cleanup();
1122
1123 // Add and expire event2 (will be more recent)
1124 purgatory.add_state(event2, "repo2".to_string(), keys.public_key());
1125 if let Some(mut entries) = purgatory.state_events.get_mut("repo2") {
1126 for entry in entries.iter_mut() {
1127 entry.expires_at = Instant::now() - Duration::from_secs(1);
1128 }
1129 }
1130 purgatory.cleanup();
1131
1132 // Both should be in expired_events
1133 assert_eq!(purgatory.expired_count(), 2);
1134
1135 // Manually set event1's expiry time to be old
1136 if let Some(mut entry) = purgatory.expired_events.get_mut(&event1_id) {
1137 *entry.value_mut() = Instant::now() - Duration::from_secs(8 * 24 * 3600); // 8 days ago
1138 }
1139
1140 // Clean up expired events older than 7 days
1141 let removed = purgatory.cleanup_expired_events(Duration::from_secs(7 * 24 * 3600));
1142
1143 // Only event1 should be removed
1144 assert_eq!(removed, 1);
1145 assert_eq!(purgatory.expired_count(), 1);
1146
1147 // event1 should be gone, event2 should remain
1148 assert!(!purgatory.is_expired(&event1_id));
1149 assert!(purgatory.is_expired(&event2_id));
1150}
1151
1152#[test]
1153fn test_expired_events_prevent_readdition() {
1154 use std::time::Duration;
1155
1156 let purgatory = Purgatory::new(PathBuf::new());
1157 let keys = Keys::generate();
1158
1159 let event = EventBuilder::text_note("test")
1160 .sign_with_keys(&keys)
1161 .unwrap();
1162 let event_id = event.id;
1163
1164 // Add event to purgatory
1165 purgatory.add_state(event.clone(), "repo".to_string(), keys.public_key());
1166
1167 // Expire it
1168 if let Some(mut entries) = purgatory.state_events.get_mut("repo") {
1169 for entry in entries.iter_mut() {
1170 entry.expires_at = Instant::now() - Duration::from_secs(1);
1171 }
1172 }
1173 purgatory.cleanup();
1174
1175 // Event should be marked as expired
1176 assert!(purgatory.is_expired(&event_id));
1177
1178 // event_ids() should return the expired event
1179 let ids = purgatory.event_ids();
1180 assert!(ids.contains(&event_id));
1181
1182 // This simulates what negentropy/REQ+EOSE should do:
1183 // Check if event is in event_ids() before adding
1184 if !ids.contains(&event_id) {
1185 purgatory.add_state(event, "repo".to_string(), keys.public_key());
1186 }
1187
1188 // Event should NOT be re-added
1189 let (state_count, _) = purgatory.count();
1190 assert_eq!(state_count, 0, "Event should not be re-added to purgatory");
1191}
1192
1193#[test]
1194fn test_pr_placeholder_not_marked_expired() {
1195 use std::time::Duration;
1196
1197 let purgatory = Purgatory::new(PathBuf::new());
1198
1199 // Add a PR placeholder (no event)
1200 purgatory.add_pr_placeholder("placeholder-id".to_string(), "commit-123".to_string());
1201
1202 // Expire it
1203 if let Some(mut entry) = purgatory.pr_events.get_mut("placeholder-id") {
1204 entry.value_mut().expires_at = Instant::now() - Duration::from_secs(1);
1205 }
1206
1207 // Run cleanup
1208 let (_, pr_removed) = purgatory.cleanup();
1209 assert_eq!(pr_removed, 1);
1210
1211 // Expired count should be 0 (placeholders don't have event IDs to track)
1212 assert_eq!(purgatory.expired_count(), 0);
1213}
1214
1215#[test]
1216fn test_user_can_resubmit_expired_event() {
1217 use std::time::Duration;
1218
1219 let purgatory = Purgatory::new(PathBuf::new());
1220 let keys = Keys::generate();
1221
1222 let event = EventBuilder::text_note("test")
1223 .sign_with_keys(&keys)
1224 .unwrap();
1225 let event_id = event.id;
1226
1227 // Add event to purgatory
1228 purgatory.add_state(event.clone(), "repo".to_string(), keys.public_key());
1229
1230 // Expire it
1231 if let Some(mut entries) = purgatory.state_events.get_mut("repo") {
1232 for entry in entries.iter_mut() {
1233 entry.expires_at = Instant::now() - Duration::from_secs(1);
1234 }
1235 }
1236 purgatory.cleanup();
1237
1238 // Event should be marked as expired
1239 assert!(purgatory.is_expired(&event_id));
1240
1241 // User re-submits the same event (simulating retry after pushing git data)
1242 // This should be allowed - the policy layer will check is_synced flag
1243 // For now, just verify the event is marked as expired
1244 assert!(purgatory.is_expired(&event_id));
1245
1246 // The policy layer (in builder.rs and state.rs) will:
1247 // - Check is_synced flag (false for user-submitted)
1248 // - Skip the expired check for user-submitted events
1249 // - Allow the event to be re-added to purgatory or accepted if git data now exists
1250}