Skip to content

Split oversized result uploads so wide-taxonomy batches don't get rejected - #149

Open
mihow wants to merge 3 commits into
mainfrom
fix/result-post-chunking
Open

Split oversized result uploads so wide-taxonomy batches don't get rejected#149
mihow wants to merge 3 commits into
mainfrom
fix/result-post-chunking

Conversation

@mihow

@mihow mihow commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

For models with very large taxonomies — for example the ~29,000-class global_moths_2024 classifier — a single batch's result upload can reach 110–142 MB, because every detection carries full per-class scores and logits arrays. In a production deployment those uploads were rejected by the reverse proxy with HTTP 413 (Request Entity Too Large), so the worker could never record its results: the upload failed, the task was never acknowledged, it eventually exhausted its redelivery budget, and the job failed with nothing stored. Smaller-taxonomy jobs were unaffected, which is why this stayed invisible until a wide-taxonomy job ran on a dense capture set.

This change makes the worker split a batch's results across multiple smaller uploads, each kept under a configurable byte cap, so wide-taxonomy jobs complete regardless of the proxy's request-body limit. It is a client-side fix that needs no server change.

Splitting is safe because of how the API already works: a request body is a container of independent results, and the server queues and acknowledges each one by its own reply_subject. Which upload carried a given result makes no difference to how it is processed. If one upload fails, only the images it carried go unacknowledged and are redelivered later; results from uploads that succeeded stay recorded, and are not sent twice.

List of Changes

# Change (user/operator effect) How (implementation)
1 Result uploads for large-taxonomy models now succeed instead of being rejected for size. New chunk_results_by_size() greedy byte-bounded packer in client.py; post_batch_results() serializes each result once, packs results into uploads under max_bytes, and posts each one. A single result that exceeds the cap on its own is sent alone and logged.
2 The size cap now bounds the bytes actually sent, rather than an undercount of them. Sizes were measured with a compact JSON encoding, but requests serializes with json.dumps defaults, which add a space after every comma and colon. On long numeric arrays that is about a quarter of the body, so the cap was advisory rather than enforced: at the 25 MB default, a batch from the ~29k-class model packed into a 25.06 MB request. Measurement now matches the encoder, and the envelope and separator costs are derived from it instead of written out.
3 A single bad response from the server no longer strands the uploads queued behind it. The handler caught only requests.RequestException, so a ValidationError from AntennaResultPostResponse.model_validate() escaped and aborted the loop. It now also catches ValueError, which covers both that and a non-finite score failing to encode.
4 Operators can tune the per-upload size cap per deployment. New setting antenna_result_post_max_bytes → env var AMI_ANTENNA_RESULT_POST_MAX_BYTES (default 25 MB), threaded through ResultPoster into the worker. The benchmark tool takes the same setting, so a benchmark reports the request count a tuned deployment would really make.
5 Regression coverage for the splitting behaviour and its failure semantics. trapdata/antenna/tests/test_result_chunking.py — 13 tests: packing under the cap, splitting a large batch, over-cap single results, upload sizes measured as requests encodes them, and the failure semantics described below.

Failure semantics

Worth stating explicitly, because the two halves sound contradictory and are not:

  • A failed upload does not stop the ones after it. Each upload carries a different set of reply_subjects, so continuing acknowledges work that is already finished instead of forcing it to be redone.
  • post_batch_results() still returns True only if every upload succeeded. A partial failure returns False even though some results were recorded.

Both are now pinned by tests (TestPostBatchResultsFailureSemantics). Note that the return value currently feeds metrics and logging only; recovery from a partial failure comes from redelivery of the unacknowledged images, not from the boolean.

Diagnosis — why one batch reaches ~140 MB

  • Each ClassificationResponse carries scores and logits, each an array of length = number of model classes. At ~29k classes that is ~1.1 MB per detection for those two arrays alone.
  • The default batch is 24 images, and trap images routinely contain many moths each, so a batch of ~130 detections lands at roughly 120–140 MB — matching the observed rejected upload sizes.
  • This is payload width, not duplication: the worker builds each upload from only the current batch's detections (worker.py), and the classifier/detector are reset per batch, so there is no cross-batch accumulation.

Notes and follow-ups (out of scope for this PR)

Directions to discuss, ordered by leverage. Important context: result payloads are expected to grow, not shrink. logits are needed and will be kept, and per-crop vector embeddings are planned for each detection. That makes compressing the upload more valuable over time, and argues against tightening the proxy's body-size limit.

  1. Request gzip is the highest-leverage lever, and will matter more as payloads grow. The worker posts raw JSON, and the API server compresses responses but has no request-body decompression (no Content-Encoding: gzip handling). The big arrays — scores, logits, and soon per-crop embeddings — are numeric and compress roughly 5–10×, so gzipping uploads could cut every request at the source. It requires the API server to accept and decompress gzipped request bodies first, and the reverse proxy to pass them through. Chunking was chosen here because it is client-side only and unblocks affected jobs now; gzip is the better long-term fix and the two compose rather than compete.
  2. Sending compact JSON would cut about 20% for free. Encoding the body in the client and posting it as bytes, rather than handing requests a dict, removes the whitespace json.dumps adds by default. It was left out of this PR because the shared test HTTP shim forwards only the json= argument and would need updating alongside it.
  3. Keep generous size headroom. Because logits stay and embeddings are coming, neither the per-upload cap nor the proxy body-size limit should be tightened. Chunking keeps individual uploads bounded regardless of how large a full batch's results become.
  4. labels is already omittable for large models. The per-classification labels array is list[str] | None and documented as "omitted if the model has too many labels" on both the worker and server schemas, so for a 29k-class model it is most likely already dropped — a minor saving already realized. The bulk is scores + logits (and soon embeddings).

Not yet verified

  • No live-worker run. Chunking is verified by unit and integration tests against the serialization path, not against a real GPU job. This should be confirmed on a real large-taxonomy job, checking that each upload lands under the deployment's proxy limit.
  • If a single image produces enough detections to exceed the cap on its own (~13+ dense detections at ~29k classes), that one result is still sent in a single upload — it cannot be split below one image without an API-contract change. The configurable cap plus a generous proxy limit covers this for now.

Test status

uv run python -m pytest trapdata/antenna/tests/ -q → 26 passed. Full suite → 51 passed, 1 skipped, with test_models.py::TestSourceImageSchema::test_url failing on an external image host; that failure is unrelated to this branch and appears on others.

…y limits

Wide-taxonomy pipelines (e.g. the global moths model with ~29k classes) emit
roughly 2 MB per detection because each classification carries full-length
labels, scores, and logits arrays. A single processed batch of two dozen images
with several detections each therefore serialized to 110-140 MB, which reverse
proxies rejected with HTTP 413 even after the body limit was raised to 512 MB.

The results for one batch were already scoped to the current batch only (no
accumulation across batches), so the size came purely from payload width times
detection count. This change splits the results for a batch across multiple
POST requests, each kept at or below a configurable byte cap, so no single
request body exceeds the proxy limit.

- Add chunk_results_by_size() and make post_batch_results() serialize each
  result once, greedily pack them into byte-bounded chunks, and POST each chunk;
  it now returns True only if every chunk succeeds. A single result that exceeds
  the cap on its own is sent alone (and logged) rather than dropped.
- Add AMI_ANTENNA_RESULT_POST_MAX_BYTES setting (default 25 MB) and thread it
  through ResultPoster to post_batch_results.
- Add tests asserting each POST body stays under the cap, no results are
  dropped, and the unsplit baseline would have exceeded the cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements byte-bounded chunking of Antenna API result POST bodies to prevent HTTP 413 (payload too large) errors when result batches exceed reverse-proxy limits. Results are now split into sequential POSTs, each constrained by a configurable per-POST byte cap (default 25 MiB).

Changes

Antenna API Result Chunking

Layer / File(s) Summary
Configuration and byte-limit constants
trapdata/settings.py, trapdata/antenna/client.py
New antenna_result_post_max_bytes setting in Settings (default 25 MiB) with GUI/INI configuration metadata; DEFAULT_RESULT_POST_MAX_BYTES constant defined in client module.
Core result chunking implementation
trapdata/antenna/client.py
New _result_json_size() and chunk_results_by_size() helpers to greedily pack serialized results into byte-bounded chunks; post_batch_results() rewritten to serialize once, chunk, and POST each chunk sequentially while aggregating success/failure across chunks.
ResultPoster integration with chunking limits
trapdata/antenna/result_posting.py
ResultPoster.__init__ now accepts max_post_bytes parameter (defaulting to DEFAULT_RESULT_POST_MAX_BYTES); _post_with_timing() explicitly passes the limit to post_batch_results().
Worker configuration and ResultPoster instantiation
trapdata/antenna/worker.py
Job worker now passes settings.antenna_result_post_max_bytes when constructing ResultPoster, threading the byte limit from configuration through to the posting logic.
Comprehensive test suite for chunking
trapdata/antenna/tests/test_result_chunking.py
New test module with helpers to generate large payloads and utilities to measure serialized request sizes; covers chunk_results_by_size packing (empty input, per-chunk caps, data preservation, oversize entries) and post_batch_results integration (multi-POST splitting, baseline validation, empty-result no-op).

Sequence Diagram

sequenceDiagram
  participant Worker
  participant ResultPoster
  participant post_batch_results
  participant chunk_results_by_size
  participant HTTP
  Worker->>ResultPoster: __init__(max_post_bytes=settings.antenna_result_post_max_bytes)
  Note over ResultPoster: stores self.max_post_bytes
  Worker->>ResultPoster: post_batch(results)
  ResultPoster->>post_batch_results: post_batch_results(..., max_bytes=self.max_post_bytes)
  post_batch_results->>post_batch_results: serialize to JSON dicts
  post_batch_results->>chunk_results_by_size: JSON dicts, max_bytes
  chunk_results_by_size-->>post_batch_results: list of chunks
  loop for each chunk
    post_batch_results->>HTTP: POST {results: chunk}
    HTTP-->>post_batch_results: 200/validation error
    post_batch_results->>post_batch_results: log per-chunk status
  end
  post_batch_results-->>ResultPoster: all_ok flag
  ResultPoster-->>Worker: success status
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 A batchy new day for results so wide,
Split by bytes and posted with pride!
No 413s shall spoil our quest,
Chunked and posted, each one blessed.
The Antenna sings—results take flight! 🚀

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main objective of the PR: splitting oversized result uploads to prevent rejection for wide-taxonomy batches.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ 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 fix/result-post-chunking

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
trapdata/antenna/result_posting.py (1)

68-76: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Update class docstring to document the new max_post_bytes parameter.

The __init__ method now accepts a max_post_bytes parameter, but the class docstring (lines 52-66) doesn't document it in the Args section. This makes the parameter undiscoverable for users reading the API documentation.

📝 Proposed documentation update

Add to the docstring's Args section (after line 59):

     Args:
         max_pending: Maximum number of concurrent posts before blocking (default: 5)
+        max_post_bytes: Maximum size in bytes of a single POST body (default: 25 MiB)
🤖 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 `@trapdata/antenna/result_posting.py` around lines 68 - 76, Update the class
docstring (the Args section in the class above __init__) to document the new
__init__ parameter max_post_bytes: explain it's the per-POST body size cap in
bytes, include the default value DEFAULT_RESULT_POST_MAX_BYTES, and place this
entry alongside max_pending and future_timeout so users can discover the
parameter when reading the API docs; reference the parameter name max_post_bytes
and the constructor method __init__ when adding the brief description.
🤖 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 `@trapdata/antenna/client.py`:
- Around line 190-207: The loop that posts chunks uses response.json() and
AntennaResultPostResponse.model_validate(), but the except currently only
catches requests.RequestException so JSON decoding or Pydantic validation errors
will leak and abort processing; update the exception handling around the
session.post/response parsing/model_validate block (the code that calls
session.post, response.json(), and AntennaResultPostResponse.model_validate) to
also catch json.JSONDecodeError and pydantic.ValidationError (or ValueError if
pydantic isn't imported) in the same except (or use a broad Exception as a last
resort), log the error via logger.error including chunk_idx/job_id/url, set
all_ok = False, and continue to the next chunk so remaining chunks are still
posted.

---

Outside diff comments:
In `@trapdata/antenna/result_posting.py`:
- Around line 68-76: Update the class docstring (the Args section in the class
above __init__) to document the new __init__ parameter max_post_bytes: explain
it's the per-POST body size cap in bytes, include the default value
DEFAULT_RESULT_POST_MAX_BYTES, and place this entry alongside max_pending and
future_timeout so users can discover the parameter when reading the API docs;
reference the parameter name max_post_bytes and the constructor method __init__
when adding the brief description.
🪄 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

Run ID: 1c2b7005-4ad0-4b52-aee5-febb971e4e3e

📥 Commits

Reviewing files that changed from the base of the PR and between a33746a and fce66be.

📒 Files selected for processing (5)
  • trapdata/antenna/client.py
  • trapdata/antenna/result_posting.py
  • trapdata/antenna/tests/test_result_chunking.py
  • trapdata/antenna/worker.py
  • trapdata/settings.py

Comment thread trapdata/antenna/client.py
@mihow mihow added the Pipeline API Updates to the requests & responses to/from processing service workers for ML pipelines label Jun 24, 2026
mihow and others added 2 commits August 11, 2026 22:22
The packer measured each result with a compact JSON encoding, but requests
serializes a json= argument with json.dumps defaults, which add a space after
every comma and colon. On the long numeric scores/logits arrays a wide-taxonomy
classifier emits that whitespace is about a quarter of the body, so the cap was
advisory rather than enforced: at the shipped 25 MB default, a batch from the
~29k-class model packed into a 25.06 MB request. Sizes are now measured the way
requests encodes them, and the envelope and entry-separator costs are derived
from the encoder instead of being written out.

Also catch ValueError alongside requests.RequestException when reading a
response. Pydantic's ValidationError from model_validate() is a ValueError and
was escaping the handler; once results are split across several POSTs, an escape
strands every chunk behind it and leaves those images to be redelivered for no
reason. (requests' own JSONDecodeError was already covered, being a
RequestException subclass since 2.27 and the floor pin here is 2.28.)

The worker tests built settings as a bare MagicMock, so the new byte-cap setting
arrived as a MagicMock and raised TypeError when the packer compared it to an
int. The failure surfaced far from its cause, as batches whose results were
never posted. Both settings helpers now supply a real number.

Adds coverage for the failure semantics the chunking relies on: a failed chunk
reports failure, does not abort the chunks after it, and an off-schema or
undecodable response fails only its own chunk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcXFHJRXMrsHPX7xz9ZifF
The benchmark built its ResultPoster without the cap, so it fell back to the
module default and quietly ignored an operator's AMI_ANTENNA_RESULT_POST_MAX_BYTES.
A benchmark run against a tuned deployment would report a different number of
requests than the worker actually makes.

Also documents future_timeout and max_post_bytes in the ResultPoster docstring,
which listed only max_pending.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcXFHJRXMrsHPX7xz9ZifF
@mihow

mihow commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Claude says: This PR had been open a couple of months collecting small fixes, so before another round of line-by-line I did a structural pass: what the change is really deciding, and what might be wrong in code the diff never touches. Most of what came out is already fixed on the branch. The rest are decisions worth making on purpose rather than by default.

Fixed on the branch

1. The size cap was not actually capping. (fa120c7)

This is the one worth reading. The packer measured each result with a compact JSON encoding, but requests serializes a json= argument using json.dumps defaults, which put a space after every comma and colon. On the long numeric scores and logits arrays this PR exists to handle, that whitespace is about a quarter of the body. So the cap was an undercount of the thing it was capping. At the shipped 25 MB default with the ~29k-class model, a batch packed into a 25.06 MB request:

29000 classes, cap 25.0 MB | before: max body 25.06 MB (100%)  <-- over cap
                           | after:  max body 23.50 MB (94%)

The overshoot is small at the current default and the proxy limit leaves room, so this was very unlikely to bite in practice. It mattered because the guarantee was not sound: the margin came from headroom rather than from the cap. Sizes are now measured the way requests encodes them, and the envelope and separator costs are derived from the encoder rather than written out, so the two cannot drift apart again.

2. A ValidationError could strand every upload behind it. (fa120c7)

CodeRabbit flagged this and was half right; details and the empirical check are in that thread. Short version: model_validate() raises Pydantic's ValidationError, which is not a requests exception and escaped the handler. Before chunking that failed one POST. After chunking it aborts the loop, so uploads queued behind the bad response are never attempted and their images wait on redelivery for nothing. The handler now catches ValueError as well, which covers it. The other half of the report, JSON decode errors, was already covered, since requests.exceptions.JSONDecodeError subclasses RequestException.

3. The five failing test_worker.py tests. (fa120c7)

These were not a product regression, which surprised me. The worker tests build settings as a bare MagicMock, so the new antenna_result_post_max_bytes arrived as a MagicMock and raised TypeError the moment the packer compared it against an int. That got swallowed by the broad except Exception in _post_results_sync and reported as a failed post, so the symptom was assert 0 == 2: results that were simply never posted. Production always gets a real int from Pydantic, so the impact was confined to the tests.

Both settings helpers now supply a real number. Worth noting the same trap had already been hit once before, which is why localization_batch_size carries a # Real integer comment. A MagicMock settings object silently invents a value for every new setting the worker reads, so each one of these lands as a mysterious product failure. If it happens a third time, building the double from a real Settings would end the category.

4. benchmark.py ignored the new setting. (da59959)

The kind of thing a diff review will not catch, since the file is not in the diff. It built its ResultPoster without the cap, so it fell back to the module default and quietly disregarded AMI_ANTENNA_RESULT_POST_MAX_BYTES. A benchmark against a tuned deployment would report a different request count than the worker actually makes. It now takes the setting, like the worker does. The ResultPoster docstring also documents max_post_bytes and future_timeout, per the other CodeRabbit note.

Worth a decision before merge

5. Chunking over gzip, made explicit. The body argued gzip is "the highest-leverage lever" and that payloads will grow as embeddings arrive, then shipped chunking. I think that is the right call: chunking is client-side only and unblocks affected jobs now, whereas gzip needs the API server to decompress request bodies and the proxy to pass them through. They also compose rather than compete. But it was being made by default rather than on purpose, so the body now says so. Happy to be overruled on the ordering.

6. The success boolean is currently decorative. post_batch_results() returns True only when every upload lands, and that is now pinned by tests. But the value feeds metrics and logging only: _process_job returns did_work regardless, so a batch with a failed upload does not fail the job. In practice recovery comes from redelivery of the unacknowledged images, which is per-image and works. I lean towards leaving it, since redelivery is the more granular mechanism and failing the job would be coarser. Flagging it because "we rely on redelivery, not on the return value" is a design position worth holding deliberately rather than discovering later.

7. Still no live-worker run. Everything here is verified against the serialization path, not a GPU job. The remaining unknown is the one the PR already names: an image dense enough to exceed the cap on its own still goes out in a single upload. A real large-taxonomy job, checking each upload lands under the proxy limit, is the last gap. That is a "confirm after merge" item to me rather than a blocker, given the cap now measures honestly.

Smaller cleanups (low priority)

  • The body claimed 7 tests covering "all-chunks-must-succeed semantics", but no test actually exercised a failing chunk. That claim is now true: TestPostBatchResultsFailureSemantics pins that a failed upload reports failure, does not abort the uploads after it, and that an off-schema or undecodable response fails only its own upload. 13 tests in that file now.
  • Sending compact JSON would cut roughly 20% off every request for free, by encoding the body in the client instead of handing requests a dict. Left out here because the shared test HTTP shim forwards only the json= argument and would need updating with it, and that file is being edited on another branch. Noted as a follow-up.

Where it stands

trapdata/antenna is 26/26 locally and 0 failures in CI. The full CI run is 49 passed, 3 failed on both 3.10 and 3.12, and those three are test_api.py::test_config_num_classification_predictions, test_api.py::test_logits_in_classification_response, and test_models.py::test_url. All three are environmental, unrelated to this branch, and being fixed separately: PR #163 fails the identical three on its current run. None of the five test_worker.py failures remain.

No history was rewritten, both pushes were fast-forwards, so anything stacked on this branch is unaffected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Pipeline API Updates to the requests & responses to/from processing service workers for ML pipelines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant