diff options
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 | } | ||