Skip to content

SOLR-18301 overseer election does not converge - #4625

Open
kotman12 wants to merge 13 commits into
apache:mainfrom
kotman12:SOLR-18301-overseer-election-does-not-converge
Open

kotman12 wants to merge 13 commits into
apache:mainfrom
kotman12:SOLR-18301-overseer-election-does-not-converge

Conversation

@kotman12

@kotman12 kotman12 commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

AI Use

I used Claude Opus 5 to greatly refine the test which is sensitive to timing. The model also aided in code research of the existing logic. The process was very slow and took many tens of iterations and supplementary "manual" checking of work. I take full responsibility of the output.

Description

Reproducing the stuck-overseer bug caused by close vs start data race. Previously overseer would enter leader election only on session expiry. By triggering election on every reconnect we uncovered certain bugs that temporarily became a lot more common with the unintended reconnect behavior. Since that issue was patched I can still recreate this issue though it is more sensitive to timing.

After digging even deeper it seems that curator behavior itself may be driving the residual bug as well, although in a more subtle way. Curator tears down the overseer with its own, internally managed timer. This can happen without the server-side session ephemerals being torn down (as I show in the tests) since ZK's check to destroy these happens on a tick-wide interval and is not synchronized at all with what Curator does (Curator's own docs acknowledge this). This difference in behavior also contributes to this bug. The net effect is you can have a permanently locked Overseer election that won't budge unless you manually restart.

Solution

Although returning to the previous reconnect behavior was a good start (see #4577) it is still possible to hit this when you have a really ill-timed reconnect that happens within a zk tick of a session boundary causing two election threads to interfere with one another. The solution is to synchronize the critical section where we write the existing overseer leader node and actually start the overseer. Before the leader node was written outside of the critical section and could survive the closure of its underlying overseer (blocking the subsequent one from taking over).

A further enhancement made here is to only launch the OverseerExitThread when a non-ZK event is triggering the tear-down of the Overseer. This is because our zk-registered callbacks already manage the Overseer lifecycle. Having a competing Overseer "lineage" (from the OET) only adds noise.

A final enhancement is to add optimistic version checks when reading/writing the Overseer leader node to prevent ABA-style interference across nodes (centralizing the logic because the shard leader election already does this).

Tests

  • testOverseerSurvivesZkReconnect

Checklist

Please review the following and check all that apply:

  • I have reviewed the guidelines for How to Contribute and my code conforms to the standards described there to the best of my ability.
  • I have created a Jira issue and added the issue ID to my pull request title.
  • I have given Solr maintainers access to contribute to my PR branch. (optional but recommended, not available for branches on forks living under an organisation)
  • I have developed this patch against the main branch.
  • I have run ./gradlew check.
  • I have added tests for my changes.
  • I have added documentation for the Reference Guide
  • I have added a changelog entry for my change

@kotman12
kotman12 marked this pull request as draft July 8, 2026 16:13
linxiaokun528 and others added 2 commits July 8, 2026 13:41
Curator's RECONNECTED event is different from the previous RECONNECTED event. Before Solr10, the OnReconnect is only triggered after a reconnection from a session expiration. Check the following

https://github.com/apache/solr/blob/fdb5314279657f7895a90123436d834e81ea3157/solr/solrj-zookeeper/src/java/org/apache/solr/common/cloud/ConnectionManager.java#L165
https://github.com/apache/solr/blob/fdb5314279657f7895a90123436d834e81ea3157/solr/solrj-zookeeper/src/java/org/apache/solr/common/cloud/ConnectionManager.java#L199

But Curator's RECONNECTED event is triggered every time a Solr node is disconnected from a ZooKeeper instance and reconnected to another ZooKeeper instance.

Therefore, currently ZkController.onReconnect is invoked every time a Solr node reconnects to a ZooKeeper instance without a session expiration, which is a huge overhead, especially when we need to rolling-restart a ZooKeeper Cluster. It can take more than 10 minutes for a small Solr cluster to level out.

Similiarly, now ZkController.onDisconnect is triggered just after a disconnection from a Zookeeper instance. It should only be triggered after a session expiration.
@kotman12
kotman12 marked this pull request as ready for review September 18, 2026 19:55
final String leaderPath;

/** Parent of {@link #leaderPath}; derived once since {@link #leaderPath} is final. */
final String leaderParentPath;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both overseer and shard leader share this concept so I wanted to centralize it.

// (roles handoff) or an unexpected crash. On a clean close or a ZK session-expiry
// reconnect, the ZkController reconnect handler owns re-election, so spawning here would
// just race it and risk two competing overseer lineages.
if (quitReceived || crashed) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason we disambiguate these from the case where ZK/curator callback closes the overseer (due to disconnect or whatever) is that when zk callback does the closing then the zk/curator machinery presumably owns the election rejoin as well. So when we run OverseerExitThread in that case it actually needlessly races the zk-callback triggered rejoin. With the concurrency bug fixes in this PR this shouldn't be a critical issue however it adds unnecessary noise and churn. It also doesn't appear to be the original intention behind OverseerExitThread

} catch (Exception e) {
log.warn("Unable to rejoinElection ", e);
if (zkController != null && !zkController.getCoreContainer().isShutDown()) {
zkController.rejoinOverseerElection(null, false);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rejoinOverseerElection here is guaranteed to first cancel the current "election" and by doing so it will close this Overseer. This, in turn, will delete the leader node.

if (!this.isClosed && !overseer.getZkController().getCoreContainer().isShutDown()) {
boolean shutDown = overseer.getZkController().getCoreContainer().isShutDown();
if (!this.isClosed && !shutDown) {
registerLeaderNode(Utils.toJSON(myProps));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registering the leader node within the synchronized block is the critical change of this patch. Without it there is a possibility of "election interference".

synchronized (this) {
if (leaderZkNodeParentVersion != null) {
try {
deleteLeaderNode();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved this here because that is how the shard leader context manages this and it seems reasonable. I don't see why we wouldn't want to be consistent here.

if (log.isInfoEnabled()) {
log.info("Quit command received {} {}", message, LeaderElector.getNodeName(myId));
}
quitReceived = true;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For posterity, this appears to be an "internal API" which lets the Cloud implement overseer node prioritization, i.e. designating/preferring some nodes to be Overseer over others.

* election fails with NodeExists.
*/
@Test
public void testOverseerWedgesOnExpiryRacingReconnect() throws Exception {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw this test reproduces with more realistic / less contrived configurations (15-30s session timeout and 2s tick window) but in the interest of build times I tuned those down.

} catch (Exception e) {
log.warn("Unable to rejoinElection ", e);
if (zkController != null && !zkController.getCoreContainer().isShutDown()) {
zkController.rejoinOverseerElection(null, false);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably remove the zkController != null check as the Overseer is now only initialized from within a zkController. Looks like zkController could be marked final as well.

@kotman12
kotman12 requested review from HoustonPutman, hossman and markrmiller and removed request for hossman September 18, 2026 20:20
@kotman12 kotman12 added this to the 10.x milestone Sep 18, 2026
@kotman12

Copy link
Copy Markdown
Contributor Author

@HoustonPutman @markrmiller I'd greatly appreciate it if either of you could take a look.

super.cancelElection();
// Delete only our own registration, guarded by the parent version captured at registration, so
// we can never remove a newer lineage's (ABA-safe). Mirrors ShardLeaderElectionContextBase.
synchronized (this) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is weird that we don't synchronize super.cancelElection or overseer.close within this block. However, the current logic already permits unsynchronized cancel vs overseer.close and the bug in question is not related to this. Theoretically, it means that two threads can be tearing down the same overseer and OverseerElectionContext (taking turns in the various sections).

It's also worth pointing out that the lifecycle of the overseer (singleton) is quite different from that of the "enclosing" OverseerElectionContext (roughly one per generation). As far as I can tell they are not robustly synchronized and the ownership structure is not well-defined. In general this model could be greatly improved outside of this change which I tried to keep as minimal as I could to address a practical problem.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants