Skip to content

Commit 5b7dfe2

Browse files
authored
feat(cli): implements content-addressable store for the dev CLI build outputs, reducing disk usage (#2725)
* feat(cli): implements content-addressable store for the dev CLI build outputs, reducing disk usage * fix a few things
1 parent af9b3e1 commit 5b7dfe2

File tree

8 files changed

+212
-16
lines changed

8 files changed

+212
-16
lines changed

.changeset/polite-eels-divide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
feat(cli): implements content-addressable store for the dev CLI build outputs, reducing disk usage

packages/cli-v3/src/build/bundle.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { DEFAULT_RUNTIME, ResolvedConfig } from "@trigger.dev/core/v3/build";
33
import { BuildManifest, BuildTarget, TaskFile } from "@trigger.dev/core/v3/schemas";
44
import * as esbuild from "esbuild";
55
import { createHash } from "node:crypto";
6-
import { join, relative, resolve } from "node:path";
7-
import { createFile } from "../utilities/fileSystem.js";
6+
import { basename, join, relative, resolve } from "node:path";
7+
import { createFile, createFileWithStore } from "../utilities/fileSystem.js";
88
import { logger } from "../utilities/logger.js";
99
import { resolveFileSources } from "../utilities/sourceFiles.js";
1010
import { VERSION } from "../version.js";
@@ -37,6 +37,8 @@ export interface BundleOptions {
3737
jsxAutomatic?: boolean;
3838
watch?: boolean;
3939
plugins?: esbuild.Plugin[];
40+
/** Shared store directory for deduplicating chunk files via hardlinks */
41+
storeDir?: string;
4042
}
4143

4244
export type BundleResult = {
@@ -51,6 +53,8 @@ export type BundleResult = {
5153
indexControllerEntryPoint: string | undefined;
5254
initEntryPoint: string | undefined;
5355
stop: (() => Promise<void>) | undefined;
56+
/** Maps output file paths to their content hashes for deduplication */
57+
outputHashes: Record<string, string>;
5458
};
5559

5660
export class BundleError extends Error {
@@ -159,7 +163,8 @@ export async function bundleWorker(options: BundleOptions): Promise<BundleResult
159163
options.target,
160164
options.cwd,
161165
options.resolvedConfig,
162-
result
166+
result,
167+
options.storeDir
163168
);
164169

165170
if (!bundleResult) {
@@ -233,14 +238,23 @@ export async function getBundleResultFromBuild(
233238
target: BuildTarget,
234239
workingDir: string,
235240
resolvedConfig: ResolvedConfig,
236-
result: esbuild.BuildResult<{ metafile: true; write: false }>
241+
result: esbuild.BuildResult<{ metafile: true; write: false }>,
242+
storeDir?: string
237243
): Promise<Omit<BundleResult, "stop"> | undefined> {
238244
const hasher = createHash("md5");
245+
const outputHashes: Record<string, string> = {};
239246

240247
for (const outputFile of result.outputFiles) {
241248
hasher.update(outputFile.hash);
242-
243-
await createFile(outputFile.path, outputFile.contents);
249+
// Store the hash for each output file (keyed by path)
250+
outputHashes[outputFile.path] = outputFile.hash;
251+
252+
if (storeDir) {
253+
// Use content-addressable store with esbuild's built-in hash for ALL files
254+
await createFileWithStore(outputFile.path, outputFile.contents, storeDir, outputFile.hash);
255+
} else {
256+
await createFile(outputFile.path, outputFile.contents);
257+
}
244258
}
245259

246260
const files: Array<{ entry: string; out: string }> = [];
@@ -308,6 +322,7 @@ export async function getBundleResultFromBuild(
308322
initEntryPoint,
309323
contentHash: hasher.digest("hex"),
310324
metafile: result.metafile,
325+
outputHashes,
311326
};
312327
}
313328

@@ -354,6 +369,7 @@ export async function createBuildManifestFromBundle({
354369
target,
355370
envVars,
356371
sdkVersion,
372+
storeDir,
357373
}: {
358374
bundle: BundleResult;
359375
destination: string;
@@ -364,6 +380,7 @@ export async function createBuildManifestFromBundle({
364380
target: BuildTarget;
365381
envVars?: Record<string, string>;
366382
sdkVersion?: string;
383+
storeDir?: string;
367384
}): Promise<BuildManifest> {
368385
const buildManifest: BuildManifest = {
369386
contentHash: bundle.contentHash,
@@ -397,11 +414,12 @@ export async function createBuildManifestFromBundle({
397414
otelImportHook: {
398415
include: resolvedConfig.instrumentedPackageNames ?? [],
399416
},
417+
outputHashes: bundle.outputHashes,
400418
};
401419

402420
if (!workerDir) {
403421
return buildManifest;
404422
}
405423

406-
return copyManifestToDir(buildManifest, destination, workerDir);
424+
return copyManifestToDir(buildManifest, destination, workerDir, storeDir);
407425
}

packages/cli-v3/src/build/manifests.ts

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
import { BuildManifest } from "@trigger.dev/core/v3/schemas";
2-
import { cp } from "node:fs/promises";
2+
import { cp, link, mkdir, readdir, readFile } from "node:fs/promises";
3+
import { createHash } from "node:crypto";
4+
import { existsSync } from "node:fs";
5+
import { join } from "node:path";
36
import { logger } from "../utilities/logger.js";
7+
import { sanitizeHashForFilename } from "../utilities/fileSystem.js";
48

59
export async function copyManifestToDir(
610
manifest: BuildManifest,
711
source: string,
8-
destination: string
12+
destination: string,
13+
storeDir?: string
914
): Promise<BuildManifest> {
10-
// Copy the dir in destination to workerDir
11-
await cp(source, destination, { recursive: true });
15+
// Copy the dir from source to destination
16+
// If storeDir is provided, create hardlinks for files that exist in the store
17+
if (storeDir) {
18+
await copyDirWithStore(source, destination, storeDir, manifest.outputHashes);
19+
} else {
20+
await cp(source, destination, { recursive: true });
21+
}
1222

13-
logger.debug("Copied manifest to dir", { source, destination });
23+
logger.debug("Copied manifest to dir", { source, destination, storeDir });
1424

1525
// Then update the manifest to point to the new workerDir
1626
const updatedManifest = { ...manifest };
@@ -37,3 +47,68 @@ export async function copyManifestToDir(
3747

3848
return updatedManifest;
3949
}
50+
51+
/**
52+
* Computes a hash of file contents to use as content-addressable key.
53+
* This is a fallback for when outputHashes is not available.
54+
*/
55+
async function computeFileHash(filePath: string): Promise<string> {
56+
const contents = await readFile(filePath);
57+
return createHash("sha256").update(contents).digest("hex").slice(0, 16);
58+
}
59+
60+
/**
61+
* Recursively copies a directory, using hardlinks for files that exist in the store.
62+
* This preserves disk space savings from the content-addressable store.
63+
*
64+
* @param source - Source directory path
65+
* @param destination - Destination directory path
66+
* @param storeDir - Content-addressable store directory
67+
* @param outputHashes - Optional map of file paths to their content hashes (from BuildManifest)
68+
*/
69+
async function copyDirWithStore(
70+
source: string,
71+
destination: string,
72+
storeDir: string,
73+
outputHashes?: Record<string, string>
74+
): Promise<void> {
75+
await mkdir(destination, { recursive: true });
76+
77+
const entries = await readdir(source, { withFileTypes: true });
78+
79+
for (const entry of entries) {
80+
const sourcePath = join(source, entry.name);
81+
const destPath = join(destination, entry.name);
82+
83+
if (entry.isDirectory()) {
84+
// Recursively copy subdirectories
85+
await copyDirWithStore(sourcePath, destPath, storeDir, outputHashes);
86+
} else if (entry.isFile()) {
87+
// Try to get hash from manifest first, otherwise compute it
88+
const contentHash = outputHashes?.[sourcePath] ?? (await computeFileHash(sourcePath));
89+
// Sanitize hash to be filesystem-safe (base64 can contain / and +)
90+
const safeHash = sanitizeHashForFilename(contentHash);
91+
const storePath = join(storeDir, safeHash);
92+
93+
if (existsSync(storePath)) {
94+
// Create hardlink to store file
95+
// Fall back to copy if hardlink fails (e.g., on Windows or cross-device)
96+
try {
97+
await link(storePath, destPath);
98+
} catch (linkError) {
99+
try {
100+
await cp(storePath, destPath);
101+
} catch (copyError) {
102+
throw linkError; // Rethrow original error if copy also fails
103+
}
104+
}
105+
} else {
106+
// File wasn't in the store - copy normally
107+
await cp(sourcePath, destPath);
108+
}
109+
} else if (entry.isSymbolicLink()) {
110+
// Preserve symbolic links (e.g., node_modules links)
111+
await cp(sourcePath, destPath, { verbatimSymlinks: true });
112+
}
113+
}
114+
}

packages/cli-v3/src/dev/devSession.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ import { createExternalsBuildExtension, resolveAlwaysExternal } from "../build/e
2020
import { type DevCommandOptions } from "../commands/dev.js";
2121
import { eventBus } from "../utilities/eventBus.js";
2222
import { logger } from "../utilities/logger.js";
23-
import { clearTmpDirs, EphemeralDirectory, getTmpDir } from "../utilities/tempDirectories.js";
23+
import {
24+
clearTmpDirs,
25+
EphemeralDirectory,
26+
getStoreDir,
27+
getTmpDir,
28+
} from "../utilities/tempDirectories.js";
2429
import { startDevOutput } from "./devOutput.js";
2530
import { startWorkerRuntime } from "./devSupervisor.js";
2631
import { startMcpServer, stopMcpServer } from "./mcpServer.js";
@@ -53,6 +58,8 @@ export async function startDevSession({
5358
}: DevSessionOptions): Promise<DevSessionInstance> {
5459
clearTmpDirs(rawConfig.workingDir);
5560
const destination = getTmpDir(rawConfig.workingDir, "build", keepTmpFiles);
61+
// Create shared store directory for deduplicating chunk files across rebuilds
62+
const storeDir = getStoreDir(rawConfig.workingDir);
5663

5764
const runtime = await startWorkerRuntime({
5865
name,
@@ -102,6 +109,7 @@ export async function startDevSession({
102109
workerDir: workerDir?.path,
103110
environment: "dev",
104111
target: "dev",
112+
storeDir,
105113
});
106114

107115
logger.debug("Created build manifest from bundle", { buildManifest });
@@ -131,7 +139,13 @@ export async function startDevSession({
131139
}
132140

133141
async function updateBuild(build: esbuild.BuildResult, workerDir: EphemeralDirectory) {
134-
const bundle = await getBundleResultFromBuild("dev", rawConfig.workingDir, rawConfig, build);
142+
const bundle = await getBundleResultFromBuild(
143+
"dev",
144+
rawConfig.workingDir,
145+
rawConfig,
146+
build,
147+
storeDir
148+
);
135149

136150
if (bundle) {
137151
await updateBundle({ ...bundle, stop: undefined }, workerDir);
@@ -190,6 +204,7 @@ export async function startDevSession({
190204
jsxFactory: rawConfig.build.jsx.factory,
191205
jsxFragment: rawConfig.build.jsx.fragment,
192206
jsxAutomatic: rawConfig.build.jsx.automatic,
207+
storeDir,
193208
});
194209

195210
await updateBundle(bundleResult);

packages/cli-v3/src/utilities/fileSystem.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,77 @@ export async function createFile(
1616
return path;
1717
}
1818

19+
/**
20+
* Sanitizes a hash to be safe for use as a filename.
21+
* esbuild's hashes are base64-encoded and may contain `/` and `+` characters.
22+
*/
23+
export function sanitizeHashForFilename(hash: string): string {
24+
return hash.replace(/\//g, "_").replace(/\+/g, "-");
25+
}
26+
27+
/**
28+
* Creates a file using a content-addressable store for deduplication.
29+
* Files are stored by their content hash, so identical content is only stored once.
30+
* The build directory gets a hardlink to the stored file.
31+
*
32+
* @param filePath - The destination path for the file
33+
* @param contents - The file contents to write
34+
* @param storeDir - The shared store directory for deduplication
35+
* @param contentHash - The content hash (e.g., from esbuild's outputFile.hash)
36+
* @returns The destination file path
37+
*/
38+
export async function createFileWithStore(
39+
filePath: string,
40+
contents: string | NodeJS.ArrayBufferView,
41+
storeDir: string,
42+
contentHash: string
43+
): Promise<string> {
44+
// Sanitize hash to be filesystem-safe (base64 can contain / and +)
45+
const safeHash = sanitizeHashForFilename(contentHash);
46+
// Store files by their content hash for true content-addressable storage
47+
const storePath = pathModule.join(storeDir, safeHash);
48+
49+
// Ensure build directory exists
50+
await fsModule.mkdir(pathModule.dirname(filePath), { recursive: true });
51+
52+
// Remove existing file at destination if it exists (hardlinks fail on existing files)
53+
if (fsSync.existsSync(filePath)) {
54+
await fsModule.unlink(filePath);
55+
}
56+
57+
// Check if content already exists in store by hash
58+
if (fsSync.existsSync(storePath)) {
59+
// Create hardlink from build path to store path
60+
// Fall back to copy if hardlink fails (e.g., on Windows or cross-device)
61+
try {
62+
await fsModule.link(storePath, filePath);
63+
} catch (linkError) {
64+
try {
65+
await fsModule.copyFile(storePath, filePath);
66+
} catch (copyError) {
67+
throw linkError; // Rethrow original error if copy also fails
68+
}
69+
}
70+
return filePath;
71+
}
72+
73+
// Write to store first (using hash as filename)
74+
await fsModule.writeFile(storePath, contents);
75+
// Create hardlink in build directory (with original filename)
76+
// Fall back to copy if hardlink fails (e.g., on Windows or cross-device)
77+
try {
78+
await fsModule.link(storePath, filePath);
79+
} catch (linkError) {
80+
try {
81+
await fsModule.copyFile(storePath, filePath);
82+
} catch (copyError) {
83+
throw linkError; // Rethrow original error if copy also fails
84+
}
85+
}
86+
87+
return filePath;
88+
}
89+
1990
export function isDirectory(configPath: string) {
2091
try {
2192
return fs.statSync(configPath).isDirectory();

packages/cli-v3/src/utilities/tempDirectories.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,15 @@ export function clearTmpDirs(projectRoot: string | undefined) {
5858
// This sometimes fails on Windows with EBUSY
5959
}
6060
}
61+
62+
/**
63+
* Gets the shared store directory for content-addressable build outputs.
64+
* This directory persists across rebuilds and is used to deduplicate
65+
* identical chunk files between build versions.
66+
*/
67+
export function getStoreDir(projectRoot: string | undefined): string {
68+
projectRoot ??= process.cwd();
69+
const storeDir = path.join(projectRoot, ".trigger", "tmp", "store");
70+
fs.mkdirSync(storeDir, { recursive: true });
71+
return storeDir;
72+
}

packages/core/src/v3/schemas/build.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export const BuildManifest = z.object({
6868
exclude: z.array(z.string()).optional(),
6969
})
7070
.optional(),
71+
/** Maps output file paths to their content hashes for deduplication during dev */
72+
outputHashes: z.record(z.string()).optional(),
7173
});
7274

7375
export type BuildManifest = z.infer<typeof BuildManifest>;

references/d3-chat/src/trigger/chat.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,6 @@ export const todoChat = schemaTask({
199199
run: async ({ input, userId }, { signal }) => {
200200
metadata.set("user_id", userId);
201201

202-
logger.info("todoChat: starting", { input, userId });
203-
204202
const system = `
205203
You are a SQL (postgres) expert who can turn natural language descriptions for a todo app
206204
into a SQL query which can then be executed against a SQL database. Here is the schema:

0 commit comments

Comments
 (0)