diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/purgatory/mod.rs | 925 | ||||
| -rw-r--r-- | src/purgatory/persistence.rs | 198 | ||||
| -rw-r--r-- | src/purgatory/types.rs | 20 |
3 files changed, 1139 insertions, 4 deletions
diff --git a/src/purgatory/mod.rs b/src/purgatory/mod.rs index 20df19b..47798a6 100644 --- a/src/purgatory/mod.rs +++ b/src/purgatory/mod.rs | |||
| @@ -12,6 +12,7 @@ | |||
| 12 | //! - **Separate stores**: State events and PR events use different indexing strategies | 12 | //! - **Separate stores**: State events and PR events use different indexing strategies |
| 13 | 13 | ||
| 14 | mod helpers; | 14 | mod helpers; |
| 15 | pub mod persistence; | ||
| 15 | pub mod sync; | 16 | pub mod sync; |
| 16 | mod types; | 17 | mod types; |
| 17 | 18 | ||
| @@ -20,10 +21,12 @@ pub use types::{PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry}; | |||
| 20 | 21 | ||
| 21 | use dashmap::DashMap; | 22 | use dashmap::DashMap; |
| 22 | use nostr_sdk::prelude::*; | 23 | use nostr_sdk::prelude::*; |
| 24 | use serde::{Deserialize, Serialize}; | ||
| 25 | use std::collections::HashMap; | ||
| 23 | use std::collections::HashSet; | 26 | use std::collections::HashSet; |
| 24 | use std::path::PathBuf; | 27 | use std::path::{Path, PathBuf}; |
| 25 | use std::sync::Arc; | 28 | use std::sync::Arc; |
| 26 | use std::time::{Duration, Instant}; | 29 | use std::time::{Duration, Instant, SystemTime}; |
| 27 | 30 | ||
| 28 | pub use sync::SyncQueueEntry; | 31 | pub use sync::SyncQueueEntry; |
| 29 | 32 | ||
| @@ -38,6 +41,63 @@ const DEFAULT_SYNC_DELAY: Duration = Duration::from_secs(180); | |||
| 38 | /// Used for batching burst arrivals during negentropy sync. | 41 | /// Used for batching burst arrivals during negentropy sync. |
| 39 | const IMMEDIATE_SYNC_DELAY: Duration = Duration::from_millis(500); | 42 | const IMMEDIATE_SYNC_DELAY: Duration = Duration::from_millis(500); |
| 40 | 43 | ||
| 44 | /// Serializable wrapper for `StatePurgatoryEntry` with time offsets. | ||
| 45 | /// | ||
| 46 | /// Stores `Instant` fields as `Duration` offsets from the `saved_at` timestamp | ||
| 47 | /// in `PurgatoryState`, allowing state to be persisted and restored across restarts. | ||
| 48 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 49 | struct SerializableStatePurgatoryEntry { | ||
| 50 | /// The nostr state event (kind 30618) awaiting git data | ||
| 51 | event: Event, | ||
| 52 | /// The repository identifier from the event's 'd' tag | ||
| 53 | identifier: String, | ||
| 54 | /// Event author pubkey | ||
| 55 | author: PublicKey, | ||
| 56 | /// Duration offset from saved_at for created_at | ||
| 57 | created_at_offset_secs: u64, | ||
| 58 | /// Duration offset from saved_at for expires_at | ||
| 59 | expires_at_offset_secs: u64, | ||
| 60 | } | ||
| 61 | |||
| 62 | /// Serializable wrapper for `PrPurgatoryEntry` with time offsets. | ||
| 63 | /// | ||
| 64 | /// Stores `Instant` fields as `Duration` offsets from the `saved_at` timestamp | ||
| 65 | /// in `PurgatoryState`, allowing state to be persisted and restored across restarts. | ||
| 66 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 67 | struct SerializablePrPurgatoryEntry { | ||
| 68 | /// The nostr PR event, if received (None = git data arrived first) | ||
| 69 | event: Option<Event>, | ||
| 70 | /// The expected commit SHA from 'c' tag (if event exists) | ||
| 71 | /// or the actual commit pushed (if git arrived first) | ||
| 72 | commit: String, | ||
| 73 | /// Duration offset from saved_at for created_at | ||
| 74 | created_at_offset_secs: u64, | ||
| 75 | /// Duration offset from saved_at for expires_at | ||
| 76 | expires_at_offset_secs: u64, | ||
| 77 | } | ||
| 78 | |||
| 79 | /// Serializable purgatory state for disk persistence. | ||
| 80 | /// | ||
| 81 | /// Contains all purgatory data needed to restore state across restarts: | ||
| 82 | /// - State events (indexed by identifier) | ||
| 83 | /// - PR events (indexed by event ID) | ||
| 84 | /// - Expired events (to prevent re-sync loops) | ||
| 85 | /// - Version number for future compatibility | ||
| 86 | /// - Saved timestamp for downtime calculation | ||
| 87 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 88 | struct PurgatoryState { | ||
| 89 | /// Version number for state format (currently 1) | ||
| 90 | version: u32, | ||
| 91 | /// When this state was saved to disk | ||
| 92 | saved_at: SystemTime, | ||
| 93 | /// State events indexed by repository identifier | ||
| 94 | state_events: HashMap<String, Vec<SerializableStatePurgatoryEntry>>, | ||
| 95 | /// PR events indexed by event ID (hex string) | ||
| 96 | pr_events: HashMap<String, SerializablePrPurgatoryEntry>, | ||
| 97 | /// Expired event IDs with their expiry timestamps | ||
| 98 | expired_events: HashMap<String, SystemTime>, | ||
| 99 | } | ||
| 100 | |||
| 41 | /// Main purgatory structure holding events awaiting git data. | 101 | /// Main purgatory structure holding events awaiting git data. |
| 42 | /// | 102 | /// |
| 43 | /// Provides thread-safe concurrent access to two separate stores: | 103 | /// Provides thread-safe concurrent access to two separate stores: |
| @@ -667,6 +727,260 @@ impl Purgatory { | |||
| 667 | pub fn sync_queue_size(&self) -> usize { | 727 | pub fn sync_queue_size(&self) -> usize { |
| 668 | self.sync_queue.len() | 728 | self.sync_queue.len() |
| 669 | } | 729 | } |
| 730 | |||
| 731 | /// Get all repository identifiers currently in purgatory. | ||
| 732 | /// | ||
| 733 | /// Returns a list of all unique repository identifiers that have state events | ||
| 734 | /// in purgatory. This is useful for re-queueing repositories after restore. | ||
| 735 | /// | ||
| 736 | /// # Returns | ||
| 737 | /// Vector of repository identifiers (e.g., "owner/repo") | ||
| 738 | /// | ||
| 739 | /// # Example | ||
| 740 | /// ```no_run | ||
| 741 | /// use ngit_grasp::purgatory::Purgatory; | ||
| 742 | /// use std::path::PathBuf; | ||
| 743 | /// | ||
| 744 | /// let purgatory = Purgatory::new(PathBuf::from("/tmp/git")); | ||
| 745 | /// let identifiers = purgatory.get_all_identifiers(); | ||
| 746 | /// for id in identifiers { | ||
| 747 | /// println!("Repository in purgatory: {}", id); | ||
| 748 | /// } | ||
| 749 | /// ``` | ||
| 750 | pub fn get_all_identifiers(&self) -> Vec<String> { | ||
| 751 | self.state_events | ||
| 752 | .iter() | ||
| 753 | .map(|entry| entry.key().clone()) | ||
| 754 | .collect() | ||
| 755 | } | ||
| 756 | |||
| 757 | /// Save purgatory state to disk. | ||
| 758 | /// | ||
| 759 | /// Serializes the current purgatory state (state_events, pr_events, expired_events) | ||
| 760 | /// to JSON and saves it to the specified path. Time-based fields (`Instant`) are | ||
| 761 | /// converted to duration offsets from the current `SystemTime` for persistence. | ||
| 762 | /// | ||
| 763 | /// Note: The sync_queue is NOT persisted - it will be rebuilt when events are | ||
| 764 | /// restored from disk. | ||
| 765 | /// | ||
| 766 | /// # Arguments | ||
| 767 | /// * `path` - Path to save the state file | ||
| 768 | /// | ||
| 769 | /// # Returns | ||
| 770 | /// Ok(()) on success, Err on failure | ||
| 771 | /// | ||
| 772 | /// # Example | ||
| 773 | /// ```no_run | ||
| 774 | /// use ngit_grasp::purgatory::Purgatory; | ||
| 775 | /// use std::path::PathBuf; | ||
| 776 | /// | ||
| 777 | /// let purgatory = Purgatory::new(PathBuf::from("/tmp/git")); | ||
| 778 | /// purgatory.save_to_disk(&PathBuf::from("/tmp/purgatory.json")).unwrap(); | ||
| 779 | /// ``` | ||
| 780 | pub fn save_to_disk(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> { | ||
| 781 | let saved_at = SystemTime::now(); | ||
| 782 | let now_instant = Instant::now(); | ||
| 783 | |||
| 784 | // Convert state_events to serializable format | ||
| 785 | let mut state_events = HashMap::new(); | ||
| 786 | for entry in self.state_events.iter() { | ||
| 787 | let identifier = entry.key().clone(); | ||
| 788 | let entries: Vec<SerializableStatePurgatoryEntry> = entry | ||
| 789 | .value() | ||
| 790 | .iter() | ||
| 791 | .map(|e| { | ||
| 792 | let created_offset = | ||
| 793 | persistence::instant_to_offset(e.created_at, saved_at, now_instant); | ||
| 794 | let expires_offset = | ||
| 795 | persistence::instant_to_offset(e.expires_at, saved_at, now_instant); | ||
| 796 | |||
| 797 | SerializableStatePurgatoryEntry { | ||
| 798 | event: e.event.clone(), | ||
| 799 | identifier: e.identifier.clone(), | ||
| 800 | author: e.author, | ||
| 801 | created_at_offset_secs: created_offset.as_secs(), | ||
| 802 | expires_at_offset_secs: expires_offset.as_secs(), | ||
| 803 | } | ||
| 804 | }) | ||
| 805 | .collect(); | ||
| 806 | state_events.insert(identifier, entries); | ||
| 807 | } | ||
| 808 | |||
| 809 | // Convert pr_events to serializable format | ||
| 810 | let mut pr_events = HashMap::new(); | ||
| 811 | for entry in self.pr_events.iter() { | ||
| 812 | let event_id = entry.key().clone(); | ||
| 813 | let e = entry.value(); | ||
| 814 | |||
| 815 | let created_offset = | ||
| 816 | persistence::instant_to_offset(e.created_at, saved_at, now_instant); | ||
| 817 | let expires_offset = | ||
| 818 | persistence::instant_to_offset(e.expires_at, saved_at, now_instant); | ||
| 819 | |||
| 820 | let serializable = SerializablePrPurgatoryEntry { | ||
| 821 | event: e.event.clone(), | ||
| 822 | commit: e.commit.clone(), | ||
| 823 | created_at_offset_secs: created_offset.as_secs(), | ||
| 824 | expires_at_offset_secs: expires_offset.as_secs(), | ||
| 825 | }; | ||
| 826 | pr_events.insert(event_id, serializable); | ||
| 827 | } | ||
| 828 | |||
| 829 | // Convert expired_events to serializable format | ||
| 830 | // We use SystemTime instead of Instant offsets for expired events since | ||
| 831 | // we don't need high precision for cleanup timing | ||
| 832 | let mut expired_events = HashMap::new(); | ||
| 833 | for entry in self.expired_events.iter() { | ||
| 834 | let event_id = entry.key().to_hex(); | ||
| 835 | // Convert Instant to SystemTime (approximate) | ||
| 836 | let expired_at_instant = *entry.value(); | ||
| 837 | let elapsed_since_expire = now_instant.saturating_duration_since(expired_at_instant); | ||
| 838 | let expired_at_system = saved_at - elapsed_since_expire; | ||
| 839 | expired_events.insert(event_id, expired_at_system); | ||
| 840 | } | ||
| 841 | |||
| 842 | // Create state structure | ||
| 843 | let state = PurgatoryState { | ||
| 844 | version: 1, | ||
| 845 | saved_at, | ||
| 846 | state_events, | ||
| 847 | pr_events, | ||
| 848 | expired_events, | ||
| 849 | }; | ||
| 850 | |||
| 851 | // Serialize to JSON and write to file | ||
| 852 | let json = serde_json::to_string_pretty(&state)?; | ||
| 853 | std::fs::write(path, json)?; | ||
| 854 | |||
| 855 | tracing::info!( | ||
| 856 | path = %path.display(), | ||
| 857 | state_events = state.state_events.len(), | ||
| 858 | pr_events = state.pr_events.len(), | ||
| 859 | expired_events = state.expired_events.len(), | ||
| 860 | "Saved purgatory state to disk" | ||
| 861 | ); | ||
| 862 | |||
| 863 | Ok(()) | ||
| 864 | } | ||
| 865 | |||
| 866 | /// Restore purgatory state from disk. | ||
| 867 | /// | ||
| 868 | /// Loads a previously saved purgatory state from the specified path and populates | ||
| 869 | /// the current purgatory instance. Adjusts time-based fields to account for downtime | ||
| 870 | /// between save and restore. | ||
| 871 | /// | ||
| 872 | /// After successful restore, the state file is deleted to prevent accidental | ||
| 873 | /// double-restore. | ||
| 874 | /// | ||
| 875 | /// # Arguments | ||
| 876 | /// * `path` - Path to the saved state file | ||
| 877 | /// | ||
| 878 | /// # Returns | ||
| 879 | /// Ok(()) on success, Err if file doesn't exist or is corrupted | ||
| 880 | /// | ||
| 881 | /// # Example | ||
| 882 | /// ```no_run | ||
| 883 | /// use ngit_grasp::purgatory::Purgatory; | ||
| 884 | /// use std::path::PathBuf; | ||
| 885 | /// | ||
| 886 | /// let purgatory = Purgatory::new(PathBuf::from("/tmp/git")); | ||
| 887 | /// match purgatory.restore_from_disk(&PathBuf::from("/tmp/purgatory.json")) { | ||
| 888 | /// Ok(()) => println!("State restored successfully"), | ||
| 889 | /// Err(e) => eprintln!("Failed to restore state: {}", e), | ||
| 890 | /// } | ||
| 891 | /// ``` | ||
| 892 | pub fn restore_from_disk(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> { | ||
| 893 | // Read and parse state file | ||
| 894 | let json = std::fs::read_to_string(path)?; | ||
| 895 | let state: PurgatoryState = serde_json::from_str(&json)?; | ||
| 896 | |||
| 897 | // Verify version | ||
| 898 | if state.version != 1 { | ||
| 899 | return Err(format!("Unsupported state version: {}", state.version).into()); | ||
| 900 | } | ||
| 901 | |||
| 902 | let now_instant = Instant::now(); | ||
| 903 | |||
| 904 | // Restore state_events | ||
| 905 | for (identifier, entries) in state.state_events { | ||
| 906 | let restored_entries: Vec<StatePurgatoryEntry> = entries | ||
| 907 | .into_iter() | ||
| 908 | .map(|e| { | ||
| 909 | let created_at = persistence::offset_to_instant( | ||
| 910 | Duration::from_secs(e.created_at_offset_secs), | ||
| 911 | state.saved_at, | ||
| 912 | now_instant, | ||
| 913 | ); | ||
| 914 | let expires_at = persistence::offset_to_instant( | ||
| 915 | Duration::from_secs(e.expires_at_offset_secs), | ||
| 916 | state.saved_at, | ||
| 917 | now_instant, | ||
| 918 | ); | ||
| 919 | |||
| 920 | StatePurgatoryEntry { | ||
| 921 | event: e.event, | ||
| 922 | identifier: e.identifier, | ||
| 923 | author: e.author, | ||
| 924 | created_at, | ||
| 925 | expires_at, | ||
| 926 | } | ||
| 927 | }) | ||
| 928 | .collect(); | ||
| 929 | |||
| 930 | self.state_events.insert(identifier, restored_entries); | ||
| 931 | } | ||
| 932 | |||
| 933 | // Restore pr_events | ||
| 934 | for (event_id, e) in state.pr_events { | ||
| 935 | let created_at = persistence::offset_to_instant( | ||
| 936 | Duration::from_secs(e.created_at_offset_secs), | ||
| 937 | state.saved_at, | ||
| 938 | now_instant, | ||
| 939 | ); | ||
| 940 | let expires_at = persistence::offset_to_instant( | ||
| 941 | Duration::from_secs(e.expires_at_offset_secs), | ||
| 942 | state.saved_at, | ||
| 943 | now_instant, | ||
| 944 | ); | ||
| 945 | |||
| 946 | let entry = PrPurgatoryEntry { | ||
| 947 | event: e.event, | ||
| 948 | commit: e.commit, | ||
| 949 | created_at, | ||
| 950 | expires_at, | ||
| 951 | }; | ||
| 952 | |||
| 953 | self.pr_events.insert(event_id, entry); | ||
| 954 | } | ||
| 955 | |||
| 956 | // Restore expired_events | ||
| 957 | for (event_id_hex, expired_at_system) in state.expired_events { | ||
| 958 | if let Ok(event_id) = EventId::from_hex(&event_id_hex) { | ||
| 959 | // Convert SystemTime back to Instant (approximate) | ||
| 960 | let elapsed_since_expire = SystemTime::now() | ||
| 961 | .duration_since(expired_at_system) | ||
| 962 | .unwrap_or(Duration::ZERO); | ||
| 963 | let expired_at_instant = now_instant - elapsed_since_expire; | ||
| 964 | |||
| 965 | self.expired_events.insert(event_id, expired_at_instant); | ||
| 966 | } | ||
| 967 | } | ||
| 968 | |||
| 969 | tracing::info!( | ||
| 970 | path = %path.display(), | ||
| 971 | state_events = self.state_events.len(), | ||
| 972 | pr_events = self.pr_events.len(), | ||
| 973 | expired_events = self.expired_events.len(), | ||
| 974 | saved_at = ?state.saved_at, | ||
| 975 | "Restored purgatory state from disk" | ||
| 976 | ); | ||
| 977 | |||
| 978 | // Delete state file after successful restore | ||
| 979 | std::fs::remove_file(path)?; | ||
| 980 | tracing::debug!(path = %path.display(), "Deleted state file after restore"); | ||
| 981 | |||
| 982 | Ok(()) | ||
| 983 | } | ||
| 670 | } | 984 | } |
| 671 | 985 | ||
| 672 | #[cfg(test)] | 986 | #[cfg(test)] |
| @@ -1249,3 +1563,610 @@ fn test_user_can_resubmit_expired_event() { | |||
| 1249 | // - Skip the expired check for user-submitted events | 1563 | // - Skip the expired check for user-submitted events |
| 1250 | // - Allow the event to be re-added to purgatory or accepted if git data now exists | 1564 | // - Allow the event to be re-added to purgatory or accepted if git data now exists |
| 1251 | } | 1565 | } |
| 1566 | |||
| 1567 | // ============================================================================ | ||
| 1568 | // Persistence Serialization Tests | ||
| 1569 | // ============================================================================ | ||
| 1570 | |||
| 1571 | #[tokio::test] | ||
| 1572 | async fn test_save_and_restore_state_events() { | ||
| 1573 | use tempfile::tempdir; | ||
| 1574 | |||
| 1575 | let temp_dir = tempdir().unwrap(); | ||
| 1576 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 1577 | |||
| 1578 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 1579 | let keys = Keys::generate(); | ||
| 1580 | |||
| 1581 | // Add multiple state events for the same identifier | ||
| 1582 | let event1 = EventBuilder::text_note("state event 1") | ||
| 1583 | .sign_with_keys(&keys) | ||
| 1584 | .unwrap(); | ||
| 1585 | let event2 = EventBuilder::text_note("state event 2") | ||
| 1586 | .sign_with_keys(&keys) | ||
| 1587 | .unwrap(); | ||
| 1588 | |||
| 1589 | let event1_id = event1.id; | ||
| 1590 | let event2_id = event2.id; | ||
| 1591 | |||
| 1592 | purgatory.add_state(event1.clone(), "test-repo".to_string(), keys.public_key()); | ||
| 1593 | purgatory.add_state(event2.clone(), "test-repo".to_string(), keys.public_key()); | ||
| 1594 | |||
| 1595 | // Save to disk | ||
| 1596 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 1597 | |||
| 1598 | // Verify file exists | ||
| 1599 | assert!(state_file.exists()); | ||
| 1600 | |||
| 1601 | // Create new purgatory and restore | ||
| 1602 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 1603 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 1604 | |||
| 1605 | // Verify file was deleted after restore | ||
| 1606 | assert!(!state_file.exists()); | ||
| 1607 | |||
| 1608 | // Verify state events were restored | ||
| 1609 | let (state_count, _) = purgatory2.count(); | ||
| 1610 | assert_eq!(state_count, 2); | ||
| 1611 | |||
| 1612 | let restored_entries = purgatory2.find_state("test-repo"); | ||
| 1613 | assert_eq!(restored_entries.len(), 2); | ||
| 1614 | |||
| 1615 | // Verify event IDs match | ||
| 1616 | let restored_ids: Vec<EventId> = restored_entries.iter().map(|e| e.event.id).collect(); | ||
| 1617 | assert!(restored_ids.contains(&event1_id)); | ||
| 1618 | assert!(restored_ids.contains(&event2_id)); | ||
| 1619 | |||
| 1620 | // Verify identifiers and authors match | ||
| 1621 | for entry in &restored_entries { | ||
| 1622 | assert_eq!(entry.identifier, "test-repo"); | ||
| 1623 | assert_eq!(entry.author, keys.public_key()); | ||
| 1624 | } | ||
| 1625 | } | ||
| 1626 | |||
| 1627 | #[tokio::test] | ||
| 1628 | async fn test_save_and_restore_pr_events() { | ||
| 1629 | use nostr_sdk::{Kind, Tag, TagKind}; | ||
| 1630 | use tempfile::tempdir; | ||
| 1631 | |||
| 1632 | let temp_dir = tempdir().unwrap(); | ||
| 1633 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 1634 | |||
| 1635 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 1636 | let keys = Keys::generate(); | ||
| 1637 | |||
| 1638 | // Add PR event with actual event | ||
| 1639 | let tags = vec![Tag::custom( | ||
| 1640 | TagKind::Custom("a".into()), | ||
| 1641 | vec!["30617:abc123:test-repo".to_string()], | ||
| 1642 | )]; | ||
| 1643 | |||
| 1644 | let pr_event = EventBuilder::new(Kind::from(1618), "PR content") | ||
| 1645 | .tags(tags) | ||
| 1646 | .sign_with_keys(&keys) | ||
| 1647 | .unwrap(); | ||
| 1648 | |||
| 1649 | let pr_event_id = pr_event.id; | ||
| 1650 | |||
| 1651 | purgatory.add_pr( | ||
| 1652 | pr_event.clone(), | ||
| 1653 | "pr-event-id".to_string(), | ||
| 1654 | "commit-abc".to_string(), | ||
| 1655 | ); | ||
| 1656 | |||
| 1657 | // Save to disk | ||
| 1658 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 1659 | |||
| 1660 | // Create new purgatory and restore | ||
| 1661 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 1662 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 1663 | |||
| 1664 | // Verify PR event was restored | ||
| 1665 | let (_, pr_count) = purgatory2.count(); | ||
| 1666 | assert_eq!(pr_count, 1); | ||
| 1667 | |||
| 1668 | let restored_entry = purgatory2.find_pr("pr-event-id").unwrap(); | ||
| 1669 | assert!(restored_entry.event.is_some()); | ||
| 1670 | assert_eq!(restored_entry.event.unwrap().id, pr_event_id); | ||
| 1671 | assert_eq!(restored_entry.commit, "commit-abc"); | ||
| 1672 | } | ||
| 1673 | |||
| 1674 | #[tokio::test] | ||
| 1675 | async fn test_save_and_restore_pr_placeholders() { | ||
| 1676 | use tempfile::tempdir; | ||
| 1677 | |||
| 1678 | let temp_dir = tempdir().unwrap(); | ||
| 1679 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 1680 | |||
| 1681 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 1682 | |||
| 1683 | // Add PR placeholder (git data arrived first) | ||
| 1684 | purgatory.add_pr_placeholder("placeholder-id".to_string(), "commit-def".to_string()); | ||
| 1685 | |||
| 1686 | // Save to disk | ||
| 1687 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 1688 | |||
| 1689 | // Create new purgatory and restore | ||
| 1690 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 1691 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 1692 | |||
| 1693 | // Verify placeholder was restored | ||
| 1694 | let (_, pr_count) = purgatory2.count(); | ||
| 1695 | assert_eq!(pr_count, 1); | ||
| 1696 | |||
| 1697 | let restored_entry = purgatory2.find_pr("placeholder-id").unwrap(); | ||
| 1698 | assert!(restored_entry.event.is_none()); // Still a placeholder | ||
| 1699 | assert_eq!(restored_entry.commit, "commit-def"); | ||
| 1700 | |||
| 1701 | // Verify it's findable as a placeholder | ||
| 1702 | assert_eq!( | ||
| 1703 | purgatory2.find_pr_placeholder("placeholder-id"), | ||
| 1704 | Some("commit-def".to_string()) | ||
| 1705 | ); | ||
| 1706 | } | ||
| 1707 | |||
| 1708 | #[tokio::test] | ||
| 1709 | async fn test_save_and_restore_expired_events() { | ||
| 1710 | use tempfile::tempdir; | ||
| 1711 | |||
| 1712 | let temp_dir = tempdir().unwrap(); | ||
| 1713 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 1714 | |||
| 1715 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 1716 | let keys = Keys::generate(); | ||
| 1717 | |||
| 1718 | let event = EventBuilder::text_note("test") | ||
| 1719 | .sign_with_keys(&keys) | ||
| 1720 | .unwrap(); | ||
| 1721 | let event_id = event.id; | ||
| 1722 | |||
| 1723 | // Add and expire event | ||
| 1724 | purgatory.add_state(event, "repo".to_string(), keys.public_key()); | ||
| 1725 | if let Some(mut entries) = purgatory.state_events.get_mut("repo") { | ||
| 1726 | for entry in entries.iter_mut() { | ||
| 1727 | entry.expires_at = Instant::now() - Duration::from_secs(1); | ||
| 1728 | } | ||
| 1729 | } | ||
| 1730 | purgatory.cleanup(); | ||
| 1731 | |||
| 1732 | // Verify event is marked as expired | ||
| 1733 | assert!(purgatory.is_expired(&event_id)); | ||
| 1734 | assert_eq!(purgatory.expired_count(), 1); | ||
| 1735 | |||
| 1736 | // Save to disk | ||
| 1737 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 1738 | |||
| 1739 | // Create new purgatory and restore | ||
| 1740 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 1741 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 1742 | |||
| 1743 | // Verify expired event was restored | ||
| 1744 | assert!(purgatory2.is_expired(&event_id)); | ||
| 1745 | assert_eq!(purgatory2.expired_count(), 1); | ||
| 1746 | |||
| 1747 | // Verify it's included in event_ids() | ||
| 1748 | let ids = purgatory2.event_ids(); | ||
| 1749 | assert!(ids.contains(&event_id)); | ||
| 1750 | } | ||
| 1751 | |||
| 1752 | #[tokio::test] | ||
| 1753 | async fn test_save_and_restore_empty_purgatory() { | ||
| 1754 | use tempfile::tempdir; | ||
| 1755 | |||
| 1756 | let temp_dir = tempdir().unwrap(); | ||
| 1757 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 1758 | |||
| 1759 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 1760 | |||
| 1761 | // Save empty purgatory | ||
| 1762 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 1763 | |||
| 1764 | // Verify file exists | ||
| 1765 | assert!(state_file.exists()); | ||
| 1766 | |||
| 1767 | // Create new purgatory and restore | ||
| 1768 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 1769 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 1770 | |||
| 1771 | // Verify purgatory is still empty | ||
| 1772 | let (state_count, pr_count) = purgatory2.count(); | ||
| 1773 | assert_eq!(state_count, 0); | ||
| 1774 | assert_eq!(pr_count, 0); | ||
| 1775 | assert_eq!(purgatory2.expired_count(), 0); | ||
| 1776 | } | ||
| 1777 | |||
| 1778 | #[tokio::test] | ||
| 1779 | async fn test_restore_missing_file() { | ||
| 1780 | use tempfile::tempdir; | ||
| 1781 | |||
| 1782 | let temp_dir = tempdir().unwrap(); | ||
| 1783 | let state_file = temp_dir.path().join("nonexistent.json"); | ||
| 1784 | |||
| 1785 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 1786 | |||
| 1787 | // Attempting to restore from missing file should error | ||
| 1788 | let result = purgatory.restore_from_disk(&state_file); | ||
| 1789 | assert!(result.is_err()); | ||
| 1790 | |||
| 1791 | // Purgatory should remain empty | ||
| 1792 | let (state_count, pr_count) = purgatory.count(); | ||
| 1793 | assert_eq!(state_count, 0); | ||
| 1794 | assert_eq!(pr_count, 0); | ||
| 1795 | } | ||
| 1796 | |||
| 1797 | #[tokio::test] | ||
| 1798 | async fn test_restore_corrupted_json() { | ||
| 1799 | use tempfile::tempdir; | ||
| 1800 | |||
| 1801 | let temp_dir = tempdir().unwrap(); | ||
| 1802 | let state_file = temp_dir.path().join("corrupted.json"); | ||
| 1803 | |||
| 1804 | // Write invalid JSON | ||
| 1805 | std::fs::write(&state_file, "{ this is not valid json }").unwrap(); | ||
| 1806 | |||
| 1807 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 1808 | |||
| 1809 | // Attempting to restore corrupted file should error | ||
| 1810 | let result = purgatory.restore_from_disk(&state_file); | ||
| 1811 | assert!(result.is_err()); | ||
| 1812 | |||
| 1813 | // Purgatory should remain empty | ||
| 1814 | let (state_count, pr_count) = purgatory.count(); | ||
| 1815 | assert_eq!(state_count, 0); | ||
| 1816 | assert_eq!(pr_count, 0); | ||
| 1817 | } | ||
| 1818 | |||
| 1819 | #[tokio::test] | ||
| 1820 | async fn test_restore_unsupported_version() { | ||
| 1821 | use tempfile::tempdir; | ||
| 1822 | |||
| 1823 | let temp_dir = tempdir().unwrap(); | ||
| 1824 | let state_file = temp_dir.path().join("wrong_version.json"); | ||
| 1825 | |||
| 1826 | // Write state with unsupported version | ||
| 1827 | let state = r#"{ | ||
| 1828 | "version": 999, | ||
| 1829 | "saved_at": {"secs_since_epoch": 1000000000, "nanos_since_epoch": 0}, | ||
| 1830 | "state_events": {}, | ||
| 1831 | "pr_events": {}, | ||
| 1832 | "expired_events": {} | ||
| 1833 | }"#; | ||
| 1834 | std::fs::write(&state_file, state).unwrap(); | ||
| 1835 | |||
| 1836 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 1837 | |||
| 1838 | // Attempting to restore unsupported version should error | ||
| 1839 | let result = purgatory.restore_from_disk(&state_file); | ||
| 1840 | assert!(result.is_err()); | ||
| 1841 | assert!(result | ||
| 1842 | .unwrap_err() | ||
| 1843 | .to_string() | ||
| 1844 | .contains("Unsupported state version")); | ||
| 1845 | } | ||
| 1846 | |||
| 1847 | #[tokio::test] | ||
| 1848 | async fn test_downtime_calculation() { | ||
| 1849 | use tempfile::tempdir; | ||
| 1850 | use tokio::time::sleep; | ||
| 1851 | |||
| 1852 | let temp_dir = tempdir().unwrap(); | ||
| 1853 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 1854 | |||
| 1855 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 1856 | let keys = Keys::generate(); | ||
| 1857 | |||
| 1858 | // Add state event | ||
| 1859 | let event = EventBuilder::text_note("test") | ||
| 1860 | .sign_with_keys(&keys) | ||
| 1861 | .unwrap(); | ||
| 1862 | |||
| 1863 | purgatory.add_state(event.clone(), "repo".to_string(), keys.public_key()); | ||
| 1864 | |||
| 1865 | // Get original expiry time | ||
| 1866 | let original_entries = purgatory.find_state("repo"); | ||
| 1867 | let original_entry = &original_entries[0]; | ||
| 1868 | let original_expires_at = original_entry.expires_at; | ||
| 1869 | let original_remaining = original_expires_at.saturating_duration_since(Instant::now()); | ||
| 1870 | |||
| 1871 | // Save to disk | ||
| 1872 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 1873 | |||
| 1874 | // Simulate downtime (100ms) | ||
| 1875 | sleep(Duration::from_millis(100)).await; | ||
| 1876 | |||
| 1877 | // Create new purgatory and restore | ||
| 1878 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 1879 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 1880 | |||
| 1881 | // Get restored expiry time | ||
| 1882 | let restored_entries = purgatory2.find_state("repo"); | ||
| 1883 | let restored_entry = &restored_entries[0]; | ||
| 1884 | let restored_expires_at = restored_entry.expires_at; | ||
| 1885 | let restored_remaining = restored_expires_at.saturating_duration_since(Instant::now()); | ||
| 1886 | |||
| 1887 | // Remaining time should be approximately the same (accounting for downtime) | ||
| 1888 | // Allow 2000ms tolerance for test execution time and sleep duration | ||
| 1889 | let diff = if restored_remaining > original_remaining { | ||
| 1890 | restored_remaining.as_millis() - original_remaining.as_millis() | ||
| 1891 | } else { | ||
| 1892 | original_remaining.as_millis() - restored_remaining.as_millis() | ||
| 1893 | }; | ||
| 1894 | |||
| 1895 | assert!( | ||
| 1896 | diff < 2000, | ||
| 1897 | "Downtime calculation should preserve remaining TTL. Original: {}ms, Restored: {}ms, Diff: {}ms", | ||
| 1898 | original_remaining.as_millis(), | ||
| 1899 | restored_remaining.as_millis(), | ||
| 1900 | diff | ||
| 1901 | ); | ||
| 1902 | } | ||
| 1903 | |||
| 1904 | #[tokio::test] | ||
| 1905 | async fn test_expiry_times_preserved() { | ||
| 1906 | use tempfile::tempdir; | ||
| 1907 | |||
| 1908 | let temp_dir = tempdir().unwrap(); | ||
| 1909 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 1910 | |||
| 1911 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 1912 | let keys = Keys::generate(); | ||
| 1913 | |||
| 1914 | // Add state event | ||
| 1915 | let event = EventBuilder::text_note("test") | ||
| 1916 | .sign_with_keys(&keys) | ||
| 1917 | .unwrap(); | ||
| 1918 | |||
| 1919 | purgatory.add_state(event.clone(), "repo".to_string(), keys.public_key()); | ||
| 1920 | |||
| 1921 | // Manually set expiry to a specific time in the future | ||
| 1922 | let custom_expiry = Instant::now() + Duration::from_secs(600); // 10 minutes | ||
| 1923 | if let Some(mut entries) = purgatory.state_events.get_mut("repo") { | ||
| 1924 | for entry in entries.iter_mut() { | ||
| 1925 | entry.expires_at = custom_expiry; | ||
| 1926 | } | ||
| 1927 | } | ||
| 1928 | |||
| 1929 | // Save to disk | ||
| 1930 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 1931 | |||
| 1932 | // Create new purgatory and restore | ||
| 1933 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 1934 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 1935 | |||
| 1936 | // Get restored expiry time | ||
| 1937 | let restored_entries = purgatory2.find_state("repo"); | ||
| 1938 | let restored_entry = &restored_entries[0]; | ||
| 1939 | let restored_remaining = restored_entry | ||
| 1940 | .expires_at | ||
| 1941 | .saturating_duration_since(Instant::now()); | ||
| 1942 | |||
| 1943 | // Should be approximately 600 seconds (allow 3 second tolerance for test execution) | ||
| 1944 | assert!( | ||
| 1945 | restored_remaining.as_secs() >= 597 && restored_remaining.as_secs() <= 603, | ||
| 1946 | "Expected ~600s remaining, got {}s", | ||
| 1947 | restored_remaining.as_secs() | ||
| 1948 | ); | ||
| 1949 | } | ||
| 1950 | |||
| 1951 | #[tokio::test] | ||
| 1952 | async fn test_multiple_state_events_same_identifier() { | ||
| 1953 | use tempfile::tempdir; | ||
| 1954 | |||
| 1955 | let temp_dir = tempdir().unwrap(); | ||
| 1956 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 1957 | |||
| 1958 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 1959 | let keys1 = Keys::generate(); | ||
| 1960 | let keys2 = Keys::generate(); | ||
| 1961 | let keys3 = Keys::generate(); | ||
| 1962 | |||
| 1963 | // Add multiple state events for the same identifier from different authors | ||
| 1964 | let event1 = EventBuilder::text_note("maintainer 1") | ||
| 1965 | .sign_with_keys(&keys1) | ||
| 1966 | .unwrap(); | ||
| 1967 | let event2 = EventBuilder::text_note("maintainer 2") | ||
| 1968 | .sign_with_keys(&keys2) | ||
| 1969 | .unwrap(); | ||
| 1970 | let event3 = EventBuilder::text_note("maintainer 3") | ||
| 1971 | .sign_with_keys(&keys3) | ||
| 1972 | .unwrap(); | ||
| 1973 | |||
| 1974 | purgatory.add_state( | ||
| 1975 | event1.clone(), | ||
| 1976 | "shared-repo".to_string(), | ||
| 1977 | keys1.public_key(), | ||
| 1978 | ); | ||
| 1979 | purgatory.add_state( | ||
| 1980 | event2.clone(), | ||
| 1981 | "shared-repo".to_string(), | ||
| 1982 | keys2.public_key(), | ||
| 1983 | ); | ||
| 1984 | purgatory.add_state( | ||
| 1985 | event3.clone(), | ||
| 1986 | "shared-repo".to_string(), | ||
| 1987 | keys3.public_key(), | ||
| 1988 | ); | ||
| 1989 | |||
| 1990 | // Save to disk | ||
| 1991 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 1992 | |||
| 1993 | // Create new purgatory and restore | ||
| 1994 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 1995 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 1996 | |||
| 1997 | // Verify all three events were restored | ||
| 1998 | let restored_entries = purgatory2.find_state("shared-repo"); | ||
| 1999 | assert_eq!(restored_entries.len(), 3); | ||
| 2000 | |||
| 2001 | // Verify all authors are present | ||
| 2002 | let authors: Vec<PublicKey> = restored_entries.iter().map(|e| e.author).collect(); | ||
| 2003 | assert!(authors.contains(&keys1.public_key())); | ||
| 2004 | assert!(authors.contains(&keys2.public_key())); | ||
| 2005 | assert!(authors.contains(&keys3.public_key())); | ||
| 2006 | } | ||
| 2007 | |||
| 2008 | #[tokio::test] | ||
| 2009 | async fn test_mixed_pr_events_and_placeholders() { | ||
| 2010 | use nostr_sdk::{Kind, Tag, TagKind}; | ||
| 2011 | use tempfile::tempdir; | ||
| 2012 | |||
| 2013 | let temp_dir = tempdir().unwrap(); | ||
| 2014 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 2015 | |||
| 2016 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 2017 | let keys = Keys::generate(); | ||
| 2018 | |||
| 2019 | // Add PR event with actual event | ||
| 2020 | let tags = vec![Tag::custom( | ||
| 2021 | TagKind::Custom("a".into()), | ||
| 2022 | vec!["30617:abc123:test-repo".to_string()], | ||
| 2023 | )]; | ||
| 2024 | |||
| 2025 | let pr_event = EventBuilder::new(Kind::from(1618), "PR content") | ||
| 2026 | .tags(tags) | ||
| 2027 | .sign_with_keys(&keys) | ||
| 2028 | .unwrap(); | ||
| 2029 | |||
| 2030 | purgatory.add_pr( | ||
| 2031 | pr_event.clone(), | ||
| 2032 | "pr-with-event".to_string(), | ||
| 2033 | "commit-abc".to_string(), | ||
| 2034 | ); | ||
| 2035 | |||
| 2036 | // Add PR placeholder | ||
| 2037 | purgatory.add_pr_placeholder("pr-placeholder".to_string(), "commit-def".to_string()); | ||
| 2038 | |||
| 2039 | // Save to disk | ||
| 2040 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 2041 | |||
| 2042 | // Create new purgatory and restore | ||
| 2043 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 2044 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 2045 | |||
| 2046 | // Verify both were restored correctly | ||
| 2047 | let (_, pr_count) = purgatory2.count(); | ||
| 2048 | assert_eq!(pr_count, 2); | ||
| 2049 | |||
| 2050 | // Verify PR event | ||
| 2051 | let pr_entry = purgatory2.find_pr("pr-with-event").unwrap(); | ||
| 2052 | assert!(pr_entry.event.is_some()); | ||
| 2053 | assert_eq!(pr_entry.commit, "commit-abc"); | ||
| 2054 | |||
| 2055 | // Verify placeholder | ||
| 2056 | let placeholder_entry = purgatory2.find_pr("pr-placeholder").unwrap(); | ||
| 2057 | assert!(placeholder_entry.event.is_none()); | ||
| 2058 | assert_eq!(placeholder_entry.commit, "commit-def"); | ||
| 2059 | assert_eq!( | ||
| 2060 | purgatory2.find_pr_placeholder("pr-placeholder"), | ||
| 2061 | Some("commit-def".to_string()) | ||
| 2062 | ); | ||
| 2063 | } | ||
| 2064 | |||
| 2065 | #[tokio::test] | ||
| 2066 | async fn test_file_cleanup_after_successful_restore() { | ||
| 2067 | use tempfile::tempdir; | ||
| 2068 | |||
| 2069 | let temp_dir = tempdir().unwrap(); | ||
| 2070 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 2071 | |||
| 2072 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 2073 | let keys = Keys::generate(); | ||
| 2074 | |||
| 2075 | // Add some data | ||
| 2076 | let event = EventBuilder::text_note("test") | ||
| 2077 | .sign_with_keys(&keys) | ||
| 2078 | .unwrap(); | ||
| 2079 | purgatory.add_state(event, "repo".to_string(), keys.public_key()); | ||
| 2080 | |||
| 2081 | // Save to disk | ||
| 2082 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 2083 | assert!(state_file.exists()); | ||
| 2084 | |||
| 2085 | // Restore | ||
| 2086 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 2087 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 2088 | |||
| 2089 | // File should be deleted after successful restore | ||
| 2090 | assert!(!state_file.exists()); | ||
| 2091 | } | ||
| 2092 | |||
| 2093 | #[tokio::test] | ||
| 2094 | async fn test_comprehensive_roundtrip() { | ||
| 2095 | use nostr_sdk::{Kind, Tag, TagKind}; | ||
| 2096 | use tempfile::tempdir; | ||
| 2097 | |||
| 2098 | let temp_dir = tempdir().unwrap(); | ||
| 2099 | let state_file = temp_dir.path().join("purgatory_state.json"); | ||
| 2100 | |||
| 2101 | let purgatory = Purgatory::new(PathBuf::new()); | ||
| 2102 | let keys1 = Keys::generate(); | ||
| 2103 | let keys2 = Keys::generate(); | ||
| 2104 | |||
| 2105 | // Add multiple state events | ||
| 2106 | let state1 = EventBuilder::text_note("state 1") | ||
| 2107 | .sign_with_keys(&keys1) | ||
| 2108 | .unwrap(); | ||
| 2109 | let state2 = EventBuilder::text_note("state 2") | ||
| 2110 | .sign_with_keys(&keys2) | ||
| 2111 | .unwrap(); | ||
| 2112 | |||
| 2113 | purgatory.add_state(state1.clone(), "repo1".to_string(), keys1.public_key()); | ||
| 2114 | purgatory.add_state(state2.clone(), "repo2".to_string(), keys2.public_key()); | ||
| 2115 | |||
| 2116 | // Add PR event | ||
| 2117 | let tags = vec![Tag::custom( | ||
| 2118 | TagKind::Custom("a".into()), | ||
| 2119 | vec!["30617:abc123:repo1".to_string()], | ||
| 2120 | )]; | ||
| 2121 | let pr_event = EventBuilder::new(Kind::from(1618), "PR") | ||
| 2122 | .tags(tags) | ||
| 2123 | .sign_with_keys(&keys1) | ||
| 2124 | .unwrap(); | ||
| 2125 | purgatory.add_pr(pr_event.clone(), "pr-1".to_string(), "commit-1".to_string()); | ||
| 2126 | |||
| 2127 | // Add PR placeholder | ||
| 2128 | purgatory.add_pr_placeholder("pr-2".to_string(), "commit-2".to_string()); | ||
| 2129 | |||
| 2130 | // Add and expire an event | ||
| 2131 | let expired_event = EventBuilder::text_note("expired") | ||
| 2132 | .sign_with_keys(&keys1) | ||
| 2133 | .unwrap(); | ||
| 2134 | let expired_id = expired_event.id; | ||
| 2135 | purgatory.add_state(expired_event, "repo3".to_string(), keys1.public_key()); | ||
| 2136 | if let Some(mut entries) = purgatory.state_events.get_mut("repo3") { | ||
| 2137 | for entry in entries.iter_mut() { | ||
| 2138 | entry.expires_at = Instant::now() - Duration::from_secs(1); | ||
| 2139 | } | ||
| 2140 | } | ||
| 2141 | purgatory.cleanup(); | ||
| 2142 | |||
| 2143 | // Verify initial state | ||
| 2144 | let (state_count, pr_count) = purgatory.count(); | ||
| 2145 | assert_eq!(state_count, 2); // state1, state2 (expired_event was cleaned up) | ||
| 2146 | assert_eq!(pr_count, 2); // pr-1, pr-2 | ||
| 2147 | assert_eq!(purgatory.expired_count(), 1); // expired_event | ||
| 2148 | |||
| 2149 | // Save to disk | ||
| 2150 | purgatory.save_to_disk(&state_file).unwrap(); | ||
| 2151 | |||
| 2152 | // Create new purgatory and restore | ||
| 2153 | let purgatory2 = Purgatory::new(PathBuf::new()); | ||
| 2154 | purgatory2.restore_from_disk(&state_file).unwrap(); | ||
| 2155 | |||
| 2156 | // Verify all data was restored correctly | ||
| 2157 | let (state_count2, pr_count2) = purgatory2.count(); | ||
| 2158 | assert_eq!(state_count2, 2); | ||
| 2159 | assert_eq!(pr_count2, 2); | ||
| 2160 | assert_eq!(purgatory2.expired_count(), 1); | ||
| 2161 | |||
| 2162 | // Verify state events | ||
| 2163 | assert_eq!(purgatory2.find_state("repo1").len(), 1); | ||
| 2164 | assert_eq!(purgatory2.find_state("repo2").len(), 1); | ||
| 2165 | |||
| 2166 | // Verify PR events | ||
| 2167 | assert!(purgatory2.find_pr("pr-1").unwrap().event.is_some()); | ||
| 2168 | assert!(purgatory2.find_pr("pr-2").unwrap().event.is_none()); | ||
| 2169 | |||
| 2170 | // Verify expired event | ||
| 2171 | assert!(purgatory2.is_expired(&expired_id)); | ||
| 2172 | } | ||
diff --git a/src/purgatory/persistence.rs b/src/purgatory/persistence.rs new file mode 100644 index 0000000..7fca2cf --- /dev/null +++ b/src/purgatory/persistence.rs | |||
| @@ -0,0 +1,198 @@ | |||
| 1 | //! Persistence utilities for purgatory state. | ||
| 2 | //! | ||
| 3 | //! This module provides conversion functions between `Instant` (which cannot be | ||
| 4 | //! serialized) and `Duration` offsets from a reference `SystemTime`. This allows | ||
| 5 | //! purgatory state to be persisted to disk and restored across restarts. | ||
| 6 | //! | ||
| 7 | //! ## Time Handling | ||
| 8 | //! | ||
| 9 | //! - `Instant` is monotonic but cannot be serialized | ||
| 10 | //! - `SystemTime` can be serialized but may go backwards (NTP, user changes) | ||
| 11 | //! - We use `SystemTime` for persistence and convert to/from `Instant` at runtime | ||
| 12 | //! - Downtime is accounted for when restoring state (elapsed time is preserved) | ||
| 13 | |||
| 14 | use std::time::{Duration, Instant, SystemTime}; | ||
| 15 | |||
| 16 | /// Convert an `Instant` to a `Duration` offset from a reference `SystemTime`. | ||
| 17 | /// | ||
| 18 | /// This allows storing an `Instant` as a serializable offset that can be | ||
| 19 | /// restored later, accounting for system downtime. | ||
| 20 | /// | ||
| 21 | /// # Arguments | ||
| 22 | /// * `instant` - The `Instant` to convert | ||
| 23 | /// * `reference_time` - The reference `SystemTime` (typically SystemTime::now()) | ||
| 24 | /// * `reference_instant` - The corresponding `Instant` (typically Instant::now()) | ||
| 25 | /// | ||
| 26 | /// # Returns | ||
| 27 | /// Duration offset from the reference time | ||
| 28 | /// | ||
| 29 | /// # Example | ||
| 30 | /// ``` | ||
| 31 | /// use std::time::{Duration, Instant, SystemTime}; | ||
| 32 | /// use ngit_grasp::purgatory::persistence::instant_to_offset; | ||
| 33 | /// | ||
| 34 | /// let now_system = SystemTime::now(); | ||
| 35 | /// let now_instant = Instant::now(); | ||
| 36 | /// let future = now_instant + Duration::from_secs(60); | ||
| 37 | /// | ||
| 38 | /// let offset = instant_to_offset(future, now_system, now_instant); | ||
| 39 | /// assert!(offset.as_secs() >= 60); | ||
| 40 | /// ``` | ||
| 41 | pub fn instant_to_offset( | ||
| 42 | instant: Instant, | ||
| 43 | _reference_time: SystemTime, | ||
| 44 | reference_instant: Instant, | ||
| 45 | ) -> Duration { | ||
| 46 | if instant >= reference_instant { | ||
| 47 | // Future instant - return positive offset | ||
| 48 | instant.duration_since(reference_instant) | ||
| 49 | } else { | ||
| 50 | // Past instant - this shouldn't happen in normal operation, | ||
| 51 | // but we handle it by returning zero duration | ||
| 52 | Duration::ZERO | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | /// Convert a `Duration` offset back to an `Instant`, accounting for downtime. | ||
| 57 | /// | ||
| 58 | /// This restores an `Instant` from a serialized offset, adjusting for the time | ||
| 59 | /// that has elapsed since the state was saved. | ||
| 60 | /// | ||
| 61 | /// # Arguments | ||
| 62 | /// * `offset` - The duration offset from the saved reference time | ||
| 63 | /// * `saved_at` - The `SystemTime` when the state was saved | ||
| 64 | /// * `reference_instant` - The current `Instant` (typically Instant::now()) | ||
| 65 | /// | ||
| 66 | /// # Returns | ||
| 67 | /// The restored `Instant`, adjusted for downtime | ||
| 68 | /// | ||
| 69 | /// # Example | ||
| 70 | /// ``` | ||
| 71 | /// use std::time::{Duration, Instant, SystemTime}; | ||
| 72 | /// use ngit_grasp::purgatory::persistence::offset_to_instant; | ||
| 73 | /// | ||
| 74 | /// let saved_at = SystemTime::now(); | ||
| 75 | /// let offset = Duration::from_secs(60); | ||
| 76 | /// let now_instant = Instant::now(); | ||
| 77 | /// | ||
| 78 | /// let restored = offset_to_instant(offset, saved_at, now_instant); | ||
| 79 | /// // restored will be approximately now_instant + 60 seconds | ||
| 80 | /// ``` | ||
| 81 | pub fn offset_to_instant( | ||
| 82 | offset: Duration, | ||
| 83 | saved_at: SystemTime, | ||
| 84 | reference_instant: Instant, | ||
| 85 | ) -> Instant { | ||
| 86 | // Calculate how much time has elapsed since the state was saved | ||
| 87 | let now_system = SystemTime::now(); | ||
| 88 | let elapsed_since_save = now_system | ||
| 89 | .duration_since(saved_at) | ||
| 90 | .unwrap_or(Duration::ZERO); | ||
| 91 | |||
| 92 | // The original deadline was: saved_at + offset | ||
| 93 | // Time remaining = (saved_at + offset) - now_system | ||
| 94 | // = offset - elapsed_since_save | ||
| 95 | |||
| 96 | if offset > elapsed_since_save { | ||
| 97 | // Deadline is still in the future | ||
| 98 | let remaining = offset - elapsed_since_save; | ||
| 99 | reference_instant + remaining | ||
| 100 | } else { | ||
| 101 | // Deadline has already passed or is right now | ||
| 102 | reference_instant | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | #[cfg(test)] | ||
| 107 | mod tests { | ||
| 108 | use super::*; | ||
| 109 | use std::thread; | ||
| 110 | use std::time::Duration; | ||
| 111 | |||
| 112 | #[test] | ||
| 113 | fn test_instant_to_offset_future() { | ||
| 114 | let now_system = SystemTime::now(); | ||
| 115 | let now_instant = Instant::now(); | ||
| 116 | let future = now_instant + Duration::from_secs(60); | ||
| 117 | |||
| 118 | let offset = instant_to_offset(future, now_system, now_instant); | ||
| 119 | |||
| 120 | // Should be approximately 60 seconds (within tolerance) | ||
| 121 | assert!(offset.as_secs() >= 59 && offset.as_secs() <= 61); | ||
| 122 | } | ||
| 123 | |||
| 124 | #[test] | ||
| 125 | fn test_instant_to_offset_past() { | ||
| 126 | let now_system = SystemTime::now(); | ||
| 127 | let past_instant = Instant::now(); | ||
| 128 | // Simulate some time passing | ||
| 129 | thread::sleep(Duration::from_millis(10)); | ||
| 130 | let now_instant = Instant::now(); | ||
| 131 | |||
| 132 | let offset = instant_to_offset(past_instant, now_system, now_instant); | ||
| 133 | |||
| 134 | // Past instants return zero duration | ||
| 135 | assert_eq!(offset, Duration::ZERO); | ||
| 136 | } | ||
| 137 | |||
| 138 | #[test] | ||
| 139 | fn test_offset_to_instant_with_time_remaining() { | ||
| 140 | let saved_at = SystemTime::now(); | ||
| 141 | let offset = Duration::from_secs(60); | ||
| 142 | |||
| 143 | // Simulate a very short downtime (< 10ms) | ||
| 144 | thread::sleep(Duration::from_millis(5)); | ||
| 145 | |||
| 146 | let now_instant = Instant::now(); | ||
| 147 | let restored = offset_to_instant(offset, saved_at, now_instant); | ||
| 148 | |||
| 149 | // Should be approximately 60 seconds in the future | ||
| 150 | let remaining = restored.duration_since(now_instant); | ||
| 151 | assert!( | ||
| 152 | remaining.as_secs() >= 59 && remaining.as_secs() <= 61, | ||
| 153 | "Expected ~60s, got {}s", | ||
| 154 | remaining.as_secs() | ||
| 155 | ); | ||
| 156 | } | ||
| 157 | |||
| 158 | #[test] | ||
| 159 | fn test_offset_to_instant_deadline_passed() { | ||
| 160 | // Simulate state saved 70 seconds ago with 60 second offset | ||
| 161 | let saved_at = SystemTime::now() - Duration::from_secs(70); | ||
| 162 | let offset = Duration::from_secs(60); | ||
| 163 | |||
| 164 | let now_instant = Instant::now(); | ||
| 165 | let restored = offset_to_instant(offset, saved_at, now_instant); | ||
| 166 | |||
| 167 | // Deadline has passed, should be now or in the past | ||
| 168 | let remaining = restored.saturating_duration_since(now_instant); | ||
| 169 | assert_eq!(remaining, Duration::ZERO); | ||
| 170 | } | ||
| 171 | |||
| 172 | #[test] | ||
| 173 | fn test_round_trip_conversion() { | ||
| 174 | let now_system = SystemTime::now(); | ||
| 175 | let now_instant = Instant::now(); | ||
| 176 | let future = now_instant + Duration::from_secs(120); | ||
| 177 | |||
| 178 | // Convert to offset | ||
| 179 | let offset = instant_to_offset(future, now_system, now_instant); | ||
| 180 | |||
| 181 | // Immediately convert back (minimal downtime) | ||
| 182 | let restored = offset_to_instant(offset, now_system, now_instant); | ||
| 183 | |||
| 184 | // Should be very close to the original future instant | ||
| 185 | let diff = if restored > future { | ||
| 186 | restored.duration_since(future) | ||
| 187 | } else { | ||
| 188 | future.duration_since(restored) | ||
| 189 | }; | ||
| 190 | |||
| 191 | // Allow for small timing differences (< 100ms) | ||
| 192 | assert!( | ||
| 193 | diff < Duration::from_millis(100), | ||
| 194 | "Round trip should preserve instant within 100ms, got {}ms", | ||
| 195 | diff.as_millis() | ||
| 196 | ); | ||
| 197 | } | ||
| 198 | } | ||
diff --git a/src/purgatory/types.rs b/src/purgatory/types.rs index 9c47616..919504b 100644 --- a/src/purgatory/types.rs +++ b/src/purgatory/types.rs | |||
| @@ -5,8 +5,14 @@ | |||
| 5 | //! problem where either the nostr event or git push can arrive first. | 5 | //! problem where either the nostr event or git push can arrive first. |
| 6 | 6 | ||
| 7 | use nostr_sdk::prelude::*; | 7 | use nostr_sdk::prelude::*; |
| 8 | use serde::{Deserialize, Serialize}; | ||
| 8 | use std::time::Instant; | 9 | use std::time::Instant; |
| 9 | 10 | ||
| 11 | /// Default value for Instant fields during deserialization | ||
| 12 | fn instant_now() -> Instant { | ||
| 13 | Instant::now() | ||
| 14 | } | ||
| 15 | |||
| 10 | /// A reference name and its target object. | 16 | /// A reference name and its target object. |
| 11 | /// | 17 | /// |
| 12 | /// Used to identify specific git refs (branches, tags) that a state event | 18 | /// Used to identify specific git refs (branches, tags) that a state event |
| @@ -59,7 +65,10 @@ impl RefUpdate { | |||
| 59 | /// State events declare the current state of a repository but may arrive | 65 | /// State events declare the current state of a repository but may arrive |
| 60 | /// before the corresponding git data has been pushed. This entry holds | 66 | /// before the corresponding git data has been pushed. This entry holds |
| 61 | /// the event and associated metadata until the git data arrives. | 67 | /// the event and associated metadata until the git data arrives. |
| 62 | #[derive(Debug, Clone)] | 68 | /// |
| 69 | /// Note: `Instant` fields cannot be serialized directly. Use the `persistence` | ||
| 70 | /// module to convert to/from serializable wrapper types. | ||
| 71 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 63 | pub struct StatePurgatoryEntry { | 72 | pub struct StatePurgatoryEntry { |
| 64 | /// The nostr state event (kind 30618) awaiting git data | 73 | /// The nostr state event (kind 30618) awaiting git data |
| 65 | pub event: Event, | 74 | pub event: Event, |
| @@ -71,9 +80,11 @@ pub struct StatePurgatoryEntry { | |||
| 71 | pub author: PublicKey, | 80 | pub author: PublicKey, |
| 72 | 81 | ||
| 73 | /// When this entry was added to purgatory | 82 | /// When this entry was added to purgatory |
| 83 | #[serde(skip, default = "instant_now")] | ||
| 74 | pub created_at: Instant, | 84 | pub created_at: Instant, |
| 75 | 85 | ||
| 76 | /// Expiry deadline (30 min from creation, may be extended) | 86 | /// Expiry deadline (30 min from creation, may be extended) |
| 87 | #[serde(skip, default = "instant_now")] | ||
| 77 | pub expires_at: Instant, | 88 | pub expires_at: Instant, |
| 78 | } | 89 | } |
| 79 | 90 | ||
| @@ -82,7 +93,10 @@ pub struct StatePurgatoryEntry { | |||
| 82 | /// PR events reference specific commits but may arrive before the git push | 93 | /// PR events reference specific commits but may arrive before the git push |
| 83 | /// containing those commits. Alternatively, a git push may arrive first, | 94 | /// containing those commits. Alternatively, a git push may arrive first, |
| 84 | /// creating a placeholder entry waiting for the corresponding PR event. | 95 | /// creating a placeholder entry waiting for the corresponding PR event. |
| 85 | #[derive(Debug, Clone)] | 96 | /// |
| 97 | /// Note: `Instant` fields cannot be serialized directly. Use the `persistence` | ||
| 98 | /// module to convert to/from serializable wrapper types. | ||
| 99 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 86 | pub struct PrPurgatoryEntry { | 100 | pub struct PrPurgatoryEntry { |
| 87 | /// The nostr PR event, if received (None = git data arrived first) | 101 | /// The nostr PR event, if received (None = git data arrived first) |
| 88 | pub event: Option<Event>, | 102 | pub event: Option<Event>, |
| @@ -92,8 +106,10 @@ pub struct PrPurgatoryEntry { | |||
| 92 | pub commit: String, | 106 | pub commit: String, |
| 93 | 107 | ||
| 94 | /// When this entry was added to purgatory | 108 | /// When this entry was added to purgatory |
| 109 | #[serde(skip, default = "instant_now")] | ||
| 95 | pub created_at: Instant, | 110 | pub created_at: Instant, |
| 96 | 111 | ||
| 97 | /// Expiry deadline (30 min from creation, may be extended) | 112 | /// Expiry deadline (30 min from creation, may be extended) |
| 113 | #[serde(skip, default = "instant_now")] | ||
| 98 | pub expires_at: Instant, | 114 | pub expires_at: Instant, |
| 99 | } | 115 | } |