Skip to content

S3 Transfer Manager v2: Options.MultipartUploadThreshold is defined but never referenced in upload decision logic #3333

Description

@pikatenor

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

  • Select this option if this issue appears to be a regression.

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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugThis issue is a bug.p2This is a standard priority issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions