Skip to main content

open_geocode/
extsort.rs

1//! Disk-backed sorting and spooling for builds larger than memory.
2//!
3//! [`ExternalSorter`] buffers items until its share of the build
4//! [`MemoryBudget`] is used (or the shared pool runs out), sorts the buffer,
5//! and writes it to a run file in the build scratch directory. Reading merges
6//! every run with a k-way heap. When everything fits no run is written and the
7//! sort is an ordinary in-memory sort, so a city extract and a planet build
8//! share one code path.
9//!
10//! [`Spool`] is the unsorted sibling: an append-only scratch file replayed in
11//! write order.
12
13use std::{
14    cmp::Reverse,
15    collections::BinaryHeap,
16    fs::{self, File},
17    io::{BufReader, BufWriter, Read, Write},
18    marker::PhantomData,
19    path::{Path, PathBuf},
20    sync::{
21        Arc,
22        atomic::{AtomicU64, Ordering},
23    },
24};
25
26use anyhow::{Context, Result, bail};
27use rayon::prelude::*;
28
29use crate::{
30    memory::{MemoryBudget, Reservation},
31    util::codec::{get_u64, put_u64},
32};
33
34const IO_BUFFER_BYTES: usize = 1 << 20;
35/// Runs merged at once. Beyond this, runs are merged in rounds so the number of
36/// open files and read buffers stays bounded.
37const MAX_MERGE_FAN_IN: usize = 64;
38/// When the shared pool is full, a sorter spills only once its buffer holds at
39/// least this fraction of its share; smaller runs would cost more than they free.
40const MIN_RUN_FRACTION: usize = 16;
41/// Smallest buffer growth step, in items.
42const MIN_CAPACITY: usize = 1_024;
43/// Smallest read buffer per run during a merge.
44const MIN_MERGE_BUFFER_BYTES: usize = 64 << 10;
45
46/// A value that can be written to and read back from scratch files.
47pub trait Spill: Sized {
48    fn encode(&self, out: &mut Vec<u8>);
49    fn decode(input: &mut &[u8]) -> Result<Self>;
50    /// Approximate bytes this value occupies in memory, counted against the
51    /// sorter budget. Values that own heap data must include it.
52    fn resident_bytes(&self) -> usize {
53        std::mem::size_of::<Self>()
54    }
55}
56
57/// Build scratch directory. Removed with everything in it when dropped.
58#[derive(Debug)]
59pub struct Scratch {
60    path: PathBuf,
61    next_file: AtomicU64,
62    spilled_bytes: AtomicU64,
63}
64
65impl Scratch {
66    pub fn create(path: impl Into<PathBuf>) -> Result<Arc<Self>> {
67        let path = path.into();
68        fs::create_dir_all(&path)
69            .with_context(|| format!("failed to create scratch directory {}", path.display()))?;
70        Ok(Arc::new(Self {
71            path,
72            next_file: AtomicU64::new(0),
73            spilled_bytes: AtomicU64::new(0),
74        }))
75    }
76
77    pub fn path(&self) -> &Path {
78        &self.path
79    }
80
81    /// Total bytes written to scratch files so far.
82    pub fn spilled_bytes(&self) -> u64 {
83        self.spilled_bytes.load(Ordering::Relaxed)
84    }
85
86    fn new_file(&self, label: &str) -> PathBuf {
87        let index = self.next_file.fetch_add(1, Ordering::Relaxed);
88        self.path.join(format!("{label}-{index:06}.bin"))
89    }
90}
91
92impl Drop for Scratch {
93    fn drop(&mut self) {
94        let _ = fs::remove_dir_all(&self.path);
95    }
96}
97
98/// Spill statistics for one sorter, surfaced in the build report.
99#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
100pub struct SortStats {
101    pub items: u64,
102    pub runs: u64,
103}
104
105pub struct ExternalSorter<T> {
106    scratch: Arc<Scratch>,
107    budget: Arc<MemoryBudget>,
108    label: &'static str,
109    /// Bytes this sorter may buffer before it spills.
110    share_bytes: usize,
111    /// Buffer capacity plus the heap bytes of buffered items.
112    reservation: Reservation,
113    buffer: Vec<T>,
114    runs: Vec<PathBuf>,
115    stats: SortStats,
116}
117
118impl<T: Spill + Ord + Send> ExternalSorter<T> {
119    pub fn new(
120        scratch: &Arc<Scratch>,
121        label: &'static str,
122        budget: &Arc<MemoryBudget>,
123        share_bytes: usize,
124    ) -> Self {
125        Self {
126            scratch: Arc::clone(scratch),
127            budget: Arc::clone(budget),
128            label,
129            share_bytes: share_bytes.max(1),
130            reservation: Reservation::empty(budget),
131            buffer: Vec::new(),
132            runs: Vec::new(),
133            stats: SortStats::default(),
134        }
135    }
136
137    pub fn push(&mut self, item: T) -> Result<()> {
138        let heap = item
139            .resident_bytes()
140            .saturating_sub(std::mem::size_of::<T>());
141        let (mut growth, mut needed) = self.needed(heap);
142        let over_share = self.reservation.bytes() + needed > self.share_bytes;
143        if over_share || !self.reservation.try_grow(needed) {
144            // Spill when the share is used up, or when the pool is full and
145            // the buffer is big enough to be worth a run.
146            if over_share || self.reservation.bytes() >= self.share_bytes / MIN_RUN_FRACTION {
147                self.spill()?;
148                (growth, needed) = self.needed(heap);
149            }
150            if !self.reservation.try_grow(needed) {
151                self.reservation.force_grow(needed);
152            }
153        }
154        self.buffer.reserve_exact(growth);
155        self.buffer.push(item);
156        self.stats.items += 1;
157        Ok(())
158    }
159
160    /// Items the buffer must grow by to take one more item, and the bytes that
161    /// costs including the item's own heap data.
162    fn needed(&self, heap: usize) -> (usize, usize) {
163        let growth = if self.buffer.len() == self.buffer.capacity() {
164            // Double, but never past what the share can hold.
165            let share_items = (self.share_bytes / std::mem::size_of::<T>().max(1)).max(1);
166            self.buffer
167                .capacity()
168                .max(MIN_CAPACITY)
169                .min(share_items.saturating_sub(self.buffer.capacity()))
170                .max(1)
171        } else {
172            0
173        };
174        (growth, heap + growth * std::mem::size_of::<T>())
175    }
176
177    pub fn stats(&self) -> SortStats {
178        self.stats
179    }
180
181    /// Sorted items, ascending, with ties in insertion order.
182    pub fn finish(mut self) -> Result<Sorted<T>> {
183        if self.runs.is_empty() {
184            // Stable sort plus run-order tie-breaking keeps equal items in push order.
185            self.buffer.par_sort();
186            let reservation =
187                std::mem::replace(&mut self.reservation, Reservation::empty(&self.budget));
188            return Ok(Sorted::Memory(
189                std::mem::take(&mut self.buffer).into_iter(),
190                reservation,
191            ));
192        }
193        // Once anything spilled, the tail is spilled too, so a finished sorter
194        // holds only merge read buffers while later phases fill their own.
195        self.spill()?;
196        while self.runs.len() > MAX_MERGE_FAN_IN {
197            let batch = self.runs.drain(..MAX_MERGE_FAN_IN).collect::<Vec<_>>();
198            let merged = self.scratch.new_file(self.label);
199            let mut writer = SpoolWriter::<T>::create(&self.scratch, &merged)?;
200            for item in Merge::<T>::open(&batch, &self.budget, self.share_bytes)? {
201                writer.write(&item?)?;
202            }
203            writer.finish()?;
204            self.runs.insert(0, merged);
205        }
206        Ok(Sorted::Merge(Merge::open(
207            &self.runs,
208            &self.budget,
209            self.share_bytes,
210        )?))
211    }
212
213    fn spill(&mut self) -> Result<()> {
214        if self.buffer.is_empty() {
215            return Ok(());
216        }
217        self.buffer.par_sort();
218        let path = self.scratch.new_file(self.label);
219        let mut writer = SpoolWriter::create(&self.scratch, &path)?;
220        for item in self.buffer.drain(..) {
221            writer.write(&item)?;
222        }
223        writer.finish()?;
224        // Return the memory to the pool, not just the items.
225        self.buffer = Vec::new();
226        self.reservation.release_all();
227        self.runs.push(path);
228        self.stats.runs += 1;
229        Ok(())
230    }
231}
232
233/// Sorted output. The in-memory form keeps its memory reserved until dropped.
234pub enum Sorted<T> {
235    Memory(std::vec::IntoIter<T>, Reservation),
236    Merge(Merge<T>),
237}
238
239impl<T: Spill + Ord> Iterator for Sorted<T> {
240    type Item = Result<T>;
241
242    fn next(&mut self) -> Option<Self::Item> {
243        match self {
244            Self::Memory(items, _) => items.next().map(Ok),
245            Self::Merge(merge) => merge.next(),
246        }
247    }
248}
249
250/// K-way merge over sorted run files.
251pub struct Merge<T> {
252    sources: Vec<SpoolReader<T>>,
253    heap: BinaryHeap<Reverse<HeapEntry<T>>>,
254    failed: bool,
255    /// Read buffers of the open runs.
256    _buffers: Reservation,
257}
258
259struct HeapEntry<T> {
260    item: T,
261    source: usize,
262}
263
264impl<T: Ord> PartialEq for HeapEntry<T> {
265    fn eq(&self, other: &Self) -> bool {
266        self.cmp(other) == std::cmp::Ordering::Equal
267    }
268}
269
270impl<T: Ord> Eq for HeapEntry<T> {}
271
272impl<T: Ord> PartialOrd for HeapEntry<T> {
273    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
274        Some(self.cmp(other))
275    }
276}
277
278impl<T: Ord> Ord for HeapEntry<T> {
279    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
280        self.item
281            .cmp(&other.item)
282            .then_with(|| self.source.cmp(&other.source))
283    }
284}
285
286impl<T: Spill + Ord> Merge<T> {
287    /// Each run is read exactly once, so its file is deleted as soon as the
288    /// merge is dropped; at planet scale this frees scratch disk between phases.
289    /// Read buffers are sized so all of them fit the sorter's share, between
290    /// [`MIN_MERGE_BUFFER_BYTES`] and [`IO_BUFFER_BYTES`] each. They are needed
291    /// to make progress, so they are reserved even when the pool is full.
292    fn open(runs: &[PathBuf], budget: &Arc<MemoryBudget>, share_bytes: usize) -> Result<Self> {
293        let buffer_bytes =
294            (share_bytes / runs.len().max(1)).clamp(MIN_MERGE_BUFFER_BYTES, IO_BUFFER_BYTES);
295        let mut buffers = Reservation::empty(budget);
296        if !buffers.try_grow(runs.len() * buffer_bytes) {
297            buffers.force_grow(runs.len() * buffer_bytes);
298        }
299        let mut sources = runs
300            .iter()
301            .map(|path| {
302                let mut reader = SpoolReader::open(path, buffer_bytes)?;
303                reader.remove_on_drop = true;
304                Ok(reader)
305            })
306            .collect::<Result<Vec<_>>>()?;
307        let mut heap = BinaryHeap::with_capacity(sources.len());
308        for (source, input) in sources.iter_mut().enumerate() {
309            if let Some(item) = input.next() {
310                heap.push(Reverse(HeapEntry {
311                    item: item?,
312                    source,
313                }));
314            }
315        }
316        Ok(Self {
317            sources,
318            heap,
319            failed: false,
320            _buffers: buffers,
321        })
322    }
323}
324
325impl<T: Spill + Ord> Iterator for Merge<T> {
326    type Item = Result<T>;
327
328    fn next(&mut self) -> Option<Self::Item> {
329        if self.failed {
330            return None;
331        }
332        let Reverse(entry) = self.heap.pop()?;
333        match self.sources[entry.source].next() {
334            Some(Ok(item)) => self.heap.push(Reverse(HeapEntry {
335                item,
336                source: entry.source,
337            })),
338            Some(Err(error)) => {
339                self.failed = true;
340                return Some(Err(error));
341            }
342            None => {}
343        }
344        Some(Ok(entry.item))
345    }
346}
347
348/// Append-only scratch file replayed in write order.
349pub struct Spool<T> {
350    writer: SpoolWriter<T>,
351    path: PathBuf,
352}
353
354impl<T: Spill> Spool<T> {
355    pub fn create(scratch: &Arc<Scratch>, label: &str) -> Result<Self> {
356        let path = scratch.new_file(label);
357        Ok(Self {
358            writer: SpoolWriter::create(scratch, &path)?,
359            path,
360        })
361    }
362
363    pub fn write(&mut self, item: &T) -> Result<()> {
364        self.writer.write(item)
365    }
366
367    /// Items written so far.
368    pub fn written(&self) -> u64 {
369        self.writer.items
370    }
371
372    /// Close the spool and read it back from the start. The file is removed
373    /// once the reader is dropped.
374    pub fn into_reader(self) -> Result<SpoolReader<T>> {
375        self.writer.finish()?;
376        let mut reader = SpoolReader::open(&self.path, IO_BUFFER_BYTES)?;
377        reader.remove_on_drop = true;
378        Ok(reader)
379    }
380}
381
382struct SpoolWriter<T> {
383    file: BufWriter<File>,
384    scratch: Arc<Scratch>,
385    buffer: Vec<u8>,
386    items: u64,
387    _marker: PhantomData<T>,
388}
389
390impl<T: Spill> SpoolWriter<T> {
391    fn create(scratch: &Arc<Scratch>, path: &Path) -> Result<Self> {
392        let file = File::create(path)
393            .with_context(|| format!("failed to create scratch file {}", path.display()))?;
394        Ok(Self {
395            file: BufWriter::with_capacity(IO_BUFFER_BYTES, file),
396            scratch: Arc::clone(scratch),
397            buffer: Vec::new(),
398            items: 0,
399            _marker: PhantomData,
400        })
401    }
402
403    fn write(&mut self, item: &T) -> Result<()> {
404        self.buffer.clear();
405        item.encode(&mut self.buffer);
406        let mut prefix = Vec::with_capacity(10);
407        put_u64(&mut prefix, self.buffer.len() as u64);
408        self.file.write_all(&prefix)?;
409        self.file.write_all(&self.buffer)?;
410        self.scratch
411            .spilled_bytes
412            .fetch_add((prefix.len() + self.buffer.len()) as u64, Ordering::Relaxed);
413        self.items += 1;
414        Ok(())
415    }
416
417    fn finish(mut self) -> Result<()> {
418        self.file.flush().context("failed to flush scratch file")
419    }
420}
421
422pub struct SpoolReader<T> {
423    file: BufReader<File>,
424    path: PathBuf,
425    buffer: Vec<u8>,
426    remove_on_drop: bool,
427    _marker: PhantomData<T>,
428}
429
430impl<T: Spill> SpoolReader<T> {
431    fn open(path: &Path, buffer_bytes: usize) -> Result<Self> {
432        let file = File::open(path)
433            .with_context(|| format!("failed to open scratch file {}", path.display()))?;
434        Ok(Self {
435            file: BufReader::with_capacity(buffer_bytes, file),
436            path: path.to_path_buf(),
437            buffer: Vec::new(),
438            remove_on_drop: false,
439            _marker: PhantomData,
440        })
441    }
442
443    fn read_item(&mut self) -> Result<Option<T>> {
444        let Some(len) = self.read_len()? else {
445            return Ok(None);
446        };
447        self.buffer.resize(len, 0);
448        self.file
449            .read_exact(&mut self.buffer)
450            .with_context(|| format!("truncated scratch file {}", self.path.display()))?;
451        let mut input = self.buffer.as_slice();
452        let item = T::decode(&mut input)?;
453        if !input.is_empty() {
454            bail!("scratch item in {} has trailing bytes", self.path.display());
455        }
456        Ok(Some(item))
457    }
458
459    /// Length prefix of the next item, or `None` at a clean end of file.
460    fn read_len(&mut self) -> Result<Option<usize>> {
461        let mut prefix = [0u8; 10];
462        let mut used = 0;
463        loop {
464            let mut byte = [0u8; 1];
465            if self.file.read(&mut byte)? == 0 {
466                if used == 0 {
467                    return Ok(None);
468                }
469                bail!("truncated length prefix in {}", self.path.display());
470            }
471            prefix[used] = byte[0];
472            used += 1;
473            if byte[0] & 0x80 == 0 {
474                break;
475            }
476            if used == prefix.len() {
477                bail!("invalid length prefix in {}", self.path.display());
478            }
479        }
480        let len = get_u64(&mut &prefix[..used])?;
481        Ok(Some(
482            usize::try_from(len).context("scratch item too large")?,
483        ))
484    }
485}
486
487impl<T: Spill> Iterator for SpoolReader<T> {
488    type Item = Result<T>;
489
490    fn next(&mut self) -> Option<Self::Item> {
491        self.read_item().transpose()
492    }
493}
494
495impl<T> Drop for SpoolReader<T> {
496    fn drop(&mut self) {
497        if self.remove_on_drop {
498            let _ = fs::remove_file(&self.path);
499        }
500    }
501}
502
503macro_rules! spill_tuple_of_unsigned {
504    ($($name:ident: $ty:ty),+) => {
505        impl Spill for ($($ty,)+) {
506            fn encode(&self, out: &mut Vec<u8>) {
507                let ($($name,)+) = *self;
508                $(put_u64(out, u64::from($name));)+
509            }
510
511            fn decode(input: &mut &[u8]) -> Result<Self> {
512                Ok(($(<$ty>::try_from(get_u64(input)?).context("scratch value out of range")?,)+))
513            }
514        }
515    };
516}
517
518spill_tuple_of_unsigned!(a: u64, b: u64);
519spill_tuple_of_unsigned!(a: u64, b: u8, c: u64);
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    fn scratch(name: &str) -> Arc<Scratch> {
526        Scratch::create(std::env::temp_dir().join(format!(
527            "open-geocode-extsort-{name}-{}",
528            uuid::Uuid::new_v4()
529        )))
530        .expect("scratch")
531    }
532
533    fn pseudo_random(count: u64) -> Vec<(u64, u64)> {
534        let mut state = 0x9e37_79b9_7f4a_7c15u64;
535        (0..count)
536            .map(|index| {
537                state ^= state << 13;
538                state ^= state >> 7;
539                state ^= state << 17;
540                (state % 1_000, index)
541            })
542            .collect()
543    }
544
545    #[test]
546    fn in_memory_sort_writes_no_runs() {
547        let scratch = scratch("memory");
548        let mut sorter =
549            ExternalSorter::new(&scratch, "pairs", &MemoryBudget::unlimited(), usize::MAX);
550        let input = pseudo_random(10_000);
551        for item in &input {
552            sorter.push(*item).expect("push");
553        }
554        assert_eq!(sorter.stats().runs, 0);
555        let sorted = sorter
556            .finish()
557            .expect("finish")
558            .collect::<Result<Vec<_>>>()
559            .expect("items");
560        let mut expected = input;
561        expected.sort();
562        assert_eq!(sorted, expected);
563        assert_eq!(scratch.spilled_bytes(), 0);
564    }
565
566    #[test]
567    fn tiny_budget_spills_many_runs_and_merges_in_rounds() {
568        let scratch = scratch("spill");
569        // Each item counts 16 bytes, so this spills every 50 items: 400 runs,
570        // more than one merge round.
571        let mut sorter =
572            ExternalSorter::new(&scratch, "pairs", &MemoryBudget::unlimited(), 50 * 16);
573        let input = pseudo_random(20_000);
574        for item in &input {
575            sorter.push(*item).expect("push");
576        }
577        assert!(sorter.stats().runs as usize > MAX_MERGE_FAN_IN);
578        let sorted = sorter
579            .finish()
580            .expect("finish")
581            .collect::<Result<Vec<_>>>()
582            .expect("items");
583        let mut expected = input;
584        expected.sort();
585        assert_eq!(sorted, expected);
586        assert!(scratch.spilled_bytes() > 0);
587    }
588
589    #[test]
590    fn sorters_sharing_a_budget_stay_within_it() {
591        let scratch = scratch("shared");
592        let item = std::mem::size_of::<(u64, u64)>();
593        // Each sorter's share alone would fit; together they do not.
594        let budget = MemoryBudget::new(64 * 1024 * item);
595        let mut first = ExternalSorter::new(&scratch, "first", &budget, 48 * 1024 * item);
596        let mut second = ExternalSorter::new(&scratch, "second", &budget, 48 * 1024 * item);
597        let input = pseudo_random(400_000);
598        for pair in input.chunks(2) {
599            first.push(pair[0]).expect("push");
600            second.push(pair[1]).expect("push");
601        }
602        assert!(
603            budget.peak() <= budget.limit(),
604            "peak {} over {}",
605            budget.peak(),
606            budget.limit()
607        );
608        assert!(first.stats().runs > 0 && second.stats().runs > 0);
609        let first = first.finish().expect("finish");
610        // A spilled sorter writes its tail out: only read buffers stay reserved.
611        assert_eq!(
612            budget.used(),
613            first_runs_buffers(&first) + second.reservation.bytes()
614        );
615        let mut merged = first.collect::<Result<Vec<_>>>().expect("first");
616        merged.extend(
617            second
618                .finish()
619                .expect("finish")
620                .collect::<Result<Vec<_>>>()
621                .expect("second"),
622        );
623        merged.sort();
624        let mut expected = input;
625        expected.sort();
626        assert_eq!(merged, expected);
627        assert_eq!(budget.used(), 0);
628    }
629
630    fn first_runs_buffers<T>(sorted: &Sorted<T>) -> usize {
631        match sorted {
632            Sorted::Memory(..) => 0,
633            Sorted::Merge(merge) => merge._buffers.bytes(),
634        }
635    }
636
637    #[test]
638    fn run_files_are_removed_once_merged() {
639        let scratch = scratch("cleanup");
640        let mut sorter =
641            ExternalSorter::new(&scratch, "pairs", &MemoryBudget::unlimited(), 10 * 16);
642        for item in pseudo_random(1_000) {
643            sorter.push(item).expect("push");
644        }
645        let files = || fs::read_dir(scratch.path()).expect("scratch").count();
646        assert!(files() > 0);
647        let sorted = sorter.finish().expect("finish");
648        assert_eq!(sorted.count(), 1_000);
649        assert_eq!(files(), 0);
650    }
651
652    #[test]
653    fn equal_keys_keep_insertion_order_across_runs() {
654        #[derive(Debug, Clone, PartialEq, Eq)]
655        struct Keyed {
656            key: u64,
657            payload: u64,
658        }
659        impl PartialOrd for Keyed {
660            fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
661                Some(self.cmp(other))
662            }
663        }
664        impl Ord for Keyed {
665            fn cmp(&self, other: &Self) -> std::cmp::Ordering {
666                self.key.cmp(&other.key)
667            }
668        }
669        impl Spill for Keyed {
670            fn encode(&self, out: &mut Vec<u8>) {
671                put_u64(out, self.key);
672                put_u64(out, self.payload);
673            }
674            fn decode(input: &mut &[u8]) -> Result<Self> {
675                Ok(Self {
676                    key: get_u64(input)?,
677                    payload: get_u64(input)?,
678                })
679            }
680        }
681
682        let scratch = scratch("stable");
683        let mut sorter = ExternalSorter::new(
684            &scratch,
685            "keyed",
686            &MemoryBudget::unlimited(),
687            7 * std::mem::size_of::<Keyed>(),
688        );
689        for payload in 0..1_000 {
690            sorter
691                .push(Keyed {
692                    key: payload % 3,
693                    payload,
694                })
695                .expect("push");
696        }
697        let sorted = sorter
698            .finish()
699            .expect("finish")
700            .collect::<Result<Vec<_>>>()
701            .expect("items");
702        for pair in sorted.windows(2) {
703            if pair[0].key == pair[1].key {
704                assert!(pair[0].payload < pair[1].payload);
705            }
706        }
707    }
708
709    #[test]
710    fn spool_replays_in_write_order_and_cleans_up() {
711        let scratch = scratch("spool");
712        let mut spool = Spool::create(&scratch, "items").expect("spool");
713        for item in [(3u64, 1u64), (1, 2), (2, 3)] {
714            spool.write(&item).expect("write");
715        }
716        assert_eq!(spool.written(), 3);
717        let reader = spool.into_reader().expect("reader");
718        let path = reader.path.clone();
719        let items = reader.collect::<Result<Vec<_>>>().expect("items");
720        assert_eq!(items, vec![(3, 1), (1, 2), (2, 3)]);
721        assert!(!path.exists());
722    }
723
724    #[test]
725    fn scratch_directory_is_removed_on_drop() {
726        let scratch = scratch("drop");
727        let path = scratch.path().to_path_buf();
728        let mut spool = Spool::create(&scratch, "items").expect("spool");
729        spool.write(&(1u64, 1u64)).expect("write");
730        drop(spool);
731        drop(scratch);
732        assert!(!path.exists());
733    }
734}