use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum OperationBudgetEvidence {
Finite { configured_ms: u64 },
Unbounded,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OperationTerminalCause {
Succeeded,
Failed,
TimedOut,
Interrupted,
Cancelled,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct OperationTimingEvidence {
pub budget: OperationBudgetEvidence,
pub start_boundary: String,
pub elapsed_ms: u64,
pub terminal_cause: OperationTerminalCause,
}
pub struct OperationBound(BoundKind);
enum BoundKind {
Finite {
started_at: Instant,
budget: Duration,
},
Unbounded {
started_at: Instant,
},
}
pub struct AttemptBound(BoundKind);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Remaining {
Finite(Duration),
Expired,
Unbounded,
}
impl OperationBound {
pub fn finite(budget: Duration) -> Self {
Self::finite_at(Instant::now(), budget)
}
pub fn unbounded() -> Self {
Self(BoundKind::Unbounded {
started_at: Instant::now(),
})
}
pub fn remaining(&self) -> Remaining {
self.remaining_at(Instant::now())
}
pub fn attempt(&self, cap: Option<Duration>) -> AttemptBound {
self.attempt_at(Instant::now(), cap)
}
pub fn is_expired(&self) -> bool {
matches!(self.remaining(), Remaining::Expired)
}
pub fn elapsed_ms(&self) -> u64 {
let started_at = match &self.0 {
BoundKind::Finite { started_at, .. } | BoundKind::Unbounded { started_at } => {
*started_at
}
};
duration_millis(started_at.elapsed())
}
pub fn timing(
&self,
start_boundary: &str,
terminal_cause: OperationTerminalCause,
) -> OperationTimingEvidence {
let (budget, started_at, configured) = match &self.0 {
BoundKind::Finite { started_at, budget } => (
OperationBudgetEvidence::Finite {
configured_ms: duration_millis(*budget),
},
*started_at,
Some(*budget),
),
BoundKind::Unbounded { started_at } => {
(OperationBudgetEvidence::Unbounded, *started_at, None)
}
};
let elapsed = started_at.elapsed();
let elapsed = if terminal_cause == OperationTerminalCause::TimedOut {
configured.map_or(elapsed, |configured| elapsed.min(configured))
} else {
elapsed
};
OperationTimingEvidence {
budget,
start_boundary: start_boundary.to_owned(),
elapsed_ms: duration_millis(elapsed),
terminal_cause,
}
}
fn finite_at(started_at: Instant, budget: Duration) -> Self {
Self(BoundKind::Finite { started_at, budget })
}
fn remaining_at(&self, now: Instant) -> Remaining {
remaining_at(&self.0, now)
}
fn attempt_at(&self, now: Instant, cap: Option<Duration>) -> AttemptBound {
let kind = match (self.remaining_at(now), cap) {
(Remaining::Finite(remaining), Some(cap)) => BoundKind::Finite {
started_at: now,
budget: remaining.min(cap),
},
(Remaining::Finite(remaining), None) => BoundKind::Finite {
started_at: now,
budget: remaining,
},
(Remaining::Unbounded, Some(cap)) => BoundKind::Finite {
started_at: now,
budget: cap,
},
(Remaining::Unbounded, None) => BoundKind::Unbounded { started_at: now },
(Remaining::Expired, _) => BoundKind::Finite {
started_at: now,
budget: Duration::ZERO,
},
};
AttemptBound(kind)
}
}
impl AttemptBound {
pub fn remaining(&self) -> Remaining {
self.remaining_at(Instant::now())
}
pub fn configured_ms(&self) -> Option<u64> {
match &self.0 {
BoundKind::Finite { budget, .. } => Some(duration_millis(*budget)),
BoundKind::Unbounded { .. } => None,
}
}
fn remaining_at(&self, now: Instant) -> Remaining {
remaining_at(&self.0, now)
}
}
fn remaining_at(kind: &BoundKind, now: Instant) -> Remaining {
match kind {
BoundKind::Finite { started_at, budget } => {
let elapsed = now.saturating_duration_since(*started_at);
if elapsed >= *budget {
Remaining::Expired
} else {
Remaining::Finite(*budget - elapsed)
}
}
BoundKind::Unbounded { .. } => Remaining::Unbounded,
}
}
#[must_use]
pub fn duration_millis(duration: Duration) -> u64 {
match u64::try_from(duration.as_millis()) {
Ok(milliseconds) => milliseconds,
Err(_) => u64::MAX,
}
}
#[cfg(test)]
mod tests {
use super::{OperationBound, OperationBudgetEvidence, OperationTerminalCause, Remaining};
use std::time::{Duration, Instant};
#[test]
fn sequential_attempts_consume_one_finite_owner_budget() {
let start = Instant::now();
let bound = OperationBound::finite_at(start, Duration::from_secs(10));
let first = bound.attempt_at(start + Duration::from_secs(2), Some(Duration::from_secs(6)));
assert_eq!(
first.remaining_at(start + Duration::from_secs(2)),
Remaining::Finite(Duration::from_secs(6))
);
assert_eq!(
first.remaining_at(start + Duration::from_secs(8)),
Remaining::Expired
);
let second = bound.attempt_at(start + Duration::from_secs(8), Some(Duration::from_secs(6)));
assert_eq!(
second.remaining_at(start + Duration::from_secs(8)),
Remaining::Finite(Duration::from_secs(2))
);
assert_eq!(
second.remaining_at(start + Duration::from_secs(10)),
Remaining::Expired
);
}
#[test]
fn subordinate_attempt_cap_does_not_classify_the_owner_as_expired() {
let start = Instant::now();
let bound = OperationBound::finite_at(start, Duration::from_secs(10));
let attempt = bound.attempt_at(start, Some(Duration::from_secs(2)));
assert_eq!(
attempt.remaining_at(start + Duration::from_secs(2)),
Remaining::Expired
);
assert_eq!(
bound.remaining_at(start + Duration::from_secs(2)),
Remaining::Finite(Duration::from_secs(8))
);
}
#[test]
fn unbounded_owner_retains_attempt_caps_without_acquiring_a_budget() {
let start = Instant::now();
let bound = OperationBound::unbounded();
assert_eq!(bound.remaining_at(start), Remaining::Unbounded);
let attempt = bound.attempt_at(start, Some(Duration::from_secs(2)));
assert_eq!(
attempt.remaining_at(start),
Remaining::Finite(Duration::from_secs(2))
);
assert_eq!(
bound.attempt_at(start, None).remaining_at(start),
Remaining::Unbounded
);
}
#[test]
fn terminal_evidence_distinguishes_finite_and_unbounded_owners() {
let start = Instant::now();
let finite = OperationBound::finite_at(start, Duration::from_secs(3));
let finite_evidence =
finite.timing("before-client-release", OperationTerminalCause::TimedOut);
assert_eq!(
finite_evidence.budget,
OperationBudgetEvidence::Finite {
configured_ms: 3_000,
}
);
assert_eq!(finite_evidence.start_boundary, "before-client-release");
assert_eq!(
finite_evidence.terminal_cause,
OperationTerminalCause::TimedOut
);
let unbounded = OperationBound::unbounded();
let unbounded_evidence =
unbounded.timing("before-readiness-wait", OperationTerminalCause::Interrupted);
assert_eq!(
unbounded_evidence.budget,
OperationBudgetEvidence::Unbounded
);
assert_eq!(
unbounded_evidence.terminal_cause,
OperationTerminalCause::Interrupted
);
}
}