This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/helix.git


The following commit(s) were added to refs/heads/master by this push:
     new 5e7d6d7f9 Add opt-in persistent watcher mode for Helix ZkClients 
(#2309)
5e7d6d7f9 is described below

commit 5e7d6d7f972080820742b3308abca3f6d30e2c6c
Author: Junkai Xue <[email protected]>
AuthorDate: Fri Nov 6 13:12:04 2026 -0800

    Add opt-in persistent watcher mode for Helix ZkClients (#2309)
    
    ZkClient has supported registering persistent ZooKeeper watches for its
    listeners since 2023 (usePersistWatcher), which removes the window in which 
a
    one-time watch is being re-armed and avoids the overwrite hazard that made
    one-time and persistent watches on the same path unsafe before ZooKeeper 3.9
    (ZOOKEEPER-4466). Until now the flag could only be set on the raw ZkClient
    constructor and only meta-client used it; HelixManager, HelixAdmin, 
helix-rest
    and every client built through RealmAwareZkClientConfig were stuck with
    one-time watches, which is the mode suspected of missing notifications in
    #2309.
    
    Turning the mode on for a whole cluster immediately exposed a bug in
    ZkClient that had been masked because only meta-client used the mode:
    addPersistListener/removePersistListener took an Object and decided by
    instanceof, testing IZkChildListener first. CallbackHandler implements both
    IZkChildListener and IZkDataListener, so in persist mode every data
    subscription a HelixManager made (ideal states, current states, configs, the
    leader node) was registered as a child subscription and no NodeDataChanged
    was ever delivered to it; a controller stopped reacting after the first
    current state write and the cluster never converged. The two methods are now
    typed overloads, so the subscribe call decides the listener kind.
    
    This change makes the mode configurable, default off:
    
    - RealmAwareZkClientConfig (and HelixZkClient.ZkClientConfig) gain
      setUsePersistWatcher/isUsePersistWatcher. The default comes from the new
      system property zk.zkclient.persistwatcher.enabled, so a whole process can
      be switched (e.g. a CI run of the test suite) without code changes.
    - DedicatedZkClientFactory, DedicatedZkClient, FederatedZkClient and
      SharedZkClientFactory.InnerSharedZkClient pass the flag to the ZkClient
      they build, using the same connectOnInit=true the shorter constructor 
used.
    - ZKHelixManager honors a RealmAwareZkClientConfig supplied through
      HelixManagerProperty as an explicit override of the system property and
      logs the mode it creates its client with.
    - ParticipantManager read the existing live instance with a one-time watch
      nobody listened for; a client in persist watcher mode refuses one-time
      watches, so it now uses readDataAndStat without a watch. That read also
      returns null instead of throwing when the live instance disappeared in the
      meantime, which is what the following retry logic always expected.
    
    Tests: TestZkClientPersistWatcher covers a listener implementing both
    interfaces. TestDedicatedZkClient, TestSharedZkClient and 
TestFederatedZkClient
    verify that a config with the flag reaches the built client (one-time 
watches
    refused, listeners notified through persistent watches).
    TestZKHelixManagerPersistWatcher runs a controller and two participants to
    convergence with the system property set, verifies their clients hold only
    persistent watches, and verifies a HelixManagerProperty config overrides the
    property. The ZkTestHelper.getZkWatch helpers now also report persistent
    watches.
    
    Enabling the mode for the full helix-core suite in CI and flipping the
    default are follow-ups; the test prints the cluster state when it does not
    converge so such runs are diagnosable.
---
 .../helix/manager/zk/ParticipantManager.java       |   4 +-
 .../apache/helix/manager/zk/ZKHelixManager.java    |   8 +
 .../test/java/org/apache/helix/ZkTestHelper.java   |  12 ++
 .../manager/TestZKHelixManagerPersistWatcher.java  | 188 +++++++++++++++++++++
 .../helix/zookeeper/api/client/HelixZkClient.java  |   6 +
 .../zookeeper/api/client/RealmAwareZkClient.java   |  21 ++-
 .../zookeeper/constant/ZkSystemPropertyKeys.java   |  15 ++
 .../zookeeper/impl/client/DedicatedZkClient.java   |   3 +-
 .../zookeeper/impl/client/FederatedZkClient.java   |   3 +-
 .../zookeeper/impl/client/SharedZkClient.java      |   1 +
 .../impl/factory/DedicatedZkClientFactory.java     |   3 +-
 .../impl/factory/SharedZkClientFactory.java        |   2 +-
 .../apache/helix/zookeeper/zkclient/ZkClient.java  |  88 +++++-----
 .../apache/helix/zookeeper/impl/ZkTestHelper.java  |  12 ++
 .../client/RealmAwareZkClientFactoryTestBase.java  |  57 +++++++
 .../impl/client/TestFederatedZkClient.java         |  45 +++++
 .../zkclient/TestZkClientPersistWatcher.java       |  66 ++++++++
 17 files changed, 490 insertions(+), 44 deletions(-)

diff --git 
a/helix-core/src/main/java/org/apache/helix/manager/zk/ParticipantManager.java 
b/helix-core/src/main/java/org/apache/helix/manager/zk/ParticipantManager.java
index 20910c891..8769f9854 100644
--- 
a/helix-core/src/main/java/org/apache/helix/manager/zk/ParticipantManager.java
+++ 
b/helix-core/src/main/java/org/apache/helix/manager/zk/ParticipantManager.java
@@ -312,7 +312,9 @@ public class ParticipantManager {
             _clusterName);
 
         Stat stat = new Stat();
-        ZNRecord record = _zkclient.readData(liveInstancePath, stat, true);
+        // No watch is needed here (nobody listens for it, and a client in 
persist-watcher mode
+        // rejects one-time watches); return null instead of throwing if the 
node is already gone.
+        ZNRecord record = _zkclient.readDataAndStat(liveInstancePath, stat, 
true);
         if (record == null) {
           /**
            * live-instance is gone as we check it, retry create live-instance
diff --git 
a/helix-core/src/main/java/org/apache/helix/manager/zk/ZKHelixManager.java 
b/helix-core/src/main/java/org/apache/helix/manager/zk/ZKHelixManager.java
index cf549de2d..f5df9c2c6 100644
--- a/helix-core/src/main/java/org/apache/helix/manager/zk/ZKHelixManager.java
+++ b/helix-core/src/main/java/org/apache/helix/manager/zk/ZKHelixManager.java
@@ -1535,6 +1535,14 @@ public class ZKHelixManager implements HelixManager, 
IZkStateListener {
         .setMonitorKey(_clusterName)
         .setMonitorInstanceName(_instanceName)
         .setMonitorRootPathOnly(isMonitorRootPathOnly());
+    // Persistent watches default to the zk.zkclient.persistwatcher.enabled 
system property; a
+    // RealmAwareZkClientConfig given through HelixManagerProperty overrides 
it.
+    if (_helixManagerProperty != null && 
_helixManagerProperty.getZkClientConfig() != null) {
+      clientConfig.setUsePersistWatcher(
+          _helixManagerProperty.getZkClientConfig().isUsePersistWatcher());
+    }
+    LOG.info("Creating ZkClient for {} {} in cluster {} with 
usePersistWatcher={}", _instanceType,
+        _instanceName, _clusterName, clientConfig.isUsePersistWatcher());
 
     if (_instanceType == InstanceType.ADMINISTRATOR) {
       return resolveZkClient(SharedZkClientFactory.getInstance(), 
_realmAwareZkConnectionConfig,
diff --git a/helix-core/src/test/java/org/apache/helix/ZkTestHelper.java 
b/helix-core/src/test/java/org/apache/helix/ZkTestHelper.java
index 3632ebfa1..fb73b53ad 100644
--- a/helix-core/src/test/java/org/apache/helix/ZkTestHelper.java
+++ b/helix-core/src/test/java/org/apache/helix/ZkTestHelper.java
@@ -417,9 +417,21 @@ public class ZkTestHelper {
     field2.setAccessible(true);
     Map<String, Set<Watcher>> childWatches = (Map<String, Set<Watcher>>) 
field2.get(watchManager);
 
+    field2 = getField(watchManager.getClass(), "persistentWatches");
+    field2.setAccessible(true);
+    Map<String, Set<Watcher>> persistentWatches =
+        (Map<String, Set<Watcher>>) field2.get(watchManager);
+
+    field2 = getField(watchManager.getClass(), "persistentRecursiveWatches");
+    field2.setAccessible(true);
+    Map<String, Set<Watcher>> persistentRecursiveWatches =
+        (Map<String, Set<Watcher>>) field2.get(watchManager);
+
     lists.put("dataWatches", new ArrayList<>(dataWatches.keySet()));
     lists.put("existWatches", new ArrayList<>(existWatches.keySet()));
     lists.put("childWatches", new ArrayList<>(childWatches.keySet()));
+    lists.put("persistentWatches", new 
ArrayList<>(persistentWatches.keySet()));
+    lists.put("persistentRecursiveWatches", new 
ArrayList<>(persistentRecursiveWatches.keySet()));
 
     return lists;
   }
diff --git 
a/helix-core/src/test/java/org/apache/helix/integration/manager/TestZKHelixManagerPersistWatcher.java
 
b/helix-core/src/test/java/org/apache/helix/integration/manager/TestZKHelixManagerPersistWatcher.java
new file mode 100644
index 000000000..ca7b7b99c
--- /dev/null
+++ 
b/helix-core/src/test/java/org/apache/helix/integration/manager/TestZKHelixManagerPersistWatcher.java
@@ -0,0 +1,188 @@
+package org.apache.helix.integration.manager;
+
+/*
+ * 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.
+ */
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.helix.HelixManagerProperty;
+import org.apache.helix.InstanceType;
+import org.apache.helix.PropertyPathBuilder;
+import org.apache.helix.TestHelper;
+import org.apache.helix.ZkTestHelper;
+import org.apache.helix.ZkUnitTestBase;
+import org.apache.helix.manager.zk.ZKHelixManager;
+import 
org.apache.helix.tools.ClusterVerifiers.BestPossibleExternalViewVerifier;
+import org.apache.helix.tools.ClusterVerifiers.ZkHelixClusterVerifier;
+import org.apache.helix.zookeeper.api.client.RealmAwareZkClient;
+import org.apache.helix.zookeeper.constant.ZkSystemPropertyKeys;
+import org.apache.zookeeper.data.Stat;
+import org.testng.Assert;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Runs a small cluster with the zk.zkclient.persistwatcher.enabled system 
property set, so that
+ * every HelixManager registers persistent ZooKeeper watches instead of 
one-time watches, and checks
+ * that a HelixManagerProperty client config can override the property per 
manager.
+ */
+public class TestZKHelixManagerPersistWatcher extends ZkUnitTestBase {
+  private static final int NUM_NODES = 2;
+  private static final int START_PORT = 12918;
+
+  private final String _clusterName = TestHelper.getTestClassName();
+  private final List<MockParticipantManager> _participants = new ArrayList<>();
+  private ClusterControllerManager _controller;
+  private String _originalPersistWatcherProperty;
+
+  @BeforeClass
+  public void beforeClass() throws Exception {
+    _originalPersistWatcherProperty =
+        System.getProperty(ZkSystemPropertyKeys.ZK_PERSIST_WATCHER_ENABLED);
+    System.setProperty(ZkSystemPropertyKeys.ZK_PERSIST_WATCHER_ENABLED, 
"true");
+
+    TestHelper.setupCluster(_clusterName, ZK_ADDR, START_PORT, "localhost", 
"TestDB", 1, 4,
+        NUM_NODES, 2, "MasterSlave", true);
+    _controller = new ClusterControllerManager(ZK_ADDR, _clusterName, 
"controller_0");
+    _controller.syncStart();
+    for (int i = 0; i < NUM_NODES; i++) {
+      MockParticipantManager participant =
+          new MockParticipantManager(ZK_ADDR, _clusterName, "localhost_" + 
(START_PORT + i));
+      participant.syncStart();
+      _participants.add(participant);
+    }
+  }
+
+  @AfterClass
+  public void afterClass() {
+    try {
+      for (MockParticipantManager participant : _participants) {
+        if (participant.isConnected()) {
+          participant.syncStop();
+        }
+      }
+      if (_controller != null && _controller.isConnected()) {
+        _controller.syncStop();
+      }
+      deleteCluster(_clusterName);
+    } finally {
+      if (_originalPersistWatcherProperty == null) {
+        System.clearProperty(ZkSystemPropertyKeys.ZK_PERSIST_WATCHER_ENABLED);
+      } else {
+        System.setProperty(ZkSystemPropertyKeys.ZK_PERSIST_WATCHER_ENABLED,
+            _originalPersistWatcherProperty);
+      }
+    }
+  }
+
+  @Test
+  public void testClusterConvergesWithPersistentWatches() throws Exception {
+    ZkHelixClusterVerifier verifier =
+        new 
BestPossibleExternalViewVerifier.Builder(_clusterName).setZkClient(_gZkClient)
+            
.setWaitTillVerify(TestHelper.DEFAULT_REBALANCE_PROCESSING_WAIT_TIME).build();
+    try {
+      boolean converged = verifier.verifyByPolling();
+      if (!converged) {
+        dumpClusterState();
+      }
+      Assert.assertTrue(converged, "Cluster did not converge in persist 
watcher mode");
+    } finally {
+      verifier.close();
+    }
+
+    // Every manager created while the property is set registers persistent 
watches only.
+    List<RealmAwareZkClient> clients = new ArrayList<>();
+    clients.add(_controller.getZkClient());
+    for (MockParticipantManager participant : _participants) {
+      clients.add(participant.getZkClient());
+    }
+    for (RealmAwareZkClient client : clients) {
+      Map<String, List<String>> watches = ZkTestHelper.getZkWatch(client);
+      Assert.assertFalse(watches.get("persistentWatches").isEmpty(),
+          "Expected persistent watches, got " + watches);
+      Assert.assertTrue(watches.get("dataWatches").isEmpty(), "Unexpected 
one-time watches " + watches);
+      Assert.assertTrue(watches.get("childWatches").isEmpty(), "Unexpected 
one-time watches " + watches);
+      Assert.assertTrue(watches.get("existWatches").isEmpty(), "Unexpected 
one-time watches " + watches);
+    }
+  }
+
+  @Test
+  public void testHelixManagerPropertyOverridesSystemProperty() throws 
Exception {
+    // The system property turns the mode on; an explicit client config turns 
it off for this manager.
+    HelixManagerProperty property = new 
HelixManagerProperty.Builder().setRealmAwareZkClientConfig(
+        new 
RealmAwareZkClient.RealmAwareZkClientConfig().setUsePersistWatcher(false)).build();
+    SpectatorWithProperty spectator = new SpectatorWithProperty(_clusterName, 
"spectator_override",
+        property);
+    spectator.connect();
+    try {
+      String path = PropertyPathBuilder.idealState(_clusterName, "TestDB0");
+      // A one-time watch is accepted, which a client in persist watcher mode 
refuses.
+      Assert.assertNotNull(spectator.getZkClient().readData(path, new Stat(), 
true),
+          "The ideal state should be readable");
+      Map<String, List<String>> watches = 
ZkTestHelper.getZkWatch(spectator.getZkClient());
+      Assert.assertTrue(watches.get("dataWatches").contains(path),
+          "Expected a one-time watch on " + path + ", got " + watches);
+      Assert.assertTrue(watches.get("persistentWatches").isEmpty(),
+          "Unexpected persistent watches " + watches);
+    } finally {
+      spectator.disconnect();
+    }
+  }
+
+  /** Prints what the controller and participants see so a non-converging run 
is diagnosable. */
+  private void dumpClusterState() {
+    System.out.println("=== Cluster state of " + _clusterName + " ===");
+    for (String path : new String[] { 
PropertyPathBuilder.liveInstance(_clusterName),
+        PropertyPathBuilder.idealState(_clusterName), 
PropertyPathBuilder.externalView(_clusterName),
+        PropertyPathBuilder.controller(_clusterName) }) {
+      dumpSubtree(path, 0);
+    }
+    for (String instance : 
_gZkClient.getChildren(PropertyPathBuilder.instance(_clusterName))) {
+      dumpSubtree(PropertyPathBuilder.instanceCurrentState(_clusterName, 
instance), 0);
+      dumpSubtree(PropertyPathBuilder.instanceMessage(_clusterName, instance), 
0);
+    }
+  }
+
+  private void dumpSubtree(String path, int depth) {
+    if (!_gZkClient.exists(path)) {
+      System.out.println(path + " (missing)");
+      return;
+    }
+    Object data = _gZkClient.readData(path, true);
+    System.out.println(path + (data == null ? "" : " -> " + data));
+    if (depth < 3) {
+      for (String child : _gZkClient.getChildren(path)) {
+        dumpSubtree(path + "/" + child, depth + 1);
+      }
+    }
+  }
+
+  private static class SpectatorWithProperty extends ZKHelixManager {
+    SpectatorWithProperty(String clusterName, String instanceName, 
HelixManagerProperty property) {
+      super(clusterName, instanceName, InstanceType.SPECTATOR, ZK_ADDR, null, 
property);
+    }
+
+    RealmAwareZkClient getZkClient() {
+      return _zkclient;
+    }
+  }
+}
diff --git 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/api/client/HelixZkClient.java
 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/api/client/HelixZkClient.java
index f3810a953..8d02d9ebd 100644
--- 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/api/client/HelixZkClient.java
+++ 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/api/client/HelixZkClient.java
@@ -153,5 +153,11 @@ public interface HelixZkClient extends RealmAwareZkClient {
       this._connectInitTimeout = connectInitTimeout;
       return this;
     }
+
+    @Override
+    public ZkClientConfig setUsePersistWatcher(boolean usePersistWatcher) {
+      this._usePersistWatcher = usePersistWatcher;
+      return this;
+    }
   }
 }
diff --git 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/api/client/RealmAwareZkClient.java
 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/api/client/RealmAwareZkClient.java
index 4e9d890c4..04aaecee9 100644
--- 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/api/client/RealmAwareZkClient.java
+++ 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/api/client/RealmAwareZkClient.java
@@ -26,6 +26,7 @@ import 
org.apache.helix.msdcommon.constant.MetadataStoreRoutingConstants;
 import org.apache.helix.msdcommon.datamodel.MetadataStoreRoutingData;
 import org.apache.helix.msdcommon.exception.InvalidRoutingDataException;
 import org.apache.helix.zookeeper.constant.RoutingDataReaderType;
+import org.apache.helix.zookeeper.constant.ZkSystemPropertyKeys;
 import org.apache.helix.zookeeper.routing.RoutingDataManager;
 import org.apache.helix.zookeeper.util.ZNRecordUtil;
 import org.apache.helix.zookeeper.zkclient.DataUpdater;
@@ -566,6 +567,9 @@ public interface RealmAwareZkClient {
     protected String _monitorKey;
     protected String _monitorInstanceName = null;
     protected boolean _monitorRootPathOnly = true;
+    // Watcher mode, see ZkSystemPropertyKeys#ZK_PERSIST_WATCHER_ENABLED for 
the default
+    protected boolean _usePersistWatcher =
+        Boolean.getBoolean(ZkSystemPropertyKeys.ZK_PERSIST_WATCHER_ENABLED);
 
     public RealmAwareZkClientConfig setZkSerializer(PathBasedZkSerializer 
zkSerializer) {
       this._zkSerializer = zkSerializer;
@@ -622,6 +626,17 @@ public interface RealmAwareZkClient {
       return this;
     }
 
+    /**
+     * Whether the client registers persistent ZooKeeper watches for its 
listeners instead of
+     * one-time watches that are re-armed after every notification. In this 
mode the client refuses
+     * explicit one-time watches such as {@code readData(path, stat, true)}. 
Defaults to the
+     * {@link ZkSystemPropertyKeys#ZK_PERSIST_WATCHER_ENABLED} system property.
+     */
+    public RealmAwareZkClientConfig setUsePersistWatcher(boolean 
usePersistWatcher) {
+      this._usePersistWatcher = usePersistWatcher;
+      return this;
+    }
+
     public PathBasedZkSerializer getZkSerializer() {
       if (_zkSerializer == null) {
         _zkSerializer = new BasicZkSerializer(new SerializableSerializer());
@@ -653,6 +668,10 @@ public interface RealmAwareZkClient {
       return _connectInitTimeout;
     }
 
+    public boolean isUsePersistWatcher() {
+      return _usePersistWatcher;
+    }
+
     /**
      * Create HelixZkClient.ZkClientConfig based on RealmAwareZkClientConfig.
      * @return
@@ -662,7 +681,7 @@ public interface RealmAwareZkClient {
           .setMonitorType(_monitorType).setMonitorKey(_monitorKey)
           
.setMonitorInstanceName(_monitorInstanceName).setMonitorRootPathOnly(_monitorRootPathOnly)
           .setOperationRetryTimeout(_operationRetryTimeout)
-          .setConnectInitTimeout(_connectInitTimeout);
+          
.setConnectInitTimeout(_connectInitTimeout).setUsePersistWatcher(_usePersistWatcher);
     }
   }
 
diff --git 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/constant/ZkSystemPropertyKeys.java
 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/constant/ZkSystemPropertyKeys.java
index 97e581665..f90abfce8 100644
--- 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/constant/ZkSystemPropertyKeys.java
+++ 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/constant/ZkSystemPropertyKeys.java
@@ -70,6 +70,21 @@ public class ZkSystemPropertyKeys {
   public static final String ZK_AUTOSYNC_ENABLED =
       "zk.zkclient.autosync.enabled";
 
+  /**
+   * Setting this property to {@code true} makes every ZkClient that is built 
from a
+   * {@code RealmAwareZkClientConfig} (HelixManager, HelixAdmin, helix-rest, 
...) register
+   * <b>persistent</b> ZooKeeper watches ({@code addWatch}) for its listeners 
instead of one-time
+   * watches that have to be re-armed after every notification. Persistent 
watches cannot miss a
+   * change between a notification and the re-registration of the watch. In 
this mode the client
+   * refuses explicit one-time watches such as {@code readData(path, stat, 
true)}.
+   * <p>
+   * An explicit {@code RealmAwareZkClientConfig#setUsePersistWatcher} 
overrides this property.
+   * <p>
+   * The default value is {@code false}. Requires a ZooKeeper server that 
supports persistent
+   * watches (3.6+; 3.9+ recommended, see ZOOKEEPER-4466 and ZOOKEEPER-4475).
+   */
+  public static final String ZK_PERSIST_WATCHER_ENABLED = 
"zk.zkclient.persistwatcher.enabled";
+
   /** System property key for jute.maxbuffer */
   public static final String JUTE_MAXBUFFER = "jute.maxbuffer";
 
diff --git 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/client/DedicatedZkClient.java
 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/client/DedicatedZkClient.java
index e21480629..d5ef42def 100644
--- 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/client/DedicatedZkClient.java
+++ 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/client/DedicatedZkClient.java
@@ -107,7 +107,8 @@ public class DedicatedZkClient implements 
RealmAwareZkClient {
     _rawZkClient = new ZkClient(zkConnection, (int) 
clientConfig.getConnectInitTimeout(),
         clientConfig.getOperationRetryTimeout(), 
clientConfig.getZkSerializer(),
         clientConfig.getMonitorType(), clientConfig.getMonitorKey(),
-        clientConfig.getMonitorInstanceName(), 
clientConfig.isMonitorRootPathOnly());
+        clientConfig.getMonitorInstanceName(), 
clientConfig.isMonitorRootPathOnly(), true,
+        clientConfig.isUsePersistWatcher());
   }
 
   @Override
diff --git 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/client/FederatedZkClient.java
 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/client/FederatedZkClient.java
index 7943bc27a..64f81ad8d 100644
--- 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/client/FederatedZkClient.java
+++ 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/client/FederatedZkClient.java
@@ -722,7 +722,8 @@ public class FederatedZkClient implements 
RealmAwareZkClient {
     return new ZkClient(new ZkConnection(zkAddress), (int) 
_clientConfig.getConnectInitTimeout(),
         _clientConfig.getOperationRetryTimeout(), _pathBasedZkSerializer,
         _clientConfig.getMonitorType(), _clientConfig.getMonitorKey(),
-        _clientConfig.getMonitorInstanceName(), 
_clientConfig.isMonitorRootPathOnly());
+        _clientConfig.getMonitorInstanceName(), 
_clientConfig.isMonitorRootPathOnly(), true,
+        _clientConfig.isUsePersistWatcher());
   }
 
   private void checkClosedState() {
diff --git 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/client/SharedZkClient.java
 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/client/SharedZkClient.java
index 210e82bd6..38285f5c8 100644
--- 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/client/SharedZkClient.java
+++ 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/client/SharedZkClient.java
@@ -102,6 +102,7 @@ public class SharedZkClient implements RealmAwareZkClient {
     zkClientConfig.setZkSerializer(clientConfig.getZkSerializer())
         .setConnectInitTimeout(clientConfig.getConnectInitTimeout())
         .setOperationRetryTimeout(clientConfig.getOperationRetryTimeout())
+        .setUsePersistWatcher(clientConfig.isUsePersistWatcher())
         .setMonitorInstanceName(clientConfig.getMonitorInstanceName())
         
.setMonitorKey(clientConfig.getMonitorKey()).setMonitorType(clientConfig.getMonitorType())
         .setMonitorRootPathOnly(clientConfig.isMonitorRootPathOnly());
diff --git 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/factory/DedicatedZkClientFactory.java
 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/factory/DedicatedZkClientFactory.java
index bbccd22e9..4d792de2f 100644
--- 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/factory/DedicatedZkClientFactory.java
+++ 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/factory/DedicatedZkClientFactory.java
@@ -62,6 +62,7 @@ public class DedicatedZkClientFactory extends 
HelixZkClientFactory {
     return new ZkClient(createZkConnection(connectionConfig),
         (int) clientConfig.getConnectInitTimeout(), 
clientConfig.getOperationRetryTimeout(),
         clientConfig.getZkSerializer(), clientConfig.getMonitorType(), 
clientConfig.getMonitorKey(),
-        clientConfig.getMonitorInstanceName(), 
clientConfig.isMonitorRootPathOnly());
+        clientConfig.getMonitorInstanceName(), 
clientConfig.isMonitorRootPathOnly(), true,
+        clientConfig.isUsePersistWatcher());
   }
 }
diff --git 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/factory/SharedZkClientFactory.java
 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/factory/SharedZkClientFactory.java
index 48d1bdee9..a35789c53 100644
--- 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/factory/SharedZkClientFactory.java
+++ 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/impl/factory/SharedZkClientFactory.java
@@ -159,7 +159,7 @@ public class SharedZkClientFactory extends 
HelixZkClientFactory {
       super(connectionManager.getConnection(), 0, 
clientConfig.getOperationRetryTimeout(),
           clientConfig.getZkSerializer(), clientConfig.getMonitorType(),
           clientConfig.getMonitorKey(), clientConfig.getMonitorInstanceName(),
-          clientConfig.isMonitorRootPathOnly());
+          clientConfig.isMonitorRootPathOnly(), true, 
clientConfig.isUsePersistWatcher());
       _connectionManager = connectionManager;
       // Register to the base dedicated RealmAwareZkClient
       _connectionManager.registerWatcher(this);
diff --git 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/zkclient/ZkClient.java 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/zkclient/ZkClient.java
index 50703a7e0..e7dbbe4bc 100644
--- 
a/zookeeper-api/src/main/java/org/apache/helix/zookeeper/zkclient/ZkClient.java
+++ 
b/zookeeper-api/src/main/java/org/apache/helix/zookeeper/zkclient/ZkClient.java
@@ -3403,49 +3403,61 @@ public class ZkClient implements Watcher {
     void run() throws KeeperException, InterruptedException;
   }
 
-  // Add a persist listener on the path.
-  // Throws UnsupportedOperationException if there is already a recursive 
persist listener on the
-  // path because it will overwrite that recursive persist listener.
-  private void addPersistListener(String path, Object listener) {
-    ManipulateListener addListeners = () -> {
-      if (_zkPathRecursiveWatcherTrie.hasListenerOnPath(path)) {
-        throw new UnsupportedOperationException(
-            "Can not subscribe PersistListener when there is an recursive 
listener on path: "
-                + path);
-      }
-      if (listener instanceof IZkChildListener) {
-        addChildListener(path, (IZkChildListener) listener);
-      } else if (listener instanceof IZkDataListener) {
-        addDataListener(path, (IZkDataListener) listener);
-      }
-    };
-    executeWithInPersistListenerMutex(addListeners);
+  /*
+   * Adds a persist child or data listener on the path. The two overloads 
exist on purpose: a
+   * listener may implement both IZkChildListener and IZkDataListener 
(CallbackHandler does), so the
+   * listener type has to come from the subscribe call that was made, not from 
instanceof checks.
+   * Throws UnsupportedOperationException if there is already a recursive 
persist listener on the
+   * path because it would overwrite that recursive persist listener.
+   */
+  private void addPersistListener(String path, IZkChildListener listener) {
+    executeWithInPersistListenerMutex(() -> {
+      checkNoRecursivePersistListener(path);
+      addChildListener(path, listener);
+    });
   }
 
+  private void addPersistListener(String path, IZkDataListener listener) {
+    executeWithInPersistListenerMutex(() -> {
+      checkNoRecursivePersistListener(path);
+      addDataListener(path, listener);
+    });
+  }
 
-  // TODO: Consider create an empty interface and let the two listeners 
interface extend that
-  // interface for code clean.
-  // This function removes persist child or data listener.
-  private void removePersistListener(String path, Object listener) {
+  private void checkNoRecursivePersistListener(String path) {
+    if (_zkPathRecursiveWatcherTrie.hasListenerOnPath(path)) {
+      throw new UnsupportedOperationException(
+          "Can not subscribe PersistListener when there is an recursive 
listener on path: " + path);
+    }
+  }
 
-    ManipulateListener removeListeners = () -> {
-      try {
-        if (listener instanceof IZkChildListener) {
-          removeChildListener(path, (IZkChildListener) listener);
-        } else if (listener instanceof IZkDataListener) {
-          removeDataListener(path, (IZkDataListener) listener);
-        }
-        if (!hasChildOrDataListeners(path)) {
-          // This will also remove persist recursive watcher on ZK. However, 
there should not be an
-          // persist recursive watcher installed in the first place.
-          getConnection().removeWatches(path, this, WatcherType.Any);
-        }
-      } catch (KeeperException.NoWatcherException e) {
-        LOG.warn("Persist watcher is already removed");
-      }
-    };
+  // Removes a persist child or data listener, and the persist watch once no 
listener is left.
+  private void removePersistListener(String path, IZkChildListener listener) {
+    executeWithInPersistListenerMutex(() -> {
+      removeChildListener(path, listener);
+      removePersistWatchIfUnused(path);
+    });
+  }
 
-    executeWithInPersistListenerMutex(removeListeners);
+  private void removePersistListener(String path, IZkDataListener listener) {
+    executeWithInPersistListenerMutex(() -> {
+      removeDataListener(path, listener);
+      removePersistWatchIfUnused(path);
+    });
+  }
+
+  private void removePersistWatchIfUnused(String path)
+      throws KeeperException, InterruptedException {
+    if (hasChildOrDataListeners(path)) {
+      return;
+    }
+    try {
+      // This will also remove persist recursive watcher on ZK. However, there 
should not be an
+      // persist recursive watcher installed in the first place.
+      getConnection().removeWatches(path, this, WatcherType.Any);
+    } catch (KeeperException.NoWatcherException e) {
+      LOG.warn("Persist watcher is already removed");
+    }
   }
 
   private void executeWithInPersistListenerMutex(ManipulateListener runnable) {
diff --git 
a/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/ZkTestHelper.java 
b/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/ZkTestHelper.java
index 5624c9eca..35622d55d 100644
--- 
a/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/ZkTestHelper.java
+++ 
b/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/ZkTestHelper.java
@@ -378,9 +378,21 @@ public class ZkTestHelper {
     field2.setAccessible(true);
     Map<String, Set<Watcher>> childWatches = (Map<String, Set<Watcher>>) 
field2.get(watchManager);
 
+    field2 = getField(watchManager.getClass(), "persistentWatches");
+    field2.setAccessible(true);
+    Map<String, Set<Watcher>> persistentWatches =
+        (Map<String, Set<Watcher>>) field2.get(watchManager);
+
+    field2 = getField(watchManager.getClass(), "persistentRecursiveWatches");
+    field2.setAccessible(true);
+    Map<String, Set<Watcher>> persistentRecursiveWatches =
+        (Map<String, Set<Watcher>>) field2.get(watchManager);
+
     lists.put("dataWatches", new ArrayList<>(dataWatches.keySet()));
     lists.put("existWatches", new ArrayList<>(existWatches.keySet()));
     lists.put("childWatches", new ArrayList<>(childWatches.keySet()));
+    lists.put("persistentWatches", new 
ArrayList<>(persistentWatches.keySet()));
+    lists.put("persistentRecursiveWatches", new 
ArrayList<>(persistentRecursiveWatches.keySet()));
 
     return lists;
   }
diff --git 
a/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/client/RealmAwareZkClientFactoryTestBase.java
 
b/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/client/RealmAwareZkClientFactoryTestBase.java
index bae31de40..88267ae17 100644
--- 
a/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/client/RealmAwareZkClientFactoryTestBase.java
+++ 
b/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/client/RealmAwareZkClientFactoryTestBase.java
@@ -21,12 +21,17 @@ package org.apache.helix.zookeeper.impl.client;
 
 import java.io.IOException;
 import java.util.NoSuchElementException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
 
 import org.apache.helix.msdcommon.exception.InvalidRoutingDataException;
 import org.apache.helix.zookeeper.api.client.RealmAwareZkClient;
 import org.apache.helix.zookeeper.datamodel.ZNRecord;
 import org.apache.helix.zookeeper.datamodel.serializer.ZNRecordSerializer;
+import org.apache.helix.zookeeper.impl.TestHelper;
+import org.apache.helix.zookeeper.zkclient.IZkDataListener;
 import org.apache.helix.zookeeper.zkclient.exception.ZkBadVersionException;
+import org.apache.zookeeper.data.Stat;
 import org.testng.Assert;
 import org.testng.annotations.AfterClass;
 import org.testng.annotations.BeforeClass;
@@ -110,6 +115,58 @@ public abstract class RealmAwareZkClientFactoryTestBase 
extends RealmAwareZkClie
     }
   }
 
+  /**
+   * The persist watcher mode configured through RealmAwareZkClientConfig must 
reach the ZkClient
+   * the factory builds: listeners are served by persistent watches and 
explicit one-time watches
+   * are refused.
+   */
+  @Test
+  public void testUsePersistWatcherConfig() throws Exception {
+    RealmAwareZkClient.RealmAwareZkConnectionConfig connectionConfig =
+        new RealmAwareZkClient.RealmAwareZkConnectionConfig.Builder()
+            .setZkRealmShardingKey(ZK_SHARDING_KEY_PREFIX).build();
+    RealmAwareZkClient.RealmAwareZkClientConfig clientConfig =
+        new 
RealmAwareZkClient.RealmAwareZkClientConfig().setUsePersistWatcher(true);
+    RealmAwareZkClient client =
+        _realmAwareZkClientFactory.buildZkClient(connectionConfig, 
clientConfig);
+    String path = ZK_SHARDING_KEY_PREFIX + "/" + 
TestHelper.getTestMethodName();
+    try {
+      client.setZkSerializer(new ZNRecordSerializer());
+      client.createPersistent(path, true);
+
+      // A one-time watch would take over the persistent watch on older 
servers, so the client
+      // refuses it in this mode.
+      try {
+        client.readData(path, new Stat(), true);
+        Assert.fail("One-time watches must be refused in persist watcher 
mode");
+      } catch (IllegalArgumentException expected) {
+        // expected
+      }
+
+      // Listeners are served by a persistent watch: every change is delivered 
without re-arming.
+      int changes = 5;
+      CountDownLatch latch = new CountDownLatch(changes);
+      client.subscribeDataChanges(path, new IZkDataListener() {
+        @Override
+        public void handleDataChange(String dataPath, Object data) {
+          latch.countDown();
+        }
+
+        @Override
+        public void handleDataDeleted(String dataPath) {
+        }
+      });
+      for (int i = 0; i < changes; i++) {
+        client.writeData(path, new ZNRecord("v" + i));
+      }
+      Assert.assertTrue(latch.await(10, TimeUnit.SECONDS),
+          "Expected " + changes + " data change notifications, " + 
latch.getCount() + " missing");
+    } finally {
+      client.deleteRecursively(path);
+      client.close();
+    }
+  }
+
   /**
    * Test creating a container node.
    */
diff --git 
a/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/client/TestFederatedZkClient.java
 
b/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/client/TestFederatedZkClient.java
index 3fea6a8bf..881715816 100644
--- 
a/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/client/TestFederatedZkClient.java
+++ 
b/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/client/TestFederatedZkClient.java
@@ -26,6 +26,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.NoSuchElementException;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 
 import org.apache.helix.msdcommon.constant.MetadataStoreRoutingConstants;
@@ -39,10 +40,12 @@ import org.apache.helix.zookeeper.constant.TestConstants;
 import org.apache.helix.zookeeper.datamodel.ZNRecord;
 import org.apache.helix.zookeeper.datamodel.serializer.ZNRecordSerializer;
 import org.apache.helix.zookeeper.routing.RoutingDataManager;
+import org.apache.helix.zookeeper.zkclient.IZkDataListener;
 import org.apache.helix.zookeeper.zkclient.IZkStateListener;
 import org.apache.helix.zookeeper.zkclient.exception.ZkBadVersionException;
 import org.apache.zookeeper.CreateMode;
 import org.apache.zookeeper.Watcher;
+import org.apache.zookeeper.data.Stat;
 import org.testng.Assert;
 import org.testng.annotations.AfterClass;
 import org.testng.annotations.BeforeClass;
@@ -76,6 +79,48 @@ public class TestFederatedZkClient extends 
RealmAwareZkClientTestBase {
     System.out.println("Ending " + 
TestFederatedZkClient.class.getSimpleName());
   }
 
+  /*
+   * The persist watcher mode configured through RealmAwareZkClientConfig must 
reach the per-realm
+   * ZkClients: listeners are served by persistent watches and explicit 
one-time watches are refused.
+   */
+  @Test
+  public void testUsePersistWatcherConfig() throws Exception {
+    FederatedZkClient client =
+        new FederatedZkClient(new 
RealmAwareZkClient.RealmAwareZkConnectionConfig.Builder().build(),
+            new 
RealmAwareZkClient.RealmAwareZkClientConfig().setUsePersistWatcher(true));
+    String path = TEST_REALM_ONE_VALID_PATH + "/persistWatcher";
+    try {
+      client.setZkSerializer(new ZNRecordSerializer());
+      client.createPersistent(path, true);
+      try {
+        client.readData(path, new Stat(), true);
+        Assert.fail("One-time watches must be refused in persist watcher 
mode");
+      } catch (IllegalArgumentException expected) {
+        // expected
+      }
+      int changes = 5;
+      CountDownLatch latch = new CountDownLatch(changes);
+      client.subscribeDataChanges(path, new IZkDataListener() {
+        @Override
+        public void handleDataChange(String dataPath, Object data) {
+          latch.countDown();
+        }
+
+        @Override
+        public void handleDataDeleted(String dataPath) {
+        }
+      });
+      for (int i = 0; i < changes; i++) {
+        client.writeData(path, new ZNRecord("v" + i));
+      }
+      Assert.assertTrue(latch.await(10, TimeUnit.SECONDS),
+          "Expected " + changes + " data change notifications, " + 
latch.getCount() + " missing");
+    } finally {
+      client.deleteRecursively(TEST_REALM_ONE_VALID_PATH);
+      client.close();
+    }
+  }
+
   /*
    * Tests that an unsupported operation should throw an 
UnsupportedOperationException.
    */
diff --git 
a/zookeeper-api/src/test/java/org/apache/helix/zookeeper/zkclient/TestZkClientPersistWatcher.java
 
b/zookeeper-api/src/test/java/org/apache/helix/zookeeper/zkclient/TestZkClientPersistWatcher.java
index c54bca1ef..6b7420628 100644
--- 
a/zookeeper-api/src/test/java/org/apache/helix/zookeeper/zkclient/TestZkClientPersistWatcher.java
+++ 
b/zookeeper-api/src/test/java/org/apache/helix/zookeeper/zkclient/TestZkClientPersistWatcher.java
@@ -24,6 +24,7 @@ import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
+import org.apache.helix.zookeeper.impl.TestHelper;
 import org.apache.helix.zookeeper.impl.ZkTestBase;
 import org.apache.helix.zookeeper.impl.client.ZkClient;
 import org.apache.helix.zookeeper.zkclient.serialize.BasicZkSerializer;
@@ -170,6 +171,71 @@ public class TestZkClientPersistWatcher extends ZkTestBase 
{
     zkClient.close();
   }
 
+  /*
+   * A listener that implements both IZkChildListener and IZkDataListener (as 
CallbackHandler does)
+   * must be registered for the kind of change it subscribed to. Registering 
it by instanceof made
+   * every data subscription a child subscription in persist mode, so data 
changes were never
+   * delivered.
+   */
+  @Test
+  void testListenerImplementingBothInterfaces() throws Exception {
+    org.apache.helix.zookeeper.impl.client.ZkClient zkClient =
+        new 
org.apache.helix.zookeeper.impl.client.ZkClient.Builder().setZkServer(ZkTestBase.ZK_ADDR)
+            .setMonitorRootPathOnly(false).setUsePersistWatcher(true).build();
+    zkClient.setZkSerializer(new BasicZkSerializer(new 
SerializableSerializer()));
+    String path = "/testListenerImplementingBothInterfaces";
+    zkClient.create(path, "data", CreateMode.PERSISTENT);
+    try {
+      class BothListener implements IZkChildListener, IZkDataListener {
+        final AtomicInteger _dataChanges = new AtomicInteger();
+        final AtomicInteger _childChanges = new AtomicInteger();
+
+        @Override
+        public void handleChildChange(String parentPath, List<String> 
currentChilds) {
+          _childChanges.incrementAndGet();
+        }
+
+        @Override
+        public void handleDataChange(String dataPath, Object data) {
+          _dataChanges.incrementAndGet();
+        }
+
+        @Override
+        public void handleDataDeleted(String dataPath) {
+        }
+      }
+      BothListener dataOnly = new BothListener();
+      BothListener childOnly = new BothListener();
+      zkClient.subscribeDataChanges(path, dataOnly);
+      zkClient.subscribeChildChanges(path, childOnly);
+
+      zkClient.writeData(path, "data1", -1);
+      zkClient.create(path + "/child", "c", CreateMode.PERSISTENT);
+
+      Assert.assertTrue(TestHelper.verify(() -> dataOnly._dataChanges.get() == 
1, 10_000L),
+          "The data listener should get the data change");
+      Assert.assertTrue(TestHelper.verify(() -> childOnly._childChanges.get() 
== 1, 10_000L),
+          "The child listener should get the child change");
+      Assert.assertEquals(dataOnly._childChanges.get(), 0,
+          "The data listener must not be registered as a child listener");
+      Assert.assertEquals(childOnly._dataChanges.get(), 0,
+          "The child listener must not be registered as a data listener");
+
+      // Unsubscribing one listener kind keeps the other one, and its watch, 
working.
+      zkClient.unsubscribeDataChanges(path, dataOnly);
+      zkClient.writeData(path, "data2", -1);
+      zkClient.create(path + "/child2", "c", CreateMode.PERSISTENT);
+      Assert.assertTrue(TestHelper.verify(() -> childOnly._childChanges.get() 
== 2, 10_000L),
+          "The child listener should still get child changes");
+      Assert.assertEquals(dataOnly._dataChanges.get(), 1,
+          "The unsubscribed data listener must not get further data changes");
+      zkClient.unsubscribeChildChanges(path, childOnly);
+    } finally {
+      zkClient.deleteRecursively(path);
+      zkClient.close();
+    }
+  }
+
   @Test
   void testSubscribeOneTimeChangeWhenUsingPersistWatcher() {
     org.apache.helix.zookeeper.impl.client.ZkClient.Builder builder =

Reply via email to