Implement LFS multipart upload support with S3#163
Conversation
- Add SignMultipartPutter interface for multipart uploads - Implement S3 multipart upload operations (initiate, complete, abort) - Add transfer protocol negotiation logic for multipart - Update batch API to support multipart transfer mode - Add multipart-specific action types (parts, verify, abort) - Implement verify endpoint with params support for ETags - Add abort endpoint for multipart uploads - Files >= 100MB use multipart upload with 100MB parts - Follows Git LFS multipart transfer proposal spec Agent-Logs-Url: https://github.com/matrixhub-ai/hfd/sessions/5bcefe7e-2ca2-403b-8902-7272ea90f018 Co-authored-by: wzshiming <6565744+wzshiming@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds Git LFS multipart upload support for S3-backed storage, exposing multipart-specific batch actions and new verify/abort endpoints to complete or cancel multipart uploads.
Changes:
- Introduces multipart-capable storage abstractions (
SignMultipartPutter, multipart structs) and implements them for S3. - Extends the LFS batch API to negotiate a transfer protocol and emit multipart actions (
parts,verify,abort) for uploads. - Adds HTTP endpoints to verify (complete) and abort multipart uploads.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| pkg/lfs/storage.go | Adds multipart upload interfaces/types for storage backends. |
| pkg/lfs/s3_storage.go | Implements S3 multipart initiation, completion, and abort using presigned URLs. |
| pkg/backend/lfs/handler.go | Registers new multipart verify/abort routes. |
| pkg/backend/lfs/handler_git_lfs.go | Negotiates transfer type; emits multipart actions; adds multipart verify/abort handlers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| partNumbers := make([]int, len(multipartUpload.Parts)) | ||
| for i, part := range multipartUpload.Parts { | ||
| partNumbers[i] = part.PartNumber | ||
| } | ||
|
|
There was a problem hiding this comment.
partNumbers is computed but never used. This will fail to compile in Go due to an unused local variable. Either remove it or include it in the response (e.g., if clients need explicit part numbers).
| partNumbers := make([]int, len(multipartUpload.Parts)) | |
| for i, part := range multipartUpload.Parts { | |
| partNumbers[i] = part.PartNumber | |
| } |
| multipartUpload, err := multipartSigner.SignMultipartPut(rv.Oid, rv.Size) | ||
| if err != nil { | ||
| slog.ErrorContext(ctx, "failed to initiate multipart upload", "oid", rv.Oid, "error", err) | ||
| // Fallback to basic transfer | ||
| rep.Actions["upload"] = h.createBasicUploadAction(ctx, rv, user) | ||
| } else { |
There was a problem hiding this comment.
When multipart initiation fails, the code falls back to adding only the basic upload action but does not add the corresponding verify action. LFS clients expect both for basic uploads; returning only upload will break the upload flow.
| } | ||
| } else { | ||
| // Storage doesn't support multipart, fallback to basic | ||
| rep.Actions["upload"] = h.createBasicUploadAction(ctx, rv, user) |
There was a problem hiding this comment.
If the storage does not support multipart, this branch falls back to adding only the basic upload action but never adds the basic verify action. That leaves the object without a verify endpoint and will break basic uploads for large objects when transfer was negotiated as multipart.
| rep.Actions["upload"] = h.createBasicUploadAction(ctx, rv, user) | |
| rep.Actions["upload"] = h.createBasicUploadAction(ctx, rv, user) | |
| // Also provide a verify action, same as for basic transfer | |
| verifyHeader := make(map[string]string) | |
| verifyLink := rv.verifyLink() | |
| if h.tokenSignValidator != nil { | |
| if token, err := h.tokenSignValidator.Sign(ctx, http.MethodPost, verifyLink, user.User, tokenExpiration); err != nil { | |
| slog.WarnContext(ctx, "failed to sign token for LFS verify link", "oid", rv.Oid, "error", err) | |
| } else if token != "" { | |
| verifyHeader["Authorization"] = "Bearer " + token | |
| } | |
| } else if len(rv.Authorization) > 0 { | |
| verifyHeader["Authorization"] = rv.Authorization | |
| } | |
| rep.Actions["verify"] = &lfsLink{Href: verifyLink, Header: verifyHeader} |
| // negotiateTransfer determines which transfer protocol to use based on client request and server capabilities. | ||
| func negotiateTransfer(requestedTransfers []string, operation string, storage lfs.Storage) string { | ||
| // Default to basic if no transfers requested | ||
| if len(requestedTransfers) == 0 { | ||
| return "basic" | ||
| } | ||
|
|
||
| // Check if multipart is supported by storage | ||
| _, supportsMultipart := storage.(lfs.SignMultipartPutter) | ||
|
|
||
| // Only use multipart for uploads | ||
| if operation == "upload" && supportsMultipart { | ||
| // Check if client requested multipart | ||
| for _, t := range requestedTransfers { | ||
| if t == "multipart" { | ||
| return "multipart" | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Fallback to basic if supported | ||
| for _, t := range requestedTransfers { | ||
| if t == "basic" { | ||
| return "basic" | ||
| } | ||
| } | ||
|
|
||
| // Default to basic | ||
| return "basic" | ||
| } |
There was a problem hiding this comment.
negotiateTransfer() selects a single batch-level transfer without considering per-object behavior (size threshold and multipart initiation failures). As written, the batch response can advertise transfer: multipart while some objects return only basic upload/verify actions, which can break clients because the transfer adapter is chosen from the batch-level transfer field. Consider negotiating multipart only when it can be used consistently for all objects in the batch (or always return multipart-style actions when transfer is multipart, even for smaller objects).
| var req struct { | ||
| Oid string `json:"oid"` | ||
| Size int64 `json:"size"` | ||
| Params map[string]any `json:"params"` | ||
| } |
There was a problem hiding this comment.
The multipart verify endpoint expects oid and size as top-level JSON fields, while upload_id/part_etags are nested under params. However, the batch response’s multipart verify action publishes params that include oid and size, which may lead clients to send them under params instead of top-level and trigger the OID/size validation failure. Align the advertised actions.verify.params shape with what handleMultipartVerify actually decodes (or make the handler accept oid/size from params).
| result, err := s.s3.CreateMultipartUpload(createInput) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to initiate multipart upload: %w", err) | ||
| } |
There was a problem hiding this comment.
uploadID := *result.UploadId dereferences a pointer returned by the AWS SDK without a nil check. If the SDK ever returns a response missing UploadId, this will panic. Prefer validating result/result.UploadId and returning a descriptive error instead of panicking.
| } | |
| } | |
| if result == nil || result.UploadId == nil { | |
| return nil, fmt.Errorf("failed to initiate multipart upload: missing UploadId in response") | |
| } |
| // Sort by part number (S3 requires this) | ||
| // Using a simple bubble sort since parts are usually small | ||
| for i := 0; i < len(completedParts); i++ { | ||
| for j := i + 1; j < len(completedParts); j++ { | ||
| if *completedParts[i].PartNumber > *completedParts[j].PartNumber { | ||
| completedParts[i], completedParts[j] = completedParts[j], completedParts[i] | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Sorting completedParts with a nested loop is O(n²) and can become very expensive with large multipart uploads (up to 10,000 parts). Use sort.Slice (or similar) to sort by PartNumber in O(n log n).
7ce99ae to
73bc92d
Compare
Implements the Git LFS multipart transfer mode proposal for efficient large file uploads to S3 storage.
Changes
Storage interface: Added
SignMultipartPutterinterface with methods for initiating, completing, and aborting multipart uploads. AddedMultipartPartandMultipartUploadstructs.S3 implementation: Implemented multipart operations in
s3Storage:SignMultipartPut()- Initiates upload, splits files into 100MB parts, generates presigned URLsCompleteMultipartUpload()- Finalizes upload with part ETags from clientAbortMultipartUpload()- Cancels upload and cleans up partsTransfer negotiation: Added
negotiateTransfer()to select between "basic" and "multipart" protocols based on:Batch API: Updated
lfsRepresent()to generate multipart-specific actions:parts[]- Array of presigned part URLs with position/sizeverify- Completion endpoint withupload_idandpart_etagsin paramsabort- Cancellation endpointEndpoints: Added
/objects/{oid}/multipart/verifyand/objects/{oid}/multipart/aborthandlersProtocol Flow
Files <100MB continue using basic transfer. Falls back to basic if storage doesn't support multipart.