Skip to main content

harmos_domain/operations/common/
policy.rs

1/* ***
2 *
3 * Harmos
4 *
5 * SPDX-License-Identifier: Apache-2.0
6 *
7 * ***
8 */
9
10/* === Modules === */
11
12/* === Re-Exports === */
13
14/* === Imports === */
15
16// Standard Library Imports
17use std::time::Duration;
18// Third Party Imports
19use serde::{Deserialize, Serialize};
20// Local Imports
21
22/* === Constants === */
23
24/* === Macros === */
25
26/* === Type Aliases === */
27
28/* === Structs === */
29
30// The request half (`timeout`, `attempt_timeout`, `retries`) bounds one
31// request-scoped invocation. The resident half (`restart`) governs observers
32// and services, which run until stopped: for them `timeout` is the stop
33// deadline and request deadlines never apply.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct OperationPolicy {
37    pub timeout: Duration,
38    pub attempt_timeout: Duration,
39    pub retries: RetryPolicy,
40    pub restart: RestartPolicy,
41}
42
43/* === Enums === */
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case", deny_unknown_fields)]
47pub enum RetryPolicy {
48    Never,
49    OnTransient { max_retries: usize, backoff: Backoff },
50}
51
52// Environmental faults of a resident spend restart credit; a healthy run of
53// `sustained_health` refills it. `Never` deactivates on the first fault.
54#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case", deny_unknown_fields)]
56pub enum RestartPolicy {
57    Never,
58    OnTransient { credit: usize, sustained_health: Duration, backoff: Backoff },
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case", deny_unknown_fields)]
63pub enum Backoff {
64    Fixed { delay: Duration, jitter: Jitter },
65    Exponential { initial: Duration, maximum: Duration, jitter: Jitter },
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case", deny_unknown_fields)]
70pub enum Jitter {
71    None,
72    Deterministic,
73}
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum FailureKind {
78    Permanent,
79    Transient,
80    Ambiguous,
81    Unauthorized,
82    Invalid,
83    Cancelled,
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case", deny_unknown_fields)]
88pub enum ReplaySafety {
89    Unknown,
90    Idempotent,
91    Deduplicated,
92}
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
95pub enum PolicyError {
96    #[error("operation deadlines must be positive (overall {timeout:?}, attempt {attempt_timeout:?})")]
97    Deadline { timeout: Duration, attempt_timeout: Duration },
98
99    #[error("a retry policy needs a positive retry count; use Never for zero")]
100    EmptyRetries,
101
102    #[error("a restart policy needs positive credit and sustained health; use Never for zero")]
103    EmptyRestarts,
104
105    #[error("retry delays must be positive and ordered (initial {initial:?}, maximum {maximum:?})")]
106    Backoff { initial: Duration, maximum: Duration },
107}
108
109/* === Traits === */
110
111/* === Implementations === */
112
113impl OperationPolicy {
114    pub fn validate(self) -> Result<(), PolicyError> {
115        if self.timeout.is_zero() || self.attempt_timeout.is_zero() {
116            return Err(PolicyError::Deadline { timeout: self.timeout, attempt_timeout: self.attempt_timeout });
117        }
118
119        self.retries.validate()?;
120        self.restart.validate()
121    }
122}
123
124impl RestartPolicy {
125    pub fn validate(self) -> Result<(), PolicyError> {
126        match self {
127            Self::Never => Ok(()),
128            Self::OnTransient { credit, sustained_health, .. } if credit == 0 || sustained_health.is_zero() => {
129                Err(PolicyError::EmptyRestarts)
130            }
131            Self::OnTransient { backoff, .. } => backoff.validate(),
132        }
133    }
134
135    pub const fn credit(self) -> usize {
136        match self {
137            Self::Never => 0,
138            Self::OnTransient { credit, .. } => credit,
139        }
140    }
141}
142
143impl RetryPolicy {
144    pub fn validate(self) -> Result<(), PolicyError> {
145        match self {
146            Self::Never => Ok(()),
147            Self::OnTransient { max_retries: 0, .. } => Err(PolicyError::EmptyRetries),
148            Self::OnTransient { backoff, .. } => backoff.validate(),
149        }
150    }
151
152    pub fn retry_after(self, failed_attempt: usize, failure: FailureKind, replay: ReplaySafety, entropy: u64) -> Option<Duration> {
153        let Self::OnTransient { max_retries, backoff } = self else {
154            return None;
155        };
156        if self.validate().is_err() || failed_attempt >= max_retries {
157            return None;
158        }
159        if !matches!(failure, FailureKind::Transient) && !(failure == FailureKind::Ambiguous && replay != ReplaySafety::Unknown) {
160            return None;
161        }
162
163        backoff.delay(failed_attempt, entropy).ok()
164    }
165}
166
167impl Backoff {
168    pub fn validate(self) -> Result<(), PolicyError> {
169        let (initial, maximum) = match self {
170            Self::Fixed { delay, .. } => (delay, delay),
171            Self::Exponential { initial, maximum, .. } => (initial, maximum),
172        };
173        if initial.is_zero() || maximum < initial {
174            return Err(PolicyError::Backoff { initial, maximum });
175        }
176
177        Ok(())
178    }
179
180    pub fn delay(self, failed_attempt: usize, entropy: u64) -> Result<Duration, PolicyError> {
181        self.validate()?;
182
183        let (nominal, maximum, jitter) = match self {
184            Self::Fixed { delay, jitter } => (delay, delay.saturating_add(from_nanos(delay.as_nanos() / 4)), jitter),
185            Self::Exponential { initial, maximum, jitter } => {
186                let shift = u32::try_from(failed_attempt).unwrap_or(u32::MAX);
187                let nanos = if shift >= 128 || initial.as_nanos() > (u128::MAX >> shift) {
188                    maximum.as_nanos()
189                } else {
190                    (initial.as_nanos() << shift).min(maximum.as_nanos())
191                };
192
193                (from_nanos(nanos), maximum, jitter)
194            }
195        };
196        if jitter == Jitter::None {
197            return Ok(nominal.min(maximum));
198        }
199        let mixed = u128::from(entropy)
200            .wrapping_mul(0x9e37_79b9_7f4a_7c15)
201            .wrapping_add((failed_attempt as u128).wrapping_mul(17).wrapping_add(11));
202        let nanos = mixed % (nominal.as_nanos() / 4 + 1);
203
204        Ok(nominal.saturating_add(from_nanos(nanos)).min(maximum))
205    }
206}
207
208/* === Public Functions === */
209
210/* === Private Functions === */
211
212// Every caller bounds nanos by a representable Duration before this conversion.
213fn from_nanos(nanos: u128) -> Duration {
214    Duration::new((nanos / 1_000_000_000) as u64, (nanos % 1_000_000_000) as u32)
215}
216
217/* === Tests === */