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/purgatory | |
| 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/purgatory')
| -rw-r--r-- | src/purgatory/mod.rs | 344 |
1 files changed, 333 insertions, 11 deletions
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)] |
| 51 | pub struct Purgatory { | 63 | pub 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] | ||
| 1047 | fn 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] | ||
| 1098 | fn 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] | ||
| 1153 | fn 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] | ||
| 1194 | fn 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] | ||
| 1216 | fn 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 | } | ||