feat(benchmark): make bulk sample upload resumable - #805
Conversation
A customer uploading 1,487 benchmark samples over a mobile connection lost 12 of them permanently. The three per-sample retries all fired within ~3 seconds, so they landed in the same congestion window and failed together, and the pairs that failed were only recoverable by scraping the logs. Recovery is now built in. `add_model` diffs the intended samples against what the server actually holds and re-uploads only the difference at lower concurrency; `participant.retry_missing(media, identifiers)` runs the same sweep on demand. Verifying before re-uploading is what makes this repeatable — a request can time out after the sample was persisted, and the backend does not constrain (participantId, identifier), so a blind retry would give that prompt a second sample and over-weight it in matchup sampling. The diff counts identifiers rather than testing set membership, since a participant may legitimately hold several samples for one identifier. Whatever is still missing after the sweep is left on `participant.failed_samples` as FailedUpload[SampleUpload] — the media, the identifier, the reason and the trace id — instead of being logged and dropped. Also: - Recover the backend trace id from the `x-trace-id` response header. A Kestrel request-timeout 408 has no problem+json body to carry `traceId`, so these errors printed "Trace Id: N/A" while the header was right there. - Jitter the retry backoff at both layers, so a pool that fails together stops retrying in lockstep. - Log intermediate retry attempts at INFO. They were at DEBUG behind suppressed error logging, which hid every transient failure that later succeeded. - Advance the progress bar on failed samples too; it previously stalled short of the total on any failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: lino <68745352+LinoGiger@users.noreply.github.com>
|
|
||
| def upload_media( | ||
| self, | ||
| assets: list[str], | ||
| identifiers: list[str], | ||
| data_type: Literal["media", "text"] = "media", | ||
| ) -> tuple[list[str], list[str]]: | ||
| max_workers: int | None = None, |
There was a problem hiding this comment.
why are we adding max workers as a parameter here when we have the global config that gets used everywhere else?
There was a problem hiding this comment.
Removed — upload_media reads the global rapidata_config.upload.maxWorkers again. Reasoning in the upload_config.py thread: the parameter only existed to let the sweep run lower, and that turned out not to be what makes the sweep work.
| assets: list[str], | ||
| identifiers: list[str], | ||
| data_type: Literal["media", "text"] = "media", | ||
| max_workers: int | None = None, |
There was a problem hiding this comment.
Same — removed from retry_missing too. It now just calls upload_media, which uses the global config.
| sweepMaxWorkers: int = Field( | ||
| default=8, | ||
| description="Worker threads for the end-of-run recovery sweep and retry_missing.", | ||
| ) |
There was a problem hiding this comment.
i don't get why this would help? why can't we just use the normal max workers?
There was a problem hiding this comment.
You are right — removed, along with the per-call max_workers parameters on upload_media / retry_missing. Uploads now read rapidata_config.upload.maxWorkers like everywhere else.
Working through why I added it, it does not survive: the sweep set is the failures, so it is small by construction. In the customer's run it was 12 items — at that size a cap of 8 vs 25 changes nothing, because you never have more than len(missing) requests in flight anyway. And by then the assets are already in the local upload cache, so the sweep is small JSON POSTs, not the large bodies that produced the 408s.
What actually does the recovery is the server diff, the sweep landing outside the congestion window, and the jitter. The concurrency drop was the weakest of the four and cost a public config field to get. Their manual recovery used maxWorkers=8, which is what put the number in my head, but I think the timing and the warm cache are what made it work, not the 8.
Anyone who genuinely is uplink-bound can still set rapidata_config.upload.maxWorkers — the MRI docs now point at that.
| # The problem+json body is the canonical source, but a gateway timeout | ||
| # (408) or an LB-generated error has no body to carry it — then the | ||
| # response header is the only place the id survives. | ||
| self.trace_id = self._trace_id_from_details(details) or trace_id |
There was a problem hiding this comment.
shouldn't this logic be the other way around?
There was a problem hiding this comment.
Yes — flipped, good catch. It is now trace_id or self._trace_id_from_details(details).
I checked the backend rather than guess, and the two sources are not the same string:
Rapidata.Shared.API/Middleware/TraceIdMiddleware.cs:17sets the header fromActivity.Current?.TraceId.ToString()— the bare 32-hex trace id.- The body's
traceIdcomes from the stockDefaultProblemDetailsFactory(there is no custom one inrapidata-backend), which usesActivity.Current?.Id— the full traceparent,00-<trace>-<span>-<flags>.
So body-first meant the reported id changed shape depending on which layer produced the error: a traceparent when a handler ran, a bare id when a Kestrel/LB timeout meant none did. Header-first gives one consistent value, and it is the form that goes straight into a trace search without stripping the wrapper — which is the whole point of the customer's request.
My original comment said the body was "canonical", which was the wrong axis to reason on. Comment rewritten to explain the format difference.
Drop sweepMaxWorkers and the per-call max_workers parameters. The sweep set is the failures, so it is small by construction and rarely reaches even the default worker count — the recovery is carried by the server diff, by running after the congestion window, and by the assets already being cached, not by the lower concurrency. Uploads read rapidata_config.upload.maxWorkers like everywhere else in the SDK. Prefer the x-trace-id header over the body's traceId rather than the reverse. TraceIdMiddleware sets the header from Activity.Current.TraceId (the bare trace id) while the stock ProblemDetailsFactory fills the body from Activity.Current.Id (the full traceparent), so header-first reports one searchable shape regardless of which layer produced the error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: lino <68745352+LinoGiger@users.noreply.github.com>
| result = self._openapi_service.leaderboard.sample_api.participant_participant_id_samples_get( | ||
| participant_id=self.id, | ||
| page=current_page, | ||
| page_size=500, |
There was a problem hiding this comment.
i think this can not be above 100
There was a problem hiding this comment.
Correct, and it was worse than a limit — it would have failed outright. Rapidata.Shared.Query/Validators/PaginationValidator.cs does RuleFor(x => x!.Size).LessThanOrEqualTo(pagingSettings.Value.MaxPageSize), and Rapidata.Shared.Configuration/global-appsettings.json:49 sets MaxPageSize: 100. So it rejects rather than clamps: every uploaded_identifier_counts call would have 400'd, taking retry_missing and the whole automatic sweep down with it.
Fixed to 100, matching the prompt and leaderboard loops already in rapidata_benchmark.py, with a named constant and a comment so the next person does not read it as a tuning knob.
Worth noting why this got through: my tests mocked uploaded_identifier_counts wholesale, so the paging loop had no coverage at all. Added test_uploaded_identifier_counts_pages_within_the_server_page_limit, which drives the real loop against a mocked API and asserts every request stays at or below 100.
| @property | ||
| def failed_samples(self) -> list[FailedUpload[SampleUpload]]: | ||
| """The samples that failed in the most recent upload on this participant. | ||
|
|
||
| Each entry carries the media/identifier pair plus the failure reason and | ||
| backend trace id, so a failed batch can be re-submitted without | ||
| reconstructing the pairs from the logs. Populated by | ||
| :meth:`upload_media` and :meth:`retry_missing` — and therefore by | ||
| ``benchmark.add_model``, which calls them. | ||
| """ | ||
| return list(self._failed_samples) |
There was a problem hiding this comment.
not sure i like to expose this, cause it's only valid for when the participant gets instantiated and stuff is uploaded. when the participant is fetched, this is always empty, no?
There was a problem hiding this comment.
You are right, and it is exactly as bad as you describe: benchmark.participants constructs these objects from the API, so failed_samples would be [] there — indistinguishable from "uploaded cleanly". A property on a long-lived entity describing a transient operation.
Removed it, along with the backing state. In its place there is now participant.missing_samples(assets, identifiers), which asks the server which of your intended pairs are absent. That is correct on any participant however it was obtained, and it cannot go stale — it is the same diff retry_missing runs, just exposed.
The per-failure detail (reason, trace id) is not lost: upload_media and retry_missing still return FailedUpload[SampleUpload] objects, and add_model logs format_error_details() for each residual failure at ERROR. What the customer actually needed was the pairs, so they could re-upload without reconstructing them from logs — and missing_samples / retry_missing give them that without any stored state.
Happy to reinstate a stored accessor if you would rather have the reasons reachable from add_model programmatically; I left it out because it reintroduces the staleness you flagged.
The sample query paged at page_size=500, but PaginationValidator rejects anything above the configured MaxPageSize of 100 rather than clamping it, so every call would have failed validation — taking retry_missing and the whole automatic sweep with it. The existing prompt and leaderboard loops in this repo already page at 100. Covered by a test that drives the real paging loop; the previous tests mocked it out, which is why this got through. Replace the failed_samples property with missing_samples(assets, identifiers). The property described the last upload performed on one object, so a participant obtained from benchmark.participants always reported an empty list — indistinguishable from a clean upload. Asking the server instead is correct on any participant and cannot go stale. Per-failure reasons and trace ids are still returned by upload_media / retry_missing and logged by add_model. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: lino <68745352+LinoGiger@users.noreply.github.com>
|
Confirmation from @LinoGiger's question territory, via Luca (leaderboard owner) — asked out-of-band whether multiple samples per
That validates the diff design in The corollary is that the backend cannot dedupe defensively, so nothing server-side stops a blind retry from doubling a sample. That is why |
The diff counted how many samples the server held per identifier, which answers "how many of these landed" but not "which". With several distinct assets under one identifier that is not enough: if a1 failed and a2 succeeded, the count of 1 was consumed against a1 in list order, so a2 was reported missing and a1 was not. The sweep then re-uploaded a2 — creating exactly the duplicate the verify-before-retry design exists to prevent — and left a1 absent for good. It only looked correct when the failure happened to come last. Match on the media as well as the identifier. The server reports an asset the same way the benchmark's prompt-asset handling already assumes: remote URLs round-trip verbatim as sourceUrl, local files come back as the bare originalFilename, text as its content. Multiplicity is still counted per pair, so the same media supplied twice still needs two samples. Where an asset cannot be read back, the sample is treated as covering an intended pair rather than as absent. Leaving a sample unsent is reported to the caller; duplicating one silently over-weights that prompt in matchup sampling. Two paths remain indistinguishable when they share a filename under one identifier (a/1.png vs b/1.png), since the server only keeps the basename. Documented on missing_samples. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: lino <68745352+LinoGiger@users.noreply.github.com>
Correction: the diff was matching on the wrong thing@LinoGiger caught that the identifier-count diff is not sufficient, and he is right. Superseding my earlier comment on this — the design it said Luca had validated was validated only on the "several samples per prompt is legal" point, not on the matching being adequate. The bug. The fix (2fd9cd0): match on the media as well as the identifier. The server reports assets in a form this repo already knows how to read — Where an asset cannot be read back (unexpected shape, metadata the backend stops sending), the sample is counted as covering an intended pair rather than as absent. That direction is deliberate: an unsent sample is returned to the caller and visible, whereas a duplicate silently over-weights the prompt. One case remains ambiguous and is documented on the method: two different paths sharing a filename under the same identifier ( Tests: |
| if isinstance(original_filename, str): | ||
| return original_filename | ||
|
|
||
| return None |
There was a problem hiding this comment.
this method doesn't really work? cause the original filename isn't necessarily the same as the path for the upload cause it only saves the last bit. can't we use the cache first and see if we have the filename there?
There was a problem hiding this comment.
Agreed — and rather than reach for the cache, the whole heuristic is now gone.
You are right that it cannot work: originalFilename only holds the last path segment, so runA/0001.png and runB/0001.png are indistinguishable once the server has reduced them. The cache would have helped for local files with a warm cache, but not for a cold one, and not uniformly with URLs — so it would have been a better guess, still a guess.
Instead the decision moved to the backend, which is what we agreed after this came up in chat: rapidata-backend#5015 (merged) gives each sample a content fingerprint from the asset store's own hash — not the filename, so an uploaded file and a URL ingest of the same bytes compare equal — with a unique index on (participantId, identifier, assetFingerprint) and a 409 for a sample the participant already holds.
So the SDK is now your version: ask which identifiers are short, re-send every asset belonging to them, and let the server reject the ones already there. 409 counts as uploaded and is not retried. The client never has to know which of an identifier's assets is missing.
Deleted: _server_asset_key, _local_asset_key, and the pair-level diff. missing_samples is replaced by missing_counts(identifiers), which reports the shortfall per identifier — deliberately not naming individual media, since that is precisely the question the client cannot answer correctly. Reasons for genuine failures still come back on FailedUpload.
retry_missing loops until nothing is short and stops early if a round fails to close the gap, so a sample the server keeps refusing cannot spin.
82 tests pass, pyright clean, docs build. Ready for another look.
The diff compared the caller's paths against the originalFilename the API reports, which only ever holds the last path segment — so it could not actually identify a file, and two runs writing 0001.png under different directories were indistinguishable. The heuristic is gone rather than patched. rapidata-backend#5015 gives samples a content fingerprint and a unique index on (participantId, identifier, assetFingerprint), returning 409 for a sample the participant already holds. So the client no longer has to work out which of an identifier's assets is the missing one: ask which identifiers are short, re-send everything belonging to them, and let the server turn away the rest. 409 counts as uploaded and is not retried — the sample it wanted exists. missing_samples is replaced by missing_counts(identifiers), which reports the shortfall per identifier. It cannot name the individual media, because that is exactly the question the client cannot answer correctly; the reasons for real failures still come back on FailedUpload. retry_missing loops until nothing is short, stopping early when a round fails to close the gap so a sample the server keeps refusing cannot spin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: lino <68745352+LinoGiger@users.noreply.github.com>
Addresses customer feedback on bulk sample upload: 12 of 1,487 benchmark samples failed permanently over a mobile connection, and the failed pairs were only recoverable by scraping the logs.
Pairs with rapidata-backend#5015 (merged), which does the deduplication this relies on.
Why it failed
Each sample is two requests —
POST /asset/file(the bytes, disk-cached) thenPOST /participant/{id}/sample(small JSON) — both inside one retry loop. The three attempts backed off2**attempt, so all of them landed within ~3 seconds of each other, well inside the congestion window that caused the failure.add_modelthen logged the failures and dropped them.What changed
Resumable upload.
add_modelruns a recovery sweep when anything fails, andparticipant.retry_missing(media, identifiers)runs it on demand: ask the server which identifiers are short, re-send every asset belonging to those identifiers, and let the backend reject the ones already there. It loops until nothing is short, stopping early if a round fails to close the gap.The client does not decide what is a duplicate. An earlier revision of this PR compared the caller's paths against the
originalFilenamethe API reports. That cannot work —originalFilenameonly holds the last path segment, sorunA/0001.pngandrunB/0001.pngare indistinguishable. @LinoGiger caught it and the heuristic is gone rather than patched. The backend now fingerprints each sample's asset by content and returns409for one the participant already holds; the SDK treats that as uploaded and does not retry it. That is what makes re-sending a whole identifier safe, and why the client never has to work out which of its assets is missing.missing_counts(identifiers)reports the shortfall per identifier. It deliberately does not name individual media — that is precisely the question the client cannot answer correctly. Reasons for genuine failures still come back onFailedUpload, which carries the media, the identifier, the reason and the trace id.Trace ids. A Kestrel request-timeout 408 is produced before any handler runs, so it has no problem+json body carrying
traceId— but it does carryx-trace-id. That header is now preferred:TraceIdMiddlewaresets it fromActivity.Current.TraceId(the bare id) while the body'straceIdcomes fromActivity.Current.Id(the full traceparent), so reading the header first reports one searchable shape regardless of which layer produced the error.Jittered backoff at both the per-sample and transport layers, so a worker pool that fails together stops retrying in lockstep.
Three smaller fixes in the same path: sample paging capped at the server's
MaxPageSizeof 100 (it rejects rather than clamps, so the earlier 500 would have 400'd every call); intermediate retry attempts logged at INFO (they were at DEBUG behindsuppress_rapidata_error_logging, so every transient failure that later succeeded was invisible); and the progress bar now advances on failures instead of stalling short of the total.Behaviour change
upload_mediapreviously returnedtuple[list[str], list[str]]documented as "successful and failed identifiers", but actually returned asset paths in both. It now returnstuple[list[str], list[FailedUpload[SampleUpload]]]— identifiers that uploaded, and rich failures. No caller in this repo or the docs used the return value.Separately, the backend change means one participant can no longer hold the same asset twice under the same prompt. That was previously legal; it would only double-weight the prompt in matchup sampling.
Still open
httpx.ReadError/WriteErrorare never retried at the transport layer (_is_retryable_errorcoversConnectError/RemoteProtocolError/ConnectTimeout/ReadTimeoutonly), and408is not in_RETRYABLE_STATUS_CODES. Both are worth fixing and are now safe to fix, since the backend rejects duplicates — but the transport retry is blind to HTTP method, so it deserves its own PR rather than riding along here.Testing
uv run pyright src/rapidata/rapidata_client— 0 errorsuv run pytest tests/— 82 passed. The 4 failures intests/rapidata_client/audience/are pre-existing onmain(verified by stashing this branch); they are untouched by this change.uv run --group docs mkdocs build— clean🔗 Session: https://poseidon.rapidata.internal/chat/session-4cbbfac5