Skip to main content

laminar_core/budget/
task_budget.rs

1//! Task budget tracking with automatic metrics on drop.
2
3use std::time::Instant;
4
5/// Tracks execution time budget for a task.
6///
7/// Created at task start, automatically records metrics on drop.
8/// Use for all Ring 0 operations and Ring 1 chunks.
9///
10/// # Example
11///
12/// ```rust,ignore
13/// use laminar_core::budget::TaskBudget;
14///
15/// fn process_event(event: &Event) {
16///     let _budget = TaskBudget::ring0_event();
17///     // Process event...
18///     // Metrics recorded automatically when _budget is dropped
19/// }
20/// ```
21///
22/// # Performance
23///
24/// The budget tracking overhead is designed to be < 10ns, consisting of:
25/// - `Instant::now()` on creation (~10-30ns depending on platform)
26/// - Elapsed time calculation on drop (~10-30ns)
27/// - Atomic counter increments for metrics (~10ns)
28///
29/// For Ring 0 hot paths where even this is too much, use `TaskBudget::ring0_event_untracked()`
30/// which skips metrics recording.
31#[derive(Debug)]
32pub struct TaskBudget {
33    /// When this task started
34    start: Instant,
35    /// Budget in nanoseconds
36    budget_ns: u64,
37    /// Task name for metrics
38    name: &'static str,
39    /// Ring for this task (0, 1, or 2)
40    ring: u8,
41    /// Whether to record metrics on drop
42    record_metrics: bool,
43}
44
45impl TaskBudget {
46    // Ring 0 budgets (microseconds or less)
47
48    /// Single event processing budget: 500ns
49    pub const RING0_EVENT_NS: u64 = 500;
50
51    /// Batch of events budget: 5μs (up to ~10 events)
52    pub const RING0_BATCH_NS: u64 = 5_000;
53
54    /// State lookup budget: 200ns
55    pub const RING0_LOOKUP_NS: u64 = 200;
56
57    /// Window trigger budget: 10μs
58    pub const RING0_WINDOW_NS: u64 = 10_000;
59
60    /// Iteration budget: 10μs (for one reactor poll cycle)
61    pub const RING0_ITERATION_NS: u64 = 10_000;
62
63    // Ring 1 budgets (milliseconds)
64
65    /// Background work chunk budget: 1ms
66    pub const RING1_CHUNK_NS: u64 = 1_000_000;
67
68    /// Checkpoint preparation budget: 10ms
69    pub const RING1_CHECKPOINT_NS: u64 = 10_000_000;
70
71    /// WAL flush budget: 100μs
72    pub const RING1_WAL_FLUSH_NS: u64 = 100_000;
73
74    /// Compaction chunk budget: 5ms
75    pub const RING1_COMPACTION_NS: u64 = 5_000_000;
76
77    // Ring 0 factory methods
78
79    /// Budget: 500ns
80    #[inline]
81    #[must_use]
82    pub fn ring0_event() -> Self {
83        Self {
84            start: Instant::now(),
85            budget_ns: Self::RING0_EVENT_NS,
86            name: "ring0_event",
87            ring: 0,
88            record_metrics: true,
89        }
90    }
91
92    /// Use this in extremely hot paths where even atomic counter
93    /// updates are too expensive.
94    #[inline]
95    #[must_use]
96    pub fn ring0_event_untracked() -> Self {
97        Self {
98            start: Instant::now(),
99            budget_ns: Self::RING0_EVENT_NS,
100            name: "ring0_event",
101            ring: 0,
102            record_metrics: false,
103        }
104    }
105
106    /// Budget: 5μs
107    #[inline]
108    #[must_use]
109    pub fn ring0_batch() -> Self {
110        Self {
111            start: Instant::now(),
112            budget_ns: Self::RING0_BATCH_NS,
113            name: "ring0_batch",
114            ring: 0,
115            record_metrics: true,
116        }
117    }
118
119    /// Budget: 200ns
120    #[inline]
121    #[must_use]
122    pub fn ring0_lookup() -> Self {
123        Self {
124            start: Instant::now(),
125            budget_ns: Self::RING0_LOOKUP_NS,
126            name: "ring0_lookup",
127            ring: 0,
128            record_metrics: true,
129        }
130    }
131
132    /// Budget: 10μs
133    #[inline]
134    #[must_use]
135    pub fn ring0_window() -> Self {
136        Self {
137            start: Instant::now(),
138            budget_ns: Self::RING0_WINDOW_NS,
139            name: "ring0_window",
140            ring: 0,
141            record_metrics: true,
142        }
143    }
144
145    /// Budget: 10μs
146    #[inline]
147    #[must_use]
148    pub fn ring0_iteration() -> Self {
149        Self {
150            start: Instant::now(),
151            budget_ns: Self::RING0_ITERATION_NS,
152            name: "ring0_iteration",
153            ring: 0,
154            record_metrics: true,
155        }
156    }
157
158    // Ring 1 factory methods
159
160    /// Budget: 1ms
161    #[inline]
162    #[must_use]
163    pub fn ring1_chunk() -> Self {
164        Self {
165            start: Instant::now(),
166            budget_ns: Self::RING1_CHUNK_NS,
167            name: "ring1_chunk",
168            ring: 1,
169            record_metrics: true,
170        }
171    }
172
173    /// Budget: 10ms
174    #[inline]
175    #[must_use]
176    pub fn ring1_checkpoint() -> Self {
177        Self {
178            start: Instant::now(),
179            budget_ns: Self::RING1_CHECKPOINT_NS,
180            name: "ring1_checkpoint",
181            ring: 1,
182            record_metrics: true,
183        }
184    }
185
186    /// Budget: 100μs
187    #[inline]
188    #[must_use]
189    pub fn ring1_wal_flush() -> Self {
190        Self {
191            start: Instant::now(),
192            budget_ns: Self::RING1_WAL_FLUSH_NS,
193            name: "ring1_wal_flush",
194            ring: 1,
195            record_metrics: true,
196        }
197    }
198
199    /// Budget: 5ms
200    #[inline]
201    #[must_use]
202    pub fn ring1_compaction() -> Self {
203        Self {
204            start: Instant::now(),
205            budget_ns: Self::RING1_COMPACTION_NS,
206            name: "ring1_compaction",
207            ring: 1,
208            record_metrics: true,
209        }
210    }
211
212    // Custom budget
213
214    /// Create a custom budget.
215    ///
216    /// # Arguments
217    ///
218    /// * `name` - Task name for metrics (must be a static string)
219    /// * `ring` - Ring number (0, 1, or 2)
220    /// * `budget_ns` - Budget in nanoseconds
221    #[inline]
222    #[must_use]
223    pub fn custom(name: &'static str, ring: u8, budget_ns: u64) -> Self {
224        Self {
225            start: Instant::now(),
226            budget_ns,
227            name,
228            ring,
229            record_metrics: true,
230        }
231    }
232
233    /// Create a custom budget without metrics recording.
234    #[inline]
235    #[must_use]
236    pub fn custom_untracked(name: &'static str, ring: u8, budget_ns: u64) -> Self {
237        Self {
238            start: Instant::now(),
239            budget_ns,
240            name,
241            ring,
242            record_metrics: false,
243        }
244    }
245
246    // Accessors
247
248    /// Task label used in metrics.
249    #[inline]
250    #[must_use]
251    pub fn name(&self) -> &'static str {
252        self.name
253    }
254
255    /// Ring number (0, 1, or 2).
256    #[inline]
257    #[must_use]
258    pub fn ring(&self) -> u8 {
259        self.ring
260    }
261
262    /// Allowed budget in nanoseconds.
263    #[inline]
264    #[must_use]
265    pub fn budget_ns(&self) -> u64 {
266        self.budget_ns
267    }
268
269    // Budget checking
270
271    /// Get elapsed time in nanoseconds.
272    ///
273    /// Note: Truncation from u128 to u64 is acceptable here because:
274    /// - u64 can hold ~584 years of nanoseconds
275    /// - Budget tracking is for sub-second operations
276    #[inline]
277    #[must_use]
278    #[allow(clippy::cast_possible_truncation)]
279    pub fn elapsed_ns(&self) -> u64 {
280        self.start.elapsed().as_nanos() as u64
281    }
282
283    /// Get remaining budget in nanoseconds (negative if exceeded).
284    ///
285    /// Returns a signed value where:
286    /// - Positive: nanoseconds of budget remaining
287    /// - Negative: nanoseconds over budget
288    #[inline]
289    #[must_use]
290    #[allow(clippy::cast_possible_wrap)]
291    pub fn remaining_ns(&self) -> i64 {
292        // Note: These casts are safe because:
293        // - budget_ns is at most ~10ms = 10_000_000ns, well under i64::MAX
294        // - elapsed_ns is measured wall-clock time, practically bounded
295        self.budget_ns as i64 - self.elapsed_ns() as i64
296    }
297
298    /// `true` when elapsed time exceeds the budget.
299    #[inline]
300    #[must_use]
301    pub fn exceeded(&self) -> bool {
302        self.elapsed_ns() > self.budget_ns
303    }
304
305    /// Check if budget is almost exceeded (>80% used).
306    ///
307    /// Useful for early warnings and preemptive yielding.
308    #[inline]
309    #[must_use]
310    pub fn almost_exceeded(&self) -> bool {
311        self.elapsed_ns() > (self.budget_ns * 8) / 10
312    }
313
314    /// Check if budget is half used (>50%).
315    ///
316    /// Useful for chunking decisions.
317    #[inline]
318    #[must_use]
319    pub fn half_used(&self) -> bool {
320        self.elapsed_ns() > self.budget_ns / 2
321    }
322
323    /// Values over 100 indicate the budget was exceeded.
324    #[inline]
325    #[must_use]
326    pub fn percentage_used(&self) -> u64 {
327        let elapsed = self.elapsed_ns();
328        if self.budget_ns == 0 {
329            return 100;
330        }
331        (elapsed * 100) / self.budget_ns
332    }
333}
334
335impl Drop for TaskBudget {
336    fn drop(&mut self) {
337        if self.record_metrics {
338            let elapsed = self.elapsed_ns();
339            if elapsed > self.budget_ns {
340                tracing::trace!(
341                    task = self.name,
342                    ring = self.ring,
343                    budget_ns = self.budget_ns,
344                    elapsed_ns = elapsed,
345                    "budget exceeded",
346                );
347            }
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use std::thread;
356    use std::time::Duration;
357
358    #[test]
359    fn test_elapsed_increases() {
360        let budget = TaskBudget::ring1_chunk();
361        let t1 = budget.elapsed_ns();
362        thread::sleep(Duration::from_micros(100));
363        let t2 = budget.elapsed_ns();
364        assert!(t2 > t1);
365    }
366
367    #[test]
368    fn test_remaining_decreases() {
369        let budget = TaskBudget::ring1_chunk();
370        let r1 = budget.remaining_ns();
371        thread::sleep(Duration::from_micros(100));
372        let r2 = budget.remaining_ns();
373        assert!(r2 < r1);
374    }
375
376    #[test]
377    fn test_percentage_used() {
378        let budget = TaskBudget::custom("test", 0, 100_000); // 100μs
379
380        // Very early, should be low percentage
381        let pct = budget.percentage_used();
382        assert!(pct < 50, "Early percentage {pct} should be low");
383    }
384
385    #[test]
386    fn test_half_used() {
387        // Use a large budget (1s) so the first check can't race past 50%
388        let budget = TaskBudget::custom("test", 0, 1_000_000_000); // 1s
389
390        // Should not be half used immediately
391        assert!(!budget.half_used());
392
393        // Sleep for 600ms (60%)
394        thread::sleep(Duration::from_millis(600));
395
396        // Should be half used now
397        assert!(budget.half_used());
398    }
399
400    #[test]
401    fn test_untracked_budget() {
402        let budget = TaskBudget::ring0_event_untracked();
403        assert!(!budget.record_metrics);
404
405        let budget2 = TaskBudget::custom_untracked("test", 0, 1000);
406        assert!(!budget2.record_metrics);
407    }
408
409    #[test]
410    fn test_ring0_iteration() {
411        let budget = TaskBudget::ring0_iteration();
412        assert_eq!(budget.name(), "ring0_iteration");
413        assert_eq!(budget.budget_ns(), TaskBudget::RING0_ITERATION_NS);
414    }
415
416    #[test]
417    fn test_ring1_compaction() {
418        let budget = TaskBudget::ring1_compaction();
419        assert_eq!(budget.name(), "ring1_compaction");
420        assert_eq!(budget.budget_ns(), TaskBudget::RING1_COMPACTION_NS);
421    }
422}