Skip to main content

oxide_batch_core/
fault.rs

1//! Runtime-neutral fault-policy values.
2//!
3//! Retry, skip, rollback, and backoff policy are restart-relevant
4//! definition data: they are fingerprint inputs and are embedded in a
5//! compiled step. The cancellable waiting contract that consumes a delay
6//! is not, and stays with the runtime.
7
8use std::error::Error;
9use std::fmt;
10use std::time::Duration;
11
12use crate::{ChunkDeliveryMode, ClassifierRevision, FailureCategory, FailureId, FailureSummary};
13
14/// The longest delay a backoff policy may produce.
15const MAX_BACKOFF: Duration = Duration::from_hours(24);
16/// The largest retry attempt count one step may declare.
17const MAX_RETRY: u32 = 65_535;
18/// The smallest bounded retry-state size one step may declare.
19const MIN_RETRY_STATE: u32 = 1;
20/// The largest bounded retry-state size one step may declare.
21const MAX_RETRY_STATE: u32 = 256;
22
23/// The framework phase that produced a fault.
24///
25/// The phase is framework-owned input for classification. It never carries an
26/// item value, error payload, or component-private state.
27#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
28#[non_exhaustive]
29pub enum FaultPhase {
30    /// An item reader failed.
31    Read,
32    /// An item processor failed.
33    Process,
34    /// An item writer failed.
35    Write,
36    /// A chunk transaction failed to begin, commit, or roll back.
37    Transaction,
38    /// Checkpoint or execution-context state could not be produced or stored.
39    Checkpoint,
40    /// An authoritative listener callback failed or panicked.
41    Listener,
42    /// Cancellable backoff failed or was interrupted.
43    Backoff,
44}
45
46impl FaultPhase {
47    /// Returns whether a classifier rule may govern this phase.
48    ///
49    /// Listener failures are never retried or skipped in M3.
50    #[must_use]
51    pub const fn is_policy_eligible(self) -> bool {
52        !matches!(self, Self::Listener)
53    }
54
55    /// Returns whether a committed skip can be counted for this phase.
56    #[must_use]
57    pub const fn is_skippable(self) -> bool {
58        matches!(self, Self::Read | Self::Process | Self::Write)
59    }
60
61    /// Returns whether the phase can structurally accept a commit-safe skip.
62    ///
63    /// Only read and process faults occur before any external write effect.
64    #[must_use]
65    pub const fn allows_commit_safe_skip(self) -> bool {
66        matches!(self, Self::Read | Self::Process)
67    }
68
69    /// Returns the stable low-cardinality telemetry name.
70    #[must_use]
71    pub const fn as_str(self) -> &'static str {
72        match self {
73            Self::Read => "read",
74            Self::Process => "process",
75            Self::Write => "write",
76            Self::Transaction => "transaction",
77            Self::Checkpoint => "checkpoint",
78            Self::Listener => "listener",
79            Self::Backoff => "backoff",
80        }
81    }
82
83    /// Returns the phase for one persisted name, rejecting unknown values.
84    ///
85    /// The names are durable fault-state data and are never renamed.
86    #[must_use]
87    pub fn from_durable_name(value: &str) -> Option<Self> {
88        Some(match value {
89            "read" => Self::Read,
90            "process" => Self::Process,
91            "write" => Self::Write,
92            "transaction" => Self::Transaction,
93            "checkpoint" => Self::Checkpoint,
94            "listener" => Self::Listener,
95            "backoff" => Self::Backoff,
96            _ => return None,
97        })
98    }
99}
100
101impl fmt::Display for FaultPhase {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        formatter.write_str(self.as_str())
104    }
105}
106
107/// The zero-based invocation ordinal for one retry key.
108///
109/// [`RetryOrdinal::INITIAL`] identifies the first component call, which is not
110/// a retry. Ordinal `r` identifies the `r`-th re-invocation.
111#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
112pub struct RetryOrdinal(u16);
113
114impl RetryOrdinal {
115    /// The ordinal of the initial, non-retried component call.
116    pub const INITIAL: Self = Self(0);
117
118    /// Validates and constructs a retry ordinal.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`FaultPolicyError::RetryOrdinalOutOfRange`] above 65,535.
123    pub fn new(value: u32) -> Result<Self, FaultPolicyError> {
124        u16::try_from(value)
125            .map(Self)
126            .map_err(|_| FaultPolicyError::RetryOrdinalOutOfRange { max: MAX_RETRY })
127    }
128
129    /// Returns the ordinal value.
130    #[must_use]
131    #[allow(
132        clippy::cast_lossless,
133        reason = "`From` is not const; the widening cast is exact"
134    )]
135    pub const fn get(self) -> u32 {
136        self.0 as u32
137    }
138
139    /// Returns whether this is the initial, non-retried call.
140    #[must_use]
141    pub const fn is_initial(self) -> bool {
142        self.0 == 0
143    }
144
145    /// Returns the next ordinal.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`FaultPolicyError::RetryOrdinalOutOfRange`] instead of
150    /// wrapping past the bounded representation.
151    pub fn checked_next(self) -> Result<Self, FaultPolicyError> {
152        self.0
153            .checked_add(1)
154            .map(Self)
155            .ok_or(FaultPolicyError::RetryOrdinalOutOfRange { max: MAX_RETRY })
156    }
157}
158
159/// The maximum number of re-invocations after the initial component call.
160#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
161pub struct RetryLimit(u16);
162
163impl RetryLimit {
164    /// A policy that never retries.
165    pub const NONE: Self = Self(0);
166
167    /// Validates and constructs a bounded retry limit.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`FaultPolicyError::RetryLimitOutOfRange`] above 65,535.
172    pub fn new(value: u32) -> Result<Self, FaultPolicyError> {
173        u16::try_from(value)
174            .map(Self)
175            .map_err(|_| FaultPolicyError::RetryLimitOutOfRange { max: MAX_RETRY })
176    }
177
178    /// Returns the configured limit.
179    #[must_use]
180    #[allow(
181        clippy::cast_lossless,
182        reason = "`From` is not const; the widening cast is exact"
183    )]
184    pub const fn get(self) -> u32 {
185        self.0 as u32
186    }
187
188    /// Returns whether retry is disabled.
189    #[must_use]
190    pub const fn is_none(self) -> bool {
191        self.0 == 0
192    }
193
194    /// Returns whether `ordinal` may still be reserved.
195    ///
196    /// The initial call is not a retry, so it is never permitted here.
197    #[must_use]
198    pub const fn permits(self, ordinal: RetryOrdinal) -> bool {
199        !ordinal.is_initial() && ordinal.0 <= self.0
200    }
201}
202
203/// The maximum number of unresolved retry keys retained for one step.
204#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
205pub struct RetryStateLimit(u16);
206
207impl RetryStateLimit {
208    /// Validates and constructs the bounded unresolved-key capacity.
209    ///
210    /// # Errors
211    ///
212    /// Returns [`FaultPolicyError::RetryStateLimitOutOfRange`] outside
213    /// `1..=256`. A definition must choose the bound explicitly.
214    pub fn new(value: u32) -> Result<Self, FaultPolicyError> {
215        if !(MIN_RETRY_STATE..=MAX_RETRY_STATE).contains(&value) {
216            return Err(FaultPolicyError::RetryStateLimitOutOfRange {
217                min: MIN_RETRY_STATE,
218                max: MAX_RETRY_STATE,
219            });
220        }
221        u16::try_from(value)
222            .map(Self)
223            .map_err(|_| FaultPolicyError::RetryStateLimitOutOfRange {
224                min: MIN_RETRY_STATE,
225                max: MAX_RETRY_STATE,
226            })
227    }
228
229    /// Returns the configured capacity.
230    #[must_use]
231    #[allow(
232        clippy::cast_lossless,
233        reason = "`From` is not const; the widening cast is exact"
234    )]
235    pub const fn get(self) -> u32 {
236        self.0 as u32
237    }
238}
239
240/// The maximum aggregate committed skips for one step in one job instance.
241#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
242pub struct SkipLimit(u64);
243
244impl SkipLimit {
245    /// A policy that permits no committed skip.
246    pub const NONE: Self = Self(0);
247
248    /// Constructs an aggregate skip limit.
249    #[must_use]
250    pub const fn new(value: u64) -> Self {
251        Self(value)
252    }
253
254    /// Returns the configured limit.
255    #[must_use]
256    pub const fn get(self) -> u64 {
257        self.0
258    }
259}
260
261/// Durable committed skip counts, kept distinct per phase.
262#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
263pub struct SkipCounts {
264    read: u64,
265    process: u64,
266    write: u64,
267}
268
269impl SkipCounts {
270    /// Counts inherited by a first attempt.
271    pub const ZERO: Self = Self {
272        read: 0,
273        process: 0,
274        write: 0,
275    };
276
277    /// Constructs committed per-phase skip counts.
278    #[must_use]
279    pub const fn new(read: u64, process: u64, write: u64) -> Self {
280        Self {
281            read,
282            process,
283            write,
284        }
285    }
286
287    /// Returns committed read skips.
288    #[must_use]
289    pub const fn read(self) -> u64 {
290        self.read
291    }
292
293    /// Returns committed process skips.
294    #[must_use]
295    pub const fn process(self) -> u64 {
296        self.process
297    }
298
299    /// Returns committed write skips.
300    #[must_use]
301    pub const fn write(self) -> u64 {
302        self.write
303    }
304
305    /// Returns the checked aggregate used by the shared skip limit.
306    ///
307    /// # Errors
308    ///
309    /// Returns [`FaultPolicyError::SkipCountOverflow`] instead of wrapping.
310    pub fn checked_total(self) -> Result<u64, FaultPolicyError> {
311        self.read
312            .checked_add(self.process)
313            .and_then(|partial| partial.checked_add(self.write))
314            .ok_or(FaultPolicyError::SkipCountOverflow)
315    }
316
317    /// Returns the totals after adding one chunk's committed skips.
318    ///
319    /// # Errors
320    ///
321    /// Returns [`FaultPolicyError::SkipCountOverflow`] instead of wrapping.
322    pub fn checked_add(self, other: Self) -> Result<Self, FaultPolicyError> {
323        let next = Self {
324            read: self
325                .read
326                .checked_add(other.read)
327                .ok_or(FaultPolicyError::SkipCountOverflow)?,
328            process: self
329                .process
330                .checked_add(other.process)
331                .ok_or(FaultPolicyError::SkipCountOverflow)?,
332            write: self
333                .write
334                .checked_add(other.write)
335                .ok_or(FaultPolicyError::SkipCountOverflow)?,
336        };
337        next.checked_total()?;
338        Ok(next)
339    }
340
341    /// Returns the counts after one committed skip in `phase`.
342    ///
343    /// # Errors
344    ///
345    /// Returns [`FaultPolicyError::PhaseNotSkippable`] for a phase that cannot
346    /// commit a skip, and [`FaultPolicyError::SkipCountOverflow`] instead of
347    /// wrapping.
348    pub fn checked_increment(self, phase: FaultPhase) -> Result<Self, FaultPolicyError> {
349        let mut next = self;
350        let counter = match phase {
351            FaultPhase::Read => &mut next.read,
352            FaultPhase::Process => &mut next.process,
353            FaultPhase::Write => &mut next.write,
354            other => return Err(FaultPolicyError::PhaseNotSkippable { phase: other }),
355        };
356        *counter = counter
357            .checked_add(1)
358            .ok_or(FaultPolicyError::SkipCountOverflow)?;
359        next.checked_total()?;
360        Ok(next)
361    }
362}
363
364/// The complete framework-owned classification input for one fault.
365///
366/// The descriptor deliberately excludes error text, source chains, item
367/// values, parameters, context values, and component-private state.
368#[derive(Clone, Copy, Debug, Eq, PartialEq)]
369pub struct FaultDescriptor {
370    phase: FaultPhase,
371    summary: FailureSummary,
372    retry_ordinal: RetryOrdinal,
373    committed_skips: SkipCounts,
374    transaction_open: bool,
375    delivery_mode: ChunkDeliveryMode,
376}
377
378impl FaultDescriptor {
379    /// Constructs the bounded classification input.
380    #[must_use]
381    pub const fn new(
382        phase: FaultPhase,
383        summary: FailureSummary,
384        retry_ordinal: RetryOrdinal,
385        committed_skips: SkipCounts,
386        transaction_open: bool,
387        delivery_mode: ChunkDeliveryMode,
388    ) -> Self {
389        Self {
390            phase,
391            summary,
392            retry_ordinal,
393            committed_skips,
394            transaction_open,
395            delivery_mode,
396        }
397    }
398
399    /// Returns the framework phase that produced the fault.
400    #[must_use]
401    pub const fn phase(self) -> FaultPhase {
402        self.phase
403    }
404
405    /// Returns the redacted failure summary.
406    #[must_use]
407    pub const fn summary(self) -> FailureSummary {
408        self.summary
409    }
410
411    /// Returns the stable failure category.
412    #[must_use]
413    pub const fn category(self) -> FailureCategory {
414        self.summary.category()
415    }
416
417    /// Returns the opaque diagnostic correlation identifier.
418    #[must_use]
419    pub const fn failure_id(self) -> FailureId {
420        self.summary.failure_id()
421    }
422
423    /// Returns the ordinal of the invocation that failed.
424    #[must_use]
425    pub const fn retry_ordinal(self) -> RetryOrdinal {
426        self.retry_ordinal
427    }
428
429    /// Returns the durable committed skip counts inherited by this attempt.
430    #[must_use]
431    pub const fn committed_skips(self) -> SkipCounts {
432        self.committed_skips
433    }
434
435    /// Returns whether a chunk transaction was open when the fault occurred.
436    #[must_use]
437    pub const fn is_transaction_open(self) -> bool {
438        self.transaction_open
439    }
440
441    /// Returns the delivery mode declared by the step definition.
442    #[must_use]
443    pub const fn delivery_mode(self) -> ChunkDeliveryMode {
444        self.delivery_mode
445    }
446}
447
448/// The deterministic backoff family selected by a definition.
449#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
450#[non_exhaustive]
451pub enum BackoffKind {
452    /// Retry immediately.
453    None,
454    /// Wait the same delay before every retry.
455    Fixed,
456    /// Multiply the initial delay by an integer factor, capped at a maximum.
457    Exponential,
458}
459
460impl BackoffKind {
461    /// Returns the stable low-cardinality telemetry name.
462    #[must_use]
463    pub const fn as_str(self) -> &'static str {
464        match self {
465            Self::None => "none",
466            Self::Fixed => "fixed",
467            Self::Exponential => "exponential",
468        }
469    }
470}
471
472impl fmt::Display for BackoffKind {
473    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
474        formatter.write_str(self.as_str())
475    }
476}
477
478/// A deterministic, jitter-free backoff schedule.
479///
480/// Every delay is derived only from the fingerprinted policy and the retry
481/// ordinal, so a restart reproduces the same schedule.
482#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
483pub struct BackoffPolicy {
484    kind: BackoffKind,
485    initial: Duration,
486    multiplier: u32,
487    maximum: Duration,
488}
489
490impl BackoffPolicy {
491    /// Returns the immediate-retry schedule.
492    #[must_use]
493    pub const fn none() -> Self {
494        Self {
495            kind: BackoffKind::None,
496            initial: Duration::ZERO,
497            multiplier: 1,
498            maximum: Duration::ZERO,
499        }
500    }
501
502    /// Returns a constant-delay schedule.
503    ///
504    /// # Errors
505    ///
506    /// Returns [`FaultPolicyError::BackoffDelayTooLong`] above 24 hours.
507    pub fn fixed(delay: Duration) -> Result<Self, FaultPolicyError> {
508        check_delay(delay)?;
509        Ok(Self {
510            kind: BackoffKind::Fixed,
511            initial: delay,
512            multiplier: 1,
513            maximum: delay,
514        })
515    }
516
517    /// Returns an integer exponential schedule capped at `maximum`.
518    ///
519    /// # Errors
520    ///
521    /// Rejects a zero multiplier, a delay above 24 hours, and a maximum below
522    /// the initial delay.
523    pub fn exponential(
524        initial: Duration,
525        multiplier: u32,
526        maximum: Duration,
527    ) -> Result<Self, FaultPolicyError> {
528        check_delay(initial)?;
529        check_delay(maximum)?;
530        if multiplier == 0 {
531            return Err(FaultPolicyError::ZeroBackoffMultiplier);
532        }
533        if maximum < initial {
534            return Err(FaultPolicyError::BackoffMaximumBelowInitial);
535        }
536        Ok(Self {
537            kind: BackoffKind::Exponential,
538            initial,
539            multiplier,
540            maximum,
541        })
542    }
543
544    /// Returns the selected backoff family.
545    #[must_use]
546    pub const fn kind(self) -> BackoffKind {
547        self.kind
548    }
549
550    /// Returns the first-retry delay.
551    #[must_use]
552    pub const fn initial(self) -> Duration {
553        self.initial
554    }
555
556    /// Returns the integer growth factor.
557    #[must_use]
558    pub const fn multiplier(self) -> u32 {
559        self.multiplier
560    }
561
562    /// Returns the schedule ceiling.
563    #[must_use]
564    pub const fn maximum(self) -> Duration {
565        self.maximum
566    }
567
568    /// Returns the delay that precedes the retry identified by `ordinal`.
569    ///
570    /// The initial call never waits. Arithmetic is checked and capped at the
571    /// configured maximum, so a large ordinal cannot overflow or exceed the
572    /// declared bound.
573    #[must_use]
574    pub fn delay_for(self, ordinal: RetryOrdinal) -> Duration {
575        if ordinal.is_initial() {
576            return Duration::ZERO;
577        }
578        match self.kind {
579            BackoffKind::None => Duration::ZERO,
580            BackoffKind::Fixed => self.initial,
581            BackoffKind::Exponential => self.exponential_delay(ordinal.get()),
582        }
583    }
584
585    fn exponential_delay(self, ordinal: u32) -> Duration {
586        let maximum_nanos = self.maximum.as_nanos();
587        let mut nanos = self.initial.as_nanos();
588        if nanos >= maximum_nanos {
589            return self.maximum;
590        }
591        if self.multiplier > 1 {
592            let factor = u128::from(self.multiplier);
593            for _ in 1..ordinal {
594                nanos = nanos.saturating_mul(factor);
595                if nanos >= maximum_nanos {
596                    return self.maximum;
597                }
598            }
599        }
600        u64::try_from(nanos).map_or(self.maximum, Duration::from_nanos)
601    }
602}
603
604fn check_delay(delay: Duration) -> Result<(), FaultPolicyError> {
605    if delay > MAX_BACKOFF {
606        return Err(FaultPolicyError::BackoffDelayTooLong {
607            max_seconds: MAX_BACKOFF.as_secs(),
608        });
609    }
610    Ok(())
611}
612
613/// How a failed unit of work is separated from committed work.
614#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
615#[non_exhaustive]
616pub enum RollbackDisposition {
617    /// Roll back the open transaction before the skip is recorded.
618    Rollback,
619    /// Commit the remaining successful work and the skip atomically.
620    ///
621    /// This narrows Spring's no-rollback behavior: the skip is still counted
622    /// and still invokes skip listeners, so an item is never silently dropped.
623    CommitSafeSkip,
624}
625
626impl RollbackDisposition {
627    /// Returns the stable, low-cardinality manifest and telemetry name.
628    #[must_use]
629    pub const fn as_str(self) -> &'static str {
630        match self {
631            Self::Rollback => "rollback",
632            Self::CommitSafeSkip => "commit_safe_skip",
633        }
634    }
635}
636
637/// The action a classifier rule declares for one phase and category.
638#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
639pub struct FaultAction {
640    retryable: bool,
641    skip: Option<RollbackDisposition>,
642}
643
644impl FaultAction {
645    /// Fails the step after a known rollback.
646    #[must_use]
647    pub const fn fail() -> Self {
648        Self {
649            retryable: false,
650            skip: None,
651        }
652    }
653
654    /// Retries within the configured limit and then fails.
655    #[must_use]
656    pub const fn retry() -> Self {
657        Self {
658            retryable: true,
659            skip: None,
660        }
661    }
662
663    /// Skips without retrying.
664    #[must_use]
665    pub const fn skip(disposition: RollbackDisposition) -> Self {
666        Self {
667            retryable: false,
668            skip: Some(disposition),
669        }
670    }
671
672    /// Retries within the configured limit and skips after exhaustion.
673    #[must_use]
674    pub const fn retry_then_skip(disposition: RollbackDisposition) -> Self {
675        Self {
676            retryable: true,
677            skip: Some(disposition),
678        }
679    }
680
681    /// Returns whether the rule accepts a retry.
682    #[must_use]
683    pub const fn is_retryable(self) -> bool {
684        self.retryable
685    }
686
687    /// Returns the accepted skip disposition, when the rule accepts a skip.
688    #[must_use]
689    pub const fn skip_disposition(self) -> Option<RollbackDisposition> {
690        self.skip
691    }
692}
693
694/// One ordered classifier rule for an exact phase and category.
695#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
696pub struct FaultRule {
697    phase: FaultPhase,
698    category: FailureCategory,
699    action: FaultAction,
700}
701
702impl FaultRule {
703    /// Validates and constructs one classification rule.
704    ///
705    /// # Errors
706    ///
707    /// Rejects a phase or category that fails closed in M3, and a commit-safe
708    /// skip for a phase that may already have produced an external effect.
709    pub fn new(
710        phase: FaultPhase,
711        category: FailureCategory,
712        action: FaultAction,
713    ) -> Result<Self, FaultPolicyError> {
714        if !phase.is_policy_eligible() || !category.is_policy_eligible() {
715            return Err(FaultPolicyError::NotPolicyEligible { phase, category });
716        }
717        if action.skip.is_some() && !phase.is_skippable() {
718            return Err(FaultPolicyError::PhaseNotSkippable { phase });
719        }
720        if action.skip == Some(RollbackDisposition::CommitSafeSkip)
721            && !phase.allows_commit_safe_skip()
722        {
723            return Err(FaultPolicyError::CommitSafeSkipPhase { phase });
724        }
725        Ok(Self {
726            phase,
727            category,
728            action,
729        })
730    }
731
732    /// Returns the governed phase.
733    #[must_use]
734    pub const fn phase(self) -> FaultPhase {
735        self.phase
736    }
737
738    /// Returns the governed category.
739    #[must_use]
740    pub const fn category(self) -> FailureCategory {
741        self.category
742    }
743
744    /// Returns the declared action.
745    #[must_use]
746    pub const fn action(self) -> FaultAction {
747        self.action
748    }
749}
750
751/// A bounded, order-independent classifier over phases and categories.
752///
753/// The revision token and the ordered rules are definition-fingerprint input.
754/// Rules address exactly one phase and category, so no outcome depends on
755/// registration order.
756#[derive(Clone, Debug, Eq, PartialEq)]
757pub struct FaultClassifier {
758    revision: ClassifierRevision,
759    rules: Box<[FaultRule]>,
760}
761
762impl FaultClassifier {
763    /// Validates and constructs a classifier.
764    ///
765    /// Each rule addresses exactly one phase and category, so the accepted
766    /// rules are bounded by that finite product.
767    ///
768    /// # Errors
769    ///
770    /// Rejects any repeated phase and category pair.
771    pub fn new(
772        revision: ClassifierRevision,
773        rules: impl IntoIterator<Item = FaultRule>,
774    ) -> Result<Self, FaultPolicyError> {
775        let mut accepted: Vec<FaultRule> = Vec::new();
776        for rule in rules {
777            if accepted
778                .iter()
779                .any(|existing| existing.phase == rule.phase && existing.category == rule.category)
780            {
781                return Err(FaultPolicyError::DuplicateRule {
782                    phase: rule.phase,
783                    category: rule.category,
784                });
785            }
786            accepted.push(rule);
787        }
788        Ok(Self {
789            revision,
790            rules: accepted.into_boxed_slice(),
791        })
792    }
793
794    /// Borrows the bounded revision token.
795    #[must_use]
796    pub const fn revision(&self) -> &ClassifierRevision {
797        &self.revision
798    }
799
800    /// Borrows the rules in registration order.
801    #[must_use]
802    pub fn rules(&self) -> &[FaultRule] {
803        &self.rules
804    }
805
806    /// Returns the action for one phase and category.
807    ///
808    /// An unmatched fault has no action and therefore fails closed.
809    #[must_use]
810    pub fn action_for(&self, phase: FaultPhase, category: FailureCategory) -> Option<FaultAction> {
811        self.rules
812            .iter()
813            .find(|rule| rule.phase == phase && rule.category == category)
814            .map(|rule| rule.action)
815    }
816}
817
818/// Framework evidence about one failed unit of work.
819///
820/// The runtime proves these properties before a policy may accept a skip. The
821/// values describe framework bookkeeping only; they carry no item value.
822#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
823pub struct FaultEvidence {
824    located: bool,
825    known_rollback: bool,
826    forward_checkpoint_proof: bool,
827}
828
829impl FaultEvidence {
830    /// Evidence for a fault whose failed unit is not located.
831    pub const NONE: Self = Self {
832        located: false,
833        known_rollback: false,
834        forward_checkpoint_proof: false,
835    };
836
837    /// Constructs complete skip evidence.
838    #[must_use]
839    pub const fn new(located: bool, known_rollback: bool, forward_checkpoint_proof: bool) -> Self {
840        Self {
841            located,
842            known_rollback,
843            forward_checkpoint_proof,
844        }
845    }
846
847    /// Records that exactly one failed input or output ordinal is identified.
848    #[must_use]
849    pub const fn with_located(mut self, located: bool) -> Self {
850        self.located = located;
851        self
852    }
853
854    /// Records that the failed work left no visible external effect.
855    #[must_use]
856    pub const fn with_known_rollback(mut self, known_rollback: bool) -> Self {
857        self.known_rollback = known_rollback;
858        self
859    }
860
861    /// Records that the reader proved its checkpoint moved past the input.
862    #[must_use]
863    pub const fn with_forward_checkpoint_proof(mut self, proof: bool) -> Self {
864        self.forward_checkpoint_proof = proof;
865        self
866    }
867
868    /// Returns whether exactly one failed unit is identified.
869    #[must_use]
870    pub const fn is_located(self) -> bool {
871        self.located
872    }
873
874    /// Returns whether the failed work is known to have been rolled back.
875    #[must_use]
876    pub const fn is_known_rollback(self) -> bool {
877        self.known_rollback
878    }
879
880    /// Returns whether forward checkpoint progress is proven.
881    #[must_use]
882    pub const fn has_forward_checkpoint_proof(self) -> bool {
883        self.forward_checkpoint_proof
884    }
885}
886
887/// The authoritative policy outcome for one fault.
888#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
889#[non_exhaustive]
890pub enum FaultDecision {
891    /// Roll back, reserve `ordinal` durably, wait `delay`, then re-invoke.
892    Retry {
893        /// The retry ordinal to reserve.
894        ordinal: RetryOrdinal,
895        /// The deterministic delay preceding re-invocation.
896        delay: Duration,
897    },
898    /// Record one committed skip with the accepted disposition.
899    Skip {
900        /// How the failed unit is separated from committed work.
901        disposition: RollbackDisposition,
902    },
903    /// Roll back and fail the step.
904    FailAndRollback,
905    /// The commit outcome is unknown and must never be guessed.
906    Unknown,
907    /// Cooperative stop governs the outcome.
908    Stop,
909}
910
911impl FaultDecision {
912    /// Returns whether the decision re-invokes the failed component.
913    #[must_use]
914    pub const fn is_retry(self) -> bool {
915        matches!(self, Self::Retry { .. })
916    }
917
918    /// Returns the accepted skip disposition, when the decision skips.
919    #[must_use]
920    pub const fn skip_disposition(self) -> Option<RollbackDisposition> {
921        match self {
922            Self::Skip { disposition } => Some(disposition),
923            _ => None,
924        }
925    }
926}
927
928/// The validated retry, backoff, skip, and rollback policy for one step.
929///
930/// The runnable example lives in the `oxide-batch` facade documentation,
931/// because the supported import path is `oxide_batch`.
932#[derive(Clone, Debug, Eq, PartialEq)]
933pub struct FaultPolicy {
934    classifier: FaultClassifier,
935    retry_limit: RetryLimit,
936    retry_state_limit: RetryStateLimit,
937    skip_limit: SkipLimit,
938    backoff: BackoffPolicy,
939}
940
941impl FaultPolicy {
942    /// Validates and constructs the step policy.
943    ///
944    /// # Errors
945    ///
946    /// Rejects a retry rule that no retry limit can ever satisfy, so a
947    /// statically impossible combination cannot reach the runtime.
948    pub fn new(
949        classifier: FaultClassifier,
950        retry_limit: RetryLimit,
951        retry_state_limit: RetryStateLimit,
952        skip_limit: SkipLimit,
953        backoff: BackoffPolicy,
954    ) -> Result<Self, FaultPolicyError> {
955        if retry_limit.is_none()
956            && let Some(rule) = classifier
957                .rules()
958                .iter()
959                .find(|rule| rule.action().is_retryable())
960        {
961            return Err(FaultPolicyError::UnreachableRetryRule {
962                phase: rule.phase(),
963                category: rule.category(),
964            });
965        }
966        Ok(Self {
967            classifier,
968            retry_limit,
969            retry_state_limit,
970            skip_limit,
971            backoff,
972        })
973    }
974
975    /// Borrows the classifier.
976    #[must_use]
977    pub const fn classifier(&self) -> &FaultClassifier {
978        &self.classifier
979    }
980
981    /// Returns the configured retry limit.
982    #[must_use]
983    pub const fn retry_limit(&self) -> RetryLimit {
984        self.retry_limit
985    }
986
987    /// Returns the unresolved retry-key capacity for one step.
988    #[must_use]
989    pub const fn retry_state_limit(&self) -> RetryStateLimit {
990        self.retry_state_limit
991    }
992
993    /// Returns the aggregate skip limit.
994    #[must_use]
995    pub const fn skip_limit(&self) -> SkipLimit {
996        self.skip_limit
997    }
998
999    /// Returns the backoff schedule.
1000    #[must_use]
1001    pub const fn backoff(&self) -> BackoffPolicy {
1002        self.backoff
1003    }
1004
1005    /// Returns whether any rule accepts a commit-safe skip.
1006    #[must_use]
1007    pub fn requires_commit_safe_skip(&self) -> bool {
1008        self.classifier.rules().iter().any(|rule| {
1009            rule.action().skip_disposition() == Some(RollbackDisposition::CommitSafeSkip)
1010        })
1011    }
1012
1013    /// Verifies the selected resource can honour the policy before user work.
1014    ///
1015    /// # Errors
1016    ///
1017    /// Returns [`FaultPolicyError::CommitSafeSkipUnsupported`] when a rule
1018    /// accepts a commit-safe skip that the transaction capability cannot
1019    /// commit atomically.
1020    pub fn validate_capabilities(
1021        &self,
1022        supports_atomic_skip: bool,
1023    ) -> Result<(), FaultPolicyError> {
1024        if self.requires_commit_safe_skip() && !supports_atomic_skip {
1025            return Err(FaultPolicyError::CommitSafeSkipUnsupported);
1026        }
1027        Ok(())
1028    }
1029
1030    /// Returns the authoritative decision for one fault.
1031    ///
1032    /// The decision is a pure function of the policy, the framework-owned
1033    /// descriptor, and framework evidence, so it is reproducible after a
1034    /// restart.
1035    #[must_use]
1036    pub fn decide(&self, fault: &FaultDescriptor, evidence: FaultEvidence) -> FaultDecision {
1037        let category = fault.category();
1038        if category == FailureCategory::UnknownCommit {
1039            return FaultDecision::Unknown;
1040        }
1041        if category == FailureCategory::Cancelled {
1042            return FaultDecision::Stop;
1043        }
1044        let phase = fault.phase();
1045        if !phase.is_policy_eligible() || !category.is_policy_eligible() {
1046            return FaultDecision::FailAndRollback;
1047        }
1048        let Some(action) = self.classifier.action_for(phase, category) else {
1049            return FaultDecision::FailAndRollback;
1050        };
1051        if action.is_retryable()
1052            && let Ok(next) = fault.retry_ordinal().checked_next()
1053            && self.retry_limit.permits(next)
1054        {
1055            return FaultDecision::Retry {
1056                ordinal: next,
1057                delay: self.backoff.delay_for(next),
1058            };
1059        }
1060        match action.skip_disposition() {
1061            Some(disposition) => self.decide_skip(fault, disposition, evidence),
1062            None => FaultDecision::FailAndRollback,
1063        }
1064    }
1065
1066    fn decide_skip(
1067        &self,
1068        fault: &FaultDescriptor,
1069        disposition: RollbackDisposition,
1070        evidence: FaultEvidence,
1071    ) -> FaultDecision {
1072        let phase = fault.phase();
1073        if !evidence.is_located() {
1074            return FaultDecision::FailAndRollback;
1075        }
1076        let phase_evidence = match phase {
1077            FaultPhase::Read => evidence.has_forward_checkpoint_proof(),
1078            FaultPhase::Process => true,
1079            FaultPhase::Write => evidence.is_known_rollback(),
1080            _ => false,
1081        };
1082        if !phase_evidence {
1083            return FaultDecision::FailAndRollback;
1084        }
1085        if disposition == RollbackDisposition::CommitSafeSkip
1086            && !(phase.allows_commit_safe_skip()
1087                && evidence.is_known_rollback()
1088                && evidence.has_forward_checkpoint_proof())
1089        {
1090            return FaultDecision::FailAndRollback;
1091        }
1092        match fault.committed_skips().checked_increment(phase) {
1093            Ok(next) => match next.checked_total() {
1094                Ok(total) if total <= self.skip_limit.get() => FaultDecision::Skip { disposition },
1095                _ => FaultDecision::FailAndRollback,
1096            },
1097            Err(_) => FaultDecision::FailAndRollback,
1098        }
1099    }
1100}
1101
1102/// A value-redacted fault-policy validation or arithmetic failure.
1103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1104#[non_exhaustive]
1105pub enum FaultPolicyError {
1106    /// A retry limit exceeded the bounded representation.
1107    RetryLimitOutOfRange {
1108        /// The largest accepted limit.
1109        max: u32,
1110    },
1111    /// A retry ordinal exceeded the bounded representation.
1112    RetryOrdinalOutOfRange {
1113        /// The largest accepted ordinal.
1114        max: u32,
1115    },
1116    /// The unresolved retry-key capacity was outside its bound.
1117    RetryStateLimitOutOfRange {
1118        /// The smallest accepted capacity.
1119        min: u32,
1120        /// The largest accepted capacity.
1121        max: u32,
1122    },
1123    /// A backoff delay exceeded the accepted maximum.
1124    BackoffDelayTooLong {
1125        /// The largest accepted delay in seconds.
1126        max_seconds: u64,
1127    },
1128    /// An exponential schedule used a zero multiplier.
1129    ZeroBackoffMultiplier,
1130    /// An exponential ceiling was below its initial delay.
1131    BackoffMaximumBelowInitial,
1132    /// A rule addressed a phase or category that fails closed in M3.
1133    NotPolicyEligible {
1134        /// The rejected phase.
1135        phase: FaultPhase,
1136        /// The rejected category.
1137        category: FailureCategory,
1138    },
1139    /// A rule accepted a skip for a phase that cannot commit one.
1140    PhaseNotSkippable {
1141        /// The rejected phase.
1142        phase: FaultPhase,
1143    },
1144    /// A rule accepted a commit-safe skip after a possible external effect.
1145    CommitSafeSkipPhase {
1146        /// The rejected phase.
1147        phase: FaultPhase,
1148    },
1149    /// The selected resource cannot commit a skip atomically.
1150    CommitSafeSkipUnsupported,
1151    /// Two rules addressed the same phase and category.
1152    DuplicateRule {
1153        /// The repeated phase.
1154        phase: FaultPhase,
1155        /// The repeated category.
1156        category: FailureCategory,
1157    },
1158    /// A retry rule could never be satisfied by the configured retry limit.
1159    UnreachableRetryRule {
1160        /// The affected phase.
1161        phase: FaultPhase,
1162        /// The affected category.
1163        category: FailureCategory,
1164    },
1165    /// Checked skip-count arithmetic rejected the update.
1166    SkipCountOverflow,
1167}
1168
1169impl fmt::Display for FaultPolicyError {
1170    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1171        match self {
1172            Self::RetryLimitOutOfRange { max } => {
1173                write!(formatter, "retry limit exceeds {max}")
1174            }
1175            Self::RetryOrdinalOutOfRange { max } => {
1176                write!(formatter, "retry ordinal exceeds {max}")
1177            }
1178            Self::RetryStateLimitOutOfRange { min, max } => {
1179                write!(formatter, "retry state limit must be within {min}..={max}")
1180            }
1181            Self::BackoffDelayTooLong { max_seconds } => {
1182                write!(formatter, "backoff delay exceeds {max_seconds} seconds")
1183            }
1184            Self::ZeroBackoffMultiplier => {
1185                formatter.write_str("exponential backoff requires a nonzero multiplier")
1186            }
1187            Self::BackoffMaximumBelowInitial => {
1188                formatter.write_str("exponential backoff maximum is below its initial delay")
1189            }
1190            Self::NotPolicyEligible { phase, category } => write!(
1191                formatter,
1192                "{phase} {category:?} faults are never retried or skipped"
1193            ),
1194            Self::PhaseNotSkippable { phase } => {
1195                write!(formatter, "{phase} faults cannot commit a skip")
1196            }
1197            Self::CommitSafeSkipPhase { phase } => {
1198                write!(formatter, "{phase} faults cannot commit a skip safely")
1199            }
1200            Self::CommitSafeSkipUnsupported => {
1201                formatter.write_str("the selected resource cannot commit a skip atomically")
1202            }
1203            Self::DuplicateRule { phase, category } => write!(
1204                formatter,
1205                "duplicate classifier rule for {phase} {category:?}"
1206            ),
1207            Self::UnreachableRetryRule { phase, category } => write!(
1208                formatter,
1209                "{phase} {category:?} retry rule requires a nonzero retry limit"
1210            ),
1211            Self::SkipCountOverflow => formatter.write_str("skip counters overflowed"),
1212        }
1213    }
1214}
1215
1216impl Error for FaultPolicyError {}