1use 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 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 spill_directory: std::env::temp_dir().join("hawdb-spill"),
137 }
138 }
139}
140
141impl ExecutionMemoryConfig {
142 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}