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(crate) fn configured_ms(&self) -> Option<u64> {
95 match &self.0 {
96 BoundKind::Finite { budget, .. } => Some(duration_millis(*budget)),
97 BoundKind::Unbounded { .. } => None,
98 }
99 }
100
101 pub fn timing(
102 &self,
103 start_boundary: &str,
104 terminal_cause: OperationTerminalCause,
105 ) -> OperationTimingEvidence {
106 let (budget, started_at, configured) = match &self.0 {
107 BoundKind::Finite { started_at, budget } => (
108 OperationBudgetEvidence::Finite {
109 configured_ms: duration_millis(*budget),
110 },
111 *started_at,
112 Some(*budget),
113 ),
114 BoundKind::Unbounded { started_at } => {
115 (OperationBudgetEvidence::Unbounded, *started_at, None)
116 }
117 };
118 let elapsed = started_at.elapsed();
119 let elapsed = if terminal_cause == OperationTerminalCause::TimedOut {
120 configured.map_or(elapsed, |configured| elapsed.min(configured))
121 } else {
122 elapsed
123 };
124 OperationTimingEvidence {
125 budget,
126 start_boundary: start_boundary.to_owned(),
127 elapsed_ms: duration_millis(elapsed),
128 terminal_cause,
129 }
130 }
131
132 fn finite_at(started_at: Instant, budget: Duration) -> Self {
133 Self(BoundKind::Finite { started_at, budget })
134 }
135
136 fn remaining_at(&self, now: Instant) -> Remaining {
137 remaining_at(&self.0, now)
138 }
139
140 fn attempt_at(&self, now: Instant, cap: Option<Duration>) -> AttemptBound {
141 let kind = match (self.remaining_at(now), cap) {
142 (Remaining::Finite(remaining), Some(cap)) => BoundKind::Finite {
143 started_at: now,
144 budget: remaining.min(cap),
145 },
146 (Remaining::Finite(remaining), None) => BoundKind::Finite {
147 started_at: now,
148 budget: remaining,
149 },
150 (Remaining::Unbounded, Some(cap)) => BoundKind::Finite {
151 started_at: now,
152 budget: cap,
153 },
154 (Remaining::Unbounded, None) => BoundKind::Unbounded { started_at: now },
155 (Remaining::Expired, _) => BoundKind::Finite {
156 started_at: now,
157 budget: Duration::ZERO,
158 },
159 };
160 AttemptBound(kind)
161 }
162}
163
164impl AttemptBound {
165 pub fn remaining(&self) -> Remaining {
166 self.remaining_at(Instant::now())
167 }
168
169 pub fn configured_ms(&self) -> Option<u64> {
170 match &self.0 {
171 BoundKind::Finite { budget, .. } => Some(duration_millis(*budget)),
172 BoundKind::Unbounded { .. } => None,
173 }
174 }
175
176 pub fn into_operation_bound(self) -> OperationBound {
179 OperationBound(self.0)
180 }
181
182 fn remaining_at(&self, now: Instant) -> Remaining {
183 remaining_at(&self.0, now)
184 }
185}
186
187fn remaining_at(kind: &BoundKind, now: Instant) -> Remaining {
188 match kind {
189 BoundKind::Finite { started_at, budget } => {
190 let elapsed = now.saturating_duration_since(*started_at);
191 if elapsed >= *budget {
192 Remaining::Expired
193 } else {
194 Remaining::Finite(*budget - elapsed)
195 }
196 }
197 BoundKind::Unbounded { .. } => Remaining::Unbounded,
198 }
199}
200
201#[must_use]
202pub fn duration_millis(duration: Duration) -> u64 {
203 match u64::try_from(duration.as_millis()) {
204 Ok(milliseconds) => milliseconds,
205 Err(_) => u64::MAX,
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::{OperationBound, OperationBudgetEvidence, OperationTerminalCause, Remaining};
212 use std::time::{Duration, Instant};
213
214 #[test]
215 fn sequential_attempts_consume_one_finite_owner_budget() {
216 let start = Instant::now();
217 let bound = OperationBound::finite_at(start, Duration::from_secs(10));
218
219 let first = bound.attempt_at(start + Duration::from_secs(2), Some(Duration::from_secs(6)));
220 assert_eq!(
221 first.remaining_at(start + Duration::from_secs(2)),
222 Remaining::Finite(Duration::from_secs(6))
223 );
224 assert_eq!(
225 first.remaining_at(start + Duration::from_secs(8)),
226 Remaining::Expired
227 );
228
229 let second = bound.attempt_at(start + Duration::from_secs(8), Some(Duration::from_secs(6)));
230 assert_eq!(
231 second.remaining_at(start + Duration::from_secs(8)),
232 Remaining::Finite(Duration::from_secs(2))
233 );
234 assert_eq!(
235 second.remaining_at(start + Duration::from_secs(10)),
236 Remaining::Expired
237 );
238 }
239
240 #[test]
241 fn subordinate_attempt_cap_does_not_classify_the_owner_as_expired() {
242 let start = Instant::now();
243 let bound = OperationBound::finite_at(start, Duration::from_secs(10));
244 let attempt = bound.attempt_at(start, Some(Duration::from_secs(2)));
245
246 assert_eq!(
247 attempt.remaining_at(start + Duration::from_secs(2)),
248 Remaining::Expired
249 );
250 assert_eq!(
251 bound.remaining_at(start + Duration::from_secs(2)),
252 Remaining::Finite(Duration::from_secs(8))
253 );
254 }
255
256 #[test]
257 fn unbounded_owner_retains_attempt_caps_without_acquiring_a_budget() {
258 let start = Instant::now();
259 let bound = OperationBound::unbounded();
260
261 assert_eq!(bound.remaining_at(start), Remaining::Unbounded);
262 let attempt = bound.attempt_at(start, Some(Duration::from_secs(2)));
263 assert_eq!(
264 attempt.remaining_at(start),
265 Remaining::Finite(Duration::from_secs(2))
266 );
267 assert_eq!(
268 bound.attempt_at(start, None).remaining_at(start),
269 Remaining::Unbounded
270 );
271 }
272
273 #[test]
274 fn terminal_evidence_distinguishes_finite_and_unbounded_owners() {
275 let start = Instant::now();
276 let finite = OperationBound::finite_at(start, Duration::from_secs(3));
277 let finite_evidence =
278 finite.timing("before-client-release", OperationTerminalCause::TimedOut);
279 assert_eq!(
280 finite_evidence.budget,
281 OperationBudgetEvidence::Finite {
282 configured_ms: 3_000,
283 }
284 );
285 assert_eq!(finite_evidence.start_boundary, "before-client-release");
286 assert_eq!(
287 finite_evidence.terminal_cause,
288 OperationTerminalCause::TimedOut
289 );
290
291 let unbounded = OperationBound::unbounded();
292 let unbounded_evidence =
293 unbounded.timing("before-readiness-wait", OperationTerminalCause::Interrupted);
294 assert_eq!(
295 unbounded_evidence.budget,
296 OperationBudgetEvidence::Unbounded
297 );
298 assert_eq!(
299 unbounded_evidence.terminal_cause,
300 OperationTerminalCause::Interrupted
301 );
302 }
303}