@@ -7,11 +7,14 @@ import (
77 "errors"
88 "fmt"
99 "io"
10+ "os"
1011 "slices"
1112 "sync"
1213 "sync/atomic"
1314
1415 "golang.org/x/sync/errgroup"
16+
17+ "github.com/e2b-dev/infra/packages/shared/pkg/units"
1518)
1619
1720type partUploader interface {
@@ -21,6 +24,55 @@ type partUploader interface {
2124 Close () error
2225}
2326
27+ const (
28+ // cloudMinPartSizeMB is the smallest non-final multipart part both S3 and
29+ // the GCS XML API accept (5 MiB). Smaller configured values would fail with
30+ // EntityTooSmall at CompleteMultipartUpload, after all bytes are shipped.
31+ cloudMinPartSizeMB = 5
32+ // cloudMaxParts bounds the part count; S3 and the GCS XML API cap multipart
33+ // uploads at 10,000 parts. Kept below the hard cap because parts are sized
34+ // by *compressed* bytes and incompressible frames can expand slightly past
35+ // the uncompressed file size this bound is computed from.
36+ cloudMaxParts = 9000
37+ )
38+
39+ // clampCloudMinPartSize resolves the configured minimum part size against the
40+ // multipart limits shared by S3 and the GCS XML API: at least 5 MiB per
41+ // non-final part, and large enough that even an incompressible file of
42+ // fileSize bytes stays under the 10,000-part cap.
43+ func clampCloudMinPartSize (cfg CompressConfig , fileSize int64 ) CompressConfig {
44+ minMB := units .BytesToMB (cfg .MinPartSize ()) // resolves the <= 0 default (50 MB)
45+ cfg .MinPartSizeMB = int (max (minMB , cloudMinPartSizeMB , units .BytesToMB (fileSize )/ cloudMaxParts + 1 ))
46+
47+ return cfg
48+ }
49+
50+ // storeFileCompressed streams localPath through compressStream into a
51+ // provider-specific multipart upload. It owns the shared recipe — open, stat,
52+ // stamp the uncompressed size into the object metadata (multipart APIs only
53+ // accept metadata at initiate time) — so providers supply just the uploader.
54+ func storeFileCompressed (ctx context.Context , localPath string , cfg CompressConfig , maxUploadConcurrency int , putOpts PutOptions , newUploader func (metadata ObjectMetadata ) (partUploader , error )) (* FullFrameTable , [32 ]byte , error ) {
55+ file , err := os .Open (localPath )
56+ if err != nil {
57+ return nil , [32 ]byte {}, fmt .Errorf ("failed to open local file %s: %w" , localPath , err )
58+ }
59+ defer file .Close ()
60+
61+ fi , err := file .Stat ()
62+ if err != nil {
63+ return nil , [32 ]byte {}, fmt .Errorf ("failed to stat local file %s: %w" , localPath , err )
64+ }
65+
66+ cfg = clampCloudMinPartSize (cfg , fi .Size ())
67+
68+ uploader , err := newUploader (putOpts .Metadata .WithUncompressedSize (fi .Size ()))
69+ if err != nil {
70+ return nil , [32 ]byte {}, fmt .Errorf ("failed to create multipart uploader: %w" , err )
71+ }
72+
73+ return compressStream (ctx , file , cfg , uploader , maxUploadConcurrency , putOpts .FrameSink )
74+ }
75+
2476type memPartUploader struct {
2577 mu sync.Mutex
2678 parts map [int ][]byte
@@ -175,6 +227,15 @@ func compressStream(ctx context.Context, in io.Reader, cfg CompressConfig, uploa
175227 return nil , [32 ]byte {}, err
176228 }
177229
230+ // Zero-byte input produces no parts, but S3 and the GCS XML API both
231+ // refuse to complete a multipart upload with zero parts — ship a single
232+ // empty final part so empty files still store successfully.
233+ if len (frameSizes ) == 0 {
234+ if err := uploader .UploadPart (ctx , 1 ); err != nil {
235+ return nil , [32 ]byte {}, fmt .Errorf ("upload empty part: %w" , err )
236+ }
237+ }
238+
178239 if err := uploader .Complete (ctx ); err != nil {
179240 return nil , [32 ]byte {}, fmt .Errorf ("complete upload: %w" , err )
180241 }
0 commit comments