Skip to content

feat(ingestion): reject empty and whitespace-only file uploads - #76

Merged
jaymeklein merged 1 commit into
mainfrom
feat/reject-empty-file-ingestion
Jul 24, 2026
Merged

feat(ingestion): reject empty and whitespace-only file uploads#76
jaymeklein merged 1 commit into
mainfrom
feat/reject-empty-file-ingestion

Conversation

@jaymeklein

Copy link
Copy Markdown
Owner

What

Reject file uploads that have no ingestible content — zero bytes or only whitespace (e.g. a lone \r\n) — at the upload boundary, returning 422.

Why

A content-less file parses to zero chunks, so it can never be indexed. The document then sits in the Indexing/Documents UI showing an "Index" action that reverts on every click: BM25 is an auto-maintained Postgres generated column and the index_document worker task is a deliberate no-op, so with no chunks there is genuinely nothing to index. This surfaced from a real document whose stored bytes were exactly \r\n (2 bytes) — non-zero, so a naive byte-count check would miss it.

Changes

  • api/services/upload.pystream_upload_with_size_guard now tracks whether any non-whitespace byte was streamed and raises a new EmptyFileError (422) when none was. The existing except BaseException cleanup unlinks the .tmp, so nothing is published to local or S3. This covers every HTTP upload path — both storage backends funnel through this guard.
  • api/services/documents.pyingest_local_path (master-only MCP path, which uses put_path and bypasses the streaming guard) rejects a zero-byte file with the same EmptyFileError before any DB row or enqueue.

Reviewer notes

  • The check keys on bytes.strip() (ASCII whitespace). A flag short-circuits it once content is seen, so a large binary is trimmed at most once. A file containing only a UTF-8 BOM would count as content and pass — intentional, and not a regression.
  • ingest_local_path guards the 0-byte case only (cheap st_size check) rather than reading a possibly-large local file into memory to whitespace-strip; the whitespace-only case is fully covered on the primary HTTP path where the incident originated.

Tests

  • Unit (test_upload.py): empty, whitespace-only (the incident case), and whitespace-surrounded-content all asserted, with .tmp cleanup verified.
  • Unit (test_ephemeral_ingest.py): local-path 0-byte rejection returns 422 before any DB/enqueue.
  • Integration (test_documents.py): endpoint returns 422 for empty and whitespace-only uploads.

Gate: ruff, mypy, and full unit + integration suite (859 passed, 13 skipped) all green.

An upload with no ingestible content (zero bytes or only whitespace, e.g.
a lone CRLF) parses to zero chunks, so it can never be indexed. Such a
document sits in the UI showing a no-op "Index" action that reverts on
every click, because BM25 is an auto-maintained generated column and the
index task is a no-op — there is nothing to index without chunks.

Reject these at the upload boundary instead:

- stream_upload_with_size_guard now tracks whether any non-whitespace byte
  was seen and raises a new EmptyFileError (422) when none was, cleaning up
  the .tmp so nothing is published to local or S3. This covers every HTTP
  upload (both storage backends funnel through the guard).
- ingest_local_path (master-only MCP path) rejects a zero-byte file with the
  same EmptyFileError before creating any row or enqueuing.

Tests: unit coverage for empty, whitespace-only, and whitespace-surrounded
content in the guard; the local-path guard; and endpoint-level 422s.
@jaymeklein
jaymeklein merged commit a936d29 into main Jul 24, 2026
8 checks passed
@jaymeklein
jaymeklein deleted the feat/reject-empty-file-ingestion branch July 24, 2026 12:47
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