upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/purgatory/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/purgatory/mod.rs')
-rw-r--r--src/purgatory/mod.rs659
1 files changed, 627 insertions, 32 deletions
diff --git a/src/purgatory/mod.rs b/src/purgatory/mod.rs
index 2c278f6..bb6ff54 100644
--- a/src/purgatory/mod.rs
+++ b/src/purgatory/mod.rs
@@ -17,7 +17,7 @@ pub mod sync;
17mod types; 17mod types;
18 18
19pub use helpers::{can_apply_state, can_satisfy_state, diagnose_state_mismatch, extract_refs_from_state, get_unpushed_refs}; 19pub use helpers::{can_apply_state, can_satisfy_state, diagnose_state_mismatch, extract_refs_from_state, get_unpushed_refs};
20pub use types::{EventSource, PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry}; 20pub use types::{AnnouncementPurgatoryEntry, EventSource, PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry};
21 21
22use dashmap::DashMap; 22use dashmap::DashMap;
23use nostr_sdk::prelude::*; 23use nostr_sdk::prelude::*;
@@ -34,6 +34,13 @@ pub use sync::SyncQueueEntry;
34/// Default expiry duration for purgatory entries (30 minutes) 34/// Default expiry duration for purgatory entries (30 minutes)
35const DEFAULT_EXPIRY: Duration = Duration::from_secs(1800); 35const DEFAULT_EXPIRY: Duration = Duration::from_secs(1800);
36 36
37/// Extended expiry for soft-expired announcements (24 hours).
38///
39/// After the initial 30-minute expiry, the bare repo is deleted but the event is
40/// retained for this additional period. This allows revival if a state event arrives
41/// late (e.g. slow sync), without permanently blocking the repository.
42const SOFT_EXPIRY_EXTENDED: Duration = Duration::from_secs(86400);
43
37/// Default delay before syncing user-submitted events (3 minutes). 44/// Default delay before syncing user-submitted events (3 minutes).
38/// This gives time for the git push to arrive after the nostr event. 45/// This gives time for the git push to arrive after the nostr event.
39const DEFAULT_SYNC_DELAY: Duration = Duration::from_secs(180); 46const DEFAULT_SYNC_DELAY: Duration = Duration::from_secs(180);
@@ -83,9 +90,35 @@ struct SerializablePrPurgatoryEntry {
83 source: types::EventSource, 90 source: types::EventSource,
84} 91}
85 92
93/// Serializable wrapper for `AnnouncementPurgatoryEntry` with time offsets.
94///
95/// Stores `Instant` fields as `Duration` offsets from the `saved_at` timestamp
96/// in `PurgatoryState`, allowing state to be persisted and restored across restarts.
97///
98/// Note: soft-expired entries (bare repo deleted) are NOT persisted — they have
99/// no git repo on disk and would be immediately cleaned up on restore anyway.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101struct SerializableAnnouncementPurgatoryEntry {
102 /// The nostr announcement event (kind 30617)
103 event: Event,
104 /// The repository identifier from the event's 'd' tag
105 identifier: String,
106 /// The owner pubkey (event author)
107 owner: PublicKey,
108 /// Path to the bare git repository (must exist on disk)
109 repo_path: PathBuf,
110 /// Relay URLs from the announcement (for sync registration)
111 relays: HashSet<String>,
112 /// Duration offset from saved_at for created_at
113 created_at_offset_secs: u64,
114 /// Duration offset from saved_at for expires_at
115 expires_at_offset_secs: u64,
116}
117
86/// Serializable purgatory state for disk persistence. 118/// Serializable purgatory state for disk persistence.
87/// 119///
88/// Contains all purgatory data needed to restore state across restarts: 120/// Contains all purgatory data needed to restore state across restarts:
121/// - Announcement events (indexed by (owner, identifier)) — non-soft-expired only
89/// - State events (indexed by identifier) 122/// - State events (indexed by identifier)
90/// - PR events (indexed by event ID) 123/// - PR events (indexed by event ID)
91/// - Expired events (to prevent re-sync loops) 124/// - Expired events (to prevent re-sync loops)
@@ -97,6 +130,10 @@ struct PurgatoryState {
97 version: u32, 130 version: u32,
98 /// When this state was saved to disk 131 /// When this state was saved to disk
99 saved_at: SystemTime, 132 saved_at: SystemTime,
133 /// Announcement events indexed by "owner_hex:identifier"
134 /// Only non-soft-expired entries are persisted (bare repo must exist).
135 #[serde(default)]
136 announcement_purgatory: HashMap<String, SerializableAnnouncementPurgatoryEntry>,
100 /// State events indexed by repository identifier 137 /// State events indexed by repository identifier
101 state_events: HashMap<String, Vec<SerializableStatePurgatoryEntry>>, 138 state_events: HashMap<String, Vec<SerializableStatePurgatoryEntry>>,
102 /// PR events indexed by event ID (hex string) 139 /// PR events indexed by event ID (hex string)
@@ -107,7 +144,8 @@ struct PurgatoryState {
107 144
108/// Main purgatory structure holding events awaiting git data. 145/// Main purgatory structure holding events awaiting git data.
109/// 146///
110/// Provides thread-safe concurrent access to two separate stores: 147/// Provides thread-safe concurrent access to three separate stores:
148/// - Announcements indexed by (pubkey, identifier)
111/// - State events indexed by repository identifier 149/// - State events indexed by repository identifier
112/// - PR events indexed by event ID 150/// - PR events indexed by event ID
113/// 151///
@@ -128,6 +166,10 @@ struct PurgatoryState {
128/// that we've already determined have no git data available. 166/// that we've already determined have no git data available.
129#[derive(Clone)] 167#[derive(Clone)]
130pub struct Purgatory { 168pub struct Purgatory {
169 /// Repository announcements (kind 30617) indexed by (owner pubkey, identifier).
170 /// Key: (PublicKey, String) where String is the repository identifier.
171 announcement_purgatory: Arc<DashMap<(PublicKey, String), AnnouncementPurgatoryEntry>>,
172
131 /// State events (kind 30618) indexed by repository identifier. 173 /// State events (kind 30618) indexed by repository identifier.
132 /// Multiple state events can wait for the same identifier (different maintainers). 174 /// Multiple state events can wait for the same identifier (different maintainers).
133 state_events: Arc<DashMap<String, Vec<StatePurgatoryEntry>>>, 175 state_events: Arc<DashMap<String, Vec<StatePurgatoryEntry>>>,
@@ -152,6 +194,7 @@ impl Purgatory {
152 /// Create a new empty purgatory. 194 /// Create a new empty purgatory.
153 pub fn new(git_data_path: impl Into<PathBuf>) -> Self { 195 pub fn new(git_data_path: impl Into<PathBuf>) -> Self {
154 Self { 196 Self {
197 announcement_purgatory: Arc::new(DashMap::new()),
155 state_events: Arc::new(DashMap::new()), 198 state_events: Arc::new(DashMap::new()),
156 pr_events: Arc::new(DashMap::new()), 199 pr_events: Arc::new(DashMap::new()),
157 sync_queue: Arc::new(DashMap::new()), 200 sync_queue: Arc::new(DashMap::new()),
@@ -576,9 +619,245 @@ impl Purgatory {
576 self.pr_events.remove(event_id); 619 self.pr_events.remove(event_id);
577 } 620 }
578 621
622 // =========================================================================
623 // Announcement Purgatory Methods
624 // =========================================================================
625
626 /// Add a repository announcement to purgatory.
627 ///
628 /// The announcement will be held until git data arrives, at which point
629 /// it will be promoted to the database and served to clients.
630 ///
631 /// # Arguments
632 /// * `event` - The announcement event (kind 30617)
633 /// * `identifier` - The repository identifier from the 'd' tag
634 /// * `owner` - The owner pubkey (event author)
635 /// * `repo_path` - Path to the bare git repository
636 /// * `relays` - Relay URLs from the announcement (for sync registration)
637 pub fn add_announcement(
638 &self,
639 event: Event,
640 identifier: String,
641 owner: PublicKey,
642 repo_path: PathBuf,
643 relays: HashSet<String>,
644 ) {
645 let now = Instant::now();
646 let entry = AnnouncementPurgatoryEntry {
647 event,
648 identifier: identifier.clone(),
649 owner,
650 repo_path,
651 relays,
652 created_at: now,
653 expires_at: now + DEFAULT_EXPIRY,
654 soft_expired: false,
655 };
656
657 let key = (owner, identifier);
658 self.announcement_purgatory.insert(key.clone(), entry);
659
660 tracing::debug!(
661 owner = %key.0,
662 identifier = %key.1,
663 "Added announcement to purgatory"
664 );
665 }
666
667 /// Find an announcement in purgatory by owner and identifier.
668 ///
669 /// # Arguments
670 /// * `owner` - The owner pubkey
671 /// * `identifier` - The repository identifier
672 ///
673 /// # Returns
674 /// The announcement entry if found, None otherwise
675 pub fn find_announcement(&self, owner: &PublicKey, identifier: &str) -> Option<AnnouncementPurgatoryEntry> {
676 let key = (*owner, identifier.to_string());
677 self.announcement_purgatory.get(&key).map(|entry| entry.clone())
678 }
679
680 /// Get all announcements in purgatory for a given identifier.
681 ///
682 /// This is used for authorization - state events and git pushes need to
683 /// check purgatory announcements for maintainer validation.
684 ///
685 /// # Arguments
686 /// * `identifier` - The repository identifier
687 ///
688 /// # Returns
689 /// Vector of announcement entries for this identifier
690 pub fn get_announcements_by_identifier(&self, identifier: &str) -> Vec<AnnouncementPurgatoryEntry> {
691 self.announcement_purgatory
692 .iter()
693 .filter(|entry| entry.key().1 == identifier)
694 .map(|entry| entry.value().clone())
695 .collect()
696 }
697
698 /// Remove an announcement from purgatory.
699 ///
700 /// # Arguments
701 /// * `owner` - The owner pubkey
702 /// * `identifier` - The repository identifier
703 pub fn remove_announcement(&self, owner: &PublicKey, identifier: &str) {
704 let key = (*owner, identifier.to_string());
705 self.announcement_purgatory.remove(&key);
706 tracing::debug!(
707 owner = %owner,
708 identifier = %identifier,
709 "Removed announcement from purgatory"
710 );
711 }
712
713 /// Promote an announcement from purgatory to active status.
714 ///
715 /// This is called when git data arrives. The announcement event is returned
716 /// so it can be saved to the database.
717 ///
718 /// # Arguments
719 /// * `owner` - The owner pubkey
720 /// * `identifier` - The repository identifier
721 ///
722 /// # Returns
723 /// The announcement event if found, None otherwise
724 pub fn promote_announcement(&self, owner: &PublicKey, identifier: &str) -> Option<Event> {
725 let key = (*owner, identifier.to_string());
726 self.announcement_purgatory.remove(&key).map(|(_, entry)| {
727 tracing::info!(
728 owner = %owner,
729 identifier = %identifier,
730 "Promoted announcement from purgatory to database"
731 );
732 entry.event
733 })
734 }
735
736 /// Check if there's an announcement in purgatory for the given owner and identifier.
737 ///
738 /// # Arguments
739 /// * `owner` - The owner pubkey
740 /// * `identifier` - The repository identifier
741 ///
742 /// # Returns
743 /// true if an announcement exists in purgatory, false otherwise
744 pub fn has_purgatory_announcement(&self, owner: &PublicKey, identifier: &str) -> bool {
745 let key = (*owner, identifier.to_string());
746 self.announcement_purgatory.contains_key(&key)
747 }
748
749 /// Extend the expiry for an announcement in purgatory.
750 ///
751 /// This is called when state events arrive for a purgatory announcement,
752 /// indicating the repository is actively receiving metadata.
753 ///
754 /// # Arguments
755 /// * `owner` - The owner pubkey
756 /// * `identifier` - The repository identifier
757 /// * `duration` - Minimum duration to guarantee from now
758 pub fn extend_announcement_expiry(&self, owner: &PublicKey, identifier: &str, duration: Duration) {
759 let key = (*owner, identifier.to_string());
760
761 // Collect revival info before taking a mutable borrow
762 let revival_info: Option<(PathBuf, bool)> = self
763 .announcement_purgatory
764 .get(&key)
765 .map(|entry| (entry.repo_path.clone(), entry.soft_expired));
766
767 if let Some(mut entry) = self.announcement_purgatory.get_mut(&key) {
768 let now = Instant::now();
769 let new_expiry = now + duration;
770 if entry.expires_at < new_expiry {
771 entry.expires_at = new_expiry;
772 }
773 // Always reset soft_expired when expiry is extended — the caller
774 // (state event or git auth) signals the repo is still active.
775 if entry.soft_expired {
776 entry.soft_expired = false;
777 }
778 }
779
780 // If the entry was soft-expired, recreate the bare repo outside the
781 // mutable borrow so we don't hold the DashMap lock during I/O.
782 if let Some((repo_path, was_soft_expired)) = revival_info {
783 if was_soft_expired {
784 if !repo_path.exists() {
785 match std::fs::create_dir_all(&repo_path) {
786 Ok(()) => {
787 // Initialise as a bare git repository
788 let status = std::process::Command::new("git")
789 .args(["init", "--bare"])
790 .arg(&repo_path)
791 .status();
792 match status {
793 Ok(s) if s.success() => {
794 tracing::info!(
795 path = %repo_path.display(),
796 owner = %owner,
797 identifier = %identifier,
798 "Recreated bare repository for revived soft-expired announcement"
799 );
800 }
801 Ok(s) => {
802 tracing::warn!(
803 path = %repo_path.display(),
804 exit_code = ?s.code(),
805 "git init --bare failed when reviving soft-expired announcement"
806 );
807 }
808 Err(e) => {
809 tracing::warn!(
810 path = %repo_path.display(),
811 error = %e,
812 "Failed to run git init --bare when reviving soft-expired announcement"
813 );
814 }
815 }
816 }
817 Err(e) => {
818 tracing::warn!(
819 path = %repo_path.display(),
820 error = %e,
821 "Failed to create directory when reviving soft-expired announcement"
822 );
823 }
824 }
825 }
826 tracing::info!(
827 owner = %owner,
828 identifier = %identifier,
829 "Revived soft-expired announcement (bare repo recreated, expiry extended)"
830 );
831 }
832 }
833 }
834
835 /// Get count of announcements in purgatory.
836 pub fn announcement_count(&self) -> usize {
837 self.announcement_purgatory.len()
838 }
839
840 /// Collect (repo_id, relay_urls) for all announcements currently in purgatory.
841 ///
842 /// Returns a vec of `(repo_id, relay_urls)` where `repo_id` is the addressable
843 /// coordinate string `"30617:{pubkey_hex}:{identifier}"`. Used by the purgatory
844 /// announcement sync timer to register StateOnly entries in `repo_sync_index`.
845 pub fn announcements_for_sync(&self) -> Vec<(String, HashSet<String>)> {
846 self.announcement_purgatory
847 .iter()
848 .map(|entry| {
849 let (owner, identifier) = entry.key();
850 let repo_id = format!("30617:{}:{}", owner.to_hex(), identifier);
851 let relays = entry.value().relays.clone();
852 (repo_id, relays)
853 })
854 .collect()
855 }
856
579 /// Get all event IDs currently stored in purgatory AND previously expired events. 857 /// Get all event IDs currently stored in purgatory AND previously expired events.
580 /// 858 ///
581 /// Returns a HashSet of all event IDs for: 859 /// Returns a HashSet of all event IDs for:
860 /// - Announcements currently held in purgatory
582 /// - State events currently held in purgatory 861 /// - State events currently held in purgatory
583 /// - PR events currently held in purgatory 862 /// - PR events currently held in purgatory
584 /// - Events that previously expired from purgatory without finding git data 863 /// - Events that previously expired from purgatory without finding git data
@@ -593,6 +872,11 @@ impl Purgatory {
593 pub fn event_ids(&self) -> HashSet<EventId> { 872 pub fn event_ids(&self) -> HashSet<EventId> {
594 let mut ids = HashSet::new(); 873 let mut ids = HashSet::new();
595 874
875 // Collect announcement event IDs
876 for entry in self.announcement_purgatory.iter() {
877 ids.insert(entry.value().event.id);
878 }
879
596 // Collect state event IDs 880 // Collect state event IDs
597 for entry in self.state_events.iter() { 881 for entry in self.state_events.iter() {
598 for state_entry in entry.value().iter() { 882 for state_entry in entry.value().iter() {
@@ -675,9 +959,86 @@ impl Purgatory {
675 /// to support migration scripts and operational monitoring. 959 /// to support migration scripts and operational monitoring.
676 /// 960 ///
677 /// # Returns 961 /// # Returns
678 /// Tuple of (num_state_removed, num_pr_removed) 962 /// Tuple of (num_announcement_removed, num_state_removed, num_pr_removed)
679 pub fn cleanup(&self) -> (usize, usize) { 963 pub fn cleanup(&self) -> (usize, usize, usize) {
680 let now = Instant::now(); 964 let now = Instant::now();
965
966 // Process expired announcements with two-phase soft expiry:
967 //
968 // Phase 1 (initial expiry, !soft_expired): Delete bare repo, set soft_expired=true,
969 // extend expiry by SOFT_EXPIRY_EXTENDED so the event is retained for revival.
970 // Phase 2 (extended expiry, soft_expired): Fully remove from purgatory.
971 //
972 // Collect entries that have passed their expires_at deadline.
973 let expired_announcements: Vec<(PublicKey, String, PathBuf, EventId, bool)> = self
974 .announcement_purgatory
975 .iter()
976 .filter(|entry| entry.value().expires_at <= now)
977 .map(|entry| {
978 let key = entry.key();
979 let v = entry.value();
980 (key.0.clone(), key.1.clone(), v.repo_path.clone(), v.event.id, v.soft_expired)
981 })
982 .collect();
983
984 let mut announcement_removed = 0;
985 for (owner, identifier, repo_path, event_id, already_soft_expired) in expired_announcements {
986 if already_soft_expired {
987 // Phase 2: fully remove
988 self.mark_expired(event_id);
989 self.announcement_purgatory.remove(&(owner.clone(), identifier.clone()));
990 announcement_removed += 1;
991 tracing::info!(
992 owner = %owner,
993 identifier = %identifier,
994 "Announcement fully expired from purgatory (soft expiry period elapsed)"
995 );
996 } else {
997 // Phase 1: soft expiry — delete bare repo, retain event.
998 //
999 // Only transition to soft_expired if the directory is gone (or never
1000 // existed). If removal fails we leave the entry untouched so the next
1001 // cleanup cycle retries the deletion automatically.
1002 let repo_gone = if repo_path.exists() {
1003 match std::fs::remove_dir_all(&repo_path) {
1004 Ok(()) => {
1005 tracing::info!(
1006 path = %repo_path.display(),
1007 owner = %owner,
1008 identifier = %identifier,
1009 "Deleted bare repository during soft expiry (event retained for revival)"
1010 );
1011 true
1012 }
1013 Err(e) => {
1014 tracing::warn!(
1015 path = %repo_path.display(),
1016 error = %e,
1017 "Failed to delete bare repository during soft expiry; will retry next cleanup cycle"
1018 );
1019 false
1020 }
1021 }
1022 } else {
1023 // Already gone (e.g. deleted externally)
1024 true
1025 };
1026
1027 if repo_gone {
1028 // Mark soft_expired and extend expiry
1029 if let Some(mut entry) = self.announcement_purgatory.get_mut(&(owner.clone(), identifier.clone())) {
1030 entry.soft_expired = true;
1031 entry.expires_at = now + SOFT_EXPIRY_EXTENDED;
1032 }
1033 tracing::debug!(
1034 owner = %owner,
1035 identifier = %identifier,
1036 "Announcement soft-expired: bare repo deleted, event retained for 24h"
1037 );
1038 }
1039 }
1040 }
1041
681 let mut state_removed = 0; 1042 let mut state_removed = 0;
682 1043
683 // Remove expired state events and mark them as expired 1044 // Remove expired state events and mark them as expired
@@ -823,17 +1184,17 @@ impl Purgatory {
823 self.pr_events.remove(&event_id_str); 1184 self.pr_events.remove(&event_id_str);
824 } 1185 }
825 1186
826 (state_removed, pr_removed) 1187 (announcement_removed, state_removed, pr_removed)
827 } 1188 }
828 1189
829 /// Remove expired entries from purgatory (legacy method). 1190 /// Remove expired entries from purgatory (legacy method).
830 /// 1191 ///
831 /// # Returns 1192 /// # Returns
832 /// Total number of entries removed (state + PR events) 1193 /// Total number of entries removed (announcement + state + PR events)
833 #[deprecated(since = "0.1.0", note = "Use cleanup() instead for separate counts")] 1194 #[deprecated(since = "0.1.0", note = "Use cleanup() instead for separate counts")]
834 pub fn remove_expired(&self) -> usize { 1195 pub fn remove_expired(&self) -> usize {
835 let (state, pr) = self.cleanup(); 1196 let (announcement, state, pr) = self.cleanup();
836 state + pr 1197 announcement + state + pr
837 } 1198 }
838 1199
839 /// Remove old expired event records. 1200 /// Remove old expired event records.
@@ -867,11 +1228,12 @@ impl Purgatory {
867 /// Get current count of entries in purgatory. 1228 /// Get current count of entries in purgatory.
868 /// 1229 ///
869 /// # Returns 1230 /// # Returns
870 /// Tuple of (state_event_count, pr_event_count) 1231 /// Tuple of (announcement_count, state_event_count, pr_event_count)
871 pub fn count(&self) -> (usize, usize) { 1232 pub fn count(&self) -> (usize, usize, usize) {
1233 let announcement_count = self.announcement_purgatory.len();
872 let state_count: usize = self.state_events.iter().map(|e| e.value().len()).sum(); 1234 let state_count: usize = self.state_events.iter().map(|e| e.value().len()).sum();
873 let pr_count = self.pr_events.len(); 1235 let pr_count = self.pr_events.len();
874 (state_count, pr_count) 1236 (announcement_count, state_count, pr_count)
875 } 1237 }
876 1238
877 /// Get count of expired events being tracked. 1239 /// Get count of expired events being tracked.
@@ -885,6 +1247,7 @@ impl Purgatory {
885 /// Clear all entries from purgatory (for testing). 1247 /// Clear all entries from purgatory (for testing).
886 #[cfg(test)] 1248 #[cfg(test)]
887 pub fn clear(&self) { 1249 pub fn clear(&self) {
1250 self.announcement_purgatory.clear();
888 self.state_events.clear(); 1251 self.state_events.clear();
889 self.pr_events.clear(); 1252 self.pr_events.clear();
890 self.sync_queue.clear(); 1253 self.sync_queue.clear();
@@ -949,6 +1312,34 @@ impl Purgatory {
949 let saved_at = SystemTime::now(); 1312 let saved_at = SystemTime::now();
950 let now_instant = Instant::now(); 1313 let now_instant = Instant::now();
951 1314
1315 // Convert announcement_purgatory to serializable format.
1316 // Skip soft-expired entries: their bare repos have been deleted, so they
1317 // cannot be meaningfully restored (the repo path no longer exists on disk).
1318 let mut announcement_purgatory = HashMap::new();
1319 for entry in self.announcement_purgatory.iter() {
1320 let e = entry.value();
1321 if e.soft_expired {
1322 continue;
1323 }
1324 let created_offset =
1325 persistence::instant_to_offset(e.created_at, saved_at, now_instant);
1326 let expires_offset =
1327 persistence::instant_to_offset(e.expires_at, saved_at, now_instant);
1328 let key = format!("{}:{}", e.owner.to_hex(), e.identifier);
1329 announcement_purgatory.insert(
1330 key,
1331 SerializableAnnouncementPurgatoryEntry {
1332 event: e.event.clone(),
1333 identifier: e.identifier.clone(),
1334 owner: e.owner,
1335 repo_path: e.repo_path.clone(),
1336 relays: e.relays.clone(),
1337 created_at_offset_secs: created_offset.as_secs(),
1338 expires_at_offset_secs: expires_offset.as_secs(),
1339 },
1340 );
1341 }
1342
952 // Convert state_events to serializable format 1343 // Convert state_events to serializable format
953 let mut state_events = HashMap::new(); 1344 let mut state_events = HashMap::new();
954 for entry in self.state_events.iter() { 1345 for entry in self.state_events.iter() {
@@ -1013,6 +1404,7 @@ impl Purgatory {
1013 let state = PurgatoryState { 1404 let state = PurgatoryState {
1014 version: 1, 1405 version: 1,
1015 saved_at, 1406 saved_at,
1407 announcement_purgatory,
1016 state_events, 1408 state_events,
1017 pr_events, 1409 pr_events,
1018 expired_events, 1410 expired_events,
@@ -1024,6 +1416,7 @@ impl Purgatory {
1024 1416
1025 tracing::info!( 1417 tracing::info!(
1026 path = %path.display(), 1418 path = %path.display(),
1419 announcements = state.announcement_purgatory.len(),
1027 state_events = state.state_events.len(), 1420 state_events = state.state_events.len(),
1028 pr_events = state.pr_events.len(), 1421 pr_events = state.pr_events.len(),
1029 expired_events = state.expired_events.len(), 1422 expired_events = state.expired_events.len(),
@@ -1071,6 +1464,45 @@ impl Purgatory {
1071 1464
1072 let now_instant = Instant::now(); 1465 let now_instant = Instant::now();
1073 1466
1467 // Restore announcement_purgatory.
1468 // Skip entries whose bare repo no longer exists on disk — this can happen
1469 // if the repo was deleted externally between save and restore.
1470 for (_key, e) in state.announcement_purgatory {
1471 if !e.repo_path.exists() {
1472 tracing::warn!(
1473 owner = %e.owner,
1474 identifier = %e.identifier,
1475 repo_path = %e.repo_path.display(),
1476 "Skipping announcement restore: bare repo no longer exists"
1477 );
1478 continue;
1479 }
1480 let created_at = persistence::offset_to_instant(
1481 Duration::from_secs(e.created_at_offset_secs),
1482 state.saved_at,
1483 now_instant,
1484 );
1485 let expires_at = persistence::offset_to_instant(
1486 Duration::from_secs(e.expires_at_offset_secs),
1487 state.saved_at,
1488 now_instant,
1489 );
1490 let key = (e.owner, e.identifier.clone());
1491 self.announcement_purgatory.insert(
1492 key,
1493 AnnouncementPurgatoryEntry {
1494 event: e.event,
1495 identifier: e.identifier,
1496 owner: e.owner,
1497 repo_path: e.repo_path,
1498 relays: e.relays,
1499 created_at,
1500 expires_at,
1501 soft_expired: false,
1502 },
1503 );
1504 }
1505
1074 // Restore state_events 1506 // Restore state_events
1075 for (identifier, entries) in state.state_events { 1507 for (identifier, entries) in state.state_events {
1076 let restored_entries: Vec<StatePurgatoryEntry> = entries 1508 let restored_entries: Vec<StatePurgatoryEntry> = entries
@@ -1140,6 +1572,7 @@ impl Purgatory {
1140 1572
1141 tracing::info!( 1573 tracing::info!(
1142 path = %path.display(), 1574 path = %path.display(),
1575 announcements = self.announcement_purgatory.len(),
1143 state_events = self.state_events.len(), 1576 state_events = self.state_events.len(),
1144 pr_events = self.pr_events.len(), 1577 pr_events = self.pr_events.len(),
1145 expired_events = self.expired_events.len(), 1578 expired_events = self.expired_events.len(),
@@ -1162,7 +1595,8 @@ mod tests {
1162 #[test] 1595 #[test]
1163 fn test_purgatory_creation() { 1596 fn test_purgatory_creation() {
1164 let purgatory = Purgatory::new(PathBuf::new()); 1597 let purgatory = Purgatory::new(PathBuf::new());
1165 let (state_count, pr_count) = purgatory.count(); 1598 let (announcement_count, state_count, pr_count) = purgatory.count();
1599 assert_eq!(announcement_count, 0);
1166 assert_eq!(state_count, 0); 1600 assert_eq!(state_count, 0);
1167 assert_eq!(pr_count, 0); 1601 assert_eq!(pr_count, 0);
1168 } 1602 }
@@ -1190,7 +1624,8 @@ mod tests {
1190 false, 1624 false,
1191 ); 1625 );
1192 1626
1193 let (state_count, pr_count) = purgatory.count(); 1627 let (announcement_count, state_count, pr_count) = purgatory.count();
1628 assert_eq!(announcement_count, 0);
1194 assert_eq!(state_count, 1); 1629 assert_eq!(state_count, 1);
1195 assert_eq!(pr_count, 1); 1630 assert_eq!(pr_count, 1);
1196 } 1631 }
@@ -1407,7 +1842,7 @@ fn test_cleanup_removes_expired_entries() {
1407 purgatory.add_pr_placeholder("pr-456".to_string(), "commit-def".to_string()); 1842 purgatory.add_pr_placeholder("pr-456".to_string(), "commit-def".to_string());
1408 1843
1409 // Verify entries are there 1844 // Verify entries are there
1410 let (state_count, pr_count) = purgatory.count(); 1845 let (_, state_count, pr_count) = purgatory.count();
1411 assert_eq!(state_count, 1); 1846 assert_eq!(state_count, 1);
1412 assert_eq!(pr_count, 2); 1847 assert_eq!(pr_count, 2);
1413 1848
@@ -1425,14 +1860,14 @@ fn test_cleanup_removes_expired_entries() {
1425 } 1860 }
1426 1861
1427 // Run cleanup 1862 // Run cleanup
1428 let (state_removed, pr_removed) = purgatory.cleanup(); 1863 let (_, state_removed, pr_removed) = purgatory.cleanup();
1429 1864
1430 // Verify counts 1865 // Verify counts
1431 assert_eq!(state_removed, 1); 1866 assert_eq!(state_removed, 1);
1432 assert_eq!(pr_removed, 2); 1867 assert_eq!(pr_removed, 2);
1433 1868
1434 // Verify entries are gone 1869 // Verify entries are gone
1435 let (state_count, pr_count) = purgatory.count(); 1870 let (_, state_count, pr_count) = purgatory.count();
1436 assert_eq!(state_count, 0); 1871 assert_eq!(state_count, 0);
1437 assert_eq!(pr_count, 0); 1872 assert_eq!(pr_count, 0);
1438} 1873}
@@ -1464,14 +1899,14 @@ fn test_cleanup_preserves_non_expired_entries() {
1464 ); 1899 );
1465 1900
1466 // Run cleanup 1901 // Run cleanup
1467 let (state_removed, pr_removed) = purgatory.cleanup(); 1902 let (_, state_removed, pr_removed) = purgatory.cleanup();
1468 1903
1469 // Nothing should be removed 1904 // Nothing should be removed
1470 assert_eq!(state_removed, 0); 1905 assert_eq!(state_removed, 0);
1471 assert_eq!(pr_removed, 0); 1906 assert_eq!(pr_removed, 0);
1472 1907
1473 // Verify entries are still there 1908 // Verify entries are still there
1474 let (state_count, pr_count) = purgatory.count(); 1909 let (_, state_count, pr_count) = purgatory.count();
1475 assert_eq!(state_count, 1); 1910 assert_eq!(state_count, 1);
1476 assert_eq!(pr_count, 1); 1911 assert_eq!(pr_count, 1);
1477} 1912}
@@ -1518,14 +1953,14 @@ fn test_cleanup_mixed_expired_and_fresh() {
1518 } 1953 }
1519 1954
1520 // Run cleanup 1955 // Run cleanup
1521 let (state_removed, pr_removed) = purgatory.cleanup(); 1956 let (_, state_removed, pr_removed) = purgatory.cleanup();
1522 1957
1523 // One of each should be removed 1958 // One of each should be removed
1524 assert_eq!(state_removed, 1); 1959 assert_eq!(state_removed, 1);
1525 assert_eq!(pr_removed, 1); 1960 assert_eq!(pr_removed, 1);
1526 1961
1527 // Verify remaining counts 1962 // Verify remaining counts
1528 let (state_count, pr_count) = purgatory.count(); 1963 let (_, state_count, pr_count) = purgatory.count();
1529 assert_eq!(state_count, 1); // One state event remains 1964 assert_eq!(state_count, 1); // One state event remains
1530 assert_eq!(pr_count, 1); // One PR event remains 1965 assert_eq!(pr_count, 1); // One PR event remains
1531} 1966}
@@ -1595,7 +2030,7 @@ fn test_expired_event_tracking() {
1595 } 2030 }
1596 2031
1597 // Run cleanup 2032 // Run cleanup
1598 let (state_removed, pr_removed) = purgatory.cleanup(); 2033 let (_, state_removed, pr_removed) = purgatory.cleanup();
1599 assert_eq!(state_removed, 1); 2034 assert_eq!(state_removed, 1);
1600 assert_eq!(pr_removed, 1); 2035 assert_eq!(pr_removed, 1);
1601 2036
@@ -1705,7 +2140,7 @@ fn test_expired_events_prevent_readdition() {
1705 } 2140 }
1706 2141
1707 // Event should NOT be re-added 2142 // Event should NOT be re-added
1708 let (state_count, _) = purgatory.count(); 2143 let (_, state_count, _) = purgatory.count();
1709 assert_eq!(state_count, 0, "Event should not be re-added to purgatory"); 2144 assert_eq!(state_count, 0, "Event should not be re-added to purgatory");
1710} 2145}
1711 2146
@@ -1724,7 +2159,7 @@ fn test_pr_placeholder_not_marked_expired() {
1724 } 2159 }
1725 2160
1726 // Run cleanup 2161 // Run cleanup
1727 let (_, pr_removed) = purgatory.cleanup(); 2162 let (_, _, pr_removed) = purgatory.cleanup();
1728 assert_eq!(pr_removed, 1); 2163 assert_eq!(pr_removed, 1);
1729 2164
1730 // Expired count should be 0 (placeholders don't have event IDs to track) 2165 // Expired count should be 0 (placeholders don't have event IDs to track)
@@ -1820,7 +2255,7 @@ async fn test_save_and_restore_state_events() {
1820 assert!(!state_file.exists()); 2255 assert!(!state_file.exists());
1821 2256
1822 // Verify state events were restored 2257 // Verify state events were restored
1823 let (state_count, _) = purgatory2.count(); 2258 let (_, state_count, _) = purgatory2.count();
1824 assert_eq!(state_count, 2); 2259 assert_eq!(state_count, 2);
1825 2260
1826 let restored_entries = purgatory2.find_state("test-repo"); 2261 let restored_entries = purgatory2.find_state("test-repo");
@@ -1877,7 +2312,7 @@ async fn test_save_and_restore_pr_events() {
1877 purgatory2.restore_from_disk(&state_file).unwrap(); 2312 purgatory2.restore_from_disk(&state_file).unwrap();
1878 2313
1879 // Verify PR event was restored 2314 // Verify PR event was restored
1880 let (_, pr_count) = purgatory2.count(); 2315 let (_, _, pr_count) = purgatory2.count();
1881 assert_eq!(pr_count, 1); 2316 assert_eq!(pr_count, 1);
1882 2317
1883 let restored_entry = purgatory2.find_pr("pr-event-id").unwrap(); 2318 let restored_entry = purgatory2.find_pr("pr-event-id").unwrap();
@@ -1906,7 +2341,7 @@ async fn test_save_and_restore_pr_placeholders() {
1906 purgatory2.restore_from_disk(&state_file).unwrap(); 2341 purgatory2.restore_from_disk(&state_file).unwrap();
1907 2342
1908 // Verify placeholder was restored 2343 // Verify placeholder was restored
1909 let (_, pr_count) = purgatory2.count(); 2344 let (_, _, pr_count) = purgatory2.count();
1910 assert_eq!(pr_count, 1); 2345 assert_eq!(pr_count, 1);
1911 2346
1912 let restored_entry = purgatory2.find_pr("placeholder-id").unwrap(); 2347 let restored_entry = purgatory2.find_pr("placeholder-id").unwrap();
@@ -1984,7 +2419,7 @@ async fn test_save_and_restore_empty_purgatory() {
1984 purgatory2.restore_from_disk(&state_file).unwrap(); 2419 purgatory2.restore_from_disk(&state_file).unwrap();
1985 2420
1986 // Verify purgatory is still empty 2421 // Verify purgatory is still empty
1987 let (state_count, pr_count) = purgatory2.count(); 2422 let (_, state_count, pr_count) = purgatory2.count();
1988 assert_eq!(state_count, 0); 2423 assert_eq!(state_count, 0);
1989 assert_eq!(pr_count, 0); 2424 assert_eq!(pr_count, 0);
1990 assert_eq!(purgatory2.expired_count(), 0); 2425 assert_eq!(purgatory2.expired_count(), 0);
@@ -2004,7 +2439,7 @@ async fn test_restore_missing_file() {
2004 assert!(result.is_err()); 2439 assert!(result.is_err());
2005 2440
2006 // Purgatory should remain empty 2441 // Purgatory should remain empty
2007 let (state_count, pr_count) = purgatory.count(); 2442 let (_, state_count, pr_count) = purgatory.count();
2008 assert_eq!(state_count, 0); 2443 assert_eq!(state_count, 0);
2009 assert_eq!(pr_count, 0); 2444 assert_eq!(pr_count, 0);
2010} 2445}
@@ -2026,7 +2461,7 @@ async fn test_restore_corrupted_json() {
2026 assert!(result.is_err()); 2461 assert!(result.is_err());
2027 2462
2028 // Purgatory should remain empty 2463 // Purgatory should remain empty
2029 let (state_count, pr_count) = purgatory.count(); 2464 let (_, state_count, pr_count) = purgatory.count();
2030 assert_eq!(state_count, 0); 2465 assert_eq!(state_count, 0);
2031 assert_eq!(pr_count, 0); 2466 assert_eq!(pr_count, 0);
2032} 2467}
@@ -2263,7 +2698,7 @@ async fn test_mixed_pr_events_and_placeholders() {
2263 purgatory2.restore_from_disk(&state_file).unwrap(); 2698 purgatory2.restore_from_disk(&state_file).unwrap();
2264 2699
2265 // Verify both were restored correctly 2700 // Verify both were restored correctly
2266 let (_, pr_count) = purgatory2.count(); 2701 let (_, _, pr_count) = purgatory2.count();
2267 assert_eq!(pr_count, 2); 2702 assert_eq!(pr_count, 2);
2268 2703
2269 // Verify PR event 2704 // Verify PR event
@@ -2310,6 +2745,141 @@ async fn test_file_cleanup_after_successful_restore() {
2310} 2745}
2311 2746
2312#[tokio::test] 2747#[tokio::test]
2748async fn test_save_and_restore_announcement_events() {
2749 use tempfile::tempdir;
2750
2751 let temp_dir = tempdir().unwrap();
2752 let state_file = temp_dir.path().join("purgatory_state.json");
2753
2754 // Create a real bare repo directory so the restore path-existence check passes
2755 let repo_dir = temp_dir.path().join("owner.git");
2756 std::fs::create_dir_all(&repo_dir).unwrap();
2757
2758 let purgatory = Purgatory::new(PathBuf::new());
2759 let keys = Keys::generate();
2760
2761 let ann_event = EventBuilder::text_note("announcement event")
2762 .sign_with_keys(&keys)
2763 .unwrap();
2764 let ann_event_id = ann_event.id;
2765
2766 let mut relays = HashSet::new();
2767 relays.insert("wss://relay.example.com".to_string());
2768
2769 purgatory.add_announcement(
2770 ann_event.clone(),
2771 "my-repo".to_string(),
2772 keys.public_key(),
2773 repo_dir.clone(),
2774 relays.clone(),
2775 );
2776
2777 // Save to disk
2778 purgatory.save_to_disk(&state_file).unwrap();
2779 assert!(state_file.exists());
2780
2781 // Create new purgatory and restore
2782 let purgatory2 = Purgatory::new(PathBuf::new());
2783 purgatory2.restore_from_disk(&state_file).unwrap();
2784
2785 // File should be deleted after restore
2786 assert!(!state_file.exists());
2787
2788 // Verify announcement was restored
2789 let (ann_count, _, _) = purgatory2.count();
2790 assert_eq!(ann_count, 1);
2791
2792 let restored = purgatory2
2793 .find_announcement(&keys.public_key(), "my-repo")
2794 .unwrap();
2795 assert_eq!(restored.event.id, ann_event_id);
2796 assert_eq!(restored.identifier, "my-repo");
2797 assert_eq!(restored.owner, keys.public_key());
2798 assert_eq!(restored.repo_path, repo_dir);
2799 assert_eq!(restored.relays, relays);
2800 assert!(!restored.soft_expired);
2801}
2802
2803#[tokio::test]
2804async fn test_soft_expired_announcements_not_persisted() {
2805 use tempfile::tempdir;
2806
2807 let temp_dir = tempdir().unwrap();
2808 let state_file = temp_dir.path().join("purgatory_state.json");
2809
2810 let repo_dir = temp_dir.path().join("owner.git");
2811 std::fs::create_dir_all(&repo_dir).unwrap();
2812
2813 let purgatory = Purgatory::new(PathBuf::new());
2814 let keys = Keys::generate();
2815
2816 let ann_event = EventBuilder::text_note("announcement event")
2817 .sign_with_keys(&keys)
2818 .unwrap();
2819
2820 purgatory.add_announcement(
2821 ann_event.clone(),
2822 "my-repo".to_string(),
2823 keys.public_key(),
2824 repo_dir.clone(),
2825 HashSet::new(),
2826 );
2827
2828 // Manually mark as soft-expired (bare repo deleted)
2829 let key = (keys.public_key(), "my-repo".to_string());
2830 if let Some(mut entry) = purgatory.announcement_purgatory.get_mut(&key) {
2831 entry.soft_expired = true;
2832 }
2833
2834 // Save to disk — soft-expired entry should be excluded
2835 purgatory.save_to_disk(&state_file).unwrap();
2836
2837 // Create new purgatory and restore
2838 let purgatory2 = Purgatory::new(PathBuf::new());
2839 purgatory2.restore_from_disk(&state_file).unwrap();
2840
2841 // Soft-expired announcement should NOT be restored
2842 let (ann_count, _, _) = purgatory2.count();
2843 assert_eq!(ann_count, 0);
2844}
2845
2846#[tokio::test]
2847async fn test_announcement_with_missing_repo_skipped_on_restore() {
2848 use tempfile::tempdir;
2849
2850 let temp_dir = tempdir().unwrap();
2851 let state_file = temp_dir.path().join("purgatory_state.json");
2852
2853 // Point to a repo path that does NOT exist
2854 let missing_repo = temp_dir.path().join("nonexistent.git");
2855
2856 let purgatory = Purgatory::new(PathBuf::new());
2857 let keys = Keys::generate();
2858
2859 let ann_event = EventBuilder::text_note("announcement event")
2860 .sign_with_keys(&keys)
2861 .unwrap();
2862
2863 purgatory.add_announcement(
2864 ann_event.clone(),
2865 "my-repo".to_string(),
2866 keys.public_key(),
2867 missing_repo.clone(),
2868 HashSet::new(),
2869 );
2870
2871 // Save to disk (repo path is serialized even though it doesn't exist)
2872 purgatory.save_to_disk(&state_file).unwrap();
2873
2874 // Create new purgatory and restore — entry should be skipped
2875 let purgatory2 = Purgatory::new(PathBuf::new());
2876 purgatory2.restore_from_disk(&state_file).unwrap();
2877
2878 let (ann_count, _, _) = purgatory2.count();
2879 assert_eq!(ann_count, 0);
2880}
2881
2882#[tokio::test]
2313async fn test_comprehensive_roundtrip() { 2883async fn test_comprehensive_roundtrip() {
2314 use nostr_sdk::{Kind, Tag, TagKind}; 2884 use nostr_sdk::{Kind, Tag, TagKind};
2315 use tempfile::tempdir; 2885 use tempfile::tempdir;
@@ -2317,10 +2887,27 @@ async fn test_comprehensive_roundtrip() {
2317 let temp_dir = tempdir().unwrap(); 2887 let temp_dir = tempdir().unwrap();
2318 let state_file = temp_dir.path().join("purgatory_state.json"); 2888 let state_file = temp_dir.path().join("purgatory_state.json");
2319 2889
2890 // Create a real bare repo directory for the announcement
2891 let repo_dir = temp_dir.path().join("owner.git");
2892 std::fs::create_dir_all(&repo_dir).unwrap();
2893
2320 let purgatory = Purgatory::new(PathBuf::new()); 2894 let purgatory = Purgatory::new(PathBuf::new());
2321 let keys1 = Keys::generate(); 2895 let keys1 = Keys::generate();
2322 let keys2 = Keys::generate(); 2896 let keys2 = Keys::generate();
2323 2897
2898 // Add announcement
2899 let ann_event = EventBuilder::text_note("announcement")
2900 .sign_with_keys(&keys1)
2901 .unwrap();
2902 let ann_event_id = ann_event.id;
2903 purgatory.add_announcement(
2904 ann_event,
2905 "repo1".to_string(),
2906 keys1.public_key(),
2907 repo_dir.clone(),
2908 HashSet::new(),
2909 );
2910
2324 // Add multiple state events 2911 // Add multiple state events
2325 let state1 = EventBuilder::text_note("state 1") 2912 let state1 = EventBuilder::text_note("state 1")
2326 .sign_with_keys(&keys1) 2913 .sign_with_keys(&keys1)
@@ -2380,7 +2967,8 @@ async fn test_comprehensive_roundtrip() {
2380 purgatory.cleanup(); 2967 purgatory.cleanup();
2381 2968
2382 // Verify initial state 2969 // Verify initial state
2383 let (state_count, pr_count) = purgatory.count(); 2970 let (ann_count, state_count, pr_count) = purgatory.count();
2971 assert_eq!(ann_count, 1); // announcement
2384 assert_eq!(state_count, 2); // state1, state2 (expired_event was cleaned up) 2972 assert_eq!(state_count, 2); // state1, state2 (expired_event was cleaned up)
2385 assert_eq!(pr_count, 2); // pr-1, pr-2 2973 assert_eq!(pr_count, 2); // pr-1, pr-2
2386 assert_eq!(purgatory.expired_count(), 1); // expired_event 2974 assert_eq!(purgatory.expired_count(), 1); // expired_event
@@ -2393,11 +2981,18 @@ async fn test_comprehensive_roundtrip() {
2393 purgatory2.restore_from_disk(&state_file).unwrap(); 2981 purgatory2.restore_from_disk(&state_file).unwrap();
2394 2982
2395 // Verify all data was restored correctly 2983 // Verify all data was restored correctly
2396 let (state_count2, pr_count2) = purgatory2.count(); 2984 let (ann_count2, state_count2, pr_count2) = purgatory2.count();
2985 assert_eq!(ann_count2, 1);
2397 assert_eq!(state_count2, 2); 2986 assert_eq!(state_count2, 2);
2398 assert_eq!(pr_count2, 2); 2987 assert_eq!(pr_count2, 2);
2399 assert_eq!(purgatory2.expired_count(), 1); 2988 assert_eq!(purgatory2.expired_count(), 1);
2400 2989
2990 // Verify announcement
2991 let restored_ann = purgatory2
2992 .find_announcement(&keys1.public_key(), "repo1")
2993 .unwrap();
2994 assert_eq!(restored_ann.event.id, ann_event_id);
2995
2401 // Verify state events 2996 // Verify state events
2402 assert_eq!(purgatory2.find_state("repo1").len(), 1); 2997 assert_eq!(purgatory2.find_state("repo1").len(), 1);
2403 assert_eq!(purgatory2.find_state("repo2").len(), 1); 2998 assert_eq!(purgatory2.find_state("repo2").len(), 1);