Repository navigation
[improve][ci] Balance the unit test groups' forks by recorded test class durations - #27299
Merged
Merged
Conversation
…ass durations A Gradle test task deals its test classes to its forks round-robin, in class name order, before any of them runs, so a few slow classes that sort close together can share one fork while the others finish early. Broker Group 1's longest fork ran 28.7 min against a balanced 21 min, Pulsar Metadata's 17.8 against 7.8. Run the nine unit test groups whose test task had four forks as `testShards`: four test tasks with one fork each, which Gradle runs in parallel under the configuration cache, with the classes packed longest-first by the durations the group's previous CI run recorded. CI keeps those durations in the Actions cache. Without them, `testShards` runs `test` as before.
1 of 11 tasks
lhotari
reviewed
Nov 2, 2026
…he fail-fast file isn't their output
lhotari
approved these changes
Nov 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
The four forks of a unit test group's test task finish far apart. In the scheduled master run of 2 Nov 12:18 UTC:
Gradle's
Testtask deals its test classes to its forks round-robin, before any of them runs, so how busy a forkis never matters (Gradle 9.8.2 bytecode):
MaxNParallelTestDefinitionProcessor.processTestDefinitioncreates up tomaxParallelForksprocessors, then doesprocessors.get(pos); pos = (pos + 1) % size.TestNGTestDefinitionProcessor.processTestDefinitiononly adds the class to a list, andstop()runsthem all in one TestNG run.
DefaultTestScanner's visitor asks for a reproducible file order(
ReproducibleDirectoryWalker, files before sub-packages, each sorted by name).-PtestGroupsis applied inside the forks.So which fork a slow class lands on depends on the names of all the module's test classes, and adding, removing or
renaming one moves every class after it to the next fork.
For example, on 31 Oct 43f1ce1 added
org.apache.bookkeeper.replication.ParallelAuditorShutdownTest. It sortsbefore
TestReplicationWorkerand everyorg.apache.pulsar.metadata.*class, so each of those moved one fork along.Round-robin over the scan order reproduces the executor of every Pulsar Metadata class in both runs' build scans
(counting
MetadataBenchmark, whose tests are allenabled = false: it takes a slot and reports nothing). Secondsfrom each run:
That commit saved 3 min of
AuditorBookieTest, but the job got 4.6 min longer: 13.0 → 17.6 min, while its balancedtime fell from 9.0 to 7.6 min.
Modifications
pulsar.java-conventions: with-PtestShards=Nand-PtestShardDurations=<file>(lines of<seconds> <class name>),testShardsrunstestShard1..testShardN. These are test tasks with one fork each,configured like
testby the existingwithType<Test>blocks.class goes with its top-level class.
excludespec on the test class files, so every class file reaches exactly one shard.FailFastNotifier's existing kill-switch file (testFailFastFile). WithtestFailFast(PRruns), a failure in one shard skips the others' remaining tests, as one test task stops all its forks today.
testShardsjust runstest, as before.testtask is unchanged.run_unit_group_gradle.sh: the nine groups whose test task had 4 forks (Broker Groups 1, 2, 3 and 5, Client Api,Client Impl, Pulsar Client, Pulsar Metadata, Proxy) run
testShardswith--configuration-cache. Gradle runs tasksof one project in parallel only with the configuration cache. Measured on 9.8.2: two test tasks of one project ran
one after the other without it, and at once with it.
unchanged.
pulsar_ci_tool.sh:record_test_durationswrites the durations from the JUnit XML reports. A report'stimeends with the class'slast test method, before its cleanup, but a shard runs its classes one after the other in one JVM. So in a shard,
a class lasts until the next class starts. For example, locally
AuditorRollingRestartTestreports 36 s buttakes 107 s; the build scan's 106 s on CI matches the 107 s.
testshare one report directory, so that run (the first one only) records the reports' times.generate_test_report_indexalso lists the shards' HTML reports.pulsar-ci.yaml: the unit-tests job restores the newestunit-test-durations-<group>-*cache entry before thetests, and saves this run's durations after a successful run. Master runs feed every PR, and a PR's runs feed its
later runs.
Verifying this change
Same classes. For each of the nine groups, I ran TestNG dry runs (
dryRun = true, set by an init script) oftestand of
testShards, and compared the classes in the JUnit XML reports. I rantestShardsboth with the durations fromthe 2 Nov 12:18 build scans and with every class split by hash. In every case the shards ran the same classes as
test, and no class ran in two shards:The test counts match too, except for one class. Under
test, the nestedSchemaInfoTest$SchemaInfoBuilderTestranits 3 tests twice (Pulsar Client: 1288 tests against the shards' 1285). TestNG runs a public static nested test
class along with its outer class, and only skips the duplicate when both are in the same JVM. Round-robin put the two
on different forks: with
--tests 'org.apache.pulsar.client.impl.schema.SchemaInfoTest*', the nested class reports3 test cases with 1 fork and 6 with 2. The shards keep a nested class with its top-level class, so it runs once.
Local timing, Pulsar Metadata. All tests passed in every run. Two pairs, back to back, on a busy shared Mac:
test(4 forks, round-robin)testShards(packed on the CI durations)The CI script end to end. I ran
run_unit_group_gradle.sh METADATAtwice, with a scheduled run's environment, adry-run init script and an empty durations directory.
testShardsrantest, and the script recorded the durations.testShard1..4with those durations, and recorded again from the shards.Other checks.
synthetic reports with a nested class and a run across midnight UTC.
actionlintonpulsar-ci.yaml, or fromshellcheckon the two scripts../gradlew quickCheckpasses.Expected on CI. The longest fork of each job, in minutes, on the 2 Nov 12:18 run, with the shards packed on the
durations of the run before it (2 Nov 06:01). The packing is done the same way as the build does it, and each value
includes ~25 s of JVM start and exit:
That is about 24 min less across the nine jobs. Broker Group 1, the CI's critical path, gets ~6 min shorter. Broker
Group 3 and Proxy happen to be well split today, and their fork order can change with any class that's added. On the
four earlier scheduled runs, the same packing cuts the nine longest forks' sum by 29–45 min.
Two limits:
KeySharedSubscriptionTesttook 5 or 10 min,which kept Client Impl at ~19 min in two runs; [fix][test] Fix KeySharedSubscriptionTest hang caused by a leaked default message TTL #27296 fixes that swing.
[improve][test] Speed up TransactionEndToEndTest #27295, [fix][test] Fix KeySharedSubscriptionTest hang caused by a leaked default message TTL #27296 and [improve][test] Speed up the ack-timeout, negative-ack and DLQ tests #27298 cut Client Impl from 45 to 29 fork-min. The first run after them gets shards of 9.0 / 7.7 /
7.5 / 6.3 min instead of 7.6 each, still well under today's 11.7.
This PR's first CI run has no recorded durations yet, so it runs
test, as today. A re-run, or the next push,restores the durations the first run saved, and should show the numbers above. The same happens once on master after
the merge.
Does this pull request potentially affect one of the following parts:
If the box was checked, please highlight the changes