@@ -29,6 +29,10 @@ var oneTB = int64(1000 * 1024 * 1024 * 1024)
2929const (
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
276280func (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
0 commit comments