Skip to content

Run the integration suite in parallel - #139

Merged
martinus merged 4 commits into
masterfrom
claude/parallel-integration-tests
Jul 25, 2026
Merged

Run the integration suite in parallel#139
martinus merged 4 commits into
masterfrom
claude/parallel-integration-tests

Conversation

@martinus

@martinus martinus commented Jul 25, 2026

Copy link
Copy Markdown
Owner

The 120-test integration suite ran serially at ~26 s. That is tolerable for a plain make check, but the sanitizer builds re-run the whole thing at 4-8x the cost (~103 s under TSAN, ~215 s under ASAN), and integration-valgrind at ~7x.

tests/run.py gains -j; make integration TEST_JOBS=… plumbs it through, and the valgrind leg picks up the same knob.

Measured on a 4-core box against an XFS scratch:

wall
integration, before 26.4 s
integration, after 4.3 s
make check 35 s → 13 s

No test changes were needed: setUp already gave every test its own mkdtemp scratch and its own hashfile inside it.

Notes

Workers are processes, not threads. test_long_path chdir()s and cwd is process-global, so threads would race it.

auto deliberately over-subscribes. min(nproc, 8) was the obvious default and it is the wrong model — the suite blocks in subprocess.run(), sync() and autotune's cache drop, so total CPU is flat across job counts (~8.7 s either way). Measured: j=4 6.9 s, j=8 4.4 s, j=12 3.6 s, j=16 3.6 s. It is now 2 * nproc under the same ceiling. The cap stays at 8 rather than the ~12 plateau because a worker under valgrind or ASAN costs far more than a plain oans, and the scratch filesystem is shared — a judgement call, not a measured optimum, and the comment says so.

One output path. Worker outcomes replay into a real TextTestResult, so unittest does the formatting in every mode. This also means -j 1 no longer switches execution model as a side effect of the job count — previously it ran in-process while every other value forked, so the mode you debugged in was not the mode that failed.

Fork is pinned. It was load-bearing and undeclared: 3.14 defaults Linux to forkserver, under which each worker would re-import harness and re-probe reflink support (~0.15 s each).

Verification

scripts/verify.sh passes end to end (build, make check, valgrind smoke). Serial and parallel agree exactly — 120 tests, 4 skipped, rc=0 — and five repeat runs, two of them oversubscribed at -j 8 on 4 cores, were all green.

Failure rendering was checked against a throwaway test exercising failure, error, skip, expectedFailure, and a body-failure-plus-cleanup-error: all five render as unittest would, the exit code is 1, and the double-fault case shows both tracebacks (the first draft of this change showed only the first).

@martinus
martinus force-pushed the claude/parallel-integration-tests branch from 82cd878 to b37a727 Compare July 25, 2026 17:27
martinus added a commit that referenced this pull request Jul 25, 2026
GLib recycles pool threads instead of joining them, so TSAN sees no edge
between a worker finishing and the teardown that frees what it wrote. The
annotations bridge that: each worker releases when it is done, and the
teardown acquires. Both sides named the GThreadPool pointer.

That pointer is exactly what teardown clears. dedupe_phase_end() does
`g_thread_pool_free(); dedupe_pool = NULL;` and free_pool() ends with
`pool->pool = NULL`, and a worker can still be in its tail when that store
lands - so reading it there to publish on is itself a data race. The failure
is worse than a spurious report: if the read lands after the store, the
`if (pool)` guard sees NULL and skips the release, the edge is never
published, and the teardown's unrelated frees get reported as races instead.
That indirection is why this read as a bug in the progress-slot teardown
(pscan_free_threads vs pscan_finish_file) rather than here.

Both sides now name a token the caller owns - a one-byte static in
run_dedupe.c, a field in struct threads_pool - written once, never cleared,
outliving every worker. oans_tsan_work_collect() is the acquire half, so the
edge no longer rides on g_thread_pool_free()'s argument.

threads.c had the same latent flaw and is fixed with it; it simply had not
been caught yet.

Only ThreadSanitizer builds are affected: both helpers are empty inlines
otherwise, so no behaviour changes for users.

Found by running the integration suite in parallel (#139), which loads the
box enough to lose that race reliably: 3 of 3 parallel runs failed with 14+
reports before, 4 of 4 clean with zero after. verify.sh and the serial TSAN
leg both pass.


Claude-Session: https://claude.ai/code/session_01AxN8SYwbj24nAyAAsPRdm4

Co-authored-by: Claude <[email protected]>
claude added 3 commits July 25, 2026 17:44
The 120-test integration suite ran serially at ~26 s. That is tolerable for a
plain `make check` but not for the sanitizer builds, which re-run the whole
suite at 4-8x the cost (~103 s under TSAN, ~215 s under ASAN), nor for
`integration-valgrind` at ~7x.

Add `-j` to tests/run.py: `auto` (the default) is min(nproc, 8), `-j 1` keeps
the previous serial TextTestRunner verbatim. `make integration JOBS=…` plumbs
it through, and `integration-valgrind` picks up the same knob.

Measured on a 4-core box against an XFS scratch: 26.4 s -> 7.9 s (3.3x), with
`make check` going 35 s -> 13 s. Serial and parallel agree exactly (120 tests,
4 skipped), and five repeat runs - including two oversubscribed at -j 8 - were
stable.

Two details worth keeping:

  - Workers are processes, not threads. test_long_path chdir()s, and cwd is
    process-global, so threads would race it. Tests were already isolated by
    setUp's per-test mkdtemp and its per-test hashfile, so no test changes
    were needed.

  - Work is dispatched one test at a time rather than as fixed shards, so the
    slow outliers (autotune ~1.3 s, vacuum ~0.7 s) can't strand a worker at
    the end of the run.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AxN8SYwbj24nAyAAsPRdm4
Follow-up to the parallel runner, from a review pass.

Reporting: the parallel path hand-rolled ~35 lines that unittest already
provides, which left two output formats to keep in step and silently dropped
information - only the first traceback per test, and nothing for
expectedFailure/unexpectedSuccess. Worker outcomes now replay into a real
TextTestResult, so unittest does the formatting and every category survives; a
test that fails in its body *and* errors in cleanup now shows both tracebacks.

With one formatter the `jobs == 1` special case dissolves: a one-worker pool is
already sequential, and the old branch meant TEST_JOBS=1 silently switched
execution model (in-process vs forked), so the mode you debugged in was not the
mode that failed.

Concurrency: `auto` was min(nproc, 8), which under-subscribes badly - the suite
blocks in subprocess.run(), sync() and autotune's cache drop, so total CPU is
flat across job counts. Measured on 4 cores over XFS: j=4 6.9s, j=8 4.4s,
j=12 3.6s, j=16 3.6s. Now 2 * nproc under the same ceiling, taking the suite
26.4s -> 4.3s (was 7.9s). The cap stays 8 rather than the ~12 plateau because a
worker under valgrind or ASAN costs far more than a plain oans; the comment now
says that instead of asserting a measurement I never made.

Also: pin the fork start method, which was load-bearing and undeclared (3.14
defaults Linux to forkserver, under which each worker would re-import harness
and re-probe reflink support); reuse harness.scratch_fstype() instead of a
second `stat -f` subprocess; rename JOBS to TEST_JOBS so it stops reading as
make's own -j; and cut the rationale from three copies to one.

Skipped from the review: a per-test `serial = True` opt-out (nothing needs it
yet) and longest-first scheduling (measured: no gain, discovery order already
happens to front-load the slow tests).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AxN8SYwbj24nAyAAsPRdm4
CI on btrfs failed test_extent_order_independent and test_streaming_dedupe,
and locally test_sparse_file_scans failed 4 runs in 12. Both are latent
dependencies on writeback that a serial suite never had to confront, not
breakage in the change - but they are the change's problem.

Physical extent layout. Several tests build a specific on-disk layout - the
fsync-forced extent boundary, or fiemap extent counts - and concurrent I/O
perturbs btrfs writeback enough that the setup does not get the layout it
intends. No amount of file isolation helps, so DuperemoveTest gains a `serial`
flag: tests/run.py holds those classes back and runs them one at a time after
the pool drains, keeping the parallel win for the other 110 tests. The four
fsync-boundary files are marked.

Delayed allocation. make_sparse handed a file straight to a scan asserting on
its extents, without ever fsyncing it. Under xfs delalloc those extents may not
be allocated yet, so oans maps none and correctly records none; running in
parallel pushed writeback further behind and turned a latent race into a 1-in-3
failure. The sparse-layout helpers now fsync, which is what the harness's own
sync() docstring already said this class of assertion needs. Fixed at the
helper rather than by serialising the test - the assertion is about oans, not
about writeback timing. 12 consecutive parallel runs clean, from 4/12 failing.

The serial flag is the mechanism the review pass proposed and I skipped as
speculative. CI found the case for it within the hour.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AxN8SYwbj24nAyAAsPRdm4
@martinus
martinus force-pushed the claude/parallel-integration-tests branch from b37a727 to 77d0545 Compare July 25, 2026 17:53
test_hardlink_pair_does_not_empty_hashfile saw 381 of 401 extents under the
TSAN build's load - the same delayed-allocation problem as the sparse tests,
just reached through mkrand/write rather than make_sparse. Twenty files had
not been written back when oans mapped them, so it recorded no extents for
them, correctly, and the test read the shortfall as a scanner bug.

Fixing it per file does work and costs far too much: fsync()ing every created
file is a journal commit apiece, and took the suite from 5.4s to 18s - most of
the parallel win, to make a guarantee no test needs at that granularity. What
the tests actually need is that the tree is on disk before oans walks it, which
is one syncfs() of the scratch at each oans invocation. That measures at 5.0s,
free against the 5.4s baseline.

So the per-helper fsyncs added with the sparse fix are gone again, replaced by
the single settle in dm(), which covers every test rather than the ones that
had already been caught flaking.

12 consecutive runs clean on the normal build, 5 on the TSAN build in parallel
with zero race reports.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AxN8SYwbj24nAyAAsPRdm4
@martinus
martinus force-pushed the claude/parallel-integration-tests branch from 77d0545 to 68fd342 Compare July 25, 2026 18:06
@martinus
martinus merged commit b84eb22 into master Jul 25, 2026
8 checks passed
martinus added a commit that referenced this pull request Jul 25, 2026
test_hardlink_pair_does_not_empty_hashfile asserted exactly 401 extents for
401 inodes, which quietly assumes btrfs gives each of these files exactly one
extent. It does not have to: CI saw 402 under the ASAN leg, where the load of
the parallel suite let writeback split one file in two. Earlier the same
assertion failed the other way, at 381, before the scratch settle went in.

How many extents a file gets is btrfs's business. The bug this test guards
emptied the hashfile - a batched-writer REPLACE cascade - so what matters is
that every inode has extents at all. The exact invariant is the row count on
the line above, which stays ==401, and the extent check becomes >=401.

Fallout from running the suite in parallel (#139), not from anything in the
scan path.


Claude-Session: https://claude.ai/code/session_01AxN8SYwbj24nAyAAsPRdm4

Co-authored-by: Claude <[email protected]>
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.

2 participants