Skip to main content

asimov_flow/
batch.rs

1// This is free and unencumbered software released into the public domain.
2
3//! Bounded, latency-aware batches for Rust-facing JSONL streams.
4//!
5//! Batch boundaries are transport groupings, not RDF graphs or transactions.
6//! Pulling batches supplies backpressure. Source errors are retained and emitted
7//! after buffered complete lines. Local processes, HTTP, and other transports
8//! share the same payload types while choosing their own error types.
9//! Types and batch flattening are runtime-independent; timed batching requires
10//! the `tokio` feature. Vectored wire slices require only `std`.
11
12use crate::{Bytes, JsonlLine, JsonlLineError, Stream, StreamExt};
13use alloc::{boxed::Box, vec::Vec};
14use core::{
15    iter::FusedIterator,
16    num::{NonZeroUsize, TryFromIntError},
17    ops::Range,
18    pin::Pin,
19    time::Duration,
20};
21
22/// Validated JSONL lines, retaining their original bytes and line endings.
23///
24/// Public [`JsonlLine`] constructors validate framing. Neither JSON nor UTF-8 is
25/// validated. An empty line is distinct from an empty
26/// batch: graph inputs ignore empty batches but write an LF for an empty line.
27/// SDK producers never emit empty batches.
28///
29/// Storage is private: reader-produced batches can retain one `Bytes` view plus
30/// line-end offsets, while batches assembled from individual lines preserve
31/// those line values. Borrowed iteration does not materialize individual shared
32/// handles. Extracting lines produces ordinary `Bytes` slices; rebuilding a
33/// batch from them does not infer allocation identity or recover a wider view.
34///
35/// ```
36/// use asimov_flow::{Bytes, JsonlBatch, JsonlLine, JsonlLineError};
37/// let batch = JsonlBatch::new(vec![
38///     JsonlLine::owned(b"{}\n".to_vec())?,
39///     JsonlLine::shared(Bytes::from_static(b"[]\r\n"))?,
40/// ]);
41/// assert_eq!(batch.len(), 2);
42/// let raw = JsonlBatch::try_from(vec![b"{}\n".to_vec()])?;
43/// assert_eq!(raw.byte_len(), 3);
44/// # Ok::<(), JsonlLineError>(())
45/// ```
46#[derive(Clone, Debug)]
47pub struct JsonlBatch {
48    storage: BatchStorage,
49    byte_len: usize,
50}
51
52#[derive(Clone, Debug)]
53enum BatchStorage {
54    // Bytes is the exact batch view. End offsets are relative to that view and
55    // preserve even empty/unterminated line boundaries. No per-line Bytes handles
56    // are created until a caller extracts owned lines.
57    Contiguous { bytes: Bytes, line_ends: Vec<usize> },
58    Lines(Vec<JsonlLine>),
59}
60
61impl Default for JsonlBatch {
62    fn default() -> Self {
63        Self {
64            storage: BatchStorage::Lines(Vec::new()),
65            byte_len: 0,
66        }
67    }
68}
69
70impl PartialEq for JsonlBatch {
71    fn eq(&self, other: &Self) -> bool {
72        self.byte_len == other.byte_len
73            && self.len() == other.len()
74            && self.lines().eq(other.lines())
75    }
76}
77impl Eq for JsonlBatch {}
78
79impl JsonlBatch {
80    /// Takes ownership of already-validated lines without copying their bytes.
81    /// Panics if their aggregate stored byte length overflows `usize`.
82    pub fn new(lines: Vec<JsonlLine>) -> Self {
83        let byte_len = lines
84            .iter()
85            .try_fold(0usize, |total, line| total.checked_add(line.len()))
86            .expect("JSONL batch byte length exceeds usize");
87        Self {
88            storage: BatchStorage::Lines(lines),
89            byte_len,
90        }
91    }
92
93    /// Frames a complete JSONL byte buffer into a contiguous batch without copying
94    /// its payload. Retains LF/CRLF endings and a final nonempty unterminated line;
95    /// an empty buffer means an empty batch. JSON and UTF-8 are not validated.
96    ///
97    /// Use this for a complete buffer, not arbitrary I/O chunks that can split a
98    /// line. Use [`crate::jsonl_lines_from_chunks`] for arbitrary chunks, or
99    /// `jsonl_batches` (with the `tokio` feature) for streaming readers.
100    ///
101    /// ```
102    /// use asimov_flow::{Bytes, JsonlBatch};
103    /// let batch = JsonlBatch::from_bytes(Bytes::from_static(b"{}\n[]\r\n"));
104    /// assert_eq!(batch.len(), 2);
105    /// assert_eq!(batch.as_contiguous_bytes(), Some(b"{}\n[]\r\n".as_slice()));
106    /// let lines = batch.into_lines(); // Cheap shared views of individual lines.
107    /// assert_eq!(lines[1].content(), b"[]");
108    /// ```
109    pub fn from_bytes(bytes: Bytes) -> Self {
110        let mut line_ends: Vec<_> = memchr::memchr_iter(b'\n', &bytes)
111            .map(|index| index + 1)
112            .collect();
113        if !bytes.is_empty() && line_ends.last().copied() != Some(bytes.len()) {
114            line_ends.push(bytes.len());
115        }
116        Self::contiguous(bytes, line_ends)
117    }
118
119    fn contiguous(bytes: Bytes, line_ends: Vec<usize>) -> Self {
120        debug_assert_eq!(line_ends.last().copied().unwrap_or(0), bytes.len());
121        debug_assert!({
122            let mut start = 0;
123            line_ends.iter().all(|&end| {
124                let valid = JsonlLine::shared_slice(bytes.clone(), start..end).is_ok();
125                start = end;
126                valid
127            })
128        });
129        Self {
130            byte_len: bytes.len(),
131            storage: BatchStorage::Contiguous { bytes, line_ends },
132        }
133    }
134
135    /// Number of lines, including blank or unterminated lines.
136    pub fn len(&self) -> usize {
137        match &self.storage {
138            BatchStorage::Contiguous { line_ends, .. } => line_ends.len(),
139            BatchStorage::Lines(lines) => lines.len(),
140        }
141    }
142
143    /// Whether the batch has no lines.
144    pub fn is_empty(&self) -> bool {
145        self.len() == 0
146    }
147
148    /// Total stored line bytes, including existing line endings. This excludes
149    /// any LF a graph input may append to unterminated lines when writing them.
150    pub fn byte_len(&self) -> usize {
151        self.byte_len
152    }
153
154    /// Borrows byte views of the lines in their original order. Use
155    /// [`into_lines`](Self::into_lines) when individual lines need owned lifetimes.
156    pub fn lines(&self) -> impl ExactSizeIterator<Item = &[u8]> + DoubleEndedIterator {
157        BatchLines {
158            batch: self,
159            front: 0,
160            back: self.len(),
161        }
162    }
163
164    /// Returns owned line values without copying payload bytes. Contiguous batches
165    /// create shared `Bytes` slices here; independently constructed lines retain
166    /// their existing storage mode. Shared values may retain larger allocations.
167    pub fn into_lines(self) -> Vec<JsonlLine> {
168        match self.storage {
169            BatchStorage::Lines(lines) => lines,
170            BatchStorage::Contiguous { bytes, line_ends } => {
171                let mut start = 0;
172                line_ends
173                    .into_iter()
174                    .map(|end| {
175                        let line = JsonlLine::framed(bytes.slice(start..end));
176                        start = end;
177                        line
178                    })
179                    .collect()
180            },
181        }
182    }
183
184    fn line_bytes(&self, index: usize) -> &[u8] {
185        match &self.storage {
186            BatchStorage::Lines(lines) => lines[index].as_bytes(),
187            BatchStorage::Contiguous { bytes, line_ends } => {
188                let start = if index == 0 { 0 } else { line_ends[index - 1] };
189                &bytes[start..line_ends[index]]
190            },
191        }
192    }
193
194    /// Returns a ready-to-write contiguous JSONL encoding without copying, when
195    /// all lines are terminated and the batch retains a contiguous backing view
196    /// (or contains just one line). Batches made from separate lines do not infer
197    /// shared allocation identity from pointer adjacency. Unterminated lines need
198    /// LF insertion and return `None`. Empty batches return an empty slice.
199    pub fn as_contiguous_bytes(&self) -> Option<&[u8]> {
200        if self.is_empty() {
201            return Some(&[]);
202        }
203        match &self.storage {
204            BatchStorage::Contiguous { bytes, .. }
205                if self.lines().all(|line| line.ends_with(b"\n")) =>
206            {
207                Some(bytes)
208            },
209            BatchStorage::Lines(lines) if lines.len() == 1 && lines[0].is_terminated() => {
210                Some(lines[0].as_bytes())
211            },
212            _ => None,
213        }
214    }
215
216    /// Copies stored bytes into one compact backing buffer, preserving line
217    /// boundaries and endings. This releases references to larger read buffers
218    /// when a filter keeps only a small subset. `byte_len` measures logical bytes,
219    /// not the allocation size retained by shared lines.
220    pub fn into_compact(self) -> Self {
221        let mut bytes = Vec::with_capacity(self.byte_len);
222        let mut line_ends = Vec::with_capacity(self.len());
223        for line in self.lines() {
224            bytes.extend_from_slice(line);
225            line_ends.push(bytes.len());
226        }
227        Self::contiguous(Bytes::from(bytes), line_ends)
228    }
229
230    /// Builds a bounded set of wire slices, including missing LF terminators.
231    /// Highly fragmented batches fall back to the caller's reusable copy buffer.
232    #[cfg(feature = "std")]
233    pub fn wire_slices(&self, maximum: usize) -> Option<Vec<std::io::IoSlice<'_>>> {
234        use std::io::IoSlice;
235        let mut slices = Vec::new();
236        match &self.storage {
237            BatchStorage::Lines(lines) => {
238                for line in lines {
239                    if !line.is_empty() {
240                        slices.push(IoSlice::new(line.as_bytes()));
241                    }
242                    if !line.is_terminated() {
243                        slices.push(IoSlice::new(b"\n"));
244                    }
245                    if slices.len() > maximum {
246                        return None;
247                    }
248                }
249            },
250            BatchStorage::Contiguous { bytes, line_ends } => {
251                let mut start = 0;
252                let mut run_start = 0;
253                for &end in line_ends {
254                    if !bytes[start..end].ends_with(b"\n") {
255                        if end != run_start {
256                            slices.push(IoSlice::new(&bytes[run_start..end]));
257                        }
258                        slices.push(IoSlice::new(b"\n"));
259                        run_start = end;
260                    }
261                    if slices.len() > maximum {
262                        return None;
263                    }
264                    start = end;
265                }
266                if run_start < bytes.len() {
267                    slices.push(IoSlice::new(&bytes[run_start..]));
268                }
269                if slices.len() > maximum {
270                    return None;
271                }
272            },
273        }
274        Some(slices)
275    }
276}
277
278struct BatchLines<'a> {
279    batch: &'a JsonlBatch,
280    front: usize,
281    back: usize,
282}
283
284impl<'a> Iterator for BatchLines<'a> {
285    type Item = &'a [u8];
286    fn next(&mut self) -> Option<Self::Item> {
287        if self.front == self.back {
288            return None;
289        }
290        let index = self.front;
291        self.front += 1;
292        Some(self.batch.line_bytes(index))
293    }
294    fn size_hint(&self) -> (usize, Option<usize>) {
295        let remaining = self.back - self.front;
296        (remaining, Some(remaining))
297    }
298}
299impl DoubleEndedIterator for BatchLines<'_> {
300    fn next_back(&mut self) -> Option<Self::Item> {
301        if self.front == self.back {
302            return None;
303        }
304        self.back -= 1;
305        Some(self.batch.line_bytes(self.back))
306    }
307}
308impl ExactSizeIterator for BatchLines<'_> {}
309impl FusedIterator for BatchLines<'_> {}
310
311/// A line with backing-buffer provenance for executor-level framing adapters.
312/// Applications normally use [`JsonlLine`] or [`JsonlBatch`].
313#[doc(hidden)]
314pub struct FramedLine(FrameStorage);
315
316enum FrameStorage {
317    Line(JsonlLine),
318    Buffer { bytes: Bytes, range: Range<usize> },
319}
320
321impl FramedLine {
322    pub(crate) fn new(bytes: Bytes, range: Range<usize>) -> Self {
323        debug_assert!(JsonlLine::shared_slice(bytes.clone(), range.clone()).is_ok());
324        Self(FrameStorage::Buffer { bytes, range })
325    }
326    pub fn as_bytes(&self) -> &[u8] {
327        match &self.0 {
328            FrameStorage::Line(line) => line.as_bytes(),
329            FrameStorage::Buffer { bytes, range } => &bytes[range.clone()],
330        }
331    }
332    #[cfg(feature = "tokio")]
333    fn len(&self) -> usize {
334        self.as_bytes().len()
335    }
336    pub fn into_line(self) -> JsonlLine {
337        match self.0 {
338            FrameStorage::Line(line) => line,
339            FrameStorage::Buffer { bytes, range } => JsonlLine::framed(bytes.slice(range)),
340        }
341    }
342}
343
344impl From<JsonlLine> for FramedLine {
345    fn from(line: JsonlLine) -> Self {
346        Self(FrameStorage::Line(line))
347    }
348}
349
350#[doc(hidden)]
351pub type FrameStream<E> = Pin<Box<dyn Stream<Item = Result<FramedLine, E>> + Send>>;
352
353#[cfg(feature = "tokio")]
354#[derive(Default)]
355enum BuilderStorage {
356    #[default]
357    Empty,
358    Buffer {
359        bytes: Bytes,
360        start: usize,
361        line_ends: Vec<usize>,
362    },
363    Lines(Vec<JsonlLine>),
364}
365
366#[cfg(feature = "tokio")]
367impl BuilderStorage {
368    fn finish(self, byte_len: usize) -> JsonlBatch {
369        match self {
370            Self::Empty => JsonlBatch::default(),
371            Self::Lines(lines) => JsonlBatch {
372                storage: BatchStorage::Lines(lines),
373                byte_len,
374            },
375            Self::Buffer {
376                bytes,
377                start,
378                mut line_ends,
379            } => {
380                let end = *line_ends.last().expect("buffered batch contains a line");
381                for offset in &mut line_ends {
382                    *offset -= start;
383                }
384                JsonlBatch::contiguous(bytes.slice(start..end), line_ends)
385            },
386        }
387    }
388}
389
390#[cfg(feature = "tokio")]
391#[derive(Default)]
392struct BatchBuilder {
393    storage: BuilderStorage,
394    byte_len: usize,
395    len: usize,
396}
397
398#[cfg(feature = "tokio")]
399impl BatchBuilder {
400    fn len(&self) -> usize {
401        self.len
402    }
403    fn byte_len(&self) -> usize {
404        self.byte_len
405    }
406    fn push(&mut self, line: FramedLine) {
407        let previous_bytes = self.byte_len;
408        self.byte_len = self
409            .byte_len
410            .checked_add(line.len())
411            .expect("JSONL batch byte length exceeds usize");
412        self.len += 1;
413        match (&mut self.storage, line) {
414            (BuilderStorage::Empty, FramedLine(FrameStorage::Buffer { bytes, range })) => {
415                self.storage = BuilderStorage::Buffer {
416                    bytes,
417                    start: range.start,
418                    line_ends: alloc::vec![range.end],
419                };
420            },
421            (
422                BuilderStorage::Buffer {
423                    bytes, line_ends, ..
424                },
425                FramedLine(FrameStorage::Buffer { bytes: next, range }),
426            ) if bytes.as_ptr() == next.as_ptr()
427                && bytes.len() == next.len()
428                && line_ends.last().copied() == Some(range.start) =>
429            {
430                line_ends.push(range.end);
431            },
432            (BuilderStorage::Lines(lines), line) => lines.push(line.into_line()),
433            (_, line) => {
434                let storage = core::mem::take(&mut self.storage);
435                let mut lines = storage.finish(previous_bytes).into_lines();
436                lines.push(line.into_line());
437                self.storage = BuilderStorage::Lines(lines);
438            },
439        }
440    }
441    fn finish(self) -> JsonlBatch {
442        self.storage.finish(self.byte_len)
443    }
444}
445
446impl From<Vec<JsonlLine>> for JsonlBatch {
447    fn from(lines: Vec<JsonlLine>) -> Self {
448        Self::new(lines)
449    }
450}
451
452impl TryFrom<Vec<Vec<u8>>> for JsonlBatch {
453    type Error = JsonlLineError;
454    /// Validates every raw line. On failure, the error offset refers to the
455    /// offending line's bytes rather than the concatenated batch.
456    fn try_from(lines: Vec<Vec<u8>>) -> Result<Self, Self::Error> {
457        lines.into_iter().map(JsonlLine::owned).collect()
458    }
459}
460
461impl FromIterator<JsonlLine> for JsonlBatch {
462    fn from_iter<T: IntoIterator<Item = JsonlLine>>(iter: T) -> Self {
463        Self::new(iter.into_iter().collect())
464    }
465}
466
467/// Transport batching policy, independent of subprocess options and listing limits.
468///
469/// Defaults are 256 lines, a 256 KiB byte target, and 10 ms from adding the first
470/// complete line to a new batch. The first threshold reached flushes the batch.
471/// Counts are nonzero by construction. One oversized line is emitted alone;
472/// lines are never split.
473#[derive(Clone, Copy, Debug, Eq, PartialEq)]
474pub struct BatchOptions {
475    /// Maximum lines per batch, not a total result limit.
476    pub max_lines: NonZeroUsize,
477    /// Typical maximum serialized batch size. A larger single line is allowed.
478    pub target_bytes: NonZeroUsize,
479    /// Maximum time spent collecting more lines after starting a batch,
480    /// while the stream is being polled. Downstream backpressure still applies.
481    /// Zero emits each line immediately. No empty timer batches are emitted.
482    pub max_delay: Duration,
483}
484
485impl BatchOptions {
486    /// Creates a policy, rejecting zero line/byte thresholds.
487    ///
488    /// ```
489    /// use asimov_flow::BatchOptions;
490    /// use std::time::Duration;
491    /// let options = BatchOptions::new(128, 64 * 1024, Duration::from_millis(5))?;
492    /// assert_eq!(options.max_lines.get(), 128);
493    /// # Ok::<(), std::num::TryFromIntError>(())
494    /// ```
495    pub fn new(
496        max_lines: usize,
497        target_bytes: usize,
498        max_delay: Duration,
499    ) -> Result<Self, TryFromIntError> {
500        Ok(Self {
501            max_lines: NonZeroUsize::try_from(max_lines)?,
502            target_bytes: NonZeroUsize::try_from(target_bytes)?,
503            max_delay,
504        })
505    }
506}
507
508impl Default for BatchOptions {
509    fn default() -> Self {
510        Self::new(256, 256 * 1024, Duration::from_millis(10)).expect("nonzero batch thresholds")
511    }
512}
513
514/// A fallible stream of batches, with an implementation-specific error type.
515pub type BatchStream<E> = Pin<Box<dyn Stream<Item = Result<JsonlBatch, E>> + Send>>;
516
517/// Validated [`JsonlLine`] values for framing and line-at-a-time adapters.
518pub type LineStream<E> = Pin<Box<dyn Stream<Item = Result<JsonlLine, E>> + Send>>;
519
520/// Groups complete lines by count, byte target, or elapsed collection time.
521///
522/// These input lines are already detached values. For a byte reader, use
523/// [`crate::jsonl_batches`] to preserve contiguous backing metadata directly
524/// through the framer and batch builder instead of detaching and regrouping lines.
525///
526/// Preserves order and bytes. EOF flushes a partial batch. On a source error,
527/// the source is dropped immediately, buffered complete lines are yielded first,
528/// and the error is yielded once as the final item. This does not prefetch while
529/// the consumer holds a batch or delay cleanup after observing a source error.
530///
531/// Timed batching requires a Tokio runtime with time enabled, including when
532/// the input is immediately ready.
533/// Buffering is bounded by the configured batch plus at most one lookahead line
534/// and the source's own buffers; individual line size is not bounded here.
535#[cfg(feature = "tokio")]
536pub fn batch_lines<E: Send + 'static>(
537    source: impl Stream<Item = Result<JsonlLine, E>> + Send + 'static,
538    options: BatchOptions,
539) -> BatchStream<E> {
540    batch_frames(source.map(|line| line.map(FramedLine::from)), options)
541}
542
543/// The common batching policy. Reader provenance is available here, allowing
544/// contiguous batches without storing backing metadata in public line values.
545#[doc(hidden)]
546#[cfg(feature = "tokio")]
547pub fn batch_frames<E: Send + 'static>(
548    source: impl Stream<Item = Result<FramedLine, E>> + Send + 'static,
549    options: BatchOptions,
550) -> BatchStream<E> {
551    Box::pin(async_stream::stream! {
552        let mut source = Box::pin(source);
553        let mut lookahead = None;
554        loop {
555            let first = match lookahead.take() {
556                Some(line) => Some(Ok(line)),
557                None => source.next().await,
558            };
559            let first = match first {
560                Some(Ok(line)) => line,
561                Some(Err(error)) => {
562                    drop(source);
563                    yield Err(error);
564                    return;
565                },
566                None => return,
567            };
568            let mut batch = BatchBuilder::default();
569            batch.push(first);
570            let deadline = tokio::time::Instant::now().checked_add(options.max_delay);
571            let timer = async {
572                match deadline {
573                    Some(deadline) => tokio::time::sleep_until(deadline).await,
574                    None => core::future::pending::<()>().await,
575                }
576            };
577            tokio::pin!(timer);
578            let mut terminal = None;
579            while batch.len() < options.max_lines.get()
580                && batch.byte_len() < options.target_bytes.get()
581                && !deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline)
582            {
583                let next = tokio::select! {
584                    biased;
585                    _ = &mut timer => break,
586                    next = source.next() => next,
587                };
588                match next {
589                    Some(Ok(line)) => {
590                        if line.len() > options.target_bytes.get() - batch.byte_len() {
591                            lookahead = Some(line);
592                            break;
593                        }
594                        batch.push(line);
595                    },
596                    Some(Err(error)) => {
597                        terminal = Some(Err(error));
598                        break;
599                    },
600                    None => {
601                        terminal = Some(Ok(()));
602                        break;
603                    },
604                }
605            }
606            if let Some(terminal) = terminal {
607                // Release process/pipe owners before suspending at the partial
608                // batch, rather than waiting for another downstream poll.
609                drop(source);
610                yield Ok(batch.finish());
611                if let Err(error) = terminal {
612                    yield Err(error);
613                }
614                return;
615            }
616            yield Ok(batch.finish());
617        }
618    })
619}
620
621/// Adapts batches to individual [`JsonlLine`] values without copying payload bytes.
622/// Empty batches are skipped. Order, line endings, and terminal errors are preserved.
623pub fn flatten_batches<E: Send + 'static>(
624    source: impl Stream<Item = Result<JsonlBatch, E>> + Send + 'static,
625) -> LineStream<E> {
626    Box::pin(async_stream::stream! {
627        let mut source = Box::pin(source);
628        while let Some(batch) = source.next().await {
629            match batch {
630                Ok(batch) => {
631                    if batch.is_empty() {
632                        futures_lite::future::yield_now().await;
633                    }
634                    for line in batch.into_lines() {
635                        yield Ok(line);
636                    }
637                },
638                Err(error) => {
639                    drop(source);
640                    yield Err(error);
641                    return;
642                },
643            }
644        }
645    })
646}
647
648#[cfg(all(test, feature = "std"))]
649mod tests {
650    use super::*;
651    use alloc::vec;
652    use core::sync::atomic::{AtomicBool, Ordering};
653    use std::sync::Arc;
654
655    #[test]
656    fn contiguous_and_detached_batches_have_the_same_line_semantics() {
657        for (bytes, expected) in [
658            (b"".as_slice(), vec![]),
659            (b"\n", vec![b"\n".as_slice()]),
660            (b"\r\n{}", vec![b"\r\n".as_slice(), b"{}"]),
661            (b"a\nb\nc\n", vec![b"a\n".as_slice(), b"b\n", b"c\n"]),
662            (b"first\nlast", vec![b"first\n".as_slice(), b"last"]),
663        ] {
664            let batch = JsonlBatch::from_bytes(Bytes::copy_from_slice(bytes));
665            assert_eq!(batch.len(), expected.len());
666            assert_eq!(batch.byte_len(), bytes.len());
667            assert_eq!(batch.lines().collect::<Vec<_>>(), expected);
668            let detached = JsonlBatch::new(batch.clone().into_lines());
669            assert_eq!(batch, detached);
670            let mut actual = batch.lines();
671            let mut expected_iter = expected.iter().copied();
672            assert_eq!(actual.next(), expected_iter.next());
673            assert_eq!(actual.len(), expected_iter.len());
674            assert_eq!(actual.next_back(), expected_iter.next_back());
675            assert_eq!(actual.len(), expected_iter.len());
676            for expected_line in expected_iter {
677                assert_eq!(actual.next(), Some(expected_line));
678            }
679            assert_eq!(actual.size_hint(), (0, Some(0)));
680            assert_eq!(actual.next(), None);
681            assert_eq!(actual.next_back(), None);
682        }
683    }
684
685    #[test]
686    fn detaching_lines_does_not_infer_a_contiguous_allocation() {
687        let original = JsonlBatch::from_bytes(Bytes::from_static(b"a\nb\n"));
688        assert!(original.as_contiguous_bytes().is_some());
689        let detached = JsonlBatch::new(original.into_lines());
690        assert!(detached.as_contiguous_bytes().is_none());
691        let wire: Vec<_> = detached
692            .wire_slices(16)
693            .unwrap()
694            .iter()
695            .flat_map(|slice| slice.iter().copied())
696            .collect();
697        assert_eq!(wire, b"a\nb\n");
698    }
699
700    #[test]
701    fn raw_batch_construction_rejects_embedded_records() {
702        assert_eq!(
703            JsonlBatch::try_from(vec![b"{}\n[]".to_vec()]).unwrap_err(),
704            JsonlLineError::EmbeddedLf { offset: 2 }
705        );
706    }
707
708    #[test]
709    fn contiguous_views_never_include_filtered_out_lines_or_reorder_records() {
710        let backing = Bytes::from_static(b"a\nsecret\nb\n");
711        let a = JsonlLine::shared_slice(backing.clone(), 0..2).unwrap();
712        let b = JsonlLine::shared_slice(backing, 9..11).unwrap();
713        for (lines, expected) in [
714            (vec![a.clone(), b.clone()], b"a\nb\n"),
715            (vec![b, a], b"b\na\n"),
716        ] {
717            let batch = JsonlBatch::new(lines);
718            assert_eq!(batch.byte_len(), 4);
719            assert!(batch.as_contiguous_bytes().is_none());
720            let bytes: Vec<_> = batch
721                .wire_slices(16)
722                .unwrap()
723                .iter()
724                .flat_map(|slice| slice.iter().copied())
725                .collect();
726            assert_eq!(bytes, expected);
727            let compact = batch.into_compact();
728            assert_eq!(compact.as_contiguous_bytes().unwrap(), expected);
729        }
730    }
731
732    #[test]
733    fn compacting_preserves_empty_and_unterminated_line_boundaries() {
734        let batch = JsonlBatch::try_from(vec![
735            Vec::new(),
736            b"{}".to_vec(),
737            b"\r\n".to_vec(),
738            b"x\n".to_vec(),
739        ])
740        .unwrap();
741        let compact = batch.clone().into_compact();
742        assert_eq!(compact, batch);
743        assert!(compact.as_contiguous_bytes().is_none());
744        let bytes: Vec<_> = compact
745            .wire_slices(16)
746            .unwrap()
747            .iter()
748            .flat_map(|slice| slice.iter().copied())
749            .collect();
750        assert_eq!(bytes, b"\n{}\n\r\nx\n");
751    }
752
753    #[test]
754    fn compacting_a_sparse_batch_releases_its_backing_owner() {
755        struct Owner {
756            bytes: Vec<u8>,
757            dropped: Arc<AtomicBool>,
758        }
759        impl AsRef<[u8]> for Owner {
760            fn as_ref(&self) -> &[u8] {
761                &self.bytes
762            }
763        }
764        impl Drop for Owner {
765            fn drop(&mut self) {
766                self.dropped.store(true, Ordering::SeqCst);
767            }
768        }
769        let dropped = Arc::new(AtomicBool::new(false));
770        let mut bytes = vec![b'x'; 256 * 1024];
771        bytes[..11].copy_from_slice(b"a\nsecret\nb\n");
772        let backing = Bytes::from_owner(Owner {
773            bytes,
774            dropped: dropped.clone(),
775        });
776        let batch = JsonlBatch::new(vec![
777            JsonlLine::shared_slice(backing.clone(), 0..2).unwrap(),
778            JsonlLine::shared_slice(backing, 9..11).unwrap(),
779        ]);
780        assert!(!dropped.load(Ordering::SeqCst));
781        let compact = batch.into_compact();
782        assert!(dropped.load(Ordering::SeqCst));
783        assert_eq!(compact.as_contiguous_bytes().unwrap(), b"a\nb\n");
784    }
785
786    #[test]
787    fn validates_thresholds_and_reports_batch_dimensions() {
788        assert!(BatchOptions::new(0, 1, Duration::ZERO).is_err());
789        assert!(BatchOptions::new(1, 0, Duration::ZERO).is_err());
790        assert_eq!(BatchOptions::default().max_lines.get(), 256);
791        assert_eq!(BatchOptions::default().target_bytes.get(), 256 * 1024);
792        assert_eq!(BatchOptions::default().max_delay, Duration::from_millis(10));
793        let batch =
794            JsonlBatch::try_from(vec![b"{}\r\n".to_vec(), Vec::new(), b"tail".to_vec()]).unwrap();
795        assert_eq!(batch.len(), 3);
796        assert_eq!(batch.byte_len(), 8);
797        assert!(!batch.is_empty());
798    }
799
800    #[test]
801    fn flattening_is_runtime_independent_and_preserves_errors() {
802        futures_lite::future::block_on(async {
803            let bytes = Bytes::from_static(b"{}\r\nlast");
804            let pointer = bytes.as_ptr();
805            let batches: BatchStream<&'static str> = Box::pin(crate::stream::iter([
806                Ok(JsonlBatch::default()),
807                Ok(JsonlBatch::from_bytes(bytes)),
808                Err("source failed"),
809                Ok(JsonlBatch::from_bytes(Bytes::from_static(b"unreachable\n"))),
810            ]));
811            let mut lines: LineStream<&'static str> = flatten_batches(batches);
812            let first = lines.next().await.unwrap().unwrap();
813            assert_eq!(first.as_bytes(), b"{}\r\n");
814            assert_eq!(first.as_bytes().as_ptr(), pointer);
815            assert_eq!(lines.next().await.unwrap().unwrap().as_bytes(), b"last");
816            assert_eq!(lines.next().await.unwrap().unwrap_err(), "source failed");
817            assert!(lines.next().await.is_none());
818        });
819    }
820
821    #[cfg(feature = "tokio")]
822    mod runtime {
823        use super::*;
824        use core::{
825            convert::Infallible,
826            sync::atomic::AtomicUsize,
827            task::{Context, Poll},
828        };
829        use std::io;
830        use tokio::{
831            io::{AsyncRead, AsyncWriteExt, ReadBuf},
832            time::{Instant, timeout},
833        };
834
835        fn options(lines: usize, bytes: usize, delay: Duration) -> BatchOptions {
836            BatchOptions::new(lines, bytes, delay).unwrap()
837        }
838
839        fn lines(values: &[&[u8]]) -> LineStream<Infallible> {
840            Box::pin(crate::stream::iter(
841                values
842                    .iter()
843                    .map(|line| Ok(JsonlLine::copy_from_slice(line).unwrap()))
844                    .collect::<Vec<_>>(),
845            ))
846        }
847
848        #[tokio::test(start_paused = true)]
849        async fn line_threshold_and_backpressure_do_not_prefetch_more_batches() {
850            let read = Arc::new(AtomicUsize::new(0));
851            let counter = read.clone();
852            let source = lines(&[b"a\n", b"b\n", b"c\n", b"d\n", b"e"]).map(move |line| {
853                counter.fetch_add(1, Ordering::SeqCst);
854                line
855            });
856            let mut batches = batch_lines(source, options(2, 1024, Duration::from_secs(1)));
857            assert_eq!(batches.next().await.unwrap().unwrap().len(), 2);
858            assert_eq!(read.load(Ordering::SeqCst), 2);
859            tokio::time::advance(Duration::from_secs(5)).await;
860            assert_eq!(
861                read.load(Ordering::SeqCst),
862                2,
863                "holding a batch must backpressure the source"
864            );
865            assert_eq!(batches.next().await.unwrap().unwrap().len(), 2);
866            assert_eq!(
867                batches
868                    .next()
869                    .await
870                    .unwrap()
871                    .unwrap()
872                    .lines()
873                    .collect::<Vec<_>>(),
874                vec![b"e".to_vec()]
875            );
876            assert!(batches.next().await.is_none());
877            assert!(batches.next().await.is_none());
878        }
879
880        #[tokio::test]
881        async fn byte_target_uses_complete_lines_and_allows_oversized_singletons() {
882            let mut batches = batch_lines(
883                lines(&[b"a\n", b"b\n", b"ccc\n", b"oversized", b"z"]),
884                options(100, 5, Duration::from_secs(1)),
885            );
886            for expected in [
887                vec![b"a\n".to_vec(), b"b\n".to_vec()],
888                vec![b"ccc\n".to_vec()],
889                vec![b"oversized".to_vec()],
890                vec![b"z".to_vec()],
891            ] {
892                assert_eq!(
893                    batches
894                        .next()
895                        .await
896                        .unwrap()
897                        .unwrap()
898                        .lines()
899                        .collect::<Vec<_>>(),
900                    expected
901                );
902            }
903            assert!(batches.next().await.is_none());
904        }
905
906        #[tokio::test(start_paused = true)]
907        async fn flushes_sparse_stream_on_deadline_without_waiting_for_eof() {
908            let source = lines(&[b"first\n"]).chain(crate::stream::pending());
909            let mut batches = batch_lines(source, options(100, 1024, Duration::from_millis(10)));
910            let start = Instant::now();
911            assert_eq!(
912                batches
913                    .next()
914                    .await
915                    .unwrap()
916                    .unwrap()
917                    .lines()
918                    .collect::<Vec<_>>(),
919                vec![b"first\n".to_vec()]
920            );
921            assert_eq!(start.elapsed(), Duration::from_millis(10));
922        }
923
924        #[tokio::test(start_paused = true)]
925        async fn never_emits_empty_batches_and_zero_delay_emits_immediately() {
926            let mut pending = batch_lines(
927                crate::stream::pending::<Result<JsonlLine, Infallible>>(),
928                BatchOptions::default(),
929            );
930            assert!(
931                timeout(Duration::from_secs(1), pending.next())
932                    .await
933                    .is_err()
934            );
935            let mut empty = batch_lines(lines(&[]), BatchOptions::default());
936            assert!(empty.next().await.is_none());
937            let mut ready = batch_lines(lines(&[b"a", b"b"]), options(100, 1024, Duration::ZERO));
938            let start = Instant::now();
939            assert_eq!(ready.next().await.unwrap().unwrap().len(), 1);
940            assert_eq!(ready.next().await.unwrap().unwrap().len(), 1);
941            assert_eq!(start.elapsed(), Duration::ZERO);
942        }
943
944        #[tokio::test(start_paused = true)]
945        async fn deadline_does_not_discard_a_partially_read_next_line() {
946            let (reader, mut writer) = tokio::io::duplex(128);
947            let writing = tokio::spawn(async move {
948                writer.write_all(b"{}\n{\"pa").await.unwrap();
949                tokio::time::sleep(Duration::from_millis(20)).await;
950                writer.write_all(b"rt\":true}\n").await.unwrap();
951            });
952            let mut batches =
953                crate::jsonl_batches(reader, options(100, 1024, Duration::from_millis(5)));
954            let start = Instant::now();
955            assert_eq!(
956                batches
957                    .next()
958                    .await
959                    .unwrap()
960                    .unwrap()
961                    .lines()
962                    .collect::<Vec<_>>(),
963                vec![b"{}\n".to_vec()]
964            );
965            assert_eq!(start.elapsed(), Duration::from_millis(5));
966            assert_eq!(
967                batches
968                    .next()
969                    .await
970                    .unwrap()
971                    .unwrap()
972                    .lines()
973                    .collect::<Vec<_>>(),
974                vec![b"{\"part\":true}\n".to_vec()]
975            );
976            assert!(batches.next().await.is_none());
977            writing.await.unwrap();
978        }
979
980        struct FailingSource {
981            step: usize,
982            dropped: Arc<AtomicBool>,
983        }
984        impl Drop for FailingSource {
985            fn drop(&mut self) {
986                self.dropped.store(true, Ordering::SeqCst);
987            }
988        }
989        impl Stream for FailingSource {
990            type Item = Result<JsonlLine, &'static str>;
991            fn poll_next(
992                mut self: Pin<&mut Self>,
993                _: &mut Context<'_>,
994            ) -> Poll<Option<Self::Item>> {
995                self.step += 1;
996                Poll::Ready(Some(match self.step {
997                    1 => Ok(JsonlLine::owned(b"first\n".to_vec()).unwrap()),
998                    2 => Err("source failed"),
999                    _ => panic!("source must not be polled after failure"),
1000                }))
1001            }
1002        }
1003
1004        #[tokio::test]
1005        async fn flushes_partial_batch_before_error_but_drops_source_before_yielding() {
1006            let dropped = Arc::new(AtomicBool::new(false));
1007            let mut batches = batch_lines(
1008                FailingSource {
1009                    step: 0,
1010                    dropped: dropped.clone(),
1011                },
1012                BatchOptions::default(),
1013            );
1014            assert_eq!(
1015                batches
1016                    .next()
1017                    .await
1018                    .unwrap()
1019                    .unwrap()
1020                    .lines()
1021                    .collect::<Vec<_>>(),
1022                vec![b"first\n".to_vec()]
1023            );
1024            assert!(
1025                dropped.load(Ordering::SeqCst),
1026                "error cleanup must not wait for the consumer's next poll"
1027            );
1028            assert_eq!(batches.next().await.unwrap().unwrap_err(), "source failed");
1029            assert!(batches.next().await.is_none());
1030        }
1031
1032        struct FailingReader(bool);
1033        impl AsyncRead for FailingReader {
1034            fn poll_read(
1035                mut self: Pin<&mut Self>,
1036                _: &mut Context<'_>,
1037                buffer: &mut ReadBuf<'_>,
1038            ) -> Poll<io::Result<()>> {
1039                if self.0 {
1040                    return Poll::Ready(Err(io::Error::other("read failed")));
1041                }
1042                self.0 = true;
1043                buffer.put_slice(b"{}\npartial");
1044                Poll::Ready(Ok(()))
1045            }
1046        }
1047
1048        #[tokio::test]
1049        async fn read_error_never_emits_an_incomplete_line() {
1050            let mut batches = crate::jsonl_batches(FailingReader(false), BatchOptions::default());
1051            assert_eq!(
1052                batches
1053                    .next()
1054                    .await
1055                    .unwrap()
1056                    .unwrap()
1057                    .lines()
1058                    .collect::<Vec<_>>(),
1059                vec![b"{}\n".to_vec()]
1060            );
1061            assert!(batches.next().await.unwrap().is_err());
1062            assert!(batches.next().await.is_none());
1063        }
1064
1065        #[tokio::test]
1066        async fn flattening_round_trips_raw_lines_and_ignores_empty_batches() {
1067            let expected = [b"{}\n".as_slice(), b"\r\n", b"\xff\n", b"tail"];
1068            let source = batch_lines(lines(&expected), options(2, 1024, Duration::from_secs(1)));
1069            let empty = crate::stream::iter([Ok(JsonlBatch::default())]);
1070            let mut flattened = flatten_batches(empty.chain(source));
1071            for line in expected {
1072                assert_eq!(flattened.next().await.unwrap().unwrap().as_bytes(), line);
1073            }
1074            assert!(flattened.next().await.is_none());
1075        }
1076    }
1077}