Skip to main content

hawdb_executor/
memory.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Execution memory defaults and admission estimates.
16
17use hawdb_core::{HawDBError, Result, RuntimeTaskContext};
18use hawdb_plan_cypher::{
19    GraphExpansionBudget, PhysicalPlan, PlanChildren, VectorExecutionResourceProfile,
20};
21use hawdb_storage::mutation::MutationLimits;
22use std::num::{NonZeroU64, NonZeroUsize};
23use std::path::PathBuf;
24use std::time::Duration;
25
26#[doc(hidden)]
27pub fn enforced_query_memory_budget(
28    memory: &ExecutionMemoryConfig,
29    task_context: Option<&RuntimeTaskContext>,
30) -> Result<NonZeroUsize> {
31    let Some(reservation) = task_context.and_then(RuntimeTaskContext::memory_reservation) else {
32        return Ok(memory.query_memory_bytes);
33    };
34    // Never widen an undersized admission to an operator-configured floor.
35    // The shared root remains the admitted reservation and the operator fails
36    // closed when its first charge cannot fit.
37    runtime_memory_budget("query memory", reservation.memory_bytes())
38}
39
40#[doc(hidden)]
41pub fn enforced_result_memory_budget(
42    memory: &ExecutionMemoryConfig,
43    task_context: Option<&RuntimeTaskContext>,
44) -> Result<NonZeroUsize> {
45    let Some(reservation) = task_context.and_then(RuntimeTaskContext::memory_reservation) else {
46        return Ok(memory.query_memory_bytes);
47    };
48    let admitted = runtime_memory_budget("query result", reservation.result_bytes())?;
49    Ok(admitted.min(memory.query_memory_bytes))
50}
51
52fn runtime_memory_budget(owner: &str, bytes: u64) -> Result<NonZeroUsize> {
53    let bytes = usize::try_from(bytes).map_err(|_| {
54        HawDBError::Execution(format!(
55            "runtime-admitted {owner} reservation {bytes} does not fit the executor address space"
56        ))
57    })?;
58    NonZeroUsize::new(bytes).ok_or_else(|| {
59        HawDBError::Execution(format!(
60            "runtime-admitted {owner} reservation must be non-zero"
61        ))
62    })
63}
64
65#[doc(hidden)]
66pub const DEFAULT_EXECUTION_BATCH_ROWS: usize = 256;
67const DEFAULT_EXECUTION_BATCH_PAYLOAD_BYTES: usize = 16 * 1024 * 1024;
68const DEFAULT_QUERY_MEMORY_BYTES: usize = 256 * 1024 * 1024;
69#[doc(hidden)]
70pub const DEFAULT_BLOCKING_OPERATOR_MEMORY_BYTES: usize = 64 * 1024 * 1024;
71const DEFAULT_EXECUTION_MAX_SPILL_BYTES: u64 = 4 * 1024 * 1024 * 1024;
72const DEFAULT_EXECUTION_MAX_SPILL_RUNS: usize = 128;
73const DEFAULT_EXECUTION_MAX_TOTAL_SPILL_BYTES: u64 = 16 * 1024 * 1024 * 1024;
74const DEFAULT_EXECUTION_MAX_TOTAL_SPILL_RUNS: usize = 512;
75const DEFAULT_EXECUTION_MIN_SPILL_FREE_BYTES: u64 = 1024 * 1024 * 1024;
76const DEFAULT_SPILL_FREE_SPACE_PROBE_INTERVAL_BYTES: u64 = 64 * 1024 * 1024;
77const DEFAULT_SPILL_ORPHAN_GRACE_PERIOD: Duration = Duration::from_secs(24 * 60 * 60);
78const MUTATION_OPERATION_BOOKKEEPING_BYTES: u64 = 64;
79const MUTATION_AFFECTED_ROW_BOOKKEEPING_BYTES: u64 = 16;
80const MUTATION_RESULT_ROW_BOOKKEEPING_BYTES: u64 = 64;
81#[doc(hidden)]
82pub const SOURCE_SEGMENT_SCAN_MAX_WAVE_BYTES: u64 = 2 * 1024 * 1024;
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ExecutionMemoryConfig {
86    /// Maximum tracked resident bytes owned by one query across all operators.
87    pub query_memory_bytes: NonZeroUsize,
88    /// Maximum row count in an executor-owned transfer batch.
89    pub batch_rows: NonZeroUsize,
90    /// Maximum estimated resident bytes in an executor-owned transfer batch.
91    pub batch_payload_bytes: NonZeroUsize,
92    /// Maximum estimated bytes retained for blocking state, including sort,
93    /// aggregation, deduplication, graph traversal state and selected top-K.
94    /// Source reads use the shared query budget independently of this cap.
95    pub blocking_operator_bytes: NonZeroUsize,
96    /// Override the row and cumulative payload limits of each seeded expansion.
97    /// Live allocations still obey the operator, batch and shared query budgets.
98    /// `None` uses the optimizer's seed-window-derived limits.
99    pub graph_expansion_budget: Option<GraphExpansionBudget>,
100    /// Maximum cumulative serialized spill bytes, including merge passes.
101    pub max_spill_bytes: NonZeroU64,
102    /// Maximum cumulative spill runs created, including merge passes.
103    pub max_spill_runs: NonZeroUsize,
104    /// Maximum live spill bytes shared by all queries using the spill directory.
105    pub max_total_spill_bytes: NonZeroU64,
106    /// Maximum live spill runs shared by all queries using the spill directory.
107    pub max_total_spill_runs: NonZeroUsize,
108    /// Free space preserved on the filesystem containing the spill directory.
109    pub min_spill_free_bytes: NonZeroU64,
110    /// Reserved bytes between filesystem free-space probes for one spill pool.
111    pub spill_free_space_probe_interval_bytes: NonZeroU64,
112    /// Minimum age before a spill file from an earlier process is removed.
113    pub spill_orphan_grace_period: Duration,
114    /// Directory governed as one shared spill pool.
115    pub spill_directory: PathBuf,
116}
117
118impl Default for ExecutionMemoryConfig {
119    fn default() -> Self {
120        Self {
121            query_memory_bytes: NonZeroUsize::new(DEFAULT_QUERY_MEMORY_BYTES)
122                .expect("default query memory budget is non-zero"),
123            batch_rows: NonZeroUsize::new(DEFAULT_EXECUTION_BATCH_ROWS)
124                .expect("default execution batch size is non-zero"),
125            batch_payload_bytes: NonZeroUsize::new(DEFAULT_EXECUTION_BATCH_PAYLOAD_BYTES)
126                .expect("default execution batch byte size is non-zero"),
127            blocking_operator_bytes: NonZeroUsize::new(DEFAULT_BLOCKING_OPERATOR_MEMORY_BYTES)
128                .expect("default blocking operator memory budget is non-zero"),
129            graph_expansion_budget: None,
130            max_spill_bytes: NonZeroU64::new(DEFAULT_EXECUTION_MAX_SPILL_BYTES)
131                .expect("default spill byte budget is non-zero"),
132            max_spill_runs: NonZeroUsize::new(DEFAULT_EXECUTION_MAX_SPILL_RUNS)
133                .expect("default spill run budget is non-zero"),
134            max_total_spill_bytes: NonZeroU64::new(DEFAULT_EXECUTION_MAX_TOTAL_SPILL_BYTES)
135                .expect("default total spill byte budget is non-zero"),
136            max_total_spill_runs: NonZeroUsize::new(DEFAULT_EXECUTION_MAX_TOTAL_SPILL_RUNS)
137                .expect("default total spill run budget is non-zero"),
138            min_spill_free_bytes: NonZeroU64::new(DEFAULT_EXECUTION_MIN_SPILL_FREE_BYTES)
139                .expect("default spill free-space reserve is non-zero"),
140            spill_free_space_probe_interval_bytes: NonZeroU64::new(
141                DEFAULT_SPILL_FREE_SPACE_PROBE_INTERVAL_BYTES,
142            )
143            .expect("default spill free-space probe interval is non-zero"),
144            spill_orphan_grace_period: DEFAULT_SPILL_ORPHAN_GRACE_PERIOD,
145            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
146            spill_directory: std::env::temp_dir().join("hawdb-spill"),
147            // Browser queries are memory-only. Do not call the unsupported temp_dir().
148            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
149            spill_directory: PathBuf::new(),
150        }
151    }
152}
153
154impl ExecutionMemoryConfig {
155    /// Returns process-wide spill usage for this configured directory.
156    pub fn spill_pool_snapshot(&self) -> hawdb_core::Result<crate::SpillPoolSnapshot> {
157        crate::spill::spill_pool_snapshot(self)
158    }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162#[doc(hidden)]
163pub struct ExecutionMemoryEstimate {
164    pub pipeline_batch_count: usize,
165    pub blocking_operator_count: usize,
166    pub pipeline_bytes: u64,
167    pub blocking_bytes: u64,
168    pub external_read_bytes: u64,
169    pub fixed_operator_bytes: u64,
170    pub total_bytes: u64,
171}
172
173#[doc(hidden)]
174pub fn estimated_execution_memory(
175    plan: &PhysicalPlan,
176    memory: &ExecutionMemoryConfig,
177) -> ExecutionMemoryEstimate {
178    let shape = peak_execution_memory_shape(plan, memory);
179    let pipeline_bytes = usize_to_u64(shape.pipeline_batch_count)
180        .saturating_mul(usize_to_u64(memory.batch_payload_bytes.get()));
181    let blocking_bytes = usize_to_u64(shape.blocking_operator_count)
182        .saturating_mul(usize_to_u64(memory.blocking_operator_bytes.get()));
183    let external_read_bytes = shape.external_read_bytes;
184    let fixed_operator_bytes = shape.fixed_operator_bytes;
185    ExecutionMemoryEstimate {
186        pipeline_batch_count: shape.pipeline_batch_count,
187        blocking_operator_count: shape.blocking_operator_count,
188        pipeline_bytes,
189        blocking_bytes,
190        external_read_bytes,
191        fixed_operator_bytes,
192        total_bytes: pipeline_bytes
193            .saturating_add(blocking_bytes)
194            .saturating_add(external_read_bytes)
195            .saturating_add(fixed_operator_bytes),
196    }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200#[doc(hidden)]
201pub struct ExternalReadMemoryBudget {
202    pub max_working_bytes: NonZeroUsize,
203    pub max_result_bytes: NonZeroUsize,
204}
205
206impl ExternalReadMemoryBudget {
207    pub fn reserved_bytes(self) -> usize {
208        self.max_working_bytes
209            .get()
210            .saturating_add(self.max_result_bytes.get())
211    }
212}
213
214#[doc(hidden)]
215pub fn external_read_memory_budget(
216    profile: VectorExecutionResourceProfile,
217    memory: &ExecutionMemoryConfig,
218) -> ExternalReadMemoryBudget {
219    let configured_working_bytes = memory.blocking_operator_bytes.get();
220    let planned_working_bytes = profile
221        .max_working_memory_bytes
222        .map(|bytes| usize::try_from(bytes).unwrap_or(usize::MAX))
223        .unwrap_or(configured_working_bytes)
224        .max(1);
225    ExternalReadMemoryBudget {
226        max_working_bytes: NonZeroUsize::new(planned_working_bytes.min(configured_working_bytes))
227            .expect("configured external read working budget is non-zero"),
228        max_result_bytes: memory.blocking_operator_bytes,
229    }
230}
231
232#[doc(hidden)]
233pub fn max_external_read_parallelism(plan: &PhysicalPlan) -> usize {
234    let own_parallelism = match plan {
235        PhysicalPlan::VectorSeedScan {
236            resource_profile, ..
237        }
238        | PhysicalPlan::TextSeedScan {
239            resource_profile, ..
240        } => resource_profile.max_parallelism.max(1),
241        _ => 1,
242    };
243    match plan.children() {
244        PlanChildren::None => own_parallelism,
245        PlanChildren::Unary(input) => own_parallelism.max(max_external_read_parallelism(input)),
246        PlanChildren::Binary(left, right) => own_parallelism
247            .max(max_external_read_parallelism(left))
248            .max(max_external_read_parallelism(right)),
249    }
250}
251
252#[doc(hidden)]
253pub fn estimated_mutation_memory_bytes(
254    limits: MutationLimits,
255    max_wal_record_bytes: Option<usize>,
256) -> u64 {
257    let Some(max_wal_record_bytes) = max_wal_record_bytes else {
258        return u64::MAX;
259    };
260    usize_to_u64(max_wal_record_bytes)
261        .saturating_mul(2)
262        .saturating_add(
263            usize_to_u64(limits.max_operations.get())
264                .saturating_mul(MUTATION_OPERATION_BOOKKEEPING_BYTES),
265        )
266        .saturating_add(
267            usize_to_u64(limits.max_affected_rows.get())
268                .saturating_mul(MUTATION_AFFECTED_ROW_BOOKKEEPING_BYTES),
269        )
270        .saturating_add(
271            usize_to_u64(limits.max_result_rows.get())
272                .saturating_mul(MUTATION_RESULT_ROW_BOOKKEEPING_BYTES),
273        )
274        .saturating_add(usize_to_u64(limits.max_result_payload_bytes.get()))
275}
276
277#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
278struct ExecutionMemoryShape {
279    pipeline_batch_count: usize,
280    blocking_operator_count: usize,
281    external_read_bytes: u64,
282    fixed_operator_bytes: u64,
283}
284
285fn peak_execution_memory_shape(
286    plan: &PhysicalPlan,
287    memory: &ExecutionMemoryConfig,
288) -> ExecutionMemoryShape {
289    let mut shape = match plan.children() {
290        PlanChildren::None => ExecutionMemoryShape::default(),
291        PlanChildren::Unary(input) => peak_execution_memory_shape(input, memory),
292        PlanChildren::Binary(left, right) => peak_shape_max(
293            peak_execution_memory_shape(left, memory),
294            peak_execution_memory_shape(right, memory),
295            memory,
296        ),
297    };
298    shape.pipeline_batch_count = shape.pipeline_batch_count.saturating_add(1);
299    if retains_blocking_state(plan) {
300        shape.blocking_operator_count = shape.blocking_operator_count.saturating_add(1);
301    }
302    if matches!(plan, PhysicalPlan::SourceSegmentScan { .. }) {
303        shape.fixed_operator_bytes = shape
304            .fixed_operator_bytes
305            .saturating_add(SOURCE_SEGMENT_SCAN_MAX_WAVE_BYTES);
306    }
307    if let PhysicalPlan::VectorSeedScan {
308        resource_profile, ..
309    }
310    | PhysicalPlan::TextSeedScan {
311        resource_profile, ..
312    } = plan
313    {
314        shape.external_read_bytes = shape.external_read_bytes.saturating_add(usize_to_u64(
315            external_read_memory_budget(*resource_profile, memory).reserved_bytes(),
316        ));
317    }
318    shape
319}
320
321fn peak_shape_max(
322    left: ExecutionMemoryShape,
323    right: ExecutionMemoryShape,
324    memory: &ExecutionMemoryConfig,
325) -> ExecutionMemoryShape {
326    let shape_bytes = |shape: ExecutionMemoryShape| {
327        usize_to_u64(shape.pipeline_batch_count)
328            .saturating_mul(usize_to_u64(memory.batch_payload_bytes.get()))
329            .saturating_add(
330                usize_to_u64(shape.blocking_operator_count)
331                    .saturating_mul(usize_to_u64(memory.blocking_operator_bytes.get())),
332            )
333            .saturating_add(shape.external_read_bytes)
334            .saturating_add(shape.fixed_operator_bytes)
335    };
336    let left_total = shape_bytes(left);
337    let right_total = shape_bytes(right);
338    if left_total > right_total
339        || (left_total == right_total && left.fixed_operator_bytes >= right.fixed_operator_bytes)
340    {
341        left
342    } else {
343        right
344    }
345}
346
347fn retains_blocking_state(plan: &PhysicalPlan) -> bool {
348    matches!(
349        plan,
350        PhysicalPlan::GraphAlgorithm { .. }
351            | PhysicalPlan::VectorSeedScan { .. }
352            | PhysicalPlan::GraphSeedScan { .. }
353            | PhysicalPlan::TextSeedScan { .. }
354            | PhysicalPlan::SourceSegmentScan { .. }
355            | PhysicalPlan::NodeCartesianProductExec { .. }
356            | PhysicalPlan::HashJoinExec { .. }
357            | PhysicalPlan::AdjacencyExpandExec { .. }
358            | PhysicalPlan::OptionalRelationshipCountSumExec { .. }
359            | PhysicalPlan::ThreadRepairStatsExec { .. }
360            | PhysicalPlan::ShortestPathExec { .. }
361            | PhysicalPlan::AggregateExec { .. }
362            | PhysicalPlan::DistinctExec { .. }
363            | PhysicalPlan::SortExec { .. }
364            | PhysicalPlan::TopNExec { .. }
365            | PhysicalPlan::ScoringRerankExec { .. }
366            | PhysicalPlan::ScoringProgramExec { .. }
367            | PhysicalPlan::HostScoringExec { .. }
368    )
369}
370
371fn usize_to_u64(value: usize) -> u64 {
372    u64::try_from(value).unwrap_or(u64::MAX)
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use hawdb_plan_cypher::Predicate;
379
380    fn admission_test_config() -> ExecutionMemoryConfig {
381        ExecutionMemoryConfig {
382            query_memory_bytes: NonZeroUsize::new(16 * 1024).unwrap(),
383            batch_rows: NonZeroUsize::new(8).unwrap(),
384            batch_payload_bytes: NonZeroUsize::new(1024).unwrap(),
385            blocking_operator_bytes: NonZeroUsize::new(4096).unwrap(),
386            graph_expansion_budget: None,
387            max_spill_bytes: NonZeroU64::new(1024 * 1024).unwrap(),
388            max_spill_runs: NonZeroUsize::new(8).unwrap(),
389            max_total_spill_bytes: NonZeroU64::new(4 * 1024 * 1024).unwrap(),
390            max_total_spill_runs: NonZeroUsize::new(32).unwrap(),
391            min_spill_free_bytes: NonZeroU64::new(1).unwrap(),
392            spill_free_space_probe_interval_bytes: NonZeroU64::new(1024).unwrap(),
393            spill_orphan_grace_period: Duration::from_secs(60),
394            spill_directory: std::env::temp_dir(),
395        }
396    }
397
398    #[test]
399    fn execution_admission_uses_configured_pipeline_and_blocking_budgets() {
400        let plan = PhysicalPlan::SortExec {
401            items: Vec::new(),
402            input: Box::new(PhysicalPlan::DistinctExec {
403                input: Box::new(PhysicalPlan::SeqNodeScan {
404                    variable: "n".to_string(),
405                    label: "Node".to_string(),
406                }),
407            }),
408        };
409
410        let estimate = estimated_execution_memory(&plan, &admission_test_config());
411
412        assert_eq!(estimate.pipeline_batch_count, 3);
413        assert_eq!(estimate.blocking_operator_count, 2);
414        assert_eq!(estimate.pipeline_bytes, 3 * 1024);
415        assert_eq!(estimate.blocking_bytes, 2 * 4096);
416        assert_eq!(estimate.external_read_bytes, 0);
417        assert_eq!(estimate.fixed_operator_bytes, 0);
418        assert_eq!(estimate.total_bytes, 11 * 1024);
419    }
420
421    #[test]
422    fn binary_admission_uses_the_higher_memory_child_path() {
423        let plan = PhysicalPlan::NodeCartesianProductExec {
424            left: Box::new(PhysicalPlan::ProjectExec {
425                items: Vec::new(),
426                input: Box::new(PhysicalPlan::ProjectExec {
427                    items: Vec::new(),
428                    input: Box::new(PhysicalPlan::SeqNodeScan {
429                        variable: "left".to_string(),
430                        label: "Left".to_string(),
431                    }),
432                }),
433            }),
434            right: Box::new(PhysicalPlan::DistinctExec {
435                input: Box::new(PhysicalPlan::SeqNodeScan {
436                    variable: "right".to_string(),
437                    label: "Right".to_string(),
438                }),
439            }),
440        };
441
442        let estimate = estimated_execution_memory(&plan, &admission_test_config());
443
444        assert_eq!(estimate.pipeline_batch_count, 3);
445        assert_eq!(estimate.blocking_operator_count, 2);
446        assert_eq!(estimate.total_bytes, 11 * 1024);
447    }
448
449    #[test]
450    fn source_segment_admission_includes_the_fixed_io_wave() {
451        let plan = PhysicalPlan::SourceSegmentScan {
452            variable: "n".to_string(),
453            predicate: Predicate::ConstantBool(true),
454        };
455
456        let estimate = estimated_execution_memory(&plan, &admission_test_config());
457
458        assert_eq!(estimate.pipeline_bytes, 1024);
459        assert_eq!(estimate.blocking_operator_count, 1);
460        assert_eq!(estimate.blocking_bytes, 4096);
461        assert_eq!(estimate.external_read_bytes, 0);
462        assert_eq!(
463            estimate.fixed_operator_bytes,
464            SOURCE_SEGMENT_SCAN_MAX_WAVE_BYTES
465        );
466        assert_eq!(
467            estimate.total_bytes,
468            1024 + 4096 + SOURCE_SEGMENT_SCAN_MAX_WAVE_BYTES
469        );
470    }
471
472    #[test]
473    fn vector_seed_admission_reserves_external_working_and_result_memory() {
474        let plan = PhysicalPlan::VectorSeedScan {
475            embedding_parameter: "embedding".to_string(),
476            output_external_id: true,
477            metadata_filters: Default::default(),
478            resource_profile: VectorExecutionResourceProfile {
479                priority: 128,
480                max_parallelism: 3,
481                max_working_memory_bytes: Some(2048),
482            },
483            vector_plan: hawdb_plan_cypher::VectorPhysicalPlan::TopK {
484                limit: 4,
485                input: Box::new(hawdb_plan_cypher::VectorPhysicalPlan::RawVectorRerank {
486                    embedding_dimension: 2,
487                    input: Box::new(hawdb_plan_cypher::VectorPhysicalPlan::VectorCandidateScan {
488                        source: hawdb_plan_cypher::VectorCandidateSource::Scalar,
489                        embedding_dimension: 2,
490                        candidate_limit: 4,
491                        input: Box::new(hawdb_plan_cypher::VectorPhysicalPlan::Filter {
492                            fields: Vec::new(),
493                        }),
494                    }),
495                }),
496            },
497        };
498
499        let estimate = estimated_execution_memory(&plan, &admission_test_config());
500
501        assert_eq!(estimate.pipeline_bytes, 1024);
502        assert_eq!(estimate.blocking_bytes, 4096);
503        assert_eq!(estimate.external_read_bytes, 2048 + 4096);
504        assert_eq!(estimate.total_bytes, 11 * 1024);
505        assert_eq!(max_external_read_parallelism(&plan), 3);
506    }
507
508    #[test]
509    fn mutation_admission_reserves_wal_staging_and_bounded_results() {
510        let limits = MutationLimits {
511            max_affected_rows: NonZeroUsize::new(3).unwrap(),
512            max_operations: NonZeroUsize::new(5).unwrap(),
513            max_result_rows: NonZeroUsize::new(7).unwrap(),
514            max_result_payload_bytes: NonZeroUsize::new(11).unwrap(),
515        };
516
517        assert_eq!(
518            estimated_mutation_memory_bytes(limits, Some(13)),
519            2 * 13
520                + 5 * MUTATION_OPERATION_BOOKKEEPING_BYTES
521                + 3 * MUTATION_AFFECTED_ROW_BOOKKEEPING_BYTES
522                + 7 * MUTATION_RESULT_ROW_BOOKKEEPING_BYTES
523                + 11
524        );
525        assert_eq!(estimated_mutation_memory_bytes(limits, None), u64::MAX);
526    }
527}