Skip to main content

oxide_batch/service/
recovery.rs

1//! Evidence-bound stale-execution recovery proposals.
2//!
3//! A proposal is a bounded, value-redacted observation. It never mutates the
4//! execution and it cannot authorize takeover. The snapshot, evidence, proposal,
5//! and the port that gathers them live in `oxide-batch-repository`.
6
7use 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
17/// Produces evidence-bound proposals without mutating repository state.
18pub 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    /// Binds explicit repository, wall-clock, monotonic-clock, and process
42    /// ownership evidence.
43    #[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    /// Replaces the validated stale threshold.
63    #[must_use]
64    pub const fn with_stale_threshold(mut self, value: StaleThreshold) -> Self {
65        self.stale_threshold = value;
66        self
67    }
68
69    /// Replaces the validated clock-skew bound.
70    #[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    /// Attaches a non-authoritative, panic-isolated telemetry sink.
77    #[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    /// Gathers one bounded proposal without changing durable state.
84    ///
85    /// # Errors
86    ///
87    /// Rejects terminal/non-candidate state, current-process ownership, a
88    /// candidate younger than the threshold, unusable clock evidence, or a
89    /// repository failure.
90    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    // The snapshot fields are private to `oxide-batch-repository`, so a case
243    // that varies one builds the whole value rather than assigning to it.
244    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}