Skip to content

fix(ingestion): bound memory across upload→process pipeline for all file types#110

Merged
jeffgreendesign merged 3 commits into
mainfrom
claude/modest-bell-hr7lo8
Jun 12, 2026
Merged

fix(ingestion): bound memory across upload→process pipeline for all file types#110
jeffgreendesign merged 3 commits into
mainfrom
claude/modest-bell-hr7lo8

Conversation

@jeffgreendesign

Copy link
Copy Markdown
Owner

Why

A real Spotify-export ZIP upload surfaced three production bugs in the async ingestion pipeline (already fixed: 9ad0cc7, ed2fdca, 27cc2ee) — all the same class of "memory proportional to (or a multiple of) adversarial input, with no ceiling." This audits the rest of the pipeline for siblings across every supported file type (.txt .md .pdf .docx .csv .xlsx .json .html/.htm and ZIP-of-those) and fixes them.

A throwaway repro harness (peak heapUsed per stage on large/adversarial fixtures) confirmed each fix; two issues it caught were worse than static analysis suggested (see F6/F7).

What changed

Fix Pathology Result
F2 JSON JSON.stringify(JSON.parse(text), null, 2) inflated dense exports 2–5× and destroyed byte offsets pass bytes through → inflation ×1.00
F3 chunk cap No per-document chunk ceiling in the server path → unbounded chunks/embeddings/rows new MAX_CHUNKS_HARD_CAP (50k) → ChunkLimitError; per-entry fail in ZIP, fail upload for single files
F1 semantic A ~9.9MB doc slipped under the 10MB guard and embedded ~10⁵ sentences at once fall back to fixed chunking past 25k sentences (repro: 0 embed calls)
F4 Ollama/Google Only OpenAI had a per-request token cap shared buildTokenBoundedBatches adds aggregate token caps to all three providers
F5 streaming Whole-document embeddings + chunks + DB records resident at once new embed-store.ts streams embed→insert in 128-chunk windows
F6 XLSX XLSX.read() parses the entire workbook before any budget applies sheetRows parse cap + cell budget (repro: 702MB → 208MB)
F7 HTML Deeply-nested markup overflowed html-to-text's recursive walk and crashed the worker depth/child-node limits + non-recursive tag-strip fallback (repro: crash → no crash)

Also wires WARN_FILE_SIZE_MB (previously unused) as an advisory large-document log, and documents the new env var in .env.example / docs/ENV.md.

Notes / scope

  • MAX_CHUNKS_PER_FILE (500) is kept as the advisory soft-warn threshold it always was; the new MAX_CHUNKS_HARD_CAP (50k) is the actual reject ceiling, set high so legitimate large uploads still process.
  • The cleared-on-review items (force-split O(n) fix, semantic-merge concat, OpenAI cap, ZIP validation) are correct and untouched.
  • Provider limits were re-verified against current docs (OpenAI 300K tok/req, Gemini Embedding 2 8192 tok/input) — the approach is current best practice.

Tests

One regression test per fix (chunker cap + semantic fallback, JSON no-inflation, batch token bounding), in the timeout-guarded large-input style of chunker.test.ts. pnpm verify passes (264 tests, lint, typecheck, security, tool-sync, build, docs build).

Follow-up (not in this PR)

A separate throughput/volume audit produced findings T1–T8 (the async architecture is solid; main gaps are: activate the dormant Cloud Tasks path + raise the Cloud Run timeout, add embedding/Anthropic retry-with-backoff, set queue maxConcurrentDispatches, use Neon's pooled connection string). The one clear code follow-up is provider retry/backoff; the rest are config/deploy.

https://claude.ai/code/session_01L1NUnaJbMha7LUQsRoxFkE


Generated by Claude Code

…ile types

Audit-driven fixes for the same class of unbounded-memory bug behind the
recent chunker/embeddings/memory-extraction OOMs, found across the rest of
the pipeline:

- json handler: stop JSON.stringify(JSON.parse(text), null, 2) — re-indenting
  inflated dense exports 2–5x and destroyed byte offsets. Pass bytes through.
- chunker: enforce MAX_CHUNKS_HARD_CAP (new) via ChunkLimitError so one doc
  can't produce unbounded chunks/embeddings/rows; fail fast as chunks are
  produced. ZIP entries fail per-entry; single files fail the upload.
- chunker (semantic): fall back to fixed chunking past MAX_SEMANTIC_SENTENCES
  so a ~9.9MB doc can't embed ~10^5 sentences and hold them all at once.
- embeddings: add aggregate per-batch token caps for Ollama and Google
  (mirroring OpenAI's 200K cap) via a shared buildTokenBoundedBatches helper.
- embed-store: stream embed→insert in windows so a document's full embedding
  array is never resident at once.
- xlsx handler: bound the parse with sheetRows and the CSV with a cell budget;
  a huge workbook no longer balloons heap on XLSX.read (702MB→208MB in repro).
- html handler: cap html-to-text depth/child-node limits and fall back to a
  non-recursive tag strip so deeply-nested markup can't overflow the call
  stack and crash the worker.
- wire WARN_FILE_SIZE_MB (previously unused) as an advisory large-doc log.

Regression tests per fix (chunker cap + semantic fallback, JSON no-inflation,
batch token bounding). pnpm verify passes.

https://claude.ai/code/session_01L1NUnaJbMha7LUQsRoxFkE
@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dashboard Building Building Preview, Comment Jun 12, 2026 10:20pm
textrawl Ready Ready Preview, Comment Jun 12, 2026 10:20pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7222f649-9b22-40d8-adf3-45ec2c1cd4a9

📥 Commits

Reviewing files that changed from the base of the PR and between 9aa81f3 and 6efc31b.

📒 Files selected for processing (1)
  • src/services/processor/handlers/xlsx.ts
💤 Files with no reviewable changes (1)
  • src/services/processor/handlers/xlsx.ts

Walkthrough

This PR hardens the document processing pipeline against resource exhaustion by introducing a configurable MAX_CHUNKS_HARD_CAP and ChunkLimitError, enforcing chunk caps in fixed and semantic chunkers with fallbacks, adding embedAndStoreChunks to window embedding generation and persistence, switching upload paths to use the new embed/store flow and emitting advisory size warnings, adding token-bounded batching for non-OpenAI providers, and tightening HTML/JSON/XLSX extraction limits and related tests.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant UploadAPI
  participant UploadProcessor
  participant warnIfLargeContent
  participant smartChunk
  participant embedAndStoreChunks
  participant EmbeddingProvider
  participant DB
  Client->>UploadAPI: POST /upload
  UploadAPI->>UploadProcessor: process upload
  UploadProcessor->>warnIfLargeContent: check extracted text size
  UploadProcessor->>smartChunk: generate chunks (maxChunks)
  UploadProcessor->>embedAndStoreChunks: stream chunks + generateEmbeddings
  embedAndStoreChunks->>EmbeddingProvider: generateEmbeddings(window)
  EmbeddingProvider-->>embedAndStoreChunks: embeddings(window)
  embedAndStoreChunks->>DB: createChunks(window with embeddings)
  DB-->>embedAndStoreChunks: persisted
  embedAndStoreChunks-->>UploadProcessor: done
  UploadProcessor-->>UploadAPI: respond with document
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title accurately summarizes the main objective: bounding memory across the upload-to-process pipeline for all file types, which is the central theme of this comprehensive memory-safety audit.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering Why, What changed (with a detailed table), Notes/scope, Tests, and Follow-up. However, the description lacks a formal Checklist section with verification items as specified in the template.
Docstring Coverage ✅ Passed Docstring coverage is 90.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/modest-bell-hr7lo8

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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/services/__tests__/chunker.test.ts`:
- Around line 127-137: The test input isn't producing many sentences because
splitIntoSentencesWithSpans only splits when the next sentence starts with a
capital letter; change the repeated sample from 'x. '.repeat(25_001) to a string
that yields capitalized sentence starts (e.g., 'A. '.repeat(25_001) or 'X.
'.repeat(25_001)) so chunkTextSemantic will actually hit the
MAX_SEMANTIC_SENTENCES guard and exercise the fallback path; keep the rest of
the test (generateEmbeddings mock, expectations) unchanged.

In `@src/services/chunker.ts`:
- Around line 15-19: The semantic chunking path currently drops the per-call
hard-cap; thread maxChunks through the semantic types and call sites by adding
maxChunks to SemanticChunkOptions, updating chunkTextSemantic() to accept and
forward maxChunks, and passing through maxChunks in both semantic fallback
locations and in smartChunk() so smartChunk(..., { maxChunks }) is honored for
CHUNKING_MODE='semantic' as well as fixed mode; update any places that call
chunkTextSemantic or the semantic fallback to use the propagated maxChunks value
instead of falling back to config.MAX_CHUNKS_HARD_CAP.

In `@src/services/embed-store.ts`:
- Around line 22-33: Ensure each generated-embedding batch matches the chunk
window before persisting: after calling generateEmbeddings(window.map((c) =>
c.content)) in the loop that uses EMBED_WINDOW, validate that embeddings.length
=== window.length and throw or reject (with a clear error) if they differ
instead of calling createChunks; reference the loop variables chunks, window,
embeddings and the functions generateEmbeddings and createChunks so the check
sits immediately after embeddings is returned and prevents inserting
incomplete/NULL embedding vectors.

In `@src/services/processor/handlers/xlsx.ts`:
- Around line 57-67: The cell-budget cap logic currently forces at least one row
via Math.max(1, ...) which can exceed MAX_XLSX_CELLS when budget < cols; change
the calculation in the block around allowedRows / cappedSheet so allowedRows =
Math.floor(budget / Math.max(cols, 1)) (no min-1), and handle the zero case by
not calling XLSX.utils.sheet_to_csv (e.g., set csv = '' or return an empty
result) when allowedRows === 0; update references to range, allowedRows,
cappedSheet, and the XLSX.utils.sheet_to_csv call so no row is rendered if the
remaining budget cannot cover a full row.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 871c268a-ca10-40ee-b9f2-bca48d55b645

📥 Commits

Reviewing files that changed from the base of the PR and between ed2fdca and 99f25c1.

📒 Files selected for processing (15)
  • .env.example
  • docs/ENV.md
  • src/api/upload.ts
  • src/services/__tests__/chunker.test.ts
  • src/services/__tests__/embeddings.test.ts
  • src/services/chunker.ts
  • src/services/embed-store.ts
  • src/services/embeddings.ts
  • src/services/processor/__tests__/json.test.ts
  • src/services/processor/handlers/html.ts
  • src/services/processor/handlers/json.ts
  • src/services/processor/handlers/xlsx.ts
  • src/services/upload-processor.ts
  • src/utils/config.ts
  • src/utils/errors.ts

Comment thread src/services/__tests__/chunker.test.ts
Comment thread src/services/chunker.ts
Comment thread src/services/embed-store.ts
Comment thread src/services/processor/handlers/xlsx.ts
…lsx budget, test

CodeRabbit review on #110 (all 4 verified valid):

- chunker: thread maxChunks through SemanticChunkOptions, chunkTextSemantic,
  both fixed-chunk fallbacks, and smartChunk so a per-call cap is honored in
  CHUNKING_MODE=semantic, not just fixed mode.
- embed-store: validate embeddings.length === window.length before createChunks
  so a short provider batch fails loudly instead of persisting NULL vectors.
- xlsx: drop the Math.max(1, ...) floor on allowedRows — when the remaining
  cell budget can't cover one full row, stop instead of overshooting the cap.
- chunker.test: the semantic-fallback test used 'x. ' (lowercase), which the
  capital-letter sentence splitter treats as ONE sentence, so it exercised the
  single-sentence path, not the MAX_SEMANTIC_SENTENCES guard. Use 'X. ' x30000.

pnpm verify passes (264 tests).

https://claude.ai/code/session_01L1NUnaJbMha7LUQsRoxFkE
Comment thread src/services/processor/handlers/xlsx.ts Fixed
The `budget = 0` in the allowedRows<=0 branch is immediately followed by
`break`, so it is never read (CodeQL: useless assignment to local variable).

https://claude.ai/code/session_01L1NUnaJbMha7LUQsRoxFkE
@jeffgreendesign jeffgreendesign merged commit 50acdd7 into main Jun 12, 2026
7 of 8 checks passed
jeffgreendesign pushed a commit that referenced this pull request Jun 12, 2026
…lsx budget, test

CodeRabbit review on #110 (all 4 verified valid):

- chunker: thread maxChunks through SemanticChunkOptions, chunkTextSemantic,
  both fixed-chunk fallbacks, and smartChunk so a per-call cap is honored in
  CHUNKING_MODE=semantic, not just fixed mode.
- embed-store: validate embeddings.length === window.length before createChunks
  so a short provider batch fails loudly instead of persisting NULL vectors.
- xlsx: drop the Math.max(1, ...) floor on allowedRows — when the remaining
  cell budget can't cover one full row, stop instead of overshooting the cap.
- chunker.test: the semantic-fallback test used 'x. ' (lowercase), which the
  capital-letter sentence splitter treats as ONE sentence, so it exercised the
  single-sentence path, not the MAX_SEMANTIC_SENTENCES guard. Use 'X. ' x30000.

pnpm verify passes (264 tests).

https://claude.ai/code/session_01L1NUnaJbMha7LUQsRoxFkE
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.

3 participants