feat(resources): the managed-resource upload streams into the multipart uploader, so a large file stores against a backend that bounds a single PUT (#1631) - #1633
Merged
Conversation
…rt uploader, so a large file stores against a backend that bounds a single PUT (#1631) The managed-resource blob write was the one upload path in the platform without multipart. `resource.S3Client` took the whole object as one `[]byte` and issued one `PutObject`, so an S3-compatible backend that bounds a single PUT refused a file the deployment's own `max_upload_bytes` allowed, and the uploader was told the storage backend would not take it with nothing they could do about it. The same file stored as a portal asset, which had always written through the transfer manager. Both write routes now walk the multipart form part by part and hand the file part to `PutObjectStream` as a reader. The object is never assembled: not in one slice, and not in a temporary file. `NewResource.Content` and `RevisionUpload.Content` are `io.Reader`, and the size a record carries is the count the write reported, since a streamed body declares no length. The corrected-CSV write both table revisers make streams for the same reason. Two things follow, and both are the point. The upload path needs no writable temporary directory: `ParseMultipartForm` spooled any part past its memory budget to `os.CreateTemp`, and the published image is built `FROM scratch`, so it has none. And the ceiling stops being resident heap per concurrent upload; it is now a bound on bytes streamed, enforced on the reader, with the refusal naming the deployment's own number. Content detection reads `contenttype.StructuredSniffLen` bytes and re-prepends them with `io.MultiReader`, so a type detected off a stream is the type detected off the bytes. The file part must now be the last part of the form. It is handed to the uploader where the walk finds it, so a part behind it is metadata the route will never read; rather than store the file and drop the field silently, the read fails there, the uploader aborts, and neither an object nor a record survives. Three clients sent it first and were reordered: the portal's upload dialog, `dev/seed-resources.sh`, and #1628's acceptance helper. The replace-content and restore routes answer a storage refusal with 503 and the account the create route gives, rather than a 500 whose message `writeError` truncated at its first colon to the bare fragment "storing revision". The dev stack grows the backend these criteria need. MinIO over TLS holds managed resources, because it is the one backend that bounds a single PUT: the AWS SDK sends `aws-chunked` with a trailing checksum over HTTPS and MinIO caps such a chunk at 16 MiB, while SeaweedFS accepts an unbounded single PUT and auto-chunks. A Hive catalog reads its metastore and its tables through one S3 client, so a second object store means a second scratch catalog: `scratch` over SeaweedFS for portal assets, `scratch_resources` over MinIO for managed resources, with Trino trusting the generated dev certificate through a truststore its own container builds. The platform runs with `TMPDIR` pointing at a path that does not exist, which is the published image's condition. `make verify` passes on this tree. The acceptance suite still owes a clean run for the registrations over managed resources, which have not been re-run since the catalog was renamed, and it carries two failures unrelated to this change, where tests assert contracts #1604 and #1588 replaced (#1632). Claude-Session: https://claude.ai/code/session_01UWUEgrYfSk7t9BgGf68WMi
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1633 +/- ##
==========================================
+ Coverage 90.45% 91.52% +1.07%
==========================================
Files 776 776
Lines 95498 77760 -17738
==========================================
- Hits 86385 71173 -15212
+ Misses 6841 4315 -2526
Partials 2272 2272 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1631
The upload path streams
A managed resource is carried from the request into the multipart uploader and never assembled anywhere.
resource.S3ClientgainsPutObjectStreambeside the[]bytewrite that thumbnails still use,NewResource.ContentandRevisionUpload.Contentareio.Reader, and the size a record carries is the count the write reported — a streamed body declares no length, so what reached storage is the only account of it.Both write routes walk the form with
r.MultipartReader()instead of parsing it. Metadata parts are read into their fields as they arrive; thefilepart is handed to the uploader unread. Content detection bufferscontenttype.StructuredSniffLen(8 KiB) and puts it back in front of the rest withio.MultiReader, so a type detected off a stream is the type detected off the bytes, and the object the backend receives starts at its first byte.Three things follow.
A backend that bounds a single PUT takes the file. MinIO refuses a
PutObjectabove 16 MiB under theaws-chunkedencoding the AWS SDK uses over HTTPS, which is what a 150 MB reference file met aschunk too big: choose chunk size <= 16MiBbehind the message "The storage backend did not accept the file. Nothing was saved." The write is multipart now, so the bound does not apply.No writable temporary directory is needed.
ParseMultipartFormspooled any part past its memory budget toos.CreateTemp(mime/multipart/formdata.go:177). The published image is builtFROM scratch(Dockerfile:6) and holds only the binary and the CA bundle, so that spool could not be created there. A walk spools nothing.max_upload_bytesstops being resident heap. It bounds bytes streamed, enforced on the reader: the read that passes the ceiling fails, the uploader aborts the multipart upload it had begun, and the route answers 400 naming this deployment's number. The request body keeps its separateMaxBytesReaderbackstop at the ceiling plus the multipart framing headroom (#1628).The corrected-CSV write both table revisers make streams for the same reason: a corrected CSV is as large as the file it corrects.
The file part goes last
Both routes read the form in order and stop at the
filepart, because that part is handed to the uploader where it is found. Every other field has to arrive before it. A form that puts a part behind the file is refused withthe file part must be the last part of the form, and because the refusal happens while the file is being read, neither an object nor a record survives it — the alternative was storing the file and dropping the field without a word.The portal's upload dialog,
dev/seed-resources.shand #1628's acceptance helper each sent the file first and now send it last. A script posting toPOST /api/v1/resourcesorPOST /api/v1/resources/{id}/contentappends its file part last.Storage refusals answer the same way on every route
ReviseContentreported a refused write asstoring revision: ..., whichwriteErrortruncates at its first colon, so a storage outage reached the caller as the bare fragment "storing revision" with a 500 beside it. Replace-content and restore now answer 503 with the account the create route gives: nothing was saved, retry. One classification serves all three, and it is what lets the ceiling travel out as a 400 rather than being flattened into a storage failure.The dev stack carries a backend that bounds a single PUT
#1631's criteria cannot be executed against a backend that accepts an unbounded single PUT. SeaweedFS does: probed at 40 MB, and it auto-chunks rather than refusing under
-filer.maxMBand-master.volumeSizeLimitMB. MinIO refuses one, over HTTPS only, because the SDK sends a header checksum over plain HTTP and a trailing one over TLS.So MinIO over TLS holds managed resources, SeaweedFS keeps portal assets, and the certificate is generated into gitignored
dev/.tls/miniobeside the api-test one. The platform trusts it throughAWS_CA_BUNDLE, whichconfig.LoadDefaultConfigreads; Go on darwin ignoresSSL_CERT_FILE, so that is the only way to trust a local certificate without touching the system keychain.A Hive catalog reads its metastore and its tables through one S3 client, and a registration points Trino at the file's own object, so two object stores means two scratch catalogs:
scratchover SeaweedFS,scratch_resourcesover MinIO with its own metastore bucket, reached by connectionsacme-scratchandacme-scratch-resources. Trino trusts the same certificate through a truststore its container builds at start, which is why the certificate namesminioas well as localhost. A deployment has one object store and needs one of these.The platform runs with
TMPDIRpointing at a path that does not exist, so the dev stack runs under the published image's condition rather than passing locally and failing once deployed. Nothing in production Go creates a temporary file.What was executed
Against MinIO, through the real routes:
size_bytesmatchesTMPDIRabsentIn
pkg/resource: a 64 MiB upload allocates under an eighth of the file, which no path holding the object can do; detection off a stream matches detection off the bytes for content longer than the sniff prefix, across four declarations; the ceiling refuses at the boundary between it and the body bound; and a form whose body runs out before the file part is reached is still refused by the size rather than as a form that would not parse.make verifypasses. Patch coverage 171/181 changed lines (94.5%).Not covered
The read path.
GetObjectreturns a[]byteandpkg/blobserveserves from it, so serving a large managed resource still holds it — a real cost with its own blast radius, and out of scope here. The default ceiling stays 100 MB.The acceptance suite carries failures this branch does not fix. Registrations over managed resources have not had a clean run since the catalog was renamed to
scratch_resources, which is dev-stack configuration and reaches no released artifact;build/1546/acceptance.mdrecords that rather than claiming a pass. Separately, two tests assert contracts #1604 and #1588 replaced, tracked in #1632 along with the reason nothing caught them:acceptance-checkverifies a transcript exists, not that the run in it passed, and neither CI nor the release workflow runs the suite.