Skip to content

Commit 40d690e

Browse files
authored
refactor(storage): shared compressed-upload framework + storage limiter (#3232)
1 parent b90a5dc commit 40d690e

17 files changed

Lines changed: 406 additions & 145 deletions

packages/shared/pkg/featureflags/flags.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,10 @@ func NewIntFlag(name string, fallback int) IntFlag {
268268
}
269269

270270
var (
271-
MaxSandboxesPerNode = NewIntFlag("max-sandboxes-per-node", 200)
272-
GcloudConcurrentUploadLimit = NewIntFlag("gcloud-concurrent-upload-limit", 8)
273-
GcloudMaxTasks = NewIntFlag("gcloud-max-tasks", 16)
271+
MaxSandboxesPerNode = NewIntFlag("max-sandboxes-per-node", 200)
272+
// The LD keys keep the legacy "gcloud-" prefix, but the limits apply to uploads on all storage providers.
273+
StorageConcurrentUploadLimit = NewIntFlag("gcloud-concurrent-upload-limit", 8)
274+
StorageMaxUploadTasks = NewIntFlag("gcloud-max-tasks", 16)
274275
ClickhouseBatcherMaxBatchSize = NewIntFlag("clickhouse-batcher-max-batch-size", 100)
275276
ClickhouseBatcherMaxDelay = NewIntFlag("clickhouse-batcher-max-delay", 1000) // 1s in milliseconds
276277
ClickhouseBatcherQueueSize = NewIntFlag("clickhouse-batcher-queue-size", 1000)

packages/shared/pkg/limit/gcloud.go

Lines changed: 0 additions & 18 deletions
This file was deleted.

packages/shared/pkg/limit/limiter.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,23 @@ import (
99
)
1010

1111
type Limiter struct {
12-
gCloudUploadLimiter *utils.AdjustableSemaphore
13-
featureFlags *featureflags.Client
12+
storageUploadLimiter *utils.AdjustableSemaphore
13+
featureFlags *featureflags.Client
1414

1515
done chan struct{}
1616
closeOnce sync.Once
1717
}
1818

1919
func New(ctx context.Context, featureFlags *featureflags.Client) (*Limiter, error) {
20-
uploadLimiter, err := utils.NewAdjustableSemaphore(int64(featureflags.GcloudConcurrentUploadLimit.Fallback()))
20+
uploadLimiter, err := utils.NewAdjustableSemaphore(int64(featureflags.StorageConcurrentUploadLimit.Fallback()))
2121
if err != nil {
2222
return nil, err
2323
}
2424

2525
l := &Limiter{
26-
gCloudUploadLimiter: uploadLimiter,
27-
featureFlags: featureFlags,
28-
done: make(chan struct{}),
26+
storageUploadLimiter: uploadLimiter,
27+
featureFlags: featureFlags,
28+
done: make(chan struct{}),
2929
}
3030

3131
go l.UpdateUploadLimitSemaphore(ctx)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package limit
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/e2b-dev/infra/packages/shared/pkg/featureflags"
8+
)
9+
10+
// AcquireUploadSlot reserves a slot in the shared storage-upload semaphore and
11+
// returns a func releasing it. Safe on a nil receiver: without a limiter the
12+
// upload is not throttled and release is a no-op.
13+
func (l *Limiter) AcquireUploadSlot(ctx context.Context) (release func(), err error) {
14+
if l == nil {
15+
return func() {}, nil
16+
}
17+
18+
if err := l.storageUploadLimiter.Acquire(ctx, 1); err != nil {
19+
return nil, fmt.Errorf("failed to acquire semaphore: %w", err)
20+
}
21+
22+
return func() { l.storageUploadLimiter.Release(1) }, nil
23+
}
24+
25+
// MaxUploadTasks returns the per-upload concurrency limit. Safe on a nil
26+
// receiver: without a limiter it returns the flag's fallback value.
27+
func (l *Limiter) MaxUploadTasks(ctx context.Context) int {
28+
if l == nil {
29+
return featureflags.StorageMaxUploadTasks.Fallback()
30+
}
31+
32+
return l.featureFlags.IntFlag(ctx, featureflags.StorageMaxUploadTasks)
33+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package limit
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/e2b-dev/infra/packages/shared/pkg/utils"
11+
)
12+
13+
func TestAcquireUploadSlotNilLimiter(t *testing.T) {
14+
t.Parallel()
15+
16+
var l *Limiter
17+
release, err := l.AcquireUploadSlot(t.Context())
18+
require.NoError(t, err)
19+
release()
20+
}
21+
22+
func TestAcquireUploadSlotBlocksWhenFull(t *testing.T) {
23+
t.Parallel()
24+
25+
sem, err := utils.NewAdjustableSemaphore(1)
26+
require.NoError(t, err)
27+
l := &Limiter{storageUploadLimiter: sem}
28+
29+
release, err := l.AcquireUploadSlot(t.Context())
30+
require.NoError(t, err)
31+
32+
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
33+
defer cancel()
34+
_, err = l.AcquireUploadSlot(ctx)
35+
require.ErrorIs(t, err, context.DeadlineExceeded)
36+
37+
release()
38+
release, err = l.AcquireUploadSlot(t.Context())
39+
require.NoError(t, err)
40+
release()
41+
}

packages/shared/pkg/limit/upload.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ func (l *Limiter) UpdateUploadLimitSemaphore(ctx context.Context) {
1717
for {
1818
select {
1919
case <-ticker.C:
20-
uploadLimitFlag := l.featureFlags.IntFlag(ctx, featureflags.GcloudConcurrentUploadLimit)
20+
uploadLimitFlag := l.featureFlags.IntFlag(ctx, featureflags.StorageConcurrentUploadLimit)
2121

2222
// Update the semaphore with the new value
23-
if err := l.gCloudUploadLimiter.SetLimit(int64(uploadLimitFlag)); err != nil {
23+
if err := l.storageUploadLimiter.SetLimit(int64(uploadLimitFlag)); err != nil {
2424
logger.L().Error(ctx, "failed to adjust upload semaphore", zap.Error(err))
2525
}
2626
case <-l.done:

packages/shared/pkg/storage/compress_upload.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

1720
type 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+
2476
type 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
}

packages/shared/pkg/storage/compress_upload_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,22 @@ func TestCompressStreamContextCancel(t *testing.T) {
210210
require.ErrorIs(t, err, context.Canceled)
211211
}
212212

213+
func TestClampCloudMinPartSize(t *testing.T) {
214+
t.Parallel()
215+
216+
// Below S3/GCS XML API's 5 MiB non-final part minimum → clamped.
217+
require.Equal(t, 5, clampCloudMinPartSize(CompressConfig{MinPartSizeMB: 1}, 0).MinPartSizeMB)
218+
// Unset resolves to the 50 MB default from MinPartSize().
219+
require.Equal(t, 50, clampCloudMinPartSize(CompressConfig{}, 0).MinPartSizeMB)
220+
require.Equal(t, 50, clampCloudMinPartSize(CompressConfig{MinPartSizeMB: 50}, 0).MinPartSizeMB)
221+
222+
// Large files raise the part size to stay under the 10,000-part cap:
223+
// 900 GiB at 50 MB parts would need ~18,400 parts.
224+
clamped := clampCloudMinPartSize(CompressConfig{}, 900<<30)
225+
require.Equal(t, 103, clamped.MinPartSizeMB)
226+
require.LessOrEqual(t, int64(900<<30)/clamped.MinPartSize(), int64(cloudMaxParts))
227+
}
228+
213229
func TestCompressStreamPartSizeMinimum(t *testing.T) {
214230
t.Parallel()
215231

packages/shared/pkg/storage/gcp_multipart.go

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -287,44 +287,6 @@ func (m *MultipartUploader) uploadPart(ctx context.Context, uploadID string, par
287287
return etag, nil
288288
}
289289

290-
type multiSliceReader struct {
291-
slices [][]byte
292-
idx int
293-
off int
294-
}
295-
296-
func (r *multiSliceReader) Read(p []byte) (int, error) {
297-
if len(p) == 0 {
298-
if r.idx >= len(r.slices) {
299-
return 0, io.EOF
300-
}
301-
302-
return 0, nil
303-
}
304-
305-
var n int
306-
for len(p) > 0 && r.idx < len(r.slices) {
307-
current := r.slices[r.idx]
308-
if r.off >= len(current) {
309-
r.idx++
310-
r.off = 0
311-
312-
continue
313-
}
314-
315-
copied := copy(p, current[r.off:])
316-
n += copied
317-
r.off += copied
318-
p = p[copied:]
319-
}
320-
321-
if n > 0 {
322-
return n, nil
323-
}
324-
325-
return 0, io.EOF
326-
}
327-
328290
// uploadPartSlices uploads a part from multiple byte slices without concatenating them.
329291
func (m *MultipartUploader) uploadPartSlices(ctx context.Context, uploadID string, partNumber int, slices [][]byte) (string, error) {
330292
totalLen := 0
@@ -337,7 +299,7 @@ func (m *MultipartUploader) uploadPartSlices(ctx context.Context, uploadID strin
337299

338300
// Use a ReaderFunc so the retryable client can replay the body on retries
339301
bodyFn := func() (io.Reader, error) {
340-
return &multiSliceReader{slices: slices}, nil
302+
return newMultiSliceReader(slices), nil
341303
}
342304

343305
req, err := retryablehttp.NewRequestWithContext(ctx, "PUT", url, retryablehttp.ReaderFunc(bodyFn))

packages/shared/pkg/storage/gcp_multipart_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,37 @@ func createTestMultipartUploader(t *testing.T, handler http.HandlerFunc, retryCo
6060
return uploader
6161
}
6262

63+
func TestMultipartUploader_PartUploaderContract(t *testing.T) {
64+
t.Parallel()
65+
66+
testPartUploaderContract(t, partUploaderTestAdapter{
67+
new: func(t *testing.T, recorder *partUploaderRecorder) partUploader {
68+
t.Helper()
69+
70+
return createTestMultipartUploader(t, func(w http.ResponseWriter, r *http.Request) {
71+
switch {
72+
case r.Method == http.MethodPost && r.URL.RawQuery == uploadsPath:
73+
recorder.started = true
74+
response := InitiateMultipartUploadResult{Bucket: testBucketName, Key: testObjectName, UploadID: "contract-upload-id"}
75+
xmlData, _ := xml.Marshal(response)
76+
w.WriteHeader(http.StatusOK)
77+
w.Write(xmlData)
78+
case r.Method == http.MethodPut:
79+
recordUploadedPart(t, recorder, w, r)
80+
case r.Method == http.MethodPost && strings.Contains(r.URL.RawQuery, "uploadId=contract-upload-id"):
81+
recorder.completed = true
82+
w.WriteHeader(http.StatusOK)
83+
case r.Method == http.MethodDelete && strings.Contains(r.URL.RawQuery, "uploadId=contract-upload-id"):
84+
recorder.aborted = true
85+
w.WriteHeader(http.StatusNoContent)
86+
default:
87+
t.Fatalf("unexpected GCP multipart request: %s %s", r.Method, r.URL.String())
88+
}
89+
})
90+
},
91+
})
92+
}
93+
6394
func TestMultipartUploader_InitiateUpload_Success(t *testing.T) {
6495
t.Parallel()
6596
expectedUploadID := "test-upload-id-123"

0 commit comments

Comments
 (0)