diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-14 10:18:47 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-14 10:18:47 +0000 |
| commit | b6c70f765dd02fb0297888d671e455df33d6fcb4 (patch) | |
| tree | 6458d711ef9a7776c31bdb108c39930cbcc3bd68 /src/purgatory/persistence.rs | |
| parent | 7dba18eb9ae64d429fef1a1f5437981efefb86b6 (diff) | |
feat(purgatory): add persistence to survive relay restarts
Implement save/restore functionality for purgatory state to prevent
event loss during relay restarts. Events in purgatory (state events,
PR events, and expired events) are now saved to disk on graceful
shutdown and restored on startup.
Key features:
- Serialize purgatory state to JSON (purgatory-state.json)
- Time conversion helpers for Instant <-> Duration serialization
- Restore with downtime adjustment (preserves remaining TTL)
- Graceful degradation (missing/corrupted files don't crash)
- File cleanup after successful restore
- get_all_identifiers() for re-queueing after restore
Files:
- src/purgatory/persistence.rs: Time conversion helpers
- src/purgatory/types.rs: Serialization derives
- src/purgatory/mod.rs: save_to_disk/restore_from_disk methods
Tests: 15 unit tests covering serialization, downtime, edge cases
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 | } | ||