Skip to main content

molframe_core/execution/
context.rs

1//! Immutable policy and shared state for one execution graph.
2
3use super::admission::Admission;
4use super::memory::MemoryAccount;
5use super::spill::DiskAccount;
6use super::{
7    CancellationToken, MemoryBudget, MemoryBudgetError, MemoryReservation, ScratchPolicy,
8    SpillError, SpillFile, TempStoragePolicy,
9};
10use crate::parallel::SharedPool;
11use std::fmt;
12use std::sync::Arc;
13
14/// Shared controls passed through every stage of an out-of-core operation.
15#[derive(Clone, Debug)]
16pub struct ExecutionContext {
17    pub(super) memory: Arc<MemoryAccount>,
18    cancellation: CancellationToken,
19    scratch: ScratchPolicy,
20    temp_storage: TempStoragePolicy,
21    disk: Option<Arc<DiskAccount>>,
22    executor: Arc<SharedPool>,
23    worker_budget: usize,
24    pub(crate) admission: Arc<Admission>,
25}
26
27impl ExecutionContext {
28    /// Starts a declarative context builder with bounded defaults.
29    #[must_use]
30    pub fn builder() -> ExecutionContextBuilder {
31        ExecutionContextBuilder::default()
32    }
33
34    /// Returns this context's execution-memory ceiling.
35    #[must_use]
36    pub fn memory_budget(&self) -> MemoryBudget {
37        self.memory.budget()
38    }
39
40    /// Maximum native tasks one operation may submit to the shared pool.
41    #[must_use]
42    pub const fn worker_budget(&self) -> usize {
43        self.worker_budget
44    }
45
46    pub(crate) fn executor(&self) -> &SharedPool {
47        &self.executor
48    }
49
50    /// Returns the shared cooperative cancellation token.
51    #[must_use]
52    pub const fn cancellation(&self) -> &CancellationToken {
53        &self.cancellation
54    }
55
56    /// Returns the reusable scratch policy.
57    #[must_use]
58    pub const fn scratch_policy(&self) -> ScratchPolicy {
59        self.scratch
60    }
61
62    /// Returns the temporary-storage policy.
63    #[must_use]
64    pub const fn temp_storage_policy(&self) -> &TempStoragePolicy {
65        &self.temp_storage
66    }
67
68    /// Creates one bounded, execution-owned spill file.
69    ///
70    /// The key becomes the deterministic file name below the configured root.
71    /// A file with the same key is never overwritten.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`SpillError::Disabled`] when spill is not configured, or a
76    /// storage error when the key, disk budget, or destination is invalid.
77    pub fn create_spill_file(&self, key: &str) -> Result<SpillFile, SpillError> {
78        let Some(account) = &self.disk else {
79            return Err(SpillError::Disabled);
80        };
81        SpillFile::create(Arc::clone(account), key)
82    }
83
84    /// Returns temporary bytes currently retained by this execution graph.
85    #[must_use]
86    pub fn spill_bytes(&self) -> u64 {
87        match &self.disk {
88            Some(account) => account.used_bytes(),
89            None => 0,
90        }
91    }
92
93    /// Total physical bytes written to execution-owned spill files.
94    #[must_use]
95    pub fn total_spilled_bytes(&self) -> u64 {
96        match &self.disk {
97            Some(account) => account.spilled_bytes(),
98            None => 0,
99        }
100    }
101
102    /// Bytes currently retained by execution-owned allocations.
103    #[must_use]
104    pub fn reserved_bytes(&self) -> usize {
105        self.memory.reserved_bytes()
106    }
107
108    /// Highest number of execution-owned bytes retained concurrently.
109    #[must_use]
110    pub fn peak_reserved_bytes(&self) -> usize {
111        self.memory.peak_reserved_bytes()
112    }
113
114    /// Batch leases that are currently alive.
115    #[must_use]
116    pub fn live_batches(&self) -> usize {
117        self.memory.live_batches()
118    }
119
120    /// Highest number of batch leases alive concurrently.
121    #[must_use]
122    pub fn peak_live_batches(&self) -> usize {
123        self.memory.peak_live_batches()
124    }
125
126    /// Charges retained bytes against this context until the result is dropped.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`MemoryBudgetError::Exhausted`] when the requested bytes do not
131    /// fit alongside current reservations.
132    pub fn try_reserve(&self, bytes: usize) -> Result<MemoryReservation, MemoryBudgetError> {
133        MemoryAccount::reserve(&self.memory, bytes)
134    }
135}
136
137impl Default for ExecutionContext {
138    fn default() -> Self {
139        Self::builder().build_infallible()
140    }
141}
142
143/// Declarative construction of an [`ExecutionContext`].
144#[derive(Clone, Debug, Default)]
145pub struct ExecutionContextBuilder {
146    memory_budget: MemoryBudget,
147    cancellation: CancellationToken,
148    scratch: ScratchPolicy,
149    temp_storage: TempStoragePolicy,
150    worker_budget: Option<usize>,
151}
152
153impl ExecutionContextBuilder {
154    /// Replaces the default 100 MB execution budget.
155    #[must_use]
156    pub const fn memory_budget(mut self, budget: MemoryBudget) -> Self {
157        self.memory_budget = budget;
158        self
159    }
160
161    /// Uses an existing cancellation domain.
162    #[must_use]
163    pub fn cancellation(mut self, token: CancellationToken) -> Self {
164        self.cancellation = token;
165        self
166    }
167
168    /// Replaces the reusable scratch policy.
169    #[must_use]
170    pub const fn scratch_policy(mut self, policy: ScratchPolicy) -> Self {
171        self.scratch = policy;
172        self
173    }
174
175    /// Replaces the temporary-storage policy.
176    #[must_use]
177    pub fn temp_storage_policy(mut self, policy: TempStoragePolicy) -> Self {
178        self.temp_storage = policy;
179        self
180    }
181
182    /// Caps native tasks submitted by one operation to the shared worker pool.
183    #[must_use]
184    pub const fn worker_budget(mut self, workers: usize) -> Self {
185        self.worker_budget = Some(workers);
186        self
187    }
188
189    /// Validates policy relationships and constructs the context.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`ContextError`] if reusable scratch could exceed the complete
194    /// execution-memory budget or spill is configured with no disk capacity.
195    pub fn build(self) -> Result<ExecutionContext, ContextError> {
196        if self.worker_budget == Some(0) {
197            return Err(ContextError::ZeroWorkers);
198        }
199        if self.scratch.max_bytes() > self.memory_budget.bytes() {
200            return Err(ContextError::ScratchExceedsMemory {
201                scratch: self.scratch.max_bytes(),
202                memory: self.memory_budget.bytes(),
203            });
204        }
205        if matches!(
206            self.temp_storage,
207            TempStoragePolicy::Directory { max_bytes: 0, .. }
208        ) {
209            return Err(ContextError::EmptyTempStorage);
210        }
211        Ok(self.finish())
212    }
213
214    fn build_infallible(self) -> ExecutionContext {
215        self.finish()
216    }
217
218    fn finish(self) -> ExecutionContext {
219        let disk = DiskAccount::from_policy(&self.temp_storage);
220        let executor = SharedPool::global();
221        let worker_budget = self.worker_budget.map_or_else(
222            || executor.capacity(),
223            |workers| workers.min(executor.capacity()),
224        );
225        ExecutionContext {
226            memory: Arc::new(MemoryAccount::new(self.memory_budget)),
227            cancellation: self.cancellation,
228            scratch: self.scratch,
229            temp_storage: self.temp_storage,
230            disk,
231            executor,
232            worker_budget,
233            admission: Arc::new(Admission::new(worker_budget.saturating_mul(2))),
234        }
235    }
236}
237
238/// Inconsistent execution policies supplied to a context builder.
239#[derive(Clone, Copy, Debug, PartialEq, Eq)]
240pub enum ContextError {
241    /// Reusable scratch alone would exceed the execution-memory ceiling.
242    ScratchExceedsMemory {
243        /// Configured scratch bytes.
244        scratch: usize,
245        /// Configured complete memory budget.
246        memory: usize,
247    },
248    /// Enabled temporary storage must have non-zero capacity.
249    EmptyTempStorage,
250    /// At least one worker is required to make progress.
251    ZeroWorkers,
252}
253
254impl fmt::Display for ContextError {
255    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
256        match self {
257            Self::ScratchExceedsMemory { scratch, memory } => write!(
258                formatter,
259                "scratch policy retains {scratch} bytes but memory budget is {memory}"
260            ),
261            Self::EmptyTempStorage => {
262                formatter.write_str("enabled temporary storage must have non-zero capacity")
263            }
264            Self::ZeroWorkers => formatter.write_str("execution worker budget must be non-zero"),
265        }
266    }
267}
268
269impl std::error::Error for ContextError {}
270
271#[cfg(test)]
272#[path = "context_tests.rs"]
273mod tests;