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