What a fleet test catches
A test that can route a request to the instance of its choice, hold an instance at its limit, stop, kill or freeze one, start one late, and watch what the balancer does with readiness, catches a specific class of production bugs. They fall into three groups by how deterministic the test is.
Deterministic by routing alone
The outcome depends only on which instance handled which request. pin(i) and instance(i).url() are all it
takes, and the test runs in a second.
| Bug | Where it comes from | The test |
|---|---|---|
Local cache treated as global |
|
Write through A, read through B, expect the new value. Shipped: |
Per-instance limits multiplied by N |
SmallRye Fault Tolerance |
Send limit+1 split across A and B, expect a rejection. Shipped: |
A scheduled job on every replica |
|
The job writes a row per run to the shared database; expect one row per tick. Shipped: |
A login valid on one instance only |
Form authentication without |
Log in through A, use the cookie through B, expect 200. Shipped: |
A login that cannot be finished where it was started |
OIDC with PKCE or a nonce: the state cookie is encrypted with a key derived from the client secret when that is at least 32 characters long, and generated per instance otherwise; a short secret, a public client, or mTLS credentials |
Start the code flow through A, log in at the provider, follow the redirect back through B, expect the login to complete. Shipped: |
A token signed with a generated key |
SmallRye JWT with a key generated at startup |
Mint through A, present to B |
A broadcast that reaches one instance’s clients |
WebSockets Next |
Connect client 1 to A and client 2 to B, broadcast from A, expect client 2 to hear it. Shipped: |
Submit here, poll there |
Any in-memory job registry, upload staged on local disk, in-memory idempotency keys or ID generators |
Submit through A, read through B. Shipped: |
An idempotency key known to one instance |
Quarkus HTTP Idempotency with its default |
Send a |
Keep-alive clients pinned to one instance |
REST Client, gRPC, HTTP/2, JDBC: per-connection balancing |
One pooled client, N requests, expect every instance to have served some. Shipped: |
Pool times replicas above the database’s limit |
Agroal |
The Nth instance fails to acquire at startup, or serves with too few connections. Shipped: |
A lost update |
Read, compute, write in Java with an in-JVM lock around it: the lock protects one process, the database sees two |
Hold a withdrawal on A past its read, withdraw on B, expect the sum of both. Shipped: |
Two pollers over one table |
Any |
Hold a drain on A past its select, drain on B, expect each row handled once. Shipped: |
A deadlock from lock order |
Two transactions that lock the same rows in opposite order; an in-JVM lock makes one instance safe |
Hold a transfer A→B on one instance past its first lock, run B→A on the other, expect one deadlock abort. Shipped: |
A broken invariant from write skew |
A limit or invariant each transaction checks and keeps alone, over rows no single-row lock covers |
Hold a booking on A past its count check, book on B, expect the limit broken. Shipped: |
Events applied out of order |
Causally ordered events for one key, pulled by competing consumers with no per-key ordering |
Apply the earlier event slowly on A while the later one commits on B, expect the wrong terminal state. Shipped: |
Deterministic with a process controlled by the test
These need an instance that is overloaded, frozen, dead or late on cue. Requests that block until the test lets
them go, pause(), kill() and running do that. The balancer’s readiness emulation does the rest.
| Bug | Where it comes from | The test |
|---|---|---|
A zombie leader |
Leader election or a job lock with a lease: a leader frozen past its lease (GC pause, VM migration, partition) still believes it leads, and finishes the write it was on |
|
At-least-once delivery shipped twice |
A Kafka consumer that crashes after processing and before committing offsets: the next holder of the partition receives the same records |
Let one instance process the records, |
Traffic to a replica that is not ready |
No readiness probe in the deployment, or one that says UP before the application can serve: a new replica gets requests while it is still loading |
|
The readiness cascade |
An overloaded instance whose readiness check fails, or that sheds its own probe: the balancer ejects it, the load lands on the next instance, which goes the same way |
Hold A at its limit; expect A to stay ready and answer the excess with 503, and B unaffected |
Requests dropped on rolling restart |
SIGTERM and the endpoint removal race; proxies route to a stopping pod for a while |
A client that never stops, a deregistration lag, |
Retry amplification across instances |
A client that retries against instances at their limit, each retry landing on another one. Retries inside one instance against its own pool are a single-instance bug: see |
Count attempts at the instances against requests at the client; expect a bounded ratio |
Fleet-wide unready from one dependency |
Readiness checks that test a shared dependency |
Not testable yet: nothing here can break a dependency. The cause, one pod leaving the rotation, is |
A job that dies with its process |
A long-running job in a thread of one instance, with its progress in memory |
|
Timing-based
Possible, not promised as deterministic: a cold JVM instance receiving a full share, thundering herds on recovery, metastable failures, retry amplification. They depend on timing or scale, and the test has to be written as a probability.
The shipped scenarios
Every module under samples/ is one application with a bug, a fleet
test that fails on it, and a @TestProfile that fixes it so that the same test passes. Reading the two test
classes side by side is the fastest way to see what the extension adds.
| Module | The bug | The fix |
|---|---|---|
|
A |
Each run claims its tick with |
|
No session encryption key; the cookie instance 0 issues sends the user back to the login page on instance 1 |
|
|
OIDC with PKCE and the client secret |
|
|
An instance stops listening on SIGTERM while the balancer keeps routing to it for a second: a client that never stops sees connection errors |
|
|
The leader of a leased job is frozen past its lease; the other instance takes over and does the item; the old leader wakes up and records the same item again |
A fencing token: the write carries the lease token it was acquired with and the database accepts it only while the lease still has that token ( |
|
The instance consuming the |
An idempotent consumer: the order id is claimed with a unique insert before shipping ( |
|
A second replica starts while a client is sending; it is listening before its catalog is loaded, and every request routed to it fails. The fix here is in the deployment, not the application |
A readiness probe: with |
|
Quarkus HTTP Idempotency with its default |
|
|
|
Tell the other instances, here over Redis pub/sub ( |
|
|
A fleet-wide counter in Redis in front of the resource ( |
|
A WebSockets Next broadcast reaches the sockets of the instance that sends it; clients on the other instance hear nothing |
Relay the message to every instance over Redis pub/sub ( |
|
Two instances poll the same outbox table and both send every pending notification |
|
|
Each instance opens its full pool against one |
Size the pool per replica ( |
|
Read, compute, write on two instances: a |
|
|
Two transfers lock the same two rows in opposite order on the two instances; Postgres detects the cycle and aborts one with SQLSTATE 40P01. A |
A consistent global lock order: always lock the lower account id first ( |
|
Both instances check that a slot has room, both pass, and both insert, breaking the capacity limit. No single-row |
|
|
Two instances pull causally ordered events for one subscription from a shared table and apply them in commit order, not event order, so a late |
A per-aggregate |
|
A long import runs in a thread of one instance; |
A checkpoint in the database and a reaper on every instance that resumes abandoned jobs ( |
|
An upload stored on the instance’s disk is a 404 through the other instance; each instance has a temporary directory of its own, as each pod has |
Shared storage: the database here ( |