Skip to main content

inferlab_runtime/
operation_bound.rs

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/// Durable evidence emitted by an operation owner when it accepts a terminal
22/// outcome. This is deliberately only a record shape: retries, lifecycle, and
23/// cleanup remain owned by their existing domains.
24#[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
33/// One elapsed-time authority for a concrete runtime operation.
34///
35/// The finite form retains only its start and total budget. Consumers can
36/// observe the remaining interval or derive a capped attempt, but cannot
37/// recover a duration from which to restart the operation clock.
38pub 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
50/// One attempt derived from an owning operation at the instant that attempt
51/// begins. Its optional cap and the owner's then-remaining time share one
52/// clock; work performed before the final wait cannot restart either value.
53pub 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    /// Preserve this attempt's original clock while passing it to a child
177    /// operation that consumes an `OperationBound`.
178    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}