We implement a make-style incremental build system as a tf::Taskflow that uses condition tasks to skip up-to-date targets and recompile only what is stale. This example demonstrates how condition tasks express data-dependent branching in a structurally fixed graph, and how to apply the auxiliary join task pattern to avoid the task race that arises at every multi-predecessor join point in the build graph.
A build system such as make or ninja maintains a directed acyclic graph of targets: source files, object files, libraries, and final binaries. Each directed edge means "this target depends on that file." When a build is requested, the build system traverses the graph and recompiles only the targets whose inputs have changed since they were last built — the target is stale. Targets whose inputs are all newer than the target are silently skipped.
The staleness check is a simple timestamp comparison: if any input file has a modification time newer than the target, the target must be rebuilt. This conditional skip-or-rebuild decision is exactly what condition tasks are designed to express — a task that inspects runtime state and routes the scheduler down one of two paths.
Consider a small C project with three translation units and a single binary:
The dependency graph is:
Source and header files (blue) have no dependencies and always exist on disk. Object files (yellow) depend on their source and included headers. The final binary (green) depends on all three object files. main.o, util.o, and math.o have no mutual dependency and can be processed simultaneously on separate cores.
Each object file target has two possible outcomes at runtime: either it is up-to-date (skip compilation) or it is stale (run the compiler). This is a binary branch — the natural role for a condition task. Each condition task checks the timestamps of its inputs against its output and returns 0 to skip or 1 to rebuild.
Condition tasks fit naturally here because the graph structure is entirely static — it is determined by the build rules, not by file contents — while the routing decision is dynamic, determined at runtime by timestamp comparison. Static tasks handle the fixed structure; condition tasks handle the dynamic branch.
A first attempt might wire the condition tasks directly to app: each condition task returns 1 to run the compile step, which then feeds app with a strong edge, and returns 0 to route directly to app via a weak edge, skipping compilation.
This graph has a fatal flaw. Task app sits at the junction of three condition tasks' outputs. All three condition tasks can complete simultaneously — if all three find their object files stale, all three fire their "1 (rebuild)" branch and the three compile tasks each try to satisfy app's strong dependency counter concurrently. This is the correct path. But if some condition tasks return 0 (skip), they schedule app directly through a weak edge at the same time that other compile tasks are also converging on app via strong edges. app can be scheduled more than once, which is undefined behaviour.
This is precisely Pitfall 2 (Task Race) from Avoid Common Pitfalls where a task sitting at the convergence of multiple condition task outputs is at risk of being scheduled concurrently by different paths.
The fix is to insert one join task per object file between the condition task and app. Each join task is a lightweight no-op that serves as a controlled merge point for the skip and rebuild paths of a single object file. app then has exactly three strong dependencies — one per join task — and is enqueued exactly once, after all three join tasks have completed.
For each object file the structure is:
The join task has one weak dependency (from the condition task on the skip path) and one strong dependency (from the compile task on the rebuild path). Exactly one of these two paths activates the join task on any given run, so it is scheduled exactly once. The following table lists the strong and weak dependency counts for all tasks in the graph:
| Task | Strong | Weak | Total |
|---|---|---|---|
| main.c, util.c, util.h, math.c, math.h | 0 | 0 | 0 |
| main.o? | 3 | 0 | 3 |
| util.o? | 2 | 0 | 2 |
| math.o? | 2 | 0 | 2 |
| cc main.o | 0 | 1 | 1 |
| cc util.o | 0 | 1 | 1 |
| cc math.o | 0 | 1 | 1 |
| main.o (join) | 1 | 1 | 2 |
| util.o (join) | 1 | 1 | 2 |
| math.o (join) | 1 | 1 | 2 |
| app | 3 | 0 | 3 |
Every task has a unique, unambiguous activation path. No task can be scheduled more than once simultaneously.
We represent each build target as a Target struct carrying its output path, input paths, and compile command. The staleness check compares modification times; condition tasks return the result. Join tasks are plain no-op lambdas whose only purpose is to serve as the controlled merge point described above.
On a fully clean build the expected output is:
After touching only util.c and rebuilding:
main.o and math.o are skipped because their condition tasks return 0 and route directly to their join tasks, bypassing compilation entirely. app runs its own link check and finds util.o newer than app, so it relinks.
app sits at the convergence of three condition task outputs and can be scheduled up to three times simultaneously. The join task absorbs this convergence: it has one weak incoming edge (from the skip path) and one strong incoming edge (from the compile path), so exactly one path activates it per run. app then has only strong incoming edges and is enqueued exactly once. This is the auxiliary task pattern described in Avoid Common Pitfalls, applied systematically at every join point in the build graph.taskflow.dump() makes the routing explicit: Calling taskflow.dump(std::cout) before running the executor emits a Graphviz description of the full graph, including the dashed weak edges from condition tasks. Inspecting the strong and weak dependency counts of each task (see Understand our Task-level Scheduling) provides a quick sanity check: any task with multiple incoming weak edges from concurrently executable condition tasks is a potential race site, and should be given an auxiliary join task.app's lambda is a secondary guard that avoids re-linking when all three join tasks came through the skip path and no object file changed. This is consistent with how make behaves: the link rule runs its recipe only when its inputs are newer than its output.