Skip to content

Commit 1ae89b1

Browse files
committed
Implement multipart copy for s3
1 parent fc41131 commit 1ae89b1

10 files changed

Lines changed: 365 additions & 35 deletions

File tree

.github/scripts/s3/run-integration-aws-iam.sh

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ pushd "${repo_root}" > /dev/null
4949
--function-name "${lambda_function_name}" \
5050
--zip-file fileb://payload.zip \
5151
--role "${iam_role_arn}" \
52-
--timeout 300 \
52+
--timeout 600 \
53+
--memory-size 512 \
5354
--handler lambda_function.test_runner_handler \
5455
--runtime python3.9
5556

@@ -58,8 +59,8 @@ pushd "${repo_root}" > /dev/null
5859
tries=0
5960
get_function_status_command="aws lambda get-function --region ${region_name} --function-name ${lambda_function_name}"
6061
function_status=$(${get_function_status_command})
61-
while [[ ( $(echo "${function_status}" | jq -r ".Configuration.State") != "Active" ) && ( $tries -ne 5 ) ]] ; do
62-
sleep 2
62+
while [[ ( $(echo "${function_status}" | jq -r ".Configuration.State") != "Active" ) && ( $tries -ne 15 ) ]] ; do
63+
sleep 3
6364
echo "Checking for function readiness; attempt: $tries"
6465
tries=$((tries + 1))
6566
function_status=$(${get_function_status_command})

s3/README.md

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,25 @@ The S3 client requires a JSON configuration file with the following structure:
1212

1313
``` json
1414
{
15-
"bucket_name": "<string> (required)",
16-
"credentials_source": "<string> [static|env_or_profile|none]",
17-
"access_key_id": "<string> (required if credentials_source = 'static')",
18-
"secret_access_key": "<string> (required if credentials_source = 'static')",
19-
"region": "<string> (optional - default: 'us-east-1')",
20-
"host": "<string> (optional)",
21-
"port": <int> (optional),
22-
"ssl_verify_peer": <bool> (optional - default: true),
23-
"use_ssl": <bool> (optional - default: true),
24-
"signature_version": "<string> (optional)",
25-
"server_side_encryption": "<string> (optional)",
26-
"sse_kms_key_id": "<string> (optional)",
27-
"multipart_upload": <bool> (optional - default: true),
28-
"download_concurrency": <int> (optional - default: 5),
29-
"download_part_size": <int64> (optional - default: 5242880), # 5 MB
30-
"upload_concurrency": <int> (optional - default: 5),
31-
"upload_part_size": <int64> (optional - default: 5242880) # 5 MB
15+
"bucket_name": "<string> (required)",
16+
"credentials_source": "<string> [static|env_or_profile|none]",
17+
"access_key_id": "<string> (required if credentials_source = 'static')",
18+
"secret_access_key": "<string> (required if credentials_source = 'static')",
19+
"region": "<string> (optional - default: 'us-east-1')",
20+
"host": "<string> (optional)",
21+
"port": <int> (optional),
22+
"ssl_verify_peer": <bool> (optional - default: true),
23+
"use_ssl": <bool> (optional - default: true),
24+
"signature_version": "<string> (optional)",
25+
"server_side_encryption": "<string> (optional)",
26+
"sse_kms_key_id": "<string> (optional)",
27+
"multipart_upload": <bool> (optional - default: true),
28+
"download_concurrency": <int> (optional - default: 5),
29+
"download_part_size": <int64> (optional - default: 5242880), # 5 MB
30+
"upload_concurrency": <int> (optional - default: 5),
31+
"upload_part_size": <int64> (optional - default: 5242880) # 5 MB
32+
"multipart_copy_threshold": <int64> (optional - default: 5368709120) # default 5 GB
33+
"multipart_copy_part_size": <int64> (optional - default: 104857600) # default 100 MB - must be at least 5 MB
3234
}
3335
```
3436
> Note: **multipart_upload** is not supported by Google - it's automatically set to false by parsing the provided 'host'

s3/client/aws_s3_blobstore.go

Lines changed: 144 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ var oneTB = int64(1000 * 1024 * 1024 * 1024)
2929
const (
3030
defaultTransferConcurrency = 5
3131
defaultTransferPartSize = int64(5 * 1024 * 1024) // 5 MB
32+
// For copy operations: use multipart copy only when necessary (>5GB)
33+
// AWS CopyObject limit is 5GB, use 100MB parts for multipart copy
34+
defaultMultipartCopyThreshold = int64(5 * 1024 * 1024 * 1024) // 5 GB
35+
defaultMultipartCopyPartSize = int64(100 * 1024 * 1024) // 100 MB
3236
)
3337

3438
// awsS3Client encapsulates AWS S3 blobstore interactions
@@ -274,29 +278,159 @@ func (b *awsS3Client) EnsureStorageExists() error {
274278
}
275279

276280
func (b *awsS3Client) Copy(srcBlob string, dstBlob string) error {
277-
slog.Info("Copying object within s3 bucket", "bucket", b.s3cliConfig.BucketName, "source_blob", srcBlob, "destination_blob", dstBlob)
281+
cfg := b.s3cliConfig
278282

279-
copySource := fmt.Sprintf("%s/%s", b.s3cliConfig.BucketName, *b.key(srcBlob))
283+
copyThreshold := defaultMultipartCopyThreshold
284+
if cfg.MultipartCopyThreshold > 0 {
285+
copyThreshold = cfg.MultipartCopyThreshold
286+
}
287+
copyPartSize := defaultMultipartCopyPartSize
288+
if cfg.MultipartCopyPartSize > 0 {
289+
copyPartSize = cfg.MultipartCopyPartSize
290+
}
280291

281-
_, err := b.s3Client.CopyObject(context.TODO(), &s3.CopyObjectInput{
282-
Bucket: aws.String(b.s3cliConfig.BucketName),
292+
headOutput, err := b.s3Client.HeadObject(context.TODO(), &s3.HeadObjectInput{
293+
Bucket: aws.String(cfg.BucketName),
294+
Key: b.key(srcBlob),
295+
})
296+
if err != nil {
297+
return fmt.Errorf("failed to get object metadata: %w", err)
298+
}
299+
if headOutput.ContentLength == nil {
300+
return errors.New("unable to determine object content length from S3 metadata")
301+
}
302+
303+
objectSize := *headOutput.ContentLength
304+
copySource := fmt.Sprintf("%s/%s", cfg.BucketName, *b.key(srcBlob))
305+
306+
// Use simple copy if file is below threshold or is empty
307+
if objectSize < copyThreshold {
308+
slog.Info("Copying object", "source", srcBlob, "destination", dstBlob, "size", objectSize)
309+
return b.simpleCopy(copySource, dstBlob)
310+
}
311+
312+
// For large files, try multipart copy first (works for AWS, MinIO, Ceph, AliCloud)
313+
// Fall back to simple copy if provider doesn't support UploadPartCopy (e.g., GCS)
314+
slog.Info("Copying large object using multipart copy", "source", srcBlob, "destination", dstBlob, "size", objectSize)
315+
316+
err = b.multipartCopy(copySource, dstBlob, objectSize, copyPartSize)
317+
if err != nil {
318+
var apiErr smithy.APIError
319+
if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NotImplemented" {
320+
slog.Info("Multipart copy not supported by provider, falling back to simple copy", "source", srcBlob, "destination", dstBlob)
321+
return b.simpleCopy(copySource, dstBlob)
322+
}
323+
return err
324+
}
325+
326+
return nil
327+
}
328+
329+
// simpleCopy performs a single CopyObject request
330+
func (b *awsS3Client) simpleCopy(copySource string, dstBlob string) error {
331+
cfg := b.s3cliConfig
332+
333+
copyInput := &s3.CopyObjectInput{
334+
Bucket: aws.String(cfg.BucketName),
283335
CopySource: aws.String(copySource),
284336
Key: b.key(dstBlob),
285-
})
337+
}
338+
if cfg.ServerSideEncryption != "" {
339+
copyInput.ServerSideEncryption = types.ServerSideEncryption(cfg.ServerSideEncryption)
340+
}
341+
if cfg.SSEKMSKeyID != "" {
342+
copyInput.SSEKMSKeyId = aws.String(cfg.SSEKMSKeyID)
343+
}
344+
345+
_, err := b.s3Client.CopyObject(context.TODO(), copyInput)
286346
if err != nil {
287347
return fmt.Errorf("failed to copy object: %w", err)
288348
}
349+
return nil
350+
}
289351

290-
waiter := s3.NewObjectExistsWaiter(b.s3Client)
291-
err = waiter.Wait(context.TODO(), &s3.HeadObjectInput{
292-
Bucket: aws.String(b.s3cliConfig.BucketName),
352+
// multipartCopy performs a multipart copy using CreateMultipartUpload, UploadPartCopy, and CompleteMultipartUpload
353+
func (b *awsS3Client) multipartCopy(copySource string, dstBlob string, objectSize int64, copyPartSize int64) error {
354+
cfg := b.s3cliConfig
355+
// Calculate number of parts using ceiling division (avoids floating-point arithmetic).
356+
// Example: objectSize=550MB, partSize=100MB => (550 + 100 - 1) / 100 = 6 parts
357+
numParts := int((objectSize + copyPartSize - 1) / copyPartSize)
358+
359+
createInput := &s3.CreateMultipartUploadInput{
360+
Bucket: aws.String(cfg.BucketName),
293361
Key: b.key(dstBlob),
294-
}, 15*time.Minute)
362+
}
363+
if cfg.ServerSideEncryption != "" {
364+
createInput.ServerSideEncryption = types.ServerSideEncryption(cfg.ServerSideEncryption)
365+
}
366+
if cfg.SSEKMSKeyID != "" {
367+
createInput.SSEKMSKeyId = aws.String(cfg.SSEKMSKeyID)
368+
}
369+
370+
createOutput, err := b.s3Client.CreateMultipartUpload(context.TODO(), createInput)
371+
if err != nil {
372+
return fmt.Errorf("failed to create multipart upload: %w", err)
373+
}
374+
375+
uploadID := *createOutput.UploadId
376+
377+
var completed bool
378+
defer func() {
379+
if !completed {
380+
_, err := b.s3Client.AbortMultipartUpload(context.TODO(), &s3.AbortMultipartUploadInput{
381+
Bucket: aws.String(cfg.BucketName),
382+
Key: b.key(dstBlob),
383+
UploadId: aws.String(uploadID),
384+
})
385+
if err != nil {
386+
slog.Warn("Failed to abort multipart upload", "uploadId", uploadID, "error", err)
387+
}
388+
}
389+
}()
390+
391+
completedParts := make([]types.CompletedPart, 0, numParts)
392+
for i := 0; i < numParts; i++ {
393+
partNumber := int32(i + 1)
394+
start := int64(i) * copyPartSize
395+
end := start + copyPartSize - 1
396+
if end >= objectSize {
397+
end = objectSize - 1
398+
}
399+
byteRange := fmt.Sprintf("bytes=%d-%d", start, end)
400+
401+
output, err := b.s3Client.UploadPartCopy(context.TODO(), &s3.UploadPartCopyInput{
402+
Bucket: aws.String(cfg.BucketName),
403+
CopySource: aws.String(copySource),
404+
CopySourceRange: aws.String(byteRange),
405+
Key: b.key(dstBlob),
406+
PartNumber: aws.Int32(partNumber),
407+
UploadId: aws.String(uploadID),
408+
})
409+
if err != nil {
410+
return fmt.Errorf("failed to copy part %d: %w", partNumber, err)
411+
}
295412

413+
completedParts = append(completedParts, types.CompletedPart{
414+
ETag: output.CopyPartResult.ETag,
415+
PartNumber: aws.Int32(partNumber),
416+
})
417+
slog.Debug("Copied part", "part", partNumber, "range", byteRange)
418+
}
419+
420+
_, err = b.s3Client.CompleteMultipartUpload(context.TODO(), &s3.CompleteMultipartUploadInput{
421+
Bucket: aws.String(cfg.BucketName),
422+
Key: b.key(dstBlob),
423+
UploadId: aws.String(uploadID),
424+
MultipartUpload: &types.CompletedMultipartUpload{
425+
Parts: completedParts,
426+
},
427+
})
296428
if err != nil {
297-
return fmt.Errorf("failed waiting for object to exist after copy: %w", err)
429+
return fmt.Errorf("failed to complete multipart upload: %w", err)
298430
}
299431

432+
completed = true
433+
slog.Debug("Multipart copy completed successfully", "parts", numParts)
300434
return nil
301435
}
302436

s3/config/config.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,19 @@ type S3Cli struct {
3333
// Optional knobs to tune transfer performance.
3434
// If zero, the client will apply sensible defaults (handled by the S3 client layer).
3535
// Part size values are provided in bytes.
36-
DownloadConcurrency int `json:"download_concurrency"`
37-
DownloadPartSize int64 `json:"download_part_size"`
38-
UploadConcurrency int `json:"upload_concurrency"`
39-
UploadPartSize int64 `json:"upload_part_size"`
40-
}
36+
DownloadConcurrency int `json:"download_concurrency"`
37+
DownloadPartSize int64 `json:"download_part_size"`
38+
UploadConcurrency int `json:"upload_concurrency"`
39+
UploadPartSize int64 `json:"upload_part_size"`
40+
MultipartCopyThreshold int64 `json:"multipart_copy_threshold"` // Default: 5GB - files larger than this use multipart copy
41+
MultipartCopyPartSize int64 `json:"multipart_copy_part_size"` // Default: 100MB - size of each part in multipart copy
42+
}
43+
44+
const (
45+
// multipartCopyMinPartSize is the AWS minimum part size for multipart operations.
46+
// Other providers may have different limits - users should consult their provider's documentation.
47+
multipartCopyMinPartSize = 5 * 1024 * 1024 // 5MB - AWS minimum part size
48+
)
4149

4250
const defaultAWSRegion = "us-east-1" //nolint:unused
4351

@@ -98,6 +106,19 @@ func NewFromReader(reader io.Reader) (S3Cli, error) {
98106
return S3Cli{}, errors.New("download/upload concurrency and part sizes must be non-negative")
99107
}
100108

109+
// Validate multipart copy settings (0 means "use defaults")
110+
// Note: Default threshold is 5GB (AWS limit), but users can configure higher values for providers
111+
// that support larger simple copies (e.g., GCS has no limit). Users should consult their provider's documentation.
112+
if c.MultipartCopyThreshold < 0 {
113+
return S3Cli{}, errors.New("multipart_copy_threshold must be non-negative (0 means use default)")
114+
}
115+
if c.MultipartCopyPartSize < 0 {
116+
return S3Cli{}, errors.New("multipart_copy_part_size must be non-negative (0 means use default)")
117+
}
118+
if c.MultipartCopyPartSize > 0 && c.MultipartCopyPartSize < multipartCopyMinPartSize {
119+
return S3Cli{}, fmt.Errorf("multipart_copy_part_size must be at least %d bytes (5MB - AWS minimum)", multipartCopyMinPartSize)
120+
}
121+
101122
switch c.CredentialsSource {
102123
case StaticCredentialsSource:
103124
if c.AccessKeyID == "" || c.SecretAccessKey == "" {

s3/config/config_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,93 @@ var _ = Describe("BlobstoreClient configuration", func() {
327327
_, err = config.NewFromReader(dummyJSONReader)
328328
Expect(err).To(MatchError("download/upload concurrency and part sizes must be non-negative"))
329329
})
330+
331+
Describe("multipart copy tuning fields", func() {
332+
It("rejects negative multipart copy threshold", func() {
333+
dummyJSONBytes := []byte(`{
334+
"access_key_id":"id",
335+
"secret_access_key":"key",
336+
"bucket_name":"some-bucket",
337+
"multipart_copy_threshold": -1
338+
}`)
339+
dummyJSONReader := bytes.NewReader(dummyJSONBytes)
340+
341+
_, err := config.NewFromReader(dummyJSONReader)
342+
Expect(err).To(MatchError("multipart_copy_threshold must be non-negative (0 means use default)"))
343+
})
344+
345+
It("rejects negative multipart copy part size", func() {
346+
dummyJSONBytes := []byte(`{
347+
"access_key_id":"id",
348+
"secret_access_key":"key",
349+
"bucket_name":"some-bucket",
350+
"multipart_copy_part_size": -1
351+
}`)
352+
dummyJSONReader := bytes.NewReader(dummyJSONBytes)
353+
354+
_, err := config.NewFromReader(dummyJSONReader)
355+
Expect(err).To(MatchError("multipart_copy_part_size must be non-negative (0 means use default)"))
356+
})
357+
358+
It("rejects multipart copy part size below AWS minimum", func() {
359+
dummyJSONBytes := []byte(`{
360+
"access_key_id":"id",
361+
"secret_access_key":"key",
362+
"bucket_name":"some-bucket",
363+
"multipart_copy_part_size": 1048576
364+
}`)
365+
dummyJSONReader := bytes.NewReader(dummyJSONBytes)
366+
367+
_, err := config.NewFromReader(dummyJSONReader)
368+
Expect(err).To(MatchError("multipart_copy_part_size must be at least 5242880 bytes (5MB - AWS minimum)"))
369+
})
370+
371+
It("accepts zero values (use defaults)", func() {
372+
dummyJSONBytes := []byte(`{
373+
"access_key_id":"id",
374+
"secret_access_key":"key",
375+
"bucket_name":"some-bucket",
376+
"multipart_copy_threshold": 0,
377+
"multipart_copy_part_size": 0
378+
}`)
379+
dummyJSONReader := bytes.NewReader(dummyJSONBytes)
380+
381+
c, err := config.NewFromReader(dummyJSONReader)
382+
Expect(err).ToNot(HaveOccurred())
383+
Expect(c.MultipartCopyThreshold).To(Equal(int64(0)))
384+
Expect(c.MultipartCopyPartSize).To(Equal(int64(0)))
385+
})
386+
387+
It("accepts valid custom values", func() {
388+
dummyJSONBytes := []byte(`{
389+
"access_key_id":"id",
390+
"secret_access_key":"key",
391+
"bucket_name":"some-bucket",
392+
"multipart_copy_threshold": 1073741824,
393+
"multipart_copy_part_size": 104857600
394+
}`)
395+
dummyJSONReader := bytes.NewReader(dummyJSONBytes)
396+
397+
c, err := config.NewFromReader(dummyJSONReader)
398+
Expect(err).ToNot(HaveOccurred())
399+
Expect(c.MultipartCopyThreshold).To(Equal(int64(1073741824))) // 1GB
400+
Expect(c.MultipartCopyPartSize).To(Equal(int64(104857600))) // 100MB
401+
})
402+
403+
It("accepts threshold above AWS limit for providers with higher limits", func() {
404+
dummyJSONBytes := []byte(`{
405+
"access_key_id":"id",
406+
"secret_access_key":"key",
407+
"bucket_name":"some-bucket",
408+
"multipart_copy_threshold": 10737418240
409+
}`)
410+
dummyJSONReader := bytes.NewReader(dummyJSONBytes)
411+
412+
c, err := config.NewFromReader(dummyJSONReader)
413+
Expect(err).ToNot(HaveOccurred())
414+
Expect(c.MultipartCopyThreshold).To(Equal(int64(10737418240))) // 10GB
415+
})
416+
})
330417
})
331418

332419
Describe("returning the S3 endpoint", func() {

0 commit comments

Comments
 (0)