1use 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
17pub const MIN_STALE_THRESHOLD: Duration = Duration::from_mins(1);
19pub const MAX_STALE_THRESHOLD: Duration = Duration::from_hours(24);
21pub const DEFAULT_STALE_THRESHOLD: Duration = Duration::from_mins(15);
23pub const MIN_CLOCK_SKEW: Duration = Duration::from_millis(100);
25pub const MAX_CLOCK_SKEW: Duration = Duration::from_mins(1);
27pub const DEFAULT_MAX_CLOCK_SKEW: Duration = Duration::from_secs(5);
29
30#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
35pub struct OwnerToken([u8; 16]);
36
37impl OwnerToken {
38 #[must_use]
40 pub const fn from_bytes(bytes: [u8; 16]) -> Self {
41 Self(bytes)
42 }
43
44 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
59#[non_exhaustive]
60pub enum OwnerObservation {
61 Absent,
63 CurrentProcess,
65 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
81pub struct StaleThreshold(Duration);
82
83impl StaleThreshold {
84 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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
111pub struct MaxClockSkew(Duration);
112
113impl MaxClockSkew {
114 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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
141pub struct MonotonicInstant(Duration);
142
143impl MonotonicInstant {
144 #[must_use]
146 pub const fn from_duration(value: Duration) -> Self {
147 Self(value)
148 }
149
150 #[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
158pub trait MonotonicClock: Send + Sync {
160 fn now(&self) -> MonotonicInstant;
162}
163
164#[derive(Clone, Debug)]
166pub struct SystemMonotonicClock {
167 origin: Instant,
168}
169
170impl SystemMonotonicClock {
171 #[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#[derive(Clone, Debug, Eq, PartialEq)]
194pub struct RecoveryStepEvidence {
195 id: StepExecutionId,
196 status: BatchStatus,
197 checkpoint: Option<StateEnvelopeDescriptor>,
198}
199
200#[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 #[must_use]
212 pub const fn new() -> Self {
213 Self(0)
214 }
215
216 #[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 #[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 #[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 #[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 #[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 #[must_use]
274 pub const fn id(&self) -> StepExecutionId {
275 self.id
276 }
277
278 #[must_use]
280 pub const fn status(&self) -> BatchStatus {
281 self.status
282 }
283
284 #[must_use]
286 pub const fn checkpoint(&self) -> Option<&StateEnvelopeDescriptor> {
287 self.checkpoint.as_ref()
288 }
289}
290
291#[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 #[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 #[must_use]
339 pub const fn status(&self) -> BatchStatus {
340 self.status
341 }
342
343 #[must_use]
345 pub const fn owner(&self) -> OwnerObservation {
346 self.owner
347 }
348
349 #[must_use]
351 pub const fn updated_at(&self) -> SystemTime {
352 self.updated_at
353 }
354
355 #[must_use]
357 pub const fn server_time(&self) -> SystemTime {
358 self.server_time
359 }
360}
361
362pub trait RecoveryRepository: Send + Sync {
364 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#[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 #[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 #[must_use]
405 pub const fn execution_id(&self) -> JobExecutionId {
406 self.snapshot.execution_id
407 }
408
409 #[must_use]
411 pub const fn status(&self) -> BatchStatus {
412 self.snapshot.status
413 }
414
415 #[must_use]
417 pub const fn attempt(&self) -> u32 {
418 self.snapshot.attempt
419 }
420
421 #[must_use]
423 pub const fn version(&self) -> ExecutionVersion {
424 self.snapshot.version
425 }
426
427 #[must_use]
429 pub const fn owner(&self) -> OwnerObservation {
430 self.snapshot.owner
431 }
432
433 #[must_use]
435 pub const fn inactivity(&self) -> Duration {
436 self.inactivity
437 }
438
439 #[must_use]
441 pub const fn updated_at(&self) -> SystemTime {
442 self.snapshot.updated_at
443 }
444
445 #[must_use]
447 pub const fn server_time(&self) -> SystemTime {
448 self.snapshot.server_time
449 }
450
451 #[must_use]
453 pub const fn observed_clock_offset(&self) -> Duration {
454 self.observed_clock_offset
455 }
456
457 #[must_use]
459 pub const fn observation_window(&self) -> Duration {
460 self.observation_window
461 }
462
463 #[must_use]
465 pub const fn latest_step(&self) -> Option<&RecoveryStepEvidence> {
466 self.snapshot.latest_step.as_ref()
467 }
468
469 #[must_use]
471 pub const fn unknown_commit(&self) -> bool {
472 self.snapshot
473 .markers
474 .contains(RecoveryMarkers::UNKNOWN_COMMIT)
475 }
476
477 #[must_use]
479 pub const fn completed_partition(&self) -> bool {
480 self.snapshot
481 .markers
482 .contains(RecoveryMarkers::COMPLETED_PARTITION)
483 }
484
485 #[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 #[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 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#[derive(Clone, Eq, PartialEq)]
558pub struct RecoveryProposal {
559 evidence: RecoveryEvidence,
560 digest: [u8; 32],
561}
562
563impl RecoveryProposal {
564 #[must_use]
569 pub fn new(evidence: RecoveryEvidence) -> Self {
570 let digest = evidence.digest();
571 Self { evidence, digest }
572 }
573
574 #[must_use]
576 pub const fn evidence(&self) -> &RecoveryEvidence {
577 &self.evidence
578 }
579
580 #[must_use]
582 pub const fn observed_version(&self) -> ExecutionVersion {
583 self.evidence.version()
584 }
585
586 #[must_use]
588 pub const fn digest(&self) -> &[u8; 32] {
589 &self.digest
590 }
591
592 #[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#[derive(Clone, Debug, Eq, PartialEq)]
611#[non_exhaustive]
612pub enum RecoveryError {
613 InvalidStaleThreshold,
615 InvalidMaxClockSkew,
617 ClockEvidenceUnusable,
620 OwnedByCurrentProcess,
622 NotStale {
624 inactivity: Duration,
626 threshold: StaleThreshold,
628 },
629 NotRecoverable {
631 status: BatchStatus,
633 },
634 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}