fix(hub): stream large whole-package zip to R2 instead of buffering (502 OOM) - #1850
fix(hub): stream large whole-package zip to R2 instead of buffering (502 OOM)#1850github-actions[bot] wants to merge 1 commit into
Conversation
…502 OOM)
The Agent Hub worker buffered every upload in memory (request.formData() +
arrayBuffer()), so the 177 MB whole-package zip needed ~350 MB working set —
past Cloudflare's 128 MB per-isolate limit. The isolate was OOM-killed and
Cloudflare returned 502, skipping the npm publish and website redeploy that run
after it.
Add an application/octet-stream publish path that streams the raw body straight
to R2 (env.BUCKET.put(key, request.body, { sha256 })) with metadata in x-gaia-*
headers; R2 verifies the client SHA-256 as it streams. The proven multipart path
for the smaller per-platform binaries is unchanged. The client streams the zip
(data=file handle) and hashes it in chunks, so neither side buffers the whole file.
Closes #1848
|
You're right — #1849 and this PR are duplicates. Both fix #1848 the same way (route the whole-package zip to a raw Worth noting before you close: neither has actually landed the fix yet, so make sure the one you keep gets merged. #1849 is the slightly fuller version — it carries a couple more streaming tests and a "follow-ups" note (chunked idempotency re-check, presigned direct-to-R2 escape hatch). If you go with #1849, closing this autofix PR is the clean move. I can't close PRs myself — a maintainer will need to do that. 🔍 Technical detailsBoth diffs touch the same surface:
Minor divergence worth a glance when you pick: this PR returns |
|
Closing based on comments above. |
The email-agent v0.2.1 release died at the "Assemble + publish the whole-package zip" step with
502 Bad Gatewayfrom Cloudflare, which skipped the npm publish and website redeploy that run after it — leaving the release half-done (per-platform binaries on the Hub, no npm package, no catalog update). The Agent Hub worker buffered the entire upload in memory (request.formData()→arrayBuffer()), so the 177 MB whole-package zip (added in #1843) needed ~350 MB working set, far past Cloudflare's hard 128 MB per-isolate limit — the isolate was OOM-killed before the worker's own size guard could even run. After this change the large zip is streamed straight to R2 with no buffering on either side, so it stays well under the memory ceiling and the release completes end-to-end.Closes #1848
The existing multipart path for the ~40 MB per-platform binaries (which stay under the limit and carry README/CHANGELOG) is left byte-identical; only a new
application/octet-streambranch is added. R2 verifies the client-supplied SHA-256 as it streams, so integrity is preserved and a mismatch fails loudly (422, nothing committed) — no silent fallback.Test plan
cd workers/agent-hub && npm run typecheck && npm test— 74 pass (10 new streaming-path tests incl. SHA mismatch → 422, up-front Content-Length 413, auth/immutability/traversal still enforced)python -m pytest hub/agents/python/email/tests/test_publish_to_r2.py -q— 4 pass (zip→octet-stream headers, binary→multipart, chunked SHA, fail-loud integrity)wrangler devlocally, stream a ~180 MB file via the new octet-stream path → expect201, object stored, no OOM🔍 Technical details
Root cause.
workers/agent-hub/src/publish.tsdidrequest.formData()thennew Uint8Array(await artifactFile.arrayBuffer())— buffering the whole body. The 250 MiBMAX_ARTIFACT_BYTESguard runs after buffering, so it never fired; the OOM happened during buffering. The502(not413) confirms the body cleared Cloudflare's edge and the isolate died mid-request.Worker (
src/publish.ts).handlePublishnow dispatches onContent-Type:multipart/form-data→handleMultipartPublish(the original logic, unchanged — server-computed SHA-256, README/CHANGELOG parts).application/octet-stream→ newhandleStreamingPublish: metadata fromx-gaia-manifest-b64/x-gaia-artifact-filename/x-gaia-artifact-sha256/ optionalx-gaia-package-files-b64headers; size limit enforced up front fromContent-Length(over →413); body stored viaenv.BUCKET.put(key, request.body, { sha256 })so R2 verifies the client digest as it streams (mismatch caught →422 integrity_mismatch, nothing stored). Auth, manifest scope, filename validation, and per-filename immutability all still run (immutabilityhead()checked before consuming the body so an idempotent re-run409s without uploading).finalizeAndRespondboth paths call, so the catalog contract is identical regardless of encoding.Client (
packaging/publish_to_r2.py).publish_onebranches on.zip: per-platform binaries keep using_post_multipart; the whole-package zip uses_post_streaming, which passes the open file handle asdata=(requests streams it and setsContent-Length) with thex-gaia-*metadata headers._sha256_filenow hashes in 1 MiB chunks, so the client never fully buffers the file either. The201/409integrity verification is unchanged and shared by both paths.Docs. Worker
README.mdgains an "Upload encodings" section documenting both paths and thex-gaia-*header contract; the packagingREADME.mdline forpublish_to_r2.pyis corrected (server-side checksum now describes only the multipart path).Tests.
test/publish.test.tsadds a streaming suite via a newstreamingPublishRequesthelper (Web-globals only —crypto.subtle/btoa— to satisfy the worker tsconfig);fake-r2.tsnow consumes aReadableStreamputvalue and already honoredoptions.sha256for the mismatch test.test_publish_to_r2.pyfollows the existingimportlib-by-path pattern used bytest_gen_binaries_lock.py.