upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-23 15:08:37 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-23 15:08:37 +0000
commit26f608e5011b9d1ad6036da75b89272835e69695 (patch)
tree8b5dfe29f65abe80e59bddbcd3ee09c0a369dba8 /src
parent4848c4029fc58f6f310a2babeae1ee82a7e41656 (diff)
persist and restore announcement events across graceful restarts
Extends purgatory persistence to include announcement purgatory entries. On graceful shutdown, non-soft-expired announcements are serialised to purgatory-state.json alongside state/PR/expired events; on startup they are restored, skipping any entry whose bare repo path no longer exists. Updates purgatory-design.md to reflect that purgatory persists through graceful shutdown and documents the new PurgatoryState disk format. Adds create_announcement_event helper to purgatory_helpers and three new integration tests in purgatory_persistence covering the full save/restore cycle, missing-repo skip, and the combined roundtrip with all entry types.
Diffstat (limited to 'src')
-rw-r--r--src/purgatory/mod.rs264
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)]
94struct 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]
2529async 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]
2585async 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]
2628async 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]
2429async fn test_comprehensive_roundtrip() { 2664async 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);