1use std::io;
39use std::path::PathBuf;
40use std::pin::Pin;
41use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
42use std::sync::{Arc, Mutex};
43use std::task::{Context, Poll};
44use std::time::Duration;
45
46use async_trait::async_trait;
47use object_store::path::Path;
48use tokio::io::AsyncWrite;
49
50use lance_core::{Error, Result};
51
52use crate::object_store::ObjectStore;
53use crate::object_writer::WriteResult;
54use crate::traits::{Reader, Writer};
55
56#[async_trait]
62pub trait SpillStore: Send + Sync + 'static {
63 async fn new_spill(&self) -> Result<(Box<dyn Writer>, Box<dyn Spill>)>;
70}
71
72#[async_trait]
77pub trait Spill: Send + Sync {
78 async fn reader(&self) -> Result<Box<dyn Reader>>;
83}
84
85#[derive(Debug, Clone)]
90struct DiskQuota {
91 cap_bytes: u64,
92 used: Arc<Mutex<u64>>,
93}
94
95impl DiskQuota {
96 fn new(cap_bytes: u64) -> Self {
97 Self {
98 cap_bytes,
99 used: Arc::new(Mutex::new(0)),
100 }
101 }
102
103 fn try_reserve(&self, n: u64) -> Result<()> {
106 let mut used = self.used.lock().unwrap();
109 let next = used.saturating_add(n);
110 if next > self.cap_bytes {
111 return Err(Error::disk_cap_exceeded(self.cap_bytes, *used));
112 }
113 *used = next;
114 Ok(())
115 }
116
117 fn release(&self, n: u64) {
119 let mut used = self.used.lock().unwrap();
121 *used = used.saturating_sub(n);
122 }
123}
124
125struct SpillWriter {
132 inner: Box<dyn Writer>,
133 quota: Option<DiskQuota>,
134 finished: Arc<AtomicBool>,
135}
136
137impl AsyncWrite for SpillWriter {
138 fn poll_write(
139 self: Pin<&mut Self>,
140 cx: &mut Context<'_>,
141 buf: &[u8],
142 ) -> Poll<io::Result<usize>> {
143 let this = self.get_mut();
144 let Some(quota) = &this.quota else {
145 return Pin::new(this.inner.as_mut()).poll_write(cx, buf);
146 };
147 if let Err(e) = quota.try_reserve(buf.len() as u64) {
151 return Poll::Ready(Err(io::Error::other(e)));
152 }
153 let poll = Pin::new(this.inner.as_mut()).poll_write(cx, buf);
154 match &poll {
155 Poll::Ready(Ok(n)) => quota.release((buf.len() - *n) as u64),
156 _ => quota.release(buf.len() as u64),
157 }
158 poll
159 }
160
161 fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
162 Pin::new(self.get_mut().inner.as_mut()).poll_flush(cx)
163 }
164
165 fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
166 let this = self.get_mut();
167 let poll = Pin::new(this.inner.as_mut()).poll_shutdown(cx);
168 if matches!(poll, Poll::Ready(Ok(()))) {
169 this.finished.store(true, Ordering::Relaxed);
172 }
173 poll
174 }
175}
176
177#[async_trait]
178impl Writer for SpillWriter {
179 async fn tell(&mut self) -> Result<usize> {
180 self.inner.tell().await
181 }
182
183 async fn shutdown(&mut self) -> Result<WriteResult> {
184 let result = self.inner.shutdown().await?;
185 self.finished.store(true, Ordering::Relaxed);
189 Ok(result)
190 }
191}
192
193pub struct LocalSpillStore {
203 store: Arc<ObjectStore>,
204 temp_dir: Mutex<Option<Arc<tempfile::TempDir>>>,
206 file_counter: Arc<AtomicU64>,
207 quota: Option<DiskQuota>,
209}
210
211fn create_temp_dir() -> Result<tempfile::TempDir> {
217 const ATTEMPTS: u32 = 5;
218 let mut attempt = 0;
219 loop {
220 match tempfile::tempdir() {
221 Ok(dir) => return Ok(dir),
222 Err(err) if err.kind() == io::ErrorKind::PermissionDenied && attempt + 1 < ATTEMPTS => {
223 attempt += 1;
224 std::thread::sleep(Duration::from_millis(10 * attempt as u64));
225 }
226 Err(err) => return Err(err.into()),
227 }
228 }
229}
230
231impl LocalSpillStore {
232 pub fn new() -> Result<Self> {
237 Ok(Self::default())
238 }
239
240 pub fn with_cap(cap_bytes: u64) -> Result<Self> {
243 Ok(Self {
244 quota: Some(DiskQuota::new(cap_bytes)),
245 ..Self::default()
246 })
247 }
248
249 fn ensure_temp_dir(&self) -> Result<Arc<tempfile::TempDir>> {
250 let mut slot = self.temp_dir.lock().unwrap();
253 if let Some(dir) = slot.as_ref() {
254 return Ok(dir.clone());
255 }
256 let dir = Arc::new(create_temp_dir()?);
257 *slot = Some(dir.clone());
258 Ok(dir)
259 }
260}
261
262impl Default for LocalSpillStore {
263 fn default() -> Self {
264 Self {
265 store: Arc::new(ObjectStore::local()),
266 temp_dir: Mutex::new(None),
267 file_counter: Arc::new(AtomicU64::new(0)),
268 quota: None,
269 }
270 }
271}
272
273#[async_trait]
274impl SpillStore for LocalSpillStore {
275 async fn new_spill(&self) -> Result<(Box<dyn Writer>, Box<dyn Spill>)> {
276 let temp_dir = self.ensure_temp_dir()?;
277 let idx = self.file_counter.fetch_add(1, Ordering::Relaxed);
278 let fs_path = temp_dir.path().join(format!("spill_{idx:06}.bin"));
279 let os_path = Path::from_absolute_path(&fs_path)?;
280 let finished = Arc::new(AtomicBool::new(false));
281
282 let writer = Box::new(SpillWriter {
283 inner: self.store.create(&os_path).await?,
284 quota: self.quota.clone(),
285 finished: finished.clone(),
286 });
287 let spill = Box::new(LocalSpill {
288 store: self.store.clone(),
289 os_path,
290 fs_path,
291 quota: self.quota.clone(),
292 finished,
293 _temp_dir: temp_dir,
294 });
295 Ok((writer, spill))
296 }
297}
298
299struct LocalSpill {
301 store: Arc<ObjectStore>,
302 os_path: Path,
303 fs_path: PathBuf,
304 quota: Option<DiskQuota>,
305 finished: Arc<AtomicBool>,
307 _temp_dir: Arc<tempfile::TempDir>,
309}
310
311#[async_trait]
312impl Spill for LocalSpill {
313 async fn reader(&self) -> Result<Box<dyn Reader>> {
314 if !self.finished.load(Ordering::Relaxed) {
318 return Err(Error::invalid_input(
319 "spill reader requested before the writer was shut down",
320 ));
321 }
322 self.store.open(&self.os_path).await
323 }
324}
325
326impl Drop for LocalSpill {
327 fn drop(&mut self) {
328 if let Some(quota) = &self.quota
332 && let Ok(metadata) = std::fs::metadata(&self.fs_path)
333 {
334 quota.release(metadata.len());
335 }
336 let _ = std::fs::remove_file(&self.fs_path);
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use tokio::io::AsyncWriteExt;
345
346 async fn finish_writer(mut writer: Box<dyn Writer>, data: &[u8]) -> Result<()> {
348 writer.write_all(data).await?;
349 Writer::shutdown(writer.as_mut()).await?;
350 Ok(())
351 }
352
353 #[test]
354 fn test_disk_quota_reserve_release() {
355 let quota = DiskQuota::new(100);
356 quota.try_reserve(60).unwrap();
357 assert!(quota.try_reserve(60).is_err());
358 quota.release(60);
359 quota.try_reserve(60).unwrap();
360 quota.try_reserve(40).unwrap();
362 assert!(quota.try_reserve(1).is_err());
363 }
364
365 #[tokio::test]
366 async fn test_write_then_read() {
367 let store = LocalSpillStore::new().unwrap();
368 let (writer, spill) = store.new_spill().await.unwrap();
369
370 let data = b"hello spill world";
371 finish_writer(writer, data).await.unwrap();
372
373 let reader = spill.reader().await.unwrap();
374 let read_back = reader.get_all().await.unwrap();
375 assert_eq!(read_back.as_ref(), data);
376 }
377
378 #[tokio::test]
379 async fn test_reader_requires_finished_writer() {
380 let store = LocalSpillStore::new().unwrap();
381 let (mut writer, spill) = store.new_spill().await.unwrap();
382 writer.write_all(b"partial").await.unwrap();
383
384 let Err(err) = spill.reader().await else {
386 panic!("reader before shutdown should be rejected");
387 };
388 assert!(
389 matches!(err, Error::InvalidInput { .. }),
390 "expected InvalidInput, got {err:?}"
391 );
392
393 Writer::shutdown(writer.as_mut()).await.unwrap();
395 let reader = spill.reader().await.unwrap();
396 assert_eq!(reader.get_all().await.unwrap().as_ref(), b"partial");
397 }
398
399 #[tokio::test]
400 async fn test_reader_ready_after_async_shutdown() {
401 let store = LocalSpillStore::new().unwrap();
405 let (mut writer, spill) = store.new_spill().await.unwrap();
406 writer.write_all(b"async").await.unwrap();
407 AsyncWriteExt::shutdown(&mut writer).await.unwrap();
408
409 let reader = spill.reader().await.unwrap();
410 assert_eq!(reader.get_all().await.unwrap().as_ref(), b"async");
411 }
412
413 #[tokio::test]
414 async fn test_empty_spill() {
415 let store = LocalSpillStore::with_cap(100).unwrap();
418 let (writer, spill) = store.new_spill().await.unwrap();
419 finish_writer(writer, b"").await.unwrap();
420
421 let reader = spill.reader().await.unwrap();
422 assert!(reader.get_all().await.unwrap().is_empty());
423 }
424
425 #[test]
426 fn test_new_does_not_create_temp_dir() {
427 let store = LocalSpillStore::new().unwrap();
430 assert!(
431 store.temp_dir.lock().unwrap().is_none(),
432 "temp directory should be created lazily on first spill"
433 );
434 assert!(
435 LocalSpillStore::default()
436 .temp_dir
437 .lock()
438 .unwrap()
439 .is_none(),
440 "Default should also defer temp directory creation"
441 );
442 }
443
444 #[tokio::test]
445 async fn test_raii_cleanup() {
446 let store = LocalSpillStore::new().unwrap();
447 let (writer, spill) = store.new_spill().await.unwrap();
448 finish_writer(writer, b"some bytes").await.unwrap();
449
450 let path = store
452 .temp_dir
453 .lock()
454 .unwrap()
455 .as_ref()
456 .expect("new_spill creates the temp dir")
457 .path()
458 .join("spill_000000.bin");
459 assert!(path.exists());
460 drop(spill);
461 assert!(!path.exists(), "spill file should be deleted on drop");
462 }
463
464 #[tokio::test]
465 async fn test_cap_exceeded() {
466 let store = LocalSpillStore::with_cap(100).unwrap();
467 let (writer, _spill) = store.new_spill().await.unwrap();
468 let err = finish_writer(writer, &[0u8; 101]).await.unwrap_err();
469 assert!(
470 matches!(err, Error::DiskCapExceeded { cap_bytes: 100, .. }),
471 "expected DiskCapExceeded, got {err:?}"
472 );
473 }
474
475 #[tokio::test]
476 async fn test_cap_shared_across_files() {
477 let store = LocalSpillStore::with_cap(100).unwrap();
478 let (writer_a, _spill_a) = store.new_spill().await.unwrap();
479 let (writer_b, _spill_b) = store.new_spill().await.unwrap();
480
481 finish_writer(writer_a, &[0u8; 60]).await.unwrap();
482 let err = finish_writer(writer_b, &[0u8; 60]).await.unwrap_err();
484 assert!(
485 matches!(err, Error::DiskCapExceeded { cap_bytes: 100, .. }),
486 "expected DiskCapExceeded, got {err:?}"
487 );
488 }
489
490 #[tokio::test]
491 async fn test_cap_freed_on_drop() {
492 let store = LocalSpillStore::with_cap(100).unwrap();
493
494 {
495 let (writer, spill) = store.new_spill().await.unwrap();
496 finish_writer(writer, &[0u8; 80]).await.unwrap();
497 drop(spill);
499 }
500
501 let (writer, _spill) = store.new_spill().await.unwrap();
502 finish_writer(writer, &[0u8; 80]).await.unwrap();
504 }
505
506 #[tokio::test]
507 async fn test_custom_implementation() {
508 struct MemStore;
510 struct MemSpill;
511
512 #[async_trait]
513 impl Spill for MemSpill {
514 async fn reader(&self) -> Result<Box<dyn Reader>> {
515 ObjectStore::memory().open(&Path::from("/mem")).await
516 }
517 }
518
519 #[async_trait]
520 impl SpillStore for MemStore {
521 async fn new_spill(&self) -> Result<(Box<dyn Writer>, Box<dyn Spill>)> {
522 let writer = ObjectStore::memory().create(&Path::from("/mem")).await?;
523 Ok((writer, Box::new(MemSpill)))
524 }
525 }
526
527 let store = MemStore;
528 let (_writer, _spill) = store.new_spill().await.unwrap();
531 }
532
533 struct ControlledWriter {
537 outcome: Poll<io::Result<usize>>,
538 }
539
540 impl AsyncWrite for ControlledWriter {
541 fn poll_write(
542 self: Pin<&mut Self>,
543 _cx: &mut Context<'_>,
544 buf: &[u8],
545 ) -> Poll<io::Result<usize>> {
546 match &self.outcome {
547 Poll::Ready(Ok(n)) => Poll::Ready(Ok((*n).min(buf.len()))),
548 Poll::Ready(Err(e)) => Poll::Ready(Err(io::Error::new(e.kind(), e.to_string()))),
549 Poll::Pending => Poll::Pending,
550 }
551 }
552 fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
553 Poll::Ready(Ok(()))
554 }
555 fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
556 Poll::Ready(Ok(()))
557 }
558 }
559
560 #[async_trait]
561 impl Writer for ControlledWriter {
562 async fn tell(&mut self) -> Result<usize> {
563 Ok(0)
564 }
565 async fn shutdown(&mut self) -> Result<WriteResult> {
566 Ok(WriteResult::default())
567 }
568 }
569
570 #[tokio::test]
571 async fn test_spill_writer_releases_unaccepted_bytes() {
572 let quota = DiskQuota::new(100);
575 let mut writer = SpillWriter {
576 inner: Box::new(ControlledWriter {
577 outcome: Poll::Ready(Ok(10)),
578 }),
579 quota: Some(quota.clone()),
580 finished: Arc::new(AtomicBool::new(false)),
581 };
582 let n = writer.write(&[0u8; 40]).await.unwrap();
583 assert_eq!(n, 10);
584 assert_eq!(
585 *quota.used.lock().unwrap(),
586 10,
587 "only the accepted bytes should remain reserved"
588 );
589
590 let quota = DiskQuota::new(100);
592 let mut writer = SpillWriter {
593 inner: Box::new(ControlledWriter {
594 outcome: Poll::Ready(Err(io::Error::other("boom"))),
595 }),
596 quota: Some(quota.clone()),
597 finished: Arc::new(AtomicBool::new(false)),
598 };
599 writer.write(&[0u8; 40]).await.unwrap_err();
600 assert_eq!(
601 *quota.used.lock().unwrap(),
602 0,
603 "a failed write should release its entire reservation"
604 );
605 }
606}