Coordinator-driven, duration-balanced sharding for JUnit Platform test suites.
A single-instance HTTP service hands integration tests to CI shards one class-batch at a time, slowest-first by measured duration, and answers one question at the end of a run: did every registered test reach a terminal non-failing state? Balancing needs durations, durations need a store, and correctness under pull-based distribution needs a central verdict. That is the whole service.
Static hash sharding is blind to duration. On the suite this was built for, a per-class span of 1.3s to 702s produced a 9-22 minute spread across shards -- most of the runner time spent waiting on one unlucky shard.
| Module | Java | What it is |
|---|---|---|
shard4j-protocol |
17 | Wire records, the identity value types, the no-history ordering hash. Zero runtime dependencies. |
shard4j-coordinator |
25 | The service. |
shard4j-engine |
17 | The JUnit Platform TestEngine and its coordinator client. Never depends on the coordinator. |
shard4j-example |
17 | A project that has never heard of shard4j, used as the boundary acceptance test. Not published. |
Coordinates are com.marvinformatics:shard4j-{protocol,coordinator,engine}, one version
across all three, released from one reactor. That is why there is no protocol version field
on the wire: a contract change breaks compilation, at build time, rather than being
renegotiated at runtime.
mvn verify
A JDK and Maven, and nothing else. No container, no cluster, no account.
Coordinator and engine are implemented and tested against each other -- the
shard4j-example module runs the full loop (census, claims, execution, results, barrier,
retry rebalance) across simulated shards against the real coordinator in a container, and
carries a working coordinated failsafe profile.
shard4j-engine is a JUnit Platform TestEngine, ServiceLoader-registered, that lands
on a consumer's test classpath and stays completely inert until configured: with
shard.enabled absent or false its discovery is empty, so Jupiter behaves exactly as if
the engine were not there. In the CI profile, failsafe's
<excludeJUnit5Engines>junit-jupiter</excludeJUnit5Engines> hands the suite to this
engine instead, which delegates discovery and execution back to Jupiter while pulling
work from the coordinator one class-batch at a time. shard4j-example/pom.xml's
coordinated profile is the complete, working integration, and it is deliberately one
integration-test execution and one verify. Retries happen inside it: a failure with
attempt budget left returns to the coordinator's claimable queue and is handed to whoever
asks next, so there is no second execution block to come back in through.
A shard's exit code is not the run's verdict; the coordinator's coverage verdict is. The
engine narrows the gap rather than closing it: a failure the coordinator will requeue is
reported to the launcher as aborted, so a still-recoverable failure does not redden the
summary and only an exhausted attempt budget does. What the coordinator is told is always
the real outcome -- the coverage verdict counts aborted as terminal-OK, so downgrading in
that direction would turn a genuine failure into passing coverage. A shard job can still
exit non-zero on a session the coordinator judges green, for everything that is not a
retryable unit failure: an engine error, a mass-abort failure, a reconciliation failure,
an @AfterAll that throws after its class's tests all passed.
The one safe way to wire that up is a non-gating shard job: every shard job reports
its own exit honestly, and the pipeline gates on a final step that reads the
coordinator's verdict for the session. Do not reconcile the two with failsafe's
testFailureIgnore, under this engine or next to it: it does not merely forgive a
retried failure, it discards every failure the summary carries -- engine errors,
mass-abort failures, reconciliation failures, and the @AfterAll that throws after its
class's tests all passed, which no per-unit record can see because units are reported
before the container finishes. That last shape leaves the verdict all-PASSED with the
summary failure as the only surviving signal, and testFailureIgnore deletes exactly
that signal. This is a rule, not a preference.
The engine is compiled against a single JUnit Platform minor and requires a matching
minor on the consumer's test classpath. This is not a pin we chose for comfort: the
Platform changed ExecutionRequest.create and EngineDiscoveryRequest's
output-directory accessors binary-incompatibly in three consecutive minors --
| Platform | how an engine constructs a nested ExecutionRequest |
|---|---|
| 1.12 | create(descriptor, listener, config, OutputDirectoryProvider) |
| 1.13 | create(descriptor, listener, config, OutputDirectoryProvider, store) |
| 1.14 | create(descriptor, listener, config, OutputDirectoryCreator, store) |
| 6.x | create(descriptor, listener, config, OutputDirectoryCreator, store, CancellationToken) |
-- so no single compiled artifact spans them. A mismatch surfaces as JUnit's own
OutputDirectoryProvider not available / OutputDirectoryCreator not available
exception, thrown from a default method on the interface whose accessor this engine did
not override, and it names the cause: "probably due to unaligned versions of the
junit-platform-engine and junit-platform-launcher jars on the classpath/module
path.". The Platform
assumes engine and launcher move together; shard4j is an engine, so it inherits that
assumption.
This release targets Platform 6. Consumers still on JUnit 5 need shard4j 0.4.0 or earlier; there is no artifact that spans both, and there will not be one.
Consumers on a framework BOM (Quarkus, Spring Boot) frequently resolve an older Platform
than the one shard4j targets, and the framework BOM wins unless told otherwise. Import
junit-bom ahead of the framework BOM in <dependencyManagement>:
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>Match the exact patch in pom.xml. Contrary to an earlier assumption here, patch bumps
within 6.1.x are not proven safe: 6.1.0 and 6.1.2 both fail this reactor's own test suite
the same way 6.0.0 does, and only 6.1.3 passes -- confirmed by testing each directly.
Dependabot still moves patches through on its own (see compatibility.json's
junit-platform-* entries, which is what actually enforces the tested version at
runtime); a bump that breaks CI simply never reaches a release.
Surefire cannot settle this for you. surefire-junit-platform is a plugin-classpath
artifact and manages nothing on the test classpath; it also lags -- surefire 3.5.6
declares <versions.junit5>5.12.2</versions.junit5>, older than either version above.
What surefire does do is align junit-platform-launcher to whatever Platform it finds on
the test classpath, which is why fixing the test classpath fixes the whole run.
The Platform floors above are one axis; the build tool that forks each shard's JVM is the other, and it has broken this reactor's own tests at least once. Each shard4j release is built and its own test suite run against exactly one JUnit Platform minor and one surefire/failsafe version:
| shard4j | JUnit Platform | surefire/failsafe |
|---|---|---|
| 0.1.0 | 1.13.4 | 3.5.4 |
| 0.2.0 -- 0.4.0 | 1.14.4 | 3.5.6 |
| 0.5.0 | 6.1.3 | 3.5.6 |
| 0.6.0 | 6.1.3 | 3.6.0 |
0.6.0 raises the surefire/failsafe floor to 3.6.0 deliberately, not as a courtesy bump. Testing the jump surfaced two breakages, both confirmed by running the identical suite against 3.5.6 and 3.6.0 back to back:
- A test-harness engine leak. 3.6.0 puts
junit-vintage-engineon a fork's classpath where 3.5.x did not, even though nothing in the reactor declares it as a dependency. The example module's own acceptance harness built its launcher session withEngineFilter.excludeEngines("junit-jupiter"), so the newly-visible vintage engine ran alongside shard4j's, and its trivially-successful root event fired after shard4j's ownFAILEDone and clobbered it. Fixed by filtering toincludeEngines(Shard4jTestEngine.ENGINE_ID)instead -- immune to whatever else a future surefire version puts on the classpath. - A broken
<excludedGroups>translation. The example module tags fixtures it never wants failsafe to pick up directly (@Tag("shard4j-fixture")) and excludes that group in its plugin config. Under 3.6.0 the mojo still parses<excludedGroups>, but the tagged classes ran anyway -- the translation into a JUnit Platform tag filter appears to be broken in this version. Worked around with file-pattern<excludes>instead, a different and unaffected mojo code path, at the cost of listing each fixture by name in two places.
Neither breakage lives in Shard4jTestEngine itself -- both are in the example module's
own test scaffolding and build configuration -- but they are exactly the class of failure a
consumer bumping surefire/failsafe would also hit, silently, with no engine-level signal.
That is what the runtime check below is for.
The engine checks this for itself, once per fork, before doing any real work.
compatibility.json, shipped inside the shard4j-engine jar, lists each tracked
build-tool artifact with the version range this exact release has actually run its own
tests against. At the top of execute(), before touching the coordinator, the engine
detects what actually forked this JVM and compares:
- Below the range's floor is a hard
ShardConfigurationException, naming the artifact and both versions -- the same loud-failure convention as every other configuration error this engine raises, never a silent fall-through to running everything anyway. - Above the range's ceiling is a
WARNING-level log banner, not a failure: a newer version may well work, nobody has verified it yet, and an untested-but-plausibly-fine version must never break a consumer's build on its own. - Undetectable (Gradle, an IDE runner,
forkCount=0, or any environment that does not fork this JVM through a Maven surefire/failsafe booter) is silence, not a warning -- consistent with the engine staying inert everywhere else it cannot see enough to act.
Detection is a classpath probe, not a compile-time dependency: shard4j-engine does not
depend on surefire or failsafe. surefire/failsafe versions are read from
META-INF/maven/org.apache.maven.surefire/surefire-booter/pom.properties -- present in
any jar Maven's own jar plugin built, and surefire-booter is the fork's own entry point
(ForkedBooter), shared by surefire and failsafe since they release from one version.
JUnit artifacts, built by Gradle and so never carrying a pom.properties, are read instead
from Package.getImplementationVersion() on a class each artifact actually ships.
junit-platform-engine, junit-platform-launcher and junit-jupiter-engine are three
separate catalog entries, not one -- on purpose. They are three jars a consumer's own
dependency management can skew independently (a framework BOM overriding one and not the
others is exactly the failure mode the JUnit Platform version alignment section above
describes), so a single "JUnit Platform" version string would hide the one case this check
exists to catch: three artifacts that individually look fine but disagree with each other.
The range's two ends are maintained differently, on purpose. firstTested -- the floor --
is a hand-edited, deliberate decision: it moves only when a release intentionally drops
support for older versions, the same way the Platform floor above has moved twice.
lastTested -- the ceiling -- is filled in by Maven resource filtering from this reactor's
own maven-surefire-plugin.version and junit.bom.version properties at build time, so it
always names exactly what this release's CI forked its tests under. A dependabot bump that
leaves CI red never reaches a release to make that claim; one that goes green raises the
ceiling for free, with nothing to remember to edit by hand.
Configuration is read from JUnit configuration parameters (which the launcher backs with
system properties) first, then environment variables (shard.foo.bar maps to
SHARD_FOO_BAR):
| Key | Required | Meaning |
|---|---|---|
shard.enabled |
no (false) |
Master switch; absent or false means completely inert. |
shard.coordinator.url |
yes | Base URL. No default, ever. |
SHARD_COORDINATOR_SECRET |
yes | Environment variable only. A value supplied as a system property is refused: properties appear in ps output and argLine echoes. |
shard.session.id |
yes | Run-scoped id minted upstream of the shards, so every shard reads one value and a partial re-run rejoins. |
shard.index |
yes | 0-based shard index. |
shard.attempt |
no (1) |
Monotonic re-run counter; a higher value voids the previous attempt's leases. |
shard.concurrency |
no (1) |
Drain slots per shard: how many classes run at once in this JVM. Above 1, read the in-shard parallelism contract below. |
shard.count |
no | Total shards this run launched. A balancing hint only -- it lets the coordinator hold back a fair share of a parameterized method's invocations for shards that have not registered yet, instead of granting them all to whichever shard asked first. Never part of any quorum. |
shard.metadata.* |
no | Forwarded verbatim; the only seam CI-vendor vocabulary may pass through. |
shard.coordinator.retry.budget |
no (5m) |
Transport retry window; must exceed the coordinator deployment's restart time. |
shard.deadline |
no | Absolute job-kill instant (ISO-8601); enables early self-release at the barrier. |
shard.abort.all-leased-is-failure |
no (true) |
Fail the shard when every leased unit across more than one class aborted. |
On GitHub Actions the mapping is: a setup job mints shard.session.id with uuidgen as a
job output, the matrix supplies shard.index, and the repository secret is exported as
SHARD_COORDINATOR_SECRET via env: on the step. On plain shell it is the
SHARD_COORDINATOR_SECRET=... mvn -Pcoordinated verify -Dshard... invocation shown in the
example's profile comment. Neither is canonical; any CI that can mint one UUID upstream
and number its shards qualifies.
Two liveness rules bind the two sides together. A shard holding a lease is trusted until
the lease expires; a shard holding no lease that stays silent for three barrier-poll
intervals is presumed dead and dropped from barrier quorums. The engine honours the
second rule with a background keepalive that pings an empty claim every five seconds for
the whole of execute(), covering the gaps a real suite has -- a slow @AfterAll
between classes, a long class setup before the first result -- where it would otherwise
be silent while holding nothing. That coverage stops at the edges of execute(), so a
consumer who declares more than one coordinated integration-test execution leaves a gap
between them where the shard is a JVM tearing down and a fresh fork spinning up --
classpath scan, discovery -- holding no lease and sending nothing; on a large consumer
classpath that gap can outlast the coordinator's 15-second presumed-death tolerance. The
consequence is a premature presumed death: a degraded rebalance that self-heals on the
shard's next call, so the outcome is red or correct, never a false green. The engine
cannot close this gap, because the shard genuinely is not running during it. One execution
is the configuration that has no such gap, which is why the example profile declares
exactly one.
shard.concurrency runs that many pull loops -- drain slots -- side by side in one shard
JVM. Each slot asks the coordinator for a class, drains it completely, and runs it as its
own nested execution, so @BeforeAll stays a once-per-class cost while two heavy classes
overlap in wall time. The ask-and-drain step is serialised across slots and a class is
fully leased before the next open ask, so the second slot receives the next-heaviest
remaining class -- cross-class ordering is preserved, not degraded to whichever classes
are adjacent. Parallelising within a class instead would buy almost
nothing on real suites, where most of the duration mass sits in single-leaf classes.
The default is 1, which is byte-for-byte today's strictly serial behaviour. Nothing on
the coordinator changes either way: a shard is one registration, one keepalive, one
barrier arrival -- it reaches the barrier only after every slot has finished, so it stays
exactly one unit of quorum arithmetic, and it can never be released while a slot still
holds work. A transport death with several slots in flight NACKs everything every slot
still holds.
The contract, above 1: your test classes must tolerate running concurrently with other
classes in the same JVM. The engine guarantees at most shard.concurrency classes in
flight and never two live instances of the same class, but it cannot see your statics: a
mutable static registry drained per class, a static client reassigned per @BeforeAll, a
fixed port, a shared temp directory -- any of these makes concurrent classes unsafe, and
the engine has no way to detect it. Audit for cross-class shared state before opting in;
until then, stay at 1.
Two more consequences of opting in are part of the same contract. First, threads: above
1, every test runs on a named shard4j-slot-N worker thread, never on the thread
failsafe called the engine on -- anything keyed to the main test thread (a thread-local
initialised outside the engine, an AWT/main-thread assumption) moves with it. At 1
nothing changes. Second, lease sizing: never two live instances of the same class means
a slot granted a class a sibling is already running -- possible whenever a unit is
re-pooled mid-run -- parks with its batch fully leased, and nothing refreshes a lease,
so the coordinator's leaseTtl must comfortably exceed roughly two full class
drains, not one. An expiry marks the whole shard departed and re-pools the parked batch:
the run stays honest but pays duplicate execution and a confusing red, so size leaseTtl
generously before raising shard.concurrency.
One compatibility note at 0.1.x: ShardConfiguration gained the concurrency record
component mid-signature, which is source-incompatible for anyone calling its constructor
positionally. Configuration keys are the supported surface; the constructor is not.
Orthogonally, Jupiter's own junit.jupiter.execution.parallel.enabled passes through to
the nested executions and is tolerated: the engine's outcome accounting is thread-safe
under concurrent events. It parallelises leaves inside one class-drain, which rarely
helps a suite dominated by single-leaf classes, and the same shared-state caveats apply.
A @ParameterizedTest method leases as one unit on a cold coordinator, because its
invocations do not exist at discovery time -- a template yields a container and zero
leaves, so the census cannot enumerate them. But the coordinator records per-invocation
durations from the first run onward, and once a method's history carries a complete
breakdown -- every row of a session seen finishing non-failing -- the scheduler hands its
invocations out individually: #1 to one shard, #2 to another, each ranked by its own
measured duration. A 5 x 55s method stops being one indivisible 275s block. Methods with
no such history still lease whole -- the same unknowns-first-then-measured shape the
ordering rule already uses, one level down.
What it costs and how it stays honest:
- Each participating shard pays its own class setup. Standard JUnit semantics: every
shard running any invocation instantiates the class and runs
@BeforeAll/@AfterAllitself. Spreading is worth it when the rows dwarf the setup, which is exactly the case the durations prove. - Positions are handed out optimistically and reconciled after. Invocation ids are
positional and shift when a
@MethodSourcechanges. A handed-out position that no longer exists materialises nothing -- JUnit drops it silently -- so the shard's reconciliation NACKs it naming the cause (the parameter set changed since it was last measured), the run fails loudly, and the coordinator drops the stale position from history so the next run expands from the corrected plan. - Growth is probed, not assumed away. Every expanded method also hands out one cardinality probe: the position just past the recorded plan. Most runs it vanishes quietly, confirming the count; when a fixture grew, the probe runs the new row, gets measured, and the next position is probed in turn -- so a grown parameter set is noticed the run it happens instead of silently never running.
- Retry and coverage stay per-position. Each invocation runs the full unit state machine: a failed row enters the retry pool alone, any shard may pick it up (paying that class setup), and the coverage verdict counts every position individually -- a vanished probe leaves the census, everything else must reach a terminal non-failing state.
- History keying stays at method level. Storage never keys by position; the breakdown is a value inside the method-keyed window entry. Distribution acts on positions, storage never does.
Set shard.count so the coordinator sizes fair shares by the fleet the run launched,
leaving room for shards still booting; without it, spreading still happens but only among
the shards that have already asked. The declared count sizes shares, never strands work:
the cap binds only while another registered, live shard may still ask, so a declared
shard that dies before registering costs nothing -- the last live asker always takes the
remainder.
This repository contains no deployment configuration, by design. There is no Helm
chart, no Kubernetes manifest, no Terraform module and no deploy/ directory, and none
will be added. The moment one exists, someone parameterises it with a real hostname. The
coordinator needs a TCP port, one writable directory and a wall clock; where it runs is
entirely a deployer's business, and every value it needs arrives as an environment
variable with no default.
For the same reason there are no secrets here, and no example value that could be mistaken for a real one.
The shared secret is a CI-scheduling credential. Its entire blast radius is test scheduling for one instance: it grants nothing on any cluster, in any cloud, or on any repository. A holder of the secret plus a live session id can inject results, poison timing history and drain a session's queue; they cannot overwrite a recorded result or turn a drained session green.
COORDINATOR_SECRETS is split on commas so rotation can run two values side by side --
which means a secret value must not contain a comma: it would silently become two
wrong values, and neither would ever match. Generate secrets from a comma-free alphabet
(hex or base64url). The coordinator refuses to start when the split produces a blank
entry, the tell-tale of a stray comma, but it cannot detect a comma inside a value.
v1 is single-tenant per instance -- one instance is one trust domain. Do not add a client-supplied tenant field to get multi-tenancy: that converts one leaked secret into cross-tenant write access. A second tenant gets a second instance.
There is no public specification document yet; the shape of the system is this.
A run is a session. Each CI shard registers with the coordinator, then loops: ask what
to run next, drain the class the coordinator names, report each result, and ask again
until the coordinator has nothing left to hand out. The choice of class is the
coordinator's, made from durations measured on earlier runs: the class with the largest
measured total still to run is named first, and the answer carries that class's first
batch of leases so a named class is never an empty promise. The total, not the single
slowest test in it -- a class is what one runner drains, so twenty 30s tests outrank a
class holding one 120s test, and a class already half drained sinks as it empties. Within
the class the order is slowest test first. A test with no history runs before every
measured one, ordered by a hash of its identity, so the schedule stays deterministic
without being alphabetical. Every grant is a lease with an expiry and a
fence, so a shard that stalls or dies loses its work back to the queue instead of taking
the run down with it.
Draining a class grants all of its leases up front, so the lease TTL must cover a shard's
slowest class share, not merely its slowest single test -- and roughly two class shares
once shard.concurrency exceeds 1 (see the in-shard parallelism contract).
Retries are re-queues, not in-place re-runs and not extra passes over the session: a
failure with attempt budget left puts the unit straight back on the claimable queue, where
whichever shard asks next takes it -- and not the shard that just failed it, if there is
any alternative. Whatever was wrong with that JVM, that machine or the state it left
behind is still there, so the open ask skips whole classes still holding this shard's
failures and sends it to different work, dropping the preference only when every remaining
class is such a class: a same-shard retry beats no retry. The choice is made once, at the
class a shard is sent to; a requeued failure is never withheld from a shard that asks for
it. COORDINATOR_MAX_ATTEMPTS (default 3) bounds the attempts per unit; the last one
has nothing behind it and its failure is terminal. Because the requeue lands inside the
same execution, a shard that has run out of work waits at the barrier rather than exiting,
and is released only once it cannot be needed.
The verdict is coverage, never exit codes and never queue emptiness: a session passes only when every registered test reached a terminal non-failing state. If every shard departs while tests remain unfinished, the session is incomplete, and incomplete is not green.
Apache-2.0. Copyright 2026 Marvin Froeder.