fix: stream CLI output instead of line-buffering to memory#80
Merged
Conversation
The CLI wrapped the decompression reader in BufReader::lines() which reads until \n. For single-line files (e.g., 2.2 GB JSON), this forced decompression of the entire file into memory before any output. - Default mode: use io::copy() to stream bytes directly to stdout - --stats mode: byte-chunk scanner counts \n and UTF-8 codepoints without building full-line String allocations - Both paths now start streaming immediately (0.008s vs 31.8s for local 44 MB .bz2, 0.11s vs 32.6s for remote)
Contributor
There was a problem hiding this comment.
Pull request overview
This PR improves the oneio CLI’s output path to avoid buffering entire “single-line” decompressed files into memory by streaming decompressed bytes directly to stdout, and reworks --stats to compute line/character counts via chunked scanning.
Changes:
- Default CLI reader mode now streams decompressed bytes to stdout via
std::io::copy()rather than iteratingBufRead::lines(). --statsmode now scans byte chunks, counting\nbytes and UTF-8 codepoints without allocating full-lineStrings.- Adds a changelog entry describing the CLI memory/performance fix.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/bin/oneio.rs | Replaces line-based iteration with streaming output; implements chunked UTF-8-aware stats scanning. |
| CHANGELOG.md | Documents the CLI streaming/stats change under [Unreleased]. |
Comment on lines
+432
to
443
| } else { | ||
| // Streaming mode: copy decompressed bytes directly to stdout | ||
| // without line-based buffering. Avoids buffering multi-GB single-line | ||
| // JSON files in memory. | ||
| let mut stdout = std::io::stdout(); | ||
| if let Err(e) = std::io::copy(&mut reader, &mut stdout) { | ||
| if e.kind() != std::io::ErrorKind::BrokenPipe { | ||
| eprintln!("read error on {path}: {e}"); | ||
| exit(1); | ||
| } | ||
| } | ||
| } |
Comment on lines
+343
to
+360
| let mut buf = [0u8; 65536]; | ||
| let mut carry: Vec<u8> = Vec::new(); | ||
| let mut last_byte = b'\n'; // treat empty file as ending with \n | ||
|
|
||
| loop { | ||
| let n = match reader.read(&mut buf) { | ||
| Ok(0) => break, | ||
| Ok(n) => n, | ||
| Err(e) => { | ||
| eprintln!("read error on {path}: {e}"); | ||
| exit(1); | ||
| } | ||
| }; | ||
|
|
||
| // Prepend carry bytes from the previous chunk (incomplete | ||
| // multi-byte UTF-8 sequence at end of last buffer). | ||
| let mut data: Vec<u8> = std::mem::take(&mut carry); | ||
| data.extend_from_slice(&buf[..n]); |
…Vec allocation - --strict-utf8 now errors immediately in default streaming mode instead of being silently ignored - Stats mode reuses a persistent Vec<u8> for the combined chunk buffer instead of allocating a new Vec every iteration
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
src/bin/oneio.rs:386
- Same as above: in the partial-valid UTF-8 branch,
count_charscurrently counts newline characters. To keep--statsoutput consistent with the previous line-based implementation, subtract the number of\nbytes from the char count for the validated prefix.
std::str::from_utf8_unchecked(&data[pos..pos + valid_up_to])
};
count_chars += valid.chars().count();
count_lines += valid.as_bytes().iter().filter(|&&b| b == b'\n').count();
if let Some(&b) = valid.as_bytes().last() {
src/bin/oneio.rs:449
io::copycan fail due to either read or write errors, but the current message always says "read error". This can mislead users when stdout fails (e.g., "No space left on device" when redirecting output).
if let Err(e) = std::io::copy(&mut reader, &mut stdout) {
if e.kind() != std::io::ErrorKind::BrokenPipe {
eprintln!("read error on {path}: {e}");
exit(1);
Comment on lines
+371
to
+372
| count_chars += valid.chars().count(); | ||
| count_lines += valid.as_bytes().iter().filter(|&&b| b == b'\n').count(); |
Comment on lines
+441
to
+444
| if cli.strict_utf8 { | ||
| eprintln!("--strict-utf8 has no effect outside --stats mode; use --stats --strict-utf8 to validate UTF-8 content"); | ||
| exit(1); | ||
| } |
| if let Some(err_len) = e.error_len() { | ||
| // Genuinely invalid UTF-8 sequence | ||
| if cli.strict_utf8 { | ||
| eprintln!("invalid UTF-8 at byte offset ~{} in {path}", pos); |
…solute offset - count_chars now excludes newline bytes, matching BufReader::lines() semantics - --strict-utf8 in default mode warns to stderr instead of hard-exiting - invalid UTF-8 error reports approximate absolute file offset instead of chunk-relative position
Comment on lines
+436
to
+446
| // Match BufReader::lines() semantics: a non-empty file that | ||
| // doesn't end with \n still has one last (implicit) line. | ||
| let mut count_lines = count_newlines; | ||
| if last_byte != b'\n' { | ||
| count_lines += 1; | ||
| } | ||
|
|
||
| // Match BufReader::lines() semantics: newline characters are | ||
| // stripped from line strings, so old stats never counted them. | ||
| count_chars = count_chars.saturating_sub(count_newlines); | ||
|
|
Comment on lines
+382
to
+387
| let valid_up_to = e.valid_up_to(); | ||
| if valid_up_to > 0 { | ||
| let valid = unsafe { | ||
| std::str::from_utf8_unchecked(&data[pos..pos + valid_up_to]) | ||
| }; | ||
| count_chars += valid.chars().count(); |
Comment on lines
+456
to
+462
| let mut stdout = std::io::stdout(); | ||
| if let Err(e) = std::io::copy(&mut reader, &mut stdout) { | ||
| if e.kind() != std::io::ErrorKind::BrokenPipe { | ||
| eprintln!("read error on {path}: {e}"); | ||
| exit(1); | ||
| } | ||
| } |
- Track CRLF pairs across chunk boundaries and subtract \r bytes from char count, matching BufReader::lines() which strips \r\n terminators - Replace unsafe from_utf8_unchecked with safe from_utf8().unwrap() - Lock stdout once in streaming mode instead of repeated per-chunk locks - Fix I/O error message to cover both read and write failures
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
src/bin/oneio.rs:487
--strict-utf8is documented as “Fail on invalid UTF-8”, but in streaming (non---stats) mode it is silently ignored (only a warning is printed). This changes the flag’s contract and can lead to users believing output was validated when it wasn’t. Consider either implementing streaming UTF-8 validation, or failing fast when--strict-utf8is used without--statsso the behavior is not silently ignored.
if cli.strict_utf8 {
eprintln!("warning: --strict-utf8 has no effect outside --stats mode; use --stats --strict-utf8 to validate UTF-8 content");
}
Comment on lines
+360
to
+370
| if cli.stats { | ||
| let mut count_newlines = 0usize; | ||
| let mut count_crlf = 0usize; | ||
| let mut count_chars = 0usize; | ||
| let mut buf = [0u8; 65536]; | ||
| let mut carry: Vec<u8> = Vec::new(); | ||
| let mut combined: Vec<u8> = Vec::with_capacity(65536 + 4); | ||
| let mut last_byte = b'\n'; // treat empty file as ending with \n | ||
| let mut file_offset = 0usize; // approx absolute byte position | ||
| let mut prev_was_cr = false; // for CRLF tracking across chunks | ||
|
|
| ## [Unreleased] | ||
|
|
||
| ### Fixed | ||
| - CLI no longer buffers multi-GB single-line files into memory before output. Default mode now streams decompressed bytes directly to stdout via `io::copy` instead of line-by-line reading. `--stats` mode uses byte-chunk scanning to count lines and characters without building full-line `String` allocations. |
Comment on lines
+367
to
+369
| let mut last_byte = b'\n'; // treat empty file as ending with \n | ||
| let mut file_offset = 0usize; // approx absolute byte position | ||
| let mut prev_was_cr = false; // for CRLF tracking across chunks |
…avior - Extract --stats logic into compute_text_stats() helper that returns Result, improving testability for edge cases (CRLF across chunks, invalid sequences, incomplete UTF-8 at EOF) - Use u64 for file_offset to avoid overflow on 32-bit or large files - CHANGELOG now documents behavior change: default mode emits raw bytes with no UTF-8 decoding or line-based rewriting
| {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", | ||
| )? | ||
| .progress_chars("#>-"), | ||
| .progress_chars(">-#"), |
Comment on lines
+221
to
+232
| /// Compute (lines, chars) from a byte stream using chunked scanning. | ||
| /// | ||
| /// Matches `BufReader::lines()` semantics: line terminators (`\n` and | ||
| /// `\r\n`) are excluded from the character count, and a non-empty file | ||
| /// without a trailing newline still counts its last segment as a line. | ||
| /// | ||
| /// When `strict_utf8` is true, returns `Err` on the first invalid UTF-8 | ||
| /// sequence or incomplete sequence at EOF. | ||
| fn compute_text_stats( | ||
| reader: &mut (dyn Read + Send), | ||
| strict_utf8: bool, | ||
| ) -> Result<(usize, usize), String> { |
- Fix indicatif progress_chars order: filled/current/remaining - Add #[cfg(test)] module with 9 tests covering LF, CRLF, no-trailing- newline, empty, single-line, strict-utf8, lossy, CRLF across chunk boundary, and bare CR edge cases
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The CLI wrapped the decompression reader in
BufReader::lines()which reads until\n. For single-line files (e.g., the 2.2 GB decompressedas2rel-collector-latest.json.bz2), this forced decompression of the entire file into memory before any output.Changes
io::copy()to stream decompressed bytes directly to stdout--statsmode: byte-chunk scanner counts\nbytes and UTF-8 codepoints without ever building full-lineStringallocations. Properly handles multi-byte UTF-8 sequences split across chunk boundaries.Before → After
.bz2→ first byte--stats(char+line count)Testing
cargo fmt --check✓cargo clippy --all-features -- -D warnings✓cargo clippy --no-default-features✓cargo test --all-features— 88 passed, 0 failed, 16 ignored (S3)