Skip to main content

sui_castore/storage/
nar_stream.rs

1//! Bounded-chunk NAR movement — the vocabulary that makes "the whole NAR is
2//! resident" stop being the only way to move one.
3//!
4//! # Why this module exists
5//!
6//! The original [`StorageBackend`](super::StorageBackend) NAR verbs were
7//!
8//! ```ignore
9//! async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, StoreError>;
10//! async fn put_nar(&self, path: &str, data: &[u8])  -> Result<(), StoreError>;
11//! ```
12//!
13//! Owned bytes in, owned bytes out. **Streaming a NAR was not expressible
14//! through that signature**, so every NAR was fully resident in the process for
15//! as long as it took to write or serve it — and a Postgres L2 `INSERT` of one
16//! measured **12.712 s** in production. With a 6 GiB pod limit, the peak was set
17//! by the largest NAR in flight and nothing bounded it. sui OOMKilled six times
18//! in one day on camelot-eks.
19//!
20//! The fix is a vocabulary, not a patch: a NAR moves as a sequence of
21//! [`NAR_CHUNK_BYTES`]-sized [`Bytes`] chunks, and the thing a writer is handed
22//! is a **re-openable** [`NarSource`] rather than a slice.
23//!
24//! # Why the write side is a source, not a stream
25//!
26//! [`TieredBackend`](super::TieredBackend)`::put_nar` must write **L2, then L3,
27//! then warm L1**, each independently, gating on the two durable tiers before
28//! the best-effort hot warm. A one-shot `Stream` can be consumed exactly once,
29//! so it would force either (a) buffering the whole NAR to fan it out — the bug
30//! being fixed — or (b) interleaving chunks across all three tiers, which
31//! changes that ordering. A source that can be **opened once per tier** keeps
32//! the ordering byte-for-byte identical while never holding more than one chunk.
33//!
34//! # Tier honesty
35//!
36//! The unbounded-buffer state is **not** truly-unrepresentable: [`collect_nar`]
37//! exists, and [`BytesNarSource`] wraps a whole NAR on purpose (test doubles,
38//! and callers who genuinely need the bytes). What *is* enforced is that a
39//! backend cannot inherit the buffering path by accident — see
40//! [`NarResidency`](super::NarResidency), which every `StorageBackend`
41//! implementor must state explicitly because it has no default.
42
43use std::path::{Path, PathBuf};
44
45use async_trait::async_trait;
46use bytes::{Bytes, BytesMut};
47use futures::stream::{self, BoxStream, StreamExt};
48use tokio::io::AsyncReadExt;
49
50use crate::StoreError;
51
52/// The bounded chunk size every streaming NAR path moves bytes in.
53///
54/// 4 MiB is large enough that a 1 GiB NAR is ~256 round trips (not 250 000) and
55/// small enough that a dozen concurrent transfers cost tens of MiB, not
56/// gigabytes. It is the *only* size constant in the streaming path: a backend
57/// that invents its own is drift.
58pub const NAR_CHUNK_BYTES: usize = 4 * 1024 * 1024;
59
60/// A NAR byte stream — bounded chunks, consumed exactly once.
61pub type NarStream = BoxStream<'static, Result<Bytes, StoreError>>;
62
63/// A **re-openable** NAR byte source.
64///
65/// [`open`](NarSource::open) yields a *fresh* bounded-chunk stream over the same
66/// bytes every time it is called, so a fan-out writer feeds each destination in
67/// order from its own stream and never has to materialize the NAR to serve more
68/// than one consumer. See the module docs for why the write side needs this and
69/// a plain `Stream` will not do.
70#[async_trait]
71pub trait NarSource: Send + Sync {
72    /// Total byte length if it is known before reading. Backends use it to size
73    /// a multipart upload or to reject an over-cap value *before* reading a
74    /// single byte; `None` is always legal and must never be load-bearing.
75    fn size_hint(&self) -> Option<u64> {
76        None
77    }
78
79    /// Open a fresh chunk stream over the same bytes.
80    ///
81    /// # Errors
82    ///
83    /// Returns a [`StoreError`] if the underlying bytes cannot be (re-)opened —
84    /// a spool file that was removed, a lower tier that lost the content
85    /// between the probe and the promotion.
86    async fn open(&self) -> Result<NarStream, StoreError>;
87}
88
89// ---------------------------------------------------------------------------
90// Sources
91// ---------------------------------------------------------------------------
92
93/// A [`NarSource`] over bytes already in memory.
94///
95/// Zero-copy: [`Bytes`] slices share one allocation, so re-opening does not
96/// duplicate the NAR. It is still **O(nar) resident** by construction — that is
97/// the point of the name. Use it for test doubles, for small values, and at a
98/// boundary that genuinely already holds the bytes; never as the way a large
99/// upload reaches a backend.
100#[derive(Debug, Clone)]
101pub struct BytesNarSource {
102    bytes: Bytes,
103}
104
105impl BytesNarSource {
106    /// Wrap owned bytes.
107    #[must_use]
108    pub fn new(bytes: impl Into<Bytes>) -> Self {
109        Self { bytes: bytes.into() }
110    }
111}
112
113impl From<&[u8]> for BytesNarSource {
114    fn from(v: &[u8]) -> Self {
115        Self::new(Bytes::copy_from_slice(v))
116    }
117}
118
119#[async_trait]
120impl NarSource for BytesNarSource {
121    fn size_hint(&self) -> Option<u64> {
122        Some(self.bytes.len() as u64)
123    }
124
125    async fn open(&self) -> Result<NarStream, StoreError> {
126        Ok(bytes_stream(self.bytes.clone()))
127    }
128}
129
130/// A [`NarSource`] over a file on disk.
131///
132/// Each [`open`](NarSource::open) is a fresh `File` read in [`NAR_CHUNK_BYTES`]
133/// steps — **O(chunk) resident regardless of file size**. This is what a spooled
134/// HTTP upload and a local-tier promotion both ride on.
135#[derive(Debug, Clone)]
136pub struct FileNarSource {
137    path: PathBuf,
138    len: Option<u64>,
139}
140
141impl FileNarSource {
142    /// Source the file at `path`. The length is probed lazily on first
143    /// [`size_hint`](NarSource::size_hint) caller demand — construction does no
144    /// I/O, so a missing file surfaces at `open` where it can be reported.
145    #[must_use]
146    pub fn new(path: impl Into<PathBuf>) -> Self {
147        Self { path: path.into(), len: None }
148    }
149
150    /// Source the file at `path`, recording a known byte length.
151    #[must_use]
152    pub fn with_len(path: impl Into<PathBuf>, len: u64) -> Self {
153        Self { path: path.into(), len: Some(len) }
154    }
155
156    /// The file this source reads.
157    #[must_use]
158    pub fn path(&self) -> &Path {
159        &self.path
160    }
161}
162
163#[async_trait]
164impl NarSource for FileNarSource {
165    fn size_hint(&self) -> Option<u64> {
166        self.len
167    }
168
169    async fn open(&self) -> Result<NarStream, StoreError> {
170        let file = tokio::fs::File::open(&self.path).await.map_err(StoreError::Io)?;
171        Ok(file_stream(file))
172    }
173}
174
175/// Default cap for the in-memory ingest fallback — see [`spool_or_buffer`].
176///
177/// This is the *worst case* peak of an ingest that could not get a spool file.
178/// It has to be generous enough that ordinary NARs keep flowing when the spool
179/// volume is unavailable, and small enough that a handful of concurrent ones
180/// cannot fill a 6 GiB pod. 256 MiB gives ~20 concurrent uploads of headroom.
181pub const DEFAULT_INGEST_MEMORY_CAP: usize = 256 * 1024 * 1024;
182
183/// Deletes the spool file when the source is dropped.
184///
185/// A separate type rather than a `Drop` on [`SpooledNarSource`] so the source
186/// stays cheap to move and the cleanup is impossible to forget in a future
187/// field addition.
188#[derive(Debug)]
189struct SpoolGuard(PathBuf);
190
191impl Drop for SpoolGuard {
192    fn drop(&mut self) {
193        // Best-effort: the spool volume may already be gone. Leaving a stray
194        // file is untidy; panicking in `drop` during an error unwind is worse.
195        let _ = std::fs::remove_file(&self.0);
196    }
197}
198
199/// A [`NarSource`] over a spool file, deleted when the source is dropped.
200///
201/// This is what turns a **one-shot** upload — an HTTP request body, which can be
202/// read exactly once — into something a fan-out writer can open per tier. Peak
203/// is one chunk in each direction.
204#[derive(Debug)]
205pub struct SpooledNarSource {
206    inner: FileNarSource,
207    _guard: SpoolGuard,
208}
209
210#[async_trait]
211impl NarSource for SpooledNarSource {
212    fn size_hint(&self) -> Option<u64> {
213        self.inner.size_hint()
214    }
215
216    async fn open(&self) -> Result<NarStream, StoreError> {
217        self.inner.open().await
218    }
219}
220
221/// Turn a one-shot byte stream into a re-openable [`NarSource`], **bounded
222/// either way**.
223///
224/// Preferred path: spool to a file in `dir` in [`NAR_CHUNK_BYTES`] steps, peak
225/// one chunk. Fallback: if the spool file cannot be *created* — no `dir`, no
226/// permission, a full volume — buffer in memory instead, hard-capped at
227/// `memory_cap`, refusing past it with [`StoreError::TooLarge`].
228///
229/// The fallback is chosen **before any bytes are read**, deliberately. A spool
230/// that fails halfway has already consumed part of a one-shot stream and cannot
231/// be restarted, so mid-write faults surface as errors rather than silently
232/// switching strategy and truncating the upload.
233///
234/// Tier honesty: the fallback path is *bounded*, not *streaming* — a machine
235/// with no usable spool directory has a `memory_cap`-sized worst case per
236/// concurrent ingest, and NARs above the cap are refused rather than cached.
237/// That is a deliberate trade against the alternative, which is the pod dying
238/// and taking every in-flight build with it.
239///
240/// # Errors
241///
242/// Propagates a read error from `stream`, a write error to the spool file, or
243/// [`StoreError::TooLarge`] when the memory fallback is in use and exceeded.
244pub async fn spool_or_buffer<S, E>(
245    mut stream: S,
246    dir: &Path,
247    memory_cap: usize,
248) -> Result<Box<dyn NarSource>, StoreError>
249where
250    S: futures::Stream<Item = Result<Bytes, E>> + Send + Unpin,
251    // Generic over the stream's error so a caller can hand over its transport's
252    // own stream (an axum body, a reqwest response) untouched. Forcing the
253    // caller to `.map()` into `StoreError` first would push a `futures`
254    // dependency onto every ingest boundary for nothing.
255    E: std::fmt::Display + Send,
256{
257    use tokio::io::AsyncWriteExt;
258
259    fn transport_err<E: std::fmt::Display>(e: E) -> StoreError {
260        StoreError::Io(std::io::Error::other(format!("nar ingest: {e}")))
261    }
262
263    let path = spool_path(dir);
264    let created = tokio::fs::File::create(&path).await;
265
266    let Ok(mut file) = created else {
267        let e = created.err().expect("checked Err");
268        tracing::warn!(
269            dir = %dir.display(),
270            error = %e,
271            cap = memory_cap,
272            "nar ingest: no spool file — falling back to a CAPPED in-memory buffer; \
273             NARs above the cap will be refused. Point TMPDIR at a writable volume.",
274        );
275        let mut buf: Vec<u8> = Vec::new();
276        while let Some(chunk) = stream.next().await {
277            let chunk = chunk.map_err(transport_err)?;
278            if buf.len() + chunk.len() > memory_cap {
279                return Err(StoreError::TooLarge {
280                    limit: memory_cap as u64,
281                    at_least: (buf.len() + chunk.len()) as u64,
282                });
283            }
284            buf.extend_from_slice(&chunk);
285        }
286        return Ok(Box::new(BytesNarSource::new(buf)));
287    };
288
289    // The guard is armed the instant the file exists, so every early return
290    // below (and any panic) removes it.
291    let guard = SpoolGuard(path.clone());
292    let mut len: u64 = 0;
293    while let Some(chunk) = stream.next().await {
294        let chunk = chunk.map_err(transport_err)?;
295        file.write_all(&chunk).await.map_err(StoreError::Io)?;
296        len += chunk.len() as u64;
297    }
298    file.flush().await.map_err(StoreError::Io)?;
299    drop(file);
300
301    Ok(Box::new(SpooledNarSource {
302        inner: FileNarSource::with_len(&path, len),
303        _guard: guard,
304    }))
305}
306
307/// A unique spool path. Unique per call, not per key: concurrent uploads of the
308/// same content-addressed key are routine, and a shared name would have them
309/// interleave into one file.
310fn spool_path(dir: &Path) -> PathBuf {
311    use std::sync::atomic::{AtomicU64, Ordering};
312    static SEQ: AtomicU64 = AtomicU64::new(0);
313    let n = SEQ.fetch_add(1, Ordering::Relaxed);
314    dir.join(format!("sui-nar-spool.{}.{n}", std::process::id()))
315}
316
317// ---------------------------------------------------------------------------
318// Stream constructors
319// ---------------------------------------------------------------------------
320
321/// Yield in-memory bytes as bounded chunks (zero-copy slices of one allocation).
322#[must_use]
323pub fn bytes_stream(bytes: Bytes) -> NarStream {
324    stream::unfold(bytes, |mut rest| async move {
325        if rest.is_empty() {
326            return None;
327        }
328        let take = rest.len().min(NAR_CHUNK_BYTES);
329        let chunk = rest.split_to(take);
330        Some((Ok(chunk), rest))
331    })
332    .boxed()
333}
334
335/// Read an open file as bounded chunks. **O(chunk) resident.**
336#[must_use]
337pub fn file_stream(file: tokio::fs::File) -> NarStream {
338    stream::unfold(Some(file), |state| async move {
339        let mut file = state?;
340        let mut buf = BytesMut::zeroed(NAR_CHUNK_BYTES);
341        match file.read(&mut buf).await {
342            Ok(0) => None,
343            Ok(n) => {
344                buf.truncate(n);
345                Some((Ok(buf.freeze()), Some(file)))
346            }
347            // Surface the fault and END the stream: a reader that keeps polling
348            // a broken file would spin forever on the same error.
349            Err(e) => Some((Err(StoreError::Io(e)), None)),
350        }
351    })
352    .boxed()
353}
354
355/// A stream that yields exactly one chunk — the whole value.
356///
357/// The buffering escape hatch, named so it is visible at a call site. Legal for
358/// a backend whose values are inherently whole (an in-memory double, a capped
359/// hot tier); never for a durable NAR tier.
360#[must_use]
361pub fn whole_value_stream(data: Vec<u8>) -> NarStream {
362    bytes_stream(Bytes::from(data))
363}
364
365/// A stream that yields nothing.
366#[must_use]
367pub fn empty_stream() -> NarStream {
368    stream::empty().boxed()
369}
370
371// ---------------------------------------------------------------------------
372// Collection
373// ---------------------------------------------------------------------------
374
375/// Drain a [`NarStream`] into one buffer.
376///
377/// **This is the unbounded path.** It exists for callers that genuinely need the
378/// whole NAR (the `get_nar` convenience verb, test doubles) and for capped tiers
379/// via `limit`. Every use is a deliberate decision to hold O(nar) bytes.
380///
381/// `limit` — when `Some(max)`, collection **refuses** the moment the accumulated
382/// length would exceed `max`, returning [`StoreError::TooLarge`]. It never
383/// accumulates past the cap, so a capped caller's peak is `max + NAR_CHUNK_BYTES`
384/// and not one byte more.
385///
386/// # Errors
387///
388/// Propagates any error the stream yields, or [`StoreError::TooLarge`] when a
389/// `limit` is set and exceeded.
390pub async fn collect_nar(mut stream: NarStream, limit: Option<usize>) -> Result<Vec<u8>, StoreError> {
391    let mut out: Vec<u8> = Vec::new();
392    while let Some(chunk) = stream.next().await {
393        let chunk = chunk?;
394        if let Some(max) = limit {
395            if out.len() + chunk.len() > max {
396                return Err(StoreError::TooLarge {
397                    limit: max as u64,
398                    at_least: (out.len() + chunk.len()) as u64,
399                });
400            }
401        }
402        out.extend_from_slice(&chunk);
403    }
404    Ok(out)
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    /// Build `n` bytes with a position-dependent pattern, so a test that
412    /// re-assembles chunks catches reordering and truncation, not just length.
413    fn pattern(n: usize) -> Vec<u8> {
414        (0..n).map(|i| (i % 251) as u8).collect()
415    }
416
417    #[tokio::test]
418    async fn bytes_source_chunks_are_bounded_and_reassemble() {
419        let data = pattern(NAR_CHUNK_BYTES * 2 + 7);
420        let src = BytesNarSource::new(data.clone());
421        assert_eq!(src.size_hint(), Some(data.len() as u64));
422
423        let mut s = src.open().await.unwrap();
424        let mut seen = Vec::new();
425        let mut chunks = 0usize;
426        while let Some(c) = s.next().await {
427            let c = c.unwrap();
428            assert!(c.len() <= NAR_CHUNK_BYTES, "a chunk exceeded the bound");
429            seen.extend_from_slice(&c);
430            chunks += 1;
431        }
432        assert_eq!(chunks, 3, "2 full chunks + a 7-byte tail");
433        assert_eq!(seen, data);
434    }
435
436    #[tokio::test]
437    async fn a_source_re_opens_to_identical_bytes() {
438        // The property TieredBackend's ordering depends on.
439        let data = pattern(NAR_CHUNK_BYTES + 1);
440        let src = BytesNarSource::new(data.clone());
441        for _ in 0..3 {
442            let got = collect_nar(src.open().await.unwrap(), None).await.unwrap();
443            assert_eq!(got, data);
444        }
445    }
446
447    #[tokio::test]
448    async fn file_source_re_opens_and_is_chunk_bounded() {
449        let dir = tempfile::tempdir().unwrap();
450        let path = dir.path().join("blob");
451        let data = pattern(NAR_CHUNK_BYTES * 2 + 13);
452        tokio::fs::write(&path, &data).await.unwrap();
453
454        let src = FileNarSource::with_len(&path, data.len() as u64);
455        assert_eq!(src.size_hint(), Some(data.len() as u64));
456        for _ in 0..2 {
457            let mut s = src.open().await.unwrap();
458            let mut seen = Vec::new();
459            while let Some(c) = s.next().await {
460                let c = c.unwrap();
461                assert!(c.len() <= NAR_CHUNK_BYTES);
462                seen.extend_from_slice(&c);
463            }
464            assert_eq!(seen, data);
465        }
466    }
467
468    #[tokio::test]
469    async fn file_source_open_of_a_missing_file_is_a_typed_error() {
470        let src = FileNarSource::new("/nonexistent/sui-castore/blob");
471        // A `NarStream` is not `Debug`, so `unwrap_err` is unavailable here.
472        match src.open().await {
473            Err(StoreError::Io(_)) => {}
474            Err(other) => panic!("expected a typed Io error, got {other}"),
475            Ok(_) => panic!("opening a missing file must not succeed"),
476        }
477    }
478
479    #[tokio::test]
480    async fn empty_input_yields_no_chunks() {
481        let src = BytesNarSource::new(Vec::new());
482        assert!(src.open().await.unwrap().next().await.is_none());
483        assert!(collect_nar(src.open().await.unwrap(), None).await.unwrap().is_empty());
484    }
485
486    #[tokio::test]
487    async fn collect_with_a_limit_refuses_instead_of_growing() {
488        let data = pattern(NAR_CHUNK_BYTES * 3);
489        let src = BytesNarSource::new(data);
490        let err = collect_nar(src.open().await.unwrap(), Some(NAR_CHUNK_BYTES))
491            .await
492            .unwrap_err();
493        match err {
494            StoreError::TooLarge { limit, at_least } => {
495                assert_eq!(limit, NAR_CHUNK_BYTES as u64);
496                assert!(at_least > limit);
497            }
498            other => panic!("expected TooLarge, got {other}"),
499        }
500    }
501
502    #[tokio::test]
503    async fn collect_at_exactly_the_limit_is_accepted() {
504        // The boundary must be inclusive: a value the cap allows must not be
505        // refused by an off-by-one.
506        let data = pattern(NAR_CHUNK_BYTES);
507        let src = BytesNarSource::new(data.clone());
508        let got = collect_nar(src.open().await.unwrap(), Some(NAR_CHUNK_BYTES)).await.unwrap();
509        assert_eq!(got, data);
510    }
511
512    #[tokio::test]
513    async fn whole_value_stream_round_trips() {
514        let data = pattern(1000);
515        let got = collect_nar(whole_value_stream(data.clone()), None).await.unwrap();
516        assert_eq!(got, data);
517    }
518
519    #[tokio::test]
520    async fn empty_stream_collects_to_nothing() {
521        assert!(collect_nar(empty_stream(), None).await.unwrap().is_empty());
522    }
523
524    // ── spool_or_buffer: a one-shot upload becomes re-openable ─────────────
525
526    fn one_shot(data: Vec<u8>, frame: usize) -> impl futures::Stream<Item = Result<Bytes, StoreError>> {
527        stream::unfold(0usize, move |sent| {
528            let data = data.clone();
529            async move {
530                if sent >= data.len() {
531                    return None;
532                }
533                let n = (data.len() - sent).min(frame);
534                Some((Ok(Bytes::copy_from_slice(&data[sent..sent + n])), sent + n))
535            }
536        })
537    }
538
539    #[tokio::test]
540    async fn a_spooled_upload_re_opens_to_the_same_bytes_every_time() {
541        // The property `TieredBackend`'s three sequential tier writes depend on.
542        let dir = tempfile::tempdir().unwrap();
543        let data = pattern(NAR_CHUNK_BYTES + 2048);
544        let src = spool_or_buffer(
545            Box::pin(one_shot(data.clone(), 8192)),
546            dir.path(),
547            DEFAULT_INGEST_MEMORY_CAP,
548        )
549        .await
550        .unwrap();
551
552        assert_eq!(src.size_hint(), Some(data.len() as u64));
553        for _ in 0..3 {
554            assert_eq!(collect_nar(src.open().await.unwrap(), None).await.unwrap(), data);
555        }
556    }
557
558    #[tokio::test]
559    async fn dropping_a_spooled_source_removes_its_file() {
560        // The spool volume is the same kind of finite resource that filled up
561        // and broke L3. Leaking one file per upload would recreate that failure
562        // one directory over.
563        let dir = tempfile::tempdir().unwrap();
564        let src = spool_or_buffer(
565            Box::pin(one_shot(pattern(4096), 1024)),
566            dir.path(),
567            DEFAULT_INGEST_MEMORY_CAP,
568        )
569        .await
570        .unwrap();
571        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
572        drop(src);
573        assert_eq!(
574            std::fs::read_dir(dir.path()).unwrap().count(),
575            0,
576            "the spool file outlived its source",
577        );
578    }
579
580    #[tokio::test]
581    async fn an_unusable_spool_directory_falls_back_to_a_capped_buffer() {
582        // A pod whose TMPDIR is missing or read-only must keep caching ordinary
583        // NARs rather than 500ing every push — but bounded, never unbounded.
584        let data = pattern(4096);
585        let src = spool_or_buffer(
586            Box::pin(one_shot(data.clone(), 512)),
587            std::path::Path::new("/nonexistent/sui-spool-dir"),
588            DEFAULT_INGEST_MEMORY_CAP,
589        )
590        .await
591        .expect("the fallback must keep small uploads working");
592        assert_eq!(collect_nar(src.open().await.unwrap(), None).await.unwrap(), data);
593    }
594
595    #[tokio::test]
596    async fn the_fallback_refuses_past_its_cap_rather_than_growing() {
597        // The fallback is the ONE place a whole NAR can still be resident, so
598        // its cap is the last line: past it, refuse. Without this the "bounded
599        // either way" claim would be false on exactly the machines that need it.
600        // `dyn NarSource` is not `Debug`, so the Ok arm is matched explicitly.
601        match spool_or_buffer(
602            Box::pin(one_shot(pattern(64 * 1024), 4096)),
603            std::path::Path::new("/nonexistent/sui-spool-dir"),
604            8 * 1024,
605        )
606        .await
607        {
608            Err(StoreError::TooLarge { limit, at_least }) => {
609                assert_eq!(limit, 8 * 1024);
610                assert!(at_least > limit);
611            }
612            Err(other) => panic!("expected TooLarge, got {other}"),
613            Ok(_) => panic!("the fallback must refuse past its cap, not grow"),
614        }
615    }
616
617    #[tokio::test]
618    async fn an_empty_upload_spools_and_re_opens_as_empty() {
619        let dir = tempfile::tempdir().unwrap();
620        let src = spool_or_buffer(
621            Box::pin(one_shot(Vec::new(), 1024)),
622            dir.path(),
623            DEFAULT_INGEST_MEMORY_CAP,
624        )
625        .await
626        .unwrap();
627        assert_eq!(src.size_hint(), Some(0));
628        assert!(collect_nar(src.open().await.unwrap(), None).await.unwrap().is_empty());
629    }
630
631    #[tokio::test]
632    async fn concurrent_spools_do_not_share_a_file() {
633        let dir = tempfile::tempdir().unwrap();
634        let mut sources = Vec::new();
635        for i in 0..8u8 {
636            let data = vec![i; 1024];
637            sources.push((
638                data.clone(),
639                spool_or_buffer(
640                    Box::pin(one_shot(data, 128)),
641                    dir.path(),
642                    DEFAULT_INGEST_MEMORY_CAP,
643                )
644                .await
645                .unwrap(),
646            ));
647        }
648        for (expected, src) in &sources {
649            assert_eq!(&collect_nar(src.open().await.unwrap(), None).await.unwrap(), expected);
650        }
651    }
652}