feat(worker): parallel multipart uploads for large artifacts
Deploy / deploy (push) Successful in 3m18s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user