Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
8e74c03
Parallelize CallbackHandler registration in initHandlers() [CICP-34606]
kabragaurav Jun 8, 2026
56f8fa6
Fix: remove unused imports, add daemon thread naming for initHandlers…
kabragaurav Jun 8, 2026
f40f78d
Add comment explaining parallelism constant choice
kabragaurav Jun 8, 2026
3753d7a
Clean up test: use static imports, remove redundant comment
kabragaurav Jun 8, 2026
d3c979f
Test null/empty handlers with real method, not mock default
kabragaurav Jun 8, 2026
b26f7b9
Fix deadlock: release synchronized(this) before calling handler.init()
kabragaurav Jun 8, 2026
307e1ad
Clarify comment: threads share one ZK connection, no new connections …
kabragaurav Jun 8, 2026
9694bc5
Add benchmark test for initHandlers with real ZK
kabragaurav Jun 9, 2026
609bb9f
Remove benchmark test from PR - used for local comparison only
kabragaurav Jun 9, 2026
79e4e37
Add timing log to initHandlers for production observability
kabragaurav Jun 9, 2026
f709270
Parallelize per-instance listener registration during controller lead…
kabragaurav Jun 9, 2026
d254777
Fix deadlock: move parallel registration out of invoke() lock scope
kabragaurav Jun 9, 2026
34ee708
Fix stale Javadoc reference and remove unnecessary LinkedHash imports
kabragaurav Jun 9, 2026
ad8c711
Fix stale inline comment and remove unused imports from tests
kabragaurav Jun 9, 2026
65f08a6
Remove TestParallelListenerRegistrationBenchmark from PR
kabragaurav Jun 9, 2026
e53282e
Remove redundant PendingInstanceListeners tests from TestInitHandlers…
kabragaurav Jun 9, 2026
92c0d72
Skip already-initialized handlers in initHandlers to avoid spurious W…
kabragaurav Jun 9, 2026
45fd807
Cap initHandlers parallelism at 10 (was 20) to bound concurrent ZK re…
kabragaurav Jun 12, 2026
4cb02f9
Remove timing estimates from initHandlers/addListener comments
kabragaurav Jun 12, 2026
dfb7686
Fix failover path: register deferred per-instance listeners on CALLBA…
kabragaurav Aug 14, 2026
b0f74ec
Address review: bounded retry on transient registration failure + end…
kabragaurav Aug 14, 2026
ce09cda
Register deferred listeners from a single hook covering every leaders…
kabragaurav Aug 14, 2026
82c7ecd
Keep _lastSeen* consistent with what actually registered (review: Pra…
kabragaurav Aug 14, 2026
5c1524f
Harden async registration: close the observation window + bound the w…
kabragaurav Aug 14, 2026
17034d2
Drop the redundant post-registration refresh (self-review)
kabragaurav Aug 14, 2026
7e1e201
Hygiene: extract runTasksInParallel helper; fix stale handleNewSessio…
kabragaurav Aug 14, 2026
36bfd59
Docs: make three comments accurate to what the code actually does (no…
kabragaurav Aug 14, 2026
1137b85
Fix import order: RejectedExecutionException after Future (checkstyle…
kabragaurav Aug 14, 2026
0b6ba52
Make initHandlers parallelism configurable via system property
kabragaurav Aug 17, 2026
d88bc4f
Contain blast radius, gate behind a flag, and fix reconnect regressio…
kabragaurav Aug 18, 2026
30be3cb
Rename pool-size flag into the feature namespace; harden test flag is…
kabragaurav Aug 18, 2026
1b630c5
Add thread-count cap and a spectator containment test [CICP-34606]
kabragaurav Aug 18, 2026
9f37007
Add real-ZK failover validation harness (server-side wchp watch count…
kabragaurav Aug 18, 2026
f7f6a02
Remove ticket refs from code/tests and drop test stdout noise
kabragaurav Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,15 @@ public class SystemPropertyKeys {

// Stage thread pool size for parallel stage execution
public static final String STAGE_THREAD_POOL_SIZE = "helix.stage.threadpool.size";

// Feature gate (default OFF) for the controller's deferred + parallel per-instance listener
// registration during leadership acquisition. When off, the controller registers
// per-instance listeners inline exactly as before, and no participant/spectator path is affected.
public static final String CONTROLLER_PARALLEL_INSTANCE_LISTENER_REGISTRATION_ENABLED =
"helix.controller.parallelInstanceListenerRegistration.enabled";

// Thread-pool size for the controller's parallel per-instance listener registration.
// Only used when the feature above is enabled; defaults to 10.
public static final String CONTROLLER_PARALLEL_INSTANCE_LISTENER_REGISTRATION_THREADS =
"helix.controller.parallelInstanceListenerRegistration.threads";
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import org.apache.helix.NotificationContext;
import org.apache.helix.PropertyKey;
import org.apache.helix.PropertyKey.Builder;
import org.apache.helix.SystemPropertyKeys;
import org.apache.helix.api.exceptions.HelixMetaDataAccessException;
import org.apache.helix.api.listeners.ClusterConfigChangeListener;
import org.apache.helix.api.listeners.ControllerChangeListener;
Expand Down Expand Up @@ -149,6 +150,19 @@ public class GenericHelixController implements IdealStateChangeListener, LiveIns
final AtomicReference<Map<String, LiveInstance>> _lastSeenInstances;
final AtomicReference<Map<String, LiveInstance>> _lastSeenSessions;

// Pending per-instance listener registrations collected during INIT for parallel registration.
// Maps session -> instanceName for current-state/task-current-state listeners,
// and a set of instance names for message/customized-state-root listeners.
private volatile PendingInstanceListeners _pendingInstanceListeners;

// Feature gate (default OFF) for the deferred + parallel per-instance listener registration
// during leadership acquisition. Read once here so a whole leadership epoch is
// decided consistently. When off, checkLiveInstancesObservation registers inline exactly as
// before (legacy), and the controller drain in ZKHelixManager never runs (_pendingInstanceListeners
// stays null). Flip on per deployment via the system property to ramp on a canary.
private final boolean _parallelInstanceRegistrationEnabled =
Boolean.getBoolean(SystemPropertyKeys.CONTROLLER_PARALLEL_INSTANCE_LISTENER_REGISTRATION_ENABLED);

// map that stores the mapping between instance and the customized state types available on that
//instance
final AtomicReference<Map<String, Set<String>>> _lastSeenCustomizedStateTypesMapRef;
Expand Down Expand Up @@ -1423,34 +1437,61 @@ protected void checkLiveInstancesObservation(List<LiveInstance> liveInstances,
}
}

for (String session : curSessions.keySet()) {
if (lastSessions == null || !lastSessions.containsKey(session)) {
String instanceName = curSessions.get(session).getInstanceName();
try {
// add current-state listeners for new sessions
manager.addCurrentStateChangeListener(this, instanceName, session);
manager.addTaskCurrentStateChangeListener(this, instanceName, session);
logger.info(manager.getInstanceName() + " added current-state listener for instance: "
+ instanceName + ", session: " + session + ", listener: " + this);
} catch (Exception e) {
logger.error("Fail to add current state listener for instance: " + instanceName
+ " with session: " + session, e);
boolean isInit = changeContext.getType() == NotificationContext.Type.INIT;

if (isInit && _parallelInstanceRegistrationEnabled) {
// Feature ON: during controller leadership acquisition, defer per-instance listener
// registration. These are registered in parallel by
// ZKHelixManager.registerDeferredInstanceListenersAsync(), triggered from
// DistributedLeaderElection.onControllerChange() for every leadership path (both new-session
// INIT and failover CALLBACK), after invoke() releases synchronized(_manager).
Map<String, String> sessionToInstance = new HashMap<>();
Set<String> newInstances = new HashSet<>();

for (String session : curSessions.keySet()) {
if (lastSessions == null || !lastSessions.containsKey(session)) {
sessionToInstance.put(session, curSessions.get(session).getInstanceName());
}
}
for (String instance : curInstances.keySet()) {
if (lastInstances == null || !lastInstances.containsKey(instance)) {
newInstances.add(instance);
}
}
_pendingInstanceListeners = new PendingInstanceListeners(sessionToInstance, newInstances);
logger.info("Deferred {} session listeners and {} instance listeners for parallel registration",
sessionToInstance.size(), newInstances.size());
} else {
// Legacy/inline path. Runs when the feature is OFF (any leadership INIT registers inline,
// exactly as before this change) OR for the incremental CALLBACK path (typically 0-1 new
// instances per event). On INIT with the flag off, lastSessions/lastInstances are null so
// every live instance is registered here inline via the unchanged addXxxListener path.
for (String session : curSessions.keySet()) {
if (lastSessions == null || !lastSessions.containsKey(session)) {
String instanceName = curSessions.get(session).getInstanceName();
try {
manager.addCurrentStateChangeListener(this, instanceName, session);
manager.addTaskCurrentStateChangeListener(this, instanceName, session);
logger.info(manager.getInstanceName() + " added current-state listener for instance: "
+ instanceName + ", session: " + session + ", listener: " + this);
} catch (Exception e) {
logger.error("Fail to add current state listener for instance: " + instanceName
+ " with session: " + session, e);
}
}
}
}

for (String instance : curInstances.keySet()) {
if (lastInstances == null || !lastInstances.containsKey(instance)) {
try {
// add message listeners for new instances
manager.addMessageListener(this, instance);
logger.info(manager.getInstanceName() + " added message listener for " + instance
+ ", listener: " + this);
} catch (Exception e) {
logger.error("Fail to add message listener for instance: " + instance, e);
for (String instance : curInstances.keySet()) {
if (lastInstances == null || !lastInstances.containsKey(instance)) {
try {
manager.addMessageListener(this, instance);
logger.info(manager.getInstanceName() + " added message listener for " + instance
+ ", listener: " + this);
} catch (Exception e) {
logger.error("Fail to add message listener for instance: " + instance, e);
}
}
}
}

for (String instance : curInstances.keySet()) {
if (lastInstances == null || !lastInstances.containsKey(instance)) {
Expand All @@ -1463,6 +1504,7 @@ protected void checkLiveInstancesObservation(List<LiveInstance> liveInstances,
"Fail to add root path listener for customized state change for instance: "
+ instance, e);
}
}
}
}

Expand Down Expand Up @@ -1667,4 +1709,75 @@ synchronized void closeRebalancer() {
}
}
}

/**
* Per-instance listener registrations deferred during initial controller setup.
* Collected by {@link #checkLiveInstancesObservation} during a leadership acquisition, then
* registered in parallel by
* {@code ZKHelixManager.registerDeferredInstanceListenersAsync}, which
* {@code DistributedLeaderElection.onControllerChange} triggers for every leadership path.
*/
public static class PendingInstanceListeners {
private final Map<String, String> _sessionToInstance;
private final Set<String> _newInstances;

public PendingInstanceListeners(Map<String, String> sessionToInstance, Set<String> newInstances) {
_sessionToInstance = sessionToInstance;
_newInstances = newInstances;
}

public Map<String, String> getSessionToInstance() {
return _sessionToInstance;
}

public Set<String> getNewInstances() {
return _newInstances;
}

public boolean isEmpty() {
return _sessionToInstance.isEmpty() && _newInstances.isEmpty();
}
}

public PendingInstanceListeners takePendingInstanceListeners() {
PendingInstanceListeners result = _pendingInstanceListeners;
_pendingInstanceListeners = null;
return result;
}

/**
* Drop a session from the last-seen set so its per-instance current-state and task-current-state
* listeners are re-registered on the next {@link #onLiveInstanceChange}. Called when the deferred
* parallel registration for this session ultimately failed, so {@code _lastSeenSessions} reflects
* only sessions whose listeners actually registered (not ones that were merely attempted).
* Uses the same {@code synchronized(_lastSeenInstances)} monitor as
* {@link #checkLiveInstancesObservation} to stay consistent with the diffing logic.
*/
public void forgetSessionForReregistration(String session) {
synchronized (_lastSeenInstances) {
Map<String, LiveInstance> sessions = _lastSeenSessions.get();
if (sessions != null && sessions.containsKey(session)) {
Map<String, LiveInstance> updated = new HashMap<>(sessions);
updated.remove(session);
_lastSeenSessions.set(updated);
}
}
}

/**
* Drop an instance from the last-seen set so its message and customized-state-root listeners are
* re-registered on the next {@link #onLiveInstanceChange}. Called when the deferred parallel
* registration for this instance ultimately failed, so {@code _lastSeenInstances} reflects only
* instances whose listeners actually registered.
*/
public void forgetInstanceForReregistration(String instance) {
synchronized (_lastSeenInstances) {
Map<String, LiveInstance> instances = _lastSeenInstances.get();
if (instances != null && instances.containsKey(instance)) {
Map<String, LiveInstance> updated = new HashMap<>(instances);
updated.remove(instance);
_lastSeenInstances.set(updated);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ public CallbackHandler(HelixManager manager, RealmAwareZkClient client, Property
public CallbackHandler(HelixManager manager, RealmAwareZkClient client, PropertyKey propertyKey,
Object listener, EventType[] eventTypes, ChangeType changeType,
HelixCallbackMonitor monitor) {
this(manager, client, propertyKey, listener, eventTypes, changeType, monitor, false);
}

CallbackHandler(HelixManager manager, RealmAwareZkClient client, PropertyKey propertyKey,
Object listener, EventType[] eventTypes, ChangeType changeType,
HelixCallbackMonitor monitor, boolean deferInit) {
if (listener == null) {
throw new HelixException("listener could not be null");
}
Expand All @@ -166,7 +172,6 @@ public CallbackHandler(HelixManager manager, RealmAwareZkClient client, Property

_uid = CALLBACK_HANDLER_UID.getAndIncrement();


_manager = manager;
_accessor = manager.getHelixDataAccessor();
_zkClient = client;
Expand All @@ -186,7 +191,9 @@ public CallbackHandler(HelixManager manager, RealmAwareZkClient client, Property

parseListenerProperties();

init();
if (!deferInit) {
init();
}
}

private void parseListenerProperties() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ public synchronized void onControllerChange(NotificationContext changeContext) {
case INIT:
case CALLBACK:
acquireLeadership(_manager, controllerHelper);
// Register the per-instance listeners that addListenersToController() deferred during this
// leadership acquisition. onControllerChange is the single ControllerChangeListener for
// every controller (standalone and distributed) and fires for both INIT (a fresh ZK
// session) and CALLBACK (a standby taking over on an existing session). Draining here,
// instead of only in ZKHelixManager.handleNewSession(), covers every path that makes us
// leader, including failover, where handleNewSession() is never called and the deferred
// list would otherwise be dropped (leaving no per-instance CURRENTSTATES watches, so
// MissingTopState never clears). Runs on a background thread because we currently hold
// synchronized(_manager) via CallbackHandler.invoke(); the worker needs that same monitor.
if (_manager instanceof ZKHelixManager) {
((ZKHelixManager) _manager).registerDeferredInstanceListenersAsync(_controller);
}
break;
case FINALIZE:
relinquishLeadership(_manager, controllerHelper);
Expand Down
Loading
Loading