Skip to content

Overlord becomeLeader() blocks for 3 × druid.indexer.runner.syncRequestTimeout and fails leadership acquisition if a MiddleManager's discovery entry flaps during startup worker sync #20339

Open

Description

@aho135

Affected Version

Observed on 36.x; the relevant code paths are unchanged on master (as of commit 2bf1643cdd). Uses the HTTP-based task runner (druid.indexer.runner.type=httpRemote).

Summary

If a MiddleManager's node-discovery entry flaps (removed then re-added) during the Overlord's startup worker-sync, the Overlord's becomeLeader() blocks for the full 3 × druid.indexer.runner.syncRequestTimeout (default 3 × PT3M = 9 minutes) awaiting a stale WorkerHolder whose initialization latch can never fire, then throws and fails leadership acquisition. During that window the Overlord is effectively leaderless (no supervisor management), and the failure triggers a further leadership transition.

Description

Cluster / config

  • httpRemote task runner; combined Coordinator/Overlord process.
  • ~12 MiddleManagers, ~40 Kafka streaming supervisors.
  • druid.indexer.runner.syncRequestTimeout left at its default PT3M.
  • ZooKeeper-based discovery (CuratorDruidNodeDiscoveryProvider).

Trigger
The ZooKeeper ensemble leader restarted, causing a brief re-election and Curator SUSPENDED → RECONNECTED on the Druid processes. This forced an Overlord leadership change. As the new leader ran becomeLeader(), one MiddleManager's ZK session had also reset, so its ephemeral discovery node was deleted and immediately re-created — a remove+add flap occurring within ~1s of the Overlord starting its worker sync.

Observed behavior (timestamps relative)

T+0.000  New leader: HttpRemoteTaskRunner - "[12] Workers are discovered."
T+0.000  ChangeRequestHttpSyncer - "Starting sync for server[...MM-X...]" (x12)
T+0.000  HttpRemoteTaskRunner - "Waiting for worker[MM-0] to sync state..."
T+1.017  HttpRemoteTaskRunner - "Kaboom! Worker[MM-X] removed!"      # old ZK node gone
T+1.350  HttpRemoteTaskRunner - "Worker[MM-X] reportin' for duty!"  # new ZK node -> new holder, syncs fine
T+1.359  HttpRemoteTaskRunner - "Task[...] location changed on worker[MM-X]"  # new holder healthy
...
T+555.4  ERROR CuratorDruidLeaderSelector - "listener becomeLeader() failed. Unable to become leader"

Stack trace (host scrubbed):

java.lang.RuntimeException: java.lang.RuntimeException: org.apache.druid.java.util.common.RE:
    Failed to sync with worker[<middlemanager-host>:8088].
	at org.apache.druid.indexing.overlord.DruidOverlord$1.becomeLeader(DruidOverlord.java)
	...

The block lasted ~555s ≈ 3 × PT3M (540s) plus the time to await the earlier, healthy workers. The re-added MiddleManager was healthy the entire time (its new WorkerHolder synced within ~1s and streamed task snapshots); only the stale, stopped holder the startup loop was awaiting never initialized.

Root cause

Leadership callbacks run on a single-threaded executor and becomeLeader() is invoked synchronously, so the whole node cannot process any further leadership transition until it returns:

  • CuratorDruidLeaderSelector#createNewLeaderLatchWithListener — isLeader() calls listener.becomeLeader() inline on the LeaderSelector[...] single-thread executor; on any thrown exception it alerts and calls notLeader().

becomeLeader() starts the task runner (a managed lifecycle instance) before the supervisor manager, and HttpRemoteTaskRunner.start() blocks until it has synced with every discovered worker:

  • DruidOverlord$1.becomeLeader() builds the "task-master" Lifecycle, adds taskRunner (HttpRemoteTaskRunner) as a managed instance, then supervisorManager, then leaderLifecycle.start().
  • HttpRemoteTaskRunner#startWorkersHandling() — after worker discovery, iterates for (WorkerHolder worker : workers.values()) { worker.waitForInitialization(); }. The loop holds WorkerHolder references obtained at loop start.

The per-worker wait can never complete for a flapped worker:

  • WorkerHolder#waitForInitialization() → syncer.awaitInitialization(); returns false → throw new RE("Failed to sync with worker[%s]").
  • ChangeRequestHttpSyncer: maxDurationToWaitForSync = 3 * serverHttpTimeout and awaitInitialization() awaits initializationLatch up to that duration. serverHttpTimeout is HttpRemoteTaskRunnerConfig#getSyncRequestTimeout() (default PT3M). initializationLatch is only counted down on the first successful full/delta sync.
  • When the worker's discovery node is removed, HttpRemoteTaskRunner#removeWorker() does workers.remove(host) then workerHolder.stop() → syncer.stop(). Stopping the syncer does not count down initializationLatch.

So when the node flaps mid-startup: the old holder the startup loop is awaiting gets stop()ped (latch stuck at 1 forever), while a brand-new holder is created via addWorker() and syncs normally. The loop keeps awaiting the dead holder, blocks the full 3 × syncRequestTimeout, then throws — failing becomeLeader().

Impact

  • Overlord leadership acquisition is delayed/failed for up to 3 × druid.indexer.runner.syncRequestTimeout (default 9 minutes) whenever a worker's discovery entry flaps during that startup window — likely during the very ZK instability that caused the leadership change in the first place.
  • During the gap the Overlord never reaches SupervisorManager.start(), so streaming supervisors are unmanaged: Kafka indexing tasks that reach their taskDuration and exit are not replaced, and ingestion falls behind. When leadership finally settles on another node, all supervisors are (re)started at once, producing a synchronized ingestion-lag spike across every datasource on the cluster.
  • The becomeLeader() failure calls notLeader(), adding another leadership transition to an already-unstable moment.

Suggested fixes (for discussion)

  1. In startWorkersHandling(), don't await a captured WorkerHolder reference — re-check the current holder in workers (skip/replace ones that have been removed/stopped), or await by host and drop the wait if the entry disappears.
  2. Have WorkerHolder#stop() / ChangeRequestHttpSyncer#stop() count down (or otherwise release) initializationLatch so awaitInitialization() returns promptly for a stopped syncer instead of blocking the full timeout.
  3. Make the initial worker sync best-effort: log-and-continue when a single worker can't sync at startup (it will sync via the normal addWorker path) rather than failing the entire becomeLeader() — optionally bound the aggregate wait rather than paying 3 × syncRequestTimeout per worker.

Debugging already done

Correlated Overlord logs (becomeLeader start → becomeLeader() failed after ~555s with Failed to sync with worker[...]), the affected MiddleManager's logs (process healthy throughout; ZK node re-announced; successfully POSTing to the new Overlord), and the discovery flap on the Overlord (Kaboom! Worker[...] removed! then reportin' for duty! within ~1s), against the code paths above. Timing matches 3 × PT3M.

Activity

  1. aho135 commented on Sep 14, 2026

    @aho135
    ContributorAuthor

    Adding a diagram to make the race easier to follow. The crux is that two threads touch the same WorkerHolder: the single-threaded LeaderSelector running becomeLeader(), and the Curator discovery-listener thread running removeWorker()/addWorker().

    flowchart TD
        A["ZooKeeper leader restarts<br/>Curator SUSPENDED → RECONNECTED<br/>Overlord leadership moves to new node"] --> B
    
        subgraph T1["LeaderSelector thread (single-threaded, synchronous)"]
          direction TB
          B["isLeader() → listener.becomeLeader()"] --> C["DruidOverlord.becomeLeader()<br/>build 'task-master' Lifecycle<br/>start HttpRemoteTaskRunner FIRST (before SupervisorManager)"]
          C --> D["HttpRemoteTaskRunner.startWorkersHandling()<br/>discover workers → create WorkerHolder A for MM-X<br/>start all syncers"]
          D --> E["loop: for (WorkerHolder w : workers.values())<br/>w.waitForInitialization()<br/>(holds reference to holder A)"]
          E --> F["reach MM-X → holderA.awaitInitialization()<br/>await latch up to 3 × syncRequestTimeout"]
          F --> G{"holder A latch<br/>counts down?"}
          G -->|"NO — A was stopped pre-init (see race)"| H["block full 3 × PT3M = 9 min"]
          H --> I["awaitInitialization() = false<br/>throw RE 'Failed to sync with worker[MM-X]'"]
          I --> J["becomeLeader() throws"]
        end
    
        subgraph T2["Curator discovery-listener thread (concurrent, ~1s later)"]
          direction TB
          K["MM-X ZK session reset →<br/>ephemeral node removed"] --> L["nodesRemoved → removeWorker(MM-X)<br/>workers.remove(MM-X)<br/>holderA.stop() → syncer.stop()"]
          L --> M["stop() does NOT count down<br/>holderA.initializationLatch<br/>(stuck at 1 forever)"]
          M --> N["MM-X re-announces node →<br/>nodesAdded → addWorker(MM-X)"]
          N --> O["create holder B → holderB.start()<br/>workers.put(MM-X, B)"]
          O --> P["holder B syncs fine<br/>(latch B counts down, streams tasks)"]
        end
    
        D -. "loop captured ref to A" .-> E
        M == "A's latch never fires ⇒ this is why G = NO" ==> G
        P -. "B is healthy but the loop is awaiting A, not B" .-> F
    
        J --> Q["CuratorDruidLeaderSelector.isLeader() catch:<br/>'becomeLeader() failed' → notLeader()"]
        Q --> R["leadership released → another node becomes leader<br/>→ mass supervisor/task restart → ingestion-lag spike"]
    
        classDef bug fill:#ffdddd,stroke:#cc0000,stroke-width:2px,color:#000;
        classDef ok fill:#ddffdd,stroke:#22aa22,color:#000;
        class M,H,I bug;
        class P ok;
    
    Loading

    In one sentence: the startup loop grabs a reference to WorkerHolder A for MM-X, but a split second later the discovery thread removeWorkers MM-X — calling holderA.stop(), which stops the syncer without counting down its initializationLatch — and creates a fresh healthy holder B; the loop keeps awaiting the now-dead A, whose latch can never fire, so it blocks the full 3 × syncRequestTimeout (9 min) and fails becomeLeader().

    Two conditions must coincide for the hang:

    1. Ordering — MM-X's remove+re-add lands after startWorkersHandling() captured holder A but while the loop still awaits it.
    2. Latch leak — WorkerHolder.stop() / ChangeRequestHttpSyncer.stop() never release initializationLatch, so a stopped-pre-init holder is indistinguishable from a merely-slow one and the awaiter waits the full timeout.
  2. jianjindream commented on Oct 2, 2026

    @jianjindream

    I investigated this on master at 3e9365196f9893ff9b1cefcfbcb57d49e4080b4a and reproduced the stale-holder initialization wait.

    Releasing the syncer's initialization latch on stop, while tracking successful initialization separately, addresses the wait. However, skipping the removed holder during startup exposes a task-recovery ordering that needs clarification:

    1. Startup is waiting on holder A before its first sync.
    2. Discovery removes A and adds replacement B for the same host.
    3. B's first sync remains pending, but startup completes after skipping A.
    4. A task marked RUNNING in storage has not yet been reported by B. TaskQueue recovery submits it to another initialized worker C.
    5. When B finally reports the original running task, the runner retains C's ownership and sends a shutdown request to B.

    The replacement is correctly excluded from eligible workers until initialized; that does not prevent reassignment to C. In a control case where B completes fullSync before task recovery, the original ownership is restored without assignment or shutdown requests.

    This is a controlled local test using the production discovery, syncer, TaskQueue recovery and assignment paths, with mocked HTTP transport and storage. TaskQueue management is invoked directly; no live cluster was tested.

    What startup synchronization contract should this fix preserve? In particular, should startup follow the current holder for an initially discovered host and wait for its first fullSync? How should that handle a remove/readd gap or repeated replacement within a bounded wait?

    I have paused the production changes rather than introduce an unconfirmed recovery policy. I can share the focused reproduction and candidate diff if useful.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions