Skip to content

Parallelize per-instance listener registration during controller leadership [CICP-34606]#193

Open
kabragaurav wants to merge 19 commits into
linkedin:devfrom
kabragaurav:gkabra/CICP-34606/parallel-inithandlers
Open

Parallelize per-instance listener registration during controller leadership [CICP-34606]#193
kabragaurav wants to merge 19 commits into
linkedin:devfrom
kabragaurav:gkabra/CICP-34606/parallel-inithandlers

Conversation

@kabragaurav

@kabragaurav kabragaurav commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Parallelize per-instance ZK watch registration during controller leadership acquisition. The bottleneck is in checkLiveInstancesObservation() which sequentially registers currentState, taskCurrentState, message, and customizedStateRoot listeners for every live instance during the INIT callback. For large clusters like venice-5 (300 instances), this means ~1200 sequential ZK roundtrips (~200ms each) = ~240 seconds, causing SLA-0 MissingTopState alerts on KSAP.

Three-part fix:

  1. CallbackHandler: Add deferred-init constructor so handler creation is fast and init() can be called separately.

  2. ZKHelixManager.addListener(): Move init() outside synchronized(this). This reduces lock hold time from ~200ms (ZK roundtrip) to ~1ms (handler creation), allowing parallel threads to overlap their ZK subscriptions. This is a global change affecting all addXxxListener callers (participants, spectators, controllers). Between _handlers.add() and init() completing, the handler is visible but not yet initialized. Safe because reset() on an uninitialized handler is a no-op, and init() has its own synchronized(this) guard.

  3. GenericHelixController.checkLiveInstancesObservation(): During INIT, collect pending instances instead of registering listeners inline. ZKHelixManager.handleNewSession(): After handleNewSessionAsController() returns and invoke() releases synchronized(_manager), register per-instance listeners in parallel using a 10-thread pool. initHandlers() skips already-initialized handlers to avoid spurious WARN logs.

JIRA: https://linkedin.atlassian.net/browse/CICP-34606
RCA doc: https://docs.google.com/document/d/1la09_BYyE77cTL9ruxgGW3u9llWMMAprYQ31654SUC0/edit
Testing doc: https://docs.google.com/document/d/14LoOytLq4-YwwcxjRPCoxvWI6520kQj3JcviL5SYX8k/edit?tab=t.0#heading=h.jt9jh4tz6tl9

Benchmark Results

TestRegisterPendingListenersBenchmark.java

  • calls the real registerPendingInstanceListeners() method via doCallRealMethod(). 100 instances, 200ms simulated ZK latency per addXxxListener call.

Run: mvn test -pl helix-core -Dtest=TestRegisterPendingListenersBenchmark -DfailIfNoTests=false -am

Time Speedup
Sequential (old: for-loop in checkLiveInstancesObservation) 40,692 ms 1.0x
Parallel (new: registerPendingInstanceListeners, 20 threads) 2,048 ms 19.9x

Latency simulated with Thread.sleep(200) per addXxxListener call to mimic production ZK roundtrips (~200ms avg from acquired leadership took: logs). Local in-memory ZK is too fast (~0ms per call) to show the difference.

Expected production improvement: For venice-5 (300 instances, ~1200 listener registrations), leadership acquisition drops from ~240s to ~24s.

Note: the shipped pool size is 10 (constant INIT_HANDLERS_PARALLELISM). The mock benchmark table above was run with 20 threads; with 10 the parallel time is roughly 2x the benchmark's, still a large speedup over sequential. 10 was chosen to bound concurrent in-flight requests on the shared ZK ensemble during leadership-acquisition bursts.

Testing Done

Unit tests in PR (all pass):

  • TestCheckLiveInstancesObservationDeferred (3 tests) - verifies INIT defers, CALLBACK registers inline, take-and-clear semantics
  • TestInitHandlersParallel (5 tests) - null/empty/single handler, parallel execution (verifies concurrency > 1), exception handling (one failure does not block others)

Existing integration tests (all pass, not modified):

  • TestHandleSession (6 tests) - session re-establishment with real ZK
  • TestDistControllerElection (4 tests) - controller leadership failover with real ZK
  • TestZkCallbackHandlerLeak (5 tests) - no handler leaks after session expiry

Real-Cluster Validation (in addition to the mock benchmark)

Doc: https://docs.google.com/document/d/14LoOytLq4-YwwcxjRPCoxvWI6520kQj3JcviL5SYX8k/edit?usp=sharing

To address the ask for non-mock validation, the change was exercised end-to-end on a
real standalone ZooKeeper with a Helix distributed-controller ("supercluster")
topology — not in-memory ZK, not mocks.

Setup

  • 1 real standalone ZooKeeper.
  • 1 grand/controller cluster (SUPER_CLUSTER) running distributed controllers.
  • 2 managed data clusters, each 100 live participants × 30 resources (64 partitions ×
    3 replicas), --activateCluster'd into the grand cluster.
  • Cluster fully converged so CURRENTSTATES are populated, then measured on a clean
    single-controller failover (the scenario this PR targets: leadership acquisition over a
    large, already-populated cluster). 7,495 total ZK watches.

A/B method (apples-to-apples)
Same ZK, same participants, same resources — only the controller binary is swapped:

  • BASELINE = PR base commit c901f6ea (serial registration).
  • BRANCH = this PR (parallel registration).
    Metric is version-agnostic: wall-time between the first and last per-instance
    CallbackHandler subscribe for a cluster (this log line exists in both versions), so the
    comparison does not rely on a log line the PR itself adds.

Result — measured watch-setup (leadership-acquisition) time (reproduced across 2 runs)

Cluster Baseline (serial) This PR (parallel) Speedup
Cluster A 5,458 ms 845 / 1,025 ms 5.3–6.5x
Cluster B 5,552 ms 988 / 977 ms 5.6–5.7x

(Each cluster: 400 per-instance subscribes = 100 instances × 4 listener types. Baseline
measured once on the identical populated cluster; PR measured on two separate runs to show
the result is stable.)

Mechanism confirmed on a live cluster

  • Real path execution (no mocks): the live controller log shows the PR's own methods running —
    checkLiveInstancesObservation() deferring during INIT, registerPendingInstanceListeners()
    start/finish, and the dedicated registerInstanceListener-<cluster> threads doing the
    subscribes. These symbols are absent at PR base c901f6ea, so the path could only come from
    this PR's code.
  • The controller registers exactly 4 listeners per instance (currentState, message,
    customizedStateRoot, taskCurrentState) → 100 × 4 = 400, matching the code's intent.
  • The resulting ZK watch fan-out on the CURRENTSTATES subtree is
    #instances × (1 + #resources) = 100 × (1 + 30) = 3,100 per cluster (verified via ZK
    wchp) — i.e. the cost the serial loop pays grows with #instances × #resources,
    exactly the regime the change targets.
  • No spurious "already initialized" initHandlers WARN at scale.

Note on magnitude vs the mock (19.9x): local ZK round-trips here are ~14 ms/subscribe,
so the serial loop is ~5.5 s and the measured speedup is ~6x. The mock used 200 ms/roundtrip
(production-representative) and showed ~20x. The real-cluster number is therefore a
conservative lower bound — in production, where ZK round-trips dominate and clusters are
larger (e.g. venice-5: 300 instances → ~1,200 registrations), the gain is larger and
matches the mock's order of magnitude.

// Each handler.init() is a ZK roundtrip (~200ms). With 1200 handlers sequentially: ~240 sec.
// 20 parallel threads: ~12 sec. Too many threads could overload the ZK ensemble with
// concurrent reads.
private static final int INIT_HANDLERS_PARALLELISM = 20;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

How the number of threads on the client side will affect the zookeeper ensemble?
One single zookeeper-server instance can handle 15K connections on total.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The 20 threads share one ZkClient (one ZK connection). No new connections are created. Updated the comment to say this.

// concurrent reads.
private static final int INIT_HANDLERS_PARALLELISM = 20;

void initHandlers(List<CallbackHandler> handlers) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The calling function is wrapped inside a synchronised block(ZKHelixManager.java) and this won't actually run in parallel. Can you confirm this?

@kabragaurav kabragaurav Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

handler.init() -> invoke() acquires synchronized(_manager) which is the same lock. Fixed: synchronized(this) now only wraps the list copy. Parallel execution runs outside the lock.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do the listeners have init-order dependency? or is it completely irrelevant?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

No order dependency. Each handler subscribes to a different ZK path (one per instance for CURRENTSTATES, TASKCURRENTSTATES, etc.). They do not depend on each other.

try {
future.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we redesign this?
If one thread fails, the executor is doing a fullShutDownNow. This might lead to half-initialised handlers.

@kabragaurav kabragaurav Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

shutdownNow() runs in the finally block after all future.get() calls complete. If one handler throws, the exception is caught and logged, and the loop continues to wait for remaining handlers. No handler is left half-initialized. shutdownNow() only cancels tasks on InterruptedException (when we break early), which is the right behavior - stop fast on interrupt.

kabragaurav and others added 6 commits June 8, 2026 20:34
Sequential handler.init() calls in initHandlers() register ZK watches
one at a time (~200ms each). For large clusters like venice-5 (300
instances, ~1200 handlers), this takes 67-290 seconds, causing SLA-0
MissingTopState alerts on KSAP.

Replace the sequential loop with parallel execution using a fixed thread
pool (capped at 20 threads). ZkClient.subscribeDataChanges() and
subscribeChildChanges() are thread-safe (ConcurrentHashMap + synchronized
writes). The existing synchronized(_manager) in CallbackHandler.invoke()
serializes listener callbacks automatically.

Expected: 1200 handlers at 20 threads = ~12s (down from 240s).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… pool

- Remove unused imports (Collections, CountDownLatch) from test file
- Add named daemon threads (initHandler-<cluster>) for debuggability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handler.init() -> invoke() acquires synchronized(_manager) which is the
same lock as synchronized(this) on ZKHelixManager. Holding the outer lock
while pool threads try to acquire it causes deadlock.

Fix: copy the handler list under the lock, release the lock, then run
init() calls outside the synchronized block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kabragaurav
kabragaurav force-pushed the gkabra/CICP-34606/parallel-inithandlers branch from 004bb92 to 1af81cc Compare June 8, 2026 15:19
…created

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@Sanju98 Sanju98 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we add an integration test that adds/removes listeners during a session re-establishment, not mocks?

// concurrent reads.
private static final int INIT_HANDLERS_PARALLELISM = 20;

void initHandlers(List<CallbackHandler> handlers) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do the listeners have init-order dependency? or is it completely irrelevant?

kabragaurav and others added 2 commits June 9, 2026 11:07
50 participants, 20 resources, 208 handlers registered against
in-memory ZK. Measures controller start time which triggers initHandlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Local results (50 participants, 208 handlers, in-memory ZK):
  dev (sequential): 2857ms
  parallel: 2472ms
Results documented in PR description.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kabragaurav

Copy link
Copy Markdown
Collaborator Author

can we add an integration test that adds/removes listeners during a session re-establishment, not mocks?

Already covered by the existing TestHandleSession.testConcurrentInitCallbackHandlers - it adds/removes listeners during session events with real in-memory ZK. We ran it and it passes (6/6). Also ran TestZkCallbackHandlerLeak (5/5) which verifies no handler leaks after session expiry.

@kabragaurav
kabragaurav force-pushed the gkabra/CICP-34606/parallel-inithandlers branch from aa5569b to 858a602 Compare June 9, 2026 06:30
Logs "initHandlers completed for cluster: X, handlers: N, took: Yms"
so we can verify the speedup after deployment via KQL:
helix_logs | where message contains "initHandlers completed"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@LZD-PratyushBhatt LZD-PratyushBhatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Image

I don't think this touches the cold-acquisition path that actually drives the MissingTopState SLA-0, and the benchmark doesn't show that it does.

Our SLA-0 is on cold leadership acquisition (onBecomeLeaderFromStandby -> fresh HelixManager -> connect()). As the ss shows, handleNewSession runs handleNewSessionAsController() before initHandlers(). The total time (5-10 mins)(the log which shows "acquired leadership … took:..") is spent inside handleNewSessionAsController()

kabragaurav and others added 3 commits June 9, 2026 14:11
…ership [CICP-34606]

The previous commits parallelized initHandlers(), but that was the wrong code
path. The actual bottleneck is addListenersToController() -> addLiveInstanceChangeListener()
callback -> checkLiveInstancesObservation(), which sequentially registers
currentState/taskCurrentState/message/customizedStateRoot listeners for every
live instance. For clusters with 300 instances, this means ~1200 sequential ZK
roundtrips (~200ms each) = ~240 seconds.

Three changes fix this:

1. CallbackHandler: add deferred-init constructor so handler creation is fast
   and init() can be called separately.

2. ZKHelixManager.addListener(): move init() outside synchronized(this). This
   reduces lock hold time from ~200ms to ~1ms per handler, allowing parallel
   threads to overlap their ZK roundtrips instead of serializing on the lock.

3. GenericHelixController.checkLiveInstancesObservation(): during INIT, collect
   pending instances instead of registering listeners inline.
   ControllerManagerHelper.addListenersToController(): after primary listeners
   are registered and all locks released, register per-instance listeners in
   parallel using a 20-thread pool.

Expected improvement: ~240s -> ~12s for a 300-instance cluster.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The parallel per-instance listener registration deadlocked when called
from addListenersToController() because that method is invoked inside
CallbackHandler.invoke() which holds synchronized(_manager). Worker
threads calling addListener() -> init() -> invoke() need the same lock.

Fix: move registerPendingInstanceListeners() to handleNewSession(),
after handleNewSessionAsController() returns and invoke() has released
the lock. Parallel threads can now acquire synchronized(_manager)
independently.

Added real ZK integration benchmark (TestParallelListenerRegistrationBenchmark)
that creates 20 live instances and verifies all 88 handlers (8 primary +
4*20 per-instance) are registered correctly during controller.connect().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kabragaurav kabragaurav changed the title Parallelize CallbackHandler registration in initHandlers() [CICP-34606] Parallelize per-instance listener registration during controller leadership [CICP-34606] Jun 9, 2026
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kabragaurav

kabragaurav commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Benchmark file (not committed - for local performance comparison only):

TestRegisterPendingListenersBenchmark.java

Results (100 instances, 200ms simulated ZK latency):

Time Speedup
Sequential (old) 40,692 ms 1.0x
Parallel (new) 2,048 ms 19.9x

To run: make registerPendingInstanceListeners package-private temporarily, then:

mvn test -pl helix-core -Dtest=TestRegisterPendingListenersBenchmark -DfailIfNoTests=false -am

kabragaurav and others added 3 commits June 9, 2026 22:44
Not needed in the committed codebase. Existing integration tests
(TestHandleSession, TestDistControllerElection) already cover the
real ZK controller path. The benchmark was useful during development
to catch the deadlock but adds CI time without testing new behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…Parallel

These 3 tests (collection/retrieval, empty, take-returns-null) tested
trivial data class behavior already covered by
TestCheckLiveInstancesObservationDeferred.testInitDefersPerInstanceListenerRegistration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ARN logs

registerPendingInstanceListeners() fully inits per-instance handlers before
initHandlers() runs. Without this check, initHandlers calls init() again on
those handlers, producing a WARN per handler ("received event in wrong order")
because _expectTypes no longer contains INIT. For 300 instances that is 1200
spurious warnings per controller startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kabragaurav

kabragaurav commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

I don't think this touches the cold-acquisition path that actually drives the MissingTopState SLA-0

@LZD-PratyushBhatt Thanks for review. The earlier commits parallelized initHandlers() which runs AFTER handleNewSessionAsController() and is mostly a no-op (handlers are already initialized by then).

Fixed in the latest commits. The parallelization now targets the correct path:

  1. handleNewSessionAsController() -> _leaderElectionHandler.init() -> invoke() -> acquireLeadership() -> addListenersToController() -> addLiveInstanceChangeListener() -> INIT callback -> checkLiveInstancesObservation()

  2. checkLiveInstancesObservation() during INIT now defers per-instance listener registration (collects pending instances instead of calling addXxxListener in a loop).

  3. After handleNewSessionAsController() returns and invoke() releases synchronized(_manager), handleNewSession() calls registerPendingInstanceListeners() which registers all per-instance listeners in parallel using a 20-thread pool.

The "acquired leadership ... took: Xms" log (DistributedLeaderElection.java line 120) will still reflect most of the time, but the per-instance registration that previously dominated it (~240s for 300 instances) now runs after it, in ~12s.

Benchmark (calls the real registerPendingInstanceListeners method with simulated 200ms ZK latency):

  • Sequential (old): 40,639ms
  • Parallel (new): 2,044ms
  • Speedup: 19.9x

EI deployment needed for production validation of the "acquired leadership took" timing.

@kabragaurav

Copy link
Copy Markdown
Collaborator Author

can we add an integration test that adds/removes listeners during a session re-establishment, not mocks?

@Sanju98 Updated since the last reply. The PR now includes two things:

Unit tests in the PR (8 tests, all pass):

  • TestCheckLiveInstancesObservationDeferred (3 tests) - verifies INIT defers per-instance registration, CALLBACK still registers inline, and take-and-clear works
  • TestInitHandlersParallel (5 tests) - verifies parallel initHandlers() with concurrency check, exception isolation, null/empty/single edge cases

Existing integration tests with real ZK (all pass, not modified):

  • TestHandleSession (6 tests) - session re-establishment including testConcurrentInitCallbackHandlers
  • TestDistControllerElection (4 tests) - controller leadership failover
  • TestZkCallbackHandlerLeak (5 tests) - no handler leaks after session expiry

Also, the code has changed significantly since your earlier inline comments. The parallelization was moved from initHandlers() (wrong path) to handleNewSession() -> registerPendingInstanceListeners() (correct path — after handleNewSessionAsController() returns and invoke() releases the lock). Details in the updated PR description.

kabragaurav and others added 2 commits June 12, 2026 11:12
…quests

The per-instance listener registration threads share one ZkClient, so the
concern is concurrent in-flight requests on the shared ZK ensemble, not new
connections. Capping at 10 bounds the per-controller request burst when many
controllers acquire leadership at once (e.g. during a disruption), with no
throughput loss vs 20 in local scale tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Timing numbers (ZK roundtrip ms, sequential/parallel seconds) belong in the
design and test docs, not in code comments. Keep only the rationale: why the
pool is capped at 10 and why init() runs outside the lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants