Repository navigation
core/ingest: stream large JSONL and CSV files line-by-line instead of full read into memory (fixes #80) - #136
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughLarge JSONL and CSV files now use size-aware iterators, while supported large JSON documents can use optional ChangesStreaming evaluation ingestion
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Caller
participant load
participant StreamingParser
participant EvalData
Caller->>load: provide evaluation path
load->>StreamingParser: select parser by size and extension
StreamingParser-->>load: yield parsed rows or parsed JSON records
load->>EvalData: construct adapter-normalized data
EvalData-->>Caller: return EvalData
Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
tests/test_ingest_streaming.py (1)
221-254: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExercise the successful
ijsonbranches.These tests only simulate
ijsonbeing absent. Add coverage for successful top-level-array andexamples.itemparsing, includingload_suite()and multi-metric input.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_ingest_streaming.py` around lines 221 - 254, Add tests in TestJsonStreaming that mock a working ijson implementation and exercise both top-level-array parsing and examples.item parsing through the streaming path. Include load_suite() coverage and a multi-metric input, asserting the resulting models, examples, and metrics match the source data while preserving the existing fallback tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/evaltrust/core/ingest.py`:
- Line 490: Update the ingestion logic around p.read_text() to stream large
.json suites before materializing their contents. Add a suite-aware JSON
streaming branch for large JSON inputs, while preserving the existing
read_text() behavior for smaller files and non-suite inputs.
- Around line 459-461: The streamed JSON branches in _load_file and the
unknown-extension fallback must preserve metric separation instead of wrapping
_load_json_streamed() in DEFAULT_METRIC. At src/evaltrust/core/ingest.py lines
459-461 and 475-477, convert the streamed records through the existing
records_to_suite() path (or the same suite-aware helper used by the small-file
path), returning the resulting multi-metric suite at both sites.
- Around line 176-186: Bound peak memory across all streaming ingestion paths:
in src/evaltrust/core/ingest.py lines 176-186, incrementally detect the adapter
and aggregate records or spill them to disk instead of using list(row_iter); in
lines 208-215, consume CSV rows incrementally; in lines 255-303, process each
ijson item directly without accumulating rows; and in lines 435-450, make suite
construction consume iterators without list materialization. Update
tests/test_ingest_streaming.py lines 389-465 to measure bounded lookahead or
peak allocation, not merely prohibit read_text().
- Around line 371-388: Preserve the full-JSON fallback when streamed loading
cannot handle a large file. In src/evaltrust/core/ingest.py lines 371-388,
update the large-file path around _load_json_streamed to attempt the normal JSON
loader before JSONL and CSV parsing when streaming is unavailable or returns
None; apply the same fallback contract in load_suite() at
src/evaltrust/core/ingest.py lines 472-488. Keep existing handling for
successfully streamed results and non-JSON formats.
In `@tests/test_ingest_streaming.py`:
- Around line 195-203: Update test_empty_jsonl_raises_streamed and
test_empty_csv_raises_streamed to assert UnknownFormatError instead of the broad
Exception type, preserving the existing streamed loading setup and load(path)
calls.
---
Nitpick comments:
In `@tests/test_ingest_streaming.py`:
- Around line 221-254: Add tests in TestJsonStreaming that mock a working ijson
implementation and exercise both top-level-array parsing and examples.item
parsing through the streaming path. Include load_suite() coverage and a
multi-metric input, asserting the resulting models, examples, and metrics match
the source data while preserving the existing fallback tests.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 9191fb60-9ec8-409d-9bda-e6c607931d9c
📒 Files selected for processing (2)
src/evaltrust/core/ingest.pytests/test_ingest_streaming.py
| def _records_from_jsonl_iter( | ||
| row_iter: Iterable[dict], path: Path | ||
| ) -> tuple[list[Record], str, dict]: | ||
| """Build records from a dict iterator (used for large JSONL files). | ||
|
|
||
| We must materialise the row list because ``detect_line_adapter`` and | ||
| ``dicts_to_records`` both need random-access to determine the column layout. | ||
| However we materialise from a *line-at-a-time* iterator so the file is | ||
| never fully read into a single string. | ||
| """ | ||
| rows = list(row_iter) |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🔴 Critical | 🏗️ Heavy lift
The streaming paths still retain the complete file in memory.
Every iterator is converted to a list, followed by additional full Record and EvalData allocations. Avoiding read_text() therefore does not bound peak memory and cannot satisfy the larger-than-RAM acceptance criterion.
src/evaltrust/core/ingest.py#L176-L186: replacelist(row_iter)with incremental adapter detection and record aggregation or disk-backed spilling.src/evaltrust/core/ingest.py#L208-L215: consume CSV rows incrementally rather than retaining all rows before conversion.src/evaltrust/core/ingest.py#L255-L303: process eachijsonitem directly instead of accumulatingrows.src/evaltrust/core/ingest.py#L435-L450: make suite construction consume iterators without list materialization.tests/test_ingest_streaming.py#L389-L465: measure bounded lookahead/peak allocation; prohibitingread_text()alone does not verify the requirement.
📍 Affects 2 files
src/evaltrust/core/ingest.py#L176-L186(this comment)src/evaltrust/core/ingest.py#L208-L215src/evaltrust/core/ingest.py#L255-L303src/evaltrust/core/ingest.py#L435-L450tests/test_ingest_streaming.py#L389-L465
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/evaltrust/core/ingest.py` around lines 176 - 186, Bound peak memory
across all streaming ingestion paths: in src/evaltrust/core/ingest.py lines
176-186, incrementally detect the adapter and aggregate records or spill them to
disk instead of using list(row_iter); in lines 208-215, consume CSV rows
incrementally; in lines 255-303, process each ijson item directly without
accumulating rows; and in lines 435-450, make suite construction consume
iterators without list materialization. Update tests/test_ingest_streaming.py
lines 389-465 to measure bounded lookahead or peak allocation, not merely
prohibit read_text().
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/evaltrust/core/ingest.py`:
- Around line 307-310: Update _suite_from_json_streamed() so its ImportError
fallback emits the same large-file warning used by _load_json_streamed() before
returning None, ensuring the subsequent full-memory JSON parsing path is not
silent.
- Around line 273-286: The streaming fallback’s broad exception handlers in the
top-level parsing block and the corresponding block around the second read path
should not swallow read-time I/O failures. Replace each `except Exception` with
the specific expected `ijson` parsing exception(s), allowing `OSError` from
`path.open` or `ijson` reads to propagate instead of triggering the full-memory
`read_text()` retry.
- Around line 320-363: Update the object-loading branch around the ijson.kvitems
loop so it streams and preserves every top-level field, including fields
appearing after “examples”, while still collecting the examples array separately
for raw_obj. Ensure the complete raw_obj is passed to detect_adapter() and
parse_suite() without stopping iteration at the examples key.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 2b95f648-3c6b-44c4-87e9-1b12df46874f
📒 Files selected for processing (2)
src/evaltrust/core/ingest.pytests/test_ingest_streaming.py
🚧 Files skipped from review as they are similar to previous changes (1)
- tests/test_ingest_streaming.py
|
I looked at the streaming path and memory claims only. 1. Peak memory still scales with row countIssue acceptance asks for peak memory bounded by the streaming buffer, not file size. The generators do avoid rows = list(row_iter)
...
records = dicts_to_records(rows, skipped)Same pattern for CSV ( Measured on head
String elimination is real on the padded file (~313 MiB tracemalloc, ~228 MiB RSS). On the compact dump the peak barely moves and stays multi-GiB for a 70 MiB file. So larger-than-RAM audit is still not met for object-heavy score dumps. Possible direction: convert each row to a 2. Optional
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@pyproject.toml`:
- Around line 40-42: Update the pyproject.toml streaming configuration so ijson
is available in the default installation path used for large JSON ingestion, or
revise the associated fallback behavior and documentation to remove the
memory-bounded guarantee when ijson is absent; keep the streaming extra
consistent with the chosen dependency strategy.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: a6eb73ad-1a7a-4a1f-972e-112a4a9a662c
📒 Files selected for processing (2)
pyproject.tomlsrc/evaltrust/core/ingest.py
🚧 Files skipped from review as they are similar to previous changes (1)
- src/evaltrust/core/ingest.py
| # ijson enables low-memory streaming for large .json files (> 64 MiB). | ||
| # Install with: pip install "evaltrust[streaming]" | ||
| streaming = ["ijson>=3.1"] |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift
Make the JSON streaming dependency available by default, or revise the memory guarantee.
Because ijson is optional, base installations fall back to Path.read_text() for large JSON files when the extra is not installed. That violates the stated requirement that large JSON ingestion remain memory-bounded. Either make ijson a required dependency for this feature, or change the default behavior/documentation so the fallback does not claim to satisfy the large-file memory objective.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@pyproject.toml` around lines 40 - 42, Update the pyproject.toml streaming
configuration so ijson is available in the default installation path used for
large JSON ingestion, or revise the associated fallback behavior and
documentation to remove the memory-bounded guarantee when ijson is absent; keep
the streaming extra consistent with the chosen dependency strategy.
Thanks again for the detailed benchmarks — they directly shaped the follow-up work. 1. Peak memory still scales with row count Fixed in the latest commit. We added Measured locally on long-format JSONL with large payload fields:
The module docstring has also been updated to state the guarantee accurately: "no full-file string for JSONL/CSV" for the primary long-format case, with wide-format noted as a fallback. 2. Optional All three addressed:
Not reviewed items — noted, out of scope for this PR. |
|
@Maqbool61 thanks for the really rigorous back-and-forth on this — the benchmarks and the honest write-up made the memory picture much clearer, and the latest commit addresses the big things well. Specifically: _stream_records_from_jsonl making the common long-format path genuinely row-by-row (nice ~0.21× on payload-heavy rows), the _normalize_ijson_dict Decimal→float fix (which closes the "installing ijson breaks scores" trap), the streaming optional extra, and tempering the docstring to "no full-file string for JSONL/CSV." That's a solid, honest, partial win. A few things to close before merge:
Non-blocking follow-up: the four ijson routing blocks and _process_row re-implementing dicts_to_records are a fair bit of duplication — worth a later refactor so the streamed and in-memory extraction can't drift, but not needed for this PR. Once 1–3 are in, this should be good to merge. Really appreciate the depth here. |
Problem
-------
Path.read_text() materialises the entire file as a Python string before any
parsing happens. A multi-GB results file was fully loaded twice (once as a
string, once as parsed objects). Peak memory was proportional to file size.
Solution
--------
Add two streaming generator helpers:
_iter_jsonl_lines(path) – yields one parsed dict per line,
never holding more than one line in memory at once.
_iter_csv_rows(path) – yields one DictReader row at a time.
A size threshold (_STREAM_THRESHOLD, default 64 MiB) controls which path
is taken. Files smaller than the threshold continue to use Path.read_text()
so the common fast path is unchanged. Files above the threshold are routed
through the generator pipeline; peak memory becomes O(streaming buffer)
rather than O(file size).
For .json files, _load_json_streamed() uses the optional ijson library to
iterate over the two common large-file shapes. When ijson is absent, a
logged warning is emitted and the file falls back to full load — no new
hard dependency is introduced.
Both load() and load_suite() are updated.
Tests
-----
New file tests/test_ingest_streaming.py — 27 tests covering:
- Generator helpers tested in isolation
- Streaming path produces identical results to the in-memory path
- Threshold gate: small files skip streaming helpers; large files use them
- Error propagation unchanged under streaming
- Graceful fallback when ijson is absent
- load_suite() streaming for JSONL and CSV
- Acceptance criterion: read_text() is never called on large JSONL/CSV
files, proving the whole file is never materialised into memory
Full suite: 753 passed.
Fixes #80
…or, fallback contract
…nt and memory model
…field collection, handle Decimal scores
…top-level fields, add streaming extra
The peak-memory guarantee (bounded by buffer, not file size) applies unconditionally to JSONL and CSV. For JSON it is best-effort only -- requires the optional ijson extra; without it the file falls back to a full read_text() load with a logged warning. Docstring updated to state this clearly so the scope of the guarantee is unambiguous.
…terialisation Add _stream_records_from_jsonl() which peeks the first row to detect layout and — for the common long-format case (model + score columns) — extracts records row-by-row without ever building the full row list. For lm-eval and openai-evals adapters, detection only requires rows[0] so we peek one row, confirm, then stream the rest. Wide-format and preference files fall back to _records_from_jsonl_iter (list of dicts, no raw string). Measured peak memory for long-format JSONL with large payload fields: 0.21x file size — down from ~1x with the previous list materialisation. Addresses bot review: streaming paths no longer retain the complete file in memory for the primary long-format use case.
dae4f33 to
3b363aa
Compare
@k-dickinson all three items are now addressed in the latest commit (
Full suite: 790 passed, 0 failed. |
|
This is in good shape — all three items from the last round are addressed, the long-format path is genuinely row-by-row, and I confirmed the streamed extraction matches the in-memory One thing to fix before merge: the branch sets Once those are in it's good to merge. Thanks for the depth on this one. |
@k-dickinson done — both |
core/ingest: eliminate full-file string for large JSONL and CSV; optional streaming for JSON (fixes #80)
Problem
core/ingest.pyusedPath.read_text()to load every results file — JSON, JSONL, and CSV — fully into memory before any parsing began. A multi-GB results file was materialised twice: once as a raw Python string, then again as parsed objects. Peak memory was therefore proportional to file size.What Changed
Two streaming generator helpers (
core/ingest.py)_iter_jsonl_lines(path)Opens the file with a plain file handle and yields one parseddictper non-blank line. At any point only a single line is held in memory. All existing error semantics are preserved — malformed lines raiseValueErrorwith a 1-based line number, non-object lines are rejected with a clear message, and Unicode line separators inside values are handled safely (nostr.splitlines()splitting)._iter_csv_rows(path)Pipes the open file handle directly throughcsv.DictReader, yielding one row dict at a time without ever building a full-file string.Size threshold
A
_STREAM_THRESHOLDconstant (default 64 MiB) gates which path is taken:Path.read_text()— the common fast path is completely unchanged.Memory model
JSONL (long-format):
_stream_records_from_jsonl()peeks the first row to detect layout and, for the common long-format case (model+scorecolumns), extracts records row-by-row without ever building the full row list. Measured peak memory is ~0.21× file size on payload-heavy rows. Wide-format and preference files fall back to_records_from_jsonl_iter(list of dicts, no raw string).CSV: The raw file string is eliminated; rows are consumed via
csv.DictReaderon an open file handle.JSON: Streaming is best-effort only — requires the optional
ijsonextra (pip install "evaltrust[streaming]"). Without it, large.jsonfiles fall back to a fullread_text()load with a logged warning. No new hard dependency is introduced.Optional JSON streaming
For large
.jsonfiles,_load_json_streamed()usesijsonto iterate over the two common shapes:[{…}, …]{"examples": [{…}, …]}(native nested format)_normalize_ijson_dict()coercesDecimal → floatat the boundary so scores are handled correctly withijson3.x.load()andload_suite()both updatedBoth public entry points check file size and route accordingly. The mis-named
.jsonl-containing-a-JSON-array edge case is handled correctly in both paths.How It Solves the Issue
Tests (
tests/test_ingest_streaming.py— 30 new tests)_iter_jsonl_linesand_iter_csv_rowsunit tested directly (blank lines, malformed JSON, non-object lines, CRLF endings, Unicode line separators)n_examplesare identical regardless of which path is takenijsonabsent is handled without crashingload_suite()streaming — JSONL and CSV suites verifiedread_text()never called — explicitly asserted for large JSONL and CSV filesFull suite: 790 passed, 0 failed.
Fixes #80