SOLR · TEST FLAKINESS WALKTHROUGH · 2026-10-24

Why RecoveryAfterSoftCommitTest went flaky

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:

  1. A change elsewhere swapped the HTTP client the test uses for a newer one.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

StateWhere it livesSearchable?Survives a crash?
BufferedRAM inside IndexWriternono
Flushednew segment files on disk (_0.cfs…), not listed in any commit pointonly through a near-real-time searcher (soft commit)no: no commit point mentions them
Committedlisted in segments_N, fsynced (hard commit)yes, by any readeryes

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

StageJenkins rateWhat changed
Before Jun 100%The old Apache client. Its wasCommError() retried NoHttpResponseException, which hid the bad routing (Part 7).
#4451, Jun 105–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 / 100Send 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.

Variant (test class)What it changesQuestion it answers
main, pinned
RecoveryAfterSoftCommitAnyReplicaTest
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 after proxy.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.

VariantFailedNotes
main, pinned to "any replica" (before)16 / 100All 16 are the committed case on the cut replica: 13 ClosedChannelException, 3 Broken pipe, the same as Jenkins.
main + #5098 leaders-only post-cut client0 / 100Recovery was still exercised every time: 100 cuts and reopens, and 200/200 "Replication Recovery was successful".
main + wasCommError also counting Jetty's errors0 / 100CloudSolrClient 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 / 100CloudSolrClient 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 removed85 / 10084 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:

    proxy.close();
    try (CloudSolrClient leaderClient =
        createNewCloudSolrClient(zkServer.getZkAddress(), DEFAULT_COLLECTION, true, 30000, 120000)) {
      for (; i < MAX_DOCS; i++) {
        ...
        leaderClient.add(document);   // shardLeadersOnly = true → directUpdate → leader
      }
    }

What happened to the other tests #4451 touched

TestProblem after #4451Status
RecoveryAfterSoftCommitTestupdate routed to the cut replica, committed, not retryable#5098 open
ReplicationFactorTestsame mechanism@AwaitsFix since Jun 28 (SOLR-18295)
ChaosMonkey* testsindexing thread's own Jetty client never closed (leak)fixed, #4709 (Aug 6)
ForceLeaderTest, ShardSplitTestclient settings ported wronglyfixed 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.

Show as a table

Where the retry lives

branch_9x: CloudLegacySolrClient.java:142
protected boolean wasCommError(Throwable rootCause) {
  return super.wasCommError(rootCause)
      || rootCause instanceof ConnectTimeoutException
      || rootCause instanceof NoHttpResponseException;
}

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)
protected boolean wasCommError(Throwable t) {
  return SolrException.hasCause(t, SocketException.class)
      || SolrException.hasCause(t, UnknownHostException.class)
      || SolrException.hasCause(t, RequestNotSentException.class);
}

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-op sendUpdatesToAllReplicasInShard(), 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 errorno
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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Prove the race. An experiment test on main: never on macOS, ~20% on Linux, 100% under load, plus a deterministic pause-then-close repro.
  7. 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.
  8. 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.
  9. 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

Theories that turned out wrong

TheoryWhat disproved it
A busy CPU makes the race likelymacOS never lost it, even with 72 busy threads. The OS (Linux) was the difference.
Apache noticed dead connections, got refused, and retriedIts retry handler only retries GET, and it only re-checks a connection after 3s idle.
The 3s re-check protects 9xA fresh Apache connection fails 100%, and the real test's connections are 14–123ms old.
Develocity's empty September means the test didn't runJenkins shows it ran all month with 1–2% failures: a Develocity data gap.
9x's coin flip is 50/509 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:

Treat a flaky test as a signal, not noise.

Method lessons

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.