Skip to content

feat(helpers): streaming bulk-ingest with bounded memory - #495

Merged
margaretjgu merged 10 commits into
mainfrom
feat/bulk-ingest-streaming
Aug 4, 2026
Merged

feat(helpers): streaming bulk-ingest with bounded memory#495
margaretjgu merged 10 commits into
mainfrom
feat/bulk-ingest-streaming

Conversation

@margaretjgu

Copy link
Copy Markdown
Member

Replaces the buffered readFileSync + splitIntoBatches approach with line-by-line streaming bounded to flush_bytes * concurrency bytes, so ingesting large files no longer requires loading the full dataset into memory. Concurrency is controlled with a semaphore that provides backpressure. NDJSON, JSON arrays, and CSV are all supported.

Closes #443

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

MegaLinter analysis: Success

Descriptor Linter Files Fixed Errors Warnings Elapsed time
✅ COPYPASTE jscpd yes no no 1.03s
✅ REPOSITORY gitleaks yes no no 58.97s
✅ REPOSITORY git_diff yes no no 0.43s
✅ REPOSITORY secretlint yes no no 30.51s
✅ REPOSITORY trivy yes no no 16.4s
✅ TYPESCRIPT eslint 4 0 0 4.13s

Notices

📣 MegaLinter 9.5.0 is out! Discover the new features and security recommendations in the release announcement. (Skip this info by defining SECURITY_SUGGESTIONS: false)

See detailed reports in MegaLinter artifacts
Set VALIDATE_ALL_CODEBASE: true in mega-linter.yml to validate all sources, not only the diff

MegaLinter is graciously provided by OX Security
Show us your support by starring ⭐ the repository

@margaretjgu
margaretjgu marked this pull request as draft July 31, 2026 20:53
@margaretjgu
margaretjgu marked this pull request as ready for review August 3, 2026 17:51

@JoshMock JoshMock left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice. Just one question but LGTM regardless. 👏

let bufBytes = 0

const submitBatch = async (body: string): Promise<void> => {
await sem.acquire()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. Very similar to how the bulk helper is implemented in the JS client. 👏

reporter.report(res.total, res.errors)
}).catch(err => {
errors.push(err)
}).finally(() => sem.release())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is released in a finally, is there any situation where one batch would completely fail to be ingested, but others would? If so, does the errors array capture enough detail to help identify what data has not been ingested? Or do we just assume they'd follow up with another bulk ingest that upserts to handle failures?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for catching this! i was focused on the happy path and missed that a fully failed batch wasn't being counted anywhere, so the summary would undercount... and only the first failed batch's error ever surfaced, so a second failure would've been silently lost.

fixed both: every batch's doc count gets counted now no matter what happens, and the error message lists every failed batch instead of just the first one.

@JoshMock

JoshMock commented Aug 3, 2026

Copy link
Copy Markdown
Member

A few things an agent review noted that might be worth running down. It went pretty hardcore so feel free to file some as follow-ups using your discretion.

  • Uncaught crash on unreadable CSV source. stream.pipe(parser) does not forward source errors; a missing/unreadable file makes the ReadStream emit error with no listener → Unhandled 'error' event, process abort. Reproduced: error: ENOENT ... Emitted 'error' event on ReadStream instance (script over PR branch deps). Previously readFileSync ENOENT surfaced as input_error. fix: pipeline() from node:stream/promises, or stream.on('error', e => parser.destroy(e)).
  • Error-code regression for non-CSV paths: file ENOENT/EACCES and malformed JSON-array SyntaxError no longer match the startsWith(...) allowlist in the handler, so they return transportError instead of input_error. Ingest failures caused by local input are now reported as transport failures.
  • Empty-input regression: No input data received from file is gone. An empty --data-file, or empty piped stdin, now silently returns total: 0, succeeded: 0. Prior behavior was an explicit input_error.
  • json source format is still fully buffered: arrayBuf accumulates the whole file before JSON.parse. Issue perf(es): bulk-ingest buffers all input into memory; OOMs on large dumps #443 acceptance explicitly requires all source formats to stream. Only NDJSON/CSV meet the goal; a multi-GB JSON array still OOMs. Not documented as a limitation anywhere
  • source_format is now ignored for ndjson vs json; format is inferred from whether the first non-empty line starts with [. An explicit --source-format json on a file whose array starts mid-line, or --source-format ndjson on an array file, both silently do whatever detection decides. Prior code had the same auto-detect inside parseInput, so it is not new, but the flag is now decorative and worth deleting or honoring.
  • No new tests for any of the new behavior: no streaming/bounded-memory test, no multi-batch ordering test, no error-path tests for missing file, malformed JSON array, or partial-batch failure. Deleted shared.test.ts coverage (parseInput line-number errors, buildBulkNdjsonBody action metadata) is not replaced by equivalent tests against the new inline logic.

@margaretjgu
margaretjgu marked this pull request as draft August 3, 2026 19:57
@margaretjgu

margaretjgu commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

went through the rest of these.

fixed in this pr:

  • csv missing/unreadable file was crashing the whole process (.pipe() doesn't forward source errors). now the stream error gets forwarded to the parser so it surfaces as input_error instead.
  • ENOENT/EACCES/EISDIR on the ndjson/json path were falling through to transport_error. added them to the input_error classification.
  • empty --data-file or empty piped stdin was silently returning total: 0 with no error. now throws input_error, matching the old behavior (an empty parsed array like [] still succeeds with 0 docs, that's not new and not a bug).

filed as a follow-up: #498, the --source-format flag being decorative for ndjson vs json. not new (old code auto-detected the same way) but worth deciding whether to honor it or drop the distinction.

added tests for all three fixes plus the missing-file/empty-file/header-only-csv edge cases.

@margaretjgu
margaretjgu marked this pull request as ready for review August 4, 2026 01:01
@margaretjgu
margaretjgu merged commit 4a39007 into main Aug 4, 2026
29 checks passed
@margaretjgu
margaretjgu deleted the feat/bulk-ingest-streaming branch August 4, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(es): bulk-ingest buffers all input into memory; OOMs on large dumps

2 participants