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