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