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::{PhysicalPlan, PlanChildren, VectorExecutionResourceProfile};
19use hawdb_storage::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            spill_directory: std::env::temp_dir().join("hawdb-spill"),
137        }
138    }
139}
140
141impl ExecutionMemoryConfig {
142    /// Returns process-wide spill usage for this configured directory.
143    pub fn spill_pool_snapshot(&self) -> hawdb_core::Result<crate::SpillPoolSnapshot> {
144        crate::spill::spill_pool_snapshot(self)
145    }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149#[doc(hidden)]
150pub struct ExecutionMemoryEstimate {
151    pub pipeline_batch_count: usize,
152    pub blocking_operator_count: usize,
153    pub pipeline_bytes: u64,
154    pub blocking_bytes: u64,
155    pub external_read_bytes: u64,
156    pub fixed_operator_bytes: u64,
157    pub total_bytes: u64,
158}
159
160#[doc(hidden)]
161pub fn estimated_execution_memory(
162    plan: &PhysicalPlan,
163    memory: &ExecutionMemoryConfig,
164) -> ExecutionMemoryEstimate {
165    let shape = peak_execution_memory_shape(plan, memory);
166    let pipeline_bytes = usize_to_u64(shape.pipeline_batch_count)
167        .saturating_mul(usize_to_u64(memory.batch_payload_bytes.get()));
168    let blocking_bytes = usize_to_u64(shape.blocking_operator_count)
169        .saturating_mul(usize_to_u64(memory.blocking_operator_bytes.get()));
170    let external_read_bytes = shape.external_read_bytes;
171    let fixed_operator_bytes = shape.fixed_operator_bytes;
172    ExecutionMemoryEstimate {
173        pipeline_batch_count: shape.pipeline_batch_count,
174        blocking_operator_count: shape.blocking_operator_count,
175        pipeline_bytes,
176        blocking_bytes,
177        external_read_bytes,
178        fixed_operator_bytes,
179        total_bytes: pipeline_bytes
180            .saturating_add(blocking_bytes)
181            .saturating_add(external_read_bytes)
182            .saturating_add(fixed_operator_bytes),
183    }
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187#[doc(hidden)]
188pub struct ExternalReadMemoryBudget {
189    pub max_working_bytes: NonZeroUsize,
190    pub max_result_bytes: NonZeroUsize,
191}
192
193impl ExternalReadMemoryBudget {
194    pub fn reserved_bytes(self) -> usize {
195        self.max_working_bytes
196            .get()
197            .saturating_add(self.max_result_bytes.get())
198    }
199}
200
201#[doc(hidden)]
202pub fn external_read_memory_budget(
203    profile: VectorExecutionResourceProfile,
204    memory: &ExecutionMemoryConfig,
205) -> ExternalReadMemoryBudget {
206    let configured_working_bytes = memory.blocking_operator_bytes.get();
207    let planned_working_bytes = profile
208        .max_working_memory_bytes
209        .map(|bytes| usize::try_from(bytes).unwrap_or(usize::MAX))
210        .unwrap_or(configured_working_bytes)
211        .max(1);
212    ExternalReadMemoryBudget {
213        max_working_bytes: NonZeroUsize::new(planned_working_bytes.min(configured_working_bytes))
214            .expect("configured external read working budget is non-zero"),
215        max_result_bytes: memory.blocking_operator_bytes,
216    }
217}
218
219#[doc(hidden)]
220pub fn max_external_read_parallelism(plan: &PhysicalPlan) -> usize {
221    let own_parallelism = match plan {
222        PhysicalPlan::VectorSeedScan {
223            resource_profile, ..
224        } => resource_profile.max_parallelism.max(1),
225        _ => 1,
226    };
227    match plan.children() {
228        PlanChildren::None => own_parallelism,
229        PlanChildren::Unary(input) => own_parallelism.max(max_external_read_parallelism(input)),
230        PlanChildren::Binary(left, right) => own_parallelism
231            .max(max_external_read_parallelism(left))
232            .max(max_external_read_parallelism(right)),
233    }
234}
235
236#[doc(hidden)]
237pub fn estimated_mutation_memory_bytes(
238    limits: MutationLimits,
239    max_wal_record_bytes: Option<usize>,
240) -> u64 {
241    let Some(max_wal_record_bytes) = max_wal_record_bytes else {
242        return u64::MAX;
243    };
244    usize_to_u64(max_wal_record_bytes)
245        .saturating_mul(2)
246        .saturating_add(
247            usize_to_u64(limits.max_operations.get())
248                .saturating_mul(MUTATION_OPERATION_BOOKKEEPING_BYTES),
249        )
250        .saturating_add(
251            usize_to_u64(limits.max_affected_rows.get())
252                .saturating_mul(MUTATION_AFFECTED_ROW_BOOKKEEPING_BYTES),
253        )
254        .saturating_add(
255            usize_to_u64(limits.max_result_rows.get())
256                .saturating_mul(MUTATION_RESULT_ROW_BOOKKEEPING_BYTES),
257        )
258        .saturating_add(usize_to_u64(limits.max_result_payload_bytes.get()))
259}
260
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
262struct ExecutionMemoryShape {
263    pipeline_batch_count: usize,
264    blocking_operator_count: usize,
265    external_read_bytes: u64,
266    fixed_operator_bytes: u64,
267}
268
269fn peak_execution_memory_shape(
270    plan: &PhysicalPlan,
271    memory: &ExecutionMemoryConfig,
272) -> ExecutionMemoryShape {
273    let mut shape = match plan.children() {
274        PlanChildren::None => ExecutionMemoryShape::default(),
275        PlanChildren::Unary(input) => peak_execution_memory_shape(input, memory),
276        PlanChildren::Binary(left, right) => peak_shape_max(
277            peak_execution_memory_shape(left, memory),
278            peak_execution_memory_shape(right, memory),
279            memory,
280        ),
281    };
282    shape.pipeline_batch_count = shape.pipeline_batch_count.saturating_add(1);
283    if retains_blocking_state(plan) {
284        shape.blocking_operator_count = shape.blocking_operator_count.saturating_add(1);
285    }
286    if matches!(plan, PhysicalPlan::SourceSegmentScan { .. }) {
287        shape.fixed_operator_bytes = shape
288            .fixed_operator_bytes
289            .saturating_add(SOURCE_SEGMENT_SCAN_MAX_WAVE_BYTES);
290    }
291    if let PhysicalPlan::VectorSeedScan {
292        resource_profile, ..
293    } = plan
294    {
295        shape.external_read_bytes = shape.external_read_bytes.saturating_add(usize_to_u64(
296            external_read_memory_budget(*resource_profile, memory).reserved_bytes(),
297        ));
298    }
299    shape
300}
301
302fn peak_shape_max(
303    left: ExecutionMemoryShape,
304    right: ExecutionMemoryShape,
305    memory: &ExecutionMemoryConfig,
306) -> ExecutionMemoryShape {
307    let shape_bytes = |shape: ExecutionMemoryShape| {
308        usize_to_u64(shape.pipeline_batch_count)
309            .saturating_mul(usize_to_u64(memory.batch_payload_bytes.get()))
310            .saturating_add(
311                usize_to_u64(shape.blocking_operator_count)
312                    .saturating_mul(usize_to_u64(memory.blocking_operator_bytes.get())),
313            )
314            .saturating_add(shape.external_read_bytes)
315            .saturating_add(shape.fixed_operator_bytes)
316    };
317    let left_total = shape_bytes(left);
318    let right_total = shape_bytes(right);
319    if left_total > right_total
320        || (left_total == right_total && left.fixed_operator_bytes >= right.fixed_operator_bytes)
321    {
322        left
323    } else {
324        right
325    }
326}
327
328fn retains_blocking_state(plan: &PhysicalPlan) -> bool {
329    matches!(
330        plan,
331        PhysicalPlan::GraphAlgorithm { .. }
332            | PhysicalPlan::VectorSeedScan { .. }
333            | PhysicalPlan::SourceSegmentScan { .. }
334            | PhysicalPlan::NodeCartesianProductExec { .. }
335            | PhysicalPlan::HashJoinExec { .. }
336            | PhysicalPlan::AdjacencyExpandExec { .. }
337            | PhysicalPlan::OptionalRelationshipCountSumExec { .. }
338            | PhysicalPlan::ThreadRepairStatsExec { .. }
339            | PhysicalPlan::ShortestPathExec { .. }
340            | PhysicalPlan::AggregateExec { .. }
341            | PhysicalPlan::DistinctExec { .. }
342            | PhysicalPlan::SortExec { .. }
343            | PhysicalPlan::TopNExec { .. }
344    )
345}
346
347fn usize_to_u64(value: usize) -> u64 {
348    u64::try_from(value).unwrap_or(u64::MAX)
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use hawdb_plan::Predicate;
355
356    fn admission_test_config() -> ExecutionMemoryConfig {
357        ExecutionMemoryConfig {
358            query_memory_bytes: NonZeroUsize::new(16 * 1024).unwrap(),
359            batch_rows: NonZeroUsize::new(8).unwrap(),
360            batch_payload_bytes: NonZeroUsize::new(1024).unwrap(),
361            blocking_operator_bytes: NonZeroUsize::new(4096).unwrap(),
362            max_spill_bytes: NonZeroU64::new(1024 * 1024).unwrap(),
363            max_spill_runs: NonZeroUsize::new(8).unwrap(),
364            max_total_spill_bytes: NonZeroU64::new(4 * 1024 * 1024).unwrap(),
365            max_total_spill_runs: NonZeroUsize::new(32).unwrap(),
366            min_spill_free_bytes: NonZeroU64::new(1).unwrap(),
367            spill_free_space_probe_interval_bytes: NonZeroU64::new(1024).unwrap(),
368            spill_orphan_grace_period: Duration::from_secs(60),
369            spill_directory: std::env::temp_dir(),
370        }
371    }
372
373    #[test]
374    fn execution_admission_uses_configured_pipeline_and_blocking_budgets() {
375        let plan = PhysicalPlan::SortExec {
376            items: Vec::new(),
377            input: Box::new(PhysicalPlan::DistinctExec {
378                input: Box::new(PhysicalPlan::SeqNodeScan {
379                    variable: "n".to_string(),
380                    label: "Node".to_string(),
381                }),
382            }),
383        };
384
385        let estimate = estimated_execution_memory(&plan, &admission_test_config());
386
387        assert_eq!(estimate.pipeline_batch_count, 3);
388        assert_eq!(estimate.blocking_operator_count, 2);
389        assert_eq!(estimate.pipeline_bytes, 3 * 1024);
390        assert_eq!(estimate.blocking_bytes, 2 * 4096);
391        assert_eq!(estimate.external_read_bytes, 0);
392        assert_eq!(estimate.fixed_operator_bytes, 0);
393        assert_eq!(estimate.total_bytes, 11 * 1024);
394    }
395
396    #[test]
397    fn binary_admission_uses_the_higher_memory_child_path() {
398        let plan = PhysicalPlan::NodeCartesianProductExec {
399            left: Box::new(PhysicalPlan::ProjectExec {
400                items: Vec::new(),
401                input: Box::new(PhysicalPlan::ProjectExec {
402                    items: Vec::new(),
403                    input: Box::new(PhysicalPlan::SeqNodeScan {
404                        variable: "left".to_string(),
405                        label: "Left".to_string(),
406                    }),
407                }),
408            }),
409            right: Box::new(PhysicalPlan::DistinctExec {
410                input: Box::new(PhysicalPlan::SeqNodeScan {
411                    variable: "right".to_string(),
412                    label: "Right".to_string(),
413                }),
414            }),
415        };
416
417        let estimate = estimated_execution_memory(&plan, &admission_test_config());
418
419        assert_eq!(estimate.pipeline_batch_count, 3);
420        assert_eq!(estimate.blocking_operator_count, 2);
421        assert_eq!(estimate.total_bytes, 11 * 1024);
422    }
423
424    #[test]
425    fn source_segment_admission_includes_the_fixed_io_wave() {
426        let plan = PhysicalPlan::SourceSegmentScan {
427            variable: "n".to_string(),
428            predicate: Predicate::ConstantBool(true),
429        };
430
431        let estimate = estimated_execution_memory(&plan, &admission_test_config());
432
433        assert_eq!(estimate.pipeline_bytes, 1024);
434        assert_eq!(estimate.blocking_operator_count, 1);
435        assert_eq!(estimate.blocking_bytes, 4096);
436        assert_eq!(estimate.external_read_bytes, 0);
437        assert_eq!(
438            estimate.fixed_operator_bytes,
439            SOURCE_SEGMENT_SCAN_MAX_WAVE_BYTES
440        );
441        assert_eq!(
442            estimate.total_bytes,
443            1024 + 4096 + SOURCE_SEGMENT_SCAN_MAX_WAVE_BYTES
444        );
445    }
446
447    #[test]
448    fn vector_seed_admission_reserves_external_working_and_result_memory() {
449        let plan = PhysicalPlan::VectorSeedScan {
450            embedding_parameter: "embedding".to_string(),
451            output_external_id: true,
452            metadata_filters: Default::default(),
453            resource_profile: VectorExecutionResourceProfile {
454                priority: 128,
455                max_parallelism: 3,
456                max_working_memory_bytes: Some(2048),
457            },
458            vector_plan: hawdb_plan::VectorPhysicalPlan::TopK {
459                limit: 4,
460                input: Box::new(hawdb_plan::VectorPhysicalPlan::RawVectorRerank {
461                    embedding_dimension: 2,
462                    input: Box::new(hawdb_plan::VectorPhysicalPlan::VectorCandidateScan {
463                        source: hawdb_plan::VectorCandidateSource::Scalar,
464                        embedding_dimension: 2,
465                        candidate_limit: 4,
466                        input: Box::new(hawdb_plan::VectorPhysicalPlan::Filter {
467                            fields: Vec::new(),
468                        }),
469                    }),
470                }),
471            },
472        };
473
474        let estimate = estimated_execution_memory(&plan, &admission_test_config());
475
476        assert_eq!(estimate.pipeline_bytes, 1024);
477        assert_eq!(estimate.blocking_bytes, 4096);
478        assert_eq!(estimate.external_read_bytes, 2048 + 4096);
479        assert_eq!(estimate.total_bytes, 11 * 1024);
480        assert_eq!(max_external_read_parallelism(&plan), 3);
481    }
482
483    #[test]
484    fn mutation_admission_reserves_wal_staging_and_bounded_results() {
485        let limits = MutationLimits {
486            max_affected_rows: NonZeroUsize::new(3).unwrap(),
487            max_operations: NonZeroUsize::new(5).unwrap(),
488            max_result_rows: NonZeroUsize::new(7).unwrap(),
489            max_result_payload_bytes: NonZeroUsize::new(11).unwrap(),
490        };
491
492        assert_eq!(
493            estimated_mutation_memory_bytes(limits, Some(13)),
494            2 * 13
495                + 5 * MUTATION_OPERATION_BOOKKEEPING_BYTES
496                + 3 * MUTATION_AFFECTED_ROW_BOOKKEEPING_BYTES
497                + 7 * MUTATION_RESULT_ROW_BOOKKEEPING_BYTES
498                + 11
499        );
500        assert_eq!(estimated_mutation_memory_bytes(limits, None), u64::MAX);
501    }
502}