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 ab04ae11f Make sync ZK writes report their real outcome when the 
caller is interrupted (#3072, #2685)
ab04ae11f is described below

commit ab04ae11fe35619cc5a33021ae020392e082471b
Author: Junkai Xue <[email protected]>
AuthorDate: Thu Nov 5 14:49:38 2026 -0800

    Make sync ZK writes report their real outcome when the caller is 
interrupted (#3072, #2685)
    
    ZkBaseDataAccessor.set() could return false while the write had in fact been
    applied. ZooKeeper's synchronous API queues the request to its send thread
    before blocking the caller for the reply, so a thread interrupted during 
that
    wait gets InterruptedException even though the server still applies the
    request. ZkClient turned that into ZkInterruptedException and
    ZkBaseDataAccessor swallowed it into RetCode.ERROR, telling callers the 
write
    definitely did not happen when the outcome was actually unknown.
    
    Two fixes, one per layer:
    
    - ZkClient: the synchronous create/setData/delete/multi paths now submit
      through the ZooKeeper async API and wait on a latch. If the calling 
thread is
      interrupted while the request is in flight, ZkClient keeps waiting for the
      outcome ZooKeeper guarantees to deliver for every queued packet, either 
the
      server reply or CONNECTIONLOSS/SESSIONEXPIRED when the connection drops. 
The
      wait is bounded by the new zk.interrupted.write.wait.ms property (default:
      the session timeout; 0 restores the previous abort-immediately behavior) 
and
      a second interrupt aborts it, so ZKHelixManager.disconnect(), which
      re-interrupts its cleanup thread every second while the connection is 
down,
      is not held for the session timeout. The interrupt flag is restored before
      returning so the caller still observes it. Only if the outcome is still
      unknown when the wait ends is ZkInterruptedException thrown.
      deleteRecursively/deleteRecursivelyAtomic let that exception through 
instead
      of wrapping it into ZkClientException.
    
    - ZkBaseDataAccessor (and ZkCacheBaseDataAccessor.set on top of it):
      create/set/update/multiSet/remove/removeWithExpectedVersion propagate
      ZkInterruptedException instead of returning false, the same way
      ZkBadVersionException is already propagated. A false return value now 
always
      means the write was not applied; an exception means it was aborted with an
      unknown outcome and the caller should re-read. BaseDataAccessor javadoc
      updated accordingly.
    
    Tests: TestZkClientInFlightWrite unit-tests the wait itself (outcome
    preserved under a pre-set interrupt, definite failures still reported, wait
    disabled, bound elapsed, second interrupt aborts, property parsing) without 
a
    ZK server so the timing cases are deterministic. TestRawZkClient covers the
    end-to-end create/setData/delete outcome under a pre-set interrupt against a
    real server. TestZkBaseDataAccessor covers propagation from every sync
    operation including the create-on-missing-node fallbacks, remove()'s
    recursive-delete fallback, and the cache accessor.
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
---
 .../java/org/apache/helix/BaseDataAccessor.java    |  28 +++-
 .../helix/manager/zk/ZkBaseDataAccessor.java       |  23 ++-
 .../helix/manager/zk/ZkCacheBaseDataAccessor.java  |   4 +
 .../helix/manager/zk/TestZkBaseDataAccessor.java   |  72 ++++++++
 .../zookeeper/constant/ZkSystemPropertyKeys.java   |  18 ++
 .../apache/helix/zookeeper/zkclient/ZkClient.java  | 179 +++++++++++++++++---
 .../zookeeper/impl/client/TestRawZkClient.java     |  45 +++++
 .../zkclient/TestZkClientInFlightWrite.java        | 184 +++++++++++++++++++++
 8 files changed, 526 insertions(+), 27 deletions(-)

diff --git a/helix-core/src/main/java/org/apache/helix/BaseDataAccessor.java 
b/helix-core/src/main/java/org/apache/helix/BaseDataAccessor.java
index 63245b544..fc1f31aed 100644
--- a/helix-core/src/main/java/org/apache/helix/BaseDataAccessor.java
+++ b/helix-core/src/main/java/org/apache/helix/BaseDataAccessor.java
@@ -41,7 +41,9 @@ public interface BaseDataAccessor<T> {
    * @param path path to the ZNode to create
    * @param record the data to write to the ZNode
    * @param options Set the type of ZNode see the valid values in {@link 
AccessOption}
-   * @return true if creation succeeded, false otherwise (e.g. if the ZNode 
exists)
+   * @return true if creation succeeded, false otherwise (e.g. if the ZNode 
exists).
+   *         If the calling thread is interrupted while the operation is in 
flight, the
+   *         implementation throws instead of returning false, because the 
outcome is unknown.
    */
   boolean create(String path, T record, int options);
 
@@ -53,7 +55,9 @@ public interface BaseDataAccessor<T> {
    * @param record the data to write to the ZNode
    * @param options Set the type of ZNode see the valid values in {@link 
AccessOption}
    * @param ttl TTL of the node in milliseconds, if options supports it
-   * @return true if creation succeeded, false otherwise (e.g. if the ZNode 
exists)
+   * @return true if creation succeeded, false otherwise (e.g. if the ZNode 
exists).
+   *         If the calling thread is interrupted while the operation is in 
flight, the
+   *         implementation throws instead of returning false, because the 
outcome is unknown.
    */
   default boolean create(String path, T record, int options, long ttl) {
     throw new UnsupportedOperationException("create with TTL support is not 
implemented.");
@@ -65,7 +69,9 @@ public interface BaseDataAccessor<T> {
    * @param path path to the ZNode to set
    * @param record the data to write to the ZNode
    * @param options Set the type of ZNode see the valid values in {@link 
AccessOption}
-   * @return true if data was successfully set, false otherwise
+   * @return true if data was successfully set, false otherwise.
+   *         If the calling thread is interrupted while the operation is in 
flight, the
+   *         implementation throws instead of returning false, because the 
outcome is unknown.
    */
   boolean set(String path, T record, int options);
 
@@ -78,7 +84,9 @@ public interface BaseDataAccessor<T> {
    * @param options Set the type of ZNode see the valid values in {@link 
AccessOption}
    * @param expectVersion the expected version of the data to be overwritten, 
-1 means match any
    *          version
-   * @return true if data was successfully set, false otherwise (e.g. if the 
version mismatches)
+   * @return true if data was successfully set, false otherwise (e.g. if the 
version mismatches).
+   *         If the calling thread is interrupted while the operation is in 
flight, the
+   *         implementation throws instead of returning false, because the 
outcome is unknown.
    */
   boolean set(String path, T record, int expectVersion, int options);
 
@@ -89,7 +97,9 @@ public interface BaseDataAccessor<T> {
    * @param path path to the ZNode to update
    * @param updater an update routine for the data to merge in
    * @param options Set the type of ZNode see the valid values in {@link 
AccessOption}
-   * @return true if data update succeeded, false otherwise
+   * @return true if data update succeeded, false otherwise.
+   *         If the calling thread is interrupted while the operation is in 
flight, the
+   *         implementation throws instead of returning false, because the 
outcome is unknown.
    */
   boolean update(String path, DataUpdater<T> updater, int options);
 
@@ -108,7 +118,9 @@ public interface BaseDataAccessor<T> {
    * This will remove the ZNode and all its descendants if any
    * @param path path to the root ZNode to remove
    * @param options Set the type of ZNode see the valid values in {@link 
AccessOption}
-   * @return true if the removal succeeded, false otherwise
+   * @return true if the removal succeeded, false otherwise.
+   *         If the calling thread is interrupted while the operation is in 
flight, the
+   *         implementation throws instead of returning false, because the 
outcome is unknown.
    */
   boolean remove(String path, int options);
 
@@ -119,7 +131,9 @@ public interface BaseDataAccessor<T> {
    * @param options Set the type of ZNode see the valid values in {@link 
AccessOption}
    * @param expectedVersion the expected version of the node to be removed, -1 
means match any
    * version
-   * @return true if the removal succeeded, false otherwise
+   * @return true if the removal succeeded, false otherwise.
+   *         If the calling thread is interrupted while the operation is in 
flight, the
+   *         implementation throws instead of returning false, because the 
outcome is unknown.
    */
   default boolean removeWithExpectedVersion(String path, int options, int 
expectedVersion) {
     throw new NotImplementedException("removeWithExpectedVersion is not 
implemented");
diff --git 
a/helix-core/src/main/java/org/apache/helix/manager/zk/ZkBaseDataAccessor.java 
b/helix-core/src/main/java/org/apache/helix/manager/zk/ZkBaseDataAccessor.java
index 06ea97fb9..63a5d95d2 100644
--- 
a/helix-core/src/main/java/org/apache/helix/manager/zk/ZkBaseDataAccessor.java
+++ 
b/helix-core/src/main/java/org/apache/helix/manager/zk/ZkBaseDataAccessor.java
@@ -51,6 +51,7 @@ import org.apache.helix.zookeeper.zkclient.ZkClient;
 import org.apache.helix.zookeeper.zkclient.callback.ZkAsyncCallbacks;
 import org.apache.helix.zookeeper.zkclient.exception.ZkBadVersionException;
 import org.apache.helix.zookeeper.zkclient.exception.ZkException;
+import org.apache.helix.zookeeper.zkclient.exception.ZkInterruptedException;
 import org.apache.helix.zookeeper.zkclient.exception.ZkNoNodeException;
 import org.apache.helix.zookeeper.zkclient.exception.ZkNodeExistsException;
 import org.apache.helix.zookeeper.zkclient.serialize.PathBasedZkSerializer;
@@ -311,6 +312,8 @@ public class ZkBaseDataAccessor<T> implements 
BaseDataAccessor<T> {
             // if parent node created/exists, retry
             retry = true;
           }
+        } catch (ZkInterruptedException e1) {
+          throw e1;
         } catch (Exception e1) {
           LOG.error("Exception while creating path: " + parentPath, e1);
           result._retCode = RetCode.ERROR;
@@ -320,6 +323,12 @@ public class ZkBaseDataAccessor<T> implements 
BaseDataAccessor<T> {
         LOG.warn("Node already exists. path: " + path);
         result._retCode = RetCode.NODE_EXISTS;
         return result;
+      } catch (ZkInterruptedException e) {
+        // The calling thread was interrupted while the request was in flight, 
so the outcome is
+        // unknown: ZooKeeper may still have applied it. Returning ERROR would 
tell the caller the
+        // write definitely did not happen, which it cannot know. Propagate 
instead so that a
+        // false return value always means "not applied". Same for the other 
sync operations.
+        throw e;
       } catch (Exception e) {
         LOG.error("Exception while creating path: " + path, e);
         result._retCode = RetCode.ERROR;
@@ -391,12 +400,14 @@ public class ZkBaseDataAccessor<T> implements 
BaseDataAccessor<T> {
               result._retCode = RetCode.ERROR;
               return result;
           }
+        } catch (ZkInterruptedException e1) {
+          throw e1;
         } catch (Exception e1) {
           LOG.error("Exception while setting path by creating: " + path, e1);
           result._retCode = RetCode.ERROR;
           return result;
         }
-      } catch (ZkBadVersionException e) {
+      } catch (ZkBadVersionException | ZkInterruptedException e) {
         LOG.debug("Exception while setting path: " + path, e);
         throw e;
       } catch (Exception e) {
@@ -471,11 +482,15 @@ public class ZkBaseDataAccessor<T> implements 
BaseDataAccessor<T> {
               result._retCode = RetCode.ERROR;
               return result;
           }
+        } catch (ZkInterruptedException e1) {
+          throw e1;
         } catch (Exception e1) {
           LOG.error("Exception while updating path by creating: " + path, e1);
           result._retCode = RetCode.ERROR;
           return result;
         }
+      } catch (ZkInterruptedException e) {
+        throw e;
       } catch (Exception e) {
         LOG.error("Exception while updating path: " + path, e);
         result._retCode = RetCode.ERROR;
@@ -524,6 +539,8 @@ public class ZkBaseDataAccessor<T> implements 
BaseDataAccessor<T> {
         _zkClient.multi(ops);
       } catch (ZkBadVersionException e) {
         retry = true;
+      } catch (ZkInterruptedException e) {
+        throw e;
       } catch (Exception e1) {
         LOG.error("Exception while updating paths: " + updaterByPath.keySet(), 
e1);
         result._retCode = RetCode.ERROR;
@@ -775,6 +792,8 @@ public class ZkBaseDataAccessor<T> implements 
BaseDataAccessor<T> {
       // despite real error, operation will throw exception when path not 
empty, and in this
       // case, we try to delete recursively
       _zkClient.delete(path);
+    } catch (ZkInterruptedException e) {
+      throw e;
     } catch (ZkException e) {
       LOG.debug("Failed to delete {} with opts {}, err: {}. Try recursive 
delete", path, options,
           e.getMessage());
@@ -801,6 +820,8 @@ public class ZkBaseDataAccessor<T> implements 
BaseDataAccessor<T> {
       // despite real error, operation will throw exception when path not 
empty, and in this
       // case, we do NOT try to delete recursively
       _zkClient.delete(path, expectedVersion);
+    } catch (ZkInterruptedException e) {
+      throw e;
     } catch (ZkException zkException) {
       LOG.error("Failed to delete {} with opts {} and expected version {}.", 
path, options,
           expectedVersion, zkException);
diff --git 
a/helix-core/src/main/java/org/apache/helix/manager/zk/ZkCacheBaseDataAccessor.java
 
b/helix-core/src/main/java/org/apache/helix/manager/zk/ZkCacheBaseDataAccessor.java
index 2be1a4f76..9691c365d 100644
--- 
a/helix-core/src/main/java/org/apache/helix/manager/zk/ZkCacheBaseDataAccessor.java
+++ 
b/helix-core/src/main/java/org/apache/helix/manager/zk/ZkCacheBaseDataAccessor.java
@@ -40,6 +40,7 @@ import org.apache.helix.zookeeper.zkclient.IZkChildListener;
 import org.apache.helix.zookeeper.zkclient.IZkDataListener;
 import org.apache.helix.zookeeper.zkclient.ZkClient;
 import org.apache.helix.zookeeper.zkclient.callback.ZkAsyncCallbacks;
+import org.apache.helix.zookeeper.zkclient.exception.ZkInterruptedException;
 import org.apache.helix.zookeeper.zkclient.exception.ZkNoNodeException;
 import org.apache.helix.zookeeper.zkclient.serialize.ZkSerializer;
 import org.apache.zookeeper.KeeperException.Code;
@@ -278,6 +279,9 @@ public class ZkCacheBaseDataAccessor<T> implements 
HelixPropertyStore<T> {
         // no cache
         success = _baseAccessor.set(serverPath, data, expectVersion, options);
       }
+    } catch (ZkInterruptedException e) {
+      // Outcome unknown, see ZkBaseDataAccessor#doSet. Returning false would 
misreport it.
+      throw e;
     } catch (Exception e) {
     } finally {
       if (cache != null) {
diff --git 
a/helix-core/src/test/java/org/apache/helix/manager/zk/TestZkBaseDataAccessor.java
 
b/helix-core/src/test/java/org/apache/helix/manager/zk/TestZkBaseDataAccessor.java
index 56337f547..fe7c9d390 100644
--- 
a/helix-core/src/test/java/org/apache/helix/manager/zk/TestZkBaseDataAccessor.java
+++ 
b/helix-core/src/test/java/org/apache/helix/manager/zk/TestZkBaseDataAccessor.java
@@ -21,7 +21,9 @@ package org.apache.helix.manager.zk;
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -42,9 +44,13 @@ import 
org.apache.helix.zookeeper.exception.ZkClientException;
 import org.apache.helix.zookeeper.zkclient.DataUpdater;
 import org.apache.helix.zookeeper.zkclient.exception.ZkBadVersionException;
 import org.apache.helix.zookeeper.zkclient.exception.ZkException;
+import org.apache.helix.zookeeper.zkclient.exception.ZkInterruptedException;
+import org.apache.helix.zookeeper.zkclient.exception.ZkNoNodeException;
 import org.apache.helix.zookeeper.zkclient.exception.ZkMarshallingError;
 import org.apache.helix.zookeeper.zkclient.serialize.ZkSerializer;
+import org.apache.zookeeper.CreateMode;
 import org.apache.zookeeper.data.Stat;
+import org.mockito.ArgumentMatchers;
 import org.mockito.Mockito;
 import org.testng.Assert;
 import org.testng.annotations.AfterMethod;
@@ -912,4 +918,70 @@ public class TestZkBaseDataAccessor extends ZkUnitTestBase 
{
         + new Date(System.currentTimeMillis()));
   }
 
+  /*
+   * An interrupt while a request is in flight leaves the outcome unknown 
(ZooKeeper may still
+   * apply it), so the sync operations must propagate it rather than report a 
definite failure.
+   */
+  @Test
+  public void testSyncOperationsPropagateInterrupt() {
+    RealmAwareZkClient zkClient = Mockito.mock(RealmAwareZkClient.class);
+    ZkInterruptedException interrupted = new ZkInterruptedException(new 
InterruptedException());
+    // The constructor above re-asserts the interrupt on the current thread; 
clear it.
+    Thread.interrupted();
+    
Mockito.doThrow(interrupted).when(zkClient).create(ArgumentMatchers.anyString(),
+        ArgumentMatchers.any(), ArgumentMatchers.any(CreateMode.class), 
ArgumentMatchers.anyLong());
+    
Mockito.doThrow(interrupted).when(zkClient).writeDataGetStat(ArgumentMatchers.anyString(),
+        ArgumentMatchers.any(), ArgumentMatchers.anyInt());
+    Mockito.doThrow(interrupted).when(zkClient)
+        .readData(ArgumentMatchers.anyString(), 
ArgumentMatchers.any(Stat.class));
+    
Mockito.doThrow(interrupted).when(zkClient).delete(ArgumentMatchers.anyString());
+    Mockito.doThrow(interrupted).when(zkClient)
+        .delete(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt());
+    Mockito.doThrow(interrupted).when(zkClient).multi(ArgumentMatchers.any());
+
+    ZkBaseDataAccessor<ZNRecord> accessor = new ZkBaseDataAccessor<>(zkClient);
+    String path = "/" + _rootPath + "/interrupted";
+    ZNRecord record = new ZNRecord("interrupted");
+    DataUpdater<ZNRecord> updater = current -> record;
+
+    assertThrowsInterrupted(() -> accessor.create(path, record, 
AccessOption.PERSISTENT));
+    assertThrowsInterrupted(() -> accessor.set(path, record, 
AccessOption.PERSISTENT));
+    assertThrowsInterrupted(() -> accessor.set(path, record, 3, 
AccessOption.PERSISTENT));
+    assertThrowsInterrupted(() -> accessor.update(path, updater, 
AccessOption.PERSISTENT));
+    assertThrowsInterrupted(() -> accessor.remove(path, 
AccessOption.PERSISTENT));
+    assertThrowsInterrupted(
+        () -> accessor.removeWithExpectedVersion(path, 
AccessOption.PERSISTENT, 0));
+    Map<String, DataUpdater<ZNRecord>> updaters = new HashMap<>();
+    updaters.put(path, updater);
+    Mockito.doReturn(record).when(zkClient)
+        .readData(ArgumentMatchers.anyString(), 
ArgumentMatchers.any(Stat.class));
+    assertThrowsInterrupted(() -> accessor.multiSet(updaters));
+
+    // The interrupt must also surface from the create-on-missing-node 
fallback of set/update.
+    Mockito.doThrow(new ZkNoNodeException()).when(zkClient).writeDataGetStat(
+        ArgumentMatchers.anyString(), ArgumentMatchers.any(), 
ArgumentMatchers.anyInt());
+    Mockito.doThrow(new ZkNoNodeException()).when(zkClient)
+        .readData(ArgumentMatchers.anyString(), 
ArgumentMatchers.any(Stat.class));
+    assertThrowsInterrupted(() -> accessor.set(path, record, 
AccessOption.PERSISTENT));
+    assertThrowsInterrupted(() -> accessor.update(path, updater, 
AccessOption.PERSISTENT));
+
+    // ... and from remove()'s recursive-delete fallback for a non-empty node.
+    Mockito.doThrow(new ZkException("not 
empty")).when(zkClient).delete(ArgumentMatchers.anyString());
+    
Mockito.doThrow(interrupted).when(zkClient).deleteRecursively(ArgumentMatchers.anyString());
+    assertThrowsInterrupted(() -> accessor.remove(path, 
AccessOption.PERSISTENT));
+
+    // The cache accessor on top must not swallow it either (property store 
users).
+    ZkCacheBaseDataAccessor<ZNRecord> cacheAccessor =
+        new ZkCacheBaseDataAccessor<>(accessor, Collections.emptyList());
+    assertThrowsInterrupted(() -> cacheAccessor.set(path, record, 
AccessOption.PERSISTENT));
+  }
+
+  private static void assertThrowsInterrupted(Runnable operation) {
+    try {
+      operation.run();
+      Assert.fail("Expected ZkInterruptedException");
+    } catch (ZkInterruptedException expected) {
+      // expected
+    }
+  }
 }
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 3190acb52..97e581665 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
@@ -100,4 +100,22 @@ public class ZkSystemPropertyKeys {
    */
   public static final String ZK_OPERATION_RETRY_TIMEOUT_MS =
       "zk.operation.retry.timeout.ms";
+
+  /**
+   * This property bounds, in milliseconds, how long a synchronous ZkClient 
write (create, setData,
+   * delete) keeps waiting for the outcome of a request that was already 
handed to ZooKeeper when
+   * the calling thread got interrupted.
+   * <p>
+   * ZooKeeper queues a request to its send thread before blocking the caller 
for the reply, so an
+   * interrupt during that wait does not stop the server from applying the 
request. Instead of
+   * reporting such a write as failed, ZkClient waits up to this long for the 
real outcome, which
+   * ZooKeeper guarantees to deliver either as the server reply or as a 
connection loss, and then
+   * restores the thread's interrupt flag. A second interrupt during the wait 
aborts it. Only if
+   * the outcome is still unknown when the wait ends is {@code 
ZkInterruptedException} thrown.
+   * <p>
+   * A value of 0 disables the wait and aborts immediately with an unknown 
outcome (the behavior
+   * prior to this property). A negative or unset value uses the ZooKeeper 
session timeout, which
+   * is the longest the client can take to notice a broken connection.
+   */
+  public static final String ZK_INTERRUPTED_WRITE_WAIT_MS = 
"zk.interrupted.write.wait.ms";
 }
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 3d46983ac..0aec48e8f 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
@@ -35,6 +35,7 @@ import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.locks.ReentrantLock;
@@ -71,6 +72,7 @@ import 
org.apache.helix.zookeeper.zkclient.serialize.ZkSerializer;
 import org.apache.helix.zookeeper.zkclient.util.ExponentialBackoffStrategy;
 import org.apache.helix.zookeeper.zkclient.util.ZkPathRecursiveWatcherTrie;
 import org.apache.zookeeper.AddWatchMode;
+import org.apache.zookeeper.AsyncCallback;
 import org.apache.zookeeper.CreateMode;
 import org.apache.zookeeper.KeeperException;
 import org.apache.zookeeper.KeeperException.ConnectionLossException;
@@ -151,6 +153,9 @@ public class ZkClient implements Watcher {
   // TODO PVo remove this later
   private Thread _zookeeperEventThread;
   private volatile boolean _closed;
+  // How long a sync write waits for its in-flight request after the caller is 
interrupted.
+  // Negative means "use the session timeout". See 
ZkSystemPropertyKeys.ZK_INTERRUPTED_WRITE_WAIT_MS.
+  private final long _interruptedWriteWaitMs;
   private PathBasedZkSerializer _pathBasedZkSerializer;
   private ZkClientMonitor _monitor;
   // When _usePersistWatcher is true, ZKClient will register itself as persist 
watcher to Zk Server.
@@ -254,6 +259,7 @@ public class ZkClient implements Watcher {
     _connection = zkConnection;
     _pathBasedZkSerializer = zkSerializer;
     _operationRetryTimeoutInMillis = operationRetryTimeout;
+    _interruptedWriteWaitMs = readInterruptedWriteWaitMs();
     _isNewSessionEventFired = false;
 
     _asyncCallRetryThread = new ZkAsyncRetryThread(zkConnection.getServers());
@@ -1124,11 +1130,17 @@ public class ZkClient implements Watcher {
 
       final String actualPath;
       if (mode.isTTL()) {
-        actualPath = retryUntilConnected(() -> 
getExpectedZookeeper(expectedSessionId)
-            .create(path, dataBytes, acl, mode, null, ttl));
+        actualPath = retryUntilConnected(
+            () -> this.<String>syncWrite(path, 
getExpectedZookeeper(expectedSessionId),
+                (zk, write) -> zk.create(path, dataBytes, acl, mode,
+                    (AsyncCallback.Create2Callback) (rc, p, ctx, name, stat) ->
+                        write.complete(rc, name), null, ttl)));
       } else {
-        actualPath = retryUntilConnected(() -> 
getExpectedZookeeper(expectedSessionId)
-            .create(path, dataBytes, acl, mode));
+        actualPath = retryUntilConnected(
+            () -> this.<String>syncWrite(path, 
getExpectedZookeeper(expectedSessionId),
+                (zk, write) -> zk.create(path, dataBytes, acl, mode,
+                    (AsyncCallback.StringCallback) (rc, p, ctx, name) -> 
write.complete(rc, name),
+                    null)));
       }
 
       record(path, dataBytes, startT, ZkClientMonitor.AccessType.WRITE);
@@ -1831,6 +1843,9 @@ public class ZkClient implements Watcher {
     // if there is any other failure case.
     try {
       delete(path);
+    } catch (ZkInterruptedException e) {
+      // Outcome unknown (see awaitInFlightWrite); do not report it as a plain 
failure.
+      throw e;
     } catch (Exception e) {
       LOG.error("zkclient {}, Failed to delete {}, exception {}", _uid, path, 
e);
       throw new ZkClientException("Failed to delete " + path, e);
@@ -1865,6 +1880,9 @@ public class ZkClient implements Watcher {
 
     try {
       opResults = multi(ops);
+    } catch (ZkInterruptedException e) {
+      // Outcome unknown (see awaitInFlightWrite); do not report it as a plain 
failure.
+      throw e;
     } catch (Exception e) {
       LOG.error("zkclient {}, Failed to delete paths {}, exception {}", _uid, 
paths, e);
       throw new ZkClientException("Failed to delete paths " + paths, e);
@@ -2130,6 +2148,122 @@ public class ZkClient implements Watcher {
     }
   }
 
+  /**
+   * Outcome holder for a write submitted through the ZooKeeper async API. 
Used by the synchronous
+   * write paths so that an interrupt of the calling thread does not lose the 
outcome of a request
+   * ZooKeeper has already queued. See {@link #awaitInFlightWrite(String, 
InFlightWrite, long)}.
+   */
+  @VisibleForTesting
+  static class InFlightWrite<T> {
+    private final CountDownLatch _done = new CountDownLatch(1);
+    volatile int _rc = KeeperException.Code.APIERROR.intValue();
+    private volatile T _result;
+
+    void complete(int rc, T result) {
+      _rc = rc;
+      _result = result;
+      _done.countDown();
+    }
+
+    /** The exception to throw for a non-OK result code. Mirrors ZooKeeper's 
synchronous API. */
+    KeeperException failure(String path) {
+      return KeeperException.create(KeeperException.Code.get(_rc), path);
+    }
+  }
+
+  /** Issues one ZooKeeper write through the async API, completing the given 
holder. */
+  @FunctionalInterface
+  private interface WriteSubmitter<T> {
+    void submit(ZooKeeper zk, InFlightWrite<T> write);
+  }
+
+  /**
+   * Runs a synchronous write (create, setData, delete, multi) by submitting 
it through the
+   * ZooKeeper async API and waiting for the outcome, so that an interrupt of 
the calling thread
+   * does not lose the result of a request ZooKeeper has already queued. Meant 
to be called inside
+   * {@link #retryUntilConnected(Callable)}, which handles the retryable error 
codes.
+   */
+  private <T> T syncWrite(final String path, final ZooKeeper zk, final 
WriteSubmitter<T> submitter)
+      throws KeeperException, InterruptedException {
+    return syncWrite(path, zk, new InFlightWrite<>(), submitter);
+  }
+
+  private <T> T syncWrite(final String path, final ZooKeeper zk, final 
InFlightWrite<T> write,
+      final WriteSubmitter<T> submitter) throws KeeperException, 
InterruptedException {
+    submitter.submit(zk, write);
+    long interruptedWaitMs =
+        _interruptedWriteWaitMs < 0 ? zk.getSessionTimeout() : 
_interruptedWriteWaitMs;
+    return awaitInFlightWrite(path, write, interruptedWaitMs);
+  }
+
+  private ZooKeeper currentZookeeper() {
+    return ((ZkConnection) getConnection()).getZookeeper();
+  }
+
+  /**
+   * Waits for a write that has already been submitted to ZooKeeper and 
returns its result, or
+   * throws the KeeperException matching its error code.
+   * <p>
+   * ZooKeeper's synchronous API queues the request to the send thread before 
blocking for the
+   * reply, so an interrupt during that wait surfaces as InterruptedException 
even though the
+   * server still applies the request. The synchronous write paths therefore 
submit through the
+   * async API and come here: if the calling thread is interrupted while 
waiting, we keep waiting
+   * up to {@code interruptedWaitMs} ({@link 
ZkSystemPropertyKeys#ZK_INTERRUPTED_WRITE_WAIT_MS},
+   * defaulting to the session timeout) because every queued packet is 
guaranteed to be completed,
+   * either with the server reply or with CONNECTIONLOSS / SESSIONEXPIRED once 
the connection
+   * drops. A second interrupt during that wait aborts it, so a caller that 
really wants out (such
+   * as ZKHelixManager#disconnect re-interrupting its cleanup thread while the 
connection is down)
+   * is not held for the session timeout. The interrupt flag is restored 
before returning so
+   * callers still observe the interrupt; only if the outcome is still unknown 
when the wait ends
+   * is the InterruptedException propagated.
+   */
+  @VisibleForTesting
+  static <T> T awaitInFlightWrite(final String path, final InFlightWrite<T> 
write,
+      final long interruptedWaitMs) throws KeeperException, 
InterruptedException {
+    try {
+      write._done.await();
+    } catch (InterruptedException e) {
+      boolean done = awaitOutcomeAfterInterrupt(write._done, 
interruptedWaitMs);
+      // Preserve the interrupt for the caller whether we report the outcome 
or give up.
+      Thread.currentThread().interrupt();
+      if (!done) {
+        LOG.warn("Caller interrupted while a write to {} was in flight; giving 
up after at most {}"
+            + " ms with the outcome unknown", path, interruptedWaitMs);
+        throw e;
+      }
+      LOG.info("Caller interrupted while a write to {} was in flight; waited 
for its outcome: {}",
+          path, KeeperException.Code.get(write._rc));
+    }
+    if (write._rc != KeeperException.Code.OK.intValue()) {
+      throw write.failure(path);
+    }
+    return write._result;
+  }
+
+  private static boolean awaitOutcomeAfterInterrupt(CountDownLatch done, long 
waitMs) {
+    try {
+      return done.await(waitMs, TimeUnit.MILLISECONDS);
+    } catch (InterruptedException e) {
+      // Interrupted again: the caller wants out now. Report the outcome only 
if it already arrived.
+      return done.getCount() == 0;
+    }
+  }
+
+  @VisibleForTesting
+  static long readInterruptedWriteWaitMs() {
+    String value = 
System.getProperty(ZkSystemPropertyKeys.ZK_INTERRUPTED_WRITE_WAIT_MS);
+    if (value == null || value.trim().isEmpty()) {
+      return -1;
+    }
+    try {
+      return Long.parseLong(value.trim());
+    } catch (NumberFormatException e) {
+      LOG.warn("Invalid value {} for {}, falling back to the session timeout", 
value,
+          ZkSystemPropertyKeys.ZK_INTERRUPTED_WRITE_WAIT_MS);
+      return -1;
+    }
+  }
+
   /**
    * @param <T>
    * @param callable
@@ -2276,14 +2410,9 @@ public class ZkClient implements Watcher {
     boolean success;
     try {
       try {
-        retryUntilConnected(new Callable<Object>() {
-
-          @Override
-          public Object call() throws Exception {
-            getConnection().delete(path, expectedVersion);
-            return null;
-          }
-        });
+        retryUntilConnected(() -> this.<Void>syncWrite(path, 
currentZookeeper(),
+            (zk, write) -> zk.delete(path, expectedVersion,
+                (AsyncCallback.VoidCallback) (rc, p, ctx) -> 
write.complete(rc, null), null)));
         success = true;
       } catch (ZkNoNodeException e) {
         success = false;
@@ -2436,8 +2565,9 @@ public class ZkClient implements Watcher {
     try {
       final byte[] data = serialize(datat, path);
       checkDataSizeLimit(path, data);
-      final Stat stat = (Stat) retryUntilConnected(
-          (Callable<Object>) () -> getConnection().writeDataReturnStat(path, 
data, expectedVersion));
+      final Stat stat = retryUntilConnected(() -> this.<Stat>syncWrite(path, 
currentZookeeper(),
+          (zk, write) -> zk.setData(path, data, expectedVersion,
+              (AsyncCallback.StatCallback) (rc, p, ctx, s) -> 
write.complete(rc, s), null)));
       record(path, data, startT, ZkClientMonitor.AccessType.WRITE);
       return stat;
     } catch (Exception e) {
@@ -2941,12 +3071,23 @@ public class ZkClient implements Watcher {
       throw new NullPointerException("ops must not be null.");
     }
 
-    return retryUntilConnected(new Callable<List<OpResult>>() {
+    if (!ops.iterator().hasNext()) {
+      // Nothing to do; ZooKeeper's synchronous multi also returns early for 
an empty transaction.
+      return Collections.emptyList();
+    }
 
-      @Override
-      public List<OpResult> call() throws Exception {
-        return getConnection().multi(ops);
-      }
+    return retryUntilConnected(() -> {
+      // Mirror ZooKeeper's synchronous multi, whose failure exception carries 
no path. (It also
+      // attaches the per-op results, but KeeperException#setMultiResults is 
package-private and
+      // nothing in Helix reads them.)
+      InFlightWrite<List<OpResult>> write = new 
InFlightWrite<List<OpResult>>() {
+        @Override
+        KeeperException failure(String path) {
+          return KeeperException.create(KeeperException.Code.get(_rc));
+        }
+      };
+      return syncWrite(null, currentZookeeper(), write, (zk, w) -> 
zk.multi(ops,
+          (AsyncCallback.MultiCallback) (rc, p, ctx, results) -> 
w.complete(rc, results), null));
     });
   }
 
diff --git 
a/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/client/TestRawZkClient.java
 
b/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/client/TestRawZkClient.java
index ed25303c0..948cdd88a 100644
--- 
a/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/client/TestRawZkClient.java
+++ 
b/zookeeper-api/src/test/java/org/apache/helix/zookeeper/impl/client/TestRawZkClient.java
@@ -1341,4 +1341,49 @@ public class TestRawZkClient extends ZkTestBase {
     Assert.assertFalse(_zkClient.exists(grandParent));
     Assert.assertFalse(_zkClient.exists(newNode));
   }
+
+  /*
+   * ZooKeeper's synchronous API hands a request to the send thread before 
blocking for the reply,
+   * so a thread interrupted during that wait used to get 
ZkInterruptedException even though the
+   * server still applied the write. ZkClient now waits out the in-flight 
request and reports its
+   * real outcome, with the interrupt flag left set for the caller to observe.
+   */
+  @Test
+  public void testSyncWriteOutcomePreservedWhenThreadInterrupted() {
+    String path = TEST_ROOT + "/interruptedWrite";
+    _zkClient.createPersistent(TEST_ROOT, true);
+    _zkClient.delete(path);
+    try {
+      Thread.currentThread().interrupt();
+      _zkClient.createPersistent(path, "v1");
+      Assert.assertTrue(Thread.interrupted(), "Interrupt flag must survive 
create");
+      Assert.assertEquals(_zkClient.readData(path), "v1");
+
+      Thread.currentThread().interrupt();
+      Stat stat = _zkClient.writeDataGetStat(path, "v2", -1);
+      Assert.assertTrue(Thread.interrupted(), "Interrupt flag must survive 
setData");
+      Assert.assertNotNull(stat);
+      Assert.assertEquals(stat.getVersion(), 1);
+      Assert.assertEquals(_zkClient.readData(path), "v2");
+
+      Thread.currentThread().interrupt();
+      Assert.assertTrue(_zkClient.delete(path));
+      Assert.assertTrue(Thread.interrupted(), "Interrupt flag must survive 
delete");
+      Assert.assertFalse(_zkClient.exists(path));
+
+      // A definite failure is still reported as that failure, not as an 
interrupt.
+      Thread.currentThread().interrupt();
+      try {
+        _zkClient.writeData(path, "v3");
+        Assert.fail("Expected ZkNoNodeException");
+      } catch (ZkNoNodeException expected) {
+        // expected
+      }
+      Assert.assertTrue(Thread.interrupted(), "Interrupt flag must survive a 
failed setData");
+    } finally {
+      // Never leak an interrupt into the next test.
+      Thread.interrupted();
+      _zkClient.delete(path);
+    }
+  }
 }
diff --git 
a/zookeeper-api/src/test/java/org/apache/helix/zookeeper/zkclient/TestZkClientInFlightWrite.java
 
b/zookeeper-api/src/test/java/org/apache/helix/zookeeper/zkclient/TestZkClientInFlightWrite.java
new file mode 100644
index 000000000..e1143490a
--- /dev/null
+++ 
b/zookeeper-api/src/test/java/org/apache/helix/zookeeper/zkclient/TestZkClientInFlightWrite.java
@@ -0,0 +1,184 @@
+package org.apache.helix.zookeeper.zkclient;
+
+/*
+ * 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.concurrent.TimeUnit;
+
+import org.apache.helix.zookeeper.constant.ZkSystemPropertyKeys;
+import org.apache.helix.zookeeper.zkclient.ZkClient.InFlightWrite;
+import org.apache.zookeeper.KeeperException;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Unit tests for the wait that the synchronous ZkClient writes perform when 
the calling thread is
+ * interrupted while the request is already queued to ZooKeeper. The outcome 
is driven by hand
+ * here so the timing-sensitive cases are deterministic; the end-to-end 
behavior against a real
+ * server is covered in TestRawZkClient.
+ */
+public class TestZkClientInFlightWrite {
+  private static final String PATH = "/in-flight";
+  private static final long WAIT_MS = TimeUnit.SECONDS.toMillis(30);
+
+  @BeforeClass
+  public void loadZkClient() {
+    // Initialize ZkClient (and its logger) before any test interrupts the 
thread: logging setup
+    // on an interrupted thread fails and would poison the class for the rest 
of the run.
+    ZkClient.readInterruptedWriteWaitMs();
+  }
+
+  @AfterMethod
+  public void clearInterrupt() {
+    Thread.interrupted();
+  }
+
+  @Test
+  public void testOutcomeReturnedWithoutInterrupt() throws Exception {
+    InFlightWrite<String> write = new InFlightWrite<>();
+    write.complete(KeeperException.Code.OK.intValue(), "result");
+    Assert.assertEquals(ZkClient.awaitInFlightWrite(PATH, write, WAIT_MS), 
"result");
+    Assert.assertFalse(Thread.currentThread().isInterrupted());
+  }
+
+  @Test
+  public void testInterruptedCallerStillGetsSuccess() throws Exception {
+    InFlightWrite<String> write = new InFlightWrite<>();
+    completeLater(write, KeeperException.Code.OK, "result");
+    Thread.currentThread().interrupt();
+    Assert.assertEquals(ZkClient.awaitInFlightWrite(PATH, write, WAIT_MS), 
"result");
+    Assert.assertTrue(Thread.interrupted(), "Interrupt flag must be 
preserved");
+  }
+
+  @Test
+  public void testInterruptedCallerStillGetsDefiniteFailure() {
+    InFlightWrite<String> write = new InFlightWrite<>();
+    completeLater(write, KeeperException.Code.NONODE, null);
+    Thread.currentThread().interrupt();
+    try {
+      ZkClient.awaitInFlightWrite(PATH, write, WAIT_MS);
+      Assert.fail("Expected NoNodeException");
+    } catch (KeeperException.NoNodeException expected) {
+      Assert.assertEquals(expected.getPath(), PATH);
+    } catch (Exception e) {
+      Assert.fail("Expected NoNodeException", e);
+    }
+    Assert.assertTrue(Thread.interrupted(), "Interrupt flag must be 
preserved");
+  }
+
+  @Test
+  public void testInterruptedCallerGivesUpWhenWaitDisabled() {
+    InFlightWrite<String> write = new InFlightWrite<>();
+    Thread.currentThread().interrupt();
+    try {
+      ZkClient.awaitInFlightWrite(PATH, write, 0);
+      Assert.fail("Expected InterruptedException");
+    } catch (InterruptedException expected) {
+      // expected
+    } catch (Exception e) {
+      Assert.fail("Expected InterruptedException", e);
+    }
+    Assert.assertTrue(Thread.interrupted(), "Interrupt flag must be 
preserved");
+  }
+
+  @Test
+  public void testInterruptedCallerGivesUpWhenOutcomeNeverArrives() {
+    InFlightWrite<String> write = new InFlightWrite<>();
+    Thread.currentThread().interrupt();
+    long start = System.nanoTime();
+    try {
+      ZkClient.awaitInFlightWrite(PATH, write, 200);
+      Assert.fail("Expected InterruptedException");
+    } catch (InterruptedException expected) {
+      // expected
+    } catch (Exception e) {
+      Assert.fail("Expected InterruptedException", e);
+    }
+    Assert.assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) 
>= 200,
+        "Must wait out the configured bound before giving up");
+    Assert.assertTrue(Thread.interrupted(), "Interrupt flag must be 
preserved");
+  }
+
+  @Test
+  public void testSecondInterruptAbortsTheWait() {
+    InFlightWrite<String> write = new InFlightWrite<>();
+    Thread caller = Thread.currentThread();
+    Thread interrupter = new Thread(() -> {
+      sleepQuietly(200);
+      caller.interrupt();
+    });
+    interrupter.start();
+    Thread.currentThread().interrupt();
+    long start = System.nanoTime();
+    try {
+      ZkClient.awaitInFlightWrite(PATH, write, WAIT_MS);
+      Assert.fail("Expected InterruptedException");
+    } catch (InterruptedException expected) {
+      // expected
+    } catch (Exception e) {
+      Assert.fail("Expected InterruptedException", e);
+    }
+    Assert.assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) 
< WAIT_MS / 2,
+        "A second interrupt must abort the wait well before the bound");
+    Assert.assertTrue(Thread.interrupted(), "Interrupt flag must be 
preserved");
+  }
+
+  @Test
+  public void testReadInterruptedWriteWaitMs() {
+    String original = 
System.getProperty(ZkSystemPropertyKeys.ZK_INTERRUPTED_WRITE_WAIT_MS);
+    try {
+      System.clearProperty(ZkSystemPropertyKeys.ZK_INTERRUPTED_WRITE_WAIT_MS);
+      Assert.assertEquals(ZkClient.readInterruptedWriteWaitMs(), -1);
+      System.setProperty(ZkSystemPropertyKeys.ZK_INTERRUPTED_WRITE_WAIT_MS, " 
");
+      Assert.assertEquals(ZkClient.readInterruptedWriteWaitMs(), -1);
+      System.setProperty(ZkSystemPropertyKeys.ZK_INTERRUPTED_WRITE_WAIT_MS, 
"not-a-number");
+      Assert.assertEquals(ZkClient.readInterruptedWriteWaitMs(), -1);
+      System.setProperty(ZkSystemPropertyKeys.ZK_INTERRUPTED_WRITE_WAIT_MS, 
"0");
+      Assert.assertEquals(ZkClient.readInterruptedWriteWaitMs(), 0);
+      System.setProperty(ZkSystemPropertyKeys.ZK_INTERRUPTED_WRITE_WAIT_MS, " 
1500 ");
+      Assert.assertEquals(ZkClient.readInterruptedWriteWaitMs(), 1500);
+    } finally {
+      if (original == null) {
+        
System.clearProperty(ZkSystemPropertyKeys.ZK_INTERRUPTED_WRITE_WAIT_MS);
+      } else {
+        System.setProperty(ZkSystemPropertyKeys.ZK_INTERRUPTED_WRITE_WAIT_MS, 
original);
+      }
+    }
+  }
+
+  private static <T> void completeLater(InFlightWrite<T> write, 
KeeperException.Code code,
+      T result) {
+    Thread completer = new Thread(() -> {
+      sleepQuietly(200);
+      write.complete(code.intValue(), result);
+    });
+    completer.setDaemon(true);
+    completer.start();
+  }
+
+  private static void sleepQuietly(long ms) {
+    try {
+      Thread.sleep(ms);
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+    }
+  }
+}

Reply via email to