diff options
Diffstat (limited to 'src/purgatory/mod.rs')
| -rw-r--r-- | src/purgatory/mod.rs | 659 |
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; | |||
| 17 | mod types; | 17 | mod types; |
| 18 | 18 | ||
| 19 | pub use helpers::{can_apply_state, can_satisfy_state, diagnose_state_mismatch, extract_refs_from_state, get_unpushed_refs}; | 19 | pub use helpers::{can_apply_state, can_satisfy_state, diagnose_state_mismatch, extract_refs_from_state, get_unpushed_refs}; |
| 20 | pub use types::{EventSource, PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry}; | 20 | pub use types::{AnnouncementPurgatoryEntry, EventSource, PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry}; |
| 21 | 21 | ||
| 22 | use dashmap::DashMap; | 22 | use dashmap::DashMap; |
| 23 | use nostr_sdk::prelude::*; | 23 | use 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) |
| 35 | const DEFAULT_EXPIRY: Duration = Duration::from_secs(1800); | 35 | const 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. | ||
| 42 | const 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. |
| 39 | const DEFAULT_SYNC_DELAY: Duration = Duration::from_secs(180); | 46 | const 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)] | ||
| 101 | struct 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)] |
| 130 | pub struct Purgatory { | 168 | pub 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] |
| 2748 | async 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] | ||
| 2804 | async 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] | ||
| 2847 | async 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] | ||
| 2313 | async fn test_comprehensive_roundtrip() { | 2883 | async 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); |