11 KiB
Design: add-storage-s3-multipart
Non-normative. The normative contract is ./specs/storage-s3/spec.md.
Goals
- Large writes (> 16 MiB by default, configurable) flow through S3 multipart without any caller code change.
- Streaming input (
ReadableStream<Uint8Array>) is chunked into parts on the fly — no whole-stream buffering. - Parts upload in bounded parallel for WAN throughput; failures cancel
in-flight workers and clean up the open
UploadId. - Error provenance is preserved at stage granularity (init / part / complete / abort) so callers and dashboards can attribute failures.
- 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
signalfires (viasignal.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/)
setbelow threshold → singlePutObject, no MPU calls (assert viaListMultipartUploadsCommandreturning empty list mid-test).setabove threshold with default tuning → parts visible mid-flight viaListPartsCommand, completes, GET round-trips byte-for-byte.setStreamover threshold with caller-supplied 5 MiB part size → exact part count =ceil(total / partSize).- Concurrency cap honoured — inject a slow part by interposing a custom
S3ClientFactorythat wrapsUploadPartwith a measured delay; assert the in-flight count never exceedsconcurrency. - Mid-upload abort via
AbortSignal→ throws,ListMultipartUploadsCommandshows zero open uploads for the key (i.e. cleanup ran). - Part failure → injected via a wrapping
S3ClientFactorythat fails part 3 deterministically; final error isMULTIPART_PART_FAILEDwithcontext.partNumber === 3; cleanup ran. - Cleanup failure path —
AbortMultipartUploadinjected to fail; original error still surfaces,MULTIPART_ABORT_FAILEDappears in the logger sink. setPartialwithoffset === 0and a > threshold payload → uses the multipart engine;capabilities.partialWritesstaysfalse.setPartialwith non-zero offset → still usesdefaultSetPartial(read-modify-write) regardless of size.- Part-size validation:
partSize < 5 MiB→ constructor throwsS3StorageError(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
linkedabort listener removal on success, - the cleanup-after-failure branch,
- the cleanup-failure swallow branch,
- the
partSize < 5 MiBvalidation 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. |