Service Bus result downloads — consumer format/decompress options + error bodies¶
Motivation¶
A Service Bus completion consumer that received a succeeded blast.transition
event could download each result_files[].download_url, but only the stored
bytes as-is. There was no way to ask for an uncompressed copy or a re-rendered
format the way NCBI Web BLAST lets a user pick the download format of one
result. Compression-vs-not is a transport concern, and the right axis to expose
is format (the same result, re-serialised) — handled by the dashboard's
streaming gateway, never by issuing a SAS (charter §9). Downloads also needed to
work without a 401 for a consumer that already proved auth by receiving the
event, and download failures needed to surface a readable reason.
User-facing change¶
result_files[]entries on a succeeded event now carrycompressed(the stored bytes are gzip) andmedia_type(the as-stored content type), so a consumer can choose a download option without a HEAD request.- The download gateway
GET /api/v1/elastic-blast/jobs/{job_id}/files/{file_id}accepts two new query options (independent of the existing signed?token=): ?decompress=1— streams a gzip result inflated on the fly (memory- bounded; the.gzsuffix is dropped from the filename).?format=csv|tsv|json— parses the BLAST XML / tabular hits and re-renders them. A parse failure returns422 result_unparseable, an oversize file returns413 result_too_large— both as a JSON error body ({"code", "message"}), never an empty/partial download.- No-401 downloads: the signed
?token=already lets a consumer download by URL alone (no bearer); the new options preserve that — a download-token caller may also decompress/transcode the same(job_id, file_id). - The example
consume.pynow exposes--decompress/--format, records the gateway's JSON error body on a failed download, and surfaceserror_message(the human-readable failure reason) from afailedevent.
API / IaC diff summary¶
- New
api/services/blast/result_transcode.py— pure, streaming/bounded gunzip (gunzip_stream,gunzip_bytes) and XML/tabular → csv/tsv/json re-render (transcode_result_bytes) withResultTooLargeError/ResultParseErrorsurfaced as HTTP error bodies. Input capped atTRANSCODE_MAX_BYTES(16 MiB). api/routes/elastic_blast.pydownload_external_blast_file— addeddecompress: bool+format: strquery params; fast path (no transform) unchanged and still streamed.api/tasks/servicebus/tasks.py_result_files_for_event— emitscompressedmedia_typeper entry.example/servicebus/consume.py—--decompress/--format, error-body capture,error_messagein the completion summary.- Docs:
example/servicebus/README.md,docs/architecture/service-bus-examples.md,docs/operate/service-bus-result-downloads.mdupdated with the new fields, options, and the no-bearer signed-link clarification. - No IaC change. No new Azure resource. No SAS. Storage stays
publicNetworkAccess: Disabled(bytes stream through theapisidecar).
Validation evidence¶
uv run pytest -q api/tests/test_result_transcode.py— 14 passed (gunzip roundtrip + oversize/non-gzip rejection; tabular/XML → csv/tsv/json; unknown format + oversize input rejected).uv run pytest -q api/tests/test_external_blast_api.py -k download— 11 passed (decompress gunzips;format=csvtranscodes a gzip tabular;422body on malformed XML;413body over the cap; a signed-?token=caller — no bearer — can transcode).uv run pytest -q api/tests/test_servicebus_tasks.py— green; theresult_filestest now assertscompressed/media_type.python3 example/servicebus/consume.py --self-test— OK (new_apply_download_options/_adjust_filename+error_messageassertions).- Docs frontmatter guard — OK, 58 navigated pages.
Hardening (post-implementation critique → 5 rounds)¶
A design self-critique surfaced 2 HIGH + 2 Medium findings, each fixed and tested:
- HIGH — silent empty download on
?format=: a non-BLAST / binary / mislabeled gzip file parsed to zero hits and returned a misleading header-only CSV with transcode_result_bytesnow rejects leftover gzip (magic byte), binary (NUL byte), and non-BLAST text (_looks_like_blast_textheuristic) with a422 result_unparseablebody, while still allowing a legitimately empty / zero-hit result through.- HIGH — unbounded
?decompress=1output:gunzip_streamnow bounds total output atDOWNLOAD_DECOMPRESS_MAX_BYTES(default 2 GiB) so a gzip bomb cannot stream unbounded bytes to a token-only caller. - Medium — transform concurrency: the buffer+parse
?format=path is gated by a boundedtransform_slotsemaphore (DOWNLOAD_TRANSFORM_CONCURRENCY, default 4, mirrors the §9 data-plane cap); exhaustion returns503 transform_busy+Retry-After. The streaming?decompresspath is memory-bounded and intentionally ungated. - Medium — gzip mislabel robustness: the route sniffs the gzip magic so a
result whose filename/media-type did not advertise gzip is still decompressed
for
?format=;gunzip_streampasses a non-gzip first chunk through unchanged instead of truncating with azlib.error. - Low — example filename collision:
consume.py --formatappends a uniquefile_idsuffix so two source files cannot overwrite each other.
New env knobs (all default-safe, no redeploy needed to tune):
DOWNLOAD_DECOMPRESS_MAX_BYTES, DOWNLOAD_TRANSFORM_CONCURRENCY. New error code:
transform_busy (503). Full affected sweep after hardening: 254 passed.
Live E2E (deployed gateway)¶
Validated end-to-end against a deployed Container App revision serving the
hardened image, using a real succeeded sharded core_nt job (10 *.out.gz
shard results) through the public download gateway:
- Happy paths (one shard file, real BLAST tabular mislabeled
blast_xml): - default →
200 application/gzip, stored bytes unchanged. ?decompress=1→200 text/plain, gzip inflated on the fly (3.5 KB → 36 KB),.gzdropped from the filename, body starts# BLASTN 2.17.0+.?format=json|csv|tsv→ correct content types + extensions; all three agree on 43 hits (jsontotal== csv rows == tsv rows), CSV header is the BLAST tabular column set.- Error paths return a JSON body, never a partial file:
?format=pdf→422(query-pattern validation detail).- unknown
file_id→404 {code: openapi_http_404}. - no bearer / no token →
401 {detail: "missing bearer token"}.
Also validated locally against real captured BLAST output (e2e_out/, a 546 KB
500-hit XML + 25 KB gzip): XML/gz → json/csv/tsv parse to 500 hits, streaming
gunzip is byte-identical, and binary / leftover-gzip / non-BLAST inputs are all
rejected with ResultParseError.
Out of scope (tracked separately)¶
Producing a format-agnostic archive (BLAST outfmt 11 ASN.1) at job time so the
gateway could re-render to any NCBI format (XML2, JSON2, pairwise text, GenBank)
via blast_formatter — the long-term option from the design discussion — is
tracked as a separate GitHub issue (#77)
(it requires an elastic-blast merge-step change and extra storage, unlike this
gateway-only work).