1use crate::ExecutionMemoryConfig;
16use hawdb_core::{
17 HawDBError, ResourceBudgetError, ResourceBudgetKind, ResourceBudgetScope, Result,
18};
19use hawdb_storage::file_descriptors::FileOpenContext;
20use std::collections::BTreeMap;
21use std::io::ErrorKind;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicU64, Ordering};
24use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock};
25use std::time::{Duration, SystemTime, UNIX_EPOCH};
26
27pub(super) const SPILL_FILE_PREFIX: &str = "hawdb-spill-v1-";
28pub(super) const SPILL_FILE_SUFFIX: &str = ".spill";
29static SPILL_POOLS: OnceLock<Mutex<BTreeMap<PathBuf, Arc<SharedSpillPool>>>> = OnceLock::new();
30static PROCESS_MARKER: OnceLock<String> = OnceLock::new();
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct SpillPoolSnapshot {
34 pub max_total_bytes: u64,
35 pub max_total_runs: usize,
36 pub min_free_bytes: u64,
37 pub free_space_probe_interval_bytes: u64,
38 pub free_space_probe_count: u64,
39 pub free_space_probe_failures: u64,
40 pub active_bytes: u64,
41 pub peak_active_bytes: u64,
42 pub pending_write_bytes: u64,
43 pub active_runs: usize,
44 pub peak_active_runs: usize,
45 pub orphan_files_removed: u64,
46 pub orphan_bytes_removed: u64,
47 pub orphan_cleanup_failures: u64,
48 pub run_delete_failures: u64,
49}
50
51#[derive(Debug, Clone, Copy)]
52struct SpillPoolLimits {
53 max_total_bytes: u64,
54 max_total_runs: usize,
55 min_free_bytes: u64,
56 free_space_probe_interval_bytes: u64,
57}
58
59impl SpillPoolLimits {
60 fn from_config(memory: &ExecutionMemoryConfig) -> Self {
61 Self {
62 max_total_bytes: memory.max_total_spill_bytes.get(),
63 max_total_runs: memory.max_total_spill_runs.get(),
64 min_free_bytes: memory.min_spill_free_bytes.get(),
65 free_space_probe_interval_bytes: memory.spill_free_space_probe_interval_bytes.get(),
66 }
67 }
68
69 fn tighten(&mut self, other: Self) {
70 self.max_total_bytes = self.max_total_bytes.min(other.max_total_bytes);
71 self.max_total_runs = self.max_total_runs.min(other.max_total_runs);
72 self.min_free_bytes = self.min_free_bytes.max(other.min_free_bytes);
73 self.free_space_probe_interval_bytes = self
74 .free_space_probe_interval_bytes
75 .min(other.free_space_probe_interval_bytes);
76 }
77}
78
79#[derive(Debug, Default)]
80struct OrphanCleanupStats {
81 files_removed: u64,
82 bytes_removed: u64,
83 failures: u64,
84}
85
86#[derive(Debug)]
87struct SpillPoolState {
88 limits: SpillPoolLimits,
89 active_bytes: u64,
90 peak_active_bytes: u64,
91 pending_write_bytes: u64,
92 last_probed_available_bytes: Option<u64>,
93 unreflected_reserved_bytes: u64,
94 bytes_reserved_since_probe: u64,
95 free_space_probe_in_progress: bool,
96 free_space_probe_count: u64,
97 free_space_probe_failures: u64,
98 active_runs: usize,
99 peak_active_runs: usize,
100 orphan_cleanup: OrphanCleanupStats,
101 run_delete_failures: u64,
102}
103
104#[derive(Debug)]
105struct SharedSpillPool {
106 directory: PathBuf,
107 state: Mutex<SpillPoolState>,
108 free_space_probe_ready: Condvar,
109 free_space_probe: Arc<dyn SpillSpaceProbe>,
110}
111
112trait SpillSpaceProbe: std::fmt::Debug + Send + Sync {
113 fn available_space(&self, directory: &Path) -> std::io::Result<u64>;
114}
115
116#[derive(Debug)]
117struct FileSystemSpillSpaceProbe;
118
119impl SpillSpaceProbe for FileSystemSpillSpaceProbe {
120 fn available_space(&self, directory: &Path) -> std::io::Result<u64> {
121 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
122 {
123 fs2::available_space(directory)
124 }
125 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
126 {
127 let _ = directory;
128 Err(std::io::Error::new(
129 std::io::ErrorKind::Unsupported,
130 "disk spill is unavailable in browser WASM",
131 ))
132 }
133 }
134}
135
136#[derive(Debug, Clone)]
137pub(crate) struct SpillPool {
138 shared: Arc<SharedSpillPool>,
139 file_descriptors: FileOpenContext,
140}
141
142impl SpillPool {
143 pub(crate) fn with_file_descriptors(mut self, context: FileOpenContext) -> Self {
144 self.file_descriptors = context;
145 self
146 }
147
148 pub(crate) fn open(memory: &ExecutionMemoryConfig) -> Result<Self> {
149 Self::open_with_context(memory, None)
150 }
151
152 pub(crate) fn open_with_context(
153 memory: &ExecutionMemoryConfig,
154 context: Option<&FileOpenContext>,
155 ) -> Result<Self> {
156 if cfg!(all(target_arch = "wasm32", target_os = "unknown")) {
157 return Err(HawDBError::Execution(
158 "disk spill is unavailable in browser WASM".into(),
159 ));
160 }
161 Self::open_admitted(memory, Arc::new(FileSystemSpillSpaceProbe), context)
162 }
163
164 #[cfg(test)]
165 fn open_with_probe(
166 memory: &ExecutionMemoryConfig,
167 free_space_probe: Arc<dyn SpillSpaceProbe>,
168 ) -> Result<Self> {
169 Self::open_admitted(memory, free_space_probe, None)
170 }
171
172 fn open_admitted(
173 memory: &ExecutionMemoryConfig,
174 free_space_probe: Arc<dyn SpillSpaceProbe>,
175 context: Option<&FileOpenContext>,
176 ) -> Result<Self> {
177 let file_descriptors = match context {
178 Some(context) => context.clone(),
179 None => FileOpenContext::for_path(&memory.spill_directory)?,
180 };
181 file_descriptors
182 .create_dir_all(&memory.spill_directory)
183 .map_err(|error| {
184 super::spill_io_error("create spill directory", &memory.spill_directory, error)
185 })?;
186 let directory = file_descriptors
187 .canonicalize(&memory.spill_directory)
188 .map_err(|error| {
189 super::spill_io_error("resolve spill directory", &memory.spill_directory, error)
190 })?;
191 let limits = SpillPoolLimits::from_config(memory);
192 let mut pools = lock_unpoisoned(SPILL_POOLS.get_or_init(Default::default));
193 if let Some(shared) = pools.get(&directory) {
194 lock_unpoisoned(&shared.state).limits.tighten(limits);
195 return Ok(Self {
196 shared: Arc::clone(shared),
197 file_descriptors,
198 });
199 }
200 let orphan_cleanup = cleanup_orphan_files(
201 &directory,
202 memory.spill_orphan_grace_period,
203 process_marker(),
204 &file_descriptors,
205 )?;
206 let shared = Arc::new(SharedSpillPool {
207 directory: directory.clone(),
208 state: Mutex::new(SpillPoolState {
209 limits,
210 active_bytes: 0,
211 peak_active_bytes: 0,
212 pending_write_bytes: 0,
213 last_probed_available_bytes: None,
214 unreflected_reserved_bytes: 0,
215 bytes_reserved_since_probe: 0,
216 free_space_probe_in_progress: false,
217 free_space_probe_count: 0,
218 free_space_probe_failures: 0,
219 active_runs: 0,
220 peak_active_runs: 0,
221 orphan_cleanup,
222 run_delete_failures: 0,
223 }),
224 free_space_probe_ready: Condvar::new(),
225 free_space_probe,
226 });
227 pools.insert(directory, Arc::clone(&shared));
228 Ok(Self {
229 shared,
230 file_descriptors,
231 })
232 }
233
234 pub(super) fn directory(&self) -> &Path {
235 &self.shared.directory
236 }
237
238 pub(crate) fn file_descriptors(&self) -> &FileOpenContext {
239 &self.file_descriptors
240 }
241
242 pub(super) fn begin_run(&self, operator: &str) -> Result<()> {
243 let mut state = lock_unpoisoned(&self.shared.state);
244 if state.active_runs >= state.limits.max_total_runs {
245 return Err(HawDBError::from(ResourceBudgetError {
246 resource: ResourceBudgetKind::SpillRuns,
247 scope: ResourceBudgetScope::SharedPool,
248 owner: operator.to_string(),
249 memory_class: None,
250 requested: 1,
251 used: state.active_runs as u64,
252 budget: state.limits.max_total_runs as u64,
253 budget_option: "ExecutionMemoryConfig.max_total_spill_runs",
254 message: format!(
255 "{operator} exceeded shared max_total_spill_runs {}",
256 state.limits.max_total_runs
257 ),
258 }));
259 }
260 state.active_runs = state.active_runs.saturating_add(1);
261 state.peak_active_runs = state.peak_active_runs.max(state.active_runs);
262 Ok(())
263 }
264
265 pub(super) fn cancel_run(&self) {
266 let mut state = lock_unpoisoned(&self.shared.state);
267 state.active_runs = state.active_runs.saturating_sub(1);
268 }
269
270 pub(crate) fn reserve_bytes(
271 &self,
272 operator: &str,
273 bytes: u64,
274 ) -> Result<SpillWriteReservation> {
275 loop {
276 let mut state = lock_unpoisoned(&self.shared.state);
277 let next_active = state.active_bytes.checked_add(bytes);
278 if next_active.is_none_or(|next| next > state.limits.max_total_bytes) {
279 return Err(HawDBError::from(ResourceBudgetError {
280 resource: ResourceBudgetKind::SpillBytes,
281 scope: ResourceBudgetScope::SharedPool,
282 owner: operator.to_string(),
283 memory_class: None,
284 requested: bytes,
285 used: state.active_bytes,
286 budget: state.limits.max_total_bytes,
287 budget_option: "ExecutionMemoryConfig.max_total_spill_bytes",
288 message: format!(
289 "{operator} exceeded shared max_total_spill_bytes {} (next active total {})",
290 state.limits.max_total_bytes,
291 state.active_bytes.saturating_add(bytes)
292 ),
293 }));
294 }
295 let next_active = next_active.expect("validated spill reservation fits u64");
296 let probe_required = state.last_probed_available_bytes.is_none()
297 || state.bytes_reserved_since_probe >= state.limits.free_space_probe_interval_bytes;
298 if probe_required {
299 if state.free_space_probe_in_progress {
300 drop(wait_unpoisoned(&self.shared.free_space_probe_ready, state));
301 continue;
302 }
303 state.free_space_probe_in_progress = true;
304 let pending_write_bytes = state.pending_write_bytes;
305 drop(state);
306
307 let probe_result = self
308 .shared
309 .free_space_probe
310 .available_space(&self.shared.directory);
311
312 let mut state = lock_unpoisoned(&self.shared.state);
313 state.free_space_probe_in_progress = false;
314 state.free_space_probe_count = state.free_space_probe_count.saturating_add(1);
315 match probe_result {
316 Ok(available) => {
317 state.last_probed_available_bytes = Some(available);
318 state.unreflected_reserved_bytes = pending_write_bytes;
319 state.bytes_reserved_since_probe = 0;
320 self.shared.free_space_probe_ready.notify_all();
321 }
322 Err(error) => {
323 state.free_space_probe_failures =
324 state.free_space_probe_failures.saturating_add(1);
325 self.shared.free_space_probe_ready.notify_all();
326 drop(state);
327 return Err(HawDBError::Execution(format!(
328 "failed to inspect free space for spill directory '{}': {error}",
329 self.shared.directory.display()
330 )));
331 }
332 }
333 continue;
334 }
335 let Some(available) = state.last_probed_available_bytes else {
336 continue;
337 };
338 let required = state
339 .limits
340 .min_free_bytes
341 .checked_add(state.unreflected_reserved_bytes)
342 .and_then(|required| required.checked_add(bytes));
343 if required.is_none_or(|required| available < required) {
344 return Err(HawDBError::from(ResourceBudgetError {
345 resource: ResourceBudgetKind::SpillBytes,
346 scope: ResourceBudgetScope::FileSystem,
347 owner: operator.to_string(),
348 memory_class: None,
349 requested: bytes,
350 used: state.unreflected_reserved_bytes,
351 budget: available.saturating_sub(state.limits.min_free_bytes),
352 budget_option: "ExecutionMemoryConfig.min_spill_free_bytes",
353 message: format!(
354 "{operator} cannot preserve min_spill_free_bytes {}: filesystem has {available} bytes available and {bytes} bytes were requested",
355 state.limits.min_free_bytes
356 ),
357 }));
358 }
359 state.active_bytes = next_active;
360 state.peak_active_bytes = state.peak_active_bytes.max(next_active);
361 state.pending_write_bytes = state.pending_write_bytes.saturating_add(bytes);
362 state.unreflected_reserved_bytes =
363 state.unreflected_reserved_bytes.saturating_add(bytes);
364 state.bytes_reserved_since_probe =
365 state.bytes_reserved_since_probe.saturating_add(bytes);
366 drop(state);
367 return Ok(SpillWriteReservation {
368 pool: self.clone(),
369 bytes,
370 committed: false,
371 });
372 }
373 }
374
375 fn commit_write(&self, bytes: u64) {
376 let mut state = lock_unpoisoned(&self.shared.state);
377 state.pending_write_bytes = state.pending_write_bytes.saturating_sub(bytes);
378 }
379
380 fn rollback_write(&self, bytes: u64) {
381 let mut state = lock_unpoisoned(&self.shared.state);
382 state.pending_write_bytes = state.pending_write_bytes.saturating_sub(bytes);
383 state.active_bytes = state.active_bytes.saturating_sub(bytes);
384 }
385
386 fn finish_run(&self, bytes: u64, pending_bytes: u64, deleted: bool) {
387 let mut state = lock_unpoisoned(&self.shared.state);
388 state.pending_write_bytes = state.pending_write_bytes.saturating_sub(pending_bytes);
389 if deleted {
390 state.active_bytes = state.active_bytes.saturating_sub(bytes);
391 state.active_runs = state.active_runs.saturating_sub(1);
392 } else {
393 state.run_delete_failures = state.run_delete_failures.saturating_add(1);
394 }
395 }
396
397 fn snapshot(&self) -> SpillPoolSnapshot {
398 let state = lock_unpoisoned(&self.shared.state);
399 SpillPoolSnapshot {
400 max_total_bytes: state.limits.max_total_bytes,
401 max_total_runs: state.limits.max_total_runs,
402 min_free_bytes: state.limits.min_free_bytes,
403 free_space_probe_interval_bytes: state.limits.free_space_probe_interval_bytes,
404 free_space_probe_count: state.free_space_probe_count,
405 free_space_probe_failures: state.free_space_probe_failures,
406 active_bytes: state.active_bytes,
407 peak_active_bytes: state.peak_active_bytes,
408 pending_write_bytes: state.pending_write_bytes,
409 active_runs: state.active_runs,
410 peak_active_runs: state.peak_active_runs,
411 orphan_files_removed: state.orphan_cleanup.files_removed,
412 orphan_bytes_removed: state.orphan_cleanup.bytes_removed,
413 orphan_cleanup_failures: state.orphan_cleanup.failures,
414 run_delete_failures: state.run_delete_failures,
415 }
416 }
417}
418
419pub(crate) struct SpillWriteReservation {
420 pool: SpillPool,
421 bytes: u64,
422 committed: bool,
423}
424
425impl SpillWriteReservation {
426 pub(super) fn commit(mut self, lease: &RunLease) {
427 lease
428 .reserved_bytes
429 .fetch_add(self.bytes, Ordering::Relaxed);
430 lease
431 .pending_write_bytes
432 .fetch_add(self.bytes, Ordering::Relaxed);
433 self.committed = true;
434 }
435}
436
437impl Drop for SpillWriteReservation {
438 fn drop(&mut self) {
439 if !self.committed {
440 self.pool.rollback_write(self.bytes);
441 }
442 }
443}
444
445#[derive(Debug)]
446pub(super) struct RunLease {
447 path: PathBuf,
448 pool: SpillPool,
449 reserved_bytes: AtomicU64,
450 pending_write_bytes: AtomicU64,
451}
452
453impl RunLease {
454 pub(super) fn new(path: PathBuf, pool: SpillPool) -> Self {
455 Self {
456 path,
457 pool,
458 reserved_bytes: AtomicU64::new(0),
459 pending_write_bytes: AtomicU64::new(0),
460 }
461 }
462
463 pub(super) fn path(&self) -> &Path {
464 &self.path
465 }
466
467 pub(super) fn file_descriptors(&self) -> &FileOpenContext {
468 self.pool.file_descriptors()
469 }
470
471 pub(super) fn mark_flushed(&self) {
472 let pending_bytes = self.pending_write_bytes.swap(0, Ordering::Relaxed);
473 self.pool.commit_write(pending_bytes);
474 }
475}
476
477impl Drop for RunLease {
478 fn drop(&mut self) {
479 let deleted = match self.file_descriptors().remove_file(&self.path) {
480 Ok(()) => true,
481 Err(error) if error.kind() == ErrorKind::NotFound => true,
482 Err(_) => false,
483 };
484 self.pool.finish_run(
485 self.reserved_bytes.load(Ordering::Relaxed),
486 self.pending_write_bytes.load(Ordering::Relaxed),
487 deleted,
488 );
489 }
490}
491
492pub(crate) fn spill_pool_snapshot(memory: &ExecutionMemoryConfig) -> Result<SpillPoolSnapshot> {
493 Ok(SpillPool::open(memory)?.snapshot())
494}
495
496fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
497 mutex
498 .lock()
499 .unwrap_or_else(std::sync::PoisonError::into_inner)
500}
501
502fn wait_unpoisoned<'a, T>(condvar: &Condvar, guard: MutexGuard<'a, T>) -> MutexGuard<'a, T> {
503 condvar
504 .wait(guard)
505 .unwrap_or_else(std::sync::PoisonError::into_inner)
506}
507
508pub(super) fn process_marker() -> &'static str {
509 PROCESS_MARKER.get_or_init(|| {
510 let started = SystemTime::now()
511 .duration_since(UNIX_EPOCH)
512 .unwrap_or(Duration::ZERO)
513 .as_nanos();
514 format!("{}-{started}", std::process::id())
515 })
516}
517
518fn cleanup_orphan_files(
519 directory: &Path,
520 grace_period: Duration,
521 current_process_marker: &str,
522 file_descriptors: &FileOpenContext,
523) -> Result<OrphanCleanupStats> {
524 let mut stats = OrphanCleanupStats::default();
525 let current_prefix = format!("{SPILL_FILE_PREFIX}{current_process_marker}-");
526 let entries = file_descriptors
527 .read_dir(directory)
528 .map_err(|error| super::spill_io_error("inspect spill directory", directory, error))?;
529 for entry in entries {
530 let entry = match entry {
531 Ok(entry) => entry,
532 Err(_) => {
533 stats.failures = stats.failures.saturating_add(1);
534 continue;
535 }
536 };
537 let name = entry.file_name();
538 let Some(name) = name.to_str() else {
539 continue;
540 };
541 if !name.starts_with(SPILL_FILE_PREFIX)
542 || !name.ends_with(SPILL_FILE_SUFFIX)
543 || name.starts_with(¤t_prefix)
544 {
545 continue;
546 }
547 let metadata = match entry.metadata() {
548 Ok(metadata) if metadata.is_file() => metadata,
549 Ok(_) => continue,
550 Err(_) => {
551 stats.failures = stats.failures.saturating_add(1);
552 continue;
553 }
554 };
555 let old_enough = metadata
556 .modified()
557 .ok()
558 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
559 .is_some_and(|age| age >= grace_period);
560 if !old_enough {
561 continue;
562 }
563 match file_descriptors.remove_file(&entry.path()) {
564 Ok(()) => {
565 stats.files_removed = stats.files_removed.saturating_add(1);
566 stats.bytes_removed = stats.bytes_removed.saturating_add(metadata.len());
567 }
568 Err(_) => stats.failures = stats.failures.saturating_add(1),
569 }
570 }
571 Ok(stats)
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use std::num::NonZeroU64;
578 use std::sync::mpsc;
579 use std::thread;
580
581 static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
582
583 #[derive(Debug)]
584 struct AdjustableSpaceProbe {
585 available: AtomicU64,
586 calls: AtomicU64,
587 }
588
589 impl AdjustableSpaceProbe {
590 fn new(available: u64) -> Self {
591 Self {
592 available: AtomicU64::new(available),
593 calls: AtomicU64::new(0),
594 }
595 }
596 }
597
598 impl SpillSpaceProbe for AdjustableSpaceProbe {
599 fn available_space(&self, _directory: &Path) -> std::io::Result<u64> {
600 self.calls.fetch_add(1, Ordering::Relaxed);
601 Ok(self.available.load(Ordering::Relaxed))
602 }
603 }
604
605 #[derive(Debug, Default)]
606 struct BlockingSpaceProbe {
607 state: Mutex<(bool, bool)>,
608 ready: Condvar,
609 calls: AtomicU64,
610 }
611
612 impl BlockingSpaceProbe {
613 fn wait_until_started(&self) {
614 let mut state = lock_unpoisoned(&self.state);
615 while !state.0 {
616 state = wait_unpoisoned(&self.ready, state);
617 }
618 }
619
620 fn release(&self) {
621 let mut state = lock_unpoisoned(&self.state);
622 state.1 = true;
623 self.ready.notify_all();
624 }
625 }
626
627 impl SpillSpaceProbe for BlockingSpaceProbe {
628 fn available_space(&self, _directory: &Path) -> std::io::Result<u64> {
629 self.calls.fetch_add(1, Ordering::Relaxed);
630 let mut state = lock_unpoisoned(&self.state);
631 state.0 = true;
632 self.ready.notify_all();
633 while !state.1 {
634 state = wait_unpoisoned(&self.ready, state);
635 }
636 Ok(u64::MAX)
637 }
638 }
639
640 fn test_memory(name: &str, probe_interval_bytes: u64) -> ExecutionMemoryConfig {
641 let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
642 ExecutionMemoryConfig {
643 min_spill_free_bytes: NonZeroU64::new(1).unwrap(),
644 spill_free_space_probe_interval_bytes: NonZeroU64::new(probe_interval_bytes).unwrap(),
645 spill_directory: std::env::temp_dir().join(format!(
646 "hawdb-spill-pool-test-{}-{name}-{sequence}",
647 std::process::id()
648 )),
649 ..ExecutionMemoryConfig::default()
650 }
651 }
652
653 #[test]
654 fn free_space_probe_is_amortized_by_reserved_bytes() {
655 let memory = test_memory("watermark", 64);
656 let probe = Arc::new(AdjustableSpaceProbe::new(1024 * 1024));
657 let pool = SpillPool::open_with_probe(&memory, probe.clone()).unwrap();
658
659 for _ in 0..8 {
660 drop(pool.reserve_bytes("test", 8).unwrap());
661 }
662 let snapshot = pool.snapshot();
663 assert_eq!(snapshot.free_space_probe_interval_bytes, 64);
664 assert_eq!(snapshot.free_space_probe_count, 1);
665 assert_eq!(probe.calls.load(Ordering::Relaxed), 1);
666
667 drop(pool.reserve_bytes("test", 1).unwrap());
668 assert_eq!(pool.snapshot().free_space_probe_count, 2);
669 assert_eq!(probe.calls.load(Ordering::Relaxed), 2);
670
671 std::fs::remove_dir(&memory.spill_directory).unwrap();
672 }
673
674 #[test]
675 fn refreshed_free_space_still_enforces_the_reserve() {
676 let memory = test_memory("exhausted", 8);
677 let probe = Arc::new(AdjustableSpaceProbe::new(128));
678 let pool = SpillPool::open_with_probe(&memory, probe.clone()).unwrap();
679
680 drop(pool.reserve_bytes("test", 8).unwrap());
681 probe.available.store(8, Ordering::Relaxed);
682 let error = match pool.reserve_bytes("test", 8) {
683 Ok(_) => panic!("exhausted free space must reject a reservation"),
684 Err(error) => error,
685 };
686
687 assert!(error.to_string().contains("min_spill_free_bytes"));
688 let HawDBError::ResourceBudgetExceeded(details) = error else {
689 panic!("expected typed free-space rejection");
690 };
691 assert_eq!(details.scope, ResourceBudgetScope::FileSystem);
692 assert_eq!(details.resource, ResourceBudgetKind::SpillBytes);
693 assert_eq!((details.requested, details.used, details.budget), (8, 0, 7));
694 assert_eq!(
695 details.budget_option,
696 "ExecutionMemoryConfig.min_spill_free_bytes"
697 );
698 assert_eq!(pool.snapshot().active_bytes, 0);
699 assert_eq!(pool.snapshot().free_space_probe_count, 2);
700 assert_eq!(probe.calls.load(Ordering::Relaxed), 2);
701 std::fs::remove_dir(&memory.spill_directory).unwrap();
702 }
703
704 #[test]
705 fn concurrent_reservations_share_a_probe_without_holding_the_pool_mutex() {
706 let memory = test_memory("concurrent", 1024);
707 let probe = Arc::new(BlockingSpaceProbe::default());
708 let pool = Arc::new(SpillPool::open_with_probe(&memory, probe.clone()).unwrap());
709
710 let first_pool = Arc::clone(&pool);
711 let first = thread::spawn(move || first_pool.reserve_bytes("first", 8));
712 probe.wait_until_started();
713 let second_pool = Arc::clone(&pool);
714 let second = thread::spawn(move || second_pool.reserve_bytes("second", 8));
715
716 let snapshot_pool = Arc::clone(&pool);
717 let (snapshot_tx, snapshot_rx) = mpsc::channel();
718 let snapshot = thread::spawn(move || {
719 snapshot_pool.snapshot();
720 snapshot_tx.send(()).unwrap();
721 });
722 snapshot_rx
723 .recv_timeout(Duration::from_secs(1))
724 .expect("pool mutex must remain available during a free-space probe");
725
726 probe.release();
727 drop(first.join().unwrap().unwrap());
728 drop(second.join().unwrap().unwrap());
729 snapshot.join().unwrap();
730 assert_eq!(pool.snapshot().free_space_probe_count, 1);
731 assert_eq!(probe.calls.load(Ordering::Relaxed), 1);
732 std::fs::remove_dir(&memory.spill_directory).unwrap();
733 }
734
735 #[test]
736 fn shared_byte_overflow_refuses_without_probing_or_charging() {
737 let mut memory = test_memory("shared-overflow", 64);
738 memory.max_total_spill_bytes = NonZeroU64::MAX;
739 let probe = Arc::new(AdjustableSpaceProbe::new(u64::MAX));
740 let pool = SpillPool::open_with_probe(&memory, probe.clone()).unwrap();
741 lock_unpoisoned(&pool.shared.state).active_bytes = u64::MAX - 8;
743 let before = pool.snapshot();
744 let error = pool.reserve_bytes("overflow", 16).err().unwrap();
745 let HawDBError::ResourceBudgetExceeded(details) = error else {
746 panic!("expected typed shared byte overflow");
747 };
748 assert_eq!(details.scope, ResourceBudgetScope::SharedPool);
749 assert_eq!(
750 (details.requested, details.used, details.budget),
751 (16, u64::MAX - 8, u64::MAX)
752 );
753 assert_eq!(pool.snapshot(), before);
754 assert_eq!(probe.calls.load(Ordering::Relaxed), 0);
755 std::fs::remove_dir(&memory.spill_directory).unwrap();
756 }
757
758 #[test]
759 fn free_space_reserve_overflow_refuses_at_maximum_available_bytes() {
760 let mut memory = test_memory("free-space-overflow", 64);
761 memory.max_total_spill_bytes = NonZeroU64::MAX;
762 let pool =
763 SpillPool::open_with_probe(&memory, Arc::new(AdjustableSpaceProbe::new(u64::MAX)))
764 .unwrap();
765 let error = pool.reserve_bytes("overflow", u64::MAX).err().unwrap();
766 let HawDBError::ResourceBudgetExceeded(details) = error else {
767 panic!("expected typed free-space overflow");
768 };
769 assert_eq!(details.scope, ResourceBudgetScope::FileSystem);
770 assert_eq!(
771 (details.requested, details.used, details.budget),
772 (u64::MAX, 0, u64::MAX - 1)
773 );
774 let snapshot = pool.snapshot();
775 assert_eq!(
776 (
777 snapshot.active_bytes,
778 snapshot.pending_write_bytes,
779 snapshot.peak_active_bytes
780 ),
781 (0, 0, 0)
782 );
783 std::fs::remove_dir(&memory.spill_directory).unwrap();
784 }
785}