1use 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 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 pub query_memory_bytes: NonZeroUsize,
88 pub batch_rows: NonZeroUsize,
90 pub batch_payload_bytes: NonZeroUsize,
92 pub blocking_operator_bytes: NonZeroUsize,
96 pub graph_expansion_budget: Option<GraphExpansionBudget>,
100 pub max_spill_bytes: NonZeroU64,
102 pub max_spill_runs: NonZeroUsize,
104 pub max_total_spill_bytes: NonZeroU64,
106 pub max_total_spill_runs: NonZeroUsize,
108 pub min_spill_free_bytes: NonZeroU64,
110 pub spill_free_space_probe_interval_bytes: NonZeroU64,
112 pub spill_orphan_grace_period: Duration,
114 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 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
149 spill_directory: PathBuf::new(),
150 }
151 }
152}
153
154impl ExecutionMemoryConfig {
155 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}