perf(storage): upload workspace snapshots in parallel verified parts (#19917)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - With remote (sandbox) execution, every agent turn ends by saving the
workspace: capture an archive, encrypt it, upload it to object storage,
and verify it.
> - On a busy S3-backed instance, the upload step is the slowest part of
a save. A 1.5 GB workspace can hold a run open for minutes after the
agent finished.
> - The upload step reads the archive several times, uploads through one
stream with 4 parts in flight, and then downloads the whole object again
to verify it.
> - Parts that use the stream path share one process-wide 32 MiB buffer
window, so saves that run at the same time slow each other down.
> - This pull request uploads parts in parallel straight from the
encrypted file, lets the backend verify every part with Content-MD5, and
replaces the full read-back with a size check for those verified
uploads.
> - The benefit is faster saves, a smaller run-finalization window, and
no interference between concurrent saves.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
Remote workspace snapshot upload (`uploadWorkspaceSnapshot`) to
S3-compatible storage.

**Subsystem affected**
Server storage (`server/src/storage/*`) and remote workspace snapshot
storage.

**Current behavior**
For one save, the upload step:
1. hashes the archive;
2. reads the archive again to encrypt it into a temporary file;
3. hashes the encrypted file;
4. uploads it as one stream with 4 parts in flight;
5. downloads the whole object to verify it.

On one hosted instance over 7 days (724 saves, median 731 MB), the
upload step ran at a median of 6.5 MB/s. With no other save running it
reached 11–14 MB/s. With six or more saves at the same time it fell to
4.4 MB/s. From the same region and with the same part settings, a plain
client reached 31 MB/s to the same kind of bucket.

**Proposed behavior**
Upload parts in parallel from byte ranges of the encrypted file, with
Content-MD5 on each part. Confirm only the stored size after a verified
upload. Digest the archive in the same pass that encrypts it.

**Reason and benefit**
Saves finish faster and hold their run open for less time. Concurrent
saves no longer serialize on the shared buffer window. A transient part
failure is retried from disk instead of failing the whole save.

**Breaking changes**
None. `StorageProvider.putObject` may now return a receipt. Providers
that return nothing keep the current behavior, including the full
read-back.

Related: none found.

## What Changed

- `storage/types.ts`, `storage/service.ts`: an upload can name its
`sourceFile`, a private file whose bytes are exactly the body.
`putObject` may return `{ integrity: "server_verified_md5" }`, and
`putFile` passes it through as `PutFileResult.integrity`.
- `storage/s3-multipart.ts`: new `putS3MultipartFile`.
- Each part reads its own byte range of the file. Up to 8 parts upload
at once, with no shared buffer window.
  - Each part carries the Content-MD5 of its range.
  - A failed part is resent from disk, up to 3 attempts.
- The first part that runs out of attempts stops its siblings. The
multipart upload is aborted only after every part settles.
- A source file whose size differs from the declared length is refused
before any request.
- `storage/s3-provider.ts`:
- A `sourceFile` larger than 16 MiB uses `putS3MultipartFile`. A smaller
one uses one PUT with Content-MD5.
- These requests use a second client with `requestChecksumCalculation:
"WHEN_REQUIRED"`, so bodies are plain and the backend verifies
Content-MD5 directly. The default client wraps bodies in aws-chunked
framing with a CRC32 trailer.
  - Other uploads are unchanged.
- `services/remote-workspace-snapshot-storage.ts`:
- Encryption digests the plaintext in the same read. An unchanged file
identity (dev, inode, size, mtime, ctime) across that read replaces the
separate digest pass. A change still fails with `archive_changed` before
any upload.
- The encrypted file is still digested from disk, so the recorded hash
covers the exact bytes that the parts read.
- The upload passes `sourceFile`. When the provider reports
`server_verified_md5`, a HEAD confirms the stored size instead of
downloading the object.
  - The full read-back stays for:
    - other providers (local disk);
    - recovery of earlier attempts;
    - the caller that keeps a verified archive file.

## Verification

- `pnpm --filter @paperclipai/server typecheck`: exit 0.
- `vitest run src/__tests__/storage-s3-multipart.test.ts
src/services/remote-workspace-snapshot-storage.test.ts`: 59 passed. New
tests:
- every part carries the Content-MD5 of its range, and 8 parts are in
flight;
  - a failed part is resent and the upload completes;
- a part that keeps failing aborts the upload only after in-flight
siblings settle;
  - a size mismatch is refused before any request;
- a small file goes as one PUT with Content-MD5, through a real HTTP
server;
- the read-back is skipped only for a server-verified upload, and the
snapshot still restores;
  - the read-back stays when the caller keeps the read-back file;
  - a stored size mismatch is rejected;
  - an archive that changes during encryption is rejected before upload.
- With the source change reverted, the three new snapshot tests fail.
- Real S3-compatible bucket (a Railway bucket, run from a workstation):
- A 3 MiB single PUT stored exactly: HEAD size, ETag and read-back
SHA-256 all matched.
  - A 96 MiB, 13-part upload stored exactly, with the same checks.
  - A part with a wrong Content-MD5 was rejected with `BadDigest`.
- The same backend stored a wrong `x-amz-checksum-sha256` without
checking it. That is why this change uses Content-MD5.
- Measured from a container in the app's region, 1 GiB with the app's
settings (8 MiB parts, 4 in flight), to the same kind of bucket: 31
MB/s. With 16 in flight: 69 MB/s.
- 77 storage-related suites: the only failures are the same 19 that fail
on unmodified `master` in this checkout. They are in 4 files: missing
bundled connector skills and an unbuilt local runner daemon.

## Risks

- Integrity now depends on the backend enforcing Content-MD5 for file
uploads. AWS S3 and the tested S3-compatible backend enforce it. A
backend that ignores Content-MD5 would accept corrupted parts without a
read-back. A later restore still fails closed: AES-GCM authentication
and the plaintext SHA-256 are checked on load.
- Up to 8 parallel part requests per save, instead of 4 shared ones.
Memory per part is a 64 KiB stream buffer.
- Archive-change detection now uses file identity instead of a second
digest. The archive lives in a private temporary directory that the
capture step creates.
- Medium risk overall: the change is limited to file-sourced S3 uploads
and the snapshot verify step.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edits, test runs, S3 API probes). AI-assisted
investigation, change and tests, with human direction.

## 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
- [ ] I have updated relevant documentation to reflect my changes (no
docs describe this path)
- [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

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-11-07 14:04:49 -08:00
1 parent 99529f9d71
commit ee556a266d
7 files changed
+385 -17

No files matched your search

+90 -1
View File
@@ -1,7 +1,11 @@
import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, UploadPartCommand, type S3Client } from "@aws-sdk/client-s3";
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 type { PutObjectInput } from "./types.js";
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";
@@ -122,3 +126,88 @@ export async function putS3Multipart(client: S3Client, bucket: string, key: stri
await cleanupStorageFailure(async () => iterator.return?.());
}
}
export const FILE_PART_CONCURRENCY = 8;
const FILE_PART_ATTEMPTS = 3;
const FILE_READ_BYTES = 1024 * 1024;
/** 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. */
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 withS3FailureDiagnostics("create_multipart_upload", () => client.send(new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: input.contentType }), { abortSignal: 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);
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 attempt = 1; ; attempt++) {
controller.signal.throwIfAborted();
const body = createReadStream(file, { start, end, highWaterMark: 64 * 1024 });
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 }));
if (!result.ETag) throw new Error("S3 upload part is incomplete");
parts[index] = { ETag: result.ETag, PartNumber: index + 1 };
return;
} catch (error) {
// A part read from disk can be resent; the stream path cannot.
if (controller.signal.aborted || attempt >= FILE_PART_ATTEMPTS) throw error;
await delay(attempt * 500, undefined, { signal: controller.signal });
} finally { body.destroy(); }
}
};
let next = 0;
const worker = async () => {
while (next < count) {
const index = next++;
try { await sendPart(index); }
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) }, worker));
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 withS3FailureDiagnostics("complete_multipart_upload", () => client.send(new CompleteMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: id, MultipartUpload: { Parts: parts } }), { abortSignal: controller.signal }));
uploadId = undefined;
return { integrity: "server_verified_md5" };
} catch (error) {
const failedUpload = uploadId;
if (failedUpload) await cleanupStorageFailure(signal => client.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: failedUpload }), { abortSignal: signal }));
throw error;
} finally {
input.signal?.removeEventListener("abort", onAbort);
}
}
+30 -1
View File
@@ -7,7 +7,8 @@ import {
ListObjectsV2Command,
ListObjectVersionsCommand,
} from "@aws-sdk/client-s3";
import { putS3Multipart } from "./s3-multipart.js";
import { assertSourceFileSize, fileRangeMd5, putS3Multipart, putS3MultipartFile } from "./s3-multipart.js";
import { createReadStream } from "node:fs";
import { recordS3BodyFailure, withS3FailureDiagnostics } from "./s3-failure.js";
import { addAbortSignal, Readable } from "node:stream";
import { retryS3RequestTimeouts } from "./s3-request-timeout-retry.js";
@@ -81,6 +82,16 @@ export function createS3StorageProvider(config: S3ProviderConfig): StorageProvid
forcePathStyle: Boolean(config.forcePathStyle),
});
retryS3RequestTimeouts(client);
// File sources carry Content-MD5 as their integrity check. Send plain bodies
// so the backend verifies that digest directly; the default client wraps
// bodies in aws-chunked framing with its own trailing checksum.
const fileClient = new S3Client({
region,
endpoint: config.endpoint,
forcePathStyle: Boolean(config.forcePathStyle),
requestChecksumCalculation: "WHEN_REQUIRED",
});
retryS3RequestTimeouts(fileClient);
async function listExactVersions(key: string, signal?: AbortSignal) {
const versions = new Set<string>(), cursors = new Set<string>();
@@ -140,6 +151,24 @@ export function createS3StorageProvider(config: S3ProviderConfig): StorageProvid
async putObject(input) {
const key = buildKey(prefix, input.objectKey);
const sourceFile = input.sourceFile;
if (sourceFile && input.contentLength > 16 * 1024 * 1024) {
return putS3MultipartFile(fileClient, bucket, key, { ...input, sourceFile });
}
if (sourceFile) {
// Content-MD5 makes the backend reject any byte it did not receive intact.
if (input.body instanceof Readable) input.body.destroy();
await assertSourceFileSize(sourceFile, input.contentLength);
const contentMd5 = await fileRangeMd5(sourceFile, 0, input.contentLength - 1, input.signal);
const body = createReadStream(sourceFile);
try {
await withS3FailureDiagnostics("put_object", () => fileClient.send(new PutObjectCommand({
Bucket: bucket, Key: key, Body: body, ContentType: input.contentType,
ContentLength: input.contentLength, ContentMD5: contentMd5,
}), { abortSignal: input.signal }));
} finally { body.destroy(); }
return { integrity: "server_verified_md5" } as const;
}
if (input.contentLength > 16 * 1024 * 1024) {
await putS3Multipart(client, bucket, key, input);
return;
+5 -2
View File
@@ -1,7 +1,7 @@
import { createHash, randomUUID } from "node:crypto";
import path from "node:path";
import { Readable } from "node:stream";
import type { StorageService, StorageProvider, PutFileInput, PutFileResult } from "./types.js";
import type { StorageService, StorageProvider, PutFileInput, PutFileResult, PutObjectReceipt } from "./types.js";
import { badRequest, forbidden, unprocessable } from "../errors.js";
import { cleanupStorageFailure } from "./failure-cleanup.js";
@@ -114,13 +114,15 @@ export function createStorageService(provider: StorageProvider): StorageService
ensureCompanyPrefix(input.companyId, objectKey);
const byteSize = "byteSize" in input ? input.byteSize : input.body.length;
const contentType = input.contentType.trim().toLowerCase();
let receipt: void | PutObjectReceipt;
try {
await provider.putObject({
receipt = await provider.putObject({
objectKey,
body: input.body,
contentType,
contentLength: byteSize,
signal: input.signal,
...("sourceFile" in input && input.sourceFile ? { sourceFile: input.sourceFile } : {}),
});
} catch (error) {
await cleanupStorageFailure(signal => provider.deleteObject({ objectKey, signal }));
@@ -136,6 +138,7 @@ export function createStorageService(provider: StorageProvider): StorageService
byteSize,
sha256: "sha256" in input ? input.sha256 : hashBuffer(input.body),
originalFilename: input.originalFilename,
...(receipt ? { integrity: receipt.integrity } : {}),
};
},
+19 -2
View File
@@ -9,6 +9,21 @@ export interface PutObjectInput {
body: Buffer | Readable;
contentType: string;
contentLength: number;
/**
* Absolute path of a private file whose bytes are exactly `body`. A provider
* may read ranges of it instead of the stream, so parts can upload in
* parallel and be resent after a transient failure.
*/
sourceFile?: string;
}
/**
* The backend verified every uploaded byte against a digest the client sent
* with it (Content-MD5 on each request), so a read-back is not needed to
* prove the stored content.
*/
export interface PutObjectReceipt {
integrity: "server_verified_md5";
}
export interface GetObjectInput {
@@ -46,7 +61,7 @@ export interface HeadObjectResult {
export interface StorageProvider {
id: StorageProviderId;
putObject(input: PutObjectInput): Promise<void>;
putObject(input: PutObjectInput): Promise<void | PutObjectReceipt>;
getObject(input: GetObjectInput): Promise<GetObjectResult>;
headObject(input: GetObjectInput): Promise<HeadObjectResult>;
deleteObject(input: DeleteObjectInput): Promise<void>;
@@ -70,7 +85,7 @@ export type PutFileInput = {
namespace: string;
originalFilename: string | null;
contentType: string;
} & ({ body: Buffer } | { body: Readable; byteSize: number; sha256: string });
} & ({ body: Buffer } | { body: Readable; byteSize: number; sha256: string; sourceFile?: string });
export interface PutFileResult {
provider: StorageProviderId;
@@ -79,6 +94,8 @@ export interface PutFileResult {
byteSize: number;
sha256: string;
originalFilename: string | null;
/** Present only when the backend verified every byte on receipt. */
integrity?: PutObjectReceipt["integrity"];
}
export interface StorageService {