# 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`) 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): ```ts 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 ```ts type PartInput = { number: number; body: Uint8Array }; async #runMultipart( fullKey: string, parts: AsyncIterable, tuning: Required, signal: AbortSignal | undefined, ): Promise { 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` 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 `UploadId`s — 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 ```text 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 │ ├── 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`. |