Files
PaperClipAI/server/src/storage/s3-multipart.ts
T
DottaandClaude Opus 5.5 4a2eb920cf fix(storage): wait out a store outage on a run's critical path instead of failing it (#20299)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A cloud instance keeps run logs and evidence in object storage, so a
run touches the store as soon as it starts
> - In storage chaos wave `w-20261109T145628-s9902` (ST1: 75–176 s
windows where every S3 call returns 503), only 1 of 8 tasks finished. A
run starting or resuming during an outage failed within a second as
`adapter_failed`, generic recovery burned its attempts seconds apart,
the task was escalated to the board, and the user saw the raw S3 error
> - This pull request treats a transient storage error as an
infrastructure wait: the run is retried on a minute-scale schedule,
rapid recovery is suppressed, the board shows it as working, and the run
error says storage was unavailable
> - The benefit is that a store outage of several minutes delays work
instead of failing and stranding it

## Linked Issues or Issue Description

Refs #19831. Storage chaos wave `w-20261109T145628-s9902` (master
`610b54f551`, `--storage s3`, ST1).

**Where the run fails.** At run start or resume, `run-output` `begin`
opens the run log: `getRunLogStore().begin` → object run log store →
object byte journal `claim` → S3 conditional PUT. The journal retries
only 3 times within about 300 ms, so a 503 escapes and fails the run as
`adapter_failed`. The async stack ends in the SDK middleware with no app
frame. `log_store` stays null on the failed run, which pins the claim.

**What followed.**
- `failRun` released the issue into generic immediate recovery. Recovery
retried 1–2 s apart inside the outage (t01: 14:58:33, :35, :59,
14:59:01), then stopped and opened a board-owned
`stranded_assigned_issue` action. These are the open recovery actions on
CHA-1, CHA-4, CHA-7 and CHA-8; a later Retry hit the outage again and
escalated the same way.
- The run error was the raw S3 text ("Injected storage outage").

**Slow store (ST2, `w-20261109T151946-s9903`).** With about 9.7 s of
added latency per call, the same claim hit the journal's 15 s
per-operation deadline (`AbortSignal.timeout(15_000)`). The run failed
after about 36 s with `AbortError: Request aborted` and burned 3 runs
the same way; 4 of 8 tasks needed a Retry.

t02/t03 (Retry parked with `process_identity_missing`) are a separate
native-recovery class and are handled in a follow-up.


**Daytona storage chaos ST1 (`w-20261109T161202-s9912`, 1/8):** with S3
returning 503 for 72 to 154 s at a time:
- Workspace restore failed at once (`remote_workspace_restore_failed:
step=snapshot_download; storage=provider_error`), three times in 40 s,
then `setup_failed`. It left an active recovery action behind.
- Runs reattached after a restart died within 2 s on the run-log claim,
then became reconciliation-required.
- Setup failed on other storage reads.
- 6 of 7 failures needed a UI Retry.

So the rule here is one class rule: a transient store error on a run's
critical path waits, it never fails the run.

## What Changed

- New `server/src/storage/storage-unavailable.ts`:
- `isStorageUnavailableError` reads the recorded storage failure
diagnostics first, then the SDK error shape. Unavailable means
429/500/502/503/504, the S3 throttling and timeout names, network codes
(`ECONNRESET`, `ECONNREFUSED`, `ETIMEDOUT`, `EPIPE`, `EAI_AGAIN`,
`ENOTFOUND`), or an `AbortError` whose cause is a `TimeoutError` (a
per-operation deadline). 403, 404 and 412 are not outages, and neither
is an abort caused by the run's own Stop.
- It defines the `storage_unavailable` code, the
`storage_unavailable_wait` retry reason, the retry policy (60 s × 10
attempts, about 10 minutes) and the user-facing message.
- `server/src/services/heartbeat/run-completion.ts`:
- `failRun` classifies such a failure as `storage_unavailable` with the
plain message.
- Unless native recovery owns the run, it schedules the storage wait
with `scheduleBoundedRetryForRun`, skips the interaction-continuation
retry, and releases the issue with `suppressImmediateRecovery`.
  - `failRunSetup` uses the same policy for this code.
- `server/src/services/heartbeat/retries.ts`: `storage_unavailable`
joins the transient infrastructure codes. It gets its own retry budget
and policy, like `runner_revision_mismatch`.
- `server/src/services/task-interruption-presentation.ts`:
`storage_unavailable_wait` is a resource wait, so the board shows the
task as working with no Retry while it waits.

- **One shared outage wait for the run's critical path**
(`server/src/storage/storage-unavailable.ts`):
- `retryThroughStorageOutage` retries a transient store failure with
backoff (2 s doubling to 30 s) until that storage operation's outage has
lasted 5 minutes, then gives the error back. The outermost wait of an
operation records its first failure, and nested and parallel waits
inside it share that start; no other request's success extends it.
Shared probes and shared reads run under their own scope, so one run's
Stop never cancels work another run waits on. A stop signal ends the
wait, and non-outage errors are never retried.
- Inside `waitOutStorageOutages`, every replayable S3 request waits
instead of failing: get, head, delete, buffered and file puts, and
multipart create, file parts and complete. A consumed upload stream is
never resent, and requests outside the scope keep failing fast.
- The scope covers run execution (`heartbeat.ts` `executeRun`: setup,
workspace restore, run-log claim and the run itself), plus
`loadWorkspaceSnapshotArchive` and `uploadWorkspaceSnapshot`, which
copy-back and recovery workers use too.
- The run-log claim retries as a whole, because the journal bounds each
operation with a 15 s deadline.
- Past the budget, the run takes the bounded `storage_unavailable` wait
below.
- The classifier follows the error's `cause` chain, so a restore failure
wrapping an outage counts. `failRunSetup` checks storage before restore
failures, so that case takes the storage wait instead of two quick
retries and an escalation (the R6 leak).
- `object-byte-journal.ts`: when its deadline expires, the journal
rethrows the recorded storage error instead of a bare `TimeoutError`.

## Verification

Red/green (each new test fails against master and passes with this PR):
- `run-completion.test.ts` "waits a minute and suppresses rapid recovery
when a run fails because storage is unavailable (storage chaos ST1)". An
S3-shaped 503 records `storage_unavailable` with the plain message (no
raw S3 text), schedules `STORAGE_UNAVAILABLE_RETRY`, releases with
`suppressImmediateRecovery: true`, and skips the continuation retry.
"keeps an ordinary adapter failure on the normal recovery path" guards
the default.
- `task-interruption-query.test.ts` "does not present an overdue
storage_unavailable_wait wait as a failed attempt": working, `canRetry:
false`.
- `storage-unavailable.test.ts`: which errors count as an outage and
which do not, including recorded diagnostics, a deadline-aborted call
(ST2), and a Stop-aborted call that must not count.

Static checks:
- `tsc --noEmit` passes for `server`.

Live verification: Phase 3 will rerun ST1 (seed 9902) with the MinIO
fault proxy after merge.

- `server/src/__tests__/storage-outage-wait.test.ts` runs against a
local S3 that answers 503 for 3 s:
- Inside the scope, a snapshot `getObject` and a run-log conditional PUT
succeed once the store is back.
  - Outside it, a request fails fast.
  - A stop ends the wait.
- Unit cases cover the budget, non-outage errors, and a
`RemoteWorkspaceRestoreError` wrapping an outage.
- `run-completion.test.ts`: a setup failure from a restore that wrapped
an outage takes the storage wait (red before).
- `object-byte-journal.test.ts`: a real `claim` whose head PUT hits its
deadline is classified as a storage outage (red before).

## Risks

- A storage outage now holds a task for up to about 10 minutes of
automatic waits instead of escalating within seconds. A longer outage
still ends in the existing board recovery.
- A run whose failure is misclassified as storage-unavailable waits
instead of using fast recovery. The classifier accepts only server-side
or network failures from the storage SDK, never 4xx answers.
- No schema change.

- A run can now wait until a storage outage has lasted 5 minutes before
it fails over to the bounded wait. It shows as working throughout, and
Stop ends the wait.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:15:48 -06:00

288 lines
15 KiB
TypeScript

import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, UploadPartCommand, type S3Client } from "@aws-sdk/client-s3";
import { sendThroughStorageOutage } from "./storage-unavailable.js";
import { recordStorageBackoff, recordStoragePart, recordStorageThrottle, withStorageRequestStats } from "./request-stats.js";
import { createHash } from "node:crypto";
import { once } from "node:events";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { PassThrough, Readable } from "node:stream";
import { setTimeout as delay } from "node:timers/promises";
import type { PutObjectInput, PutObjectReceipt } from "./types.js";
import { withS3FailureDiagnostics } from "./s3-failure.js";
import { cleanupStorageFailure } from "./failure-cleanup.js";
const bufferedPartSize = 8 * 1024 * 1024;
const bufferedPayloadLimit = 4 * bufferedPartSize;
let bufferedPayloadBytes = 0;
/** Each part is streamed too: multipart must not become another whole-file
* buffer or a fixed part-count ceiling below the storage provider's limits. */
export async function putS3Multipart(client: S3Client, bucket: string, key: string, input: PutObjectInput) {
const source = input.body instanceof Readable ? input.body : Readable.from([input.body]);
const iterator = source[Symbol.asyncIterator]();
const controller = new AbortController();
const active = new Set<Promise<void>>();
const bodies = new Set<PassThrough>();
let failed = false;
let failure: unknown;
const stop = (error: unknown) => {
if (failed) return;
failed = true;
failure = error;
controller.abort(error);
source.destroy();
for (const body of bodies) body.destroy();
};
const check = () => { if (failed) throw failure; };
const onAbort = () => stop(input.signal!.reason);
input.signal?.addEventListener("abort", onAbort, { once: true });
if (input.signal?.aborted) onAbort();
const readNext = async () => {
controller.signal.throwIfAborted();
let abort!: () => void;
const interrupted = new Promise<never>((_resolve, reject) => {
abort = () => reject(controller.signal.reason);
controller.signal.addEventListener("abort", abort, { once: true });
});
try { return await Promise.race([iterator.next(), interrupted]); }
finally { controller.signal.removeEventListener("abort", abort); }
};
let pending = Buffer.alloc(0);
const readPart = async (length: number, write: (chunk: Buffer) => Promise<void>) => {
while (length > 0) {
check();
if (!pending.length) {
const next = await readNext();
check();
if (next.done) throw new Error("S3 upload source was truncated");
pending = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value);
if (!pending.length) continue;
}
const count = Math.min(length, pending.length, 64 * 1024), chunk = pending.subarray(0, count);
pending = pending.subarray(count); length -= count;
await write(chunk);
}
};
let uploadId: string | undefined;
// Scale part sizes to the S3 part-count limit while retaining stream backpressure.
const partSize = Math.max(bufferedPartSize, Math.ceil(input.contentLength / 10_000));
try {
check();
uploadId = (await withS3FailureDiagnostics("create_multipart_upload", () => client.send(new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: input.contentType }), { abortSignal: controller.signal }), controller.signal)).UploadId;
if (!uploadId) throw new Error("S3 did not return a multipart upload id");
const parts: { ETag: string; PartNumber: number }[] = [];
let partNumber = 0;
for (let offset = 0; offset < input.contentLength;) {
while (active.size >= 4) { await Promise.race(active); check(); }
check();
const length = Math.min(partSize, input.contentLength - offset);
const number = ++partNumber;
let buffered: Buffer | null = null;
if (partSize <= bufferedPartSize && bufferedPayloadBytes + length <= bufferedPayloadLimit) {
bufferedPayloadBytes += length;
try {
const payload = Buffer.allocUnsafe(length);
let position = 0;
await readPart(length, async chunk => { chunk.copy(payload, position); position += chunk.length; });
buffered = payload;
} catch (error) { bufferedPayloadBytes -= length; throw error; }
}
let remaining = length;
const body = new PassThrough({ highWaterMark: 64 * 1024 });
bodies.add(body);
const write = async (chunk: Buffer) => {
check(); remaining -= chunk.length;
if (!body.write(chunk)) await once(body, "drain", { signal: controller.signal });
};
const sending = (async () => {
const result = await withS3FailureDiagnostics("upload_part", () => client.send(new UploadPartCommand({ Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: number, Body: body, ContentLength: length }), { abortSignal: controller.signal }), controller.signal);
if (remaining || !body.readableEnded || !result.ETag) throw new Error("S3 upload part is incomplete");
parts[number - 1] = { ETag: result.ETag, PartNumber: number };
})().catch(stop);
const producing = buffered ? (async () => {
for (let position = 0; position < buffered.length; position += 64 * 1024)
await write(buffered.subarray(position, Math.min(position + 64 * 1024, buffered.length)));
body.end();
})().catch(stop) : Promise.resolve();
const uploading = Promise.all([sending, producing]).then(() => undefined)
.finally(() => { if (buffered) bufferedPayloadBytes -= length; active.delete(uploading); bodies.delete(body); body.destroy(); });
active.add(uploading);
if (!buffered) {
await readPart(length, write);
body.end();
}
offset += length;
}
if (pending.length || !(await readNext()).done) throw new Error("S3 upload source exceeds its declared size");
await Promise.all(active);
check();
await withS3FailureDiagnostics("complete_multipart_upload", () => client.send(new CompleteMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts } }), { abortSignal: controller.signal }), controller.signal);
} catch (error) {
stop(error);
await Promise.all(active);
if (uploadId) await cleanupStorageFailure(signal => withStorageRequestStats(() => client.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId }), { abortSignal: signal })));
throw failure;
} finally {
input.signal?.removeEventListener("abort", onAbort);
source.destroy();
await cleanupStorageFailure(async () => iterator.return?.());
}
}
export const FILE_PART_CONCURRENCY = 8;
const FILE_PART_ATTEMPTS = 3;
/** A provider that asks for a lower request rate gets more, longer-spaced attempts. */
export const THROTTLED_PART_ATTEMPTS = 8;
const THROTTLE_BACKOFF_BASE_MS = 1_000;
const THROTTLE_BACKOFF_MAX_MS = 20_000;
const FILE_READ_BYTES = 1024 * 1024;
/**
* S3 answers a request-rate limit with 503 SlowDown; compatible gateways also
* use 429. The SDK never retries a part because its body is a stream, so the
* upload must slow down itself.
*/
function isRequestRateLimited(error: unknown) {
if (!error || typeof error !== "object") return false;
const { name, $metadata } = error as { name?: unknown; $metadata?: { httpStatusCode?: unknown } };
return name === "SlowDown" || $metadata?.httpStatusCode === 503 || $metadata?.httpStatusCode === 429;
}
const TRANSIENT_FILE_REQUEST_STATUS = new Set([408, 429, 500, 502, 503, 504]);
const TRANSIENT_FILE_REQUEST_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ECONNABORTED", "ETIMEDOUT", "EPIPE", "EAI_AGAIN",
"ENOTFOUND", "ERR_STREAM_PREMATURE_CLOSE", "UND_ERR_SOCKET"]);
const TRANSIENT_FILE_REQUEST_NAMES = new Set(["RequestTimeout", "TimeoutError", "NetworkingError", "InternalError", "ServiceUnavailable"]);
function isTransientFileRequestFailure(error: unknown) {
if (!error || typeof error !== "object") return false;
const { name, code, $metadata } = error as { name?: unknown; code?: unknown; $metadata?: { httpStatusCode?: unknown } };
const status = $metadata?.httpStatusCode;
if (typeof status === "number") return TRANSIENT_FILE_REQUEST_STATUS.has(status);
return (typeof code === "string" && TRANSIENT_FILE_REQUEST_CODES.has(code))
|| (typeof name === "string" && TRANSIENT_FILE_REQUEST_NAMES.has(name));
}
export async function sendFileRequestWithRetry<T>(signal: AbortSignal | undefined, send: () => Promise<T>): Promise<T> {
for (let failures = 0, throttles = 0; ;) {
signal?.throwIfAborted();
try {
return await send();
} catch (error) {
if (signal?.aborted) throw error;
if (isRequestRateLimited(error)) {
recordStorageThrottle();
if (++throttles >= THROTTLED_PART_ATTEMPTS) throw error;
const ceiling = Math.min(THROTTLE_BACKOFF_MAX_MS, THROTTLE_BACKOFF_BASE_MS * 2 ** (throttles - 1));
const wait = Math.floor(Math.random() * ceiling);
recordStorageBackoff(wait);
await delay(wait, undefined, { signal });
continue;
}
if (!isTransientFileRequestFailure(error) || ++failures >= FILE_PART_ATTEMPTS) throw error;
recordStorageBackoff(failures * 500);
await delay(failures * 500, undefined, { signal });
}
}
}
/** Base64 MD5 of an inclusive byte range, as the Content-MD5 header expects. */
export async function fileRangeMd5(file: string, start: number, end: number, signal?: AbortSignal) {
const digest = createHash("md5");
for await (const chunk of createReadStream(file, { start, end, signal, highWaterMark: FILE_READ_BYTES })) digest.update(chunk);
return digest.digest("base64");
}
export async function assertSourceFileSize(file: string, contentLength: number) {
const stats = await stat(file);
if (!stats.isFile() || stats.size !== contentLength) throw new Error("S3 upload source file does not match its declared size");
}
/** A file source is read per part instead of through one stream. Parts upload
* in parallel without the shared memory window, each carries Content-MD5 so
* the backend rejects bytes it did not receive intact, and a failed part is
* resent from disk instead of failing the whole upload. When the backend
* limits the request rate, the part backs off with jitter and the upload
* halves its parts in flight, once per round of throttled requests. */
export async function putS3MultipartFile(
client: S3Client, bucket: string, key: string, input: PutObjectInput & { sourceFile: string },
): Promise<PutObjectReceipt> {
const file = input.sourceFile;
const controller = new AbortController();
const onAbort = () => controller.abort(input.signal!.reason);
input.signal?.addEventListener("abort", onAbort, { once: true });
if (input.signal?.aborted) onAbort();
// This path never reads the stream body; release its file descriptor now.
if (input.body instanceof Readable) input.body.destroy();
let uploadId: string | undefined;
try {
await assertSourceFileSize(file, input.contentLength);
controller.signal.throwIfAborted();
const partSize = Math.max(bufferedPartSize, Math.ceil(input.contentLength / 10_000));
const count = Math.ceil(input.contentLength / partSize);
uploadId = (await sendThroughStorageOutage(() => withS3FailureDiagnostics("create_multipart_upload", () => client.send(new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: input.contentType }), { abortSignal: controller.signal }), controller.signal), controller.signal)).UploadId;
if (!uploadId) throw new Error("S3 did not return a multipart upload id");
const id = uploadId;
const parts: { ETag: string; PartNumber: number }[] = new Array(count);
// Workers at or above this slot stop taking new parts. A reduction counts
// only for requests sent after the previous one, so a burst of parallel
// throttled parts halves the rate once rather than once per part.
let concurrency = FILE_PART_CONCURRENCY, reductions = 0;
const sendPart = async (index: number) => {
const start = index * partSize, length = Math.min(partSize, input.contentLength - start), end = start + length - 1;
const contentMd5 = await fileRangeMd5(file, start, end, controller.signal);
for (let failures = 0, throttles = 0; ;) {
controller.signal.throwIfAborted();
const body = createReadStream(file, { start, end, highWaterMark: 64 * 1024 });
const sentAfter = reductions;
try {
const result = await withS3FailureDiagnostics("upload_part", () => client.send(new UploadPartCommand({
Bucket: bucket, Key: key, UploadId: id, PartNumber: index + 1, Body: body, ContentLength: length, ContentMD5: contentMd5,
}), { abortSignal: controller.signal }), controller.signal);
if (!result.ETag) throw new Error("S3 upload part is incomplete");
parts[index] = { ETag: result.ETag, PartNumber: index + 1 };
recordStoragePart();
return;
} catch (error) {
// A part read from disk can be resent; the stream path cannot.
if (controller.signal.aborted) throw error;
if (isRequestRateLimited(error)) {
recordStorageThrottle();
if (++throttles >= THROTTLED_PART_ATTEMPTS) throw error;
if (sentAfter === reductions) { concurrency = Math.max(1, Math.floor(concurrency / 2)); reductions++; }
const ceiling = Math.min(THROTTLE_BACKOFF_MAX_MS, THROTTLE_BACKOFF_BASE_MS * 2 ** (throttles - 1));
const wait = Math.floor(Math.random() * ceiling);
recordStorageBackoff(wait);
await delay(wait, undefined, { signal: controller.signal });
continue;
}
if (++failures >= FILE_PART_ATTEMPTS) throw error;
recordStorageBackoff(failures * 500);
await delay(failures * 500, undefined, { signal: controller.signal });
} finally { body.destroy(); }
}
};
let next = 0;
const worker = async (slot: number) => {
while (next < count && slot < concurrency) {
const index = next++;
try { await sendThroughStorageOutage(() => sendPart(index), controller.signal); }
catch (error) { if (!controller.signal.aborted) controller.abort(error); throw error; }
}
};
// Wait for every sibling to settle before the multipart upload is aborted.
const settled = await Promise.allSettled(Array.from({ length: Math.min(FILE_PART_CONCURRENCY, count) }, (_, slot) => worker(slot)));
const rejected = settled.find((entry): entry is PromiseRejectedResult => entry.status === "rejected");
if (rejected) throw controller.signal.aborted && controller.signal.reason !== undefined ? controller.signal.reason : rejected.reason;
controller.signal.throwIfAborted();
await sendThroughStorageOutage(() => withS3FailureDiagnostics("complete_multipart_upload", () => client.send(new CompleteMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: id, MultipartUpload: { Parts: parts } }), { abortSignal: controller.signal }), controller.signal), controller.signal);
uploadId = undefined;
return { integrity: "server_verified_md5" };
} catch (error) {
const failedUpload = uploadId;
if (failedUpload) await cleanupStorageFailure(signal => withStorageRequestStats(() => client.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: failedUpload }), { abortSignal: signal })));
throw error;
} finally {
input.signal?.removeEventListener("abort", onAbort);
}
}