Skip to content

Commit 3ed73b2

Browse files
committed
Stream large staged file materialization
1 parent 982d1e5 commit 3ed73b2

2 files changed

Lines changed: 182 additions & 23 deletions

File tree

packages/runtime-playground/src/mount-materialization.ts

Lines changed: 123 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash } from "node:crypto"
2-
import { cp, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"
2+
import { cp, lstat, mkdir, mkdtemp, open, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"
33
import { tmpdir } from "node:os"
44
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
55
import { materializationPhaseResult, namedFileTreeSkipPolicyNames, phpStringArrayLiteral, type MaterializationDiagnostic, type MaterializationPhaseResult, type MountSpec } from "@automattic/wp-codebox-core"
@@ -43,7 +43,14 @@ export interface ReadonlyMountStaging {
4343

4444
interface HostMountFilePayload {
4545
target: string
46-
contentsBase64: string
46+
source: string
47+
size: number
48+
}
49+
50+
interface HostMountFileVerificationPayload {
51+
target: string
52+
contentsBase64?: string
53+
sha256?: string
4754
}
4855

4956
interface HostMountDirectoryMaterializationResponse {
@@ -70,6 +77,8 @@ interface HostMountFileVerificationResponse {
7077

7178
const HOST_MOUNT_FILE_BATCH_SIZE = 100
7279
const HOST_MOUNT_DIRECTORY_BATCH_SIZE = 500
80+
const HOST_MOUNT_CHUNKED_WRITE_THRESHOLD = 1024 * 1024
81+
const HOST_MOUNT_WRITE_CHUNK_SIZE = 256 * 1024
7382

7483
/**
7584
* Playground's Node filesystem mount handler is writable. Snapshot readonly
@@ -256,8 +265,8 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
256265
let created = 0
257266
let skipped = 0
258267
const directoryBatch: string[] = []
259-
const fileBatch: HostMountFilePayload[] = []
260-
const verificationBatch: HostMountFilePayload[] = []
268+
const fileBatch: Array<HostMountFilePayload & { contentsBase64: string }> = []
269+
const verificationBatch: HostMountFileVerificationPayload[] = []
261270

262271
const flushDirectories = async () => {
263272
if (directoryBatch.length === 0) {
@@ -290,22 +299,32 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
290299
skipped += result.skipped
291300
}
292301
const writeFilePayload = async (payload: HostMountFilePayload) => {
302+
const target = payload.target.trim()
303+
if (!target || target.includes("\0")) {
304+
skipped++
305+
return
306+
}
307+
if (payload.size > HOST_MOUNT_CHUNKED_WRITE_THRESHOLD) {
308+
const verification = await materializeHostMountFileInChunks(server, { ...payload, target })
309+
materialized++
310+
verificationBatch.push(verification)
311+
if (verificationBatch.length >= HOST_MOUNT_FILE_BATCH_SIZE) {
312+
await flushVerificationBatch()
313+
}
314+
return
315+
}
316+
const contents = await readFile(payload.source)
317+
const contentsBase64 = contents.toString("base64")
293318
if (!server.playground.writeFile) {
294-
fileBatch.push(payload)
319+
fileBatch.push({ ...payload, contentsBase64 })
295320
if (fileBatch.length >= HOST_MOUNT_FILE_BATCH_SIZE) {
296321
await flushFileBatch()
297322
}
298323
return
299324
}
300-
const target = payload.target.trim()
301-
if (!target || target.includes("\0")) {
302-
skipped++
303-
return
304-
}
305-
const contents = Buffer.from(payload.contentsBase64, "base64")
306325
const text = contents.toString("utf8")
307326
if (!Buffer.from(text, "utf8").equals(contents)) {
308-
fileBatch.push(payload)
327+
fileBatch.push({ ...payload, contentsBase64 })
309328
if (fileBatch.length >= HOST_MOUNT_FILE_BATCH_SIZE) {
310329
await flushFileBatch()
311330
}
@@ -314,12 +333,12 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
314333
try {
315334
await server.playground.writeFile(target, text)
316335
materialized++
317-
verificationBatch.push(payload)
336+
verificationBatch.push({ target, contentsBase64 })
318337
if (verificationBatch.length >= HOST_MOUNT_FILE_BATCH_SIZE) {
319338
await flushVerificationBatch()
320339
}
321340
} catch {
322-
const fallback = await materializeHostMountFilesWithPhp(server, [payload], [])
341+
const fallback = await materializeHostMountFilesWithPhp(server, [{ ...payload, contentsBase64 }], [])
323342
materialized += fallback.materialized
324343
created += fallback.created
325344
skipped += fallback.skipped
@@ -384,7 +403,7 @@ async function* hostMountEntriesForVfs(mount: MountSpec): AsyncGenerator<HostMou
384403
if (parent && parent !== ".") {
385404
yield { type: "directory", target: parent }
386405
}
387-
yield { type: "file", file: { target: mount.target, contentsBase64: (await readFile(mount.source)).toString("base64") } }
406+
yield { type: "file", file: { target: mount.target, source: mount.source, size: sourceStat.size } }
388407
return
389408
}
390409
if (!sourceStat.isDirectory()) {
@@ -420,12 +439,13 @@ async function* hostMountEntriesForVfs(mount: MountSpec): AsyncGenerator<HostMou
420439
if (!entry.isFile()) {
421440
continue
422441
}
423-
yield { type: "file", file: { target: `${target}/${relativePath}`, contentsBase64: (await readFile(absolutePath)).toString("base64") } }
442+
const fileStat = await stat(absolutePath)
443+
yield { type: "file", file: { target: `${target}/${relativePath}`, source: absolutePath, size: fileStat.size } }
424444
}
425445
}
426446
}
427447

428-
async function materializeHostMountFilesWithPhp(server: PlaygroundCliServer, files: HostMountFilePayload[], directories: string[]): Promise<{ materialized: number; created: number; skipped: number }> {
448+
async function materializeHostMountFilesWithPhp(server: PlaygroundCliServer, files: Array<HostMountFilePayload & { contentsBase64: string }>, directories: string[]): Promise<{ materialized: number; created: number; skipped: number }> {
429449
const response = await server.playground.run({ code: hostMountWritePhp(files, directories) })
430450
assertPlaygroundResponseOk("playground-staged-input-write", response)
431451
const parsed = parseMaterializationJson<HostMountFileMaterializationResponse>(response.text, "wp-codebox/host-mount-materialization/v1", "playground-staged-input-write")
@@ -436,7 +456,54 @@ async function materializeHostMountFilesWithPhp(server: PlaygroundCliServer, fil
436456
}
437457
}
438458

439-
async function verifyHostMountFilesWithPhp(server: PlaygroundCliServer, files: HostMountFilePayload[]): Promise<{ repaired: number; skipped: number }> {
459+
async function materializeHostMountFileInChunks(server: PlaygroundCliServer, file: HostMountFilePayload): Promise<HostMountFileVerificationPayload> {
460+
const hash = createHash("sha256")
461+
const snapshotDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-staged-file-"))
462+
const snapshot = join(snapshotDirectory, basename(file.source) || "staged-file")
463+
try {
464+
await cp(file.source, snapshot)
465+
const handle = await open(snapshot, "r")
466+
try {
467+
const truncateResponse = await server.playground.run({ code: hostMountChunkWritePhp(file.target) })
468+
assertPlaygroundResponseOk("playground-staged-input-chunked-write", truncateResponse)
469+
assertChunkWriteResult(truncateResponse.text, file.target, 0)
470+
471+
const buffer = Buffer.allocUnsafe(HOST_MOUNT_WRITE_CHUNK_SIZE)
472+
let position = 0
473+
let chunk = 0
474+
while (position < file.size) {
475+
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, file.size - position), position)
476+
if (bytesRead === 0) {
477+
throw new Error(`playground-staged-input-chunked-write could not read ${boundedTarget(file.target)} after ${position} byte(s)`)
478+
}
479+
const contents = buffer.subarray(0, bytesRead)
480+
hash.update(contents)
481+
const response = await server.playground.run({ code: hostMountChunkWritePhp(file.target, contents.toString("base64")) })
482+
assertPlaygroundResponseOk("playground-staged-input-chunked-write", response)
483+
assertChunkWriteResult(response.text, file.target, ++chunk)
484+
position += bytesRead
485+
}
486+
} finally {
487+
await handle.close()
488+
}
489+
} finally {
490+
await rm(snapshotDirectory, { recursive: true, force: true })
491+
}
492+
return { target: file.target, sha256: hash.digest("hex") }
493+
}
494+
495+
function assertChunkWriteResult(text: string, target: string, chunk: number): void {
496+
const parsed = parseMaterializationJson<HostMountFileMaterializationResponse>(text, "wp-codebox/host-mount-chunk-materialization/v1", "playground-staged-input-chunked-write")
497+
if ((parsed.skipped ?? 0) > 0 || (chunk > 0 && (parsed.materialized ?? 0) !== 1)) {
498+
throw new Error(`playground-staged-input-chunked-write could not write ${boundedTarget(target)} at chunk ${chunk}`)
499+
}
500+
}
501+
502+
function boundedTarget(target: string): string {
503+
return target.length <= 200 ? target : `${target.slice(0, 197)}...`
504+
}
505+
506+
async function verifyHostMountFilesWithPhp(server: PlaygroundCliServer, files: HostMountFileVerificationPayload[]): Promise<{ repaired: number; skipped: number }> {
440507
const response = await server.playground.run({ code: hostMountVerifyPhp(files) })
441508
assertPlaygroundResponseOk("playground-staged-input-verify", response)
442509
const parsed = parseMaterializationJson<HostMountFileVerificationResponse>(response.text, "wp-codebox/host-mount-verification/v1", "playground-staged-input-verify")
@@ -557,7 +624,7 @@ async function hostFileHashes(directory: string, relativeDirectory = ""): Promis
557624
return files
558625
}
559626

560-
function hostMountWritePhp(files: HostMountFilePayload[], directories: string[]): string {
627+
function hostMountWritePhp(files: Array<HostMountFilePayload & { contentsBase64: string }>, directories: string[]): string {
561628
const payload = JSON.stringify(JSON.stringify({ files, directories }))
562629
return `<?php
563630
$payload = json_decode(${payload}, true);
@@ -600,21 +667,26 @@ echo json_encode(array('schema' => 'wp-codebox/host-mount-materialization/v1', '
600667
`
601668
}
602669

603-
function hostMountVerifyPhp(files: HostMountFilePayload[]): string {
670+
function hostMountVerifyPhp(files: HostMountFileVerificationPayload[]): string {
604671
const payload = JSON.stringify(JSON.stringify({ files }))
605672
return `<?php
606673
$payload = json_decode(${payload}, true);
607674
$repaired = 0;
608675
$skipped = 0;
609676
foreach (($payload['files'] ?? array()) as $file) {
610677
$target = (string) ($file['target'] ?? '');
678+
$expected_hash = (string) ($file['sha256'] ?? '');
611679
$contents = base64_decode((string) ($file['contentsBase64'] ?? ''), true);
612-
if ('' === $target || str_contains($target, "\0") || false === $contents) {
680+
if ('' === $target || str_contains($target, "\0") || ('' === $expected_hash && false === $contents)) {
613681
$skipped++;
614682
continue;
615683
}
616684
$target_hash = is_file($target) ? hash_file('sha256', $target) : false;
617-
if (is_string($target_hash) && hash_equals(hash('sha256', $contents), $target_hash)) {
685+
if (is_string($target_hash) && hash_equals('' === $expected_hash ? hash('sha256', $contents) : $expected_hash, $target_hash)) {
686+
continue;
687+
}
688+
if ('' !== $expected_hash) {
689+
$skipped++;
618690
continue;
619691
}
620692
$directory = dirname($target);
@@ -633,6 +705,35 @@ echo json_encode(array('schema' => 'wp-codebox/host-mount-verification/v1', 'rep
633705
`
634706
}
635707

708+
function hostMountChunkWritePhp(target: string, contentsBase64?: string): string {
709+
const payload = JSON.stringify(JSON.stringify({ target, contentsBase64 }))
710+
return `<?php
711+
$payload = json_decode(${payload}, true);
712+
$target = (string) ($payload['target'] ?? '');
713+
$contents_base64 = $payload['contentsBase64'] ?? null;
714+
$materialized = 0;
715+
$skipped = 0;
716+
if ('' === $target || str_contains($target, "\0")) {
717+
$skipped++;
718+
} elseif (null === $contents_base64) {
719+
$handle = fopen($target, 'wb');
720+
if (false === $handle) {
721+
$skipped++;
722+
} else {
723+
fclose($handle);
724+
}
725+
} else {
726+
$contents = base64_decode((string) $contents_base64, true);
727+
if (false === $contents || strlen($contents) !== file_put_contents($target, $contents, FILE_APPEND)) {
728+
$skipped++;
729+
} else {
730+
$materialized++;
731+
}
732+
}
733+
echo json_encode(array('schema' => 'wp-codebox/host-mount-chunk-materialization/v1', 'materialized' => $materialized, 'skipped' => $skipped), JSON_UNESCAPED_SLASHES);
734+
`
735+
}
736+
636737
function hostMountMkdirPhp(directories: string[]): string {
637738
const payload = JSON.stringify(JSON.stringify({ directories }))
638739
return `<?php

tests/mount-materialization.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import assert from "node:assert/strict"
2+
import { createHash } from "node:crypto"
23
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
34
import { tmpdir } from "node:os"
45
import { dirname, join } from "node:path"
@@ -249,7 +250,64 @@ try {
249250
await rm(malformedVerificationSource, { recursive: true, force: true })
250251
}
251252

252-
function materializationPayload(code: string): { directories?: string[]; files?: Array<{ target: string; contentsBase64: string }> } {
253+
const chunkedSource = await mkdtemp(join(tmpdir(), "wp-codebox-chunked-materialization-"))
254+
255+
try {
256+
const sourcePath = join(chunkedSource, "artifact.bin")
257+
const contents = Buffer.alloc(1024 * 1024 + 77)
258+
for (let offset = 0; offset < contents.length; offset++) {
259+
contents[offset] = (offset * 31 + 255) % 256
260+
}
261+
await writeFile(sourcePath, contents)
262+
263+
const target = "/wordpress/wp-content/uploads/artifact.bin"
264+
const sandboxFiles = new Map<string, Buffer>([[target, Buffer.from("stale target contents")]])
265+
const chunkPayloadLengths: number[] = []
266+
let directWrites = 0
267+
let sourceTruncated = false
268+
let verificationPayload: { sha256?: string; contentsBase64?: string } | undefined
269+
const result = await materializePlaygroundStagedInputs({
270+
playground: {
271+
async run({ code }: { code: string }) {
272+
const payload = materializationPayload(code)
273+
if (code.includes("wp-codebox/host-mount-chunk-materialization/v1")) {
274+
const chunk = payload as { target: string; contentsBase64?: string }
275+
if (chunk.contentsBase64 === undefined) {
276+
await writeFile(sourcePath, Buffer.alloc(0))
277+
sourceTruncated = true
278+
sandboxFiles.set(chunk.target, Buffer.alloc(0))
279+
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-chunk-materialization/v1", materialized: 0, skipped: 0 }) }
280+
}
281+
chunkPayloadLengths.push(chunk.contentsBase64.length)
282+
sandboxFiles.set(chunk.target, Buffer.concat([sandboxFiles.get(chunk.target) ?? Buffer.alloc(0), Buffer.from(chunk.contentsBase64, "base64")]))
283+
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-chunk-materialization/v1", materialized: 1, skipped: 0 }) }
284+
}
285+
if (code.includes("wp-codebox/host-mount-verification/v1")) {
286+
verificationPayload = payload.files?.[0]
287+
const expected = verificationPayload?.sha256
288+
const actual = sandboxFiles.has(target) ? createHash("sha256").update(sandboxFiles.get(target)!).digest("hex") : undefined
289+
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-verification/v1", repaired: 0, skipped: expected === actual ? 0 : 1 }) }
290+
}
291+
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-directory-materialization/v1", created: payload.directories?.length ?? 0, skipped: 0 }) }
292+
},
293+
async writeFile() {
294+
directWrites++
295+
},
296+
},
297+
} as never, [{ type: "file", source: sourcePath, target, mode: "readwrite" }])
298+
299+
assert.equal(result.materialized, 1)
300+
assert.equal(sourceTruncated, true, "chunked writes retain a snapshot when the mounted source inode is truncated")
301+
assert.equal(directWrites, 0, "large files never use the whole-file direct writer")
302+
assert.deepEqual(sandboxFiles.get(target), contents, "chunked writes preserve exact binary bytes and replace existing targets")
303+
assert.deepEqual(chunkPayloadLengths, [349528, 349528, 349528, 349528, 104])
304+
assert.equal(verificationPayload?.contentsBase64, undefined, "large-file verification does not embed the file contents")
305+
assert.match(verificationPayload?.sha256 ?? "", /^[a-f0-9]{64}$/)
306+
} finally {
307+
await rm(chunkedSource, { recursive: true, force: true })
308+
}
309+
310+
function materializationPayload(code: string): { target?: string; contentsBase64?: string; directories?: string[]; files?: Array<{ target: string; contentsBase64?: string; sha256?: string }> } {
253311
const match = code.match(/\$payload = json_decode\((.*), true\);/)
254312
assert.ok(match, "materialization PHP includes a JSON payload")
255313
return JSON.parse(JSON.parse(match[1]))

0 commit comments

Comments
 (0)