1use 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 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 pub query_memory_bytes: NonZeroUsize,
86 pub batch_rows: NonZeroUsize,
88 pub batch_payload_bytes: NonZeroUsize,
90 pub blocking_operator_bytes: NonZeroUsize,
92 pub max_spill_bytes: NonZeroU64,
94 pub max_spill_runs: NonZeroUsize,
96 pub max_total_spill_bytes: NonZeroU64,
98 pub max_total_spill_runs: NonZeroUsize,
100 pub min_spill_free_bytes: NonZeroU64,
102 pub spill_free_space_probe_interval_bytes: NonZeroU64,
104 pub spill_orphan_grace_period: Duration,
106 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 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
140 spill_directory: PathBuf::new(),
141 }
142 }
143}
144
145impl ExecutionMemoryConfig {
146 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}