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}
173
174#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
175pub struct WorkResourceBudget {
176 pub max_work_units: Option<u64>,
177}
178
179#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
180pub struct DeadlineResourceBudget {
181 pub max_elapsed: Option<Duration>,
182}
183
184#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
185pub struct OptimizationResourceBudget {
186 pub mixed_cache_candidates: Option<usize>,
187 pub mixed_cache_edges: Option<usize>,
188 pub max_threads: Option<usize>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Default)]
192pub struct EvaluationBudgets {
193 pub semantic: SemanticResourceBudget,
194 pub admission: AdmissionResourceBudget,
195 pub retained: RetainedResourceBudget,
196 pub scratch: ScratchResourceBudget,
197 pub work: WorkResourceBudget,
198 pub deadline: DeadlineResourceBudget,
199 pub optimization: OptimizationResourceBudget,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum DiskScratchPolicy {
204 NativeTemporary,
205 MemoryOnly,
206}
207
208impl DiskScratchPolicy {
209 pub const fn as_str(self) -> &'static str {
210 match self {
211 Self::NativeTemporary => "native_temporary",
212 Self::MemoryOnly => "memory_only",
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct ResourceEnvelope {
219 pub retained_bytes: u64,
220 pub request_scratch_bytes: u64,
221 pub materialized_graph_bytes: u64,
222 pub max_work_units: u64,
223 pub deadline: Option<Duration>,
224 pub max_threads: usize,
225 pub disk_scratch: DiskScratchPolicy,
226}
227
228impl ResourceEnvelope {
229 pub fn to_budgets(&self) -> EvaluationBudgets {
231 let cache_pool = self.retained_bytes / 8;
232 let mixed_cache = cache_pool.saturating_mul(60) / 100;
233 let lookup_cache = cache_pool.saturating_sub(mixed_cache);
234 let schedule = self.request_scratch_bytes / 2;
235 let graph_source = self.request_scratch_bytes.saturating_mul(35) / 100;
236 let spill_overlay = self
237 .request_scratch_bytes
238 .saturating_sub(schedule)
239 .saturating_sub(graph_source);
240 EvaluationBudgets {
241 admission: AdmissionResourceBudget {
242 materialized_graph_bytes: Some(self.materialized_graph_bytes),
243 ..AdmissionResourceBudget::default()
244 },
245 retained: RetainedResourceBudget {
246 total_bytes: Some(self.retained_bytes),
247 mixed_cache_bytes: Some(mixed_cache),
248 lookup_cache_bytes: Some(lookup_cache),
249 },
250 scratch: ScratchResourceBudget {
251 total_bytes: Some(self.request_scratch_bytes),
252 schedule_discovery_bytes: Some(schedule),
253 graph_source_bytes: Some(graph_source),
254 spill_overlay_bytes: Some(spill_overlay),
255 disk_scratch_policy: Some(self.disk_scratch),
256 },
257 work: WorkResourceBudget {
258 max_work_units: Some(self.max_work_units),
259 },
260 deadline: DeadlineResourceBudget {
261 max_elapsed: self.deadline,
262 },
263 optimization: OptimizationResourceBudget {
264 mixed_cache_candidates: Some(
265 usize::try_from(mixed_cache / 64).unwrap_or(usize::MAX),
266 ),
267 mixed_cache_edges: Some(usize::try_from(mixed_cache / 64).unwrap_or(usize::MAX)),
268 max_threads: Some(self.max_threads),
269 },
270 ..EvaluationBudgets::default()
271 }
272 }
273}
274
275#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
276#[non_exhaustive]
277pub enum LegacyResourceConfigDisposition {
278 #[default]
279 NotPresent,
280 Mapped,
281 IgnoredByExplicitBudget,
282}
283
284impl LegacyResourceConfigDisposition {
285 pub const fn as_str(self) -> &'static str {
286 match self {
287 Self::NotPresent => "not_present",
288 Self::Mapped => "mapped",
289 Self::IgnoredByExplicitBudget => "ignored_by_explicit_budget",
290 }
291 }
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct EvaluationResourceConfigDiagnostic {
301 pub max_vertices: LegacyResourceConfigDisposition,
302 pub max_memory_mb_retained: LegacyResourceConfigDisposition,
303 pub max_memory_mb_scratch: LegacyResourceConfigDisposition,
304 pub max_eval_time: LegacyResourceConfigDisposition,
305 pub graph_admission_activation_deferred_to_c2: bool,
307}
308
309#[derive(Debug, Clone)]
310pub(crate) struct ResolvedEvaluationBudgets {
311 pub budgets: EvaluationBudgets,
312 pub diagnostic: Option<EvaluationResourceConfigDiagnostic>,
313}
314
315pub(crate) fn split_legacy_memory_bytes(bytes: u64) -> (u64, u64) {
316 let scratch = bytes / 2;
317 (bytes.saturating_sub(scratch), scratch)
318}
319
320pub(crate) fn resolve_evaluation_budgets(
321 explicit: &EvaluationBudgets,
322 max_vertices: Option<usize>,
323 max_memory_mb: Option<usize>,
324 max_eval_time: Option<Duration>,
325) -> ResolvedEvaluationBudgets {
326 let legacy_present =
327 max_vertices.is_some() || max_memory_mb.is_some() || max_eval_time.is_some();
328 if !legacy_present {
329 return ResolvedEvaluationBudgets {
330 budgets: explicit.clone(),
331 diagnostic: None,
332 };
333 }
334
335 let mut budgets = explicit.clone();
336 let mut diagnostic = EvaluationResourceConfigDiagnostic {
337 max_vertices: LegacyResourceConfigDisposition::NotPresent,
338 max_memory_mb_retained: LegacyResourceConfigDisposition::NotPresent,
339 max_memory_mb_scratch: LegacyResourceConfigDisposition::NotPresent,
340 max_eval_time: LegacyResourceConfigDisposition::NotPresent,
341 graph_admission_activation_deferred_to_c2: false,
342 };
343 if let Some(max_vertices) = max_vertices {
344 if budgets.admission.graph_vertex_hard_limit.is_none() {
345 budgets.admission.graph_vertex_hard_limit = Some(max_vertices);
346 diagnostic.max_vertices = LegacyResourceConfigDisposition::Mapped;
347 } else {
348 diagnostic.max_vertices = LegacyResourceConfigDisposition::IgnoredByExplicitBudget;
349 }
350 }
351 if let Some(max_eval_time) = max_eval_time {
352 if budgets.deadline.max_elapsed.is_none() {
353 budgets.deadline.max_elapsed = Some(max_eval_time);
354 diagnostic.max_eval_time = LegacyResourceConfigDisposition::Mapped;
355 } else {
356 diagnostic.max_eval_time = LegacyResourceConfigDisposition::IgnoredByExplicitBudget;
357 }
358 }
359 if let Some(memory_mb) = max_memory_mb {
360 let bytes = u64::try_from(memory_mb)
361 .unwrap_or(u64::MAX)
362 .saturating_mul(1024 * 1024);
363 let (retained, scratch) = split_legacy_memory_bytes(bytes);
364 if budgets.retained.total_bytes.is_none() {
365 budgets.retained.total_bytes = Some(retained);
366 diagnostic.max_memory_mb_retained = LegacyResourceConfigDisposition::Mapped;
367 } else {
368 diagnostic.max_memory_mb_retained =
369 LegacyResourceConfigDisposition::IgnoredByExplicitBudget;
370 }
371 if budgets.scratch.total_bytes.is_none() {
372 budgets.scratch.total_bytes = Some(scratch);
373 diagnostic.max_memory_mb_scratch = LegacyResourceConfigDisposition::Mapped;
374 } else {
375 diagnostic.max_memory_mb_scratch =
376 LegacyResourceConfigDisposition::IgnoredByExplicitBudget;
377 }
378 }
379 ResolvedEvaluationBudgets {
380 budgets,
381 diagnostic: Some(diagnostic),
382 }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq)]
386#[non_exhaustive]
387pub enum ResourceLedgerError {
388 Exhausted(ResourceExhaustionDetail),
389 ReleaseUnderflow {
390 reason: ResourceExhaustionReason,
391 reserved: u64,
392 released: u64,
393 },
394}
395
396impl ResourceLedgerError {
397 pub fn into_excel_error(self) -> ExcelError {
398 let detail = match self {
399 Self::Exhausted(detail) => detail,
400 Self::ReleaseUnderflow {
401 reserved, released, ..
402 } => ResourceExhaustionDetail {
403 reason: ResourceExhaustionReason::ArithmeticOverflow,
404 limit: reserved,
405 observed: released,
406 request_id: None,
407 },
408 };
409 ExcelError::new(ExcelErrorKind::NImpl)
410 .with_message(format!(
411 "evaluation resource exhausted: {} (observed {}, limit {})",
412 detail.reason.as_str(),
413 detail.observed,
414 detail.limit
415 ))
416 .with_extra(ExcelErrorExtra::Resource {
417 detail: Box::new(detail),
418 })
419 }
420}
421
422#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
423pub struct ResourceLedgerSnapshot {
424 pub retained_limit: Option<u64>,
425 pub mixed_cache_limit: Option<u64>,
426 pub retained_current: u64,
427 pub retained_peak: u64,
428 pub scratch_limit: Option<u64>,
429 pub schedule_discovery_limit: Option<u64>,
430 pub scratch_current: u64,
431 pub scratch_peak: u64,
432 pub disk_scratch_policy: Option<DiskScratchPolicy>,
433 pub work_limit: Option<u64>,
434 pub work_charged: u64,
435 pub deadline_ns: Option<u64>,
436 pub deadline_checkpoints: u64,
437 pub exhaustion: Option<ResourceExhaustionReason>,
438}
439
440type ElapsedClock = Arc<dyn Fn() -> Duration + Send + Sync>;
441
442pub(crate) struct ResourceLedger {
449 request_id: Option<u64>,
450 budgets: EvaluationBudgets,
451 retained_current: u64,
452 retained_peak: u64,
453 mixed_cache_current: u64,
454 scratch_current: u64,
455 scratch_peak: u64,
456 work_charged: u64,
457 deadline_checkpoints: u64,
458 exhaustion: Option<ResourceExhaustionReason>,
459 elapsed: ElapsedClock,
460}
461
462impl fmt::Debug for ResourceLedger {
463 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
464 f.debug_struct("ResourceLedger")
465 .field("request_id", &self.request_id)
466 .field("budgets", &self.budgets)
467 .field("snapshot", &self.snapshot())
468 .finish()
469 }
470}
471
472impl ResourceLedger {
473 pub fn new(request_id: Option<u64>, budgets: EvaluationBudgets) -> Self {
474 let started = FzInstant::now();
475 Self::with_elapsed_clock(request_id, budgets, Arc::new(move || started.elapsed()))
476 }
477
478 fn with_elapsed_clock(
479 request_id: Option<u64>,
480 budgets: EvaluationBudgets,
481 elapsed: ElapsedClock,
482 ) -> Self {
483 Self {
484 request_id,
485 budgets,
486 retained_current: 0,
487 retained_peak: 0,
488 mixed_cache_current: 0,
489 scratch_current: 0,
490 scratch_peak: 0,
491 work_charged: 0,
492 deadline_checkpoints: 0,
493 exhaustion: None,
494 elapsed,
495 }
496 }
497
498 #[cfg(test)]
499 pub(crate) fn with_test_elapsed_clock(
500 request_id: Option<u64>,
501 budgets: EvaluationBudgets,
502 elapsed: ElapsedClock,
503 ) -> Self {
504 Self::with_elapsed_clock(request_id, budgets, elapsed)
505 }
506
507 fn exhausted(
508 &mut self,
509 reason: ResourceExhaustionReason,
510 limit: u64,
511 observed: u64,
512 ) -> ResourceLedgerError {
513 self.exhaustion = Some(reason);
514 ResourceLedgerError::Exhausted(ResourceExhaustionDetail {
515 reason,
516 limit,
517 observed,
518 request_id: self.request_id,
519 })
520 }
521
522 pub(crate) fn observe_retained(&mut self, bytes: u64) {
524 self.retained_current = self.retained_current.saturating_add(bytes);
525 self.retained_peak = self.retained_peak.max(self.retained_current);
526 }
527
528 pub(crate) fn observe_scratch(&mut self, bytes: u64) {
530 self.scratch_current = self.scratch_current.saturating_add(bytes);
531 self.scratch_peak = self.scratch_peak.max(self.scratch_current);
532 }
533
534 fn minimum_limit(first: Option<u64>, second: Option<u64>) -> Option<u64> {
535 match (first, second) {
536 (Some(first), Some(second)) => Some(first.min(second)),
537 (Some(limit), None) | (None, Some(limit)) => Some(limit),
538 (None, None) => None,
539 }
540 }
541
542 pub(crate) fn mixed_cache_limit(&self) -> Option<u64> {
543 Self::minimum_limit(
544 self.budgets.retained.total_bytes,
545 self.budgets.retained.mixed_cache_bytes,
546 )
547 }
548
549 pub(crate) fn schedule_discovery_limit(&self) -> Option<u64> {
550 Self::minimum_limit(
551 self.budgets.scratch.total_bytes,
552 self.budgets.scratch.schedule_discovery_bytes,
553 )
554 }
555
556 pub(crate) fn graph_source_limit(&self) -> Option<u64> {
557 Self::minimum_limit(
558 self.budgets.scratch.total_bytes,
559 self.budgets.scratch.graph_source_bytes,
560 )
561 }
562
563 pub(crate) fn disk_scratch_policy(&self) -> Option<DiskScratchPolicy> {
564 self.budgets.scratch.disk_scratch_policy
565 }
566
567 pub(crate) fn can_reserve_schedule_discovery(&self, bytes: u64) -> bool {
568 let Some(next) = self.scratch_current.checked_add(bytes) else {
569 return false;
570 };
571 self.schedule_discovery_limit()
572 .is_none_or(|limit| next <= limit)
573 }
574
575 pub(crate) fn account_mixed_cache(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
576 let Some(without_previous) = self.retained_current.checked_sub(self.mixed_cache_current)
577 else {
578 return Err(ResourceLedgerError::ReleaseUnderflow {
579 reason: ResourceExhaustionReason::RetainedMemory,
580 reserved: self.retained_current,
581 released: self.mixed_cache_current,
582 });
583 };
584 let Some(next) = without_previous.checked_add(bytes) else {
585 return Err(self.exhausted(
586 ResourceExhaustionReason::ArithmeticOverflow,
587 u64::MAX,
588 u64::MAX,
589 ));
590 };
591 if let Some(limit) = self.mixed_cache_limit()
592 && next > limit
593 {
594 return Err(self.exhausted(ResourceExhaustionReason::RetainedMemory, limit, next));
595 }
596 self.retained_current = next;
597 self.mixed_cache_current = bytes;
598 self.retained_peak = self.retained_peak.max(next);
599 Ok(())
600 }
601
602 pub(crate) fn reserve_graph_source(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
603 let Some(next) = self.scratch_current.checked_add(bytes) else {
604 return Err(self.exhausted(
605 ResourceExhaustionReason::ArithmeticOverflow,
606 u64::MAX,
607 u64::MAX,
608 ));
609 };
610 if let Some(limit) = self.graph_source_limit()
611 && next > limit
612 {
613 return Err(self.exhausted(ResourceExhaustionReason::ScratchMemory, limit, next));
614 }
615 self.scratch_current = next;
616 self.scratch_peak = self.scratch_peak.max(next);
617 Ok(())
618 }
619
620 pub(crate) fn reserve_schedule_discovery(
621 &mut self,
622 bytes: u64,
623 ) -> Result<(), ResourceLedgerError> {
624 let Some(next) = self.scratch_current.checked_add(bytes) else {
625 return Err(self.exhausted(
626 ResourceExhaustionReason::ArithmeticOverflow,
627 u64::MAX,
628 u64::MAX,
629 ));
630 };
631 if let Some(limit) = self.schedule_discovery_limit()
632 && next > limit
633 {
634 return Err(self.exhausted(ResourceExhaustionReason::ScratchMemory, limit, next));
635 }
636 self.scratch_current = next;
637 self.scratch_peak = self.scratch_peak.max(next);
638 Ok(())
639 }
640
641 pub fn reserve_retained(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
642 let Some(next) = self.retained_current.checked_add(bytes) else {
643 return Err(self.exhausted(
644 ResourceExhaustionReason::ArithmeticOverflow,
645 u64::MAX,
646 u64::MAX,
647 ));
648 };
649 if let Some(limit) = self.budgets.retained.total_bytes
650 && next > limit
651 {
652 return Err(self.exhausted(ResourceExhaustionReason::RetainedMemory, limit, next));
653 }
654 self.retained_current = next;
655 self.retained_peak = self.retained_peak.max(next);
656 Ok(())
657 }
658
659 pub fn release_retained(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
660 let Some(next) = self.retained_current.checked_sub(bytes) else {
661 return Err(ResourceLedgerError::ReleaseUnderflow {
662 reason: ResourceExhaustionReason::RetainedMemory,
663 reserved: self.retained_current,
664 released: bytes,
665 });
666 };
667 self.retained_current = next;
668 Ok(())
669 }
670
671 pub fn reserve_scratch(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
672 let Some(next) = self.scratch_current.checked_add(bytes) else {
673 return Err(self.exhausted(
674 ResourceExhaustionReason::ArithmeticOverflow,
675 u64::MAX,
676 u64::MAX,
677 ));
678 };
679 if let Some(limit) = self.budgets.scratch.total_bytes
680 && next > limit
681 {
682 return Err(self.exhausted(ResourceExhaustionReason::ScratchMemory, limit, next));
683 }
684 self.scratch_current = next;
685 self.scratch_peak = self.scratch_peak.max(next);
686 Ok(())
687 }
688
689 pub fn release_scratch(&mut self, bytes: u64) -> Result<(), ResourceLedgerError> {
690 let Some(next) = self.scratch_current.checked_sub(bytes) else {
691 return Err(ResourceLedgerError::ReleaseUnderflow {
692 reason: ResourceExhaustionReason::ScratchMemory,
693 reserved: self.scratch_current,
694 released: bytes,
695 });
696 };
697 self.scratch_current = next;
698 Ok(())
699 }
700
701 pub(crate) fn scratch_checkpoint(&self) -> u64 {
702 self.scratch_current
703 }
704
705 pub(crate) fn release_scratch_to(
706 &mut self,
707 checkpoint: u64,
708 ) -> Result<(), ResourceLedgerError> {
709 if checkpoint > self.scratch_current {
710 return Err(ResourceLedgerError::ReleaseUnderflow {
711 reason: ResourceExhaustionReason::ScratchMemory,
712 reserved: self.scratch_current,
713 released: checkpoint,
714 });
715 }
716 self.scratch_current = checkpoint;
717 Ok(())
718 }
719
720 pub fn release_all_scratch(&mut self) {
721 self.scratch_current = 0;
722 }
723
724 pub fn charge_work(&mut self, units: u64) -> Result<(), ResourceLedgerError> {
725 let Some(next) = self.work_charged.checked_add(units) else {
726 return Err(self.exhausted(
727 ResourceExhaustionReason::ArithmeticOverflow,
728 u64::MAX,
729 u64::MAX,
730 ));
731 };
732 if let Some(limit) = self.budgets.work.max_work_units
733 && next > limit
734 {
735 return Err(self.exhausted(ResourceExhaustionReason::WorkUnits, limit, next));
736 }
737 self.work_charged = next;
738 Ok(())
739 }
740
741 pub fn checkpoint_deadline(&mut self) -> Result<(), ResourceLedgerError> {
742 self.preflight_commit_window(Duration::ZERO)
743 }
744
745 pub(crate) fn preflight_commit_window(
746 &mut self,
747 estimate: Duration,
748 ) -> Result<(), ResourceLedgerError> {
749 self.deadline_checkpoints = self.deadline_checkpoints.saturating_add(1);
750 let Some(limit) = self.budgets.deadline.max_elapsed else {
751 return Ok(());
752 };
753 let elapsed = (self.elapsed)();
754 let projected = elapsed.checked_add(estimate).unwrap_or(Duration::MAX);
755 if projected >= limit {
756 let limit_ns = u64::try_from(limit.as_nanos()).unwrap_or(u64::MAX);
757 let observed_ns = u64::try_from(projected.as_nanos()).unwrap_or(u64::MAX);
758 return Err(self.exhausted(ResourceExhaustionReason::Deadline, limit_ns, observed_ns));
759 }
760 Ok(())
761 }
762
763 pub fn snapshot(&self) -> ResourceLedgerSnapshot {
764 ResourceLedgerSnapshot {
765 retained_limit: self.budgets.retained.total_bytes,
766 mixed_cache_limit: self.mixed_cache_limit(),
767 retained_current: self.retained_current,
768 retained_peak: self.retained_peak,
769 scratch_limit: self.budgets.scratch.total_bytes,
770 schedule_discovery_limit: self.schedule_discovery_limit(),
771 scratch_current: self.scratch_current,
772 scratch_peak: self.scratch_peak,
773 disk_scratch_policy: self.budgets.scratch.disk_scratch_policy,
774 work_limit: self.budgets.work.max_work_units,
775 work_charged: self.work_charged,
776 deadline_ns: self
777 .budgets
778 .deadline
779 .max_elapsed
780 .map(|duration| u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)),
781 deadline_checkpoints: self.deadline_checkpoints,
782 exhaustion: self.exhaustion,
783 }
784 }
785}