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