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 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}