diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-14 10:46:30 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-14 10:46:30 +0000 |
| commit | 4c8f1813fada9ce2bfd371095b0721bff68173e3 (patch) | |
| tree | d42869f89e4916bb8dc36fd26c9ac5f888e042ac /src/purgatory/persistence.rs | |
| parent | 7dba18eb9ae64d429fef1a1f5437981efefb86b6 (diff) | |
| parent | b101afa00bc28e1b55286145cb81e32a5b3decc9 (diff) | |
Add purgatory persistence to survive relay restarts
Implement save/restore functionality for both purgatory state and
rejected events cache. Events are now saved to disk on graceful
shutdown and restored on startup, preventing data loss during
relay restarts.
Key features:
- Purgatory state persisted to JSON (state events, PR events, expired events)
- Rejected events cache persisted (hot cache + cold index)
- Downtime adjustment preserves remaining TTL
- Graceful degradation on missing/corrupted files
- Automatic re-queueing of restored repositories
- Comprehensive test coverage (45 tests)
Diffstat (limited to 'src/purgatory/persistence.rs')
| -rw-r--r-- | src/purgatory/persistence.rs | 198 |
1 files changed, 198 insertions, 0 deletions
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 | } | ||