Skip to content

feat(core): select affected tasks instead of affected projects - #36841

Draft
AgentEnder wants to merge 14 commits into
feat/nxc-4859-task-based-affectedfrom
feat/nxc-4859-affected-task-granularity
Draft

feat(core): select affected tasks instead of affected projects#36841
AgentEnder wants to merge 14 commits into
feat/nxc-4859-task-based-affectedfrom
feat/nxc-4859-affected-task-granularity

Conversation

@AgentEnder

@AgentEnder AgentEnder commented Aug 28, 2026

Copy link
Copy Markdown
Member

Stacked on #36825. Review the commits after cleanup(core): move the affected touched-project locators into rust.

Current Behavior

nx affected selects projects, so every target of every dependent runs whether or not the change can reach it. Changing a spec file in packages/nx selects all 45 test tasks here, including projects whose test inputs never see that file.

Two target configs also create a dependency the project graph never records:

{
  "inputs": [{ "input": "production", "projects": ["shared-config"] }],
  "dependsOn": [{ "projects": ["api"], "target": "build" }],
}

gather_project_inputs inlines the named project's filesets into the consumer's plan, and processTasksForMultipleProjects builds a task edge to it. Neither needs a project-graph edge, so the reverse walk misses both, and a change to a project referenced only this way selects nothing.

Expected Behavior

nx affected selects tasks whose inputs the change reaches. We build the candidate task graph, resolve each task's hash plan, and match the changed paths against each instruction's globs.

Off by default, opt in with NX_AFFECTED_GRANULARITY=task. Env var only, so the trial leaves no config to migrate away later.

Selection

Nx repo, changing one spec file. Selection marks 74 of 280 candidate tasks: 65 lint, 2 test, 1 build, and nothing in build-base or typecheck.

command project-grained task-grained
-t test, test tasks 45 4
-t test, whole graph 207 160
-t build 163 153
-t build,test,lint 278 276

The first row is the win. The rest of each graph is the dependency closure of the selected tasks, which has to stay so the selected ones can run; those restore from cache. lint reads nearly every file in a project, so a graph dominated by lint re-inflates and -t build,test,lint barely moves.

Cost

Selection now plans the candidate tasks, which the hasher then reuses instead of planning again, so a run plans once rather than twice.

command, warm nx 23.1.2 this PR, project this PR, task
show projects --affected 477ms 515ms n/a
affected -t lint, fully cached 13811ms 14266ms 13888ms

Task hashes are unchanged: this branch, master and nx 23.1.2 all hit the same cache entries.

show projects --affected is still project-grained, so there is no task column for it.

Related Issue(s)

NXC-4859

@AgentEnder
AgentEnder force-pushed the feat/nxc-4859-affected-task-granularity branch from 1340f19 to be5c9d6 Compare August 28, 2026 20:01
@nx-cloud

nx-cloud Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit aa507c2

Command Status Duration Result
nx-cloud record -- nx format:check ❌ Failed <1s View ↗
nx run-many -t check-imports check-lock-files c... ✅ Succeeded 5s View ↗
nx-cloud record -- nx sync:check ✅ Succeeded 18s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-30 02:29:09 UTC

@AgentEnder
AgentEnder changed the base branch from feat/nxc-4861-files-input to feat/nxc-4859-task-based-affected August 28, 2026 20:22
@AgentEnder
AgentEnder force-pushed the feat/nxc-4859-affected-task-granularity branch from be5c9d6 to cf266fc Compare August 29, 2026 17:06
Tests the changed paths against each hash-plan instruction's globs, rather
than resolving instructions to file lists and intersecting. A deleted file has
no entry in the workspace file map, so a resolved list could never contain it
and every rename would be missed. It also inverts the cost to
O(unique instructions x changed files), and instructions are interned so a glob
set shared by a thousand tasks is compiled once.

TaskOutput is deliberately skipped: it resolves to a dependent task's build
artifacts, which are gitignored and do not exist yet when affected runs.
Dependency changes reach a consumer through task-edge propagation instead.
Builds the full candidate task graph, plans it, matches changed paths against
each instruction's globs, then propagates along dependentTasksOutputFiles edges
with three bits per task over a topological order.

The locators still run and seed the result. A file intersection alone
under-selects three ways they cover: ProjectConfiguration resolves to no files,
so a project.json edit can be invisible; lockfile and external-dependency
changes are not paths; and a blanket trigger like nx.json has no fileset to
match. Seeding keeps this a superset of the project-grained answer.

Extracts the graph marshal and the locator fan-out so filterAffected and
computeAffectedTasks share one locator path rather than two.

NOT yet wired to any command: planning every candidate task costs ~3ms/task on
this repo, so discovering that 4 tasks are affected costs ~760ms of planning
across 171 candidates. See the notes on the PR before enabling this anywhere.
Adds pruneTaskGraphToSelection, threads an optional TaskSelection through
runCommand so the pruned graph reaches the runner, and dispatches on
nx.json affected.granularity (NX_AFFECTED_GRANULARITY overrides). Default
stays project-grained.

The prune keeps each selected task's dependency closure: an affected task's
upstream still has to run or restore from cache. It is applied before
validation so a cycle or atomizer error names what will actually run, and
re-applied after sync generators rebuild the graph.

Gate only engages for `nx affected` with a target; `nx graph --affected` has
no target to select against and stays project-grained.
A task can only be affected if a changed file reaches its plan, and every
route there marks the owning project: its own files, a dependency's files
inlined by ^ inputs (whose dependents the reverse walk picks up), and
{workspaceRoot} filesets, which getImplicitlyTouchedProjects attributes. So
projects-of(affected tasks) is a subset of the project-grained set, and
planning anything outside it is wasted.

Measured on this repo, selection plus hashing the selected set:
  a README change      1369ms -> 6ms
  a leaf e2e spec      1956ms -> 523ms
Planning every candidate to discover that nothing is affected was the whole
cost of the docs-only case, which is a common CI shape.

Also shares one HashPlanner per project graph, so the candidate pass populates
subtree_memo and instruction_pool once rather than per call.
Two target-configuration shapes create a real dependency the project graph
never records, so the reverse walk in filterAffected cannot see either:

  inputs: [{ input: 'production', projects: ['shared-config'] }]
    gather_project_inputs resolves the list and inlines those projects'
    filesets into the task's plan, so its hash genuinely depends on them.

  dependsOn: [{ projects: ['api'], target: 'build' }]
    processTasksForMultipleProjects builds a task edge to a named project.

Project-grained affected under-selects for both today: a change to a project
referenced only this way selects nothing. Task granularity has to close the
hole, because it uses the project-grained answer to bound which tasks are
worth planning.

Also narrows the seeds. The file matcher is precise for ordinary source
files, so seeding every task of a touched project there just reproduced
project granularity. Seeds now cover only what glob matching cannot see:
project.json/package.json edits, lockfile changes, and the deleted-project
blanket.
Task-grained affected plans the candidate tasks to decide what is affected,
and the hasher then plans the survivors. A planner carries subtree_memo,
instruction_pool and external_deps_mapped across getPlans calls, so handing
the same instance to both makes the second pass mostly memo hits.

Threaded as an argument from computeAffectedTasks through runCommand to
createTaskHasher, rather than held in a module-level cache keyed on the
graph. A cache works but retains napi objects for as long as the graph is
reachable; passing it explicitly scopes the lifetime to the command and
avoids depending on GC timing, which a WeakRef would not.

Measured on this repo, selection plus hashing the selected set, paired runs:
  a spec-only change    3699ms -> 3118ms
  a source file         3987ms -> 3647ms
  a project.json        3788ms -> 3260ms
Drops the nx.json affected.granularity key. This is a trial flag that we
expect to remove once task granularity becomes the default, and a config key
would need a migration to take back out.
I/O tracing turns an observed read of a generated artifact into a
FileSetInput with includeIgnored, which can preclude an explicit
dependentTasksOutputFiles input. The plan then holds a disk-backed Files
instruction reading a dependency's build output with no TaskOutput anywhere
in it, and propagation missed the edge entirely.

dependentOutputEdges now builds consumer -> producer edges from both
sources: an explicit TaskOutput, whose producer id is recovered by matching
the embedded outputs, and an includeIgnored fileset whose pattern overlaps a
producer's declared outputs.

Matching is pattern-to-pattern against declared outputs, never the
filesystem. The artifact is the thing the run would produce, so it is absent
from disk and gitignored, and any check that consults disk answers "no edge"
for every task that has not been built. Overlap compares literal prefixes
segment-wise, so dist/libs/ui does not swallow dist/libs/ui-legacy.

Producers are searched over the whole dependency closure. TaskOutput does not
record whether transitive was set and a traced read cannot say how deep the
producer sits, so a narrower scope could miss an edge; over-reporting costs a
cache hit, missing one skips a task that needed to run.

TaskOutput stays out of direct changed-file matching, since its artifacts can
never appear in a diff.
The first cut walked the dependency closure per consumer, cloning task ids at
every hop and re-reducing the same glob set once per task that referenced it.
That was 216ms for 335 tasks here, and it scales with tasks x closure.

Instructions are interned, so a glob set shared by a thousand tasks has one
id and is now reduced to its literal prefixes once. The closure walk moves
over positions into a flattened graph rather than hashing ids, reuses one
generation-stamped scratch buffer per rayon worker, and runs in parallel.
Same 1560 edges, 216ms -> 54ms.

Also returns a consumer -> producers map instead of an edge list, which
halves the strings crossing the boundary and is the shape the caller builds
anyway.
The blanket rule fired whenever the project-grained pass happened to select
every project, as a proxy for "a blanket locator returned everything". In a
small workspace an ordinary source change does exactly that, so every task of
every project got seeded and task granularity collapsed back to project
granularity.

Only a deleted project config actually needs the widening: its project is
gone from the graph, so no surviving task has a fileset that names it. The
check mirrors projects_from_project_glob_changes, which decides the same
thing by asking whether the file is still on disk.

nx.json needs no seed at all. Every plan carries it in the always-on
workspace fileset (ALWAYS_ON_FILES in hash_planner), so the matcher already
reaches every task precisely.
…locators

The project locators and the task matcher both answer "which project owns
this path", and both carried their own root map plus the same comment
explaining why create_project_root_mappings cannot be used. Fixing that
shared helper later now means finding one workaround rather than two.

ProjectRoots and normalize_path move to affected/project_paths.rs with tests
for the cases that motivated them: an empty project root, a whole-segment
match so libs/a does not claim libs/a-legacy, and Windows separators.

Also trims the matcher while it is being touched. Only instructions that
matched a file are kept, so task selection is membership rather than
collecting every hit; the per-task index vector is now built only under
collectMatches, the reduce runs on rayon, and file owners borrow from the
graph instead of allocating a String each. 125ms -> 42ms for 335 tasks.
…ing twice

Task-grained selection plans every candidate task, then the hasher planned the
surviving ones all over again. Planning costs about 3ms per task on this repo,
so the second pass was roughly a second on a 335 task graph, and it is what put
the task path 18% behind the project-grained one.

subsetHashPlans narrows an existing HashPlans to a task list. A plan is a vector
of interned instruction ids, so this filters a map and clones an Arc; the work
that made the plans is shared, not repeated.

It returns null when any requested task has no plan, which is the caller's
signal that the plans describe some other task graph. The hasher then plans for
real. That happens with an I/O snapshot bundle, whose plans are fetched after
selection and so describe different hashing, and after makeAcyclic, which drops
edges and changes which upstream outputs a task reads.

Verified hash-identical across all 276 tasks of a real affected run, reused
against freshly planned. Warm cache totals, median of three, this repo:

  changed              task before  task after  project
  utils/path.ts             7449ms      6315ms   6291ms
  ab-testing.spec.ts        6510ms      5102ms   6374ms

The wide case reaches parity, and the narrow case is 20% ahead because it also
hashes 233 tasks rather than 278.
The env-vars-documented conformance rule fails on any NX_ variable read in
source that has no row in the reference table, and NX_AFFECTED_GRANULARITY
had none. That was the only violation in the run; every other rule passed.
oxlint rejects no-duplicate-imports, and both files had picked up a second
import from a module they already imported: subsetHashPlans from ../native
in the hasher, and TargetDependencies from ../../config/nx-json in affected.
@AgentEnder
AgentEnder force-pushed the feat/nxc-4859-affected-task-granularity branch from 1487e21 to aa507c2 Compare August 30, 2026 02:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant