fix(aws-lambda): lambda sites create uploads source files over 2 GiB and 5 GB (#5964)

* fix(aws-lambda): lambda sites create uploads source files over 2 GiB and 5 GB

Site ids are hashed in fixed-size reads instead of whole-file reads, so files
over 2 GiB no longer fail; every existing site keeps its id.

Files over S3's 5 GB single-PUT limit upload in parts that S3 checks one by
one, and the object is created only after the whole file's SHA-256 matches.
Content-store refresh and render-time verification handle those objects too.

* fix(aws-lambda): keep small-site hashing as fast as before

Reads use a 256 KiB buffer: the 8 MiB one made hashing small projects slower,
and big files hash no slower with the smaller buffer.

* perf(aws-lambda): stream big uploads from disk with 8 parts in flight

Each part is hashed from disk and then streamed from disk instead of held in
memory, so a 5 GB upload peaks near 150 MB instead of 500 MB. Eight parts go
up at once, and a part whose connection drops is resent from a fresh read.
This commit is contained in:
Miguel Ángel
2026-10-20 17:52:18 +00:00
committed by GitHub
parent ba1fa8b12a
commit c8f322c259
5 changed files with 474 additions and 64 deletions
+14 -30
View File
@@ -2,16 +2,14 @@
// chunk's mdat payload with UploadPartCopy, so almost no video byte passes through the function.
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
type CompletedPart,
GetObjectCommand,
type S3Client,
UploadPartCommand,
UploadPartCopyCommand,
} from "@aws-sdk/client-s3";
import { buildStitchedHeader, readMp4Layout } from "@hyperframes/producer/distributed";
import { parseS3Uri } from "./s3Transport.js";
import { multipartUpload, type MultipartTarget, parseS3Uri } from "./s3Transport.js";
const MIN_PART_BYTES = 5 * 1024 * 1024;
@@ -64,9 +62,7 @@ async function getRange(s3: S3Client, uri: string, start: number, end: number):
return Buffer.from(await response.Body.transformToByteArray());
}
type UploadTarget = { Bucket: string; Key: string; UploadId: string };
async function sendPart(s3: S3Client, part: Part, partNumber: number, target: UploadTarget) {
async function sendPart(s3: S3Client, part: Part, partNumber: number, target: MultipartTarget) {
if ("copy" in part) {
const { uri, start, end } = part.copy;
const source = parseS3Uri(uri);
@@ -90,7 +86,11 @@ async function sendPart(s3: S3Client, part: Part, partNumber: number, target: Up
}
/** Settles every part before failing, so an abort never races a part still in flight. */
async function sendParts(s3: S3Client, parts: readonly Part[], target: UploadTarget) {
async function sendParts(
s3: S3Client,
parts: readonly Part[],
target: MultipartTarget,
): Promise<CompletedPart[]> {
const settled = await Promise.allSettled(
parts.map(async (part, index) => {
const etag = await sendPart(s3, part, index + 1, target);
@@ -102,7 +102,10 @@ async function sendParts(s3: S3Client, parts: readonly Part[], target: UploadTar
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (failure) throw failure.reason;
return settled.map((result) => (result as PromiseFulfilledResult<string>).value);
return settled.map((result, index) => ({
ETag: (result as PromiseFulfilledResult<string>).value,
PartNumber: index + 1,
}));
}
/** Writes `parts` in order to `destUri`; a failure aborts the upload so no parts linger. */
@@ -113,28 +116,9 @@ export async function uploadParts(
contentType: string,
): Promise<void> {
const { bucket, key } = parseS3Uri(destUri);
const created = await s3.send(
new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: contentType }),
await multipartUpload(s3, { Bucket: bucket, Key: key, ContentType: contentType }, (target) =>
sendParts(s3, parts, target),
);
if (!created.UploadId) throw new Error(`[s3Stitch] no upload id for ${destUri}`);
const target = { Bucket: bucket, Key: key, UploadId: created.UploadId };
try {
const etags = await sendParts(s3, parts, target);
await s3.send(
new CompleteMultipartUploadCommand({
...target,
MultipartUpload: { Parts: etags.map((ETag, index) => ({ ETag, PartNumber: index + 1 })) },
}),
);
} catch (error) {
await s3.send(new AbortMultipartUploadCommand(target)).catch((abortError: unknown) => {
console.error(
`[s3Stitch] abort failed for ${destUri}; parts remain until a lifecycle rule removes them`,
abortError,
);
});
throw error;
}
}
export interface StitchResult {
+181 -1
View File
@@ -7,13 +7,15 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CONTENT_STORE_REFRESH_AFTER_DAYS, contentStoreUri } from "./contentStore.js";
import { HeadObjectCommand, S3Client } from "@aws-sdk/client-s3";
import {
downloadS3ObjectToFileVerified,
ensureContentStoreObject,
MAX_SINGLE_PUT_BYTES,
formatS3Uri,
parseS3Uri,
presignVerifiedS3GetUrl,
@@ -276,6 +278,184 @@ describe("presignVerifiedS3GetUrl", () => {
});
});
describe("objects over the single-PUT limit", () => {
const bigSize = MAX_SINGLE_PUT_BYTES + 1;
// sha256 of bigSize zero bytes, computed outside this code.
const bigDigest = "edcddf01fc829bf06be2b5393a9793cdd43598a0fd483c57f41a9b58183f6e33";
function sparseFile(name: string): string {
const path = join(scratchRoot, name);
writeFileSync(path, "");
truncateSync(path, bigSize);
return path;
}
it("stores a file over 5 GB in checked parts, created only if the key is free", async () => {
const source = sparseFile("big-upload.bin");
const s3 = new MultipartFakeS3();
try {
expect(await putContentStoreFile(s3.asClient(), source, "bucket", bigDigest)).toBe(
"uploaded",
);
} finally {
rmSync(source);
}
const [create, complete] = [s3.calls[1]!, s3.calls.at(-1)!];
expect(s3.calls.map((c) => c.name)).not.toContain("PutObjectCommand");
expect(create).toMatchObject({
name: "CreateMultipartUploadCommand",
input: { Metadata: { sha256: bigDigest }, ChecksumAlgorithm: "SHA256" },
});
expect(complete).toMatchObject({
name: "CompleteMultipartUploadCommand",
input: { IfNoneMatch: "*", MpuObjectSize: bigSize },
});
expect(s3.partSizes.reduce((a, b) => a + b, 0)).toBe(bigSize);
expect(s3.partSizes.length).toBeLessThanOrEqual(10_000);
expect(s3.maxPartsInFlight).toBeGreaterThan(1);
expect(s3.maxPartsInFlight).toBeLessThanOrEqual(8);
}, 120_000);
it("resends a part from a fresh read after a dropped connection", async () => {
const source = sparseFile("big-retry.bin");
const s3 = new MultipartFakeS3();
s3.failNextPart = true;
try {
expect(await putContentStoreFile(s3.asClient(), source, "bucket", bigDigest)).toBe(
"uploaded",
);
} finally {
rmSync(source);
}
expect(s3.partSizes.reduce((a, b) => a + b, 0)).toBe(bigSize);
expect(s3.calls.at(-1)).toMatchObject({ name: "CompleteMultipartUploadCommand" });
}, 120_000);
it("aborts instead of completing when the file no longer has the digest it was stored under", async () => {
const source = sparseFile("big-changed.bin");
const s3 = new MultipartFakeS3();
try {
await expect(
putContentStoreFile(s3.asClient(), source, "bucket", "0".repeat(64)),
).rejects.toMatchObject({ name: "PLAN_ARTIFACT_DIGEST_MISMATCH" });
} finally {
rmSync(source);
}
const names = s3.calls.map((c) => c.name);
expect(names).toContain("AbortMultipartUploadCommand");
expect(names).not.toContain("CompleteMultipartUploadCommand");
}, 120_000);
it("refreshes an aging object over 5 GB by copying it onto itself in parts", async () => {
const s3 = new MultipartFakeS3();
s3.head = {
ContentLength: bigSize,
Metadata: { sha256: bigDigest },
LastModified: new Date(Date.now() - CONTENT_STORE_REFRESH_AFTER_DAYS * 86_400_000),
};
await ensureContentStoreObject(s3.asClient(), "bucket", bigDigest, bigSize);
const copies = s3.calls.filter((c) => c.name === "UploadPartCopyCommand");
expect(copies.map((c) => c.input.CopySourceRange)).toEqual([
`bytes=0-${MAX_SINGLE_PUT_BYTES - 1}`,
`bytes=${MAX_SINGLE_PUT_BYTES}-${MAX_SINGLE_PUT_BYTES}`,
]);
expect(s3.calls.at(-1)).toMatchObject({ name: "CompleteMultipartUploadCommand" });
expect(s3.calls[1]).toMatchObject({ input: { Metadata: { sha256: bigDigest } } });
});
it("signs a multipart object by its recorded digest only above the single-PUT limit", async () => {
const uri = contentStoreUri("bucket", bigDigest);
const head = {
ChecksumSHA256: "Y29tcG9zaXRl-2",
ChecksumType: "COMPOSITE",
Metadata: { sha256: bigDigest },
};
const holding = (ContentLength: number) => {
const s3 = new MultipartFakeS3();
s3.head = { ...head, ContentLength };
return s3.asClient();
};
expect(await presignVerifiedS3GetUrl(holding(bigSize), uri, bigDigest, bigSize, 60)).toContain(
bigDigest,
);
await expect(
presignVerifiedS3GetUrl(holding(42), uri, bigDigest, 42, 60),
).rejects.toMatchObject({ name: "PLAN_ARTIFACT_DIGEST_MISMATCH" });
});
});
/** Behaves like S3 at the size limits and records part sizes instead of holding the bytes. */
class MultipartFakeS3 {
readonly calls: { name: string; input: Record<string, unknown> }[] = [];
readonly partSizes: number[] = [];
head: Record<string, unknown> | undefined;
failNextPart = false;
maxPartsInFlight = 0;
#inFlight = 0;
asClient(): S3Client {
const client = new S3Client({
region: "us-east-2",
credentials: { accessKeyId: "AKIDTEST", secretAccessKey: "test-secret" },
});
client.send = ((command: { constructor: { name: string }; input: Record<string, unknown> }) =>
this.send(command)) as S3Client["send"];
return client;
}
async send(command: {
constructor: { name: string };
input: Record<string, unknown>;
}): Promise<unknown> {
const { name } = command.constructor;
const { Body, ...input } = command.input;
this.calls.push({ name, input });
if (name === "HeadObjectCommand") {
if (this.head) return this.head;
throw Object.assign(new Error("not found"), {
name: "NotFound",
$metadata: { httpStatusCode: 404 },
});
}
if (name === "PutObjectCommand" && Number(input.ContentLength) > MAX_SINGLE_PUT_BYTES) {
(Body as NodeJS.ReadableStream & { destroy(): void }).destroy();
throw Object.assign(new Error("EntityTooLarge"), { name: "EntityTooLarge" });
}
if (name === "CopyObjectCommand" && Number(this.head?.ContentLength) > MAX_SINGLE_PUT_BYTES) {
throw Object.assign(new Error("copy source too large"), { name: "InvalidRequest" });
}
if (name === "CreateMultipartUploadCommand") return { UploadId: "upload-1" };
if (name === "UploadPartCommand") {
this.maxPartsInFlight = Math.max(this.maxPartsInFlight, ++this.#inFlight);
try {
const hash = createHash("sha256");
let length = 0;
for await (const chunk of Body as AsyncIterable<Buffer>) {
hash.update(chunk);
length += chunk.length;
}
if (this.failNextPart) {
this.failNextPart = false;
throw Object.assign(new Error("connection reset"), { name: "ECONNRESET" });
}
if (length !== input.ContentLength || hash.digest("base64") !== input.ChecksumSHA256) {
throw Object.assign(new Error("part checksum mismatch"), { name: "BadDigest" });
}
this.partSizes.push(length);
return { ETag: `"part-${String(input.PartNumber)}"` };
} finally {
this.#inFlight -= 1;
}
}
if (name === "UploadPartCopyCommand") {
return { CopyPartResult: { ETag: `"copy-${String(input.PartNumber)}"` } };
}
if (name === "CompleteMultipartUploadCommand" || name === "AbortMultipartUploadCommand") {
return {};
}
throw new Error(`unexpected command ${name}`);
}
}
class ContentAddressedFakeS3 {
readonly objects = new Map<string, { bytes: Buffer; sha256: string; lastModified?: Date }>();
readonly copies: Record<string, unknown>[] = [];
+250 -26
View File
@@ -25,20 +25,35 @@ import {
rmSync,
statSync,
} from "node:fs";
import { createHash } from "node:crypto";
import { createHash, type Hash } from "node:crypto";
import { dirname } from "node:path";
import { pipeline } from "node:stream/promises";
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
type CompletedPart,
CopyObjectCommand,
CreateMultipartUploadCommand,
type CreateMultipartUploadCommandInput,
GetObjectCommand,
HeadObjectCommand,
type HeadObjectCommandOutput,
PutObjectCommand,
type S3Client,
UploadPartCommand,
UploadPartCopyCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import * as tar from "tar";
import { CONTENT_STORE_REFRESH_AFTER_DAYS, contentStoreUri } from "./contentStore.js";
/** S3's largest PutObject or CopyObject; bigger objects go multipart. */
export const MAX_SINGLE_PUT_BYTES = 5 * 1024 ** 3;
const MAX_PARTS = 10_000;
const MIN_FILE_PART_BYTES = 64 * 1024 * 1024;
const FILE_PARTS_IN_FLIGHT = 8;
const PART_ATTEMPTS = 3;
/** Parsed `s3://bucket/key` URI. */
export interface S3Location {
bucket: string;
@@ -121,7 +136,9 @@ export async function presignVerifiedS3GetUrl(
new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" }),
);
const expectedChecksum = Buffer.from(expectedSha256, "hex").toString("base64");
if (head.ChecksumSHA256 !== expectedChecksum || head.ContentLength !== expectedSize) {
const attested =
head.ChecksumSHA256 === expectedChecksum || isDigestCheckedMultipart(head, expectedSha256);
if (!attested || head.ContentLength !== expectedSize) {
const error = new Error(
`[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${uri} expected ${expectedSha256} (${expectedSize} bytes), S3 holds checksum ${String(head.ChecksumSHA256)} (${String(head.ContentLength)} bytes)`,
);
@@ -133,13 +150,17 @@ export async function presignVerifiedS3GetUrl(
});
}
/**
* Upload a local file's contents to an S3 URI using a streaming
* `PutObjectCommand`. PutObject's 5 GB cap comfortably exceeds the
* distributed pipeline's 2 GB planDir limit and the typical
* chunk size (≤ 200 MB), so a single PUT works for every artifact this
* adapter handles.
*/
// S3 keeps only a checksum of part checksums for a multipart object; its uploader
// checked the whole-file digest it records before completing (uploadFileInParts).
function isDigestCheckedMultipart(head: HeadObjectCommandOutput, expectedSha256: string): boolean {
return (
head.ChecksumType === "COMPOSITE" &&
(head.ContentLength ?? 0) > MAX_SINGLE_PUT_BYTES &&
head.Metadata?.sha256 === expectedSha256
);
}
/** Upload a local file to an S3 URI, streamed: one PutObject up to S3's 5 GB cap, multipart above. */
export async function uploadFileToS3(
client: S3Client,
localPath: string,
@@ -151,6 +172,14 @@ export async function uploadFileToS3(
}
const { bucket, key } = parseS3Uri(uri);
const size = statSync(localPath).size;
if (size > MAX_SINGLE_PUT_BYTES) {
await uploadFileInParts(client, localPath, size, {
Bucket: bucket,
Key: key,
ContentType: contentType,
});
return;
}
await client.send(
new PutObjectCommand({
Bucket: bucket,
@@ -183,7 +212,7 @@ async function putImmutableFile(
uri: string,
expectedSha256: string,
contentType?: string,
): Promise<{ outcome: "uploaded" | "reused"; lastModified?: Date }> {
): Promise<{ outcome: "uploaded" | "reused"; lastModified?: Date; size?: number }> {
assertSha256(expectedSha256);
if (!existsSync(localPath)) {
throw new Error(`[s3Transport] upload source missing: ${localPath}`);
@@ -192,9 +221,39 @@ async function putImmutableFile(
const size = statSync(localPath).size;
const existing = await inspectContentAddressedObject(client, bucket, key, size, expectedSha256);
if (existing.state === "matching")
return { outcome: "reused", lastModified: existing.lastModified };
return { outcome: "reused", lastModified: existing.lastModified, size };
if (existing.state === "conflict") throwImmutableObjectConflict(uri);
// HEAD followed by an unconditional PUT can overwrite a conflicting object published by a
// concurrent planner. Conditional create (IfNoneMatch) makes immutable CAS and fixed-key
// manifest publication race-safe.
try {
if (size > MAX_SINGLE_PUT_BYTES) {
const create = { Bucket: bucket, Key: key, ContentType: contentType };
await uploadFileInParts(client, localPath, size, create, expectedSha256);
} else {
await putImmutableBody(client, localPath, bucket, key, size, expectedSha256, contentType);
}
return { outcome: "uploaded" };
} catch (error) {
if (!isS3PreconditionFailed(error)) throw error;
const raced = await inspectContentAddressedObject(client, bucket, key, size, expectedSha256);
if (raced.state === "matching") return { outcome: "reused" };
if (raced.state === "conflict") throwImmutableObjectConflict(uri);
// No winner left to verify: rethrow the service error so the orchestrator may retry.
throw error;
}
}
async function putImmutableBody(
client: S3Client,
localPath: string,
bucket: string,
key: string,
size: number,
expectedSha256: string,
contentType?: string,
): Promise<void> {
const body = createReadStream(localPath);
try {
await client.send(
@@ -206,21 +265,9 @@ async function putImmutableFile(
ContentLength: size,
Metadata: { sha256: expectedSha256 },
ChecksumSHA256: Buffer.from(expectedSha256, "hex").toString("base64"),
// HEAD followed by an unconditional PUT can overwrite a conflicting
// object published by a concurrent planner. Conditional create makes
// immutable CAS and fixed-key manifest publication race-safe.
IfNoneMatch: "*",
}),
);
return { outcome: "uploaded" };
} catch (error) {
if (!isS3PreconditionFailed(error)) throw error;
const raced = await inspectContentAddressedObject(client, bucket, key, size, expectedSha256);
if (raced.state === "matching") return { outcome: "reused" };
if (raced.state === "conflict") throwImmutableObjectConflict(uri);
// The winning object was deleted between the conditional failure and
// verification. Preserve the service error so the orchestrator may retry.
throw error;
} finally {
// A failed conditional request may reject before consuming the stream.
// Explicit teardown avoids retaining the source descriptor on a warm
@@ -229,6 +276,147 @@ async function putImmutableFile(
}
}
export type MultipartTarget = { Bucket: string; Key: string; UploadId: string };
/**
* Creates a multipart upload, lets `sendParts` fill it, then completes it. A failure aborts the
* upload so no parts linger; `sendParts` must settle every part before it rejects.
*/
export async function multipartUpload(
client: S3Client,
create: CreateMultipartUploadCommandInput & { Bucket: string; Key: string },
sendParts: (target: MultipartTarget) => Promise<CompletedPart[]>,
complete: { ifNoneMatch?: "*"; objectSize?: number } = {},
): Promise<void> {
const created = await client.send(new CreateMultipartUploadCommand(create));
const uri = formatS3Uri({ bucket: create.Bucket, key: create.Key });
if (!created.UploadId) throw new Error(`[s3Transport] no multipart upload id for ${uri}`);
const target = { Bucket: create.Bucket, Key: create.Key, UploadId: created.UploadId };
try {
const parts = await sendParts(target);
await client.send(
new CompleteMultipartUploadCommand({
...target,
MultipartUpload: { Parts: parts },
IfNoneMatch: complete.ifNoneMatch,
MpuObjectSize: complete.objectSize,
}),
);
} catch (error) {
await client.send(new AbortMultipartUploadCommand(target)).catch((abortError: unknown) => {
console.error(
`[s3Transport] abort failed for ${uri}; parts remain until a lifecycle rule removes them`,
abortError,
);
});
throw error;
}
}
// Hashes each part from disk in order, then streams it from disk, so memory stays flat; S3 checks
// each part's SHA-256. With
// `expectedSha256`, the object exists only if the whole file matches it and the key is free.
async function uploadFileInParts(
client: S3Client,
localPath: string,
size: number,
create: CreateMultipartUploadCommandInput & { Bucket: string; Key: string },
expectedSha256?: string,
): Promise<void> {
const partBytes = Math.max(MIN_FILE_PART_BYTES, Math.ceil(size / MAX_PARTS));
const sendParts = async (target: MultipartTarget): Promise<CompletedPart[]> => {
const whole = createHash("sha256");
const parts: Promise<CompletedPart>[] = [];
const inFlight = new Set<Promise<void>>();
let failure: { error: unknown } | undefined;
try {
for (let start = 0; start < size && !failure; start += partBytes) {
if (inFlight.size >= FILE_PARTS_IN_FLIGHT) await Promise.race(inFlight);
const range = { start, end: Math.min(start + partBytes, size) };
const checksum = await hashRange(localPath, range, whole);
const sent = sendFilePart(client, target, parts.length + 1, localPath, range, checksum);
parts.push(sent);
const slot: Promise<void> = sent.then(
() => void inFlight.delete(slot),
(error: unknown) => {
failure ??= { error };
inFlight.delete(slot);
},
);
inFlight.add(slot);
}
} finally {
await Promise.all(inFlight);
}
if (failure) throw failure.error;
const actual = whole.digest("hex");
if (expectedSha256 && actual !== expectedSha256) {
const error = new Error(
`[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${localPath} changed: expected ${expectedSha256}, read ${actual}`,
);
error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
throw error;
}
return Promise.all(parts);
};
await multipartUpload(
client,
{
...create,
Metadata: expectedSha256 ? { sha256: expectedSha256 } : undefined,
ChecksumAlgorithm: "SHA256",
},
sendParts,
{ ifNoneMatch: expectedSha256 ? "*" : undefined, objectSize: size },
);
}
type ByteRange = { start: number; end: number };
/** SHA-256 (base64) of `[start, end)` of the file, also fed to `whole`. */
async function hashRange(path: string, { start, end }: ByteRange, whole: Hash): Promise<string> {
const part = createHash("sha256");
let read = 0;
for await (const chunk of createReadStream(path, { start, end: end - 1 })) {
whole.update(chunk as Buffer);
part.update(chunk as Buffer);
read += (chunk as Buffer).length;
}
if (read !== end - start) throw new Error(`[s3Transport] ${path} shrank while uploading`);
return part.digest("base64");
}
// A streamed body cannot be replayed by the SDK's own retry, so each attempt opens a fresh stream.
async function sendFilePart(
client: S3Client,
target: MultipartTarget,
partNumber: number,
path: string,
{ start, end }: ByteRange,
checksum: string,
): Promise<CompletedPart> {
for (let attempt = 1; ; attempt++) {
const body = createReadStream(path, { start, end: end - 1 });
try {
const result = await client.send(
new UploadPartCommand({
...target,
PartNumber: partNumber,
Body: body,
ContentLength: end - start,
ChecksumSHA256: checksum,
}),
);
if (!result.ETag) throw new Error(`[s3Transport] part ${partNumber} returned no ETag`);
return { ETag: result.ETag, PartNumber: partNumber, ChecksumSHA256: checksum };
} catch (error) {
if (attempt >= PART_ATTEMPTS) throw error;
} finally {
body.destroy();
}
}
}
export async function mapConcurrent<T>(
values: readonly T[],
concurrency: number,
@@ -301,8 +489,8 @@ export async function putContentStoreFile(
sha256: string,
): Promise<"uploaded" | "reused"> {
const uri = contentStoreUri(bucket, sha256);
const { outcome, lastModified } = await putImmutableFile(client, localPath, uri, sha256);
if (lastModified) await refreshAgingObject(client, uri, sha256, lastModified);
const { outcome, lastModified, size } = await putImmutableFile(client, localPath, uri, sha256);
if (lastModified) await refreshAgingObject(client, uri, sha256, lastModified, size ?? 0);
return outcome;
}
@@ -317,7 +505,9 @@ export async function ensureContentStoreObject(
const { key } = parseS3Uri(uri);
const existing = await inspectContentAddressedObject(client, bucket, key, sizeBytes, sha256);
if (existing.state === "matching") {
if (existing.lastModified) await refreshAgingObject(client, uri, sha256, existing.lastModified);
if (existing.lastModified) {
await refreshAgingObject(client, uri, sha256, existing.lastModified, sizeBytes);
}
return;
}
if (existing.state === "conflict") throwImmutableObjectConflict(uri);
@@ -345,10 +535,44 @@ async function refreshAgingObject(
uri: string,
sha256: string,
lastModified: Date,
size: number,
): Promise<void> {
const { bucket, key } = parseS3Uri(uri);
const ageMs = Date.now() - lastModified.getTime();
if (ageMs < CONTENT_STORE_REFRESH_AFTER_DAYS * 86_400_000) return;
if (size > MAX_SINGLE_PUT_BYTES) {
const create = {
Bucket: bucket,
Key: key,
Metadata: { sha256 },
ChecksumAlgorithm: "SHA256" as const,
};
await multipartUpload(client, create, async (target) => {
// ponytail: serial copies (a 6 GB object is two parts); parallel if refresh time matters.
const parts: CompletedPart[] = [];
for (let start = 0; start < size; start += MAX_SINGLE_PUT_BYTES) {
const end = Math.min(start + MAX_SINGLE_PUT_BYTES, size);
const PartNumber = parts.length + 1;
const { CopyPartResult } = await client.send(
new UploadPartCopyCommand({
...target,
PartNumber,
CopySource: `${bucket}/${key}`,
CopySourceRange: `bytes=${start}-${end - 1}`,
}),
);
if (!CopyPartResult?.ETag)
throw new Error(`[s3Transport] copy part ${PartNumber} of ${uri} returned no ETag`);
parts.push({
ETag: CopyPartResult.ETag,
PartNumber,
ChecksumSHA256: CopyPartResult.ChecksumSHA256,
});
}
return parts;
});
return;
}
await client.send(
new CopyObjectCommand({
Bucket: bucket,
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "bun:test";
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, truncateSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { hashProjectDir, listSiteFiles } from "./projectHash.js";
@@ -71,4 +71,19 @@ describe("listSiteFiles / hashProjectDir", () => {
.slice(0, 16);
expect(hashProjectDir(dir)).not.toBe(unversioned);
});
it("hashes a file larger than a runtime can read in one call", () => {
const dir = project();
writeFileSync(join(dir, "big.mov"), "");
truncateSync(join(dir, "big.mov"), 2 ** 32 + 1);
// Same bytes hashed outside this code: "site-v2\0big.mov\0" + 4 GiB + 1 zero bytes + index.html.
expect(hashProjectDir(dir)).toBe("014f8f69f7633edb");
}, 60_000);
it("keeps the site id of a small project unchanged", () => {
const dir = project();
mkdirSync(join(dir, "assets"));
writeFileSync(join(dir, "assets", "clip.mp4"), Buffer.from([0, 1, 2, 255]));
expect(hashProjectDir(dir)).toBe("12972d522bd6829d");
});
});
@@ -9,13 +9,14 @@
* exactly why it lives here rather than being copy-pasted per adapter.
*/
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
import { closeSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { PLAN_PROJECT_DIR_SKIP_SEGMENTS } from "./plan.js";
// Bump when the hashed set changes so uploads cached under an older scheme are never reused.
const SITE_ID_SCHEME = "site-v2\0";
const READ_CHUNK_BYTES = 256 * 1024;
/**
* The files a site upload ships, as `/`-separated paths under `projectDir`. Symlinks are followed
@@ -62,20 +63,26 @@ export function listSiteFiles(projectDir: string): string[] {
* same-bytes rename still yields a fresh id. We trim to 16 chars because the
* full 64 isn't useful in an object key for legibility. Pass `files` when the
* caller already listed them, so the hashed set is the shipped set.
*
* Reads are synchronous: project trees are typically tens of MB at most
* (HTML/CSS/JS plus a few composition assets), so the simpler shape wins over
* a streaming pipeline.
* Files are read in fixed-size chunks, so no file is ever held whole in memory.
*/
export function hashProjectDir(
projectDir: string,
files: readonly string[] = listSiteFiles(projectDir),
): string {
const hash = createHash("sha256").update(SITE_ID_SCHEME);
const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES);
for (const rel of files) {
hash.update(rel);
hash.update("\0");
hash.update(readFileSync(join(projectDir, rel)));
const fd = openSync(join(projectDir, rel), "r");
try {
let read: number;
while ((read = readSync(fd, chunk, 0, chunk.length, null)) > 0) {
hash.update(chunk.subarray(0, read));
}
} finally {
closeSync(fd);
}
}
return hash.digest("hex").slice(0, 16);
}