Skip to content
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
12 changes: 11 additions & 1 deletion packages/nx/src/tasks-runner/task-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { TaskStatus } from './tasks-runner';
import { Batch, TasksSchedule } from './tasks-schedule';
import {
calculateReverseDeps,
expandInitiatingTasksThroughNoop,
getExecutorForTask,
getPrintableCommandArgsForTask,
getTargetConfigurationForTask,
Expand Down Expand Up @@ -98,7 +99,16 @@ export class TaskOrchestrator {
);
private reverseTaskDeps = calculateReverseDeps(this.taskGraph);

private initializingTaskIds = new Set(this.initiatingTasks.map((t) => t.id));
// `nx:noop` initiating tasks exit instantly via the fast-path in
// `spawnProcess`. If we treat the noop itself as the keep-alive anchor for
// its continuous dependencies, `cleanUpUnneededContinuousTasks` kills those
// children the moment the noop finishes. Expand through noops so the
// underlying real tasks become the anchors.
private initializingTaskIds = expandInitiatingTasksThroughNoop(
this.initiatingTasks,
this.taskGraph,
this.projectGraph
);

private processedTasks = new Map<string, Promise<NodeJS.ProcessEnv>>();

Expand Down
180 changes: 180 additions & 0 deletions packages/nx/src/tasks-runner/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
expandDependencyConfigSyntaxSugar,
expandInitiatingTasksThroughNoop,
expandWildcardTargetConfiguration,
getDependencyConfigs,
getOutputsForTargetAndConfiguration,
Expand All @@ -8,6 +9,7 @@ import {
validateOutputs,
} from './utils';
import { ProjectGraph, ProjectGraphProjectNode } from '../config/project-graph';
import { Task, TaskGraph } from '../config/task-graph';
import { ProjectConfiguration } from '../config/workspace-json-project-json';

describe('utils', () => {
Expand Down Expand Up @@ -833,6 +835,184 @@ describe('utils', () => {
});
});

describe('expandInitiatingTasksThroughNoop', () => {
function mkTask(id: string): Task {
const [project, target] = id.split(':');
return {
id,
target: { project, target },
overrides: {},
outputs: [],
projectRoot: project,
parallelism: true,
};
}

function mkProjectGraph(
targets: Record<string, Record<string, { executor: string }>>
): ProjectGraph {
const nodes: ProjectGraph['nodes'] = {};
for (const [project, ts] of Object.entries(targets)) {
nodes[project] = {
name: project,
type: 'lib',
data: { root: `libs/${project}`, targets: ts as any },
};
}
return { nodes, dependencies: {}, externalNodes: {} };
}

function mkTaskGraph(
tasks: string[],
dependencies: Record<string, string[]> = {},
continuousDependencies: Record<string, string[]> = {}
): TaskGraph {
const taskMap: Record<string, Task> = {};
for (const t of tasks) taskMap[t] = mkTask(t);
const deps: Record<string, string[]> = {};
const cdeps: Record<string, string[]> = {};
for (const t of tasks) {
deps[t] = dependencies[t] ?? [];
cdeps[t] = continuousDependencies[t] ?? [];
}
return {
tasks: taskMap,
dependencies: deps,
continuousDependencies: cdeps,
roots: [],
};
}

it('returns initiating ids unchanged when none are noop', () => {
const projectGraph = mkProjectGraph({
app: { build: { executor: 'nx:run-commands' } },
});
const taskGraph = mkTaskGraph(['app:build']);
const result = expandInitiatingTasksThroughNoop(
[taskGraph.tasks['app:build']],
taskGraph,
projectGraph
);
expect([...result]).toEqual(['app:build']);
});

it('replaces a noop initiating task with its continuous dependencies', () => {
const projectGraph = mkProjectGraph({
app: {
dev: { executor: 'nx:noop' },
serve: { executor: 'nx:run-commands' },
watch: { executor: 'nx:run-commands' },
},
});
const taskGraph = mkTaskGraph(
['app:dev', 'app:serve', 'app:watch'],
{},
{ 'app:dev': ['app:serve', 'app:watch'] }
);
const result = expandInitiatingTasksThroughNoop(
[taskGraph.tasks['app:dev']],
taskGraph,
projectGraph
);
expect([...result].sort()).toEqual(['app:serve', 'app:watch']);
});

it('replaces a noop initiating task with its non-continuous dependencies', () => {
const projectGraph = mkProjectGraph({
app: {
orchestrate: { executor: 'nx:noop' },
build: { executor: 'nx:run-commands' },
},
});
const taskGraph = mkTaskGraph(['app:orchestrate', 'app:build'], {
'app:orchestrate': ['app:build'],
});
const result = expandInitiatingTasksThroughNoop(
[taskGraph.tasks['app:orchestrate']],
taskGraph,
projectGraph
);
expect([...result]).toEqual(['app:build']);
});

it('recursively collapses nested noop orchestrators', () => {
const projectGraph = mkProjectGraph({
app: {
outer: { executor: 'nx:noop' },
inner: { executor: 'nx:noop' },
serve: { executor: 'nx:run-commands' },
},
});
const taskGraph = mkTaskGraph(
['app:outer', 'app:inner', 'app:serve'],
{},
{
'app:outer': ['app:inner'],
'app:inner': ['app:serve'],
}
);
const result = expandInitiatingTasksThroughNoop(
[taskGraph.tasks['app:outer']],
taskGraph,
projectGraph
);
expect([...result]).toEqual(['app:serve']);
});

it('returns an empty set for a noop with no dependencies', () => {
const projectGraph = mkProjectGraph({
app: { nothing: { executor: 'nx:noop' } },
});
const taskGraph = mkTaskGraph(['app:nothing']);
const result = expandInitiatingTasksThroughNoop(
[taskGraph.tasks['app:nothing']],
taskGraph,
projectGraph
);
expect(result.size).toBe(0);
});

it('terminates on cyclic noop dependencies', () => {
const projectGraph = mkProjectGraph({
app: {
a: { executor: 'nx:noop' },
b: { executor: 'nx:noop' },
},
});
const taskGraph = mkTaskGraph(['app:a', 'app:b'], {
'app:a': ['app:b'],
'app:b': ['app:a'],
});
const result = expandInitiatingTasksThroughNoop(
[taskGraph.tasks['app:a']],
taskGraph,
projectGraph
);
expect(result.size).toBe(0);
});

it('preserves non-noop initiating tasks alongside expanded noops', () => {
const projectGraph = mkProjectGraph({
app: {
dev: { executor: 'nx:noop' },
serve: { executor: 'nx:run-commands' },
test: { executor: 'nx:run-commands' },
},
});
const taskGraph = mkTaskGraph(
['app:dev', 'app:serve', 'app:test'],
{},
{ 'app:dev': ['app:serve'] }
);
const result = expandInitiatingTasksThroughNoop(
[taskGraph.tasks['app:dev'], taskGraph.tasks['app:test']],
taskGraph,
projectGraph
);
expect([...result].sort()).toEqual(['app:serve', 'app:test']);
});
});

class GraphBuilder {
nodes: Record<string, ProjectGraphProjectNode> = {};

Expand Down
42 changes: 42 additions & 0 deletions packages/nx/src/tasks-runner/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,48 @@ export function getExecutorNameForTask(task: Task, projectGraph: ProjectGraph) {
return getTargetConfigurationForTask(task, projectGraph)?.executor;
}

/**
* Expand a set of initiating task IDs by walking through any `nx:noop` tasks
* and replacing them with their direct dependencies + continuous dependencies.
* Non-noop tasks are kept as-is; cycles are safe.
*
* An `nx:noop` executor returns immediately, so if it is the only thing
* anchoring a continuous child, the child gets killed by
* `cleanUpUnneededContinuousTasks` the moment the noop completes. Treating the
* noop's dependencies as the real anchors preserves the intended orchestration.
*/
export function expandInitiatingTasksThroughNoop(
initiatingTasks: Task[],
taskGraph: TaskGraph,
projectGraph: ProjectGraph
): Set<string> {
const expanded = new Set<string>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we have a util for this somewhere.

const visited = new Set<string>();
const queue: string[] = initiatingTasks.map((t) => t.id);

while (queue.length > 0) {
const taskId = queue.shift()!;
if (visited.has(taskId)) continue;
visited.add(taskId);

const task = taskGraph.tasks[taskId];
if (!task) continue;

if (getExecutorNameForTask(task, projectGraph) === 'nx:noop') {
for (const dep of taskGraph.dependencies[taskId] ?? []) {
queue.push(dep);
}
for (const dep of taskGraph.continuousDependencies[taskId] ?? []) {
queue.push(dep);
}
} else {
expanded.add(taskId);
}
}

return expanded;
}

export function getExecutorForTask(
task: Task,
projects: Record<string, ProjectConfiguration>
Expand Down
Loading