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