kotman12 commented on code in PR #4625: URL: https://github.com/apache/solr/pull/4625#discussion_r3547547913
########## solr/core/src/test/org/apache/solr/cloud/OverseerElectionReconnectTest.java: ########## @@ -0,0 +1,386 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.cloud; + +import java.lang.invoke.MethodHandles; +import java.net.URI; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.cloud.SolrZkClient; +import org.apache.solr.common.util.TimeSource; +import org.apache.solr.core.CloudConfig; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.util.SocketProxy; +import org.apache.solr.util.TimeOut; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Tests that overseer election recovers correctly after a ZooKeeper connection blip (SUSPENDED → + * RECONNECTED with same session). Reproduces the doom loop where the old ephemeral leader node + * still exists on reconnect, causing NodeExistsException → unbounded recursion → + * StackOverflowError. + * + * <p>Uses a SocketProxy between the Solr node and ZooKeeper so that pausing traffic triggers + * SUSPENDED without expiring the session (ZK stays running, session stays alive, ephemerals + * persist). + */ +public class OverseerElectionReconnectTest extends SolrTestCaseJ4 { + + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private static final String SOLRXML = "<solr></solr>"; + + /** + * Probabilistic reproduction of the single-node overseer livelock. Rapidly toggles connectivity to + * the ZK ensemble so that, on some cycle, {@code onReconnect} fires while the {@code + * OverseerExitThread} spawned by {@code onDisconnect} is mid-{@code runLeaderProcess} — landing a + * {@code close()} in the window between {@code makePath(leaderPath)} and the guarded {@code + * overseer.start()}. When that happens the leader znode is created with no overseer behind it (a + * "zombie"), every later election fails with NodeExists, and the overseer never recovers even after + * connectivity is stable. + * + * <p>Health/wedge is decided precisely: a healthy overseer has a live updater thread whose id equals + * the id stored in {@code /overseer_elect/leader}. A zombie has a leader znode whose id matches no + * running updater. + */ + @Test + public void testOverseerWedgesUnderRapidZkReconnects() throws Exception { + Path zkDir = createTempDir("zkData"); + Path ccDir = createTempDir("testOverseerRapidReconnect-solr"); + + ZkTestServer zkServer = new ZkTestServer(zkDir); + zkServer.setTheTickTime(1000); + try { + zkServer.run(); + + SocketProxy zkProxy = new SocketProxy(); + zkProxy.open(URI.create("http://127.0.0.1:" + zkServer.getPort())); + String proxiedZkAddress = "127.0.0.1:" + zkProxy.getListenPort() + "/solr"; + log.info("ZK on port {}, proxy on port {}", zkServer.getPort(), zkProxy.getListenPort()); + + try { + int zkClientTimeout = 30000; // >> outage durations, so the session never expires + CloudConfig cloudConfig = + new CloudConfig.CloudConfigBuilder("127.0.0.1", 8984) + .setLeaderConflictResolveWait(180000) + .setLeaderVoteWait(180000) + .build(); + + CoreContainer cc = createCoreContainer(ccDir, SOLRXML); + try (ZkController zkController = + new ZkController(cc, proxiedZkAddress, zkClientTimeout, cloudConfig); + SolrZkClient probe = + new SolrZkClient.Builder() + .withUrl(zkServer.getZkAddress()) + .withTimeout(30000, TimeUnit.MILLISECONDS) + .build()) { + + assertNotNull("Overseer leader should be elected", waitForOverseerLeader(zkServer, 30)); + assertTrue( + "Overseer should be healthy before the storm", + waitForHealthyOverseer(zkController, probe, 30)); + log.info("Baseline healthy overseer established; starting reconnect storm"); + + // Sweep short outage durations so RECONNECTED lands at varying offsets relative to the + // OET's makePath->start window. Short outages keep the client reconnecting to the same + // session (no expiry) while giving onDisconnect time to spawn the OET. + int[] outageMs = {80, 110, 140, 170, 200, 240, 300, 130, 95, 180, 260, 150}; + boolean recoveredThisCycle = true; + for (int i = 0; i < 60 && recoveredThisCycle; i++) { + int outage = outageMs[i % outageMs.length]; + log.info( + "Reconnect storm cycle {}: cutting ZK connectivity for {}ms (leader={}, updater={})", + i, + outage, + OverseerTaskProcessor.getLeaderId(probe), + updaterId(zkController)); + zkProxy.close(); + Thread.sleep(outage); + zkProxy.reopen(); + // Give this cycle a bounded chance to recover; if it can't, stop hammering (don't risk + // eventually expiring the session, which would clear the zombie and mask the bug). + recoveredThisCycle = waitForHealthyOverseer(zkController, probe, 6); + if (!recoveredThisCycle) { + log.info("Overseer did not recover within 6s after cycle {} (outage {}ms)", i, outage); + } + } + + // Authoritative check: with connectivity now stable, a healthy cluster returns to a + // running overseer whose id matches /overseer_elect/leader. A zombie never does. + boolean healthy = waitForHealthyOverseer(zkController, probe, 45); + if (!healthy) { + log.error( + "WEDGED: /overseer_elect/leader id={} but no running updater matches it; updater={}", + OverseerTaskProcessor.getLeaderId(probe), + updaterId(zkController)); + } + assertTrue( + "Overseer wedged after rapid ZK reconnects (zombie leader / NodeExists spin) — see" + + " 'ZOMBIE LEADER' warning in the log", + healthy); + } finally { + cc.shutdown(); + } + } finally { + zkProxy.close(); + } + } finally { + zkServer.shutdown(); + } + } + + /** + * Reproduction of the <em>residual</em> overseer zombie race that survives PR #4577 (which stops + * {@code onReconnect}/{@code onDisconnect} from firing on same-session blips, so the first test's + * same-session storm can no longer trigger it). Here we drive <em>real session expiries</em> and + * try to make the expiry coincide with the reconnect. + * + * <p>Mechanics of the surviving race: on a genuine expiry the departing lineage's {@code + * OverseerExitThread} (spawned from {@code ClusterStateUpdater.run()}'s {@code finally}) still runs + * {@code checkIfIamStillLeader} → {@code getData(/overseer_elect/leader)}. If that read lands + * <em>after</em> the new session's {@code onExpiredReconnection} lineage has already recreated the + * leader znode, the OET does not take the {@code NoNode} early-out — it falls into its {@code + * finally} and calls {@code rejoinOverseerElection} unconditionally. That second lineage races the + * first on the shared {@code overseerElector}/{@code Overseer}, reopening the D1 window where a + * {@code close()} lands between {@code makePath(leaderPath)} and the guarded {@code + * overseer.start()} → a zombie leader znode with no updater behind it. + * + * <p>To hit it we need a short session timeout (so expiry is reachable in a test) and outages that + * <em>straddle</em> the session-timeout boundary so the client reconnects right as/after the server + * expires the session. ZooKeeper clamps the negotiated session timeout to [2*tickTime, 20*tickTime] + * and only detects expiry on tickTime-wide buckets, so tickTime must be lowered to make a ~1s + * session possible and to give fine timing granularity around the boundary. + */ + @Test + public void testOverseerWedgesOnExpiryRacingReconnect() throws Exception { Review Comment: I've added this check to show that this bug pre-dates the curator migration although it would be much harder to hit. Basically you'd need a strong coincidence of a reconnect at the same time as the session expiry. However, if you do a leader re-election on a small network blip that can _easily_ trigger both stop and start in a very tight time window. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
