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

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/dev/pr-12755-c389c24b0ec24c55dafbf2d0e9d98189474d3e2f
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit eb8b80400ece6b81525529c4c2ab634839f38533
Author: WPNG <[email protected]>
AuthorDate: Tue Oct 27 12:37:18 2026 +0000

    [Fix][Zeta] Fix lite member (worker) cannot join cluster when started 
before master (#12755)
    
    Co-authored-by: wpngwang <[email protected]>
---
 .../engines/zeta/separated-cluster-deployment.md   |   4 +
 .../engines/zeta/separated-cluster-deployment.md   |   2 +
 .../server/joiner/LiteNodeDropOutTcpIpJoiner.java  | 146 +++++-
 .../joiner/LiteNodeDropOutDiscoveryJoinerTest.java | 504 +++++++++++++++++++++
 .../joiner/LiteNodeDropOutTcpIpJoinerTest.java     | 382 ++++++++++++++++
 5 files changed, 1032 insertions(+), 6 deletions(-)

diff --git a/docs/en/engines/zeta/separated-cluster-deployment.md 
b/docs/en/engines/zeta/separated-cluster-deployment.md
index 6eee00d7c7..bcc20d1393 100644
--- a/docs/en/engines/zeta/separated-cluster-deployment.md
+++ b/docs/en/engines/zeta/separated-cluster-deployment.md
@@ -587,6 +587,10 @@ mkdir -p $SEATUNNEL_HOME/logs
 
 The logs will be written to `$SEATUNNEL_HOME/logs/seatunnel-engine-worker.log`.
 
+> A Worker started before any Master will keep retrying the configured master 
addresses for up to
+> `hazelcast.max.join.seconds` (default 300s) and log `This lite member cannot 
assume the master
+> role ...` while waiting.
+
 ## 8. Submit And Manage Jobs
 
 ### 8.1 Submit Jobs With The SeaTunnel Engine Client
diff --git a/docs/zh/engines/zeta/separated-cluster-deployment.md 
b/docs/zh/engines/zeta/separated-cluster-deployment.md
index ed0300d5cf..311b0d4f01 100644
--- a/docs/zh/engines/zeta/separated-cluster-deployment.md
+++ b/docs/zh/engines/zeta/separated-cluster-deployment.md
@@ -546,6 +546,8 @@ mkdir -p $SEATUNNEL_HOME/logs
 
 日志将写入 `$SEATUNNEL_HOME/logs/seatunnel-engine-worker.log`
 
+> 如果 Worker 先于任何 Master 启动,它会在 `hazelcast.max.join.seconds`(默认 300 秒)内持续重试配置的 
master 地址,并在等待期间输出 `This lite member cannot assume the master role ...` 日志。
+
 ## 8. 安装 SeaTunnel Engine 客户端
 
 ### 8.1 和服务端一样设置`SEATUNNEL_HOME`
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/joiner/LiteNodeDropOutTcpIpJoiner.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/joiner/LiteNodeDropOutTcpIpJoiner.java
index afb3eab795..800da87b5c 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/joiner/LiteNodeDropOutTcpIpJoiner.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/joiner/LiteNodeDropOutTcpIpJoiner.java
@@ -81,6 +81,49 @@ public class LiteNodeDropOutTcpIpJoiner extends TcpIpJoiner {
         return claimingMastership;
     }
 
+    /**
+     * Re-declared from {@link com.hazelcast.internal.cluster.Joiner} to 
downgrade Hazelcast's INFO
+     * "added to the blacklist" log to FINE only for the noisy case: a lite 
member temporarily
+     * blacklisting a candidate during the retry loop (it 
blacklists/unblacklists every candidate
+     * each cycle). Non-lite members and permanent entries keep Hazelcast's 
INFO so operators still
+     * see those signals; the one-shot state log in {@link #logRetryState} 
already summarizes the
+     * lite-member retry.
+     *
+     * <p>The map bookkeeping mirrors Hazelcast 5.1 {@code 
AbstractJoiner#blacklist} and {@code
+     * AbstractJoiner#unblacklist}; re-check these two methods when the shaded 
Hazelcast is
+     * upgraded.
+     */
+    @Override
+    public void blacklist(Address address, boolean permanent) {
+        if (node.isLiteMember() && !permanent) {
+            Boolean previous = blacklistedAddresses.putIfAbsent(address, 
permanent);
+            if (previous == null && logger.isFineEnabled()) {
+                logger.fine(address + " is added to the blacklist.");
+            }
+            return;
+        }
+        super.blacklist(address, permanent);
+    }
+
+    /**
+     * Re-declared from {@link com.hazelcast.internal.cluster.Joiner} to 
downgrade Hazelcast's INFO
+     * "removed from the blacklist" log to FINE for lite members. See {@link 
#blacklist(Address,
+     * boolean)}.
+     */
+    @Override
+    public boolean unblacklist(Address address) {
+        if (node.isLiteMember()) {
+            if (blacklistedAddresses.remove(address, Boolean.FALSE)) {
+                if (logger.isFineEnabled()) {
+                    logger.fine(address + " is removed from the blacklist.");
+                }
+                return true;
+            }
+            return false;
+        }
+        return super.unblacklist(address);
+    }
+
     private int getConnTimeoutSeconds() {
         return joinConfig.getTcpIpConfig().getConnectionTimeoutSeconds();
     }
@@ -146,8 +189,35 @@ public class LiteNodeDropOutTcpIpJoiner extends 
TcpIpJoiner {
 
             long maxJoinMillis = getMaxJoinMillis();
             long startTime = Clock.currentTimeMillis();
+            // Tracks whether the retry state (no candidates / permanently 
blacklisted /
+            // temporarily blacklisted) has been logged once for this join 
attempt. Local to this
+            // method so that a re-join (for example after hot restart) logs 
again.
+            boolean retryStateLogged = false;
+
+            // An interrupt must also terminate the retry attempt: with an 
empty or fully
+            // permanently-blacklisted candidate list, tryJoinAddresses() 
returns before reaching
+            // its own blockOnJoin(), so the retry helper would otherwise keep 
throwing
+            // InterruptedException and spin until the join deadline. Checking 
the flag here gives
+            // the loop an explicit exit while the helper restores the flag.
+            while (shouldRetry()
+                    && !Thread.currentThread().isInterrupted()
+                    && (Clock.currentTimeMillis() - startTime < 
maxJoinMillis)) {
+                // If the initial join produced no candidates (for example a 
discovery strategy
+                // that returned nothing before the master was published), 
refresh once per pass so
+                // a master that appears later can still be found within the 
overall join deadline.
+                // For TCP/IP this re-resolves a static member list and is 
effectively a no-op.
+                if (possibleAddresses.isEmpty()) {
+                    try {
+                        possibleAddresses = getPossibleAddresses();
+                    } catch (Exception e) {
+                        // A transient discovery failure should not abort the 
whole join attempt;
+                        // keep the empty list so the next pass retries. 
Errors (e.g. OOM) are left
+                        // to propagate so a fatal condition is not swallowed 
for the whole window.
+                        logger.warning(
+                                "Failed to refresh candidate master addresses; 
will retry.", e);
+                    }
+                }
 
-            while (shouldRetry() && (Clock.currentTimeMillis() - startTime < 
maxJoinMillis)) {
                 tryJoinAddresses(possibleAddresses);
 
                 if (clusterService.isJoined()) {
@@ -155,11 +225,27 @@ public class LiteNodeDropOutTcpIpJoiner extends 
TcpIpJoiner {
                 }
 
                 // update for seatunnel, lite member can not become master node
-                if (isAllBlacklisted(possibleAddresses) && 
!node.isLiteMember()) {
-                    logger.fine(
-                            "This node will assume master role since none of 
the possible members accepted join request.");
-                    clusterJoinManager.setThisMemberAsMaster();
-                    return;
+                if (isAllBlacklisted(possibleAddresses)) {
+                    if (node.isLiteMember()) {
+                        boolean clearedAny = 
retryTemporaryBlacklistedAddresses(possibleAddresses);
+                        if (!retryStateLogged) {
+                            retryStateLogged = true;
+                            logRetryState(possibleAddresses, clearedAny);
+                        }
+                        if (!clusterService.isJoined()) {
+                            // Reset any stale master address learned earlier 
so the next pass
+                            // re-asks all candidates instead of pinning to a 
dead master.
+                            clusterService.setMasterAddressToJoin(null);
+                        }
+                        // A lite member can never claim mastership, so skip 
the redundant claim
+                        // attempt and the master-address reset below.
+                        continue;
+                    } else {
+                        logger.fine(
+                                "This node will assume master role since none 
of the possible members accepted join request.");
+                        clusterJoinManager.setThisMemberAsMaster();
+                        return;
+                    }
                 }
 
                 if (tryClaimMastership(possibleAddresses)) {
@@ -173,6 +259,54 @@ public class LiteNodeDropOutTcpIpJoiner extends 
TcpIpJoiner {
         }
     }
 
+    /**
+     * Waits for one join retry interval and clears only the temporary 
blacklist entries so that the
+     * next {@link #tryJoinAddresses(Collection)} call retries them. Returns 
{@code true} if at
+     * least one temporary entry was cleared, {@code false} if there was 
nothing to clear (an empty
+     * candidate list, or every candidate is permanently blacklisted).
+     *
+     * <p>Permanently blacklisted addresses are intentionally left untouched: 
{@link
+     * #unblacklist(Address)} only removes temporary (non-permanent) entries.
+     */
+    private boolean retryTemporaryBlacklistedAddresses(Collection<Address> 
possibleAddresses) {
+        try {
+            clusterService.blockOnJoin(JOIN_RETRY_WAIT_TIME);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+        boolean clearedAny = false;
+        for (Address address : possibleAddresses) {
+            if (unblacklist(address)) {
+                clearedAny = true;
+            }
+        }
+        return clearedAny;
+    }
+
+    /**
+     * Logs a single, accurate message describing why this lite member cannot 
join yet, based on
+     * whether the candidate list is empty, every candidate is permanently 
blacklisted, or there are
+     * temporarily blacklisted candidates that will keep being retried.
+     */
+    private void logRetryState(Collection<Address> possibleAddresses, boolean 
clearedAny) {
+        if (possibleAddresses.isEmpty()) {
+            logger.warning(
+                    "This lite member has no candidate master addresses to 
join; it will keep "
+                            + "re-checking for candidate master addresses 
until a master is found or "
+                            + "hazelcast.max.join.seconds elapses.");
+        } else if (!clearedAny) {
+            logger.warning(
+                    "All candidate master addresses are permanently 
blacklisted: "
+                            + possibleAddresses
+                            + "; this lite member cannot retry them.");
+        } else {
+            logger.info(
+                    "This lite member cannot assume the master role and all 
candidate master "
+                            + "addresses are temporarily blacklisted. It will 
keep retrying until a "
+                            + "master becomes reachable or 
hazelcast.max.join.seconds elapses.");
+        }
+    }
+
     private boolean tryClaimMastership(Collection<Address> addresses) {
         boolean consensus = false;
         if (isThisNodeMasterCandidate(addresses)) {
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/joiner/LiteNodeDropOutDiscoveryJoinerTest.java
 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/joiner/LiteNodeDropOutDiscoveryJoinerTest.java
new file mode 100644
index 0000000000..6d33176b1e
--- /dev/null
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/joiner/LiteNodeDropOutDiscoveryJoinerTest.java
@@ -0,0 +1,504 @@
+/*
+ * 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.seatunnel.engine.server.joiner;
+
+import org.apache.seatunnel.engine.server.TestUtils;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import com.hazelcast.cluster.Address;
+import com.hazelcast.config.Config;
+import com.hazelcast.config.DiscoveryConfig;
+import com.hazelcast.config.DiscoveryStrategyConfig;
+import com.hazelcast.config.TcpIpConfig;
+import com.hazelcast.config.properties.PropertyDefinition;
+import com.hazelcast.instance.impl.DefaultNodeContext;
+import com.hazelcast.instance.impl.HazelcastInstanceFactory;
+import com.hazelcast.instance.impl.HazelcastInstanceImpl;
+import com.hazelcast.instance.impl.HazelcastInstanceProxy;
+import com.hazelcast.instance.impl.Node;
+import com.hazelcast.internal.cluster.Joiner;
+import com.hazelcast.logging.ILogger;
+import com.hazelcast.spi.discovery.DiscoveryNode;
+import com.hazelcast.spi.discovery.DiscoveryStrategy;
+import com.hazelcast.spi.discovery.DiscoveryStrategyFactory;
+import com.hazelcast.spi.discovery.SimpleDiscoveryNode;
+
+import java.net.UnknownHostException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+/**
+ * Verifies that the discovery-based lite-member joiner ({@link 
LiteNodeDropOutDiscoveryJoiner})
+ * inherits the TCP/IP lite-member retry fix from {@link 
LiteNodeDropOutTcpIpJoiner}, including
+ * refreshing an initially-empty discovery result within the overall join 
deadline.
+ */
+class LiteNodeDropOutDiscoveryJoinerTest {
+
+    private static final String HOST = "127.0.0.1";
+
+    /**
+     * A discovery-based worker (lite member) starts before the master. The 
discovery strategy
+     * already returns the master address before the master port is ready, so 
the worker temporarily
+     * blacklists it and must keep retrying (inherited from the TCP/IP fix) 
until the master starts
+     * and the worker joins.
+     */
+    @Test
+    void discoveryWorkerStartedBeforeMasterShouldEventuallyJoin() throws 
Exception {
+        String clusterName =
+                
TestUtils.getClusterName("LiteNodeDropOutDiscoveryJoinerTest_workerJoin");
+        int masterPort = TestUtils.getAvailablePort();
+        int workerPort = TestUtils.getAvailablePort();
+
+        Config workerConfig = newDiscoveryConfig(clusterName, workerPort, 
masterPort);
+        workerConfig.setLiteMember(true);
+        workerConfig.setProperty("hazelcast.max.join.seconds", "60");
+        // Keep the initial discovery poll window short; the strategy returns 
a node immediately.
+        workerConfig.setProperty("hazelcast.wait.seconds.before.join", "1");
+
+        // Fires once the worker has actually blacklisted the not-yet-started 
master address.
+        CountDownLatch blacklistLatch = new CountDownLatch(1);
+
+        AtomicReference<HazelcastInstanceImpl> workerRef = new 
AtomicReference<>();
+        AtomicReference<Throwable> workerError = new AtomicReference<>();
+        Thread workerThread =
+                new Thread(
+                        () -> {
+                            try {
+                                workerRef.set(startNode(workerConfig, 
blacklistLatch));
+                            } catch (Throwable t) {
+                                workerError.set(t);
+                            }
+                        },
+                        "test-discovery-worker-started-before-master");
+        workerThread.start();
+
+        HazelcastInstanceImpl master = null;
+        try {
+            // Wait until the worker has provably blacklisted the 
not-yet-started master address.
+            Assertions.assertTrue(
+                    blacklistLatch.await(30, SECONDS),
+                    "worker should have blacklisted the not-yet-started master 
address");
+
+            Config masterConfig = newTcpIpConfig(clusterName, masterPort);
+            master = startNode(masterConfig, null);
+
+            workerThread.join(SECONDS.toMillis(60));
+            Assertions.assertFalse(workerThread.isAlive(), "worker should have 
finished joining");
+            Assertions.assertNull(workerError.get(), "worker should not fail 
to start");
+
+            HazelcastInstanceImpl worker = workerRef.get();
+            Assertions.assertNotNull(worker);
+            Assertions.assertTrue(worker.node.getClusterService().isJoined());
+            Assertions.assertFalse(worker.node.isMaster(), "a worker must 
never become master");
+            Assertions.assertTrue(master.node.isMaster());
+        } finally {
+            workerThread.interrupt();
+            try {
+                workerThread.join(SECONDS.toMillis(5));
+            } catch (InterruptedException ignored) {
+                Thread.currentThread().interrupt();
+            }
+            if (workerRef.get() != null) {
+                workerRef.get().shutdown();
+            }
+            if (master != null) {
+                master.shutdown();
+            }
+            // Force-stop any still-starting worker node (e.g. when a timeout 
assertion failed
+            // before workerRef was set) so it cannot leak into later tests in 
the same JVM.
+            HazelcastInstanceFactory.shutdownAll();
+        }
+    }
+
+    /**
+     * A discovery-based worker (lite member) starts before the master, but 
discovery returns no
+     * candidate until after the initial discovery window. The worker must 
keep refreshing discovery
+     * within the join deadline and join once the master becomes discoverable, 
without ever becoming
+     * master itself.
+     *
+     * <p>The master is made discoverable only after the initial discovery 
window ({@code
+     * hazelcast.wait.seconds.before.join}=1s) has elapsed, so the empty-list 
refresh path (not the
+     * initial-discovery path) is exercised.
+     */
+    @Test
+    void discoveryWorkerStartedWithEmptyDiscoveryShouldEventuallyJoin() throws 
Exception {
+        String clusterName =
+                
TestUtils.getClusterName("LiteNodeDropOutDiscoveryJoinerTest_emptyJoin");
+        int masterPort = TestUtils.getAvailablePort();
+        int workerPort = TestUtils.getAvailablePort();
+
+        // Discovery returns no candidates until this latch is counted down 
(i.e. the master is up).
+        CountDownLatch masterReady = new CountDownLatch(1);
+        AtomicInteger discoverCount = new AtomicInteger();
+
+        Config workerConfig =
+                newEmptyDiscoveryConfig(
+                        clusterName, workerPort, masterPort, masterReady, 
discoverCount);
+        workerConfig.setLiteMember(true);
+        workerConfig.setProperty("hazelcast.max.join.seconds", "60");
+        workerConfig.setProperty("hazelcast.wait.seconds.before.join", "1");
+
+        AtomicReference<HazelcastInstanceImpl> workerRef = new 
AtomicReference<>();
+        AtomicReference<Throwable> workerError = new AtomicReference<>();
+        Thread workerThread =
+                new Thread(
+                        () -> {
+                            try {
+                                workerRef.set(startNode(workerConfig, null));
+                            } catch (Throwable t) {
+                                workerError.set(t);
+                            }
+                        },
+                        "test-discovery-empty-join");
+        workerThread.start();
+
+        HazelcastInstanceImpl master = null;
+        try {
+            // Wait until the worker has polled discovery enough times to be 
past the initial
+            // discovery window (hazelcast.wait.seconds.before.join=1s) and 
into the empty-list
+            // refresh loop. The initial window polls roughly 7 times with the 
backoff idle
+            // strategy, so 10 polls guarantee the refresh path is exercised. 
Polling the counter
+            // instead of a fixed sleep keeps this deterministic even on slow 
runners.
+            long pollDeadline = System.currentTimeMillis() + 
SECONDS.toMillis(30);
+            while (discoverCount.get() < 10 && System.currentTimeMillis() < 
pollDeadline) {
+                Thread.sleep(100);
+            }
+            Assertions.assertTrue(
+                    discoverCount.get() >= 10,
+                    "worker should have polled discovery past the initial 
window");
+
+            // Record how many times discovery has been polled so far, then 
make the master
+            // discoverable and verify the worker polls discovery again (not 
just joins).
+            int discoveriesBeforeMaster = discoverCount.get();
+
+            master = startNode(newTcpIpConfig(clusterName, masterPort), null);
+            masterReady.countDown();
+
+            workerThread.join(SECONDS.toMillis(60));
+            Assertions.assertFalse(workerThread.isAlive(), "worker should have 
finished joining");
+            Assertions.assertNull(workerError.get(), "worker should not fail 
to start");
+            Assertions.assertTrue(
+                    discoverCount.get() > discoveriesBeforeMaster,
+                    "worker should have re-polled discovery after the master 
became discoverable");
+
+            HazelcastInstanceImpl worker = workerRef.get();
+            Assertions.assertNotNull(worker);
+            Assertions.assertTrue(worker.node.getClusterService().isJoined());
+            Assertions.assertFalse(worker.node.isMaster(), "a worker must 
never become master");
+            Assertions.assertTrue(master.node.isMaster());
+        } finally {
+            workerThread.interrupt();
+            try {
+                workerThread.join(SECONDS.toMillis(5));
+            } catch (InterruptedException ignored) {
+                Thread.currentThread().interrupt();
+            }
+            if (workerRef.get() != null) {
+                workerRef.get().shutdown();
+            }
+            if (master != null) {
+                master.shutdown();
+            }
+            HazelcastInstanceFactory.shutdownAll();
+        }
+    }
+
+    /**
+     * Interrupting a discovery-based worker whose discovery keeps returning 
no candidates must stop
+     * the retry loop promptly (rather than spinning until {@code 
hazelcast.max.join.seconds}), and
+     * the worker must not join.
+     */
+    @Test
+    void interruptedWorkerWithEmptyCandidatesShouldStopRetrying() throws 
Exception {
+        String clusterName =
+                
TestUtils.getClusterName("LiteNodeDropOutDiscoveryJoinerTest_interruptEmpty");
+        int masterPort = TestUtils.getAvailablePort(); // never published
+        int workerPort = TestUtils.getAvailablePort();
+
+        CountDownLatch masterReady = new CountDownLatch(1); // never counted 
down
+        AtomicInteger discoverCount = new AtomicInteger();
+
+        Config workerConfig =
+                newEmptyDiscoveryConfig(
+                        clusterName, workerPort, masterPort, masterReady, 
discoverCount);
+        workerConfig.setLiteMember(true);
+        workerConfig.setProperty("hazelcast.max.join.seconds", "60");
+        workerConfig.setProperty("hazelcast.wait.seconds.before.join", "1");
+
+        AtomicReference<HazelcastInstanceImpl> workerRef = new 
AtomicReference<>();
+        Thread workerThread =
+                new Thread(
+                        () -> {
+                            try {
+                                workerRef.set(startNode(workerConfig, null));
+                            } catch (Throwable ignored) {
+                                // The join attempt is expected to fail after 
interruption.
+                            }
+                        },
+                        "test-interrupt-empty-candidates");
+        workerThread.start();
+
+        try {
+            // Wait until the worker has passed the initial discovery window 
and is polling
+            // discovery from the empty-list refresh loop.
+            long pollDeadline = System.currentTimeMillis() + 
SECONDS.toMillis(30);
+            while (discoverCount.get() < 10 && System.currentTimeMillis() < 
pollDeadline) {
+                Thread.sleep(100);
+            }
+            Assertions.assertTrue(
+                    discoverCount.get() >= 10, "worker should have entered the 
refresh loop");
+
+            workerThread.interrupt();
+            workerThread.join(SECONDS.toMillis(5));
+            Assertions.assertFalse(
+                    workerThread.isAlive(),
+                    "worker should stop retrying promptly after interruption");
+            Assertions.assertNull(workerRef.get(), "worker should not have 
joined");
+        } finally {
+            workerThread.interrupt();
+            if (workerRef.get() != null) {
+                workerRef.get().shutdown();
+            }
+            HazelcastInstanceFactory.shutdownAll();
+        }
+    }
+
+    private static HazelcastInstanceImpl startNode(Config config, 
CountDownLatch blacklistLatch) {
+        String instanceName = 
HazelcastInstanceFactory.createInstanceName(config);
+        HazelcastInstanceProxy proxy =
+                (HazelcastInstanceProxy)
+                        HazelcastInstanceFactory.newHazelcastInstance(
+                                config,
+                                instanceName,
+                                new 
LiteNodeDropOutDiscoveryJoinerContext(blacklistLatch));
+        return proxy.getOriginal();
+    }
+
+    private static Config newDiscoveryConfig(String clusterName, int 
workerPort, int masterPort)
+            throws UnknownHostException {
+        Config config = new Config();
+        config.setClusterName(clusterName);
+        config.getNetworkConfig().setPort(workerPort);
+        config.getNetworkConfig().setPortAutoIncrement(false);
+        
config.getNetworkConfig().getJoin().getMulticastConfig().setEnabled(false);
+        config.getNetworkConfig().getJoin().getTcpIpConfig().setEnabled(false);
+        DiscoveryConfig discoveryConfig = 
config.getNetworkConfig().getJoin().getDiscoveryConfig();
+        discoveryConfig.addDiscoveryStrategyConfig(
+                new DiscoveryStrategyConfig(
+                        new StaticDiscoveryStrategyFactory(new Address(HOST, 
masterPort))));
+        return config;
+    }
+
+    private static Config newEmptyDiscoveryConfig(
+            String clusterName,
+            int workerPort,
+            int masterPort,
+            CountDownLatch masterReady,
+            AtomicInteger discoverCount)
+            throws UnknownHostException {
+        Config config = new Config();
+        config.setClusterName(clusterName);
+        config.getNetworkConfig().setPort(workerPort);
+        config.getNetworkConfig().setPortAutoIncrement(false);
+        
config.getNetworkConfig().getJoin().getMulticastConfig().setEnabled(false);
+        config.getNetworkConfig().getJoin().getTcpIpConfig().setEnabled(false);
+        DiscoveryConfig discoveryConfig = 
config.getNetworkConfig().getJoin().getDiscoveryConfig();
+        discoveryConfig.addDiscoveryStrategyConfig(
+                new DiscoveryStrategyConfig(
+                        new InitiallyEmptyDiscoveryStrategyFactory(
+                                new Address(HOST, masterPort), masterReady, 
discoverCount)));
+        return config;
+    }
+
+    private static Config newTcpIpConfig(String clusterName, int port) {
+        Config config = new Config();
+        config.setClusterName(clusterName);
+        config.getNetworkConfig().setPort(port);
+        config.getNetworkConfig().setPortAutoIncrement(false);
+        
config.getNetworkConfig().getJoin().getMulticastConfig().setEnabled(false);
+        TcpIpConfig tcpIpConfig = 
config.getNetworkConfig().getJoin().getTcpIpConfig();
+        tcpIpConfig.setEnabled(true);
+        tcpIpConfig.setConnectionTimeoutSeconds(1);
+        return config;
+    }
+
+    /** A discovery joiner that counts down the given latch when an address is 
blacklisted. */
+    private static class CountingBlacklistDiscoveryJoiner extends 
LiteNodeDropOutDiscoveryJoiner {
+
+        private final CountDownLatch blacklistLatch;
+
+        CountingBlacklistDiscoveryJoiner(Node node, CountDownLatch 
blacklistLatch) {
+            super(node);
+            this.blacklistLatch = blacklistLatch;
+        }
+
+        @Override
+        public void blacklist(Address address, boolean permanent) {
+            super.blacklist(address, permanent);
+            if (blacklistLatch != null) {
+                blacklistLatch.countDown();
+            }
+        }
+    }
+
+    /** Node context that selects the discovery joiner for lite members and 
TCP/IP otherwise. */
+    private static class LiteNodeDropOutDiscoveryJoinerContext extends 
DefaultNodeContext {
+
+        private final CountDownLatch blacklistLatch;
+
+        LiteNodeDropOutDiscoveryJoinerContext(CountDownLatch blacklistLatch) {
+            this.blacklistLatch = blacklistLatch;
+        }
+
+        @Override
+        public Joiner createJoiner(Node node) {
+            return node.isLiteMember()
+                    ? new CountingBlacklistDiscoveryJoiner(node, 
blacklistLatch)
+                    : new LiteNodeDropOutTcpIpJoiner(node);
+        }
+    }
+
+    /** Discovery strategy that always returns the fixed master address. */
+    private static final class StaticDiscoveryStrategy implements 
DiscoveryStrategy {
+
+        private final Iterable<DiscoveryNode> nodes;
+
+        private StaticDiscoveryStrategy(Address address) {
+            this.nodes = Collections.singleton(new 
SimpleDiscoveryNode(address));
+        }
+
+        @Override
+        public void start() {}
+
+        @Override
+        public Iterable<DiscoveryNode> discoverNodes() {
+            return nodes;
+        }
+
+        @Override
+        public void destroy() {}
+
+        @Override
+        public Map<String, String> discoverLocalMetadata() {
+            return Collections.emptyMap();
+        }
+    }
+
+    private static final class StaticDiscoveryStrategyFactory implements 
DiscoveryStrategyFactory {
+
+        private final Address address;
+
+        private StaticDiscoveryStrategyFactory(Address address) {
+            this.address = address;
+        }
+
+        @Override
+        public Class<? extends DiscoveryStrategy> getDiscoveryStrategyType() {
+            return StaticDiscoveryStrategy.class;
+        }
+
+        @Override
+        public DiscoveryStrategy newDiscoveryStrategy(
+                DiscoveryNode discoveryNode, ILogger logger, Map<String, 
Comparable> properties) {
+            return new StaticDiscoveryStrategy(address);
+        }
+
+        @Override
+        public Collection<PropertyDefinition> getConfigurationProperties() {
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * Discovery strategy that returns no nodes until the given latch is 
counted down, then returns
+     * the fixed master address. Models a master that is not published to 
discovery until after the
+     * worker has started.
+     */
+    private static final class InitiallyEmptyDiscoveryStrategy implements 
DiscoveryStrategy {
+
+        private final Address address;
+        private final CountDownLatch masterReady;
+        private final AtomicInteger discoverCount;
+
+        private InitiallyEmptyDiscoveryStrategy(
+                Address address, CountDownLatch masterReady, AtomicInteger 
discoverCount) {
+            this.address = address;
+            this.masterReady = masterReady;
+            this.discoverCount = discoverCount;
+        }
+
+        @Override
+        public void start() {}
+
+        @Override
+        public Iterable<DiscoveryNode> discoverNodes() {
+            discoverCount.incrementAndGet();
+            if (masterReady.getCount() > 0) {
+                return Collections.emptyList();
+            }
+            return Collections.singleton(new SimpleDiscoveryNode(address));
+        }
+
+        @Override
+        public void destroy() {}
+
+        @Override
+        public Map<String, String> discoverLocalMetadata() {
+            return Collections.emptyMap();
+        }
+    }
+
+    private static final class InitiallyEmptyDiscoveryStrategyFactory
+            implements DiscoveryStrategyFactory {
+
+        private final Address address;
+        private final CountDownLatch masterReady;
+        private final AtomicInteger discoverCount;
+
+        private InitiallyEmptyDiscoveryStrategyFactory(
+                Address address, CountDownLatch masterReady, AtomicInteger 
discoverCount) {
+            this.address = address;
+            this.masterReady = masterReady;
+            this.discoverCount = discoverCount;
+        }
+
+        @Override
+        public Class<? extends DiscoveryStrategy> getDiscoveryStrategyType() {
+            return InitiallyEmptyDiscoveryStrategy.class;
+        }
+
+        @Override
+        public DiscoveryStrategy newDiscoveryStrategy(
+                DiscoveryNode discoveryNode, ILogger logger, Map<String, 
Comparable> properties) {
+            return new InitiallyEmptyDiscoveryStrategy(address, masterReady, 
discoverCount);
+        }
+
+        @Override
+        public Collection<PropertyDefinition> getConfigurationProperties() {
+            return Collections.emptyList();
+        }
+    }
+}
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/joiner/LiteNodeDropOutTcpIpJoinerTest.java
 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/joiner/LiteNodeDropOutTcpIpJoinerTest.java
new file mode 100644
index 0000000000..3698c34208
--- /dev/null
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/joiner/LiteNodeDropOutTcpIpJoinerTest.java
@@ -0,0 +1,382 @@
+/*
+ * 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.seatunnel.engine.server.joiner;
+
+import org.apache.seatunnel.engine.server.TestUtils;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import com.hazelcast.cluster.Address;
+import com.hazelcast.config.Config;
+import com.hazelcast.config.TcpIpConfig;
+import com.hazelcast.instance.impl.DefaultNodeContext;
+import com.hazelcast.instance.impl.HazelcastInstanceFactory;
+import com.hazelcast.instance.impl.HazelcastInstanceImpl;
+import com.hazelcast.instance.impl.HazelcastInstanceProxy;
+import com.hazelcast.instance.impl.Node;
+import com.hazelcast.internal.cluster.Joiner;
+
+import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+/**
+ * Verifies the lite-member (worker) join behavior of {@link 
LiteNodeDropOutTcpIpJoiner}.
+ *
+ * <p>A worker is a Hazelcast lite member and therefore can never assume the 
master role. The
+ * regression being guarded here is: when a worker starts before the master, 
connecting to the
+ * candidate master addresses fails and they are temporarily blacklisted. 
Without the retry fix the
+ * worker would never retry those addresses and would deadlock until {@code
+ * hazelcast.max.join.seconds} elapses.
+ */
+class LiteNodeDropOutTcpIpJoinerTest {
+
+    private static final String HOST = "127.0.0.1";
+
+    private static Config newConfig(String clusterName, int port, String... 
members) {
+        Config config = new Config();
+        config.setClusterName(clusterName);
+        config.getNetworkConfig().setPort(port);
+        config.getNetworkConfig().setPortAutoIncrement(false);
+        
config.getNetworkConfig().getJoin().getMulticastConfig().setEnabled(false);
+        TcpIpConfig tcpIpConfig = 
config.getNetworkConfig().getJoin().getTcpIpConfig();
+        tcpIpConfig.setEnabled(true);
+        // Keep the connection timeout small so that a failed connection 
attempt against a
+        // not-yet-started master is detected and blacklisted quickly, making 
the test
+        // deterministic.
+        tcpIpConfig.setConnectionTimeoutSeconds(1);
+        tcpIpConfig.setMembers(Arrays.asList(members));
+        return config;
+    }
+
+    private static HazelcastInstanceImpl startNode(Config config, 
CountDownLatch blacklistLatch) {
+        String instanceName = 
HazelcastInstanceFactory.createInstanceName(config);
+        HazelcastInstanceProxy proxy =
+                (HazelcastInstanceProxy)
+                        HazelcastInstanceFactory.newHazelcastInstance(
+                                config,
+                                instanceName,
+                                new 
LiteNodeDropOutJoinerContext(blacklistLatch));
+        return proxy.getOriginal();
+    }
+
+    /**
+     * A worker (lite member) starts before the master. The worker initially 
fails to connect to the
+     * not-yet-started master and temporarily blacklists its address, then 
retries. Once the master
+     * is started (before the join timeout), the worker must join the cluster 
and must never become
+     * master itself.
+     *
+     * <p>The test starts the master only after the worker has been observed 
to blacklist the master
+     * address (signaled via a latch), guaranteeing the temporary-blacklist 
retry path is exercised
+     * rather than a plain first-attempt join.
+     */
+    @Test
+    void workerStartedBeforeMasterShouldEventuallyJoin() throws Exception {
+        String clusterName = 
TestUtils.getClusterName("LiteNodeDropOutTcpIpJoinerTest_workerJoin");
+        int masterPort = TestUtils.getAvailablePort();
+        int workerPort = TestUtils.getAvailablePort();
+
+        Config workerConfig = newConfig(clusterName, workerPort, HOST + ":" + 
masterPort);
+        workerConfig.setLiteMember(true);
+        // Generous join window; the worker is expected to join well before 
this expires.
+        workerConfig.setProperty("hazelcast.max.join.seconds", "60");
+
+        // Fires once the worker has actually blacklisted the not-yet-started 
master address.
+        CountDownLatch blacklistLatch = new CountDownLatch(1);
+
+        AtomicReference<HazelcastInstanceImpl> workerRef = new 
AtomicReference<>();
+        AtomicReference<Throwable> workerError = new AtomicReference<>();
+        Thread workerThread =
+                new Thread(
+                        () -> {
+                            try {
+                                workerRef.set(startNode(workerConfig, 
blacklistLatch));
+                            } catch (Throwable t) {
+                                workerError.set(t);
+                            }
+                        },
+                        "test-worker-started-before-master");
+        workerThread.start();
+
+        HazelcastInstanceImpl master = null;
+        try {
+            // Wait until the worker has provably blacklisted the 
not-yet-started master address.
+            Assertions.assertTrue(
+                    blacklistLatch.await(30, SECONDS),
+                    "worker should have blacklisted the not-yet-started master 
address");
+
+            Config masterConfig = newConfig(clusterName, masterPort);
+            master = startNode(masterConfig, null);
+
+            workerThread.join(SECONDS.toMillis(60));
+            Assertions.assertFalse(workerThread.isAlive(), "worker should have 
finished joining");
+            Assertions.assertNull(workerError.get(), "worker should not fail 
to start");
+
+            HazelcastInstanceImpl worker = workerRef.get();
+            Assertions.assertNotNull(worker);
+            Assertions.assertTrue(worker.node.getClusterService().isJoined());
+            Assertions.assertFalse(worker.node.isMaster(), "a worker must 
never become master");
+            Assertions.assertTrue(master.node.isMaster());
+        } finally {
+            workerThread.interrupt();
+            try {
+                workerThread.join(SECONDS.toMillis(5));
+            } catch (InterruptedException ignored) {
+                Thread.currentThread().interrupt();
+            }
+            if (workerRef.get() != null) {
+                workerRef.get().shutdown();
+            }
+            if (master != null) {
+                master.shutdown();
+            }
+            // Force-stop any still-starting worker node (e.g. when a timeout 
assertion failed
+            // before workerRef was set) so it cannot leak into later tests in 
the same JVM.
+            HazelcastInstanceFactory.shutdownAll();
+        }
+    }
+
+    /**
+     * A worker (lite member) with no master available must time out and must 
never assume the
+     * master role. If the worker incorrectly self-elected, {@code startNode} 
would return
+     * successfully instead of throwing.
+     */
+    @Test
+    void workerWithoutMasterShouldTimeoutAndNeverBecomeMaster() {
+        String clusterName =
+                
TestUtils.getClusterName("LiteNodeDropOutTcpIpJoinerTest_workerTimeout");
+        int masterPort = TestUtils.getAvailablePort(); // never started
+        int workerPort = TestUtils.getAvailablePort();
+
+        Config workerConfig = newConfig(clusterName, workerPort, HOST + ":" + 
masterPort);
+        workerConfig.setLiteMember(true);
+        workerConfig.setProperty("hazelcast.max.join.seconds", "5");
+
+        Assertions.assertThrows(IllegalStateException.class, () -> 
startNode(workerConfig, null));
+    }
+
+    /**
+     * The non-lite member path must remain unchanged. A non-lite member whose 
candidate addresses
+     * are all blacklisted (for example, a dead target address) must still 
assume the master role
+     * instead of entering the lite-member retry loop.
+     */
+    @Test
+    void nonLiteMemberShouldAssumeMasterWhenAllAddressesBlacklisted() {
+        String clusterName =
+                
TestUtils.getClusterName("LiteNodeDropOutTcpIpJoinerTest_nonLiteSelfElect");
+        int deadTargetPort = TestUtils.getAvailablePort(); // never started
+        int memberPort = TestUtils.getAvailablePort();
+
+        Config config = newConfig(clusterName, memberPort, HOST + ":" + 
deadTargetPort);
+        config.setProperty("hazelcast.max.join.seconds", "30");
+
+        HazelcastInstanceImpl instance = startNode(config, null);
+        try {
+            
Assertions.assertTrue(instance.node.getClusterService().isJoined());
+            Assertions.assertTrue(instance.node.isMaster());
+        } finally {
+            instance.shutdown();
+        }
+    }
+
+    /** A reachable master must stay excluded when its address is permanently 
blacklisted. */
+    @Test
+    void retryMustNotClearAPermanentBlacklistEvenWhenTheMasterIsReachable() 
throws Exception {
+        String clusterName = 
TestUtils.getClusterName("LiteNodeDropOutTcpIpJoinerTest_permanent");
+        int masterPort = TestUtils.getAvailablePort();
+        int workerPort = TestUtils.getAvailablePort();
+        Address masterAddress = new Address(HOST, masterPort);
+        HazelcastInstanceImpl master = startNode(newConfig(clusterName, 
masterPort), null);
+        AtomicReference<HazelcastInstanceImpl> unexpectedWorker = new 
AtomicReference<>();
+        try {
+            Config workerConfig = newConfig(clusterName, workerPort, HOST + 
":" + masterPort);
+            workerConfig.setLiteMember(true);
+            workerConfig.setProperty("hazelcast.max.join.seconds", "15");
+            Assertions.assertThrows(
+                    IllegalStateException.class,
+                    () -> {
+                        String instanceName =
+                                
HazelcastInstanceFactory.createInstanceName(workerConfig);
+                        HazelcastInstanceProxy proxy =
+                                (HazelcastInstanceProxy)
+                                        
HazelcastInstanceFactory.newHazelcastInstance(
+                                                workerConfig,
+                                                instanceName,
+                                                new DefaultNodeContext() {
+                                                    @Override
+                                                    public Joiner 
createJoiner(Node node) {
+                                                        return new 
LiteNodeDropOutTcpIpJoiner(
+                                                                node) {
+                                                            @Override
+                                                            public void 
doJoin() {
+                                                                // 
AbstractJoiner.join clears
+                                                                // old entries 
before doJoin, so
+                                                                // inject the 
permanent exclusion
+                                                                // here.
+                                                                
blacklist(masterAddress, true);
+                                                                super.doJoin();
+                                                            }
+                                                        };
+                                                    }
+                                                });
+                        unexpectedWorker.set(proxy.getOriginal());
+                    });
+            Assertions.assertTrue(master.node.getClusterService().isJoined());
+            Assertions.assertTrue(master.node.isMaster());
+            Assertions.assertEquals(1, 
master.node.getClusterService().getMembers().size());
+        } finally {
+            if (unexpectedWorker.get() != null) {
+                unexpectedWorker.get().shutdown();
+            }
+            master.shutdown();
+        }
+    }
+
+    /**
+     * Interrupting a worker whose candidates are all permanently blacklisted 
must stop the retry
+     * loop promptly (rather than spinning until {@code 
hazelcast.max.join.seconds}), and the worker
+     * must not join.
+     */
+    @Test
+    void interruptedWorkerWithPermanentBlacklistShouldStopRetrying() throws 
Exception {
+        String clusterName =
+                
TestUtils.getClusterName("LiteNodeDropOutTcpIpJoinerTest_interruptPermanent");
+        int masterPort = TestUtils.getAvailablePort(); // never started
+        int workerPort = TestUtils.getAvailablePort();
+        Address masterAddress = new Address(HOST, masterPort);
+
+        // Counts down once the retry loop reaches the unblacklist step, so 
the interrupt lands
+        // deterministically while the worker is in the 
all-permanent-blacklist retry state.
+        CountDownLatch retryLatch = new CountDownLatch(1);
+
+        Config workerConfig = newConfig(clusterName, workerPort, HOST + ":" + 
masterPort);
+        workerConfig.setLiteMember(true);
+        workerConfig.setProperty("hazelcast.max.join.seconds", "60");
+
+        AtomicReference<HazelcastInstanceImpl> workerRef = new 
AtomicReference<>();
+        Thread workerThread =
+                new Thread(
+                        () -> {
+                            try {
+                                String instanceName =
+                                        
HazelcastInstanceFactory.createInstanceName(workerConfig);
+                                HazelcastInstanceProxy proxy =
+                                        (HazelcastInstanceProxy)
+                                                
HazelcastInstanceFactory.newHazelcastInstance(
+                                                        workerConfig,
+                                                        instanceName,
+                                                        new 
DefaultNodeContext() {
+                                                            @Override
+                                                            public Joiner 
createJoiner(Node node) {
+                                                                return new 
LiteNodeDropOutTcpIpJoiner(
+                                                                        node) {
+                                                                    @Override
+                                                                    public 
void doJoin() {
+                                                                        // 
AbstractJoiner.join
+                                                                        // 
clears old entries
+                                                                        // 
before doJoin, so inject
+                                                                        // the 
permanent exclusion
+                                                                        // 
here.
+                                                                        
blacklist(
+                                                                               
 masterAddress,
+                                                                               
 true);
+                                                                        
super.doJoin();
+                                                                    }
+
+                                                                    @Override
+                                                                    public 
boolean unblacklist(
+                                                                            
Address address) {
+                                                                        
boolean result =
+                                                                               
 super.unblacklist(
+                                                                               
         address);
+                                                                        
retryLatch.countDown();
+                                                                        return 
result;
+                                                                    }
+                                                                };
+                                                            }
+                                                        });
+                                workerRef.set(proxy.getOriginal());
+                            } catch (Throwable ignored) {
+                                // The join attempt is expected to fail after 
interruption.
+                            }
+                        },
+                        "test-interrupt-permanent-blacklist");
+        workerThread.start();
+
+        try {
+            Assertions.assertTrue(
+                    retryLatch.await(30, SECONDS),
+                    "worker should have entered the permanent-blacklist retry 
state");
+
+            workerThread.interrupt();
+            workerThread.join(SECONDS.toMillis(5));
+            Assertions.assertFalse(
+                    workerThread.isAlive(),
+                    "worker should stop retrying promptly after interruption");
+            Assertions.assertNull(workerRef.get(), "worker should not have 
joined");
+        } finally {
+            workerThread.interrupt();
+            if (workerRef.get() != null) {
+                workerRef.get().shutdown();
+            }
+            HazelcastInstanceFactory.shutdownAll();
+        }
+    }
+
+    /**
+     * A joiner that counts down the given latch whenever an address is 
blacklisted, so tests can
+     * deterministically wait for the worker to reach the temporary-blacklist 
state.
+     */
+    private static class CountingBlacklistJoiner extends 
LiteNodeDropOutTcpIpJoiner {
+
+        private final CountDownLatch blacklistLatch;
+
+        CountingBlacklistJoiner(Node node, CountDownLatch blacklistLatch) {
+            super(node);
+            this.blacklistLatch = blacklistLatch;
+        }
+
+        @Override
+        public void blacklist(Address address, boolean permanent) {
+            super.blacklist(address, permanent);
+            if (blacklistLatch != null) {
+                blacklistLatch.countDown();
+            }
+        }
+    }
+
+    /**
+     * Minimal {@link com.hazelcast.instance.impl.NodeContext} that wires in 
the joiner under test.
+     */
+    private static class LiteNodeDropOutJoinerContext extends 
DefaultNodeContext {
+
+        private final CountDownLatch blacklistLatch;
+
+        LiteNodeDropOutJoinerContext(CountDownLatch blacklistLatch) {
+            this.blacklistLatch = blacklistLatch;
+        }
+
+        @Override
+        public Joiner createJoiner(Node node) {
+            return new CountingBlacklistJoiner(node, blacklistLatch);
+        }
+    }
+}

Reply via email to