memory_budget/budget/memory/accessors.rs
1//! Read-only accessors and the lease API on [`MemoryBudget`].
2
3use std::sync::Arc;
4use std::sync::atomic::Ordering;
5use std::time::Duration;
6
7use super::handle::MemoryBudget;
8use crate::ReservationError;
9use crate::lease::LeaseGuard;
10
11const EXECUTOR_SPILL_TARGET_DIVISOR: u64 = 16;
12const MIN_EXECUTOR_SPILL_RUN_BUFFER_BYTES: u64 = 1024 * 1024;
13const MAX_EXECUTOR_SPILL_RUN_BUFFER_BYTES: u64 = 32 * 1024 * 1024;
14
15const RESULT_CHANNEL_TARGET_DIVISOR: u64 = 32;
16const MIN_RESULT_CHANNEL_BYTES: u64 = 256 * 1024;
17const MAX_RESULT_CHANNEL_BYTES: u64 = 8 * 1024 * 1024;
18
19impl MemoryBudget {
20 /// Observe current process/allocator usage without running a cache tick.
21 ///
22 /// This is a measurement, not an allocation reservation. The maximum of
23 /// process usage, allocator residency and live heap bytes protects against
24 /// both compressed pages and allocations outside the configured allocator.
25 #[must_use]
26 pub fn observed_usage_bytes(&self) -> u64 {
27 self.state
28 .jemalloc
29 .read()
30 .observed_usage_bytes(self.state.rss.read_rss_bytes())
31 }
32
33 /// Reserve `bytes`, panicking if outstanding leases would exceed the target.
34 ///
35 /// Use [`Self::try_lease`] when exhaustion is an expected refusal. The
36 /// reservation must precede allocation and follow its ownership to release.
37 /// This bounds outstanding reservations, not unregistered allocations.
38 ///
39 /// # Panics
40 ///
41 /// Panics when the aggregate reservation would exceed the target.
42 #[must_use = "dropping the guard immediately releases the lease"]
43 pub fn lease(&self, bytes: u64) -> LeaseGuard {
44 self.try_lease(bytes)
45 .unwrap_or_else(|error| panic!("{error}"))
46 }
47
48 /// Reserve bytes atomically without exceeding the aggregate lease target.
49 ///
50 /// Registered cache ceilings are reduced by active leases on the next
51 /// coordination tick. Existing allocations are not synchronously evicted;
52 /// callers must account for cache/baseline ownership separately before
53 /// claiming an enforced whole-process allocation bound.
54 ///
55 /// # Errors
56 ///
57 /// Returns [`ReservationError`] without changing accounting when the
58 /// request cannot fit. No payload is allocated and no waiter is queued.
59 pub fn try_lease(&self, bytes: u64) -> Result<LeaseGuard, ReservationError> {
60 let capacity = self.target_bytes();
61 let mut reserved = self.state.active_lease_bytes.load(Ordering::Relaxed);
62 loop {
63 let Some(next) = reserved.checked_add(bytes).filter(|next| *next <= capacity) else {
64 return Err(ReservationError::new(bytes, reserved, capacity));
65 };
66 match self.state.active_lease_bytes.compare_exchange_weak(
67 reserved,
68 next,
69 Ordering::Relaxed,
70 Ordering::Relaxed,
71 ) {
72 Ok(_) => return Ok(LeaseGuard::new(bytes, Arc::clone(&self.state))),
73 Err(observed) => reserved = observed,
74 }
75 }
76 }
77
78 /// Soft RSS target in bytes, before any lease deductions.
79 #[must_use]
80 pub fn target_bytes(&self) -> u64 {
81 self.state.config.target_bytes()
82 }
83
84 /// Effective target after subtracting admitted active leases.
85 #[must_use]
86 pub fn effective_target_bytes(&self) -> u64 {
87 self.state
88 .config
89 .target_bytes()
90 .saturating_sub(self.state.active_lease_bytes.load(Ordering::Relaxed))
91 }
92
93 /// Per-operator external-sort run buffer for executor spill paths.
94 ///
95 /// Derived from the effective process target, so active leases shrink
96 /// executor spill buffers before an operator starts rather than letting
97 /// every operator size itself against the whole process budget.
98 ///
99 /// # Panics
100 ///
101 /// Panics if the bounded spill-buffer size cannot fit in `usize`.
102 #[must_use]
103 pub fn executor_spill_run_buffer_bytes(&self) -> usize {
104 let bytes = (self.effective_target_bytes() / EXECUTOR_SPILL_TARGET_DIVISOR).clamp(
105 MIN_EXECUTOR_SPILL_RUN_BUFFER_BYTES,
106 MAX_EXECUTOR_SPILL_RUN_BUFFER_BYTES,
107 );
108 usize::try_from(bytes).expect("executor spill run buffer fits usize")
109 }
110
111 /// Byte budget for one result stream's producer→consumer channel
112 /// (BME-22): the ceiling on encoded result bytes in flight between
113 /// the encoder and its consumer/spool tee. A **byte** bound, not a
114 /// frame count — frames vary in size by orders of magnitude, so
115 /// counting them would bound the wrong quantity (BME-12's lesson).
116 ///
117 /// Derived from the effective process target so active leases shrink
118 /// in-flight result buffering the same way they shrink spill
119 /// buffers.
120 ///
121 /// # Panics
122 ///
123 /// Panics if the bounded channel size cannot fit in `usize`.
124 #[must_use]
125 pub fn result_channel_bytes(&self) -> usize {
126 let bytes = (self.effective_target_bytes() / RESULT_CHANNEL_TARGET_DIVISOR)
127 .clamp(MIN_RESULT_CHANNEL_BYTES, MAX_RESULT_CHANNEL_BYTES);
128 usize::try_from(bytes).expect("result channel size fits usize")
129 }
130
131 /// Tick cadence used by the spawned background loop.
132 #[must_use]
133 pub fn tick_interval(&self) -> Duration {
134 self.state.config.tick()
135 }
136
137 /// Optional hard ceiling. When set, the budget aborts the
138 /// process if RSS exceeds this value — a backstop for runaway
139 /// growth in production.
140 #[must_use]
141 pub fn hard_ceiling_bytes(&self) -> Option<u64> {
142 self.state.config.hard_ceiling_bytes()
143 }
144}