A test that cuts off a replica and keeps writing started failing ~15% of the time on
main in June 2026. This page walks through the cause, one layer at a time: which client sends the
update, what HTTP/2 does on a connection the proxy just killed, and why Solr refuses to retry, and why the old client on branch_9x never hit it.
Just here for the story? Start here.
Solr is a search engine that keeps several copies (replicas) of each piece of an
index on different machines. If one machine drops off the network for a while, it has to catch
up when it comes back. RecoveryAfterSoftCommitTest checks exactly that: it cuts one
replica off on purpose, keeps writing, reconnects it, and checks that it recovers cleanly.
For months the test passed every time. Then one day in June 2026 it started failing now and
then, up to one run in ten, and nobody had touched the test. A test that fails "sometimes" is
called flaky, and the tempting move is to shrug and rerun it. This page is what happens if
you don't.
The short version:
A change elsewhere swapped the HTTP client the test uses for a newer one.
Sometimes the test's own writes went to the very replica it had just cut off. The new client
reuses connections, and on Linux it can start sending a request down a connection that is
already dead before it notices.
At that point the client can't know whether the server got the request, so it refuses
to resend it. That's the safe choice, because resending could apply a write twice. The test
fails.
The twist: the old client "never failed" because it resent those requests, a more
forgiving choice that suited its older transport. That behaviour kept the test's routing
problem out of sight.
The fix is a few lines in the test: send its writes to the healthy copy. In 100 runs of the
worst case, that took failures from 16 to 0.
Along the way: why the test exists at all (it was written in 2015 to guard against an
index-corruption bug), a race that only shows up on Linux, and a setting that silently did
nothing. Eleven years after it was written, the same test exposed a real client regression,
and its last failures pointed back at the test itself.
How to read this: curious about why it failed? Click through the
animation in Part 3. Prefer the detective story, wrong guesses included? Go to
Part 8. Want the evidence? Parts 1, 5 and 7 have the charts and numbers. Unfamiliar
word? See the glossary at the bottom.
PART 1
The symptom: clean for months, then flaky from June 10
"Flaky" here means a run failed and then passed when Gradle retried it. No run ever failed for good.
Data: the test's Develocity test history (all branches) and its
Jenkins failure history in Hossman's reports on fucit.org.
Feb–May 2026
0
flaky runs out of ~3,250
main since Jun 10
15%
87 flaky of 579 runs (Develocity)
branch_9x since Jun 10
0%
0 flaky of 476 runs
Flaky runs per month, all branches
Develocity has no September runs of this test. Jenkins shows it ran all month
(1–2% failures), so that gap is on Develocity's side.
Show as a table
Jenkins weekly failure rate (Solr Jenkins jobs, all branches)
Source: the test's Jenkins failure history on fucit.org
(data: failure-rate-history.csv). Two steps line up with
two commits: up the week #4451 merged, and down to ~2% right after SOLR-18401 started retrying
requests that were never sent. What's left is the committed case. The two most recent main
failures (#15202, #15239) both show it: IOException occurred when talking to server
on the cut replica.
Show as a table
Since June 10, by branch
Show as a table
The branch split is the first big clue. main and branch_10x got the same commit,
SOLR-18188 / #4451 ("Remove Apache HttpClient usage from test infra and tests"): on Jun 10 on
main and Jun 17 on 10x, and 10x went flaky exactly from Jun 17. branch_9x never got it and never
went flaky.
PART 2
The client stack: who sends the update
The test calls one method, cloudClient.add(doc). Under it sit five layers. Click a
layer to see what it does here. The orange edge marks what #4451 changed.
Show the stack:
PART 3
The race, step by step
Step through what happens in the microseconds after the test cuts the replica off. The path
splits at step 5, depending on whether Jetty has already noticed the cut. Switch between the two
outcomes to compare them.
Arrow keys work too.
Code
PART 4
Why LBSolrClient won't retry it
When a request to one replica fails, LBSolrClient can try the next one (here, the
leader). For queries it does that freely. For updates it's strict, because replaying an
update the server already applied could apply it twice. Pick a failure to see where it goes:
1. Is it an UPDATE?
isNonRetryable = req.getRequestType() == UPDATE || isAdmin: yes, so only the safe cases below may retry.
2. Did it fail while connecting?
isConnectException(rootCause): ConnectException means no connection was ever made, so nothing was sent.
3. Did the client mark it "request not sent"?
hasCause(e, RequestNotSentException.class): set by HttpJettySolrClient when the HEADERS never went out (SOLR-18401, Aug 26).
Result
PART 5
The experiment: same test, macOS vs Linux
A purpose-built test, RecoveryAfterSoftCommitProxyRaceTest, cuts the proxy, waits a
fixed delay, and sends one update
straight to the cut replica. Each round is classified as committed (not retryable, the flaky
failure) or not sent (safe to retry). The chart shows the share of rounds that failed committed.
macOS (18-core laptop)Linux (Docker)
Show as a table
macOS never lost the race. Even with 72 busy threads, Jetty
noticed the cut before the HEADERS went out every time, so every failure was "not sent" and retryable.
Linux lost it often: ~20% of sends within 200µs of the cut, close to CI's
~15%. Under CPU load, 100% at 0µs. One failure even reproduced CI's exact Broken pipe trace.
pause-then-close freezes the proxy first, so the request is fully written (committed) but
never forwarded, then closes it. It fails committed every time on both systems: a deterministic
repro, proving Solr never even received a request the client refuses to retry.
PART 6
The fix: don't send updates to the replica you just cut off
First: what this test is actually for
It helps to know the three states a document goes through in Lucene:
State
Where it lives
Searchable?
Survives a crash?
Buffered
RAM inside IndexWriter
no
no
Flushed
new segment files on disk (_0.cfs…), not listed in any commit point
only through a near-real-time searcher (soft commit)
no: no commit point mentions them
Committed
listed in segments_N, fsynced (hard commit)
yes, by any reader
yes
The test came in with SOLR-6640,
"Replication can cause index corruption" (fix by Shalin Shekhar Mangar, Jan 2015). It checks that
a replica holding soft-commit searchers can still recover by copying the whole index after a
partition:
maxBufferedDocs=2 3 adds force a flush: segment files on disk, but not committed
soft commit a searcher opens on those flushed-but-uncommitted files
proxy.close() the replica is cut off; the leader keeps indexing
numRecordsToKeep=2 the replica falls too far behind to replay updates → it needs a full index copy
proxy.reopen() recovery must discard the uncommitted files the searcher holds open,
copy the leader's index, and come back ACTIVE without corruption
The 2014 bug. Leader and replica each name their own segment files (_0.cfs,
_1.cfs…), so the same name can hold different documents on each node. During the full
copy, the replica rolled back its writer to discard its uncommitted segments, but its soft-commit
searcher still held them open, so the stale files survived. The fetcher then skipped any leader
file whose name already existed locally:
// SnapPuller.downloadIndexFiles(), 2014
if (!slowFileExists(indexDir, file.get(NAME)) || downloadCompleteIndex) {
... fetch from the leader ...
} else {
LOG.info("Skipping download for " + file.get(NAME) + " because it already exists");
}
Its only guard, isIndexStale(), compared sizes. A leftover file with the leader's name
and size was kept, the replica took the leader's commit point, and that commit point now pointed at
the replica's own different documents: "shard2 is not consistent. Got 62 … and got 24". It
needed all of: a soft-commit searcher on uncommitted segments, a full copy rather than an update
replay, and a same-named, same-sized leftover. The leader was never affected. The fix closes the
searcher first, rolls back, and waits until the stale files are really deleted, falling back to a
copy into a fresh directory. (The name-and-size mechanism is our reading of the 2014 code and the
fix's comments, not reproduced.)
The adds during the cut exist only to push the leader past numRecordsToKeep;
which replica the client sends them to is not what the test checks.
Leaders-first client, or a retry inside the test?
#5098 asks exactly this. Leaders-first is the right call: it delivers the adds to the leader,
which is all the test needs. A retry loop inside the test would replay updates that the client
deliberately refuses to replay (CheckRetryUnrollTest asserts that
ClosedChannelException is not retried). And what the client does while a replica is
unreachable is a separate concern, already covered by SOLR-18401's tests; if anyone wants more
coverage of it, it belongs in its own test.
Where the failure rate came from, and where it goes
Stage
Jenkins rate
What changed
Before Jun 10
0%
The old Apache client. Its wasCommError() retried NoHttpResponseException, which hid the bad routing (Part 7).
#4451, Jun 10
5–10%
Jetty HTTP/2 test client. "Any replica" routing now really happens (~50%). Dead-session errors are never retried, whether the request was sent or not.
SOLR-18401 (David Smiley), Aug 26
~2%
HttpJettySolrClient marks failures that happened before the HEADERS went out as RequestNotSentException, and LBSolrClient and CloudSolrClient retry those safely. That removed most failures.
Residual, today
~2%
Only the committed case is left: the HEADERS were written to the local socket, then the write failed. The client can't know whether the server saw it, so it correctly won't replay it. Main #15202 and #15239 are both this kind.
#5098 (proposed)
0 / 100
Send the post-cut adds to the leader. Updates never reach the cut replica, so neither failure case can happen. No client change needed. Measured below, in the worst case.
Measured: 100 runs of each variant
Each variant starts from the real RecoveryAfterSoftCommitTest and changes exactly one
thing. Two of them change wasCommError(): when a request fails,
CloudSolrClient asks it whether this was a communication problem, and if so refreshes
cluster state and retries the whole request, possibly on another replica. Otherwise the error
goes straight back to the caller.
Builds the client with sendUpdatesToAnyReplica() on every run instead of the random coin flip.
How often does it fail when the risky routing always happens?
main + #5098 …LeadersOnlyTest
Same, but the adds afterproxy.close() go through a leaders-only client,
createNewCloudSolrClient(…, true, …).
Does the proposed fix remove the failures, and is recovery still tested?
main + wider wasCommError …WideRetryTest
Same as pinned, but the client is a CloudHttp2SolrClient subclass whose
wasCommError() also returns true for ClosedChannelException and
EOFException (Jetty's EofException).
Is the missing retry the whole cause? A proof, not a fix: it replays committed updates.
branch_9x, pinned …AnyReplica9xTest
branch_9x's old Apache client, forced to any-replica (bypassing the no-op setter), with routing logged.
Does the old client survive the risky routing?
branch_9x, retry removed …NarrowRetry9xTest
Same, but the old client's wasCommError() drops NoHttpResponseException,
back to the base rule.
Does the old client fail once its retry is gone?
Results. Every variant forces the risky routing (updates may go to any replica) on every run, so
these numbers are the worst case, not the Jenkins rate. Linux in Docker, 100 iterations each.
Variant
Failed
Notes
main, pinned to "any replica" (before)
16 / 100
All 16 are the committed case on the cut replica: 13 ClosedChannelException, 3 Broken pipe, the same as Jenkins.
main + #5098 leaders-only post-cut client
0 / 100
Recovery was still exercised every time: 100 cuts and reopens, and 200/200 "Replication Recovery was successful".
main + wasCommError also counting Jetty's errors
0 / 100
CloudSolrClient caught and retried 17 would-be failures (15 ClosedChannelException, 2 Broken pipe), matching the 16 in the unchanged run. The missing retry accounts for the whole residual, but this cure replays committed updates, so it isn't safe to ship.
branch_9x, pinned (old client, unchanged)
0 / 100
CloudSolrClient retried 99 NoHttpResponseExceptions (commError=true) and 2 connection resets; recovery 200/200. The old client hits the dead connection on almost every run and is rescued every time.
branch_9x with that retry removed
85 / 100
84 are the cut-replica failure, with NoHttpResponseException now commError=false (not retried); 1 is unrelated (a core failed to create during setup). Without its retry, the old client fails far more than main's new one.
What the five runs prove, from both directions: remove the old
client's retry and 9x fails 84% of the time; add the same kind of retry to main and it stops
failing. The old client was never protected by its transport; it was rescued by the retry on almost
every run. #5098 reaches 0 without touching the client, and recovery is still exercised.
No client-side retry can be safe here: "committed" means the HEADERS left the client, and in
general the server may have applied the update. So the fix belongs in the test. The test is about
how the leader handles an unreachable replica, so its own updates should go to the leader,
which is never the cut replica. That's what #5098
(SOLR-18530) does, and what Hossman suggested on SOLR-18188 in June:
update routed to the cut replica, committed, not retryable
#5098 open
ReplicationFactorTest
same mechanism
@AwaitsFix since Jun 28 (SOLR-18295)
ChaosMonkey* tests
indexing thread's own Jetty client never closed (leak)
fixed, #4709 (Aug 6)
ForceLeaderTest, ShardSplitTest
client settings ported wrongly
fixed in June
PART 7
Why branch_9x never flaked
branch_9x's test uses the old Apache HTTP/1.1 client. You might expect it to handle the cut
replica better. It doesn't: it was protected by a retry one layer up, and it reached the cut
replica less often in the first place.
Credit: David Smiley identified the retry part in
SOLR-18401 (Aug 24, 2026), naming
the CloudLegacySolrClient.wasCommError() override as the regression from SOLR-18188
and designing the commit-based fix. This part measures that mechanism in the real 9x test, and
adds the routing difference and an explanation for the remaining failures.
The old client fails more on a fresh connection, until CloudSolrClient retries it
Old Apache client on branch_9x, Linux (Docker). The first three bars count single updates in the experiment harness; the last two count runs of the real test.
When this is true, CloudSolrClient.requestWithRetryOnStaleState retries the
whole update, and the second attempt reaches the leader. Over 100 pinned runs it retried 99
NoHttpResponseExceptions (commError=true) and no run failed. With the
override removed, 84 of 100 runs failed.
main: CloudSolrClient.java:213 (the subclass is gone)
Jetty's ClosedChannelException and EofException: Broken pipe
match none of these, so there's no retry and the test fails.
#4451 changed three things at once
branch_9x (old client)
main after #4451
How often the test client may send to the cut replica
~25%. "Any replica" went through the deprecated no-opsendUpdatesToAllReplicasInShard(), so only the builder's own random roll could set it.
~50%. sendUpdatesToAnyReplica() works.
What hitting the dead connection looks like
NoHttpResponseException, 100% on a fresh connection
ClosedChannelException or Broken pipe, ~20% on Linux
Retried by CloudSolrClient?
yes: counts as a communication error
no
The old "immunity" was a permissive replay. SOLR-18401 describes
NoHttpResponseException as "provably never processed", but Apache raises it after
writing the request, when no response comes back. In the 9x runs it fired on fully written
requests. So the old client also replayed the committed case, which is why it showed 0%.
SOLR-18401's rule is stricter: it retries only requests whose headers never left, and refuses
exactly the committed case. That is the ~2% that remains. Replaying a committed update is
harmless for a plain add with a fixed id, but not for an atomic inc, which is why the
fix belongs in the test (#5098) rather than in bringing the old retry back.
A minor factor: Apache re-checks a pooled connection after 3s idle. It then gets
refused, and its load balancer fails over to the leader (5/5 in the idle-gap rounds). In
the real test the connection is usually only 14–123ms old, so this rarely applies.
PART 8
How we found it
When and where. Develocity history by month, then week, then day: the first flaky
runs were on Jun 10–11. main and branch_10x went flaky; branch_9x stayed clean.
Which commit. Five candidates landed on main around Jun 8–10, with different 10x
backport dates. 10x went flaky the day #4451 was backported (Jun 17), when the other four
were already there.
How it fails. The Jenkins stack trace: the add right after proxy.close(),
sent to the cut replica's port, failing with Broken pipe after the request had started.
Down the stack. The routing coin flip, then LBSolrClient's rule that
updates are retried only if provably not sent, then HttpJettySolrClient's commit
flag, then the Jetty source: "committed" means written to the local socket buffer.
Prior work. Hossman and David analysed the flakiness on SOLR-18188 in June. In
SOLR-18401 (Aug 24) David traced the regression to the lost wasCommError retry,
used the same fucit.org rates, and shipped the "not sent" fix. Nick's #5098 has the test fix.
Prove the race. An experiment test on main: never on macOS, ~20% on Linux, 100%
under load, plus a deterministic pause-then-close repro.
Cross-check with Jenkins. Hossman's fucit.org reports matched the Jun 8 week onset,
showed SOLR-18401's step down to ~2%, filled Develocity's September gap, and confirmed that
today's failures are the committed kind.
Measure the fix. 100 runs per variant, all forcing the risky routing: 16/100 failures
before, 0/100 with #5098's leaders-only client, with recovery exercised every time.
Why did 9x pass? A 9x experiment comparing both clients, then the real 9x test
instrumented and pinned to "any replica", which revealed the cloud-level retry.
What this investigation adds to SOLR-18188 and SOLR-18401
The residual ~2%, explained: the old client also replayed committed requests
(NoHttpResponseException fires after the write). The new rule refuses those, and
only routing to the leader removes them.
The routing difference: on 9x the helper's "any replica" was a no-op setter (~25%
exposure); #4451 made it real (~50%).
The race is Linux-only (never on macOS), with a deterministic pause-then-close repro.
The 8% → 2% drop, quantified: about 4 in 5 hits fail "not sent", which is exactly what
SOLR-18401 retries.
Theories that turned out wrong
Theory
What disproved it
A busy CPU makes the race likely
macOS never lost it, even with 72 busy threads. The OS (Linux) was the difference.
Apache noticed dead connections, got refused, and retried
Its retry handler only retries GET, and it only re-checks a connection after 3s idle.
The 3s re-check protects 9x
A fresh Apache connection fails 100%, and the real test's connections are 14–123ms old.
Develocity's empty September means the test didn't run
Jenkins shows it ran all month with 1–2% failures: a Develocity data gap.
9x's coin flip is 50/50
9 of 10 runs were leaders-only: on 9x the "any replica" setter is a no-op.
Epilogue: what one flaky test revealed
The test was written in 2015 as the regression test for one bug. Eleven years later, its
flakiness pointed at two more things, each one layer deeper:
2015, why it exists: an index-corruption bug in recovery with soft-commit searchers
(SOLR-6640). That bug was found through ChaosMonkeySafeLeaderTest; this test came in
with the fix, to keep it from coming back.
Aug 2026: a real SolrJ regression. Production clients behind idle-timeout load balancers
or firewalls would get hard failures on requests that never reached Solr. SOLR-18401's own write-up
says how it was found: "RecoveryAfterSoftCommitTest went from zero CI failures to a sustained
3-10% failure rate."
Today: its last ~2% marks the edge of what a client can safely replay, and shows the test's
own routing was the remaining problem (#5098).
Treat a flaky test as a signal, not noise.
Method lessons
Use branch timing as a natural experiment to pick one commit out of several.
Measure before explaining. Several plausible explanations here were wrong.
Make the failure deterministic (pause-then-close) so it can be studied at will.
Test where CI runs. The race doesn't show up on macOS.
Instrument the real path. The harness said the old client fails 100%; only the real
test showed the retry that rescued it.
Expect harness bugs. A wrong doc id, load-balancer state leaking between rounds, and
overwritten results could each have misled us.
REFERENCE
Glossary
SocketProxy
A test helper sitting between clients and a Solr node. close() closes every connection through it and stops accepting new ones: a fake network partition.
HTTP/2 session vs stream
A session is one long-lived TCP connection; each request is a stream on it. Jetty keeps sessions pooled (up to 4 per host, never closed for idleness here) and reuses them.
HEADERS / DATA frames
An HTTP/2 request is a HEADERS frame (method, path, headers) followed by DATA frames (the body). Solr streams the JavaBin body, so DATA goes out after HEADERS.
Committed
Jetty's event that the request's HEADERS were written. It means written into the local socket buffer, not received by the server.
Selector
Jetty's I/O thread that reads from sockets. Jetty only learns the peer closed the connection when the selector reads end-of-stream (filled < 0).
FIN / RST / EPIPE
TCP close signals. After a FIN the local kernel still accepts a write; the peer answers it with an RST, and the next write fails with EPIPE, "Broken pipe".
RequestNotSentException
Solr's marker that a request failed before anything was sent, so it's safe to replay elsewhere (SOLR-18401).
Zombie
LBSolrClient's list of endpoints that failed retryably; it skips them and pings them every 60s to see if they're back.
wasCommError
CloudSolrClient's check for a "communication error". If true, it retries the whole request, possibly on another replica. The old CloudLegacySolrClient added Apache's NoHttpResponseException to it.
NoHttpResponseException
Apache HttpClient's error when a request was written but the connection closed before any response arrived.
Flush / hard commit / soft commit
A flush writes buffered documents as a new segment on disk, not yet committed. A hard commit writes a commit point (segments_N) and fsyncs. A soft commit opens a searcher straight on the writer's current state, so flushed but uncommitted segments become searchable without being durable.
numRecordsToKeep / PeerSync / full replication
A replica that missed only a few updates catches up by replaying them from the leader's update log (PeerSync). If it missed more than numRecordsToKeep, it has to copy the whole index from the leader (full replication, via IndexFetcher).
shardLeadersOnly
A CloudSolrClient setting. True: updates go straight to shard leaders. False: any active replica, shuffled. The test framework picks it at random.