Dependency Graphs & Incremental Builds - Task DAGs, Topological Scheduling, Content Hashing & Affected Detection
Package vs task vs action graphs; topological waves and the critical path; mtime vs content hash vs verifying and constructive traces; early cutoff; runnable Python mini build engine (topo sort, three rebuilders, cycle error) and TS monorepo task engine with affected detection; static vs dynamic dependencies and the Build Systems a la Carte grid.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Why must a build graph be a DAG?
Answer
A cycle has no valid topological order, so tools refuse it.
L2
What is a topological wave?
Answer
The set of tasks whose dependencies are all finished, which can run concurrently.
L3
What bounds wall-clock time no matter how many cores you add?
Answer
The critical path, the longest chain of dependent tasks.
L4
mtime vs content hash?
Answer
mtime rebuilds on touch and checkout. Content hashes ignore touch and only react to changed bytes.
L5
What is early cutoff?
Answer
Stopping propagation when a rebuilt output is byte-identical to its previous value.
L6
How does a monorepo tool compute affected packages?
Answer
Diff against the base, map files to owning packages, then take the reverse-dependency closure.
L7
Static vs dynamic dependencies?
Answer
Static deps are known before running a task. Dynamic deps are discovered while running, like header scanning.
Failure modes
Affected detection skips a test that should run
A script, fixture, env var or generated file is missing from the graph, so the changed file never reaches the test.
mtime rebuilds everything after a fresh checkout
Checkouts and cache restores reset timestamps, so mtime-based tools rebuild everything or miss real changes.
Root files mark everything affected
Editing the root lockfile or base config touches every package, so affected runs fall back to running all.
Misconceptions
More cores always make the build faster.
Wall-clock time cannot drop below the critical path.
Hashing inputs is enough for early cutoff.
Plain dirty propagation still reruns dependents. You need a verifying or constructive trace that compares the rebuilt output.
Affected detection is a correctness feature.
It is an optimization on top of a correct graph. A missing edge silently skips work.
Interviewer traps
Replacing a real graph with hand-written CI path filters.
Path filters drift when someone adds a cross-package import. Derive affected sets from the package or action graph.
Using recursive Make across directories.
Each sub-make sees only part of the graph, which breaks correctness and parallelism.
Design scenario
Same prompt for every reader.
Requirements
Run only what a PR can affect, keep correctness when shared code changes, and explain why a package was skipped.
Failure assumptions
- Some tests read fixtures from sibling packages.
- Generated code is checked in for a few packages.
- The root lockfile changes weekly.
Constraints
- No hand-maintained per-job path globs.
- A cycle in the graph must fail the PR, not be ignored.
Prompt
Design the PR pipeline for a monorepo with 60 packages where full CI takes 40 minutes.
API
How does a developer ask for the affected set for a given base commit?
Data
What do you record per task so the next run can cut off early?
Architecture
Where do the package graph, task graph and cache sit in CI?
Overview
Incremental builds rest on two decisions that Build Systems a la Carte shows are independent: in what order do we run tasks (the scheduler) and does this task need to run at all (the rebuilder). The order comes from a dependency graph, topologically sorted so every task runs after its inputs and independent tasks run in parallel. The "does it need to run" answer comes from change detection, either file timestamps (Make) or content hashes (almost everything modern). Monorepo tools add one more layer on top: affected detection, which maps a set of changed files to the packages and tasks that could possibly be impacted, and skips everything else.
Three graphs people mix up
| Graph | Nodes | Edges | Who builds it | Example |
|---|---|---|---|---|
| Package (project) graph | Packages or projects | "A depends on B" from manifests or imports | npm/pnpm workspaces, Turborepo, Nx, Gradle | web depends on ui |
| Task graph | Package-task pairs | "web:build needs ui:build first" | Turborepo dependsOn, Nx targetDefaults, Gradle task deps | web:build -> ui:build |
| Action graph | Single commands with declared inputs and outputs | Output of one action is input of another | Bazel, Buck2, Pants, Ninja, Make rules | compile(util.c) -> link(app) |
A package graph tells you what might be affected; a task graph tells you what order to run scripts in; an action graph tells the build system exactly which commands to rerun. Turborepo and Nx derive a task graph from a package graph. Bazel and Buck2 work on the action graph directly, which is why they can rerun one compile step instead of a whole package.
How does the build decide a task is dirty?
Prefer
Content hashes with recorded traces
Compare input hashes with the last recorded run and check whether the rebuilt output actually changed.
- A touch changes nothing.
- A comment-only edit stops at main.o through early cutoff.
- Constructive traces let another machine reuse the result.
Alternative
File modification times
Free and simple, but timestamps move without content changing and the reverse.
- touch rebuilds main.o, util.o and app.
- Fresh CI checkouts reset every mtime.
- No early cutoff, because rewriting a file bumps its mtime.
From a diff to the tasks that run
Diagram 1 in four moves. The runnable engines below show each step.
- 1
Map changed files to packages
Diff against the base commit and find the owning packages or targets. - 2
Walk reverse dependencies
Everything downstream of a changed package is affected. - 3
Order tasks into waves
Kahn's algorithm gives parallel waves. A cycle stops the build. - 4
Skip unchanged inputs
Restore from cache when input hashes match, and cut off early when an output is identical.
Topological scheduling and parallelism
A build graph must be a DAG (directed acyclic graph). A topological order puts every node after its dependencies. Kahn's algorithm makes parallelism visible: repeatedly take all nodes with no unfinished dependencies (one "wave"), run them concurrently, then remove them. The number of waves is the length of the critical path, the floor on wall-clock time no matter how many cores you add. A cycle means there is no valid order, so tools refuse it (Make prints a circular dependency warning and drops the edge; Bazel, Nx and Turborepo report the cycle as an error).
Decisions
- 1
Step 1: list changed files since the base commit
- nextStep 2: map files to owning packages or targets
- 2
Step 2: map files to owning packages or targets
- nextStep 3: walk reverse dependencies to get the affected set
- 3
Step 3: walk reverse dependencies to get the affected set
- nextStep 4: expand to tasks and order them topologically
- 4
Step 4: expand to tasks and order them topologically
- nextStep 5: cycle in the graph?
- ?
Step 5: cycle in the graph?
- nextFailure path: no valid order, report the cycle and stop
- nextStep 6: run each wave of ready tasks in parallel
- 6
Failure path: no valid order, report the cycle and stop
- 7
Step 6: run each wave of ready tasks in parallel
- nextStep 7: input hashes unchanged since last run?
- ?
Step 7: input hashes unchanged since last run?
- nextStep 8a: skip or restore from cache
- nextStep 8b: run, record new hashes, compare output for early cutoff
- 9
Step 8a: skip or restore from cache
- 10
Step 8b: run, record new hashes, compare output for early cutoff
Lesson map
Dependency Graphs & Incremental Builds - Task DAGs, Topological Scheduling, Content Hashing & Affected Detection
Package vs task vs action graphs; topological waves and the critical path; mtime vs content hash vs verifying and constructive traces; early cutoff; runnable Python mini build engine (topo sort, three rebuilders, cycle error) and TS monorepo task engine with affected detection; static vs dynamic dependencies and the Build Systems a la Carte grid.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["Step 1: list changed files since the base commit"] b["Step 2: map files to owning packages or targets"] c["Step 3: walk reverse dependencies to get the affected set"] d["Step 4: expand to tasks and order them topologically"] e["Step 5: cycle in the graph?"] x["Failure path: no valid order, report the cycle and stop"] f["Step 6: run each wave of ready tasks in parallel"] g["Step 7: input hashes unchanged since last run?"] h["Step 8a: skip or restore from cache"] i["Step 8b: run, record new hashes, compare output for early cutoff"] a -->|continues| b b -->|continues| c c -->|continues| d d -->|continues| e e -->|continues| x e -->|continues| f f -->|continues| g g -->|continues| h g -->|continues| i
Change detection: mtime vs content hash
| Approach | How it decides "dirty" | Strengths | Failure modes | Used by |
|---|---|---|---|---|
| Modification time | Output older than any input | Free, the filesystem tracks it | touch, git checkout, clock skew, restored backups; no early cutoff | Make |
| Content hash, dirty propagation | Input bytes changed, or any dependency re-ran | Ignores touch | Rebuilds dependents even when a rebuilt output is byte-identical | Simple hash-based scripts |
| Verifying trace (hash of inputs last time) | Any input hash differs from the recorded trace | Ignores touch, gives early cutoff | Must store traces; hashing large trees costs time | Shake, Ninja, Bazel (local), Gradle up-to-date checks |
| Constructive trace (input hashes to output value) | Look up the result by input hashes, maybe from another machine | Enables shared and remote caches | Wrong if inputs are undeclared or tasks are non-deterministic | Bazel, Buck2, Turborepo and Nx caches, Gradle build cache |
Early cutoff is the key optimization: if a comment edit changes main.c but the compiled main.o is byte-for-byte identical, nothing downstream needs to run. Make cannot do this, because rewriting main.o bumps its mtime and every dependent looks stale.
Affected detection in monorepos
| Tool family | How "affected" is computed | Granularity | Main risk |
|---|---|---|---|
Turborepo (--affected, --filter with git ranges) | Changed files to packages, plus dependents in the package graph | Package | Global files (root lockfile, root config) mark everything affected |
Nx (nx affected) | Changed files to projects using the project graph, which can include import analysis | Project | Implicit dependencies missed if not declared or inferred |
Bazel (rdeps queries, or tools that diff target hashes between two commits) | Reverse dependencies of changed targets in the action graph | Target | Query and hashing cost on very large graphs |
| Plain CI path filters | Hand-written globs per job | Directory | Drift: someone adds a dependency and forgets the glob |
Affected detection is an optimization on top of a correct graph. If an edge is missing (a script reads a sibling folder without declaring it), affected detection silently skips tests that should have run. Caching has the same weakness, which is why both reward precise inputs.
Static vs dynamic dependencies
Build Systems a la Carte separates applicative tasks, whose dependencies are known before running them, from monadic tasks, which discover dependencies while running (read a file listing other files, then depend on those).
| Static (applicative) | Dynamic (monadic) | |
|---|---|---|
| Graph known up front? | Yes | Only after running some tasks |
| Scheduler | Topological sort (Make, Ninja, CloudBuild, Buck in the paper's model) | Suspending (Shake, Nix) or restarting (Excel, Bazel) |
| Example | main.o from main.c and util.h listed in a Makefile | C header discovery, release.tar built from files named inside release.txt |
| Pros | Simple, easy to parallelize and query | Expressive, no "generate the Makefile" phases |
| Cons | Needs workarounds (dep files, multi-phase builds) for discovered deps | Harder to predict, query and distribute |
The paper's grid places real systems by scheduler and rebuilder:
| Rebuilder \ Scheduler | Topological | Restarting | Suspending |
|---|---|---|---|
| Dirty bit | Make | Excel | - |
| Verifying traces | Ninja | - | Shake |
| Constructive traces | CloudBuild | Bazel | Cloud Shake (proposed) |
| Deep constructive traces | Buck | - | Nix |
Modern tools have moved since 2018: Buck2's docs describe a dynamic (monadic) graph engine, and Nix's experimental dynamic derivations aim to make the store layer monadic too. The grid still explains why they behave as they do.
Runnable: a mini build engine (Python)
The engine sorts tasks into parallel waves with Kahn's algorithm, then runs the same three edits under three rebuilders: a no-op build, touch util.h, and a comment-only edit to main.c. Finally it introduces a cycle.
"""Mini build engine: topological scheduling plus three ways to decide "is it dirty?".
Rebuilders compared (names from "Build Systems a la Carte", Mokhov, Mitchell, Peyton Jones 2018):
- mtime : Make-style dirty bit; rebuild if any input is newer than the output
- hash : content hashes for change detection, but any re-run dependency marks
dependents dirty (dirtiness propagates, no early cutoff)
- hash+cut : verifying trace; rebuild only if an input's hash differs from the last
build, so a rebuilt output with an identical hash stops propagation (early cutoff)
Time is simulated with a counter so the output is deterministic.
"""
import hashlib
from collections import deque
def sha(s: str) -> str:
return hashlib.sha256(s.encode()).hexdigest()[:8]
# key -> (inputs, function of input values). Sources have no entry.
rules = {
"util.o": (["util.c", "util.h"], lambda v: "O[" + strip_comments(v[0]) + "]"),
"main.o": (["main.c", "util.h"], lambda v: "O[" + strip_comments(v[0]) + "]"),
"app": (["util.o", "main.o"], lambda v: "EXE[" + v[0] + v[1] + "]"),
}
def strip_comments(code: str) -> str:
"""Our 'compiler' ignores // comments, so a comment edit yields identical object code."""
return " ".join(line.split("//")[0].strip() for line in code.splitlines()).strip()
def topo_levels(rules):
"""Kahn's algorithm, grouped into levels: everything in one level can run in parallel."""
nodes = set(rules) | {i for ins, _ in rules.values() for i in ins}
indeg = {n: 0 for n in nodes}
users = {n: [] for n in nodes}
for out, (ins, _) in rules.items():
for i in ins:
indeg[out] += 1; users[i].append(out)
level = sorted(n for n in nodes if indeg[n] == 0)
levels, seen = [], 0
while level:
levels.append(level); seen += len(level)
nxt = []
for n in level:
for u in users[n]:
indeg[u] -= 1
if indeg[u] == 0: nxt.append(u)
level = sorted(nxt)
if seen != len(nodes):
raise ValueError("cycle detected: a build graph must be a DAG")
return levels
class Engine:
def __init__(self, mode):
self.mode = mode; self.clock = 0
self.files = {} # name -> (content, mtime)
self.trace = {} # out -> list of input hashes seen at last build
def write(self, name, content, touch_only=False):
self.clock += 1
old = self.files.get(name, (content, 0))[0]
self.files[name] = (old if touch_only else content, self.clock)
def build(self):
ran = []
for level in topo_levels(rules):
for out in level:
if out not in rules: continue
ins, fn = rules[out]
if self.dirty(out, ins, ran):
ran.append(out)
self.write(out, fn([self.files[i][0] for i in ins]))
self.trace[out] = [sha(self.files[i][0]) for i in ins]
return ran
def dirty(self, out, ins, ran):
if out not in self.files: return True
if self.mode == "mtime":
return any(self.files[i][1] > self.files[out][1] for i in ins)
changed = self.trace.get(out) != [sha(self.files[i][0]) for i in ins]
if self.mode == "hash":
return changed or any(i in ran for i in ins)
return changed # hash+cut: identical input hashes mean nothing to do
print("parallel levels:", topo_levels(rules))
for mode in ["mtime", "hash", "hash+cut"]:
e = Engine(mode)
e.write("util.h", "int add(int,int);")
e.write("util.c", "int add(int a,int b){return a+b;}")
e.write("main.c", "int main(){return add(1,2);}")
e.build()
a = e.build()
e.write("util.h", "", touch_only=True) # `touch util.h`
b = e.build()
e.write("main.c", "int main(){return add(1,2);} // tidy") # comment-only edit
c = e.build()
print(f"{mode:9} no-op={a} touch={b} comment-edit={c}")
rules["util.h"] = (["app"], lambda v: v[0]) # introduce a cycle
try:
topo_levels(rules)
except ValueError as err:
print("error:", err)Output:
parallel levels: [['main.c', 'util.c', 'util.h'], ['main.o', 'util.o'], ['app']]
mtime no-op=[] touch=['main.o', 'util.o', 'app'] comment-edit=['main.o', 'app']
hash no-op=[] touch=[] comment-edit=['main.o', 'app']
hash+cut no-op=[] touch=[] comment-edit=['main.o']
error: cycle detected: a build graph must be a DAGRead the output row by row: mtime rebuilds everything on a touch; hashing ignores the touch but still relinks app after the comment edit; the verifying trace sees that main.o came out identical and stops there.
Runnable: a monorepo task engine with affected detection (TypeScript)
This is the shape of a Turborepo- or Nx-style run: a package graph becomes a task graph (build depends on dependencies' build, test on its own build), tasks run in topological waves, each task hash includes its dependencies' hashes, and an "affected" run selects only packages downstream of the changed files.
// Mini monorepo task engine: package graph -> task graph -> topological waves,
// content-hash based skipping, and "affected" selection from a list of changed files.
// Tiny non-cryptographic hash (FNV-1a, 32-bit) so the demo needs no Node type packages.
// Real tools use SHA-256 (Bazel, Nix) or fast hashes such as xxHash; the idea is identical.
function sha(s: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
return h.toString(16).padStart(8, "0");
}
type Pkg = { deps: string[]; files: Record<string, string> };
const repo: Record<string, Pkg> = {
utils: { deps: [], files: { "utils/src/index.ts": "export const x = 1" } },
ui: { deps: ["utils"], files: { "ui/src/button.tsx": "button v1" } },
api: { deps: ["utils"], files: { "api/src/client.ts": "client v1" } },
web: { deps: ["ui", "api"], files: { "web/src/page.tsx": "page v1" } },
docs: { deps: ["ui"], files: { "docs/src/index.md": "docs v1" } },
};
// Task graph: "build" depends on the build of every dependency package (Turborepo "^build").
// "test" depends on the same package's build.
type Task = string; // "pkg:task"
const deps = (t: Task): Task[] => {
const [p, name] = t.split(":");
return name === "build" ? repo[p].deps.map((d) => `${d}:build`) : [`${p}:build`];
};
// Topological waves (Kahn). Tasks in the same wave can run in parallel.
function waves(targets: Task[]): Task[][] {
const all = new Set<Task>();
const visit = (t: Task) => { if (!all.has(t)) { all.add(t); deps(t).forEach(visit); } };
targets.forEach(visit);
const indeg = new Map<Task, number>([...all].map((t) => [t, deps(t).length]));
const out: Task[][] = [];
let ready = [...all].filter((t) => indeg.get(t) === 0).sort();
while (ready.length) {
out.push(ready);
const next: Task[] = [];
for (const t of all) {
if (indeg.get(t)! > 0 && deps(t).every((d) => out.flat().includes(d)) && !next.includes(t)) next.push(t);
}
next.forEach((t) => indeg.set(t, 0));
ready = next.sort();
}
return out;
}
// Task hash = own files + task name + hashes of the tasks it depends on (so changes propagate).
const memo = new Map<Task, string>();
function taskHash(t: Task): string {
if (memo.has(t)) return memo.get(t)!;
const p = t.split(":")[0];
const own = Object.entries(repo[p].files).map(([f, c]) => f + sha(c)).join("|");
const h = sha(t + own + deps(t).map(taskHash).join(","));
memo.set(t, h);
return h;
}
const cache = new Set<string>();
function run(targets: Task[], label: string) {
memo.clear();
const ran: Task[] = [];
const hit: Task[] = [];
for (const wave of waves(targets)) {
for (const t of wave) {
const k = taskHash(t);
if (cache.has(k)) hit.push(t); else { cache.add(k); ran.push(t); }
}
}
console.log(`${label}: ran ${ran.length} [${ran.join(" ")}] cache hits ${hit.length}`);
}
// "Affected": owning packages of changed files, plus everything that depends on them.
function affected(changedFiles: string[]): string[] {
const set = new Set(Object.keys(repo).filter((p) => changedFiles.some((f) => f.startsWith(p + "/"))));
let grew = true;
while (grew) {
grew = false;
for (const [p, pkg] of Object.entries(repo)) {
if (!set.has(p) && pkg.deps.some((d) => set.has(d))) { set.add(p); grew = true; }
}
}
return [...set].sort();
}
const everything = Object.keys(repo).flatMap((p) => [`${p}:build`, `${p}:test`]);
console.log("waves:", waves(everything).map((w) => `[${w.join(" ")}]`).join(" -> "));
run(everything, "cold build");
run(everything, "second build, nothing changed");
repo.api.files["api/src/client.ts"] = "client v2";
const changed = ["api/src/client.ts"];
const aff = affected(changed);
console.log("affected packages:", aff.join(", "));
run(aff.flatMap((p) => [`${p}:build`, `${p}:test`]), "affected-only run after editing api");Output:
waves: [utils:build] -> [api:build ui:build utils:test] -> [api:test docs:build ui:test web:build] -> [docs:test web:test]
cold build: ran 10 [utils:build api:build ui:build utils:test api:test docs:build ui:test web:build docs:test web:test] cache hits 0
second build, nothing changed: ran 0 [] cache hits 10
affected packages: api, web
affected-only run after editing api: ran 4 [api:build api:test web:build web:test] cache hits 2Expectedwaves: [utils:build] -> [api:build ui:build utils:test] -> [api:test docs:build ui:test web:build] -> [docs:test web:test] cold build: ran 10 [utils:build api:build ui:build utils:test api:test docs:build ui:test web:build docs:test web:test] cache hits 0 second build, nothing changed: ran 0 [] cache hits 10 affected packages: api, web affected-only run after editing api: ran 4 [api:build api:test web:build web:test] cache hits 2
Press Run. Snippets must be self-contained — no network, files, or native modules.
utils:build and ui:build still appear as cache hits in the affected run because web:build needs their outputs on disk; affected selection decides what to ask for, and the cache decides what actually executes.
What happens if you choose otherwise
- mtime instead of hashes in CI: fresh checkouts give every file a new mtime, so mtime-based tools rebuild everything or, after cache restores, rebuild nothing that should be rebuilt.
- No graph, just "run all": always correct, never fast. Fine for 5 packages, painful at 50.
- Path-filter CI instead of a real graph: works until the first undeclared cross-package import ships untested code.
- Recursive Make across directories: each sub-make sees only part of the graph, which breaks both correctness and parallelism (the classic "Recursive Make Considered Harmful" argument).
Pitfalls
- Root-level files (lockfile, base
tsconfig, CI config) usually mark everything affected. Keep them stable or scope them. - Generated code must be a declared task output, or affected detection will not see its consumers.
- A test that reads fixtures from a sibling package needs that edge declared.
- Hashing huge directories (
node_modules, build outputs) on every run can cost more than it saves; hash lockfile entries, not installed trees.
Interview Q&A
What is early cutoff and which change-detection schemes support it?
Answer
Stopping propagation when a rebuilt output is identical to its previous value. Hash-based verifying and constructive traces support it; Make's mtime scheme cannot, because rewriting a file bumps its timestamp.
How does a monorepo tool decide what is affected by a pull request?
Answer
Diff the PR against its base, map changed files to owning packages, then take the reverse-dependency closure in the package graph. Global inputs (root lockfile, shared config) usually mark everything.
Why does the number of cores stop helping at some point?
Answer
Wall-clock time is bounded below by the critical path, the longest chain of dependent tasks. Extra workers only help with tasks that are ready at the same time.
Static vs dynamic dependencies: give an example of each.
Answer
Static: a link step that depends on two object files listed in the rule. Dynamic: a compile whose header dependencies are only known after scanning the source, or an archive built from a file list produced by an earlier task.
Why might "affected" CI skip a test that should run?
Answer
Because the graph is missing an edge: a script, test fixture, env var or generated file that the tool does not know about.
Package graph, task graph and action graph: what does each tell you?
Answer
The package graph says what might be affected, the task graph says what order to run scripts in, and the action graph tells the build system exactly which commands to rerun.
Why does mtime-based rebuilding misbehave in CI?
Answer
Fresh checkouts give every file a new mtime, so tools rebuild everything, and after cache restores they can rebuild nothing that should be rebuilt.
Why do upstream tasks still show as cache hits in an affected-only run?
Answer
Affected selection decides what to ask for, but downstream tasks still need upstream outputs on disk, so the cache restores them.
Check yourself
Draw the package graph of a repo you know. Pick one shared package, compute its reverse-dependency closure, and count how many CI jobs an edit there would trigger.
Elsewhere in the library
These pages stay as they are. This lesson only points at them: Graphs — Topological Sort & DAGs — Kahn, DFS Finish Times & Cycles, Graphs — DFS & BFS Traversal — Components, Cycles & Grid Flood, Terraform — Resources, Providers & the Dependency Graph, CI Performance — Caching, Parallelism & Flaky Jobs, TypeScript — Modules, Emit, Strictness, Declaration Files & Tooling.