Skip to main content

oxide_batch_repository/
recovery.rs

1//! Evidence-bound stale-execution recovery observations.
2//!
3//! A snapshot is a bounded, value-redacted observation gathered with repository
4//! server time. It never mutates the execution and it cannot authorize
5//! takeover. The repository compares the complete owner token without returning
6//! that token to the caller. The proposer that turns these observations into a
7//! proposal lives above this crate.
8
9use std::error::Error;
10use std::fmt;
11use std::time::{Duration, Instant, SystemTime};
12
13use oxide_batch_core::{BatchStatus, ExecutionVersion, JobExecutionId, StepExecutionId};
14
15use crate::{BoxFuture, CanonicalWriter, RepositoryError, StateEnvelopeDescriptor, hex_digest};
16
17/// Minimum accepted stale-execution threshold.
18pub const MIN_STALE_THRESHOLD: Duration = Duration::from_mins(1);
19/// Maximum accepted stale-execution threshold.
20pub const MAX_STALE_THRESHOLD: Duration = Duration::from_hours(24);
21/// Default stale-execution threshold.
22pub const DEFAULT_STALE_THRESHOLD: Duration = Duration::from_mins(15);
23/// Minimum accepted repository/local clock-skew bound.
24pub const MIN_CLOCK_SKEW: Duration = Duration::from_millis(100);
25/// Maximum accepted repository/local clock-skew bound.
26pub const MAX_CLOCK_SKEW: Duration = Duration::from_mins(1);
27/// Default repository/local clock-skew bound.
28pub const DEFAULT_MAX_CLOCK_SKEW: Duration = Duration::from_secs(5);
29
30/// A per-process 16-byte execution-owner token.
31///
32/// The token is evidence only. It is not a lease, never expires, grants no
33/// authority, and must not be reused by another process.
34#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
35pub struct OwnerToken([u8; 16]);
36
37impl OwnerToken {
38    /// Constructs a token from application-generated random bytes.
39    #[must_use]
40    pub const fn from_bytes(bytes: [u8; 16]) -> Self {
41        Self(bytes)
42    }
43
44    /// Returns the complete bytes for a repository ownership comparison.
45    #[must_use]
46    pub const fn as_bytes(&self) -> &[u8; 16] {
47        &self.0
48    }
49}
50
51impl fmt::Debug for OwnerToken {
52    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53        formatter.write_str("OwnerToken(<redacted>)")
54    }
55}
56
57/// The durable owner-token observation relative to the inspecting process.
58#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
59#[non_exhaustive]
60pub enum OwnerObservation {
61    /// No owner token was recorded.
62    Absent,
63    /// The complete token matches the inspecting process.
64    CurrentProcess,
65    /// A complete, different token was recorded.
66    OtherProcess,
67}
68
69impl OwnerObservation {
70    const fn code(self) -> &'static str {
71        match self {
72            Self::Absent => "ABSENT",
73            Self::CurrentProcess => "CURRENT_PROCESS",
74            Self::OtherProcess => "OTHER_PROCESS",
75        }
76    }
77}
78
79/// A bounded stale-execution threshold.
80#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
81pub struct StaleThreshold(Duration);
82
83impl StaleThreshold {
84    /// Validates a threshold in `1 min..=24 h`.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`RecoveryError::InvalidStaleThreshold`] outside the bound.
89    pub fn new(value: Duration) -> Result<Self, RecoveryError> {
90        if !(MIN_STALE_THRESHOLD..=MAX_STALE_THRESHOLD).contains(&value) {
91            return Err(RecoveryError::InvalidStaleThreshold);
92        }
93        Ok(Self(value))
94    }
95
96    /// Returns the validated duration.
97    #[must_use]
98    pub const fn get(self) -> Duration {
99        self.0
100    }
101}
102
103impl Default for StaleThreshold {
104    fn default() -> Self {
105        Self(DEFAULT_STALE_THRESHOLD)
106    }
107}
108
109/// A bounded repository/local wall-clock skew tolerance.
110#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
111pub struct MaxClockSkew(Duration);
112
113impl MaxClockSkew {
114    /// Validates a skew bound in `100 ms..=60 s`.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`RecoveryError::InvalidMaxClockSkew`] outside the bound.
119    pub fn new(value: Duration) -> Result<Self, RecoveryError> {
120        if !(MIN_CLOCK_SKEW..=MAX_CLOCK_SKEW).contains(&value) {
121            return Err(RecoveryError::InvalidMaxClockSkew);
122        }
123        Ok(Self(value))
124    }
125
126    /// Returns the validated duration.
127    #[must_use]
128    pub const fn get(self) -> Duration {
129        self.0
130    }
131}
132
133impl Default for MaxClockSkew {
134    fn default() -> Self {
135        Self(DEFAULT_MAX_CLOCK_SKEW)
136    }
137}
138
139/// A runtime-neutral reading of one monotonic clock.
140#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
141pub struct MonotonicInstant(Duration);
142
143impl MonotonicInstant {
144    /// Constructs a deterministic reading for an injected test clock.
145    #[must_use]
146    pub const fn from_duration(value: Duration) -> Self {
147        Self(value)
148    }
149
150    /// Returns the elapsed monotonic duration, or `None` when time went back.
151    #[doc(hidden)]
152    #[must_use]
153    pub fn checked_elapsed_since(self, earlier: Self) -> Option<Duration> {
154        self.0.checked_sub(earlier.0)
155    }
156}
157
158/// Supplies monotonic readings for bounded recovery observations.
159pub trait MonotonicClock: Send + Sync {
160    /// Returns the current monotonic reading.
161    fn now(&self) -> MonotonicInstant;
162}
163
164/// An application-owned system monotonic clock.
165#[derive(Clone, Debug)]
166pub struct SystemMonotonicClock {
167    origin: Instant,
168}
169
170impl SystemMonotonicClock {
171    /// Starts a new monotonic epoch owned by the caller.
172    #[must_use]
173    pub fn new() -> Self {
174        Self {
175            origin: Instant::now(),
176        }
177    }
178}
179
180impl Default for SystemMonotonicClock {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186impl MonotonicClock for SystemMonotonicClock {
187    fn now(&self) -> MonotonicInstant {
188        MonotonicInstant(self.origin.elapsed())
189    }
190}
191
192/// Redacted evidence for the latest durable step execution.
193#[derive(Clone, Debug, Eq, PartialEq)]
194pub struct RecoveryStepEvidence {
195    id: StepExecutionId,
196    status: BatchStatus,
197    checkpoint: Option<StateEnvelopeDescriptor>,
198}
199
200/// Closed boolean recovery markers retained as one bounded bit set.
201#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
202pub struct RecoveryMarkers(u8);
203
204impl RecoveryMarkers {
205    const UNKNOWN_COMMIT: u8 = 1;
206    const COMPLETED_PARTITION: u8 = 1 << 1;
207    const COMMITTED_FLOW_DECISION: u8 = 1 << 2;
208    const AMBIGUOUS_EXTERNAL_EFFECT: u8 = 1 << 3;
209
210    /// Constructs an empty marker set.
211    #[must_use]
212    pub const fn new() -> Self {
213        Self(0)
214    }
215
216    /// Records whether the last durable marker is an unknown commit.
217    #[must_use]
218    pub const fn with_unknown_commit(mut self, value: bool) -> Self {
219        if value {
220            self.0 |= Self::UNKNOWN_COMMIT;
221        }
222        self
223    }
224
225    /// Records whether completed partition evidence exists.
226    #[must_use]
227    pub const fn with_completed_partition(mut self, value: bool) -> Self {
228        if value {
229            self.0 |= Self::COMPLETED_PARTITION;
230        }
231        self
232    }
233
234    /// Records whether a committed flow decision exists.
235    #[must_use]
236    pub const fn with_committed_flow_decision(mut self, value: bool) -> Self {
237        if value {
238            self.0 |= Self::COMMITTED_FLOW_DECISION;
239        }
240        self
241    }
242
243    /// Records whether the definition declares an ambiguous external effect.
244    #[must_use]
245    pub const fn with_ambiguous_external_effect(mut self, value: bool) -> Self {
246        if value {
247            self.0 |= Self::AMBIGUOUS_EXTERNAL_EFFECT;
248        }
249        self
250    }
251
252    const fn contains(self, marker: u8) -> bool {
253        self.0 & marker != 0
254    }
255}
256
257impl RecoveryStepEvidence {
258    /// Constructs one value-redacted step observation.
259    #[must_use]
260    pub const fn new(
261        id: StepExecutionId,
262        status: BatchStatus,
263        checkpoint: Option<StateEnvelopeDescriptor>,
264    ) -> Self {
265        Self {
266            id,
267            status,
268            checkpoint,
269        }
270    }
271
272    /// Returns the latest durable step-execution identifier.
273    #[must_use]
274    pub const fn id(&self) -> StepExecutionId {
275        self.id
276    }
277
278    /// Returns its durable lifecycle status.
279    #[must_use]
280    pub const fn status(&self) -> BatchStatus {
281        self.status
282    }
283
284    /// Borrows its redacted checkpoint envelope descriptor.
285    #[must_use]
286    pub const fn checkpoint(&self) -> Option<&StateEnvelopeDescriptor> {
287        self.checkpoint.as_ref()
288    }
289}
290
291/// One adapter-owned recovery snapshot gathered with repository server time.
292///
293/// The snapshot contains only the closed evidence fields accepted by the M4
294/// contract. It has no parameter, context, checkpoint payload, item, error
295/// text, credential, endpoint, or SQL value.
296#[derive(Clone, Debug, Eq, PartialEq)]
297pub struct RecoverySnapshot {
298    execution_id: JobExecutionId,
299    status: BatchStatus,
300    attempt: u32,
301    version: ExecutionVersion,
302    owner: OwnerObservation,
303    updated_at: SystemTime,
304    server_time: SystemTime,
305    latest_step: Option<RecoveryStepEvidence>,
306    markers: RecoveryMarkers,
307}
308
309impl RecoverySnapshot {
310    /// Constructs one adapter-owned, value-redacted snapshot.
311    #[must_use]
312    #[allow(clippy::too_many_arguments)]
313    pub const fn new(
314        execution_id: JobExecutionId,
315        status: BatchStatus,
316        attempt: u32,
317        version: ExecutionVersion,
318        owner: OwnerObservation,
319        updated_at: SystemTime,
320        server_time: SystemTime,
321        latest_step: Option<RecoveryStepEvidence>,
322        markers: RecoveryMarkers,
323    ) -> Self {
324        Self {
325            execution_id,
326            status,
327            attempt,
328            version,
329            owner,
330            updated_at,
331            server_time,
332            latest_step,
333            markers,
334        }
335    }
336
337    /// Returns the observed lifecycle status.
338    #[must_use]
339    pub const fn status(&self) -> BatchStatus {
340        self.status
341    }
342
343    /// Returns the owner-token observation.
344    #[must_use]
345    pub const fn owner(&self) -> OwnerObservation {
346        self.owner
347    }
348
349    /// Returns the durable timestamp whose age establishes staleness.
350    #[must_use]
351    pub const fn updated_at(&self) -> SystemTime {
352        self.updated_at
353    }
354
355    /// Returns the repository server time this snapshot was gathered with.
356    #[must_use]
357    pub const fn server_time(&self) -> SystemTime {
358        self.server_time
359    }
360}
361
362/// Adapter port for one bounded, server-time recovery observation.
363pub trait RecoveryRepository: Send + Sync {
364    /// Reads one value-redacted snapshot without changing durable state.
365    fn recovery_snapshot<'a>(
366        &'a self,
367        execution_id: JobExecutionId,
368        current_owner: &'a OwnerToken,
369    ) -> BoxFuture<'a, Result<RecoverySnapshot, RepositoryError>>;
370}
371
372/// Canonical evidence retained by one recovery proposal.
373#[derive(Clone, Debug, Eq, PartialEq)]
374pub struct RecoveryEvidence {
375    snapshot: RecoverySnapshot,
376    inactivity: Duration,
377    observed_clock_offset: Duration,
378    observation_window: Duration,
379}
380
381impl RecoveryEvidence {
382    /// Binds one snapshot to the clock evidence gathered around it.
383    ///
384    /// The three durations are the proposer's own observations: the inactivity
385    /// it measured against repository server time, the offset it observed
386    /// between that server time and its local wall clock, and the monotonic
387    /// window the snapshot read occupied.
388    #[must_use]
389    pub const fn new(
390        snapshot: RecoverySnapshot,
391        inactivity: Duration,
392        observed_clock_offset: Duration,
393        observation_window: Duration,
394    ) -> Self {
395        Self {
396            snapshot,
397            inactivity,
398            observed_clock_offset,
399            observation_window,
400        }
401    }
402
403    /// Returns the execution identity.
404    #[must_use]
405    pub const fn execution_id(&self) -> JobExecutionId {
406        self.snapshot.execution_id
407    }
408
409    /// Returns the observed lifecycle status.
410    #[must_use]
411    pub const fn status(&self) -> BatchStatus {
412        self.snapshot.status
413    }
414
415    /// Returns the attempt ordinal.
416    #[must_use]
417    pub const fn attempt(&self) -> u32 {
418        self.snapshot.attempt
419    }
420
421    /// Returns the observed optimistic version.
422    #[must_use]
423    pub const fn version(&self) -> ExecutionVersion {
424        self.snapshot.version
425    }
426
427    /// Returns the owner-token observation.
428    #[must_use]
429    pub const fn owner(&self) -> OwnerObservation {
430        self.snapshot.owner
431    }
432
433    /// Returns the durable inactivity observed against repository server time.
434    #[must_use]
435    pub const fn inactivity(&self) -> Duration {
436        self.inactivity
437    }
438
439    /// Returns the durable timestamp whose age established staleness.
440    #[must_use]
441    pub const fn updated_at(&self) -> SystemTime {
442        self.snapshot.updated_at
443    }
444
445    /// Returns the repository-server time bound into this observation.
446    #[must_use]
447    pub const fn server_time(&self) -> SystemTime {
448        self.snapshot.server_time
449    }
450
451    /// Returns the absolute repository/local wall-clock offset.
452    #[must_use]
453    pub const fn observed_clock_offset(&self) -> Duration {
454        self.observed_clock_offset
455    }
456
457    /// Returns the monotonic window that bounded this observation.
458    #[must_use]
459    pub const fn observation_window(&self) -> Duration {
460        self.observation_window
461    }
462
463    /// Borrows the latest durable step evidence, when any step exists.
464    #[must_use]
465    pub const fn latest_step(&self) -> Option<&RecoveryStepEvidence> {
466        self.snapshot.latest_step.as_ref()
467    }
468
469    /// Returns whether the last durable marker is an unknown commit.
470    #[must_use]
471    pub const fn unknown_commit(&self) -> bool {
472        self.snapshot
473            .markers
474            .contains(RecoveryMarkers::UNKNOWN_COMMIT)
475    }
476
477    /// Returns whether completed partition evidence exists.
478    #[must_use]
479    pub const fn completed_partition(&self) -> bool {
480        self.snapshot
481            .markers
482            .contains(RecoveryMarkers::COMPLETED_PARTITION)
483    }
484
485    /// Returns whether a committed flow decision exists.
486    #[must_use]
487    pub const fn committed_flow_decision(&self) -> bool {
488        self.snapshot
489            .markers
490            .contains(RecoveryMarkers::COMMITTED_FLOW_DECISION)
491    }
492
493    /// Returns whether the definition declares an ambiguous external effect.
494    #[must_use]
495    pub const fn ambiguous_external_effect(&self) -> bool {
496        self.snapshot
497            .markers
498            .contains(RecoveryMarkers::AMBIGUOUS_EXTERNAL_EFFECT)
499    }
500
501    fn digest(&self) -> [u8; 32] {
502        let mut writer = CanonicalWriter::new("oxide-batch.recovery-evidence.v1");
503        writer.push_u64(self.execution_id().get());
504        writer.push_str(self.status().as_str());
505        writer.push_u64(u64::from(self.attempt()));
506        writer.push_u64(self.version().get());
507        writer.push_str(self.owner().code());
508        // Bind the durable timestamp rather than the advancing observation
509        // time and its derived durations. A stateless client can therefore
510        // regenerate the same digest exactly while durable evidence is
511        // unchanged; a stop request or lifecycle write changes `updated_at`.
512        push_system_time(&mut writer, self.snapshot.updated_at);
513        match self.latest_step() {
514            Some(step) => {
515                writer.push_u64(step.id().get());
516                writer.push_str(step.status().as_str());
517                match step.checkpoint() {
518                    Some(checkpoint) => {
519                        writer.push_u64(u64::from(checkpoint.format_version()));
520                        writer.push_str(checkpoint.schema_id().as_str());
521                        writer.push_u64(u64::from(checkpoint.schema_version().get()));
522                        writer
523                            .push_u64(u64::try_from(checkpoint.encoded_len()).unwrap_or(u64::MAX));
524                    }
525                    None => writer.push_str("NO_CHECKPOINT"),
526                }
527            }
528            None => writer.push_str("NO_STEP"),
529        }
530        writer.push_u64(u64::from(self.unknown_commit()));
531        writer.push_u64(u64::from(self.completed_partition()));
532        writer.push_u64(u64::from(self.committed_flow_decision()));
533        writer.push_u64(u64::from(self.ambiguous_external_effect()));
534        writer.digest()
535    }
536}
537
538fn push_duration(writer: &mut CanonicalWriter, value: Duration) {
539    writer.push_u64(value.as_secs());
540    writer.push_u64(u64::from(value.subsec_nanos()));
541}
542
543fn push_system_time(writer: &mut CanonicalWriter, value: SystemTime) {
544    match value.duration_since(SystemTime::UNIX_EPOCH) {
545        Ok(duration) => {
546            writer.push_str("AFTER_EPOCH");
547            push_duration(writer, duration);
548        }
549        Err(error) => {
550            writer.push_str("BEFORE_EPOCH");
551            push_duration(writer, error.duration());
552        }
553    }
554}
555
556/// A validated, evidence-bound recovery proposal.
557#[derive(Clone, Eq, PartialEq)]
558pub struct RecoveryProposal {
559    evidence: RecoveryEvidence,
560    digest: [u8; 32],
561}
562
563impl RecoveryProposal {
564    /// Seals one proposal over its evidence.
565    ///
566    /// The digest is computed here rather than supplied, so a proposal cannot
567    /// carry a digest that its evidence does not produce.
568    #[must_use]
569    pub fn new(evidence: RecoveryEvidence) -> Self {
570        let digest = evidence.digest();
571        Self { evidence, digest }
572    }
573
574    /// Borrows the bounded redacted evidence.
575    #[must_use]
576    pub const fn evidence(&self) -> &RecoveryEvidence {
577        &self.evidence
578    }
579
580    /// Returns the observed execution version bound into the digest.
581    #[must_use]
582    pub const fn observed_version(&self) -> ExecutionVersion {
583        self.evidence.version()
584    }
585
586    /// Returns the canonical evidence digest.
587    #[must_use]
588    pub const fn digest(&self) -> &[u8; 32] {
589        &self.digest
590    }
591
592    /// Returns the lowercase hexadecimal digest.
593    #[must_use]
594    pub fn digest_hex(&self) -> String {
595        hex_digest(&self.digest)
596    }
597}
598
599impl fmt::Debug for RecoveryProposal {
600    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
601        formatter
602            .debug_struct("RecoveryProposal")
603            .field("evidence", &self.evidence)
604            .field("digest", &self.digest_hex())
605            .finish()
606    }
607}
608
609/// A typed recovery-proposal failure.
610#[derive(Clone, Debug, Eq, PartialEq)]
611#[non_exhaustive]
612pub enum RecoveryError {
613    /// The configured stale threshold was outside `1 min..=24 h`.
614    InvalidStaleThreshold,
615    /// The configured clock-skew bound was outside `100 ms..=60 s`.
616    InvalidMaxClockSkew,
617    /// Repository time, local wall time, or the monotonic observation window
618    /// could not provide usable evidence.
619    ClockEvidenceUnusable,
620    /// The execution is still owned by the inspecting process.
621    OwnedByCurrentProcess,
622    /// The durable inactivity has not crossed the strict stale threshold.
623    NotStale {
624        /// Observed durable inactivity.
625        inactivity: Duration,
626        /// Configured threshold.
627        threshold: StaleThreshold,
628    },
629    /// The status is neither ambiguous nor an active stale candidate.
630    NotRecoverable {
631        /// Observed durable status.
632        status: BatchStatus,
633    },
634    /// The repository could not produce evidence.
635    Repository(RepositoryError),
636}
637
638impl fmt::Display for RecoveryError {
639    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
640        match self {
641            Self::InvalidStaleThreshold => {
642                formatter.write_str("stale threshold must be between 1 minute and 24 hours")
643            }
644            Self::InvalidMaxClockSkew => {
645                formatter.write_str("maximum clock skew must be between 100 ms and 60 seconds")
646            }
647            Self::ClockEvidenceUnusable => {
648                formatter.write_str("repository and local clocks cannot provide usable evidence")
649            }
650            Self::OwnedByCurrentProcess => {
651                formatter.write_str("the execution is owned by the inspecting process")
652            }
653            Self::NotStale {
654                inactivity,
655                threshold,
656            } => write!(
657                formatter,
658                "durable inactivity of {inactivity:?} has not exceeded {:?}",
659                threshold.get()
660            ),
661            Self::NotRecoverable { status } => {
662                write!(
663                    formatter,
664                    "an execution in {status} is not a recovery candidate"
665                )
666            }
667            Self::Repository(error) => error.fmt(formatter),
668        }
669    }
670}
671
672impl Error for RecoveryError {
673    fn source(&self) -> Option<&(dyn Error + 'static)> {
674        match self {
675            Self::Repository(error) => Some(error),
676            _ => None,
677        }
678    }
679}