Split oversized result uploads so wide-taxonomy batches don't get rejected - #149
Split oversized result uploads so wide-taxonomy batches don't get rejected#149mihow wants to merge 3 commits into
Conversation
…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>
📝 WalkthroughWalkthroughThis 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). ChangesAntenna API Result Chunking
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 winUpdate class docstring to document the new
max_post_bytesparameter.The
__init__method now accepts amax_post_bytesparameter, 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
📒 Files selected for processing (5)
trapdata/antenna/client.pytrapdata/antenna/result_posting.pytrapdata/antenna/tests/test_result_chunking.pytrapdata/antenna/worker.pytrapdata/settings.py
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
|
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 branch1. The size cap was not actually capping. ( This is the one worth reading. The packer measured each result with a compact JSON encoding, but 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 2. A CodeRabbit flagged this and was half right; details and the empirical check are in that thread. Short version: 3. The five failing These were not a product regression, which surprised me. The worker tests build settings as a bare Both settings helpers now supply a real number. Worth noting the same trap had already been hit once before, which is why 4. The kind of thing a diff review will not catch, since the file is not in the diff. It built its Worth a decision before merge5. 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. 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)
Where it stands
No history was rewritten, both pushes were fast-forwards, so anything stacked on this branch is unaffected. |
Summary
For models with very large taxonomies — for example the ~29,000-class
global_moths_2024classifier — a single batch's result upload can reach 110–142 MB, because every detection carries full per-classscoresandlogitsarrays. 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
chunk_results_by_size()greedy byte-bounded packer inclient.py;post_batch_results()serializes each result once, packs results into uploads undermax_bytes, and posts each one. A single result that exceeds the cap on its own is sent alone and logged.requestsserializes withjson.dumpsdefaults, 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.requests.RequestException, so aValidationErrorfromAntennaResultPostResponse.model_validate()escaped and aborted the loop. It now also catchesValueError, which covers both that and a non-finite score failing to encode.antenna_result_post_max_bytes→ env varAMI_ANTENNA_RESULT_POST_MAX_BYTES(default 25 MB), threaded throughResultPosterinto the worker. The benchmark tool takes the same setting, so a benchmark reports the request count a tuned deployment would really make.trapdata/antenna/tests/test_result_chunking.py— 13 tests: packing under the cap, splitting a large batch, over-cap single results, upload sizes measured asrequestsencodes them, and the failure semantics described below.Failure semantics
Worth stating explicitly, because the two halves sound contradictory and are not:
reply_subjects, so continuing acknowledges work that is already finished instead of forcing it to be redone.post_batch_results()still returnsTrueonly if every upload succeeded. A partial failure returnsFalseeven 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
ClassificationResponsecarriesscoresandlogits, each an array of length = number of model classes. At ~29k classes that is ~1.1 MB per detection for those two arrays alone.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.
logitsare 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.Content-Encoding: gziphandling). 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.requestsa dict, removes the whitespacejson.dumpsadds by default. It was left out of this PR because the shared test HTTP shim forwards only thejson=argument and would need updating alongside it.logitsstay 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.labelsis already omittable for large models. The per-classificationlabelsarray islist[str] | Noneand 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 isscores+logits(and soon embeddings).Not yet verified
Test status
uv run python -m pytest trapdata/antenna/tests/ -q→ 26 passed. Full suite → 51 passed, 1 skipped, withtest_models.py::TestSourceImageSchema::test_urlfailing on an external image host; that failure is unrelated to this branch and appears on others.