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