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    active_elapsed_before_step: Duration,
495    recalc_retained_current: u64,
496}
497
498impl fmt::Debug for ResourceLedger {
499    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
500        f.debug_struct("ResourceLedger")
501            .field("request_id", &self.request_id)
502            .field("budgets", &self.budgets)
503            .field("snapshot", &self.snapshot())
504            .finish()
505    }
506}
507
508impl ResourceLedger {
509    pub fn new(request_id: Option<u64>, budgets: EvaluationBudgets) -> Self {
510        let started = FzInstant::now();
511        Self::with_elapsed_clock(request_id, budgets, Arc::new(move || started.elapsed()))
512    }
513
514    fn with_elapsed_clock(
515        request_id: Option<u64>,
516        budgets: EvaluationBudgets,
517        elapsed: ElapsedClock,
518    ) -> Self {
519        Self {
520            request_id,
521            budgets,
522            retained_current: 0,
523            retained_peak: 0,
524            mixed_cache_current: 0,
525            scratch_current: 0,
526            scratch_peak: 0,
527            work_charged: 0,
528            deadline_checkpoints: 0,
529            exhaustion: None,
530            elapsed,
531            active_elapsed_before_step: Duration::ZERO,
532            recalc_retained_current: 0,
533        }
534    }
535
536    #[cfg(test)]
537    pub(crate) fn with_test_elapsed_clock(
538        request_id: Option<u64>,
539        budgets: EvaluationBudgets,
540        elapsed: ElapsedClock,
541    ) -> Self {
542        Self::with_elapsed_clock(request_id, budgets, elapsed)
543    }
544
545    pub(crate) fn account_recalc_retained(
546        &mut self,
547        bytes: u64,
548    ) -> Result<(), ResourceLedgerError> {
549        self.release_retained(self.recalc_retained_current)?;
550        self.recalc_retained_current = 0;
551        self.reserve_retained(bytes)?;
552        self.recalc_retained_current = bytes;
553        Ok(())
554    }
555
556    pub(crate) fn resume_job(&mut self, work: u64, elapsed: Duration) {
557        self.work_charged = work;
558        self.active_elapsed_before_step = elapsed;
559    }
560
561    fn exhausted(
562        &mut self,
563        reason: ResourceExhaustionReason,
564        limit: u64,
565        observed: u64,
566    ) -> ResourceLedgerError {
567        self.exhaustion = Some(reason);
568        ResourceLedgerError::Exhausted(ResourceExhaustionDetail {
569            reason,
570            limit,
571            observed,
572            request_id: self.request_id,
573        })
574    }
575
576    /// Observe retained ownership without activating the C1b retained cap.
577    pub(crate) fn observe_retained(&mut self, bytes: u64) {
578        self.retained_current = self.retained_current.saturating_add(bytes);
579        self.retained_peak = self.retained_peak.max(self.retained_current);
580    }
581
582    /// Observe scoped scratch ownership without activating non-C1b scratch fields.
583    pub(crate) fn observe_scratch(&mut self, bytes: u64) {
584        self.scratch_current = self.scratch_current.saturating_add(bytes);
585        self.scratch_peak = self.scratch_peak.max(self.scratch_current);
586    }
587
588    fn minimum_limit(first: Option<u64>, second: Option<u64>) -> Option<u64> {
589        match (first, second) {
590            (Some(first), Some(second)) => Some(first.min(second)),
591            (Some(limit), None) | (None, Some(limit)) => Some(limit),
592            (None, None) => None,
593        }
594    }
595
596    pub(crate) fn mixed_cache_limit(&self) -> Option<u64> {
597        Self::minimum_limit(
598            self.budgets.retained.total_bytes,
599            self.budgets.retained.mixed_cache_bytes,
600        )
601    }
602
603    pub(crate) fn schedule_discovery_limit(&self) -> Option<u64> {
604        Self::minimum_limit(
605            self.budgets.scratch.total_bytes,
606            self.budgets.scratch.schedule_discovery_bytes,
607        )
608    }
609
610    pub(crate) fn graph_source_limit(&self) -> Option<u64> {
611        Self::minimum_limit(
612            self.budgets.scratch.total_bytes,
613            self.budgets.scratch.graph_source_bytes,
614        )
615    }
616
617    pub(crate) fn disk_scratch_policy(&self) -> Option<DiskScratchPolicy> {
618        self.budgets.scratch.disk_scratch_policy
619    }
620
621    pub(crate) fn can_reserve_schedule_discovery(&self, bytes: u64) -> bool {
622        let Some(next) = self.scratch_current.checked_add(bytes) else {
623            return false;
624        };
625        self.schedule_discovery_limit()
626            .is_none_or(|limit| next <= limit)
627    }
628
629    pub(crate) fn account_mixed_cache(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
630        let Some(without_previous) = self.retained_current.checked_sub(self.mixed_cache_current)
631        else {
632            return Err(ResourceLedgerError::ReleaseUnderflow {
633                reason: ResourceExhaustionReason::RetainedMemory,
634                reserved: self.retained_current,
635                released: self.mixed_cache_current,
636            });
637        };
638        let Some(next) = without_previous.checked_add(bytes) else {
639            return Err(self.exhausted(
640                ResourceExhaustionReason::ArithmeticOverflow,
641                u64::MAX,
642                u64::MAX,
643            ));
644        };
645        if let Some(limit) = self.mixed_cache_limit()
646            && next > limit
647        {
648            return Err(self.exhausted(ResourceExhaustionReason::RetainedMemory, limit, next));
649        }
650        self.retained_current = next;
651        self.mixed_cache_current = bytes;
652        self.retained_peak = self.retained_peak.max(next);
653        Ok(())
654    }
655
656    pub(crate) fn reserve_graph_source(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
657        let Some(next) = self.scratch_current.checked_add(bytes) else {
658            return Err(self.exhausted(
659                ResourceExhaustionReason::ArithmeticOverflow,
660                u64::MAX,
661                u64::MAX,
662            ));
663        };
664        if let Some(limit) = self.graph_source_limit()
665            && next > limit
666        {
667            return Err(self.exhausted(ResourceExhaustionReason::ScratchMemory, limit, next));
668        }
669        self.scratch_current = next;
670        self.scratch_peak = self.scratch_peak.max(next);
671        Ok(())
672    }
673
674    pub(crate) fn reserve_schedule_discovery(
675        &mut self,
676        bytes: u64,
677    ) -> Result<(), ResourceLedgerError> {
678        let Some(next) = self.scratch_current.checked_add(bytes) else {
679            return Err(self.exhausted(
680                ResourceExhaustionReason::ArithmeticOverflow,
681                u64::MAX,
682                u64::MAX,
683            ));
684        };
685        if let Some(limit) = self.schedule_discovery_limit()
686            && next > limit
687        {
688            return Err(self.exhausted(ResourceExhaustionReason::ScratchMemory, limit, next));
689        }
690        self.scratch_current = next;
691        self.scratch_peak = self.scratch_peak.max(next);
692        Ok(())
693    }
694
695    pub fn reserve_retained(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
696        let Some(next) = self.retained_current.checked_add(bytes) else {
697            return Err(self.exhausted(
698                ResourceExhaustionReason::ArithmeticOverflow,
699                u64::MAX,
700                u64::MAX,
701            ));
702        };
703        if let Some(limit) = self.budgets.retained.total_bytes
704            && next > limit
705        {
706            return Err(self.exhausted(ResourceExhaustionReason::RetainedMemory, limit, next));
707        }
708        self.retained_current = next;
709        self.retained_peak = self.retained_peak.max(next);
710        Ok(())
711    }
712
713    pub fn release_retained(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
714        let Some(next) = self.retained_current.checked_sub(bytes) else {
715            return Err(ResourceLedgerError::ReleaseUnderflow {
716                reason: ResourceExhaustionReason::RetainedMemory,
717                reserved: self.retained_current,
718                released: bytes,
719            });
720        };
721        self.retained_current = next;
722        Ok(())
723    }
724
725    pub fn reserve_scratch(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
726        let Some(next) = self.scratch_current.checked_add(bytes) else {
727            return Err(self.exhausted(
728                ResourceExhaustionReason::ArithmeticOverflow,
729                u64::MAX,
730                u64::MAX,
731            ));
732        };
733        if let Some(limit) = self.budgets.scratch.total_bytes
734            && next > limit
735        {
736            return Err(self.exhausted(ResourceExhaustionReason::ScratchMemory, limit, next));
737        }
738        self.scratch_current = next;
739        self.scratch_peak = self.scratch_peak.max(next);
740        Ok(())
741    }
742
743    pub fn release_scratch(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
744        let Some(next) = self.scratch_current.checked_sub(bytes) else {
745            return Err(ResourceLedgerError::ReleaseUnderflow {
746                reason: ResourceExhaustionReason::ScratchMemory,
747                reserved: self.scratch_current,
748                released: bytes,
749            });
750        };
751        self.scratch_current = next;
752        Ok(())
753    }
754
755    pub(crate) fn scratch_checkpoint(&self) -> u64 {
756        self.scratch_current
757    }
758
759    pub(crate) fn release_scratch_to(
760        &mut self,
761        checkpoint: u64,
762    ) -> Result<(), ResourceLedgerError> {
763        if checkpoint > self.scratch_current {
764            return Err(ResourceLedgerError::ReleaseUnderflow {
765                reason: ResourceExhaustionReason::ScratchMemory,
766                reserved: self.scratch_current,
767                released: checkpoint,
768            });
769        }
770        self.scratch_current = checkpoint;
771        Ok(())
772    }
773
774    pub fn release_all_scratch(&mut self) {
775        self.scratch_current = 0;
776    }
777
778    pub fn charge_work(&mut self, units: u64) -> Result<(), ResourceLedgerError> {
779        let Some(next) = self.work_charged.checked_add(units) else {
780            return Err(self.exhausted(
781                ResourceExhaustionReason::ArithmeticOverflow,
782                u64::MAX,
783                u64::MAX,
784            ));
785        };
786        if let Some(limit) = self.budgets.work.max_work_units
787            && next > limit
788        {
789            return Err(self.exhausted(ResourceExhaustionReason::WorkUnits, limit, next));
790        }
791        self.work_charged = next;
792        Ok(())
793    }
794
795    pub fn checkpoint_deadline(&mut self) -> Result<(), ResourceLedgerError> {
796        self.preflight_commit_window(Duration::ZERO)
797    }
798
799    pub(crate) fn preflight_commit_window(
800        &mut self,
801        estimate: Duration,
802    ) -> Result<(), ResourceLedgerError> {
803        self.deadline_checkpoints = self.deadline_checkpoints.saturating_add(1);
804        let Some(limit) = self.budgets.deadline.max_elapsed else {
805            return Ok(());
806        };
807        let elapsed = (self.elapsed)().saturating_add(self.active_elapsed_before_step);
808        let projected = elapsed.checked_add(estimate).unwrap_or(Duration::MAX);
809        if projected >= limit {
810            let limit_ns = u64::try_from(limit.as_nanos()).unwrap_or(u64::MAX);
811            let observed_ns = u64::try_from(projected.as_nanos()).unwrap_or(u64::MAX);
812            return Err(self.exhausted(ResourceExhaustionReason::Deadline, limit_ns, observed_ns));
813        }
814        Ok(())
815    }
816
817    pub fn snapshot(&self) -> ResourceLedgerSnapshot {
818        ResourceLedgerSnapshot {
819            retained_limit: self.budgets.retained.total_bytes,
820            mixed_cache_limit: self.mixed_cache_limit(),
821            retained_current: self.retained_current,
822            retained_peak: self.retained_peak,
823            scratch_limit: self.budgets.scratch.total_bytes,
824            schedule_discovery_limit: self.schedule_discovery_limit(),
825            scratch_current: self.scratch_current,
826            scratch_peak: self.scratch_peak,
827            disk_scratch_policy: self.budgets.scratch.disk_scratch_policy,
828            work_limit: self.budgets.work.max_work_units,
829            work_charged: self.work_charged,
830            deadline_ns: self
831                .budgets
832                .deadline
833                .max_elapsed
834                .map(|duration| u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)),
835            deadline_checkpoints: self.deadline_checkpoints,
836            exhaustion: self.exhaustion,
837        }
838    }
839}