Add ControllerEventQueueSizeGauge to detect a wedged ("zombie leader" controller)#201
Add ControllerEventQueueSizeGauge to detect a wedged ("zombie leader" controller)#201LZD-PratyushBhatt wants to merge 5 commits into
Conversation
…) controller The controller pipeline is drained by a single ClusterEventProcessor thread while the ZK session is kept alive by a separate ZK-client thread, so a wedged controller can keep leadership and a live ZK session yet stop processing events entirely. None of the existing controller signals (ZK session expiries, controllership-takeover failures, ZK op latency, WAGED internal failure) catch this, since the process stays up and leader. Expose the DEFAULT cluster-event pipeline backlog as a per-cluster JMX gauge on ClusterStatusMonitor, updated from GenericHelixController on both the enqueue side (ZK-callback / periodic-rebalance threads) and the dequeue side (pipeline thread). A healthy controller keeps this near 0 (the queue dedups by event type, so depth saturates at the few distinct pending event types); a wedged controller lets it climb. EKG/alerts should gate on a sustained average above ~0. Add unit tests for the gauge default and reversible setter.
|
The CI is failing but Copilot analysis tells that this failure is not because of this PR's changes. |
| _maxInstanceMsgQueueSize.set(0L); | ||
| _totalPastDueMsgSize.set(0L); | ||
| _totalMsgQueueSize.set(0L); | ||
| _rebalanceFailureCount.set(0L); |
There was a problem hiding this comment.
reset() runs on leadership change/monitor reset and zeros every counter. So stale numbers from a prior leadership period aren't attributed to the new one._controllerEventQueueSizeGauge appears only at its declaration, the setter, and the getter - it is not in reset().
Add this line:
_controllerEventQueueSizeGauge.set(0L);There was a problem hiding this comment.
Good point, done
| * Backlog of the DEFAULT controller cluster-event pipeline (events enqueued but not yet | ||
| * processed). Stays near 0 on a healthy controller because the pipeline drains quickly and the | ||
| * queue dedups by event type; climbs when the controller holds leadership but has stopped | ||
| * processing events ("zombie leader"). Alert on a sustained average above ~0. |
There was a problem hiding this comment.
We should combine this gauge with the progress signal Helix already has, ClusterEventStatus...TotalProcessed.EventCounter
The disambiguator is to combine this gauge with the progress signal Helix already has, ClusterEventStatus...TotalProcessed.EventCounter:
- Idle + healthy: queue depth 0, counter flat.
- Busy + healthy: queue depth occasionally > 0, counter advancing.
- Wedged (zombie leader): queue depth sustained > 0, counter NOT advancing (and InQueue wait-time climbing).
So the robust alert is the conjunction: ControllerEventQueueSizeGauge sustained > 0 AND TotalProcessed.EventCounter rate ~= 0.
There was a problem hiding this comment.
Yeah, that will be done on alerting end. I've updated the java doc
| Assert.assertEquals(monitor.getWagedFallbackInUseGauge(), 0L); | ||
| } | ||
|
|
||
| @Test |
There was a problem hiding this comment.
We should add tests that reflect this PR's behaviour like:
- that enqueueing to the DEFAULT _eventQueue makes the gauge climb, and dequeueing brings it down
- that only the DEFAULT queue updates it and the TASK queue (_taskEventQueue) does not (the queue == _eventQueue / _eventBlockingQueue == _eventQueue guards — the easiest thing to get wrong here, and untestable by the current tests)
- that the value resets to 0 on leadership change (comment 2 — and a test here would have caught that the reset is missing)
- nqueue several distinct event types without draining, assert the gauge reflects the backlog depth.
There was a problem hiding this comment.
I've added for some which looked related. The rest (gauge climbs on enqueue / drops on dequeue, DEFAULT-vs-TASK queue discrimination, backlog depth across distinct event types) touch the GenericHelixController enqueue/dequeue wiring, so I'll cover those as a follow-up integration test.
Zero _controllerEventQueueSizeGauge in ClusterStatusMonitor.reset() so a backlog from a prior leadership period is not re-reported by the reused monitor instance after re-election, matching the existing rebalance/WAGED counter reset semantics. Add a regression test that sets the gauge, calls reset(), and asserts it returns to 0. Add a brief MBean Javadoc note that depth alone is ambiguous (pair with ClusterEventStatus...TotalProcessed.EventCounter) and that the alert threshold/windowing belongs in the alerting layer, keeping the operational detail in the PR discussion rather than in source.
…line Complements ControllerEventQueueSizeGauge. Raw queue depth alone cannot tell a wedged controller from a busy-but-progressing one: the event queue dedups by event type (depth saturates at the few distinct pending types), and a producer faster than the single processor thread can keep it non-empty while the controller is still making progress. This adds a reversible 0/1 gauge that is 1 only when the DEFAULT event queue is non-empty AND no pipeline run has completed within a stall threshold (5s for now), i.e. the controller holds events but is not draining them. It is computed lazily in the JMX getter from the queue-size gauge plus a last-pipeline- completion timestamp reported by GenericHelixController, so it stays correct even if the pipeline thread is dead and no setter runs. Producer-rate- independent; gate EKG/alerts on == 1. Add unit tests for the idle, busy-but-progressing, no-baseline, wedged, and leadership-reset cases.
Replace the hardcoded 5s stall threshold with a per-cluster ClusterConfig value (CONTROLLER_PIPELINE_STALL_THRESHOLD_MS, default 5000ms) so ControllerPipelineStalledGauge can be tuned without a redeploy. GenericHelixController reads the value from ClusterConfig on each DEFAULT pipeline completion and pushes it to ClusterStatusMonitor, which now holds the threshold in a field (defaulting until first reported) and uses it in the lazily-computed stalled gauge. Non-positive values are ignored so a misconfiguration cannot silently disable the gauge. Add ClusterConfig getter/setter + default/round-trip test and a monitor test asserting the configured threshold changes the gauge result.
ControllerPipelineStalledGauge returns 0 while its last-completion timestamp is 0 (no baseline), so a controller that wedges before completing its very first pipeline run after acquiring leadership (e.g. a freshly-migrated Customer cluster whose first run hangs) would never be flagged. Seed the baseline to the current time when monitoring is enabled (leadership acquired) so "no pipeline completed since becoming active" is measurable and such a wedge is caught after the stall threshold. Cold-start slowness that could briefly read as stalled is covered by the EKG warm-up window and the configurable threshold.
Issues
Fixes #200
Description
What: Add a new per-cluster controller JMX gauge,
ControllerEventQueueSizeGauge, onClusterStatusMonitor, exposing the depth of the DEFAULT cluster-event pipeline queue(
GenericHelixController._eventQueue).Why: The controller pipeline is drained by a single
ClusterEventProcessorthread whilethe ZK session is kept alive by a separate ZK-client thread. As a result a wedged controller
can keep leadership and a live ZK session yet stop processing events entirely. None of the
existing controller signals (ZK session expiries, controllership-takeover failures, ZK op
latency, WAGED internal failure) catch this, because the process stays up and remains leader.
This "zombie leader" failure mode is currently invisible to monitoring.
How: Expose the DEFAULT cluster-event pipeline backlog as a per-cluster JMX gauge on
ClusterStatusMonitor, updated fromGenericHelixControlleron both the enqueue side(ZK-callback / periodic-rebalance threads, in
enqueueEventwhen the target is_eventQueue)and the dequeue side (the pipeline thread, in
ClusterEventProcessor.run()aftertake()forthe DEFAULT queue). Updates are guarded by
_isMonitoringand a null check on the monitor.A healthy controller keeps this near 0: the queue dedups by event type
(
DedupEventBlockingQueue), so depth saturates at the few distinct pending event types and thepipeline drains them quickly. A wedged controller lets it climb and stay elevated. EKG / alerts
should therefore gate on a sustained average above ~0 (not MAX), with a small threshold.
Sensor / attribute exposed: ObjectName
ClusterStatus:cluster={cluster}, attributeControllerEventQueueSizeGauge.Files changed:
helix-core/.../monitoring/mbeans/ClusterStatusMonitor.java- newAtomicLongfield, settersetControllerEventQueueSizeGauge(long), gettergetControllerEventQueueSizeGauge().helix-core/.../monitoring/mbeans/ClusterStatusMonitorMBean.java- getter declared on the MBeaninterface (auto-exports the JMX attribute).
helix-core/.../controller/GenericHelixController.java-updateControllerEventQueueSizeGauge()helper, invoked at enqueue and dequeue of the DEFAULT
_eventQueue.Tests
The following tests are written for this issue:
TestClusterStatusMonitor#testControllerEventQueueSizeGaugeStartsAtZeroTestClusterStatusMonitor#testControllerEventQueueSizeGaugeReflectsLatestSetterThe following is the result of the "mvn test" command on the appropriate module
(
mvn test -pl helix-core -Dtest=TestClusterStatusMonitor):Changes that Break Backward Compatibility (Optional)
No backward-incompatible behavior changes. The only API surface change is additive: a new
read-only getter
getControllerEventQueueSizeGauge()on theClusterStatusMonitorMBeanJMXinterface. No existing method signatures are changed or removed, and the sole implementor
(
ClusterStatusMonitor) is updated in this PR. The new value defaults to 0 until the controllerreports it, so existing consumers and dashboards are unaffected.
Documentation (Optional)
Not applicable. The change adds a single self-describing JMX gauge (documented via Javadoc on the
MBean interface); no public wiki change is required.
Commits
In addition, my commits follow the guidelines from "How to write a good git commit message":
Code Quality
(helix-style-intellij.xml if IntelliJ IDE is used)