1use 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(¤t_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}