1use std::fmt;
8use std::sync::{Arc, Mutex};
9use std::time::{Duration, SystemTime};
10
11use crate::{
12 BatchStatus, Clock, JobExecutionId, MaxClockSkew, MonotonicClock, OwnerObservation, OwnerToken,
13 RecoveryError, RecoveryEvidence, RecoveryProposal, RecoveryRepository, StaleThreshold,
14 TelemetryEventKind, TelemetryEventSink, TelemetryRecord,
15};
16
17pub struct RecoveryProposer<R> {
19 repository: R,
20 wall_clock: Arc<dyn Clock>,
21 monotonic_clock: Arc<dyn MonotonicClock>,
22 current_owner: OwnerToken,
23 stale_threshold: StaleThreshold,
24 max_clock_skew: MaxClockSkew,
25 server_time_floor: Mutex<Option<SystemTime>>,
26 event_sink: Option<Arc<dyn TelemetryEventSink>>,
27}
28
29impl<R> fmt::Debug for RecoveryProposer<R> {
30 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31 formatter
32 .debug_struct("RecoveryProposer")
33 .field("current_owner", &self.current_owner)
34 .field("stale_threshold", &self.stale_threshold)
35 .field("max_clock_skew", &self.max_clock_skew)
36 .finish_non_exhaustive()
37 }
38}
39
40impl<R: RecoveryRepository> RecoveryProposer<R> {
41 #[must_use]
44 pub fn new(
45 repository: R,
46 wall_clock: Arc<dyn Clock>,
47 monotonic_clock: Arc<dyn MonotonicClock>,
48 current_owner: OwnerToken,
49 ) -> Self {
50 Self {
51 repository,
52 wall_clock,
53 monotonic_clock,
54 current_owner,
55 stale_threshold: StaleThreshold::default(),
56 max_clock_skew: MaxClockSkew::default(),
57 server_time_floor: Mutex::new(None),
58 event_sink: None,
59 }
60 }
61
62 #[must_use]
64 pub const fn with_stale_threshold(mut self, value: StaleThreshold) -> Self {
65 self.stale_threshold = value;
66 self
67 }
68
69 #[must_use]
71 pub const fn with_max_clock_skew(mut self, value: MaxClockSkew) -> Self {
72 self.max_clock_skew = value;
73 self
74 }
75
76 #[must_use]
78 pub fn with_event_sink(mut self, sink: Arc<dyn TelemetryEventSink>) -> Self {
79 self.event_sink = Some(sink);
80 self
81 }
82
83 pub async fn propose(
91 &self,
92 execution_id: JobExecutionId,
93 ) -> Result<RecoveryProposal, RecoveryError> {
94 let before = self.monotonic_clock.now();
95 let local_wall = self.wall_clock.now();
96 let snapshot = self
97 .repository
98 .recovery_snapshot(execution_id, &self.current_owner)
99 .await
100 .map_err(RecoveryError::Repository)?;
101 let after = self.monotonic_clock.now();
102 {
103 let mut floor = self
104 .server_time_floor
105 .lock()
106 .unwrap_or_else(std::sync::PoisonError::into_inner);
107 if floor.is_some_and(|previous| snapshot.server_time() < previous) {
108 return Err(RecoveryError::ClockEvidenceUnusable);
109 }
110 *floor = Some(snapshot.server_time());
111 }
112 let observation_window = after
113 .checked_elapsed_since(before)
114 .ok_or(RecoveryError::ClockEvidenceUnusable)?;
115 if observation_window > self.max_clock_skew.get() {
116 return Err(RecoveryError::ClockEvidenceUnusable);
117 }
118 let observed_clock_offset = absolute_system_difference(snapshot.server_time(), local_wall);
119 if observed_clock_offset > self.max_clock_skew.get() {
120 return Err(RecoveryError::ClockEvidenceUnusable);
121 }
122 let inactivity = snapshot
123 .server_time()
124 .duration_since(snapshot.updated_at())
125 .map_err(|_| RecoveryError::ClockEvidenceUnusable)?;
126
127 match snapshot.status() {
128 BatchStatus::Unknown => {}
129 BatchStatus::Starting | BatchStatus::Started | BatchStatus::Stopping => {
130 if snapshot.owner() == OwnerObservation::CurrentProcess {
131 return Err(RecoveryError::OwnedByCurrentProcess);
132 }
133 if inactivity <= self.stale_threshold.get() {
134 return Err(RecoveryError::NotStale {
135 inactivity,
136 threshold: self.stale_threshold,
137 });
138 }
139 }
140 status => return Err(RecoveryError::NotRecoverable { status }),
141 }
142
143 let evidence = RecoveryEvidence::new(
144 snapshot,
145 inactivity,
146 observed_clock_offset,
147 observation_window,
148 );
149 let proposal = RecoveryProposal::new(evidence);
150 if proposal.evidence().status() != BatchStatus::Unknown {
151 crate::telemetry::emit_safely(
152 self.event_sink.as_ref(),
153 &TelemetryRecord::recovery(TelemetryEventKind::StaleDetected, &proposal),
154 );
155 }
156 crate::telemetry::emit_safely(
157 self.event_sink.as_ref(),
158 &TelemetryRecord::recovery(TelemetryEventKind::RecoveryProposed, &proposal),
159 );
160 Ok(proposal)
161 }
162}
163
164fn absolute_system_difference(left: SystemTime, right: SystemTime) -> Duration {
165 left.duration_since(right)
166 .unwrap_or_else(|_| right.duration_since(left).unwrap_or(Duration::MAX))
167}
168
169#[cfg(test)]
170mod tests {
171 #![allow(clippy::expect_used)]
172 use std::collections::VecDeque;
173 use std::sync::Mutex;
174
175 use super::*;
176 use crate::{
177 BoxFuture, ExecutionVersion, MonotonicInstant, RecoveryMarkers, RecoverySnapshot,
178 RepositoryError,
179 };
180
181 #[derive(Debug)]
182 struct FixedWall(SystemTime);
183
184 impl Clock for FixedWall {
185 fn now(&self) -> SystemTime {
186 self.0
187 }
188 }
189
190 #[derive(Debug)]
191 struct SequenceMonotonic(Mutex<VecDeque<MonotonicInstant>>);
192
193 impl SequenceMonotonic {
194 fn observations(values: impl IntoIterator<Item = Duration>) -> Self {
195 Self(Mutex::new(
196 values
197 .into_iter()
198 .map(MonotonicInstant::from_duration)
199 .collect(),
200 ))
201 }
202 }
203
204 impl MonotonicClock for SequenceMonotonic {
205 fn now(&self) -> MonotonicInstant {
206 self.0
207 .lock()
208 .expect("monotonic observations lock")
209 .pop_front()
210 .expect("test provides every observation")
211 }
212 }
213
214 #[derive(Debug)]
215 struct SnapshotRepository(Mutex<VecDeque<RecoverySnapshot>>);
216
217 impl RecoveryRepository for SnapshotRepository {
218 fn recovery_snapshot<'a>(
219 &'a self,
220 _execution_id: JobExecutionId,
221 _current_owner: &'a OwnerToken,
222 ) -> BoxFuture<'a, Result<RecoverySnapshot, RepositoryError>> {
223 Box::pin(async move {
224 self.0
225 .lock()
226 .expect("snapshot observations lock")
227 .pop_front()
228 .ok_or(RepositoryError::Unavailable)
229 })
230 }
231 }
232
233 fn snapshot(server_time: SystemTime, status: BatchStatus) -> RecoverySnapshot {
234 snapshot_with(
235 server_time,
236 status,
237 OwnerObservation::OtherProcess,
238 server_time - Duration::from_mins(16),
239 )
240 }
241
242 fn snapshot_with(
245 server_time: SystemTime,
246 status: BatchStatus,
247 owner: OwnerObservation,
248 updated_at: SystemTime,
249 ) -> RecoverySnapshot {
250 RecoverySnapshot::new(
251 JobExecutionId::new(7).expect("static id"),
252 status,
253 2,
254 ExecutionVersion::new(3),
255 owner,
256 updated_at,
257 server_time,
258 None,
259 RecoveryMarkers::new()
260 .with_unknown_commit(status == BatchStatus::Unknown)
261 .with_committed_flow_decision(true),
262 )
263 }
264
265 #[tokio::test]
266 async fn stale_proposal_is_version_bound_and_redacted() {
267 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000);
268 let proposer = RecoveryProposer::new(
269 SnapshotRepository(Mutex::new([snapshot(now, BatchStatus::Started)].into())),
270 Arc::new(FixedWall(now)),
271 Arc::new(SequenceMonotonic::observations([
272 Duration::ZERO,
273 Duration::from_millis(2),
274 ])),
275 OwnerToken::from_bytes([9; 16]),
276 );
277
278 let proposal = proposer
279 .propose(JobExecutionId::new(7).expect("static id"))
280 .await
281 .expect("old foreign-owned execution is stale");
282
283 assert_eq!(proposal.observed_version(), ExecutionVersion::new(3));
284 assert_eq!(proposal.digest_hex().len(), 64);
285 assert_eq!(proposal.evidence().owner(), OwnerObservation::OtherProcess);
286 assert_eq!(proposal.evidence().inactivity(), Duration::from_mins(16));
287 assert!(!format!("{proposal:?}").contains(&format!("{:?}", [9; 16])));
288 }
289
290 #[tokio::test]
291 async fn current_owner_and_young_activity_do_not_become_stale() {
292 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000);
293 let owned = snapshot_with(
294 now,
295 BatchStatus::Started,
296 OwnerObservation::CurrentProcess,
297 now - Duration::from_mins(16),
298 );
299 let proposer = RecoveryProposer::new(
300 SnapshotRepository(Mutex::new([owned].into())),
301 Arc::new(FixedWall(now)),
302 Arc::new(SequenceMonotonic::observations([
303 Duration::ZERO,
304 Duration::ZERO,
305 ])),
306 OwnerToken::from_bytes([9; 16]),
307 );
308 assert_eq!(
309 proposer
310 .propose(JobExecutionId::new(7).expect("static id"))
311 .await,
312 Err(RecoveryError::OwnedByCurrentProcess)
313 );
314
315 let young = snapshot_with(
316 now,
317 BatchStatus::Starting,
318 OwnerObservation::OtherProcess,
319 now - Duration::from_mins(1),
320 );
321 let proposer = RecoveryProposer::new(
322 SnapshotRepository(Mutex::new([young].into())),
323 Arc::new(FixedWall(now)),
324 Arc::new(SequenceMonotonic::observations([
325 Duration::ZERO,
326 Duration::ZERO,
327 ])),
328 OwnerToken::from_bytes([9; 16]),
329 );
330 assert!(matches!(
331 proposer
332 .propose(JobExecutionId::new(7).expect("static id"))
333 .await,
334 Err(RecoveryError::NotStale { .. })
335 ));
336 }
337
338 #[tokio::test]
339 async fn backwards_repository_time_invalidates_the_next_observation() {
340 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000);
341 let earlier = now - Duration::from_secs(1);
342 let proposer = RecoveryProposer::new(
343 SnapshotRepository(Mutex::new(
344 [
345 snapshot(now, BatchStatus::Unknown),
346 snapshot(earlier, BatchStatus::Unknown),
347 ]
348 .into(),
349 )),
350 Arc::new(FixedWall(now)),
351 Arc::new(SequenceMonotonic::observations([
352 Duration::ZERO,
353 Duration::from_millis(1),
354 Duration::from_millis(2),
355 Duration::from_millis(3),
356 ])),
357 OwnerToken::from_bytes([9; 16]),
358 );
359 proposer
360 .propose(JobExecutionId::new(7).expect("static id"))
361 .await
362 .expect("first observation is usable");
363 assert_eq!(
364 proposer
365 .propose(JobExecutionId::new(7).expect("static id"))
366 .await,
367 Err(RecoveryError::ClockEvidenceUnusable)
368 );
369 }
370
371 #[tokio::test]
372 async fn advancing_observation_time_preserves_a_durable_evidence_digest() {
373 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000);
374 let first = snapshot(now, BatchStatus::Started);
375 let second = snapshot_with(
376 now + Duration::from_secs(1),
377 BatchStatus::Started,
378 OwnerObservation::OtherProcess,
379 now - Duration::from_mins(16),
380 );
381 let changed = snapshot(now + Duration::from_secs(2), BatchStatus::Started);
382 let proposer = RecoveryProposer::new(
383 SnapshotRepository(Mutex::new([first, second, changed].into())),
384 Arc::new(FixedWall(now)),
385 Arc::new(SequenceMonotonic::observations([
386 Duration::ZERO,
387 Duration::from_millis(1),
388 Duration::from_millis(2),
389 Duration::from_millis(3),
390 Duration::from_millis(4),
391 Duration::from_millis(5),
392 ])),
393 OwnerToken::from_bytes([9; 16]),
394 );
395
396 let id = JobExecutionId::new(7).expect("static id");
397 let earlier = proposer.propose(id).await.expect("first proposal");
398 let later = proposer.propose(id).await.expect("later proposal");
399 let changed = proposer.propose(id).await.expect("changed proposal");
400
401 assert_ne!(
402 earlier.evidence().server_time(),
403 later.evidence().server_time()
404 );
405 assert_eq!(earlier.digest(), later.digest());
406 assert_ne!(later.digest(), changed.digest());
407 }
408}