2525import java .util .List ;
2626import java .util .Map ;
2727import java .util .concurrent .ExecutionException ;
28+ import java .util .concurrent .Executors ;
29+ import java .util .concurrent .ScheduledExecutorService ;
30+ import java .util .concurrent .TimeUnit ;
2831import java .util .function .Consumer ;
2932
3033import org .apache .commons .io .FileUtils ;
3336import org .apache .hugegraph .meta .lock .LockResult ;
3437import org .apache .hugegraph .type .define .CollectionType ;
3538import org .apache .hugegraph .util .E ;
39+ import org .apache .hugegraph .util .Log ;
3640import org .apache .hugegraph .util .collection .CollectionFactory ;
41+ import org .slf4j .Logger ;
3742
3843import com .google .common .base .Strings ;
3944
4247import io .etcd .jetcd .ClientBuilder ;
4348import io .etcd .jetcd .KV ;
4449import io .etcd .jetcd .KeyValue ;
50+ import io .etcd .jetcd .Watch ;
4551import io .etcd .jetcd .kv .GetResponse ;
4652import io .etcd .jetcd .lease .LeaseKeepAliveResponse ;
4753import io .etcd .jetcd .options .DeleteOption ;
5763
5864public class EtcdMetaDriver implements MetaDriver {
5965
66+ private static final Logger LOG = Log .logger (EtcdMetaDriver .class );
67+
6068 private final Client client ;
6169 private final EtcdDistributedLock lock ;
6270
71+ // Re-subscribes a dropped watch off the jetcd callback thread; single
72+ // daemon thread, process-lifetime (no close()), so JVM shutdown reclaims it.
73+ private final ScheduledExecutorService reWatchExecutor =
74+ Executors .newSingleThreadScheduledExecutor (r -> {
75+ Thread thread = new Thread (r , "etcd-meta-rewatch" );
76+ thread .setDaemon (true );
77+ return thread ;
78+ });
79+
80+ // Backoff before re-subscribing a dropped watch. Package-private and
81+ // mutable only so tests can set it to 0; never reassigned in production.
82+ long reWatchDelayMs = 1000L ;
83+
6384 public EtcdMetaDriver (String trustFile , String clientCertFile ,
6485 String clientKeyFile , Object ... endpoints ) {
6586 ClientBuilder builder = this .etcdMetaDriverBuilder (endpoints );
@@ -76,6 +97,13 @@ public EtcdMetaDriver(Object... endpoints) {
7697 this .lock = EtcdDistributedLock .getInstance (this .client );
7798 }
7899
100+ // Package-private constructor for tests: inject a mock Client and skip lock
101+ // setup (watch tests never touch the distributed lock).
102+ EtcdMetaDriver (Client client ) {
103+ this .client = client ;
104+ this .lock = null ;
105+ }
106+
79107 private static ByteSequence toByteSequence (String content ) {
80108 return ByteSequence .from (content .getBytes ());
81109 }
@@ -303,9 +331,8 @@ public void unlock(String key, LockResult lockResult) {
303331 @ SuppressWarnings ("unchecked" )
304332 @ Override
305333 public <T > void listen (String key , Consumer <T > consumer ) {
306-
307- this .client .getWatchClient ().watch (toByteSequence (key ),
308- (Consumer <WatchResponse >) consumer );
334+ this .watchKey (toByteSequence (key ), WatchOption .DEFAULT ,
335+ (Consumer <WatchResponse >) consumer );
309336 }
310337
311338 /**
@@ -314,9 +341,63 @@ public <T> void listen(String key, Consumer<T> consumer) {
314341 @ SuppressWarnings ("unchecked" )
315342 @ Override
316343 public <T > void listenPrefix (String prefix , Consumer <T > consumer ) {
317- ByteSequence sequence = toByteSequence (prefix );
318344 WatchOption option = WatchOption .newBuilder ().isPrefix (true ).build ();
319- this .client .getWatchClient ().watch (sequence , option , (Consumer <WatchResponse >) consumer );
345+ this .watchKey (toByteSequence (prefix ), option ,
346+ (Consumer <WatchResponse >) consumer );
347+ }
348+
349+ /**
350+ * Subscribe a watch that survives the terminal close jetcd cannot recover
351+ * from. The bare {@code Consumer} overload discards {@code onError} and
352+ * {@code onCompleted}, so a non-retryable failure silently drops the
353+ * listener (issue #3036).
354+ * <p>
355+ * jetcd 0.5.9 ({@code WatchImpl.WatcherImpl.handleError}) already retries
356+ * <em>retryable</em> errors itself: it notifies {@code onError} and then
357+ * reschedules {@code resume()} on the same watcher. Re-subscribing from
358+ * {@code onError} would therefore open a duplicate watch on every transient
359+ * reconnect, so {@code onError} only logs here. A non-retryable error (or an
360+ * explicit cancel) ends in {@code close()}, which removes the watcher and
361+ * invokes {@code onCompleted}; that is the only point where the watch is
362+ * truly gone, so re-subscribe happens there. The old watcher is already
363+ * closed and removed, so the replacement is not a duplicate.
364+ */
365+ private void watchKey (ByteSequence key , WatchOption option ,
366+ Consumer <WatchResponse > consumer ) {
367+ Watch .Listener listener = Watch .listener (
368+ consumer ,
369+ throwable -> LOG .warn ("etcd meta watch error for key '{}', " +
370+ "jetcd will retry if recoverable" ,
371+ key .toString (Charset .defaultCharset ()),
372+ throwable ),
373+ () -> this .scheduleReWatch (key , option , consumer ));
374+ this .client .getWatchClient ().watch (key , option , listener );
375+ }
320376
377+ private void scheduleReWatch (ByteSequence key , WatchOption option ,
378+ Consumer <WatchResponse > consumer ) {
379+ this .reWatchExecutor .schedule (() -> this .reWatch (key , option , consumer ),
380+ this .reWatchDelayMs , TimeUnit .MILLISECONDS );
381+ }
382+
383+ /**
384+ * Re-establish a watch dropped by a terminal close. If the re-subscribe
385+ * itself fails (e.g. the endpoint is still unreachable), it is retried with
386+ * the same backoff instead of giving up, otherwise a single failed attempt
387+ * would lose the listener permanently.
388+ */
389+ private void reWatch (ByteSequence key , WatchOption option ,
390+ Consumer <WatchResponse > consumer ) {
391+ try {
392+ LOG .info ("Re-establishing etcd meta watch for key '{}'" ,
393+ key .toString (Charset .defaultCharset ()));
394+ this .watchKey (key , option , consumer );
395+ } catch (Exception e ) {
396+ LOG .warn ("Failed to re-establish etcd meta watch for key '{}', " +
397+ "retrying in {} ms" ,
398+ key .toString (Charset .defaultCharset ()),
399+ this .reWatchDelayMs , e );
400+ this .scheduleReWatch (key , option , consumer );
401+ }
321402 }
322403}
0 commit comments