Skip to content

blob: add multipart uploads - #3769

Closed
TuSKan wants to merge 2 commits into
google:masterfrom
TuSKan:blob-multipart
Closed

blob: add multipart uploads#3769
TuSKan wants to merge 2 commits into
google:masterfrom
TuSKan:blob-multipart

Conversation

@TuSKan

@TuSKan TuSKan commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Adds multipart upload to blob.

S3, GCS and Azure can all assemble one object from parts uploaded in any order, concurrently, or from separate processes. blob.Writer cannot express that — it is a single sequential stream owned by one goroutine — so today the only portable way to write a large object is to stream it whole, and callers who need the native behaviour drop out of the portable API into As.

API

u, err := b.NewMultipartUploader(ctx, key, nil)
part, err := u.UploadPart(ctx, blob.UploaderPart{Number: 1}, r)
err = u.Commit(ctx, parts)   // or u.Abort(ctx)
  • Parts are numbered from 1 and assembled in ascending order regardless of the order they were uploaded in.
  • UploadPart returns the part value Commit needs; the caller collects them.
  • Nothing is readable at the key until Commit.
  • UploadID is durable, so OpenMultipartUploader resumes an upload started in another process.
  • MultipartUploaderOptions mirrors WriterOptions (content type, cache control, metadata, ContentCRC32C, BeforeUpload), minus the content-type sniffing, which a multipart upload cannot do.

Compatibility

Driver support is optional. blob.Bucket type-asserts driver.MultipartUploaderBucket and returns Unimplemented when the driver doesn't provide it, so no existing driver — in this repo or outside it — has to change. The prefixed and single-key wrappers forward the interface, so blob.PrefixedBucket and ?prefix= URLs keep multipart rather than reporting it unsupported.

Drivers

s3blob uses S3's native multipart upload. azureblob uses block blobs (StageBlock / CommitBlockList), gcsblob composes objects, and fileblob and memblob assemble the object themselves — fileblob by renaming the assembled file into place, so Commit stays atomic.

Testing

drivertest covers out-of-order upload, abort, resume, and that the committed object carries the content type, cache control and metadata it was created with. memblob and fileblob run it for real, including through the prefixed wrapper, and need no recordings. There's a runnable Example on memblob, so the feature is demonstrable without credentials.

The cloud drivers need replay recordings before this can go green. azureblob, gcsblob and s3blob test against recorded HTTP replays, and none exist for the new multipart calls, so the new conformance subtest fails for them:

--- FAIL: TestConformance/TestMultipartUploader
    open testdata\TestConformance\TestMultipartUploader.replay:
    The system cannot find the file specified.

Recording them needs real cloud credentials, which I don't have.

How I'd suggest splitting this

The first commit — core API, memblob, conformance coverage, docs — builds and tests green on its own and needs no recordings. The second adds the four remaining drivers, three of which cannot pass CI until someone with credentials records them.

Happy to reshape along that line: land the core with memblob and fileblob (both fully tested), and let azureblob, gcsblob and s3blob follow in a separate PR once recordings exist. Or leave it as-is if you'd rather record them and take it in one go. Let me know which you prefer and I'll push it.

Worth arguing about

The exported surface: Bucket gains two methods and the package gains four types, and those are hard to change once released even at v0.x. I'd rather adjust the names and the shape of UploaderPart now than after.

TuSKan added 2 commits August 13, 2026 17:55
Large objects on S3, GCS and Azure can be assembled from parts uploaded
in any order, concurrently, or from separate processes. blob.Writer
cannot express that: it is a single sequential stream owned by one
goroutine. This adds a portable API for it.

    u, err := b.NewMultipartUploader(ctx, key, nil)
    part, err := u.UploadPart(ctx, blob.UploaderPart{Number: 1}, r)
    err = u.Commit(ctx, parts)

Parts are numbered from 1 and assembled in ascending order regardless of
the order they were uploaded in. Nothing is readable at the key until
Commit. UploadID is durable, so OpenMultipartUploader can resume an
upload begun elsewhere.

Driver support is optional. blob.Bucket type-asserts
driver.MultipartUploaderBucket and returns Unimplemented when a driver
does not provide it, so no existing driver -- in this repo or outside
it -- has to change. The prefixed and single-key bucket wrappers forward
the interface, so blob.PrefixedBucket and "?prefix=" URLs keep it.

memblob implements it, which makes the feature demonstrable in a test
and in a runnable example without credentials. drivertest covers
out-of-order upload, abort, resume, and that the committed object
carries the content type, cache control and metadata it was created
with.
… s3blob

s3blob maps onto S3's native multipart upload: CreateMultipartUpload,
UploadPart, CompleteMultipartUpload. Part numbers are validated against
S3's documented 1-10000 range, and OpenMultipartUploader confirms the
upload exists with ListParts so a mistyped or expired upload ID fails
where it was passed rather than much later at Commit.

azureblob uses block blobs: StageBlock per part, CommitBlockList to
assemble. gcsblob composes objects. fileblob writes parts as sidecar
files and concatenates them on commit, renaming the assembled file into
place so nothing is visible at the key until Commit succeeds.

Each driver persists its upload state, so an UploadID outlives the
process that created it and OpenMultipartUploader can resume from
another one.
@vangent

vangent commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Hmm. I don't think this is something we want or need to expose. The drivers should do this automatically.

For example, S3's transfermanager (which s3blob uses) says "UploadObject uploads an object to S3, intelligently buffering large files into smaller chunks and sending them in parallel across multiple goroutines." (https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager@v0.1.2#Options). Some of the behavior is managed by WriterOptions like MaxConcurrency and BufferSize. GCS supports BufferSize, but the driver doesn't (yet) use EnableParallelUpload (https://pkg.go.dev/cloud.google.com/go/storage#hdr-Parallel_Uploads).

@TuSKan

TuSKan commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Fair enough on throughput — you're right, it plainly can, and BufferSize/MaxConcurrency already tune it. s3blob wires them into transfermanager, azureblob into UploadStream.

Three things I'd still like your read on.

gcsblob is the odd one out today. It sets ChunkSize from BufferSize but never reads MaxConcurrency, and doesn't use EnableParallelUpload. So of the three, GCS is the only one not getting the automatic behaviour.

Resumability isn't covered by any of them. transfermanager.Client exposes UploadObject and no upload ID, no resume, no abort handle. If the process dies mid-upload the transfer starts from zero, and the incomplete MPU is orphaned in S3 until a lifecycle rule aborts it. Is that a gap you'd consider in scope for blob, or deliberately out of it?

That last clause is the crux for me. When the driver owns the upload ID, a crash leaves state the caller cannot see, resume, or abort; recovery is only possible out-of-band via lifecycle rules. When the caller owns it, they can persist it and either finish or abort on restart.

So perhaps the real question isn't multipart vs. automatic, but whether blob wants to expose any handle on in-flight upload state at all. If the answer is no, I'm happy to close this. no problem.

@vangent

vangent commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

"gcsblob is the odd one out today" -> yes, let's deal with that explicitly.

"whether blob wants to expose any handle on in-flight upload state" -> I think the answer to that is "no". I'd prefer to keep the simple semantics we have, which seem to be good enough for ~everyone (nobody has asked for this feature in the last few years). Escape hatches exist for users who want to handle the edge cases.

@vangent vangent closed this Aug 14, 2026
TuSKan added a commit to TuSKan/gocloud-ext that referenced this pull request Aug 14, 2026
Assembles one blob from parts uploaded in any order, concurrently, or
from separate processes. blob.Writer cannot express that: drivers do fan
out underneath it -- s3blob via transfermanager, azureblob via
UploadStream -- but the upload belongs to one process and dies with it.
This gives the caller a durable UploadID instead, so an upload can be
started on one machine, written from others, committed by a third, and
resumed after a crash.

The implementation here uses only the public gocloud.dev/blob API:
parts are staged as ordinary objects and Commit streams them, in
ascending part number, into a single Writer at the destination. Because
every read and write goes through the bucket, the driver applies its own
key escaping and any PrefixedBucket wrapping, so a committed object
always lands where blob.Bucket will find it. That covers fileblob,
sftpblob, httpblob over WebDAV and memblob with no backend-specific
code. Its cost is that Commit re-reads and rewrites each part, which is
why native s3mp/gcsmp/azmp packages will follow.

Two decisions come from bugs found in the rejected upstream proposal
(google/go-cloud#3769):

UploadPart takes a part number and nothing else. That PR's UploaderPart
carried an Offset, and fileblob assembled by it while memblob sorted by
Number, so portable code passing only Number silently produced corrupt
bytes. With no offset in the signature the failure is unrepresentable.

The mptest conformance suite passes only what the documented API
requires. Its upstream counterpart always supplied Offset and Size,
which is precisely why it never caught the above. It also asserts that
Parts survive encoding/json, since a Part that cannot cross a process
boundary defeats the purpose of the package.

Options are stored in a manifest object beside the parts, so a process
that resumes an upload commits with the content type and metadata it was
created with rather than whatever the resuming caller happened to pass.

60 conformance subtests pass across memblob, fileblob and a prefixed
bucket, with none skipped. Resume is exercised through an independent
bucket handle on fileblob, which is the cross-process path this package
exists for.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants