Skip to main content

hawdb_executor/spill/
pool.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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(&current_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}