This is an automated email from the ASF dual-hosted git repository.
imbajin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hugegraph.git
The following commit(s) were added to refs/heads/master by this push:
new 398a5edc9 fix(core): self-heal etcd meta watch on transport
reconnect (#3062)
398a5edc9 is described below
commit 398a5edc953b6381d9b73001eacbac8e71e38d7e
Author: Davide Polato <[email protected]>
AuthorDate: Tue Jun 23 13:43:04 2026 +0200
fix(core): self-heal etcd meta watch on transport reconnect (#3062)
EtcdMetaDriver.listen/listenPrefix handed jetcd a bare
Consumer<WatchResponse>,
so a terminal watch error (e.g. after a transport reconnect) was swallowed
and
the JVM-global schema-cache-clear listener died silently: a node stopped
receiving cross-node cache-clear events with no error or warning.
Switch to the Watch.Listener overload and re-subscribe on
onError/onCompleted
via a daemon-backed backoff, mirroring the self-heal PdMetaDriver already
gets
from KvClient. The driver watch now stays live across reconnects, so
CachedSchemaTransactionV2's register-once flag staying true is correct; the
unused resetMetaListenerForReconnect stopgap and its TODO are removed.
---
.../backend/cache/CachedSchemaTransactionV2.java | 29 +---
.../org/apache/hugegraph/meta/EtcdMetaDriver.java | 91 +++++++++++-
.../apache/hugegraph/meta/EtcdMetaDriverTest.java | 152 +++++++++++++++++++++
.../org/apache/hugegraph/unit/UnitTestSuite.java | 2 +
4 files changed, 244 insertions(+), 30 deletions(-)
diff --git
a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/cache/CachedSchemaTransactionV2.java
b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/cache/CachedSchemaTransactionV2.java
index d6fbe9796..b6c47e705 100644
---
a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/cache/CachedSchemaTransactionV2.java
+++
b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/cache/CachedSchemaTransactionV2.java
@@ -52,10 +52,10 @@ public class CachedSchemaTransactionV2 extends
SchemaTransactionV2 {
// MetaDriver doesn't expose unlisten, register the meta listener once.
// Lifecycle: this JVM-global flag is intentionally never reset by
- // unlistenChanges() (the underlying gRPC watch is process-wide). If that
- // watch is silently dropped after a transport reconnect, recovery is not
- // automatic; resetMetaListenerForReconnect() is only a manual hook to let
- // the next schema operation install a fresh watch.
+ // unlistenChanges() (the underlying gRPC watch is process-wide). The
driver
+ // watch self-heals across transport reconnects (PdMetaDriver via KvClient,
+ // EtcdMetaDriver via Watch.Listener re-subscribe), so the subscription
stays
+ // live and the flag staying true is correct.
private static final AtomicBoolean metaEventListenerRegistered =
new AtomicBoolean(false);
@@ -251,27 +251,6 @@ public class CachedSchemaTransactionV2 extends
SchemaTransactionV2 {
}
}
- /**
- * Manually reset the JVM-global meta listener flag after detecting that
- * the MetaManager transport reconnected and dropped the underlying gRPC
- * watch. This method is not wired to a MetaManager/MetaDriver reconnect
- * callback today; callers must invoke it explicitly after detecting that
- * condition. Without such a manual reset {@link
#metaEventListenerRegistered}
- * would stay {@code true} forever and this JVM would stop receiving
- * cross-node schema cache clear events with no error or warning.
- *
- * <p>TODO: wire this into MetaManager once it exposes a transport
- * reconnect callback (e.g. {@code listenReconnect} /
- * {@code onTransportReconnect}). Until then it must be invoked
- * explicitly by code that detects the reconnect.
- */
- public static void resetMetaListenerForReconnect() {
- if (metaEventListenerRegistered.compareAndSet(true, false)) {
- LOG.warn("Schema cache clear meta listener lost on reconnect - " +
- "will re-register on next schema operation.");
- }
- }
-
public void clearCache(boolean notify) {
// Same TOCTOU ordering as clearSchemaCache(String): clear nameCache
// first, then the array attachment, then idCache last.
diff --git
a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/meta/EtcdMetaDriver.java
b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/meta/EtcdMetaDriver.java
index 8c9600e6b..75042131c 100644
---
a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/meta/EtcdMetaDriver.java
+++
b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/meta/EtcdMetaDriver.java
@@ -25,6 +25,9 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.apache.commons.io.FileUtils;
@@ -33,7 +36,9 @@ import org.apache.hugegraph.meta.lock.EtcdDistributedLock;
import org.apache.hugegraph.meta.lock.LockResult;
import org.apache.hugegraph.type.define.CollectionType;
import org.apache.hugegraph.util.E;
+import org.apache.hugegraph.util.Log;
import org.apache.hugegraph.util.collection.CollectionFactory;
+import org.slf4j.Logger;
import com.google.common.base.Strings;
@@ -42,6 +47,7 @@ import io.etcd.jetcd.Client;
import io.etcd.jetcd.ClientBuilder;
import io.etcd.jetcd.KV;
import io.etcd.jetcd.KeyValue;
+import io.etcd.jetcd.Watch;
import io.etcd.jetcd.kv.GetResponse;
import io.etcd.jetcd.lease.LeaseKeepAliveResponse;
import io.etcd.jetcd.options.DeleteOption;
@@ -57,9 +63,24 @@ import io.netty.handler.ssl.SslProvider;
public class EtcdMetaDriver implements MetaDriver {
+ private static final Logger LOG = Log.logger(EtcdMetaDriver.class);
+
private final Client client;
private final EtcdDistributedLock lock;
+ // Re-subscribes a dropped watch off the jetcd callback thread; single
+ // daemon thread, process-lifetime (no close()), so JVM shutdown reclaims
it.
+ private final ScheduledExecutorService reWatchExecutor =
+ Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread thread = new Thread(r, "etcd-meta-rewatch");
+ thread.setDaemon(true);
+ return thread;
+ });
+
+ // Backoff before re-subscribing a dropped watch. Package-private and
+ // mutable only so tests can set it to 0; never reassigned in production.
+ long reWatchDelayMs = 1000L;
+
public EtcdMetaDriver(String trustFile, String clientCertFile,
String clientKeyFile, Object... endpoints) {
ClientBuilder builder = this.etcdMetaDriverBuilder(endpoints);
@@ -76,6 +97,13 @@ public class EtcdMetaDriver implements MetaDriver {
this.lock = EtcdDistributedLock.getInstance(this.client);
}
+ // Package-private constructor for tests: inject a mock Client and skip
lock
+ // setup (watch tests never touch the distributed lock).
+ EtcdMetaDriver(Client client) {
+ this.client = client;
+ this.lock = null;
+ }
+
private static ByteSequence toByteSequence(String content) {
return ByteSequence.from(content.getBytes());
}
@@ -303,9 +331,8 @@ public class EtcdMetaDriver implements MetaDriver {
@SuppressWarnings("unchecked")
@Override
public <T> void listen(String key, Consumer<T> consumer) {
-
- this.client.getWatchClient().watch(toByteSequence(key),
- (Consumer<WatchResponse>) consumer);
+ this.watchKey(toByteSequence(key), WatchOption.DEFAULT,
+ (Consumer<WatchResponse>) consumer);
}
/**
@@ -314,9 +341,63 @@ public class EtcdMetaDriver implements MetaDriver {
@SuppressWarnings("unchecked")
@Override
public <T> void listenPrefix(String prefix, Consumer<T> consumer) {
- ByteSequence sequence = toByteSequence(prefix);
WatchOption option = WatchOption.newBuilder().isPrefix(true).build();
- this.client.getWatchClient().watch(sequence, option,
(Consumer<WatchResponse>) consumer);
+ this.watchKey(toByteSequence(prefix), option,
+ (Consumer<WatchResponse>) consumer);
+ }
+
+ /**
+ * Subscribe a watch that survives the terminal close jetcd cannot recover
+ * from. The bare {@code Consumer} overload discards {@code onError} and
+ * {@code onCompleted}, so a non-retryable failure silently drops the
+ * listener (issue #3036).
+ * <p>
+ * jetcd 0.5.9 ({@code WatchImpl.WatcherImpl.handleError}) already retries
+ * <em>retryable</em> errors itself: it notifies {@code onError} and then
+ * reschedules {@code resume()} on the same watcher. Re-subscribing from
+ * {@code onError} would therefore open a duplicate watch on every
transient
+ * reconnect, so {@code onError} only logs here. A non-retryable error (or
an
+ * explicit cancel) ends in {@code close()}, which removes the watcher and
+ * invokes {@code onCompleted}; that is the only point where the watch is
+ * truly gone, so re-subscribe happens there. The old watcher is already
+ * closed and removed, so the replacement is not a duplicate.
+ */
+ private void watchKey(ByteSequence key, WatchOption option,
+ Consumer<WatchResponse> consumer) {
+ Watch.Listener listener = Watch.listener(
+ consumer,
+ throwable -> LOG.warn("etcd meta watch error for key '{}', " +
+ "jetcd will retry if recoverable",
+ key.toString(Charset.defaultCharset()),
+ throwable),
+ () -> this.scheduleReWatch(key, option, consumer));
+ this.client.getWatchClient().watch(key, option, listener);
+ }
+ private void scheduleReWatch(ByteSequence key, WatchOption option,
+ Consumer<WatchResponse> consumer) {
+ this.reWatchExecutor.schedule(() -> this.reWatch(key, option,
consumer),
+ this.reWatchDelayMs,
TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Re-establish a watch dropped by a terminal close. If the re-subscribe
+ * itself fails (e.g. the endpoint is still unreachable), it is retried
with
+ * the same backoff instead of giving up, otherwise a single failed attempt
+ * would lose the listener permanently.
+ */
+ private void reWatch(ByteSequence key, WatchOption option,
+ Consumer<WatchResponse> consumer) {
+ try {
+ LOG.info("Re-establishing etcd meta watch for key '{}'",
+ key.toString(Charset.defaultCharset()));
+ this.watchKey(key, option, consumer);
+ } catch (Exception e) {
+ LOG.warn("Failed to re-establish etcd meta watch for key '{}', " +
+ "retrying in {} ms",
+ key.toString(Charset.defaultCharset()),
+ this.reWatchDelayMs, e);
+ this.scheduleReWatch(key, option, consumer);
+ }
}
}
diff --git
a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/meta/EtcdMetaDriverTest.java
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/meta/EtcdMetaDriverTest.java
new file mode 100644
index 000000000..cf0429b55
--- /dev/null
+++
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/meta/EtcdMetaDriverTest.java
@@ -0,0 +1,152 @@
+/*
+ * 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.
+ */
+
+package org.apache.hugegraph.meta;
+
+import java.nio.charset.Charset;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.hugegraph.testutil.Assert;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import io.etcd.jetcd.ByteSequence;
+import io.etcd.jetcd.Client;
+import io.etcd.jetcd.Watch;
+import io.etcd.jetcd.options.WatchOption;
+import io.etcd.jetcd.watch.WatchResponse;
+
+/**
+ * Unit tests for {@link EtcdMetaDriver}'s watch recovery (issue #3036). jetcd
+ * retries recoverable errors itself, so the driver must re-subscribe only on
the
+ * terminal {@code onCompleted} close, not on every {@code onError}. A mock
jetcd
+ * {@link Client} drives these paths through the package-private test
+ * constructor; no live etcd is needed.
+ */
+public class EtcdMetaDriverTest {
+
+ @Test
+ public void testListenReWatchesOnCompleted() {
+ Watch watch = Mockito.mock(Watch.class);
+ EtcdMetaDriver driver = newDriver(watch);
+
+ driver.listen("k", response -> { });
+ // onCompleted is jetcd's terminal-close signal: the watcher is gone,
so
+ // the driver must re-subscribe (a second watch() call).
+ captureListener(watch).onCompleted();
+
+ Mockito.verify(watch, Mockito.timeout(2000).times(2))
+ .watch(Mockito.any(ByteSequence.class),
+ Mockito.any(WatchOption.class),
+ Mockito.any(Watch.Listener.class));
+ }
+
+ @Test
+ public void testListenDoesNotReWatchOnError() {
+ Watch watch = Mockito.mock(Watch.class);
+ EtcdMetaDriver driver = newDriver(watch);
+
+ driver.listen("k", response -> { });
+ // jetcd already reschedules the same watcher for recoverable errors;
+ // re-subscribing here would create a duplicate watch. Assert it does
not.
+ captureListener(watch).onError(new RuntimeException("recoverable"));
+
+ Mockito.verify(watch, Mockito.after(500).times(1))
+ .watch(Mockito.any(ByteSequence.class),
+ Mockito.any(WatchOption.class),
+ Mockito.any(Watch.Listener.class));
+ }
+
+ @Test
+ public void testListenPrefixReWatchPreservesKeyAndPrefix() {
+ Watch watch = Mockito.mock(Watch.class);
+ EtcdMetaDriver driver = newDriver(watch);
+
+ driver.listenPrefix("prefix", response -> { });
+ captureListener(watch).onCompleted();
+
+ ArgumentCaptor<ByteSequence> keyCaptor =
+ ArgumentCaptor.forClass(ByteSequence.class);
+ ArgumentCaptor<WatchOption> optionCaptor =
+ ArgumentCaptor.forClass(WatchOption.class);
+ Mockito.verify(watch, Mockito.timeout(2000).times(2))
+ .watch(keyCaptor.capture(), optionCaptor.capture(),
+ Mockito.any(Watch.Listener.class));
+
+ // The re-created watch must still target "prefix" with prefix
semantics,
+ // not silently downgrade to an exact-key watch.
+ ByteSequence reWatchKey = keyCaptor.getAllValues().get(1);
+ WatchOption reWatchOption = optionCaptor.getAllValues().get(1);
+ Assert.assertEquals("prefix",
+ reWatchKey.toString(Charset.defaultCharset()));
+ Assert.assertTrue(reWatchOption.isPrefix());
+ }
+
+ @Test
+ public void testReWatchRetriesWhenFirstAttemptThrows() {
+ Watch watch = Mockito.mock(Watch.class);
+ // Initial watch() succeeds, the first re-watch throws, the retry
succeeds.
+ // A single failed re-watch must not abandon recovery permanently.
+ Mockito.when(watch.watch(Mockito.any(ByteSequence.class),
+ Mockito.any(WatchOption.class),
+ Mockito.any(Watch.Listener.class)))
+ .thenReturn(null)
+ .thenThrow(new RuntimeException("etcd still unreachable"))
+ .thenReturn(null);
+ EtcdMetaDriver driver = newDriver(watch);
+
+ driver.listen("k", response -> { });
+ captureListener(watch).onCompleted();
+
+ Mockito.verify(watch, Mockito.timeout(2000).times(3))
+ .watch(Mockito.any(ByteSequence.class),
+ Mockito.any(WatchOption.class),
+ Mockito.any(Watch.Listener.class));
+ }
+
+ @Test
+ public void testListenDeliversEventsToConsumer() {
+ Watch watch = Mockito.mock(Watch.class);
+ AtomicReference<WatchResponse> received = new AtomicReference<>();
+ EtcdMetaDriver driver = newDriver(watch);
+
+ driver.listen("k", received::set);
+ WatchResponse response = Mockito.mock(WatchResponse.class);
+ captureListener(watch).onNext(response);
+
+ Assert.assertSame(response, received.get());
+ }
+
+ private static EtcdMetaDriver newDriver(Watch watch) {
+ Client client = Mockito.mock(Client.class);
+ Mockito.when(client.getWatchClient()).thenReturn(watch);
+ EtcdMetaDriver driver = new EtcdMetaDriver(client);
+ // No backoff in tests so the re-subscribe runs promptly.
+ driver.reWatchDelayMs = 0L;
+ return driver;
+ }
+
+ private static Watch.Listener captureListener(Watch watch) {
+ ArgumentCaptor<Watch.Listener> captor =
+ ArgumentCaptor.forClass(Watch.Listener.class);
+ Mockito.verify(watch).watch(Mockito.any(ByteSequence.class),
+ Mockito.any(WatchOption.class),
+ captor.capture());
+ return captor.getValue();
+ }
+}
diff --git
a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
index fb7f0e744..2bb10efaf 100644
---
a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
+++
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
@@ -18,6 +18,7 @@
package org.apache.hugegraph.unit;
import org.apache.hugegraph.core.RoleElectionStateMachineTest;
+import org.apache.hugegraph.meta.EtcdMetaDriverTest;
import org.apache.hugegraph.meta.MetaManagerSchemaCacheClearEventTest;
import org.apache.hugegraph.traversal.optimize.TraversalUtilOptimizeTest;
import org.apache.hugegraph.unit.api.filter.LoadDetectFilterTest;
@@ -99,6 +100,7 @@ import org.junit.runners.Suite;
CacheTest.LevelCacheTest.class,
CachedSchemaTransactionTest.class,
MetaManagerSchemaCacheClearEventTest.class,
+ EtcdMetaDriverTest.class,
CachedGraphTransactionTest.class,
CacheManagerTest.class,
RamTableTest.class,