1use serde::{Deserialize, Serialize};
2use std::time::{Duration, Instant};
3
4#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
6pub enum OperationBudgetEvidence {
7 Finite { configured_ms: u64 },
8 Unbounded,
9}
10
11#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum OperationTerminalCause {
14 Succeeded,
15 Failed,
16 TimedOut,
17 Interrupted,
18 Cancelled,
19}
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
25#[serde(deny_unknown_fields)]
26pub struct OperationTimingEvidence {
27 pub budget: OperationBudgetEvidence,
28 pub start_boundary: String,
29 pub elapsed_ms: u64,
30 pub terminal_cause: OperationTerminalCause,
31}
32
33pub struct OperationBound(BoundKind);
39
40enum BoundKind {
41 Finite {
42 started_at: Instant,
43 budget: Duration,
44 },
45 Unbounded {
46 started_at: Instant,
47 },
48}
49
50pub struct AttemptBound(BoundKind);
54
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
56pub enum Remaining {
57 Finite(Duration),
58 Expired,
59 Unbounded,
60}
61
62impl OperationBound {
63 pub fn finite(budget: Duration) -> Self {
64 Self::finite_at(Instant::now(), budget)
65 }
66
67 pub fn unbounded() -> Self {
68 Self(BoundKind::Unbounded {
69 started_at: Instant::now(),
70 })
71 }
72
73 pub fn remaining(&self) -> Remaining {
74 self.remaining_at(Instant::now())
75 }
76
77 pub fn attempt(&self, cap: Option<Duration>) -> AttemptBound {
78 self.attempt_at(Instant::now(), cap)
79 }
80
81 pub fn is_expired(&self) -> bool {
82 matches!(self.remaining(), Remaining::Expired)
83 }
84
85 pub fn elapsed_ms(&self) -> u64 {
86 let started_at = match &self.0 {
87 BoundKind::Finite { started_at, .. } | BoundKind::Unbounded { started_at } => {
88 *started_at
89 }
90 };
91 duration_millis(started_at.elapsed())
92 }
93
94 pub fn timing(
95 &self,
96 start_boundary: &str,
97 terminal_cause: OperationTerminalCause,
98 ) -> OperationTimingEvidence {
99 let (budget, started_at, configured) = match &self.0 {
100 BoundKind::Finite { started_at, budget } => (
101 OperationBudgetEvidence::Finite {
102 configured_ms: duration_millis(*budget),
103 },
104 *started_at,
105 Some(*budget),
106 ),
107 BoundKind::Unbounded { started_at } => {
108 (OperationBudgetEvidence::Unbounded, *started_at, None)
109 }
110 };
111 let elapsed = started_at.elapsed();
112 let elapsed = if terminal_cause == OperationTerminalCause::TimedOut {
113 configured.map_or(elapsed, |configured| elapsed.min(configured))
114 } else {
115 elapsed
116 };
117 OperationTimingEvidence {
118 budget,
119 start_boundary: start_boundary.to_owned(),
120 elapsed_ms: duration_millis(elapsed),
121 terminal_cause,
122 }
123 }
124
125 fn finite_at(started_at: Instant, budget: Duration) -> Self {
126 Self(BoundKind::Finite { started_at, budget })
127 }
128
129 fn remaining_at(&self, now: Instant) -> Remaining {
130 remaining_at(&self.0, now)
131 }
132
133 fn attempt_at(&self, now: Instant, cap: Option<Duration>) -> AttemptBound {
134 let kind = match (self.remaining_at(now), cap) {
135 (Remaining::Finite(remaining), Some(cap)) => BoundKind::Finite {
136 started_at: now,
137 budget: remaining.min(cap),
138 },
139 (Remaining::Finite(remaining), None) => BoundKind::Finite {
140 started_at: now,
141 budget: remaining,
142 },
143 (Remaining::Unbounded, Some(cap)) => BoundKind::Finite {
144 started_at: now,
145 budget: cap,
146 },
147 (Remaining::Unbounded, None) => BoundKind::Unbounded { started_at: now },
148 (Remaining::Expired, _) => BoundKind::Finite {
149 started_at: now,
150 budget: Duration::ZERO,
151 },
152 };
153 AttemptBound(kind)
154 }
155}
156
157impl AttemptBound {
158 pub fn remaining(&self) -> Remaining {
159 self.remaining_at(Instant::now())
160 }
161
162 pub fn configured_ms(&self) -> Option<u64> {
163 match &self.0 {
164 BoundKind::Finite { budget, .. } => Some(duration_millis(*budget)),
165 BoundKind::Unbounded { .. } => None,
166 }
167 }
168
169 fn remaining_at(&self, now: Instant) -> Remaining {
170 remaining_at(&self.0, now)
171 }
172}
173
174fn remaining_at(kind: &BoundKind, now: Instant) -> Remaining {
175 match kind {
176 BoundKind::Finite { started_at, budget } => {
177 let elapsed = now.saturating_duration_since(*started_at);
178 if elapsed >= *budget {
179 Remaining::Expired
180 } else {
181 Remaining::Finite(*budget - elapsed)
182 }
183 }
184 BoundKind::Unbounded { .. } => Remaining::Unbounded,
185 }
186}
187
188#[must_use]
189pub fn duration_millis(duration: Duration) -> u64 {
190 match u64::try_from(duration.as_millis()) {
191 Ok(milliseconds) => milliseconds,
192 Err(_) => u64::MAX,
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::{OperationBound, OperationBudgetEvidence, OperationTerminalCause, Remaining};
199 use std::time::{Duration, Instant};
200
201 #[test]
202 fn sequential_attempts_consume_one_finite_owner_budget() {
203 let start = Instant::now();
204 let bound = OperationBound::finite_at(start, Duration::from_secs(10));
205
206 let first = bound.attempt_at(start + Duration::from_secs(2), Some(Duration::from_secs(6)));
207 assert_eq!(
208 first.remaining_at(start + Duration::from_secs(2)),
209 Remaining::Finite(Duration::from_secs(6))
210 );
211 assert_eq!(
212 first.remaining_at(start + Duration::from_secs(8)),
213 Remaining::Expired
214 );
215
216 let second = bound.attempt_at(start + Duration::from_secs(8), Some(Duration::from_secs(6)));
217 assert_eq!(
218 second.remaining_at(start + Duration::from_secs(8)),
219 Remaining::Finite(Duration::from_secs(2))
220 );
221 assert_eq!(
222 second.remaining_at(start + Duration::from_secs(10)),
223 Remaining::Expired
224 );
225 }
226
227 #[test]
228 fn subordinate_attempt_cap_does_not_classify_the_owner_as_expired() {
229 let start = Instant::now();
230 let bound = OperationBound::finite_at(start, Duration::from_secs(10));
231 let attempt = bound.attempt_at(start, Some(Duration::from_secs(2)));
232
233 assert_eq!(
234 attempt.remaining_at(start + Duration::from_secs(2)),
235 Remaining::Expired
236 );
237 assert_eq!(
238 bound.remaining_at(start + Duration::from_secs(2)),
239 Remaining::Finite(Duration::from_secs(8))
240 );
241 }
242
243 #[test]
244 fn unbounded_owner_retains_attempt_caps_without_acquiring_a_budget() {
245 let start = Instant::now();
246 let bound = OperationBound::unbounded();
247
248 assert_eq!(bound.remaining_at(start), Remaining::Unbounded);
249 let attempt = bound.attempt_at(start, Some(Duration::from_secs(2)));
250 assert_eq!(
251 attempt.remaining_at(start),
252 Remaining::Finite(Duration::from_secs(2))
253 );
254 assert_eq!(
255 bound.attempt_at(start, None).remaining_at(start),
256 Remaining::Unbounded
257 );
258 }
259
260 #[test]
261 fn terminal_evidence_distinguishes_finite_and_unbounded_owners() {
262 let start = Instant::now();
263 let finite = OperationBound::finite_at(start, Duration::from_secs(3));
264 let finite_evidence =
265 finite.timing("before-client-release", OperationTerminalCause::TimedOut);
266 assert_eq!(
267 finite_evidence.budget,
268 OperationBudgetEvidence::Finite {
269 configured_ms: 3_000,
270 }
271 );
272 assert_eq!(finite_evidence.start_boundary, "before-client-release");
273 assert_eq!(
274 finite_evidence.terminal_cause,
275 OperationTerminalCause::TimedOut
276 );
277
278 let unbounded = OperationBound::unbounded();
279 let unbounded_evidence =
280 unbounded.timing("before-readiness-wait", OperationTerminalCause::Interrupted);
281 assert_eq!(
282 unbounded_evidence.budget,
283 OperationBudgetEvidence::Unbounded
284 );
285 assert_eq!(
286 unbounded_evidence.terminal_cause,
287 OperationTerminalCause::Interrupted
288 );
289 }
290}