Skip to content

Commit 91b350e

Browse files
authored
fix(core): skip stale recomputations and prevent lost file changes in daemon (#34424)
## Current Behavior When file changes arrive rapidly, the daemon triggers multiple concurrent project graph recomputations that all run to completion — wasting CPU/memory on redundant work and returning stale results. Additionally, after processing file changes, the daemon clears all tracked files indiscriminately. Files that changed mid-recomputation are silently lost and never reflected in the project graph until another unrelated file change arrives. ## Expected Behavior Stale recomputations detect when a newer one has started and exit early, chaining to the newer promise so callers always get the freshest result. File change tracking now uses versioned maps. Each batch of file watcher events gets a unique version, and only files matching the snapshotted version are cleared after processing. Files that changed mid-recomputation are preserved and picked up by the next cycle.
1 parent 50ca951 commit 91b350e

File tree

1 file changed

+45
-11
lines changed

1 file changed

+45
-11
lines changed

packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,10 @@ export let fileMapWithFiles:
6666
export let currentProjectFileMapCache: FileMapCache | undefined;
6767
export let currentProjectGraph: ProjectGraph | undefined;
6868

69-
const collectedUpdatedFiles = new Set<string>();
70-
const collectedDeletedFiles = new Set<string>();
69+
// Maps file path to a version counter that increments on each modification.
70+
// This lets us detect mid-flight re-modifications when clearing processed files.
71+
const collectedUpdatedFiles = new Map<string, number>();
72+
const collectedDeletedFiles = new Map<string, number>();
7173
const projectGraphRecomputationListeners = new Set<
7274
(
7375
projectGraph: ProjectGraph,
@@ -79,6 +81,8 @@ let storedWorkspaceConfigHash: string | undefined;
7981
let waitPeriod = 100;
8082
let scheduledTimeoutId;
8183
let knownExternalNodes: Record<string, ProjectGraphExternalNode> = {};
84+
let fileChangeCounter = 0;
85+
let recomputationGeneration = 0;
8286

8387
export async function getCachedSerializedProjectGraphPromise(): Promise<SerializedProjectGraph> {
8488
try {
@@ -167,14 +171,15 @@ export function addUpdatedAndDeletedFiles(
167171
updatedFiles: string[],
168172
deletedFiles: string[]
169173
) {
174+
++fileChangeCounter;
170175
for (let f of [...createdFiles, ...updatedFiles]) {
171176
collectedDeletedFiles.delete(f);
172-
collectedUpdatedFiles.add(f);
177+
collectedUpdatedFiles.set(f, fileChangeCounter);
173178
}
174179

175180
for (let f of deletedFiles) {
176181
collectedUpdatedFiles.delete(f);
177-
collectedDeletedFiles.add(f);
182+
collectedDeletedFiles.set(f, fileChangeCounter);
178183
}
179184

180185
// Notify file change listeners immediately when files change
@@ -272,9 +277,6 @@ async function processCollectedUpdatedAndDeletedFiles(
272277
);
273278
}
274279
}
275-
276-
collectedUpdatedFiles.clear();
277-
collectedDeletedFiles.clear();
278280
} catch (e) {
279281
// this is expected
280282
// for instance, project.json can be incorrect or a file we are trying to has
@@ -294,10 +296,17 @@ async function processCollectedUpdatedAndDeletedFiles(
294296
async function processFilesAndCreateAndSerializeProjectGraph(
295297
plugins: LoadedNxPlugin[]
296298
): Promise<SerializedProjectGraph> {
299+
const myGeneration = ++recomputationGeneration;
300+
301+
// Helper to check if this recomputation is stale (a newer one has started)
302+
const isStale = () => myGeneration !== recomputationGeneration;
303+
297304
try {
298305
performance.mark('hash-watched-changes-start');
299-
const updatedFiles = [...collectedUpdatedFiles.values()];
300-
const deletedFiles = [...collectedDeletedFiles.values()];
306+
const updatedFilesSnapshot = new Map(collectedUpdatedFiles);
307+
const deletedFilesSnapshot = new Map(collectedDeletedFiles);
308+
const updatedFiles = [...updatedFilesSnapshot.keys()];
309+
const deletedFiles = [...deletedFilesSnapshot.keys()];
301310
let updatedFileHashes = updateFilesInContext(
302311
workspaceRoot,
303312
updatedFiles,
@@ -312,8 +321,8 @@ async function processFilesAndCreateAndSerializeProjectGraph(
312321
serverLogger.requestLog(
313322
`Updated workspace context based on watched changes, recomputing project graph...`
314323
);
315-
serverLogger.requestLog([...updatedFiles.values()]);
316-
serverLogger.requestLog([...deletedFiles]);
324+
serverLogger.requestLog(updatedFiles);
325+
serverLogger.requestLog(deletedFiles);
317326
const nxJson = readNxJson(workspaceRoot);
318327
global.NX_GRAPH_CREATION = true;
319328

@@ -334,11 +343,36 @@ async function processFilesAndCreateAndSerializeProjectGraph(
334343
throw e;
335344
}
336345
}
346+
347+
// Early exit if a newer recomputation has started - chain to the newer one
348+
if (isStale()) {
349+
return cachedSerializedProjectGraphPromise;
350+
}
351+
337352
await processCollectedUpdatedAndDeletedFiles(
338353
projectConfigurationsResult,
339354
updatedFileHashes,
340355
deletedFiles
341356
);
357+
358+
// Only remove files whose version matches the snapshot — if the version
359+
// is higher, the file was modified again mid-flight and needs reprocessing.
360+
for (const [f, version] of updatedFilesSnapshot) {
361+
if (collectedUpdatedFiles.get(f) === version) {
362+
collectedUpdatedFiles.delete(f);
363+
}
364+
}
365+
for (const [f, version] of deletedFilesSnapshot) {
366+
if (collectedDeletedFiles.get(f) === version) {
367+
collectedDeletedFiles.delete(f);
368+
}
369+
}
370+
371+
// Early exit if a newer recomputation has started - chain to the newer one
372+
if (isStale()) {
373+
return cachedSerializedProjectGraphPromise;
374+
}
375+
342376
const g = await createAndSerializeProjectGraph(projectConfigurationsResult);
343377

344378
delete global.NX_GRAPH_CREATION;

0 commit comments

Comments
 (0)