Parallel Safety Contract¶
Parallel safety is an ownership problem expressed through filesystem effects. A graph may correctly allow two targets to overlap while their recipes still collide on a shared path, process, port, database, or mutable cache.
Page maps¶
graph LR
course["Deep Dive Make"] --> module["Parallel Safety and Project Structure"]
module --> page["Parallel Safety Contract"]
page --> proof["Ownership and publication evidence"]
flowchart LR
target["target owner"] --> candidate["target-specific candidate"]
candidate --> validate["successful complete result"]
validate --> publish["atomic publish"]
publish --> consumer["declared consumer"]
target --> cleanup["failure cleanup"]
The final path must not look valid before the producer has completed and accepted its work.
State the contract¶
For every target update:
- one graph owner controls each declared final path;
- temporary and side-effect paths also have owners;
- every meaningful read has a truthful dependency or documented external contract;
- publication exposes a complete accepted result;
- failure does not leave a plausible final artifact;
- independently runnable recipes do not mutate shared state unsafely;
- a repeated or concurrent invocation has an explicit workspace policy.
The first five are necessary even under -j1. Parallelism increases the number of
interleavings that reveal their absence.
Build an effect inventory¶
Do not review only $@. Inspect every path and service touched:
| Effect | Questions |
|---|---|
| read | is it declared, stable while read, and owned by a completed producer |
| create | is the path unique to this target or safely idempotent |
| replace | does one recipe own the final name and publish atomically |
| append | who defines record integrity and ordering |
| delete | can another runnable target still need the path |
| directory mutation | can concurrent creation/removal or globbing change behavior |
| external service | are namespace, transaction, and cleanup boundaries explicit |
| cache | is it acceleration-only, or can its state change output meaning |
A target that writes one final file can still own several hidden side effects.
One graph owner per final path¶
This is unsafe:
.PHONY: alpha bravo
alpha:
printf 'alpha\n' > build/result.txt
bravo:
printf 'bravo\n' > build/result.txt
Both recipes claim the same final path. Possible results depend on timing, and Make cannot infer that collision from shell text.
Repair by giving workers distinct outputs and one publisher:
.PHONY: all
all: build/result.txt
build/alpha.txt:
@mkdir -p "$(@D)"
printf 'alpha\n' > "$@"
build/bravo.txt:
@mkdir -p "$(@D)"
printf 'bravo\n' > "$@"
build/result.txt: build/alpha.txt build/bravo.txt
@candidate="$@.candidate.$$$$"; \
cat $^ > "$$candidate" && \
mv -f "$$candidate" "$@" || { rm -f "$$candidate"; exit 1; }
Now alpha and bravo may overlap, while result publication has one owner and waits for both inputs.
Shared append is a format contract¶
Two recipes appending to one log can produce:
- unstable record order;
- interleaved records larger than one atomic write boundary;
- truncated or partially encoded data after failure;
- a file whose completion cannot be inferred.
If order does not matter and an external logging system explicitly provides record atomicity, that service owns the contract. A plain shared file usually does not.
Prefer one per-target log followed by one deterministic merge:
LOGS := build/logs/alpha.log build/logs/bravo.log
build/run.log: $(LOGS)
@candidate="$@.candidate.$$$$"; \
cat $(sort $^) > "$$candidate" && \
mv -f "$$candidate" "$@" || { rm -f "$$candidate"; exit 1; }
Sorting prerequisite names gives the merge an explicit ordering policy. It does not sort records inside each worker log.
Temporary names belong to the contract¶
This looks target-specific but collides across recipes:
alpha:
render alpha > build/output.tmp
mv build/output.tmp build/alpha.out
bravo:
render bravo > build/output.tmp
mv build/output.tmp build/bravo.out
Derive candidates from the final target and add process uniqueness when separate Make invocations may share the workspace:
build/%.out: inputs/%.txt
@candidate="$@.candidate.$$$$"; \
render "$<" > "$$candidate" && \
validate "$$candidate" && \
mv -f "$$candidate" "$@" || { rm -f "$$candidate"; exit 1; }
Within one Make invocation, Make normally updates one target once. Two independent Make processes can still update the same target concurrently. A repository must either:
- isolate their output roots;
- coordinate the whole workspace explicitly;
- reject concurrent invocations;
- use a producer with transactional semantics.
A PID-suffixed candidate prevents candidate collision but does not decide which independent invocation owns the final result.
Atomic publication has a filesystem boundary¶
Rename-based publication is atomic when candidate and final path are on the same filesystem and the platform supports the required rename semantics. A candidate in a global scratch directory may cross filesystems and lose that property.
Use the final directory:
build/report.json: data/input.csv scripts/report.py | build/
@candidate="$(@D)/.$(@F).candidate.$$$$"; \
python3 scripts/report.py "$<" "$$candidate" && \
python3 scripts/check_report.py "$$candidate" && \
mv -f "$$candidate" "$@" || { rm -f "$$candidate"; exit 1; }
The consumer sees the previous complete result or the new complete result, not the candidate.
Failure hygiene is observable¶
Use .DELETE_ON_ERROR as baseline protection for targets whose recipe fails after
modifying $@:
It does not know every side-effect path. Recipes still need to remove their candidates and avoid publishing the final path before validation.
Test failure deliberately:
- preserve any accepted existing artifact;
- make the producer fail after writing candidate content;
- confirm the final artifact is absent or retains the prior accepted value according to policy;
- confirm no candidate is mistaken for a final artifact;
- repair the fault and replay;
- verify the next unchanged request converges.
“The command returned nonzero” is not enough when poison output remains.
Directories have shared ownership risks¶
Concurrent mkdir -p build/reports is normally an idempotent setup operation. Concurrent
unconditional mkdir build/reports can cause one recipe to fail after another creates
the directory. Concurrent cleanup is more dangerous:
This target can erase another target’s output. Cleaning a shared root belongs to an explicit lifecycle request that is not a prerequisite of ordinary producers.
Prefer target-local directories or idempotent creation of the narrow parent directory.
Locks are not the first repair¶
A lock can be appropriate when an external singleton resource truly permits only one user. Before adding one, answer:
- what resource is singular;
- why outputs cannot be partitioned;
- who owns lock acquisition, timeout, stale-lock recovery, and release;
- whether the lock is visible in the public environment contract;
- whether waiting preserves Make’s failure and cancellation behavior.
Do not use a lock to preserve two-writer output design that should become worker outputs plus one publisher.
Review an ownership table¶
Complete this before running -j:
| Target | Reads | Final writes | Candidate writes | Appends | Deletes | External state | Owner conflict |
|---|---|---|---|---|---|---|---|
Then classify every pair that may overlap:
- independent — no meaningful shared effects;
- read-sharing — both read stable completed state;
- producer/consumer — needs a graph edge;
- multi-writer — needs ownership redesign;
- singleton resource — needs an explicit serialization contract.
This is more precise than marking the whole build “parallel-safe” or “unsafe.”
Controlled challenge matrix¶
| Suspected weakness | Controlled challenge | Accepted repair evidence |
|---|---|---|
| shared final path | delay one writer after opening the path | distinct workers and one deterministic publisher |
| shared candidate | delay between candidate write and rename | target-local candidates with no collision |
| partial publication | fail after candidate generation | final path never exposes rejected content |
| shared append | emit multi-line tagged records repeatedly | per-target logs and governed merge |
| unsafe directory lifecycle | overlap creation and removal | narrow idempotent setup and separate cleanup |
| independent invocation collision | run isolated output roots concurrently | explicit workspace policy and stable artifacts |
Delays expose timing windows; they are never the repair.
End-of-page checkpoint¶
Before leaving this page, you should be able to:
- inventory reads, writes, appends, deletes, candidates, and external state;
- distinguish one graph owner from one operating-system process;
- repair multi-writer output with worker artifacts and one publisher;
- explain the filesystem boundary of rename-based atomic publication;
- test failure hygiene without treating nonzero exit as sufficient;
- identify when a lock represents a real singleton contract rather than hidden bad ownership.