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}