Skip to content

Repository files navigation

shard4j

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.

Modules

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.

Build

mvn verify

A JDK and Maven, and nothing else. No container, no cluster, no account.

Status

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.

The engine

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.

JUnit Platform version alignment

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.

Build-tool compatibility matrix

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-engine on 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 with EngineFilter.excludeEngines("junit-jupiter"), so the newly-visible vintage engine ran alongside shard4j's, and its trivially-successful root event fired after shard4j's own FAILED one and clobbered it. Fixed by filtering to includeEngines(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.

In-shard parallelism

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.

Invocation distribution

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/@AfterAll itself. 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 @MethodSource changes. 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.

Deployment

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.

Security posture

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.

Design

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.

Licence

Apache-2.0. Copyright 2026 Marvin Froeder.

About

Coordinator-driven, duration-balanced sharding for JUnit Platform test suites.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages