Skip to main content

formualizer_eval/engine/
resource_ledger.rs

1use std::fmt;
2use std::sync::Arc;
3use std::time::Duration;
4
5use formualizer_common::{
6    ExcelError, ExcelErrorExtra, ExcelErrorKind, ResourceExhaustionDetail, ResourceExhaustionReason,
7};
8
9use crate::instant::FzInstant;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum EvaluationIncompleteReason {
14    FormulaPlaneTopologyCandidates,
15    FormulaPlaneTopologyEdges,
16    FormulaPlaneTopologyRetainedBytes,
17    FormulaPlaneTopologyScratchBytes,
18    FormulaPlaneTopologyAllocation,
19    FormulaPlaneTopologySemanticStructural,
20    DirtyClosureWork,
21}
22
23impl EvaluationIncompleteReason {
24    pub const fn as_str(self) -> &'static str {
25        match self {
26            Self::FormulaPlaneTopologyCandidates => "formula_plane_topology_candidates",
27            Self::FormulaPlaneTopologyEdges => "formula_plane_topology_edges",
28            Self::FormulaPlaneTopologyRetainedBytes => "formula_plane_topology_retained_bytes",
29            Self::FormulaPlaneTopologyScratchBytes => "formula_plane_topology_scratch_bytes",
30            Self::FormulaPlaneTopologyAllocation => "formula_plane_topology_allocation",
31            Self::FormulaPlaneTopologySemanticStructural => {
32                "formula_plane_topology_semantic_structural"
33            }
34            Self::DirtyClosureWork => "dirty_closure_work",
35        }
36    }
37}
38
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40pub struct SemanticResourceBudget {
41    pub max_rows: Option<u32>,
42    pub max_columns: Option<u32>,
43}
44
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub struct AdmissionResourceBudget {
47    /// Hard cap on the exact final graph vertex count at common mutation preflight.
48    pub graph_vertex_hard_limit: Option<usize>,
49    /// Hard cap on the exact final graph edge count at common mutation preflight.
50    pub graph_edge_hard_limit: Option<usize>,
51    /// Hard cap on formula cells actually materialized into the legacy graph.
52    pub materialization_cells: Option<u64>,
53    /// Hard cap on bytes attributed to newly materialized graph vertices and edges.
54    pub materialized_graph_bytes: Option<u64>,
55}
56
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub(crate) struct GraphAdmission {
59    pub final_vertices: usize,
60    pub final_edges: usize,
61    pub materialization_cells: u64,
62    pub added_vertices: usize,
63    pub added_edges: usize,
64}
65
66impl GraphAdmission {
67    pub(crate) fn materialized_graph_bytes(self) -> Result<u64, ResourceLedgerError> {
68        let vertices = u64::try_from(self.added_vertices).map_err(|_| {
69            ResourceLedgerError::Exhausted(ResourceExhaustionDetail {
70                reason: ResourceExhaustionReason::ArithmeticOverflow,
71                limit: u64::MAX,
72                observed: u64::MAX,
73                request_id: None,
74            })
75        })?;
76        let edges = u64::try_from(self.added_edges).map_err(|_| {
77            ResourceLedgerError::Exhausted(ResourceExhaustionDetail {
78                reason: ResourceExhaustionReason::ArithmeticOverflow,
79                limit: u64::MAX,
80                observed: u64::MAX,
81                request_id: None,
82            })
83        })?;
84        vertices
85            .checked_mul(64)
86            .and_then(|bytes| {
87                edges
88                    .checked_mul(16)
89                    .and_then(|edge_bytes| bytes.checked_add(edge_bytes))
90            })
91            .ok_or(ResourceLedgerError::Exhausted(ResourceExhaustionDetail {
92                reason: ResourceExhaustionReason::ArithmeticOverflow,
93                limit: u64::MAX,
94                observed: u64::MAX,
95                request_id: None,
96            }))
97    }
98}
99
100pub(crate) fn graph_admission_enabled(budgets: &EvaluationBudgets) -> bool {
101    let admission = &budgets.admission;
102    admission.graph_vertex_hard_limit.is_some()
103        || admission.graph_edge_hard_limit.is_some()
104        || admission.materialization_cells.is_some()
105        || admission.materialized_graph_bytes.is_some()
106}
107
108pub(crate) fn preflight_graph_admission(
109    budgets: &EvaluationBudgets,
110    usage: GraphAdmission,
111    request_id: Option<u64>,
112) -> Result<(), ResourceLedgerError> {
113    let exhausted = |reason, limit, observed| {
114        ResourceLedgerError::Exhausted(ResourceExhaustionDetail {
115            reason,
116            limit,
117            observed,
118            request_id,
119        })
120    };
121    if let Some(limit) = budgets.admission.graph_vertex_hard_limit
122        && usage.final_vertices > limit
123    {
124        return Err(exhausted(
125            ResourceExhaustionReason::GraphVertices,
126            limit as u64,
127            usage.final_vertices as u64,
128        ));
129    }
130    if let Some(limit) = budgets.admission.graph_edge_hard_limit
131        && usage.final_edges > limit
132    {
133        return Err(exhausted(
134            ResourceExhaustionReason::GraphEdges,
135            limit as u64,
136            usage.final_edges as u64,
137        ));
138    }
139    if let Some(limit) = budgets.admission.materialization_cells
140        && usage.materialization_cells > limit
141    {
142        return Err(exhausted(
143            ResourceExhaustionReason::MaterializationCells,
144            limit,
145            usage.materialization_cells,
146        ));
147    }
148    let bytes = usage.materialized_graph_bytes()?;
149    if let Some(limit) = budgets.admission.materialized_graph_bytes
150        && bytes > limit
151    {
152        return Err(exhausted(ResourceExhaustionReason::Admission, limit, bytes));
153    }
154    Ok(())
155}
156
157#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
158pub struct RetainedResourceBudget {
159    pub total_bytes: Option<u64>,
160    pub mixed_cache_bytes: Option<u64>,
161    pub lookup_cache_bytes: Option<u64>,
162}
163
164#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
165pub struct ScratchResourceBudget {
166    pub total_bytes: Option<u64>,
167    pub schedule_discovery_bytes: Option<u64>,
168    pub graph_source_bytes: Option<u64>,
169    pub spill_overlay_bytes: Option<u64>,
170    /// Request scratch backing policy. Declarative until disk-backed scratch is activated.
171    pub disk_scratch_policy: Option<DiskScratchPolicy>,
172    /// Most cells one range or array value may have when it is materialised
173    /// as a dense array (a range turned into an array value, an array
174    /// operator's result, a function that copies its input). Exceeding it
175    /// gives `#NUM!` before anything is allocated. Cells are counted after
176    /// whole-row and whole-column references are trimmed to the used region.
177    /// `None` means the default, 2^24 (16,777,216) cells, the bound the
178    /// generated-array functions (SEQUENCE, RANDARRAY, MAKEARRAY, ...) use.
179    pub materialized_array_cells: Option<u64>,
180}
181
182/// Default for [`ScratchResourceBudget::materialized_array_cells`].
183pub(crate) const DEFAULT_MATERIALIZED_ARRAY_CELLS: u64 = 1 << 24;
184
185/// `#NUM!` when a `rows x cols` dense array would exceed `budget` cells.
186/// Overflowing products fail closed. One multiply and one compare: call it
187/// once per materialisation, before allocating, never per cell.
188#[inline]
189pub(crate) fn materialized_cells_exceed(
190    rows: usize,
191    cols: usize,
192    budget: u64,
193) -> Option<ExcelError> {
194    match (rows as u64).checked_mul(cols as u64) {
195        Some(cells) if cells <= budget => None,
196        _ => Some(materialization_budget_error()),
197    }
198}
199
200#[cold]
201#[inline(never)]
202pub(crate) fn materialization_budget_error() -> ExcelError {
203    // The same bare #NUM! the generated-array guard has always returned.
204    ExcelError::new(ExcelErrorKind::Num)
205}
206
207#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
208pub struct WorkResourceBudget {
209    pub max_work_units: Option<u64>,
210}
211
212#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
213pub struct DeadlineResourceBudget {
214    pub max_elapsed: Option<Duration>,
215}
216
217#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
218pub struct OptimizationResourceBudget {
219    pub mixed_cache_candidates: Option<usize>,
220    pub mixed_cache_edges: Option<usize>,
221    pub max_threads: Option<usize>,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Default)]
225pub struct EvaluationBudgets {
226    pub semantic: SemanticResourceBudget,
227    pub admission: AdmissionResourceBudget,
228    pub retained: RetainedResourceBudget,
229    pub scratch: ScratchResourceBudget,
230    pub work: WorkResourceBudget,
231    pub deadline: DeadlineResourceBudget,
232    pub optimization: OptimizationResourceBudget,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum DiskScratchPolicy {
237    NativeTemporary,
238    MemoryOnly,
239}
240
241impl DiskScratchPolicy {
242    pub const fn as_str(self) -> &'static str {
243        match self {
244            Self::NativeTemporary => "native_temporary",
245            Self::MemoryOnly => "memory_only",
246        }
247    }
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct ResourceEnvelope {
252    pub retained_bytes: u64,
253    pub request_scratch_bytes: u64,
254    pub materialized_graph_bytes: u64,
255    pub max_work_units: u64,
256    pub deadline: Option<Duration>,
257    pub max_threads: usize,
258    pub disk_scratch: DiskScratchPolicy,
259}
260
261impl ResourceEnvelope {
262    /// Deterministically derive detailed budgets from this aggregate envelope.
263    pub fn to_budgets(&self) -> EvaluationBudgets {
264        let cache_pool = self.retained_bytes / 8;
265        let mixed_cache = cache_pool.saturating_mul(60) / 100;
266        let lookup_cache = cache_pool.saturating_sub(mixed_cache);
267        let schedule = self.request_scratch_bytes / 2;
268        let graph_source = self.request_scratch_bytes.saturating_mul(35) / 100;
269        let spill_overlay = self
270            .request_scratch_bytes
271            .saturating_sub(schedule)
272            .saturating_sub(graph_source);
273        EvaluationBudgets {
274            admission: AdmissionResourceBudget {
275                materialized_graph_bytes: Some(self.materialized_graph_bytes),
276                ..AdmissionResourceBudget::default()
277            },
278            retained: RetainedResourceBudget {
279                total_bytes: Some(self.retained_bytes),
280                mixed_cache_bytes: Some(mixed_cache),
281                lookup_cache_bytes: Some(lookup_cache),
282            },
283            scratch: ScratchResourceBudget {
284                total_bytes: Some(self.request_scratch_bytes),
285                schedule_discovery_bytes: Some(schedule),
286                graph_source_bytes: Some(graph_source),
287                spill_overlay_bytes: Some(spill_overlay),
288                disk_scratch_policy: Some(self.disk_scratch),
289                materialized_array_cells: None,
290            },
291            work: WorkResourceBudget {
292                max_work_units: Some(self.max_work_units),
293            },
294            deadline: DeadlineResourceBudget {
295                max_elapsed: self.deadline,
296            },
297            optimization: OptimizationResourceBudget {
298                mixed_cache_candidates: Some(
299                    usize::try_from(mixed_cache / 64).unwrap_or(usize::MAX),
300                ),
301                mixed_cache_edges: Some(usize::try_from(mixed_cache / 64).unwrap_or(usize::MAX)),
302                max_threads: Some(self.max_threads),
303            },
304            ..EvaluationBudgets::default()
305        }
306    }
307}
308
309#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
310#[non_exhaustive]
311pub enum LegacyResourceConfigDisposition {
312    #[default]
313    NotPresent,
314    Mapped,
315    IgnoredByExplicitBudget,
316}
317
318impl LegacyResourceConfigDisposition {
319    pub const fn as_str(self) -> &'static str {
320        match self {
321            Self::NotPresent => "not_present",
322            Self::Mapped => "mapped",
323            Self::IgnoredByExplicitBudget => "ignored_by_explicit_budget",
324        }
325    }
326}
327
328/// Field-level disposition of deprecated resource configuration.
329///
330/// Every legacy value fills only its otherwise-unset destination budget field. An explicit
331/// destination wins independently, so one `max_memory_mb` value can map to retained memory while
332/// being ignored for scratch memory. Engines expose at most one diagnostic containing all fields.
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct EvaluationResourceConfigDiagnostic {
335    pub max_vertices: LegacyResourceConfigDisposition,
336    pub max_memory_mb_retained: LegacyResourceConfigDisposition,
337    pub max_memory_mb_scratch: LegacyResourceConfigDisposition,
338    pub max_eval_time: LegacyResourceConfigDisposition,
339    /// Compatibility diagnostic field. Always false now that common admission is active.
340    pub graph_admission_activation_deferred_to_c2: bool,
341}
342
343#[derive(Debug, Clone)]
344pub(crate) struct ResolvedEvaluationBudgets {
345    pub budgets: EvaluationBudgets,
346    pub diagnostic: Option<EvaluationResourceConfigDiagnostic>,
347}
348
349pub(crate) fn split_legacy_memory_bytes(bytes: u64) -> (u64, u64) {
350    let scratch = bytes / 2;
351    (bytes.saturating_sub(scratch), scratch)
352}
353
354pub(crate) fn resolve_evaluation_budgets(
355    explicit: &EvaluationBudgets,
356    max_vertices: Option<usize>,
357    max_memory_mb: Option<usize>,
358    max_eval_time: Option<Duration>,
359) -> ResolvedEvaluationBudgets {
360    let legacy_present =
361        max_vertices.is_some() || max_memory_mb.is_some() || max_eval_time.is_some();
362    if !legacy_present {
363        return ResolvedEvaluationBudgets {
364            budgets: explicit.clone(),
365            diagnostic: None,
366        };
367    }
368
369    let mut budgets = explicit.clone();
370    let mut diagnostic = EvaluationResourceConfigDiagnostic {
371        max_vertices: LegacyResourceConfigDisposition::NotPresent,
372        max_memory_mb_retained: LegacyResourceConfigDisposition::NotPresent,
373        max_memory_mb_scratch: LegacyResourceConfigDisposition::NotPresent,
374        max_eval_time: LegacyResourceConfigDisposition::NotPresent,
375        graph_admission_activation_deferred_to_c2: false,
376    };
377    if let Some(max_vertices) = max_vertices {
378        if budgets.admission.graph_vertex_hard_limit.is_none() {
379            budgets.admission.graph_vertex_hard_limit = Some(max_vertices);
380            diagnostic.max_vertices = LegacyResourceConfigDisposition::Mapped;
381        } else {
382            diagnostic.max_vertices = LegacyResourceConfigDisposition::IgnoredByExplicitBudget;
383        }
384    }
385    if let Some(max_eval_time) = max_eval_time {
386        if budgets.deadline.max_elapsed.is_none() {
387            budgets.deadline.max_elapsed = Some(max_eval_time);
388            diagnostic.max_eval_time = LegacyResourceConfigDisposition::Mapped;
389        } else {
390            diagnostic.max_eval_time = LegacyResourceConfigDisposition::IgnoredByExplicitBudget;
391        }
392    }
393    if let Some(memory_mb) = max_memory_mb {
394        let bytes = u64::try_from(memory_mb)
395            .unwrap_or(u64::MAX)
396            .saturating_mul(1024 * 1024);
397        let (retained, scratch) = split_legacy_memory_bytes(bytes);
398        if budgets.retained.total_bytes.is_none() {
399            budgets.retained.total_bytes = Some(retained);
400            diagnostic.max_memory_mb_retained = LegacyResourceConfigDisposition::Mapped;
401        } else {
402            diagnostic.max_memory_mb_retained =
403                LegacyResourceConfigDisposition::IgnoredByExplicitBudget;
404        }
405        if budgets.scratch.total_bytes.is_none() {
406            budgets.scratch.total_bytes = Some(scratch);
407            diagnostic.max_memory_mb_scratch = LegacyResourceConfigDisposition::Mapped;
408        } else {
409            diagnostic.max_memory_mb_scratch =
410                LegacyResourceConfigDisposition::IgnoredByExplicitBudget;
411        }
412    }
413    ResolvedEvaluationBudgets {
414        budgets,
415        diagnostic: Some(diagnostic),
416    }
417}
418
419#[derive(Debug, Clone, PartialEq, Eq)]
420#[non_exhaustive]
421pub enum ResourceLedgerError {
422    Exhausted(ResourceExhaustionDetail),
423    ReleaseUnderflow {
424        reason: ResourceExhaustionReason,
425        reserved: u64,
426        released: u64,
427    },
428}
429
430impl ResourceLedgerError {
431    pub fn into_excel_error(self) -> ExcelError {
432        let detail = match self {
433            Self::Exhausted(detail) => detail,
434            Self::ReleaseUnderflow {
435                reserved, released, ..
436            } => ResourceExhaustionDetail {
437                reason: ResourceExhaustionReason::ArithmeticOverflow,
438                limit: reserved,
439                observed: released,
440                request_id: None,
441            },
442        };
443        ExcelError::new(ExcelErrorKind::NImpl)
444            .with_message(format!(
445                "evaluation resource exhausted: {} (observed {}, limit {})",
446                detail.reason.as_str(),
447                detail.observed,
448                detail.limit
449            ))
450            .with_extra(ExcelErrorExtra::Resource {
451                detail: Box::new(detail),
452            })
453    }
454}
455
456#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
457pub struct ResourceLedgerSnapshot {
458    pub retained_limit: Option<u64>,
459    pub mixed_cache_limit: Option<u64>,
460    pub retained_current: u64,
461    pub retained_peak: u64,
462    pub scratch_limit: Option<u64>,
463    pub schedule_discovery_limit: Option<u64>,
464    pub scratch_current: u64,
465    pub scratch_peak: u64,
466    pub disk_scratch_policy: Option<DiskScratchPolicy>,
467    pub work_limit: Option<u64>,
468    pub work_charged: u64,
469    pub deadline_ns: Option<u64>,
470    pub deadline_checkpoints: u64,
471    pub exhaustion: Option<ResourceExhaustionReason>,
472}
473
474type ElapsedClock = Arc<dyn Fn() -> Duration + Send + Sync>;
475
476/// Internal accounting mechanism for a single evaluation request.
477///
478/// The budget and snapshot *data* types in this module are public so callers can
479/// configure limits and read outcomes. The ledger itself drives reserve/release
480/// pairing and work charging inside the engine, and publishing that protocol would
481/// freeze an internal contract that no external caller can meaningfully use.
482pub(crate) struct ResourceLedger {
483    request_id: Option<u64>,
484    budgets: EvaluationBudgets,
485    retained_current: u64,
486    retained_peak: u64,
487    mixed_cache_current: u64,
488    scratch_current: u64,
489    scratch_peak: u64,
490    work_charged: u64,
491    deadline_checkpoints: u64,
492    exhaustion: Option<ResourceExhaustionReason>,
493    elapsed: ElapsedClock,
494}
495
496impl fmt::Debug for ResourceLedger {
497    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
498        f.debug_struct("ResourceLedger")
499            .field("request_id", &self.request_id)
500            .field("budgets", &self.budgets)
501            .field("snapshot", &self.snapshot())
502            .finish()
503    }
504}
505
506impl ResourceLedger {
507    pub fn new(request_id: Option<u64>, budgets: EvaluationBudgets) -> Self {
508        let started = FzInstant::now();
509        Self::with_elapsed_clock(request_id, budgets, Arc::new(move || started.elapsed()))
510    }
511
512    fn with_elapsed_clock(
513        request_id: Option<u64>,
514        budgets: EvaluationBudgets,
515        elapsed: ElapsedClock,
516    ) -> Self {
517        Self {
518            request_id,
519            budgets,
520            retained_current: 0,
521            retained_peak: 0,
522            mixed_cache_current: 0,
523            scratch_current: 0,
524            scratch_peak: 0,
525            work_charged: 0,
526            deadline_checkpoints: 0,
527            exhaustion: None,
528            elapsed,
529        }
530    }
531
532    #[cfg(test)]
533    pub(crate) fn with_test_elapsed_clock(
534        request_id: Option<u64>,
535        budgets: EvaluationBudgets,
536        elapsed: ElapsedClock,
537    ) -> Self {
538        Self::with_elapsed_clock(request_id, budgets, elapsed)
539    }
540
541    fn exhausted(
542        &mut self,
543        reason: ResourceExhaustionReason,
544        limit: u64,
545        observed: u64,
546    ) -> ResourceLedgerError {
547        self.exhaustion = Some(reason);
548        ResourceLedgerError::Exhausted(ResourceExhaustionDetail {
549            reason,
550            limit,
551            observed,
552            request_id: self.request_id,
553        })
554    }
555
556    /// Observe retained ownership without activating the C1b retained cap.
557    pub(crate) fn observe_retained(&mut self, bytes: u64) {
558        self.retained_current = self.retained_current.saturating_add(bytes);
559        self.retained_peak = self.retained_peak.max(self.retained_current);
560    }
561
562    /// Observe scoped scratch ownership without activating non-C1b scratch fields.
563    pub(crate) fn observe_scratch(&mut self, bytes: u64) {
564        self.scratch_current = self.scratch_current.saturating_add(bytes);
565        self.scratch_peak = self.scratch_peak.max(self.scratch_current);
566    }
567
568    fn minimum_limit(first: Option<u64>, second: Option<u64>) -> Option<u64> {
569        match (first, second) {
570            (Some(first), Some(second)) => Some(first.min(second)),
571            (Some(limit), None) | (None, Some(limit)) => Some(limit),
572            (None, None) => None,
573        }
574    }
575
576    pub(crate) fn mixed_cache_limit(&self) -> Option<u64> {
577        Self::minimum_limit(
578            self.budgets.retained.total_bytes,
579            self.budgets.retained.mixed_cache_bytes,
580        )
581    }
582
583    pub(crate) fn schedule_discovery_limit(&self) -> Option<u64> {
584        Self::minimum_limit(
585            self.budgets.scratch.total_bytes,
586            self.budgets.scratch.schedule_discovery_bytes,
587        )
588    }
589
590    pub(crate) fn graph_source_limit(&self) -> Option<u64> {
591        Self::minimum_limit(
592            self.budgets.scratch.total_bytes,
593            self.budgets.scratch.graph_source_bytes,
594        )
595    }
596
597    pub(crate) fn disk_scratch_policy(&self) -> Option<DiskScratchPolicy> {
598        self.budgets.scratch.disk_scratch_policy
599    }
600
601    pub(crate) fn can_reserve_schedule_discovery(&self, bytes: u64) -> bool {
602        let Some(next) = self.scratch_current.checked_add(bytes) else {
603            return false;
604        };
605        self.schedule_discovery_limit()
606            .is_none_or(|limit| next <= limit)
607    }
608
609    pub(crate) fn account_mixed_cache(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
610        let Some(without_previous) = self.retained_current.checked_sub(self.mixed_cache_current)
611        else {
612            return Err(ResourceLedgerError::ReleaseUnderflow {
613                reason: ResourceExhaustionReason::RetainedMemory,
614                reserved: self.retained_current,
615                released: self.mixed_cache_current,
616            });
617        };
618        let Some(next) = without_previous.checked_add(bytes) else {
619            return Err(self.exhausted(
620                ResourceExhaustionReason::ArithmeticOverflow,
621                u64::MAX,
622                u64::MAX,
623            ));
624        };
625        if let Some(limit) = self.mixed_cache_limit()
626            && next > limit
627        {
628            return Err(self.exhausted(ResourceExhaustionReason::RetainedMemory, limit, next));
629        }
630        self.retained_current = next;
631        self.mixed_cache_current = bytes;
632        self.retained_peak = self.retained_peak.max(next);
633        Ok(())
634    }
635
636    pub(crate) fn reserve_graph_source(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
637        let Some(next) = self.scratch_current.checked_add(bytes) else {
638            return Err(self.exhausted(
639                ResourceExhaustionReason::ArithmeticOverflow,
640                u64::MAX,
641                u64::MAX,
642            ));
643        };
644        if let Some(limit) = self.graph_source_limit()
645            && next > limit
646        {
647            return Err(self.exhausted(ResourceExhaustionReason::ScratchMemory, limit, next));
648        }
649        self.scratch_current = next;
650        self.scratch_peak = self.scratch_peak.max(next);
651        Ok(())
652    }
653
654    pub(crate) fn reserve_schedule_discovery(
655        &mut self,
656        bytes: u64,
657    ) -> Result<(), ResourceLedgerError> {
658        let Some(next) = self.scratch_current.checked_add(bytes) else {
659            return Err(self.exhausted(
660                ResourceExhaustionReason::ArithmeticOverflow,
661                u64::MAX,
662                u64::MAX,
663            ));
664        };
665        if let Some(limit) = self.schedule_discovery_limit()
666            && next > limit
667        {
668            return Err(self.exhausted(ResourceExhaustionReason::ScratchMemory, limit, next));
669        }
670        self.scratch_current = next;
671        self.scratch_peak = self.scratch_peak.max(next);
672        Ok(())
673    }
674
675    pub fn reserve_retained(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
676        let Some(next) = self.retained_current.checked_add(bytes) else {
677            return Err(self.exhausted(
678                ResourceExhaustionReason::ArithmeticOverflow,
679                u64::MAX,
680                u64::MAX,
681            ));
682        };
683        if let Some(limit) = self.budgets.retained.total_bytes
684            && next > limit
685        {
686            return Err(self.exhausted(ResourceExhaustionReason::RetainedMemory, limit, next));
687        }
688        self.retained_current = next;
689        self.retained_peak = self.retained_peak.max(next);
690        Ok(())
691    }
692
693    pub fn release_retained(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
694        let Some(next) = self.retained_current.checked_sub(bytes) else {
695            return Err(ResourceLedgerError::ReleaseUnderflow {
696                reason: ResourceExhaustionReason::RetainedMemory,
697                reserved: self.retained_current,
698                released: bytes,
699            });
700        };
701        self.retained_current = next;
702        Ok(())
703    }
704
705    pub fn reserve_scratch(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
706        let Some(next) = self.scratch_current.checked_add(bytes) else {
707            return Err(self.exhausted(
708                ResourceExhaustionReason::ArithmeticOverflow,
709                u64::MAX,
710                u64::MAX,
711            ));
712        };
713        if let Some(limit) = self.budgets.scratch.total_bytes
714            && next > limit
715        {
716            return Err(self.exhausted(ResourceExhaustionReason::ScratchMemory, limit, next));
717        }
718        self.scratch_current = next;
719        self.scratch_peak = self.scratch_peak.max(next);
720        Ok(())
721    }
722
723    pub fn release_scratch(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
724        let Some(next) = self.scratch_current.checked_sub(bytes) else {
725            return Err(ResourceLedgerError::ReleaseUnderflow {
726                reason: ResourceExhaustionReason::ScratchMemory,
727                reserved: self.scratch_current,
728                released: bytes,
729            });
730        };
731        self.scratch_current = next;
732        Ok(())
733    }
734
735    pub(crate) fn scratch_checkpoint(&self) -> u64 {
736        self.scratch_current
737    }
738
739    pub(crate) fn release_scratch_to(
740        &mut self,
741        checkpoint: u64,
742    ) -> Result<(), ResourceLedgerError> {
743        if checkpoint > self.scratch_current {
744            return Err(ResourceLedgerError::ReleaseUnderflow {
745                reason: ResourceExhaustionReason::ScratchMemory,
746                reserved: self.scratch_current,
747                released: checkpoint,
748            });
749        }
750        self.scratch_current = checkpoint;
751        Ok(())
752    }
753
754    pub fn release_all_scratch(&mut self) {
755        self.scratch_current = 0;
756    }
757
758    pub fn charge_work(&mut self, units: u64) -> Result<(), ResourceLedgerError> {
759        let Some(next) = self.work_charged.checked_add(units) else {
760            return Err(self.exhausted(
761                ResourceExhaustionReason::ArithmeticOverflow,
762                u64::MAX,
763                u64::MAX,
764            ));
765        };
766        if let Some(limit) = self.budgets.work.max_work_units
767            && next > limit
768        {
769            return Err(self.exhausted(ResourceExhaustionReason::WorkUnits, limit, next));
770        }
771        self.work_charged = next;
772        Ok(())
773    }
774
775    pub fn checkpoint_deadline(&mut self) -> Result<(), ResourceLedgerError> {
776        self.preflight_commit_window(Duration::ZERO)
777    }
778
779    pub(crate) fn preflight_commit_window(
780        &mut self,
781        estimate: Duration,
782    ) -> Result<(), ResourceLedgerError> {
783        self.deadline_checkpoints = self.deadline_checkpoints.saturating_add(1);
784        let Some(limit) = self.budgets.deadline.max_elapsed else {
785            return Ok(());
786        };
787        let elapsed = (self.elapsed)();
788        let projected = elapsed.checked_add(estimate).unwrap_or(Duration::MAX);
789        if projected >= limit {
790            let limit_ns = u64::try_from(limit.as_nanos()).unwrap_or(u64::MAX);
791            let observed_ns = u64::try_from(projected.as_nanos()).unwrap_or(u64::MAX);
792            return Err(self.exhausted(ResourceExhaustionReason::Deadline, limit_ns, observed_ns));
793        }
794        Ok(())
795    }
796
797    pub fn snapshot(&self) -> ResourceLedgerSnapshot {
798        ResourceLedgerSnapshot {
799            retained_limit: self.budgets.retained.total_bytes,
800            mixed_cache_limit: self.mixed_cache_limit(),
801            retained_current: self.retained_current,
802            retained_peak: self.retained_peak,
803            scratch_limit: self.budgets.scratch.total_bytes,
804            schedule_discovery_limit: self.schedule_discovery_limit(),
805            scratch_current: self.scratch_current,
806            scratch_peak: self.scratch_peak,
807            disk_scratch_policy: self.budgets.scratch.disk_scratch_policy,
808            work_limit: self.budgets.work.max_work_units,
809            work_charged: self.work_charged,
810            deadline_ns: self
811                .budgets
812                .deadline
813                .max_elapsed
814                .map(|duration| u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)),
815            deadline_checkpoints: self.deadline_checkpoints,
816            exhaustion: self.exhaustion,
817        }
818    }
819}