Files
2026-05-27 23:29:53 +01:00

11 KiB

Design: add-storage-s3-multipart

Non-normative. The normative contract is ./specs/storage-s3/spec.md.

Goals

  1. Large writes (> 16 MiB by default, configurable) flow through S3 multipart without any caller code change.
  2. Streaming input (ReadableStream<Uint8Array>) is chunked into parts on the fly — no whole-stream buffering.
  3. Parts upload in bounded parallel for WAN throughput; failures cancel in-flight workers and clean up the open UploadId.
  4. Error provenance is preserved at stage granularity (init / part / complete / abort) so callers and dashboards can attribute failures.
  5. Zero changes to StorageLike; multipart is invisible to consumers who don't need to know.

Trigger threshold

S3 enforces a minimum part size of 5 MiB (except the final part). Maximum parts per upload is 10 000. Maximum object size is 5 TiB.

The default trigger is 16 MiB. Rationale:

  • Below 16 MiB a single PUT is faster (one round trip, no MPU bookkeeping).
  • Above 16 MiB the latency of the three MPU control calls amortises against the parallelism win.
  • 16 MiB / 5 MiB part = 4 parts at the floor, which matches the default concurrency of 4 — every part can start in parallel.

Caller override via options.multipart.threshold. Minimum legal value is 5 MiB (anything smaller would force the first part below the AWS floor).

API surface

Public surface is unchanged. The new opt-in shape is added to S3Options (and to a per-call StorageOptions-shaped overlay for set only, so single-call overrides are possible without reconfiguring the adapter):

export interface MultipartTuning {
    /** Bytes; default 16 MiB; minimum 5 MiB. */
    readonly threshold?: number;
    /** Bytes; default 8 MiB; minimum 5 MiB; maximum 5 GiB per AWS. */
    readonly partSize?: number;
    /** Concurrent part workers; default 4; minimum 1; maximum 32. */
    readonly concurrency?: number;
}

// Added to S3Options
readonly multipart?: MultipartTuning;

setMultipart keeps its existing signature (it is already public) and becomes a thin wrapper over the new engine.

Internal engine

One private method, #runMultipart, owns the entire CreateMPU → parallel-parts → CompleteMPU lifecycle. Both set (when over threshold) and setMultipart delegate to it.

Pseudo-shape

type PartInput = { number: number; body: Uint8Array };

async #runMultipart(
    fullKey: string,
    parts: AsyncIterable<PartInput>,
    tuning: Required<MultipartTuning>,
    signal: AbortSignal | undefined,
): Promise<void> {
    const uploadId = await this.#initMultipart(fullKey, signal);
    const completed: { ETag: string; PartNumber: number }[] = [];
    try {
        await this.#runPartWorkers(
            fullKey, uploadId, parts, tuning.concurrency, completed, signal,
        );
        completed.sort((a, b) => a.PartNumber - b.PartNumber);
        await this.#completeMultipart(fullKey, uploadId, completed, signal);
    } catch (err) {
        await this.#abortMultipart(fullKey, uploadId).catch(/* logged */);
        throw err;
    }
}

Part chunker

A generator #chunkStream(stream, partSize) reads from ReadableStream<Uint8Array> and yields PartInput records exactly partSize bytes long, plus a final shorter part. Memory ceiling is partSize + the single in-flight buffer from reader.read().

For the byte-array set path, the chunker is bypassed: parts are sliced directly from the input Uint8Array and yielded synchronously. This avoids an unnecessary ReadableStream allocation for the common case.

Worker pool

#runPartWorkers keeps tuning.concurrency pending UploadPart promises in flight. As one resolves, its { ETag, PartNumber } is pushed to completed and the next part is drawn from the iterator. The pool uses a structured Promise.race-based scheduler — when any worker rejects, the pool aborts remaining workers via a shared AbortController linked to the caller's signal, then re-throws the first error.

Abort propagation

The engine creates an internal AbortController (linked). Its signal is passed to every S3Client.send call. linked is aborted when either:

  • the caller-supplied signal fires (via signal.addEventListener("abort")),
  • any part worker rejects.

The AbortMultipartUploadCommand is sent with a fresh signal (no abort inheritance) so cleanup still runs after the caller aborts. Cleanup failures are caught, wrapped in MULTIPART_ABORT_FAILED, logged at warn level, and swallowed — re-throwing them would mask the original error.

Error mapping

Stage Code
CreateMultipartUpload failure or missing UploadId MULTIPART_INIT_FAILED
UploadPart failure (after retry) MULTIPART_PART_FAILED (with partNumber in context)
CompleteMultipartUpload failure MULTIPART_COMPLETE_FAILED
AbortMultipartUpload failure during cleanup MULTIPART_ABORT_FAILED (logged, not thrown)

All four are registered via defineCodes("S3", [...] as const) alongside the existing entries. The legacy MULTIPART_FAILED code remains in the registry (backwards-compat alias; no new call site raises it) so any consumer that asserts on it does not see an unknown code lookup failure.

Resumability — out of scope

Resumable uploads require persisting { key, uploadId, completedParts[] } across process restarts. That introduces a storage dependency, a recovery protocol, and a garbage-collection contract for orphaned UploadIds — none of which fit inside StorageLike. Callers needing resume should layer a dedicated coordinator over S3.setMultipart, using S3Client directly for ListPartsCommand discovery.

Streaming input

setStream(key, stream, opts?) is the new internal helper, exposed via the existing setMultipart public method (which already accepts a ReadableStream). The byte-array set path constructs a synchronous part iterator from the input buffer instead of routing through ReadableStream — two ingress paths, one engine.

Test strategy

Per CLAUDE.md rule 2 (no mocks): the multipart engine is tested end-to-end against a real S3-compatible service via testcontainers. The genesis monorepo already has @suaveplan/testing-integration-garage-s3 (packages/testing/testing-integration-garage-s3/src/fixture/) which spins up Garage (S3-compatible) in a container with credentials wired into the fixture's env-bridge. This change extends — does NOT replace — that fixture.

Why Garage over MinIO: Garage is already present in the monorepo fixture set, fully supports S3 multipart (CreateMPU/UploadPart/CompleteMPU/AbortMPU), and runs faster in CI containers than MinIO for the multi-part shapes this test plan exercises.

If a behaviour is only triggerable via a specific MinIO quirk (e.g. SlowDown throttling pattern) it will be added as a second fixture; for the requirements in this change, Garage covers every observable shape.

Test plan (in tests/integration/)

  1. set below threshold → single PutObject, no MPU calls (assert via ListMultipartUploadsCommand returning empty list mid-test).
  2. set above threshold with default tuning → parts visible mid-flight via ListPartsCommand, completes, GET round-trips byte-for-byte.
  3. setStream over threshold with caller-supplied 5 MiB part size → exact part count = ceil(total / partSize).
  4. Concurrency cap honoured — inject a slow part by interposing a custom S3ClientFactory that wraps UploadPart with a measured delay; assert the in-flight count never exceeds concurrency.
  5. Mid-upload abort via AbortSignal → throws, ListMultipartUploadsCommand shows zero open uploads for the key (i.e. cleanup ran).
  6. Part failure → injected via a wrapping S3ClientFactory that fails part 3 deterministically; final error is MULTIPART_PART_FAILED with context.partNumber === 3; cleanup ran.
  7. Cleanup failure path — AbortMultipartUpload injected to fail; original error still surfaces, MULTIPART_ABORT_FAILED appears in the logger sink.
  8. setPartial with offset === 0 and a > threshold payload → uses the multipart engine; capabilities.partialWrites stays false.
  9. setPartial with non-zero offset → still uses defaultSetPartial (read-modify-write) regardless of size.
  10. Part-size validation: partSize < 5 MiB → constructor throws S3StorageError(MULTIPART_INIT_FAILED, ...) at config time, not at upload time.

vitest.integration.config.ts already exists. Tests live under tests/integration/multipart.test.ts so the unit suite (src/**/*.test.ts) stays fast and the integration sweep is opt-in via the existing turbo task.

Coverage

100/100/100/100 on the new module per project gate, including:

  • the linked abort listener removal on success,
  • the cleanup-after-failure branch,
  • the cleanup-failure swallow branch,
  • the partSize < 5 MiB validation guard.

File layout

packages/storage/storage-s3/src/
├── s3/
│   ├── s3.ts                      # set() routes to engine over threshold
│   ├── s3.md                      # updated: threshold + tuning + cap honesty
│   ├── s3.test.ts
│   └── s3-branches.test.ts
├── multipart/                     # NEW
│   ├── multipart-engine.ts        # #runMultipart, init/complete/abort helpers
│   ├── multipart-engine.md
│   ├── multipart-engine.test.ts
│   ├── part-chunker.ts            # ReadableStream → AsyncIterable<PartInput>
│   ├── part-chunker.md
│   ├── part-chunker.test.ts
│   ├── worker-pool.ts             # bounded-concurrency scheduler with abort
│   ├── worker-pool.md
│   ├── worker-pool.test.ts
│   └── index.ts                   # explicit named re-exports (rule 10/6a)
├── constants/
│   └── index.ts                   # DEFAULT_MULTIPART_THRESHOLD, _CONCURRENCY
├── error-codes.ts                 # +4 codes; MULTIPART_FAILED retained
└── types/
    └── s3.types.ts                # MultipartTuning interface

packages/storage/storage-s3/tests/integration/
└── multipart.test.ts              # uses testing-integration-garage-s3

Decisions & trade-offs

Decision Alternative Why
Auto-promote inside set Force callers to pick setMultipart Public surface = StorageLike; large-write performance shouldn't depend on knowing about S3 specifics.
Default threshold 16 MiB 100 MiB (AWS guideline) At 16 MiB four parallel parts amortise control overhead; lower threshold is safer for chunked-drive workloads where many medium objects benefit.
setPartial whole-object multipart Always r-m-w Random-access patches genuinely require r-m-w (S3 has no byte-range PUT); whole-object writes via setPartial deserve the fast path.
capabilities.partialWrites = false Flip to true S3 multipart is whole-object only — flipping the flag would lie to capability-aware consumers (e.g. drive-plugin-chunks sparse-file layout).
Bounded Promise.race scheduler p-limit / p-queue external dep One small file, zero new catalog entries, abort handling tightly coupled to the engine.
Garage testcontainer MinIO / LocalStack / mocked SDK Real-service per rule 2; Garage already in testing-integration-garage-s3.