Acknowledgements
Describe the bug
The MultipartUploadThreshold field in transfermanager.Options is defined and resolved with a default value (16 MiB), but it is never actually referenced in the upload decision logic.
The single-upload vs. multipart-upload decision is solely determined by PartSizeBytes, making MultipartUploadThreshold dead code with no effect on behavior.
Regression Issue
Expected Behavior
When MultipartUploadThreshold is set, files larger than that threshold should be uploaded using multipart upload, even if they are smaller than PartSizeBytes.
In other words, the upload path should be:
objectSize < MultipartUploadThreshold → single upload (PutObject)
objectSize >= MultipartUploadThreshold → multipart upload (CreateMultipartUpload + UploadPart + CompleteMultipartUpload)
Current Behavior
The single-upload vs. multipart-upload decision is made in uploader.nextReader() (api_op_UploadObject.go), which reads up to PartSizeBytes bytes from the body. If the entire body fits within PartSizeBytes, it returns io.EOF, which causes uploader.upload() to call singleUpload().
MultipartUploadThreshold is never consulted anywhere in this flow. Setting it to any value has absolutely no effect.
Relevant code in nextReader():
|
// read first part up to a maximum of PartSize to avoid allocating 8MB buffer out of the gate |
|
r := io.LimitReader(u.in.Body, u.options.PartSizeBytes) |
|
firstPart, err := io.ReadAll(r) |
|
if err != nil { |
|
return nil, 0, func() {}, err |
|
} |
|
n := len(firstPart) |
|
if int64(n) < u.options.PartSizeBytes { |
|
return bytes.NewReader(firstPart), n, func() {}, io.EOF |
|
} |
|
return bytes.NewReader(firstPart), n, func() {}, nil |
Relevant code in upload():
|
r, n, cleanUp, err := u.nextReader(ctx) |
|
|
|
if err == io.EOF { |
|
return u.singleUpload(ctx, r, n, cleanUp, clientOptions...) |
|
} else if err != nil { |
|
cleanUp() |
|
return nil, err |
|
} |
Reproduction Steps
package main
import (
"context"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
func main() {
cfg, _ := config.LoadDefaultConfig(context.Background())
s3Client := s3.NewFromConfig(cfg)
// Set threshold to 1KB — expect multipart upload for anything > 1KB
tm := transfermanager.New(s3Client, func(o *transfermanager.Options) {
o.MultipartUploadThreshold = 1024 // 1 KB
o.PartSizeBytes = 5 * 1024 * 1024 // 5 MB (minimum allowed)
})
// Upload a 100KB object — should use multipart (> 1KB threshold)
// but actually uses single PutObject (< 5MB PartSizeBytes)
body := strings.NewReader(strings.Repeat("x", 100*1024)) // 100 KB
_, err := tm.UploadObject(context.Background(), &transfermanager.UploadObjectInput{
Bucket: aws.String("test-bucket"),
Key: aws.String("test-key"),
Body: body,
}) // Observe via CloudTrail or network trace: PutObject is called, not CreateMultipartUpload
if err != nil {
panic(err)
}
}
Possible Solution
Use min(MultipartUploadThreshold, PartSizeBytes) as the cutoff instead of PartSizeBytes.
func (u *uploader) nextReader(ctx context.Context) (io.Reader, int, func(), error) {
if !u.multipleRead {
u.multipleRead = true
r := io.LimitReader(u.in.Body, u.options.PartSizeBytes)
firstPart, err := io.ReadAll(r)
if err != nil {
return nil, 0, func() {}, err
}
n := len(firstPart)
// Use min(MultipartUploadThreshold, PartSizeBytes) as the cutoff.
// We can only observe up to PartSizeBytes of data here, so the
// threshold is capped to that to avoid silent data truncation.
threshold := u.options.MultipartUploadThreshold
if u.options.PartSizeBytes < threshold {
threshold = u.options.PartSizeBytes
}
if int64(n) < threshold {
return bytes.NewReader(firstPart), n, func() {}, io.EOF // → single upload
}
return bytes.NewReader(firstPart), n, func() {}, nil // → multipart upload
}
// ...
}
- Read size stays
PartSizeBytes → first chunk size is consistent with subsequent parts
upload() is untouched — it still branches on io.EOF from nextReader()
threshold < partSize (e.g. 1KB, 5MB): compares against 1KB, so ≥1KB triggers multipart
threshold > partSize (e.g. 16MB, 8MB): clamped to 8MB, same as current behavior (threshold was dead code anyway)
threshold == partSize: identical to current logic
Additional Information/Context
- With the default values (
PartSizeBytes=8MB, MultipartUploadThreshold=16MB), the effective threshold becomes 8MB. Since the threshold was previously dead code, this does not change any observable behavior.
- According to the specifications, the minimum part size is 5MB, while it also states, "There is no minimum size limit on the last part of your multipart upload." I'm unsure whether it's permissible to be below the minimum size if there is only one part.
AWS Go SDK V2 Module Versions Used
github.com/aws/aws-sdk-go-v2/config v1.32.10
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.5
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1
Compiler and Version used
go version go1.25.5 darwin/arm64
Operating System and version
Darwin 25.2.0 arm64 (macOS)
Acknowledgements
go get -u github.com/aws/aws-sdk-go-v2/...)Describe the bug
The
MultipartUploadThresholdfield intransfermanager.Optionsis defined and resolved with a default value (16 MiB), but it is never actually referenced in the upload decision logic.The single-upload vs. multipart-upload decision is solely determined by
PartSizeBytes, makingMultipartUploadThresholddead code with no effect on behavior.Regression Issue
Expected Behavior
When
MultipartUploadThresholdis set, files larger than that threshold should be uploaded using multipart upload, even if they are smaller thanPartSizeBytes.In other words, the upload path should be:
objectSize < MultipartUploadThreshold→ single upload (PutObject)objectSize >= MultipartUploadThreshold→ multipart upload (CreateMultipartUpload + UploadPart + CompleteMultipartUpload)Current Behavior
The single-upload vs. multipart-upload decision is made in
uploader.nextReader()(api_op_UploadObject.go), which reads up toPartSizeBytesbytes from the body. If the entire body fits withinPartSizeBytes, it returnsio.EOF, which causesuploader.upload()to callsingleUpload().MultipartUploadThresholdis never consulted anywhere in this flow. Setting it to any value has absolutely no effect.Relevant code in
nextReader():aws-sdk-go-v2/feature/s3/transfermanager/api_op_UploadObject.go
Lines 896 to 906 in 2b53e5b
Relevant code in
upload():aws-sdk-go-v2/feature/s3/transfermanager/api_op_UploadObject.go
Lines 817 to 824 in 2b53e5b
Reproduction Steps
Possible Solution
Use
min(MultipartUploadThreshold, PartSizeBytes)as the cutoff instead ofPartSizeBytes.PartSizeBytes→ first chunk size is consistent with subsequent partsupload()is untouched — it still branches onio.EOFfromnextReader()threshold < partSize(e.g. 1KB, 5MB): compares against 1KB, so ≥1KB triggers multipartthreshold > partSize(e.g. 16MB, 8MB): clamped to 8MB, same as current behavior (threshold was dead code anyway)threshold == partSize: identical to current logicAdditional Information/Context
PartSizeBytes=8MB,MultipartUploadThreshold=16MB), the effective threshold becomes 8MB. Since the threshold was previously dead code, this does not change any observable behavior.AWS Go SDK V2 Module Versions Used
Compiler and Version used
go version go1.25.5 darwin/arm64
Operating System and version
Darwin 25.2.0 arm64 (macOS)