Skip to content

fix: ingestion crash bugs and resilience found during a live batch ingest - #42

Merged
n0nuser merged 5 commits into
mainfrom
fix/ingestion-resilience-and-crash-bugs
Jul 7, 2026
Merged

fix: ingestion crash bugs and resilience found during a live batch ingest#42
n0nuser merged 5 commits into
mainfrom
fix/ingestion-resilience-and-crash-bugs

Conversation

@n0nuser

@n0nuser n0nuser commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Context

Deployed the stack via Docker and ran a real batch ingest of 37 PDFs (Spanish urban-planning documents). The container crashed repeatedly (exit 137). This PR is the result of chasing that down — the actual root cause turned out to be different from the initial diagnosis, and several other real issues surfaced along the way.

Issues found and their fixes

  1. Root cause of every crash — infinite loop in chunk overlap (localrag/ingestion/structural_chunker.py)
    _split_long_paragraph computed the next start as end - overlap_chars, assuming end always lands at start + max_chars. Boundary snapping (sentence/word-aware splitting) can make end land much earlier; subtracting the full overlap from a short end then leaves start unchanged or pushes it backward — hangs forever. Triggered by real Spanish legal-document text with a sparse early sentence boundary followed by a long unbroken run. Fixed by capping overlap to end - start - 1, guaranteeing forward progress. Regression test included.

  2. OCR memory hygiene (localrag/ingestion/parsers/pdf.py) — a plausible-looking but ultimately incorrect initial diagnosis, kept because it's still a real issue:

    • pypdfium2 pages/bitmaps wrap native memory not reclaimed by Python's GC; now explicitly .close()d per page, plus gc.collect() since ctypes-backed finalizers can be deferred to a GC pass.
    • Render scale is now capped so the bitmap's longest side never exceeds ~2200px — urban-planning PDFs routinely embed oversized architectural sheets (A0/A1) as a single page, and the previous flat scale=2.0 could produce tens-of-megapixels bitmaps for those.
    • Added tesseract-ocr-spa to the Docker image (OCR fallback was English-only).
  3. Batch ingestion had no resilience (localrag/ingestion/service.py, localrag/api/service.py, localrag/api/schemas.py) — a single bad file aborted the entire batch with no visibility into what would have succeeded. ingest_directory/rebuild_collection now process every file, retry failures once at the end, and report permanent failures via a new failed_sources field (path + error) in the JSON response, logged at WARNING then ERROR. Single-file endpoints keep their existing raise-on-failure contract but get the same one-time retry.

  4. O(n²) BM25 rebuild (localrag/ingestion/service.py) — bm25_index.refresh() was called after every successfully ingested file instead of once per batch. refresh() rebuilds the entire BM25 corpus from scratch, so an N-file batch did O(n²) work. Now called once per batch.

  5. delete_by_source ordering bug (localrag/ingestion/service.py) — ran before the embed call instead of after, so a failed embed left a source with zero vectors instead of its previous (still valid) ones. Reordered.

  6. POST /ingest/upload (localrag/api/routers/ingest.py) — new multipart file-picker upload endpoint, for callers that only have a local file rather than a server path. Streams to disk in 1MiB chunks enforcing a real byte-counted size cap (not Content-Length), validates extension against the parser allow-list, documents all limitations in the OpenAPI description (rendered in Swagger).

  7. No restart policy on any containerlocalrag-api got SIGKILL'd during debugging and just stayed dead with no auto-recovery. Added restart: unless-stopped to all 5 services.

  8. Prometheus/Grafana silently never started (infra/prometheus/prometheus.yml) — pre-existing YAML indentation bug broke Prometheus's own config parsing; anything depends_on-ing it (Grafana) never started either. Fixed.

  9. Manual model pull step — added a one-shot localrag-setup compose service that pulls OLLAMA_EMBED_MODEL/OLLAMA_LLM_MODEL via the existing uv run localrag setup CLI command before localrag-api starts. Also switched the default chat model to gemma3:4b (better quality, 128K context, native multilingual).

  10. Dev/prod compose divergence + Windows ergonomicsdocker-compose.override.yml previously diverged from base (hot-reload command). Now identical to base plus one Windows-only (WSL2) bind mount for drag-and-drop document ingestion.

  11. data/uploads/ wasn't gitignored — added, since it's the /ingest/upload destination (user documents, not repo content).

Test plan

  • uv run pytest -q -m "not integration" — 104 passed
  • uv run ruff check . / ruff format --check . — clean
  • uv run mypy localrag — clean (pre-existing untyped-lib warnings only)
  • Live-verified against the actual 37-file batch that originally crashed: all 37 now ingest cleanly (2599 chunks), including the specific files that previously triggered the infinite loop and the largest oversized-page files
  • End-to-end Spanish query against the ingested corpus, correct cited sources

n0nuser added 5 commits July 7, 2026 06:43
_split_long_paragraph computed the next start as `end - overlap_chars`,
assuming end always lands at start + max_chars. Boundary snapping (added
in a prior change) can make end land much earlier than that; subtracting
the full overlap from a short end can then leave start unchanged or push
it backward, hanging forever. This was the actual root cause behind every
ingestion crash chased during a live batch run — pypdfium2 memory usage
was a red herring.

Caps the overlap to (end - start - 1) so start always advances by at
least one character. Adds a regression test with the exact adversarial
shape that triggered it: an early sentence boundary followed by a long
run with no further boundaries or spaces.
pypdfium2 pages/bitmaps wrap native memory that isn't reclaimed by
Python's GC without an explicit close() — a scanned PDF accumulated one
rendered page's worth of native memory per page for the life of the
document. Both are now closed per page, with a gc.collect() afterward
since ctypes-backed objects can defer their finalizer to a GC pass.

Also caps the render scale so the bitmap's longest side never exceeds
~2200px, regardless of the page's own physical size: urban-planning /
architectural PDFs routinely embed oversized sheets (A0/A1 or larger) as
a single page, and at the previous flat scale=2.0 those pages could be
tens of megapixels each.

Neither of these turned out to be the actual crash cause found during
this session (see the chunker infinite-loop fix), but they're real
memory-hygiene issues worth keeping regardless.

Adds tesseract-ocr-spa to the Docker image for Spanish-language OCR
fallback (previously English-only).
Batch ingestion (ingest_directory, rebuild_collection) previously
re-raised on the first file that failed to parse/embed, aborting the
whole batch and losing visibility into which of the remaining files
would have succeeded. ingest_paths now processes every file, retries
failures once at the end of the batch, and reports any that still fail
via a new failed_sources list (path + error), logged at WARNING on the
first failure and ERROR if the retry also fails. Single-file endpoints
(POST /ingest, /ingest/upload) get the same one-time retry but still
raise (502) on a hard failure, keeping their existing contract.

Also fixes a real performance bug found while diagnosing a large batch
ingest: bm25_index.refresh() was called after every successful file
instead of once per batch. refresh() rebuilds the entire BM25 corpus
from scratch, so an N-file batch was doing O(n^2) work instead of O(n).

Reordered delete_by_source to run after embedding succeeds rather than
before, so a failed embed call no longer deletes the source's existing
(still valid) vectors before the retry has a chance to replace them.
Adds a file-picker upload path alongside the existing server-path-based
/ingest, for callers that only have a local file (browser "Choose File"),
not a path on the machine running the API.

Streams the upload to disk in 1MiB chunks under UPLOAD_DIR while
enforcing UPLOAD_MAX_BYTES by counting real bytes transferred rather
than trusting Content-Length (413 + partial-file cleanup on overflow),
validates the extension against the same parser allow-list used by
path-based ingest (415 on mismatch), and documents its limitations
(no AV scan, extension-only validation, single file per request,
persistent storage so later /collections/rebuild can re-embed it) in
the endpoint's OpenAPI description, rendered in Swagger.

Requires python-multipart (FastAPI's UploadFile/Form dependency).
… default

Adds a one-shot localrag-setup compose service that pulls
OLLAMA_EMBED_MODEL / OLLAMA_LLM_MODEL via the existing `uv run localrag
setup` CLI command before localrag-api starts, so `docker compose up`
no longer needs a manual `docker exec ... ollama pull` step. Reuses the
CLI's own pull logic rather than duplicating it as shell commands.

Switches the default chat model from llama3.2 to gemma3:4b (better
quality, 128K context, native multilingual support).

Adds restart: unless-stopped to all 5 services. During this session's
ingestion debugging, localrag-api was killed (exit 137) with no restart
policy configured, so it just stayed dead until manually restarted —
this was masking real crashes as "the API is unreachable" rather than
"it crashed and needs attention", and meant one bad request could take
the whole deployment down until someone noticed.

docker-compose.override.yml previously diverged from the base compose
file for hot-reload dev ergonomics; now it's identical to base plus one
added bind mount (Windows-only, WSL2) so documents can be dropped into
a Windows folder instead of copied into the WSL2 filesystem by hand.

Fixes a pre-existing YAML indentation bug in
infra/prometheus/prometheus.yml that had been silently breaking
Prometheus and, transitively, Grafana (Prometheus failed to start on
`docker compose up`, so anything depends_on'ing it never started
either) since the file was introduced.

Adds .gitignore entry for data/uploads/ (POST /ingest/upload
destination — user-uploaded documents, not repo content).
@n0nuser
n0nuser merged commit 929fcb1 into main Jul 7, 2026
8 checks passed
@n0nuser
n0nuser deleted the fix/ingestion-resilience-and-crash-bugs branch July 7, 2026 04:46
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.

1 participant