Skip to main content

molframe_core/execution/
memory.rs

1//! Shared accounting for bounded execution memory.
2
3use std::fmt;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicUsize, Ordering};
6
7/// Default upper bound for retained execution workspace: 100 MB.
8///
9/// A default, not a ceiling. The caller who provisioned the machine is the only
10/// party who knows what the working set may be, so a budget larger than this is
11/// accepted without argument.
12pub const DEFAULT_MEMORY_BUDGET_BYTES: usize = 100_000_000;
13
14/// A validated upper bound for memory retained by one execution context.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub struct MemoryBudget {
17    bytes: usize,
18}
19
20impl MemoryBudget {
21    /// Creates a non-zero budget.
22    ///
23    /// There is no compile-time maximum. A ceiling written into a library is a
24    /// ceiling nobody can raise, and a caller reading a hundred-gigabyte entry
25    /// needs a working set no constant here could have anticipated.
26    ///
27    /// # Errors
28    ///
29    /// Returns [`MemoryBudgetError::Zero`] when `bytes` is zero, because a
30    /// zero-byte budget cannot make forward progress.
31    pub const fn new(bytes: usize) -> Result<Self, MemoryBudgetError> {
32        if bytes == 0 {
33            return Err(MemoryBudgetError::Zero);
34        }
35        Ok(Self { bytes })
36    }
37
38    /// Returns the maximum number of retained bytes.
39    #[must_use]
40    pub const fn bytes(self) -> usize {
41        self.bytes
42    }
43}
44
45impl Default for MemoryBudget {
46    fn default() -> Self {
47        Self {
48            bytes: DEFAULT_MEMORY_BUDGET_BYTES,
49        }
50    }
51}
52
53/// Failure to construct or reserve an execution-memory budget.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum MemoryBudgetError {
56    /// A zero-byte budget cannot make forward progress.
57    Zero,
58    /// The context has insufficient unreserved capacity.
59    Exhausted {
60        /// Bytes requested by the operation.
61        requested: usize,
62        /// Bytes still available at the time of the attempt.
63        available: usize,
64    },
65}
66
67impl fmt::Display for MemoryBudgetError {
68    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            Self::Zero => formatter.write_str("execution memory budget must be non-zero"),
71            Self::Exhausted {
72                requested,
73                available,
74            } => write!(
75                formatter,
76                "execution requested {requested} bytes with {available} bytes available"
77            ),
78        }
79    }
80}
81
82impl std::error::Error for MemoryBudgetError {}
83
84#[derive(Debug)]
85pub(crate) struct MemoryAccount {
86    budget: MemoryBudget,
87    reserved: AtomicUsize,
88    peak_reserved: AtomicUsize,
89    live_batches: AtomicUsize,
90    peak_live_batches: AtomicUsize,
91}
92
93impl MemoryAccount {
94    pub(crate) fn new(budget: MemoryBudget) -> Self {
95        Self {
96            budget,
97            reserved: AtomicUsize::new(0),
98            peak_reserved: AtomicUsize::new(0),
99            live_batches: AtomicUsize::new(0),
100            peak_live_batches: AtomicUsize::new(0),
101        }
102    }
103
104    pub(crate) const fn budget(&self) -> MemoryBudget {
105        self.budget
106    }
107
108    pub(crate) fn reserve(
109        account: &Arc<Self>,
110        bytes: usize,
111    ) -> Result<MemoryReservation, MemoryBudgetError> {
112        let mut current = account.reserved.load(Ordering::Acquire);
113        loop {
114            let Some(next) = current.checked_add(bytes) else {
115                return Err(MemoryBudgetError::Exhausted {
116                    requested: bytes,
117                    available: account.budget.bytes().saturating_sub(current),
118                });
119            };
120            if next > account.budget.bytes() {
121                return Err(MemoryBudgetError::Exhausted {
122                    requested: bytes,
123                    available: account.budget.bytes().saturating_sub(current),
124                });
125            }
126            match account.reserved.compare_exchange_weak(
127                current,
128                next,
129                Ordering::AcqRel,
130                Ordering::Acquire,
131            ) {
132                Ok(_) => {
133                    account.peak_reserved.fetch_max(next, Ordering::AcqRel);
134                    return Ok(MemoryReservation {
135                        account: Arc::clone(account),
136                        bytes,
137                    });
138                }
139                Err(observed) => current = observed,
140            }
141        }
142    }
143
144    pub(crate) fn reserved_bytes(&self) -> usize {
145        self.reserved.load(Ordering::Acquire)
146    }
147
148    pub(crate) fn peak_reserved_bytes(&self) -> usize {
149        self.peak_reserved.load(Ordering::Acquire)
150    }
151
152    pub(crate) fn live_batches(&self) -> usize {
153        self.live_batches.load(Ordering::Acquire)
154    }
155
156    pub(crate) fn peak_live_batches(&self) -> usize {
157        self.peak_live_batches.load(Ordering::Acquire)
158    }
159
160    fn begin_batch(account: &Arc<Self>) -> BatchGuard {
161        let live = account.live_batches.fetch_add(1, Ordering::AcqRel) + 1;
162        account.peak_live_batches.fetch_max(live, Ordering::AcqRel);
163        BatchGuard {
164            account: Arc::clone(account),
165        }
166    }
167}
168
169/// Exclusive accounting for bytes retained by an operation.
170///
171/// Dropping the value returns its capacity to the context. Reservations cannot
172/// be cloned, preventing one charge from representing multiple owners.
173#[derive(Debug)]
174pub struct MemoryReservation {
175    account: Arc<MemoryAccount>,
176    bytes: usize,
177}
178
179impl MemoryReservation {
180    /// Reports whether this reservation belongs to the supplied execution account.
181    #[must_use]
182    pub fn belongs_to(&self, context: &super::ExecutionContext) -> bool {
183        Arc::ptr_eq(&self.account, &context.memory)
184    }
185
186    /// Returns the charged byte count.
187    #[must_use]
188    pub const fn bytes(&self) -> usize {
189        self.bytes
190    }
191
192    /// Reduces this reservation to the exact retained byte count.
193    pub fn shrink_to(&mut self, bytes: usize) {
194        if bytes >= self.bytes {
195            return;
196        }
197        let released = self.bytes - bytes;
198        self.bytes = bytes;
199        self.account.reserved.fetch_sub(released, Ordering::AcqRel);
200    }
201
202    /// Extends this reservation before its owner grows an allocation.
203    ///
204    /// # Errors
205    ///
206    /// Returns [`MemoryBudgetError::Exhausted`] without changing this
207    /// reservation when the additional bytes do not fit.
208    pub fn try_grow(&mut self, additional: usize) -> Result<(), MemoryBudgetError> {
209        if additional == 0 {
210            return Ok(());
211        }
212        let mut extra = MemoryAccount::reserve(&self.account, additional)?;
213        let Some(bytes) = self.bytes.checked_add(additional) else {
214            return Err(MemoryBudgetError::Exhausted {
215                requested: additional,
216                available: 0,
217            });
218        };
219        self.bytes = bytes;
220        extra.bytes = 0;
221        Ok(())
222    }
223
224    pub(crate) fn begin_batch(&self) -> BatchGuard {
225        MemoryAccount::begin_batch(&self.account)
226    }
227}
228
229impl Drop for MemoryReservation {
230    fn drop(&mut self) {
231        self.account
232            .reserved
233            .fetch_sub(self.bytes, Ordering::AcqRel);
234    }
235}
236
237/// Exactly-once accounting for one live batch lease.
238#[derive(Debug)]
239pub(crate) struct BatchGuard {
240    account: Arc<MemoryAccount>,
241}
242
243impl Drop for BatchGuard {
244    fn drop(&mut self) {
245        self.account.live_batches.fetch_sub(1, Ordering::AcqRel);
246    }
247}
248
249#[cfg(test)]
250#[path = "memory_tests.rs"]
251mod tests;
252
253crate::diagnostic_from!(
254    MemoryBudgetError,
255    |_error| crate::Code::E7001,
256    |diagnostic, error| match error {
257        MemoryBudgetError::Zero => diagnostic.with_context("budget", "zero"),
258        MemoryBudgetError::Exhausted {
259            requested,
260            available,
261        } => diagnostic
262            .with_context("requested_bytes", requested.to_string())
263            .with_context("available_bytes", available.to_string()),
264    }
265);