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 pub graph_vertex_hard_limit: Option<usize>,
49 pub graph_edge_hard_limit: Option<usize>,
51 pub materialization_cells: Option<u64>,
53 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 pub disk_scratch_policy: Option<DiskScratchPolicy>,
172 pub materialized_array_cells: Option<u64>,
180}
181
182pub(crate) const DEFAULT_MATERIALIZED_ARRAY_CELLS: u64 = 1 << 24;
184
185#[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 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 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#[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 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
476pub(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 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 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}