diff options
Diffstat (limited to 'src/purgatory/mod.rs')
| -rw-r--r-- | src/purgatory/mod.rs | 264 |
1 files changed, 262 insertions, 2 deletions
diff --git a/src/purgatory/mod.rs b/src/purgatory/mod.rs index f5f8b31..9a63bf6 100644 --- a/src/purgatory/mod.rs +++ b/src/purgatory/mod.rs | |||
| @@ -83,9 +83,35 @@ struct SerializablePrPurgatoryEntry { | |||
| 83 | expires_at_offset_secs: u64, | 83 | expires_at_offset_secs: u64, |
| 84 | } | 84 | } |
| 85 | 85 | ||
| 86 | /// Serializable wrapper for `AnnouncementPurgatoryEntry` with time offsets. | ||
| 87 | /// | ||
| 88 | /// Stores `Instant` fields as `Duration` offsets from the `saved_at` timestamp | ||
| 89 | /// in `PurgatoryState`, allowing state to be persisted and restored across restarts. | ||
| 90 | /// | ||
| 91 | /// Note: soft-expired entries (bare repo deleted) are NOT persisted — they have | ||
| 92 | /// no git repo on disk and would be immediately cleaned up on restore anyway. | ||
| 93 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 94 | struct SerializableAnnouncementPurgatoryEntry { | ||
| 95 | /// The nostr announcement event (kind 30617) | ||
| 96 | event: Event, | ||
| 97 | /// The repository identifier from the event's 'd' tag | ||
| 98 | identifier: String, | ||
| 99 | /// The owner pubkey (event author) | ||
| 100 | owner: PublicKey, | ||
| 101 | /// Path to the bare git repository (must exist on disk) | ||
| 102 | repo_path: PathBuf, | ||
| 103 | /// Relay URLs from the announcement (for sync registration) | ||
| 104 | relays: HashSet<String>, | ||
| 105 | /// Duration offset from saved_at for created_at | ||
| 106 | created_at_offset_secs: u64, | ||
| 107 | /// Duration offset from saved_at for expires_at | ||
| 108 | expires_at_offset_secs: u64, | ||
| 109 | } | ||
| 110 | |||
| 86 | /// Serializable purgatory state for disk persistence. | 111 | /// Serializable purgatory state for disk persistence. |
| 87 | /// | 112 | /// |
| 88 | /// Contains all purgatory data needed to restore state across restarts: | 113 | /// Contains all purgatory data needed to restore state across restarts: |
| 114 | /// - Announcement events (indexed by (owner, identifier)) — non-soft-expired only | ||
| 89 | /// - State events (indexed by identifier) | 115 | /// - State events (indexed by identifier) |
| 90 | /// - PR events (indexed by event ID) | 116 | /// - PR events (indexed by event ID) |
| 91 | /// - Expired events (to prevent re-sync loops) | 117 | /// - Expired events (to prevent re-sync loops) |
| @@ -97,6 +123,10 @@ struct PurgatoryState { | |||
| 97 | version: u32, | 123 | version: u32, |
| 98 | /// When this state was saved to disk | 124 | /// When this state was saved to disk |
| 99 | saved_at: SystemTime, | 125 | saved_at: SystemTime, |
| 126 | /// Announcement events indexed by "owner_hex:identifier" | ||
| 127 | /// Only non-soft-expired entries are persisted (bare repo must exist). | ||
| 128 | #[serde(default)] | ||
| 129 | announcement_purgatory: HashMap<String, SerializableAnnouncementPurgatoryEntry>, | ||
| 100 | /// State events indexed by repository identifier | 130 | /// State events indexed by repository identifier |
| 101 | state_events: HashMap<String, Vec<SerializableStatePurgatoryEntry>>, | 131 | state_events: HashMap<String, Vec<SerializableStatePurgatoryEntry>>, |
| 102 | /// PR events indexed by event ID (hex string) | 132 | /// PR events indexed by event ID (hex string) |
| @@ -1114,6 +1144,34 @@ impl Purgatory { | |||
| 1114 | let saved_at = SystemTime::now(); | 1144 | let saved_at = SystemTime::now(); |
| 1115 | let now_instant = Instant::now(); | 1145 | let now_instant = Instant::now(); |
| 1116 | 1146 | ||
| 1147 | // Convert announcement_purgatory to serializable format. | ||
| 1148 | // Skip soft-expired entries: their bare repos have been deleted, so they | ||
| 1149 | // cannot be meaningfully restored (the repo path no longer exists on disk). | ||
| 1150 | let mut announcement_purgatory = HashMap::new(); | ||
| 1151 | for entry in self.announcement_purgatory.iter() { | ||
| 1152 | let e = entry.value(); | ||
| 1153 | if e.soft_expired { | ||
| 1154 | continue; | ||
| 1155 | } | ||
| 1156 | let created_offset = | ||
| 1157 | persistence::instant_to_offset(e.created_at, saved_at, now_instant); | ||
| 1158 | let expires_offset = | ||
| 1159 | persistence::instant_to_offset(e.expires_at, saved_at, now_instant); | ||
| 1160 | let key = format!("{}:{}", e.owner.to_hex(), e.identifier); | ||
| 1161 | announcement_purgatory.insert( | ||
| 1162 | key, | ||
| 1163 | SerializableAnnouncementPurgatoryEntry { | ||
| 1164 | event: e.event.clone(), | ||
| 1165 | identifier: e.identifier.clone(), | ||
| 1166 | owner: e.owner, | ||
| 1167 | repo_path: e.repo_path.clone(), | ||
| 1168 | relays: e.relays.clone(), | ||
| 1169 | created_at_offset_secs: created_offset.as_secs(), | ||
| 1170 | expires_at_offset_secs: expires_offset.as_secs(), | ||
| 1171 | }, | ||
| 1172 | ); | ||
| 1173 | } | ||
| 1174 | |||
| 1117 | // Convert state_events to serializable format | 1175 | // Convert state_events to serializable format |
| 1118 | let mut state_events = HashMap::new(); | 1176 | let mut state_events = HashMap::new(); |
| 1119 | for entry in self.state_events.iter() { | 1177 | for entry in self.state_events.iter() { |
| @@ -1176,6 +1234,7 @@ impl Purgatory { | |||
| 1176 | let state = PurgatoryState { | 1234 | let state = PurgatoryState { |
| 1177 | version: 1, | 1235 | version: 1, |
| 1178 | saved_at, | 1236 | saved_at, |
| 1237 | announcement_purgatory, | ||
| 1179 | state_events, | 1238 | state_events, |
| 1180 | pr_events, | 1239 | pr_events, |
| 1181 | expired_events, | 1240 | expired_events, |
| @@ -1187,6 +1246,7 @@ impl Purgatory { | |||
| 1187 | 1246 | ||
| 1188 | tracing::info!( | 1247 | tracing::info!( |
| 1189 | path = %path.display(), | 1248 | path = %path.display(), |
| 1249 | announcements = state.announcement_purgatory.len(), | ||
| 1190 | state_events = state.state_events.len(), | 1250 | state_events = state.state_events.len(), |
| 1191 | pr_events = state.pr_events.len(), | 1251 | pr_events = state.pr_events.len(), |
| 1192 | expired_events = state.expired_events.len(), | 1252 | expired_events = state.expired_events.len(), |
| @@ -1234,6 +1294,45 @@ impl Purgatory { | |||
| 1234 | 1294 | ||
| 1235 | let now_instant = Instant::now(); | 1295 | let now_instant = Instant::now(); |
| 1236 | 1296 | ||
| 1297 | // Restore announcement_purgatory. | ||
| 1298 | // Skip entries whose bare repo no longer exists on disk — this can happen | ||
| 1299 | // if the repo was deleted externally between save and restore. | ||
| 1300 | for (_key, e) in state.announcement_purgatory { | ||
| 1301 | if !e.repo_path.exists() { | ||
| 1302 | tracing::warn!( | ||
| 1303 | owner = %e.owner, | ||
| 1304 | identifier = %e.identifier, | ||
| 1305 | repo_path = %e.repo_path.display(), | ||
| 1306 | "Skipping announcement restore: bare repo no longer exists" | ||
| 1307 | ); | ||
| 1308 | continue; | ||
| 1309 | } | ||
| 1310 | let created_at = persistence::offset_to_instant( | ||
| 1311 | Duration::from_secs(e.created_at_offset_secs), | ||
| 1312 | state.saved_at, | ||
| 1313 | now_instant, | ||
| 1314 | ); | ||
| 1315 | let expires_at = persistence::offset_to_instant( | ||
| 1316 | Duration::from_secs(e.expires_at_offset_secs), | ||
| 1317 | state.saved_at, | ||
| 1318 | now_instant, | ||
| 1319 | ); | ||
| 1320 | let key = (e.owner, e.identifier.clone()); | ||
| 1321 | self.announcement_purgatory.insert( | ||
| 1322 | key, | ||
| 1323 | AnnouncementPurgatoryEntry { | ||
| 1324 | event: e.event, | ||
| 1325 | identifier: e.identifier, | ||
| 1326 | owner: e.owner, | ||
| 1327 | repo_path: e.repo_path, | ||
| 1328 | relays: e.relays, | ||
| 1329 | created_at, | ||
| 1330 | expires_at, | ||
| 1331 | soft_expired: false, | ||
| 1332 | }, | ||
| 1333 | ); | ||
| 1334 | } | ||
| 1335 | |||
| 1237 | // Restore state_events | 1336 | // Restore state_events |
| 1238 | for (identifier, entries) in state.state_events { | 1337 | for (identifier, entries) in state.state_events { |
| 1239 | let restored_entries: Vec<StatePurgatoryEntry> = entries | 1338 | let restored_entries: Vec<StatePurgatoryEntry> = entries |
| @@ -1301,6 +1400,7 @@ impl Purgatory { | |||
| 1301 | 1400 | ||
| 1302 | tracing::info!( | 1401 | tracing::info!( |
| 1303 | path = %path.display(), | 1402 | path = %path.display(), |
| 1403 | announcements = self.announcement_purgatory.len(), | ||
| 1304 | state_events = self.state_events.len(), | 1404 | state_events = self.state_events.len(), |
| 1305 | pr_events = self.pr_events.len(), | 1405 | pr_events = self.pr_events.len(), |
| 1306 | expired_events = self.expired_events.len(), | 1406 | expired_events = self.expired_events.len(), |
| @@ -2426,6 +2526,141 @@ async fn test_file_cleanup_after_successful_restore() { | |||
| 2426 | } | 2526 | } |
| 2427 | 2527 | ||
| 2428 | #[tokio::test] | 2528 | #[tokio::test] |
| 2529 | async fn test_save_and_restore_announcement_events() { | ||
| 2530 | use tempfile::tempdir; | ||
| 2531 | |||
| 2532 | let temp_dir = tempdir().unwrap(); | ||
| 2533 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 2534 | |||
| 2535 | // Create a real bare repo directory so the restore path-existence check passes | ||
| 2536 | let repo_dir = temp_dir.path().join("owner.git"); | ||
| 2537 | std::fs::create_dir_all(&repo_dir).unwrap(); | ||
| 2538 | |||
| 2539 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 2540 | let keys = Keys::generate(); | ||
| 2541 | |||
| 2542 | let ann_event = EventBuilder::text_note("announcement event") | ||
| 2543 | .sign_with_keys(&keys) | ||
| 2544 | .unwrap(); | ||
| 2545 | let ann_event_id = ann_event.id; | ||
| 2546 | |||
| 2547 | let mut relays = HashSet::new(); | ||
| 2548 | relays.insert("wss://relay.example.com".to_string()); | ||
| 2549 | |||
| 2550 | purgatory.add_announcement( | ||
| 2551 | ann_event.clone(), | ||
| 2552 | "my-repo".to_string(), | ||
| 2553 | keys.public_key(), | ||
| 2554 | repo_dir.clone(), | ||
| 2555 | relays.clone(), | ||
| 2556 | ); | ||
| 2557 | |||
| 2558 | // Save to disk | ||
| 2559 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 2560 | assert!(state_file.exists()); | ||
| 2561 | |||
| 2562 | // Create new purgatory and restore | ||
| 2563 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 2564 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 2565 | |||
| 2566 | // File should be deleted after restore | ||
| 2567 | assert!(!state_file.exists()); | ||
| 2568 | |||
| 2569 | // Verify announcement was restored | ||
| 2570 | let (ann_count, _, _) = purgatory2.count(); | ||
| 2571 | assert_eq!(ann_count, 1); | ||
| 2572 | |||
| 2573 | let restored = purgatory2 | ||
| 2574 | .find_announcement(&keys.public_key(), "my-repo") | ||
| 2575 | .unwrap(); | ||
| 2576 | assert_eq!(restored.event.id, ann_event_id); | ||
| 2577 | assert_eq!(restored.identifier, "my-repo"); | ||
| 2578 | assert_eq!(restored.owner, keys.public_key()); | ||
| 2579 | assert_eq!(restored.repo_path, repo_dir); | ||
| 2580 | assert_eq!(restored.relays, relays); | ||
| 2581 | assert!(!restored.soft_expired); | ||
| 2582 | } | ||
| 2583 | |||
| 2584 | #[tokio::test] | ||
| 2585 | async fn test_soft_expired_announcements_not_persisted() { | ||
| 2586 | use tempfile::tempdir; | ||
| 2587 | |||
| 2588 | let temp_dir = tempdir().unwrap(); | ||
| 2589 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 2590 | |||
| 2591 | let repo_dir = temp_dir.path().join("owner.git"); | ||
| 2592 | std::fs::create_dir_all(&repo_dir).unwrap(); | ||
| 2593 | |||
| 2594 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 2595 | let keys = Keys::generate(); | ||
| 2596 | |||
| 2597 | let ann_event = EventBuilder::text_note("announcement event") | ||
| 2598 | .sign_with_keys(&keys) | ||
| 2599 | .unwrap(); | ||
| 2600 | |||
| 2601 | purgatory.add_announcement( | ||
| 2602 | ann_event.clone(), | ||
| 2603 | "my-repo".to_string(), | ||
| 2604 | keys.public_key(), | ||
| 2605 | repo_dir.clone(), | ||
| 2606 | HashSet::new(), | ||
| 2607 | ); | ||
| 2608 | |||
| 2609 | // Manually mark as soft-expired (bare repo deleted) | ||
| 2610 | let key = (keys.public_key(), "my-repo".to_string()); | ||
| 2611 | if let Some(mut entry) = purgatory.announcement_purgatory.get_mut(&key) { | ||
| 2612 | entry.soft_expired = true; | ||
| 2613 | } | ||
| 2614 | |||
| 2615 | // Save to disk — soft-expired entry should be excluded | ||
| 2616 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 2617 | |||
| 2618 | // Create new purgatory and restore | ||
| 2619 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 2620 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 2621 | |||
| 2622 | // Soft-expired announcement should NOT be restored | ||
| 2623 | let (ann_count, _, _) = purgatory2.count(); | ||
| 2624 | assert_eq!(ann_count, 0); | ||
| 2625 | } | ||
| 2626 | |||
| 2627 | #[tokio::test] | ||
| 2628 | async fn test_announcement_with_missing_repo_skipped_on_restore() { | ||
| 2629 | use tempfile::tempdir; | ||
| 2630 | |||
| 2631 | let temp_dir = tempdir().unwrap(); | ||
| 2632 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 2633 | |||
| 2634 | // Point to a repo path that does NOT exist | ||
| 2635 | let missing_repo = temp_dir.path().join("nonexistent.git"); | ||
| 2636 | |||
| 2637 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 2638 | let keys = Keys::generate(); | ||
| 2639 | |||
| 2640 | let ann_event = EventBuilder::text_note("announcement event") | ||
| 2641 | .sign_with_keys(&keys) | ||
| 2642 | .unwrap(); | ||
| 2643 | |||
| 2644 | purgatory.add_announcement( | ||
| 2645 | ann_event.clone(), | ||
| 2646 | "my-repo".to_string(), | ||
| 2647 | keys.public_key(), | ||
| 2648 | missing_repo.clone(), | ||
| 2649 | HashSet::new(), | ||
| 2650 | ); | ||
| 2651 | |||
| 2652 | // Save to disk (repo path is serialized even though it doesn't exist) | ||
| 2653 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 2654 | |||
| 2655 | // Create new purgatory and restore — entry should be skipped | ||
| 2656 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 2657 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 2658 | |||
| 2659 | let (ann_count, _, _) = purgatory2.count(); | ||
| 2660 | assert_eq!(ann_count, 0); | ||
| 2661 | } | ||
| 2662 | |||
| 2663 | #[tokio::test] | ||
| 2429 | async fn test_comprehensive_roundtrip() { | 2664 | async fn test_comprehensive_roundtrip() { |
| 2430 | use nostr_sdk::{Kind, Tag, TagKind}; | 2665 | use nostr_sdk::{Kind, Tag, TagKind}; |
| 2431 | use tempfile::tempdir; | 2666 | use tempfile::tempdir; |
| @@ -2433,10 +2668,27 @@ async fn test_comprehensive_roundtrip() { | |||
| 2433 | let temp_dir = tempdir().unwrap(); | 2668 | let temp_dir = tempdir().unwrap(); |
| 2434 | let state_file = temp_dir.path().join("purgatory_state.json"); | 2669 | let state_file = temp_dir.path().join("purgatory_state.json"); |
| 2435 | 2670 | ||
| 2671 | // Create a real bare repo directory for the announcement | ||
| 2672 | let repo_dir = temp_dir.path().join("owner.git"); | ||
| 2673 | std::fs::create_dir_all(&repo_dir).unwrap(); | ||
| 2674 | |||
| 2436 | let purgatory = Purgatory::new(PathBuf::new()); | 2675 | let purgatory = Purgatory::new(PathBuf::new()); |
| 2437 | let keys1 = Keys::generate(); | 2676 | let keys1 = Keys::generate(); |
| 2438 | let keys2 = Keys::generate(); | 2677 | let keys2 = Keys::generate(); |
| 2439 | 2678 | ||
| 2679 | // Add announcement | ||
| 2680 | let ann_event = EventBuilder::text_note("announcement") | ||
| 2681 | .sign_with_keys(&keys1) | ||
| 2682 | .unwrap(); | ||
| 2683 | let ann_event_id = ann_event.id; | ||
| 2684 | purgatory.add_announcement( | ||
| 2685 | ann_event, | ||
| 2686 | "repo1".to_string(), | ||
| 2687 | keys1.public_key(), | ||
| 2688 | repo_dir.clone(), | ||
| 2689 | HashSet::new(), | ||
| 2690 | ); | ||
| 2691 | |||
| 2440 | // Add multiple state events | 2692 | // Add multiple state events |
| 2441 | let state1 = EventBuilder::text_note("state 1") | 2693 | let state1 = EventBuilder::text_note("state 1") |
| 2442 | .sign_with_keys(&keys1) | 2694 | .sign_with_keys(&keys1) |
| @@ -2476,7 +2728,8 @@ async fn test_comprehensive_roundtrip() { | |||
| 2476 | purgatory.cleanup(); | 2728 | purgatory.cleanup(); |
| 2477 | 2729 | ||
| 2478 | // Verify initial state | 2730 | // Verify initial state |
| 2479 | let (_, state_count, pr_count) = purgatory.count(); | 2731 | let (ann_count, state_count, pr_count) = purgatory.count(); |
| 2732 | assert_eq!(ann_count, 1); // announcement | ||
| 2480 | assert_eq!(state_count, 2); // state1, state2 (expired_event was cleaned up) | 2733 | assert_eq!(state_count, 2); // state1, state2 (expired_event was cleaned up) |
| 2481 | assert_eq!(pr_count, 2); // pr-1, pr-2 | 2734 | assert_eq!(pr_count, 2); // pr-1, pr-2 |
| 2482 | assert_eq!(purgatory.expired_count(), 1); // expired_event | 2735 | assert_eq!(purgatory.expired_count(), 1); // expired_event |
| @@ -2489,11 +2742,18 @@ async fn test_comprehensive_roundtrip() { | |||
| 2489 | purgatory2.restore_from_disk(&state_file).unwrap(); | 2742 | purgatory2.restore_from_disk(&state_file).unwrap(); |
| 2490 | 2743 | ||
| 2491 | // Verify all data was restored correctly | 2744 | // Verify all data was restored correctly |
| 2492 | let (_, state_count2, pr_count2) = purgatory2.count(); | 2745 | let (ann_count2, state_count2, pr_count2) = purgatory2.count(); |
| 2746 | assert_eq!(ann_count2, 1); | ||
| 2493 | assert_eq!(state_count2, 2); | 2747 | assert_eq!(state_count2, 2); |
| 2494 | assert_eq!(pr_count2, 2); | 2748 | assert_eq!(pr_count2, 2); |
| 2495 | assert_eq!(purgatory2.expired_count(), 1); | 2749 | assert_eq!(purgatory2.expired_count(), 1); |
| 2496 | 2750 | ||
| 2751 | // Verify announcement | ||
| 2752 | let restored_ann = purgatory2 | ||
| 2753 | .find_announcement(&keys1.public_key(), "repo1") | ||
| 2754 | .unwrap(); | ||
| 2755 | assert_eq!(restored_ann.event.id, ann_event_id); | ||
| 2756 | |||
| 2497 | // Verify state events | 2757 | // Verify state events |
| 2498 | assert_eq!(purgatory2.find_state("repo1").len(), 1); | 2758 | assert_eq!(purgatory2.find_state("repo1").len(), 1); |
| 2499 | assert_eq!(purgatory2.find_state("repo2").len(), 1); | 2759 | assert_eq!(purgatory2.find_state("repo2").len(), 1); |