Skip to content

fix(core): throw errors when task graph has invalid continuous tasks #30924

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/nx/schemas/nx-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,11 @@
"type": "object"
}
},
"continuous": {
"type": "boolean",
"default": false,
"description": "Whether this target runs continuously until stopped"
},
"parallelism": {
"type": "boolean",
"default": true,
Expand Down
4 changes: 3 additions & 1 deletion packages/nx/src/tasks-runner/run-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
} from '../project-graph/plugins/tasks-execution-hooks';
import { createProjectGraphAsync } from '../project-graph/project-graph';
import { NxArgs } from '../utils/command-line-utils';
import { isRelativePath } from '../utils/fileutils';
import { handleErrors } from '../utils/handle-errors';
import { isCI } from '../utils/is-ci';
import { isNxCloudUsed } from '../utils/nx-cloud-utils';
Expand Down Expand Up @@ -58,6 +57,7 @@ import { TaskResultsLifeCycle } from './life-cycles/task-results-life-cycle';
import { TaskTimingsLifeCycle } from './life-cycles/task-timings-life-cycle';
import { getTuiTerminalSummaryLifeCycle } from './life-cycles/tui-summary-life-cycle';
import {
assertTaskGraphDoesNotContainInvalidTargets,
findCycle,
makeAcyclic,
validateNoAtomizedTasks,
Expand Down Expand Up @@ -358,6 +358,8 @@ function createTaskGraphAndRunValidations(
extraOptions.excludeTaskDependencies
);

assertTaskGraphDoesNotContainInvalidTargets(taskGraph);

const cycle = findCycle(taskGraph);
if (cycle) {
if (process.env.NX_IGNORE_CYCLES === 'true' || nxArgs.nxIgnoreCycles) {
Expand Down
67 changes: 55 additions & 12 deletions packages/nx/src/tasks-runner/task-graph-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import '../internal-testing-utils/mock-fs';

import { vol } from 'memfs';

import {
findCycle,
findCycles,
Expand All @@ -22,7 +20,7 @@ describe('task graph utils', () => {
e: ['q', 'a'],
q: [],
},
} as any)
})
).toEqual(['a', 'c', 'e', 'a']);

expect(
Expand All @@ -36,10 +34,56 @@ describe('task graph utils', () => {
f: ['q'],
q: ['e'],
},
} as any)
})
).toEqual(['a', 'c', 'a']);
});

it('should return a continuous cycle is there', () => {
expect(
findCycle({
dependencies: {
a: [],
b: [],
c: [],
d: [],
e: [],
q: [],
},
continuousDependencies: {
a: ['b', 'c'],
b: ['d'],
c: ['e'],
d: [],
e: ['q', 'a'],
q: [],
},
})
).toEqual(['a', 'c', 'e', 'a']);

expect(
findCycle({
dependencies: {
a: ['b'],
b: [],
c: [],
d: [],
e: [],
f: [],
q: [],
},
continuousDependencies: {
a: [],
b: ['a'],
c: [],
d: [],
e: [],
f: [],
q: [],
},
})
).toEqual(['a', 'b', 'a']);
});

it('should return null when no cycle', () => {
expect(
findCycle({
Expand All @@ -51,7 +95,7 @@ describe('task graph utils', () => {
e: ['q'],
q: [],
},
} as any)
})
).toEqual(null);
});
});
Expand All @@ -68,7 +112,7 @@ describe('task graph utils', () => {
e: ['q', 'a'],
q: [],
},
} as any)
})
).toEqual(new Set(['a', 'c', 'e']));

expect(
Expand All @@ -82,7 +126,7 @@ describe('task graph utils', () => {
f: ['q'],
q: ['e'],
},
} as any)
})
).toEqual(new Set(['a', 'c', 'e', 'f', 'q']));
expect(
findCycles({
Expand All @@ -95,7 +139,7 @@ describe('task graph utils', () => {
f: ['q'],
q: ['c'],
},
} as any)
})
).toEqual(new Set(['a', 'b', 'd', 'c', 'f', 'q']));
});

Expand All @@ -110,7 +154,7 @@ describe('task graph utils', () => {
e: ['q'],
q: [],
},
} as any)
})
).toEqual(null);
});
});
Expand All @@ -126,7 +170,7 @@ describe('task graph utils', () => {
d: [],
e: ['a'],
},
} as any;
};
makeAcyclic(graph);

expect(graph.dependencies).toEqual({
Expand All @@ -151,13 +195,12 @@ describe('task graph utils', () => {
mockProcessExit = jest
.spyOn(process, 'exit')
.mockImplementation((code: number) => {
return undefined as any as never;
return undefined as never;
});
});

afterEach(() => {
process.env = env;
vol.reset();
mockProcessExit.mockRestore();
});

Expand Down
60 changes: 55 additions & 5 deletions packages/nx/src/tasks-runner/task-graph-utils.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { ProjectGraph } from '../config/project-graph';
import { TaskGraph } from '../config/task-graph';
import { Task, TaskGraph } from '../config/task-graph';
import { output } from '../utils/output';

function _findCycle(
graph: { dependencies: Record<string, string[]> },
graph: {
dependencies: Record<string, string[]>;
continuousDependencies?: Record<string, string[]>;
},
id: string,
visited: { [taskId: string]: boolean },
path: string[]
): string[] | null {
if (visited[id]) return null;
visited[id] = true;

for (const d of graph.dependencies[id]) {
for (const d of [
...graph.dependencies[id],
...(graph.continuousDependencies?.[id] ?? []),
]) {
if (path.includes(d)) return [...path, d];
const cycle = _findCycle(graph, d, visited, [...path, d]);
if (cycle) return cycle;
Expand All @@ -25,6 +31,7 @@ function _findCycle(
*/
export function findCycle(graph: {
dependencies: Record<string, string[]>;
continuousDependencies?: Record<string, string[]>;
}): string[] | null {
const visited = {};
for (const t of Object.keys(graph.dependencies)) {
Expand All @@ -45,6 +52,7 @@ export function findCycle(graph: {
*/
export function findCycles(graph: {
dependencies: Record<string, string[]>;
continuousDependencies?: Record<string, string[]>;
}): Set<string> | null {
const visited = {};
const cycles = new Set<string>();
Expand All @@ -63,7 +71,10 @@ export function findCycles(graph: {
}

function _makeAcyclic(
graph: { dependencies: Record<string, string[]> },
graph: {
dependencies: Record<string, string[]>;
continuousDependencies?: Record<string, string[]>;
},
id: string,
visited: { [taskId: string]: boolean },
path: string[]
Expand All @@ -72,9 +83,11 @@ function _makeAcyclic(
visited[id] = true;

const deps = graph.dependencies[id];
for (const d of [...deps]) {
const continuousDeps = graph.continuousDependencies?.[id] ?? [];
for (const d of [...deps, ...continuousDeps]) {
if (path.includes(d)) {
deps.splice(deps.indexOf(d), 1);
continuousDeps.splice(continuousDeps.indexOf(d), 1);
} else {
_makeAcyclic(graph, d, visited, [...path, d]);
}
Expand Down Expand Up @@ -142,3 +155,40 @@ export function validateNoAtomizedTasks(
}
process.exit(1);
}

export function assertTaskGraphDoesNotContainInvalidTargets(
taskGraph: TaskGraph
) {
const invalidTasks = [];
for (const task of Object.values(taskGraph.tasks)) {
if (
task.parallelism === false &&
taskGraph.continuousDependencies[task.id].length > 0
) {
invalidTasks.push(task);
}
}

if (invalidTasks.length > 0) {
throw new NonParallelTaskDependsOnContinuousTasksError(
invalidTasks,
taskGraph
);
}
}

class NonParallelTaskDependsOnContinuousTasksError extends Error {
constructor(public invalidTasks: Task[], taskGraph: TaskGraph) {
let message =
'The following tasks do not support parallelism but depend on continuous tasks:';

for (const task of invalidTasks) {
message += `\n - ${task.id} -> ${taskGraph.continuousDependencies[
task.id
].join(', ')}`;
}

super(message);
this.name = 'NonParallelTaskDependsOnContinuousTasksError';
}
}
Loading