feat(worker): parallel multipart uploads for large artifacts
Deploy / deploy (push) Successful in 3m18s

From the studio a single connection to Hetzner nbg1 (188 ms away) is
capped near 0.1 MB/s, although a browser speed test shows 50 Mbps.
Measured: 1 connection 0.13 MB/s, 8 -> 0.64, 24 -> 1.69 MB/s. At the old
speed a ProRes MOV could not finish inside the 30 min request timeout.

- Worker 1.7.2 uploads files >= 16 MB as S3 multipart: 8 MB parts on
  uploadConcurrency connections (config.json, default 16), each part
  retried up to 4 times; aborts the upload if a part keeps failing.
- artifact-presign returns uploadId + per-part presigned URLs when asked
  for multipart; new E20b artifact-complete stitches or aborts.
- Old workers and old servers fall back to the single PUT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-01 18:48:28 +02:00
parent 477aef7b27
commit 91e69f0bcb
11 changed files with 530 additions and 21 deletions
+63
View File
@@ -17,6 +17,10 @@ import {
GetObjectCommand,
ListObjectsV2Command,
HeadObjectCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import fs from "fs";
@@ -323,6 +327,65 @@ export async function generateHetznerPresignedUploadUrl(
return getSignedUrl(client, command, { expiresIn });
}
/**
* Multipart upload with presigned part URLs, for the render worker's large
* files. A single PUT from the studio to nbg1 is capped near 0.1 MB/s per
* connection (188 ms away, lossy); parts uploaded in parallel scale almost
* linearly with the number of connections.
*
* Part URLs live 6 h: parts queue behind each other, and on a slow uplink the
* last ones can start long after the upload began.
*/
export async function createHetznerMultipartUpload(
key: string,
contentType: string,
partCount: number,
expiresIn: number = 6 * 3600
): Promise<{ uploadId: string; partUrls: string[] }> {
const { client, bucket } = await buildHetznerClient();
const created = await client.send(
new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: contentType })
);
if (!created.UploadId) throw new Error("Storage did not return an UploadId");
const uploadId = created.UploadId;
const partUrls = await Promise.all(
Array.from({ length: partCount }, (_, i) =>
getSignedUrl(
client,
new UploadPartCommand({ Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: i + 1 }),
{ expiresIn }
)
)
);
return { uploadId, partUrls };
}
export async function completeHetznerMultipartUpload(
key: string,
uploadId: string,
parts: { partNumber: number; etag: string }[]
): Promise<void> {
const { client, bucket } = await buildHetznerClient();
await client.send(
new CompleteMultipartUploadCommand({
Bucket: bucket,
Key: key,
UploadId: uploadId,
MultipartUpload: {
Parts: [...parts]
.sort((a, b) => a.partNumber - b.partNumber)
.map((p) => ({ PartNumber: p.partNumber, ETag: p.etag })),
},
})
);
}
/** Frees the parts already stored; otherwise they are billed until expiry. */
export async function abortHetznerMultipartUpload(key: string, uploadId: string): Promise<void> {
const { client, bucket } = await buildHetznerClient();
await client.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId }));
}
/**
* In-process cache for Hetzner key existence checks.
* Positive hits (key exists) are cached indefinitely for the process lifetime