Skip to content

Commit b96015e

Browse files
Caio-Nogueiraaron-cf
authored andcommitted
dofs: Link staged chunks during sync apply
Sync receivers already stage chunk payloads in content-addressed storage before applying file entries. Reading them back and concatenating a whole-file buffer makes peak isolate memory roughly twice the file size. Link each file's existing chunk references directly while preserving metadata and validating declared sizes. This keeps payload bytes out of the apply path and relies on vfs_chunks reachability to protect linked blobs from collection.
1 parent 50bbc35 commit b96015e

4 files changed

Lines changed: 186 additions & 62 deletions

File tree

packages/dofs/src/fs/gc.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import { createHash } from "node:crypto";
2+
13
import { describe, expect, it } from "vitest";
24

35
import type { Database } from "../storage.js";
6+
import { applyChanges } from "../sync/apply.js";
7+
import { stageBlob } from "../sync/blobs.js";
48
import { gc } from "./gc.js";
9+
import { readFile } from "./readFile.js";
510
import { rm } from "./rm.js";
611
import { withDB } from "./with-db.js";
712
import { writeFile } from "./writeFile.js";
@@ -33,6 +38,44 @@ describe("gc", () => {
3338
});
3439
});
3540

41+
it("does not free blobs a sync apply linked", async () => {
42+
await withDB(async (db) => {
43+
// A pull stages chunks before linking them, so vfs_chunks reachability
44+
// must protect blobs independently of their staging timestamp.
45+
const bytes = new TextEncoder().encode("staged content");
46+
const hash = new Uint8Array(createHash("sha256").update(bytes).digest());
47+
stageBlob(db, hash, bytes, 1000);
48+
49+
await applyChanges(
50+
db,
51+
[
52+
{
53+
kind: "file",
54+
rev: 1,
55+
path: "/staged.txt",
56+
mode: 0o644,
57+
mtime: 1000,
58+
size: bytes.byteLength,
59+
chunks: [{ hash, size: bytes.byteLength }],
60+
},
61+
],
62+
new Map(),
63+
{ source: "upstream" },
64+
);
65+
expect(blobCount(db)).toBe(1);
66+
67+
// A zero safety window and far-future clock make every blob stale;
68+
// only references from vfs_chunks protect this blob and manifest.
69+
expect(gc(db, { now: () => 999_999_999, safetyWindowMs: 0 })).toEqual({
70+
blobsFreed: 0,
71+
manifestsFreed: 0,
72+
});
73+
expect(blobCount(db)).toBe(1);
74+
expect(blobBytesCount(db)).toBe(1);
75+
expect(await readFile(db, "/staged.txt", "utf8")).toBe("staged content");
76+
});
77+
});
78+
3679
it("frees orphan blobs left behind by overwrite", async () => {
3780
await withDB(async (db) => {
3881
await writeFile(db, "/x.txt", "first", {}, () => 1000);

packages/dofs/src/fs/writeFile.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,10 +211,20 @@ async function writeFileStreaming(
211211
flush(carry);
212212
}
213213

214-
// Wire up the inode against the staged blobs in one short
215-
// transaction. From this point on the SQL is the same shape as the
216-
// synchronous path — only the chunk-bytes step is skipped because
217-
// stageBlob already landed them above.
214+
linkStagedChunksSync(db, canonical, parts, chunkRefs, { ...options, mode }, mtime);
215+
}
216+
217+
// Link a path to chunks already staged in content-addressed storage.
218+
// This keeps payload bytes out of memory during sync apply.
219+
export function linkStagedChunksSync(
220+
db: Database,
221+
canonical: string,
222+
parts: string[],
223+
chunkRefs: { hash: Uint8Array; size: number }[],
224+
options: WriteFileOptions,
225+
mtime: number,
226+
): void {
227+
const mode = (options.mode ?? 0o644) & 0o7777;
218228
db.transactionSync(() => {
219229
const parentInode = resolveParent(db, parts, canonical);
220230
const leafName = parts[parts.length - 1];
@@ -251,6 +261,8 @@ async function writeFileStreaming(
251261
ref.size,
252262
);
253263
}
264+
// Referenced chunks cannot be collected, so last_seen only protects
265+
// blobs during the staging window before this transaction.
254266
const manifestHash = buildManifest(db, chunkRefs, mtime);
255267
const rev = incrementRev(db);
256268
let totalSize = 0;

packages/dofs/src/sync/apply.test.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from "vitest";
1+
import { describe, expect, it, vi } from "vitest";
22

33
import { link } from "../fs/link.js";
44
import { mkdir } from "../fs/mkdir.js";
@@ -10,8 +10,9 @@ import { resolveInode } from "../fs/resolve.js";
1010
import { rm } from "../fs/rm.js";
1111
import { symlink } from "../fs/symlink.js";
1212
import { withDB, withTwoDBs } from "../fs/with-db.js";
13-
import { writeFile, writeFileSync } from "../fs/writeFile.js";
13+
import { CHUNK_SIZE, writeFile, writeFileSync } from "../fs/writeFile.js";
1414
import { applyChanges, applyChangesSync } from "./apply.js";
15+
import { stageBlob } from "./blobs.js";
1516
import type { ChangeEntry } from "./changes.js";
1617
import { coalesceChanges } from "./coalesce.js";
1718
import { fetchObjects } from "./fetch.js";
@@ -73,6 +74,74 @@ describe("applyChanges", () => {
7374
);
7475
});
7576

77+
it("links staged chunks without reading payload bytes", async () => {
78+
const content = `${"a".repeat(CHUNK_SIZE)}b`;
79+
await withTwoDBs(
80+
async (a) => {
81+
await writeFile(a, "/large.txt", content, {}, () => 1);
82+
const entries = await drain(coalesceChanges(a, 0));
83+
const entry = entries.find((candidate) => candidate.path === "/large.txt");
84+
if (entry?.kind !== "file") throw new Error("missing file entry");
85+
expect(entry.chunks).toHaveLength(2);
86+
return { entry, objects: await collectObjects(a, [entry]) };
87+
},
88+
async (b, { entry, objects }) => {
89+
for (const chunk of entry.chunks) {
90+
const bytes = objects.get(hex(chunk.hash));
91+
if (bytes === undefined) throw new Error("missing chunk bytes");
92+
stageBlob(b, chunk.hash, bytes, 2);
93+
}
94+
95+
const all = vi.spyOn(b, "all");
96+
try {
97+
await applyChanges(b, [entry], new Map(), { source: "upstream" });
98+
const payloadReads = all.mock.calls.filter(([query]) =>
99+
query.includes("SELECT bytes FROM vfs_blob_bytes"),
100+
);
101+
expect(all.mock.calls.length).toBeGreaterThan(0);
102+
expect(payloadReads).toHaveLength(0);
103+
} finally {
104+
all.mockRestore();
105+
}
106+
expect(await readFile(b, "/large.txt", "utf8")).toBe(content);
107+
},
108+
);
109+
});
110+
111+
it("links staged chunks synchronously without reading payload bytes", async () => {
112+
const content = `${"a".repeat(CHUNK_SIZE)}b`;
113+
await withTwoDBs(
114+
async (a) => {
115+
await writeFile(a, "/large.txt", content, {}, () => 1);
116+
const entries = await drain(coalesceChanges(a, 0));
117+
const entry = entries.find((candidate) => candidate.path === "/large.txt");
118+
if (entry?.kind !== "file") throw new Error("missing file entry");
119+
expect(entry.chunks).toHaveLength(2);
120+
return { entry, objects: await collectObjects(a, [entry]) };
121+
},
122+
async (b, { entry, objects }) => {
123+
for (const chunk of entry.chunks) {
124+
const bytes = objects.get(hex(chunk.hash));
125+
if (bytes === undefined) throw new Error("missing chunk bytes");
126+
stageBlob(b, chunk.hash, bytes, 2);
127+
}
128+
129+
const all = vi.spyOn(b, "all");
130+
try {
131+
applyChangesSync(b, [entry], new Map(), { source: "upstream" });
132+
const payloadReads = all.mock.calls.filter(([query]) =>
133+
query.includes("SELECT bytes FROM vfs_blob_bytes"),
134+
);
135+
expect(all.mock.calls.length).toBeGreaterThan(0);
136+
expect(payloadReads).toHaveLength(0);
137+
} finally {
138+
all.mockRestore();
139+
}
140+
expect(await readFile(b, "/large.txt", "utf8")).toBe(content);
141+
},
142+
);
143+
});
144+
76145
it("commits in batches capped by byte budget", async () => {
77146
// Force many small files; with a tiny byte budget the apply
78147
// path should still converge, just across more batches. We

packages/dofs/src/sync/apply.ts

Lines changed: 56 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import { invalidateResolveSubtree } from "../fs/resolveCache.js";
55
import { rm } from "../fs/rm.js";
66
import { symlink } from "../fs/symlink.js";
77
import { unlinkDirent } from "../fs/unlink.js";
8-
import { writeFile, writeFileSync } from "../fs/writeFile.js";
8+
import { linkStagedChunksSync } from "../fs/writeFile.js";
99
import { canonicalizePath } from "../path.js";
1010
import { incrementRev } from "../rev.js";
1111
import type { Database } from "../storage.js";
12+
import { stageBlob } from "./blobs.js";
1213
import type { ChangeEntry } from "./changes.js";
1314
import { computeManifestHash } from "./manifests.js";
1415

@@ -289,35 +290,7 @@ export async function applyChanges(
289290
if (pathsInBatch >= maxPaths) flush();
290291
continue;
291292
}
292-
// file: assemble chunk bytes. First check the in-memory map
293-
// (the streaming hand-off); fall back to vfs_blob_bytes (the
294-
// staged-via-pushObjects path).
295-
const parts: Uint8Array[] = [];
296-
let total = 0;
297-
for (const c of entry.chunks) {
298-
const k = hex(c.hash);
299-
let bytes = objects.get(k);
300-
if (bytes === undefined) {
301-
const row = db.one<{ bytes: Uint8Array }>(
302-
"SELECT bytes FROM vfs_blob_bytes WHERE hash = ?",
303-
c.hash,
304-
);
305-
bytes = row?.bytes;
306-
}
307-
if (bytes === undefined) {
308-
throw new Error(`applyChanges: missing object ${k} for ${entry.path}`);
309-
}
310-
parts.push(bytes);
311-
total += bytes.byteLength;
312-
}
313-
const buf = new Uint8Array(total);
314-
let off = 0;
315-
for (const p of parts) {
316-
buf.set(p, off);
317-
off += p.byteLength;
318-
}
319-
removeReplaceableFinalEntry(db, entry.path, "file");
320-
await writeFile(db, entry.path, buf, { mode: entry.mode }, () => entry.mtime);
293+
const total = applyFileEntry(db, entry, objects);
321294
applied++;
322295
bytesInBatch += total;
323296
pathsInBatch++;
@@ -408,32 +381,7 @@ export function applyChangesSync(
408381
if (pathsInBatch >= maxPaths) flush();
409382
continue;
410383
}
411-
const parts: Uint8Array[] = [];
412-
let total = 0;
413-
for (const c of entry.chunks) {
414-
const k = hex(c.hash);
415-
let bytes = objects.get(k);
416-
if (bytes === undefined) {
417-
const row = db.one<{ bytes: Uint8Array }>(
418-
"SELECT bytes FROM vfs_blob_bytes WHERE hash = ?",
419-
c.hash,
420-
);
421-
bytes = row?.bytes;
422-
}
423-
if (bytes === undefined) {
424-
throw new Error(`applyChanges: missing object ${k} for ${entry.path}`);
425-
}
426-
parts.push(bytes);
427-
total += bytes.byteLength;
428-
}
429-
const buf = new Uint8Array(total);
430-
let off = 0;
431-
for (const p of parts) {
432-
buf.set(p, off);
433-
off += p.byteLength;
434-
}
435-
removeReplaceableFinalEntry(db, entry.path, "file");
436-
writeFileSync(db, entry.path, buf, { mode: entry.mode }, () => entry.mtime);
384+
const total = applyFileEntry(db, entry, objects);
437385
applied++;
438386
bytesInBatch += total;
439387
pathsInBatch++;
@@ -448,6 +396,58 @@ export function applyChangesSync(
448396
return { applied, skipped };
449397
}
450398

399+
// Link a file entry to staged chunks without loading payload bytes.
400+
// In-memory objects are staged individually before the link.
401+
402+
// Validate declared sizes without loading payloads; chunk hashes remain
403+
// trusted here, matching stageBlob's existing contract.
404+
function applyFileEntry(
405+
db: Database,
406+
entry: Extract<ChangeEntry, { kind: "file" }>,
407+
objects: Map<string, Uint8Array>,
408+
): number {
409+
let total = 0;
410+
for (const c of entry.chunks) {
411+
total += c.size;
412+
const staged = stagedBlobSize(db, c.hash);
413+
if (staged === undefined) {
414+
const k = hex(c.hash);
415+
const bytes = objects.get(k);
416+
if (bytes === undefined) {
417+
throw new Error(`applyChanges: missing object ${k} for ${entry.path}`);
418+
}
419+
assertChunkSize(bytes.byteLength, c.size, c.hash, entry.path);
420+
stageBlob(db, c.hash, bytes, entry.mtime);
421+
continue;
422+
}
423+
assertChunkSize(staged, c.size, c.hash, entry.path);
424+
}
425+
removeReplaceableFinalEntry(db, entry.path, "file");
426+
const { parts, path: canonical } = canonicalizePath(entry.path);
427+
linkStagedChunksSync(db, canonical, parts, entry.chunks, { mode: entry.mode }, entry.mtime);
428+
return total;
429+
}
430+
431+
// Return a staged chunk's size without loading its payload bytes.
432+
// A short byte row is treated as an interrupted write.
433+
function stagedBlobSize(db: Database, hash: Uint8Array): number | undefined {
434+
return db.one<{ size: number }>(
435+
`SELECT b.size AS size
436+
FROM vfs_blobs b
437+
JOIN vfs_blob_bytes bb ON bb.hash = b.hash
438+
WHERE b.hash = ?
439+
AND length(bb.bytes) = b.size`,
440+
hash,
441+
)?.size;
442+
}
443+
444+
function assertChunkSize(actual: number, declared: number, hash: Uint8Array, path: string): void {
445+
if (actual === declared) return;
446+
throw new Error(
447+
`applyChanges: chunk ${hex(hash)} for ${path} declares ${declared} bytes but holds ${actual}`,
448+
);
449+
}
450+
451451
// Compare an entry against the local node graph. Returns true when
452452
// the entry would be a no-op apply: the manifest hash (files), mode
453453
// (dirs), or mode + symlink target (symlinks) already matches.

0 commit comments

Comments
 (0)