This is an automated email from the ASF dual-hosted git repository.
davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git
The following commit(s) were added to refs/heads/dev by this push:
new 811702191a [Improve][Zeta] Refactor coordinator executor creation and
fix failover lifecycle issues (#10865)
811702191a is described below
commit 811702191a7d87236a68cc285a03e868d8eece67
Author: zhiwei.niu <[email protected]>
AuthorDate: Thu May 14 22:37:59 2026 +0800
[Improve][Zeta] Refactor coordinator executor creation and fix failover
lifecycle issues (#10865)
---
.../SplitClusterPendingJobLifecycleFailoverIT.java | 396 +++++++++++++++++++++
.../pending_jobs_streaming_lifecycle.conf | 42 +++
.../engine/server/CoordinatorService.java | 41 ++-
.../engine/server/CoordinatorServiceTest.java | 286 ++++++++++++++-
4 files changed, 736 insertions(+), 29 deletions(-)
diff --git
a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/SplitClusterPendingJobLifecycleFailoverIT.java
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/SplitClusterPendingJobLifecycleFailoverIT.java
new file mode 100644
index 0000000000..17033d47b4
--- /dev/null
+++
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/SplitClusterPendingJobLifecycleFailoverIT.java
@@ -0,0 +1,396 @@
+/*
+ * 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.e2e;
+
+import org.apache.seatunnel.common.config.Common;
+import org.apache.seatunnel.common.config.DeployMode;
+import org.apache.seatunnel.engine.client.SeaTunnelClient;
+import org.apache.seatunnel.engine.client.job.ClientJobExecutionEnvironment;
+import org.apache.seatunnel.engine.client.job.ClientJobProxy;
+import org.apache.seatunnel.engine.common.config.ConfigProvider;
+import org.apache.seatunnel.engine.common.config.JobConfig;
+import org.apache.seatunnel.engine.common.config.SeaTunnelConfig;
+import org.apache.seatunnel.engine.common.config.server.ScheduleStrategy;
+import org.apache.seatunnel.engine.common.exception.SeaTunnelEngineException;
+import org.apache.seatunnel.engine.common.job.JobStatus;
+import org.apache.seatunnel.engine.server.SeaTunnelServer;
+import org.apache.seatunnel.engine.server.SeaTunnelServerStarter;
+
+import org.awaitility.Awaitility;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import com.hazelcast.client.config.ClientConfig;
+import com.hazelcast.instance.impl.HazelcastInstanceImpl;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+public class SplitClusterPendingJobLifecycleFailoverIT {
+ private static final String JOB_CONFIG_FILE =
"pending_jobs_streaming_lifecycle.conf";
+
+ @Test
+ public void testPendingJobLifecycleInMasterFailover() {
+ String testClusterName =
+
"SplitClusterPendingJobLifecycleFailoverIT_testPendingJobLifecycleInMasterFailover";
+ HazelcastInstanceImpl masterNode1 = null;
+ HazelcastInstanceImpl masterNode2 = null;
+ HazelcastInstanceImpl workerNode1 = null;
+ HazelcastInstanceImpl workerNode2 = null;
+ SeaTunnelClient engineClient = null;
+ ClientJobProxy holderJob = null;
+ ClientJobProxy pendingJob = null;
+
+ SeaTunnelConfig masterNode1Config =
getSeaTunnelConfig(testClusterName);
+ SeaTunnelConfig masterNode2Config =
getSeaTunnelConfig(testClusterName);
+ SeaTunnelConfig workerNode1Config =
getSeaTunnelConfig(testClusterName);
+ SeaTunnelConfig workerNode2Config =
getSeaTunnelConfig(testClusterName);
+ configurePendingLifecycleTest(masterNode1Config);
+ configurePendingLifecycleTest(masterNode2Config);
+ configurePendingLifecycleTest(workerNode1Config);
+ configurePendingLifecycleTest(workerNode2Config);
+
+ try {
+ masterNode1 =
SeaTunnelServerStarter.createMasterHazelcastInstance(masterNode1Config);
+ masterNode2 =
SeaTunnelServerStarter.createMasterHazelcastInstance(masterNode2Config);
+ workerNode1 =
SeaTunnelServerStarter.createWorkerHazelcastInstance(workerNode1Config);
+
+ HazelcastInstanceImpl finalMasterNode = masterNode1;
+ Awaitility.await()
+ .atMost(10, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertEquals(
+ 3,
finalMasterNode.getCluster().getMembers().size()));
+
+ Common.setDeployMode(DeployMode.CLUSTER);
+ ClientConfig clientConfig =
ConfigProvider.locateAndGetClientConfig();
+
clientConfig.setClusterName(TestUtils.getClusterName(testClusterName));
+ engineClient = new SeaTunnelClient(clientConfig);
+
+ holderJob =
+ submitJob(
+ engineClient,
+ masterNode1Config,
+ "pending_job_lifecycle_holder",
+ TestUtils.getResource(JOB_CONFIG_FILE));
+ assertJobStatusWithTimeout(holderJob, JobStatus.RUNNING, 120);
+
+ HazelcastInstanceImpl activeMaster =
waitAndFindActiveMaster(masterNode1, masterNode2);
+ assertPendingQueueState(activeMaster, null, 0);
+
+ pendingJob =
+ submitJob(
+ engineClient,
+ masterNode1Config,
+ "pending_job_lifecycle_pending",
+ TestUtils.getResource(JOB_CONFIG_FILE));
+ final ClientJobProxy finalPendingJob = pendingJob;
+ final long pendingJobId = finalPendingJob.getJobId();
+ assertJobStatusWithTimeout(pendingJob, JobStatus.PENDING, 120);
+ assertPendingQueueState(activeMaster, pendingJobId, 1);
+ activeMaster.shutdown();
+ HazelcastInstanceImpl standbyMaster =
+ activeMaster == masterNode1 ? masterNode2 : masterNode1;
+
+ Awaitility.await()
+ .atMost(30, TimeUnit.SECONDS)
+ .untilAsserted(
+ () -> {
+ Assertions.assertTrue(
+
standbyMaster.getLifecycleService().isRunning());
+ Assertions.assertEquals(
+ 2,
standbyMaster.getCluster().getMembers().size());
+ });
+ Awaitility.await()
+ .atMost(30, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertTrue(
+ isCoordinatorActive(standbyMaster),
+ "Standby master should become
active after failover"));
+ ClientJobProxy pendingJobAfterFailover =
+ engineClient.createJobClient().getJobProxy(pendingJobId);
+ assertPendingQueueContainsJob(standbyMaster, pendingJobId, 1);
+
+ Awaitility.await()
+ .during(10, TimeUnit.SECONDS)
+ .atMost(20, TimeUnit.SECONDS)
+ .untilAsserted(
+ () -> {
+ assertPendingQueueContainsJob(standbyMaster,
pendingJobId, 1);
+ Assertions.assertEquals(
+ JobStatus.PENDING,
pendingJobAfterFailover.getJobStatus());
+ });
+
+ workerNode2 =
SeaTunnelServerStarter.createWorkerHazelcastInstance(workerNode2Config);
+ Awaitility.await()
+ .atMost(60, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertEquals(
+ 3,
standbyMaster.getCluster().getMembers().size()));
+ assertJobStatusWithTimeout(pendingJobAfterFailover,
JobStatus.RUNNING, 180);
+ assertPendingQueueNotContainsJob(standbyMaster, pendingJobId);
+
+ pendingJobAfterFailover.cancelJob();
+ assertJobStatusWithTimeout(pendingJobAfterFailover,
JobStatus.CANCELED, 120);
+
engineClient.createJobClient().getJobProxy(holderJob.getJobId()).cancelJob();
+ } finally {
+ if (engineClient != null) {
+ engineClient.close();
+ }
+ if (masterNode1 != null) {
+ masterNode1.shutdown();
+ }
+ if (masterNode2 != null) {
+ masterNode2.shutdown();
+ }
+ if (workerNode1 != null) {
+ workerNode1.shutdown();
+ }
+ if (workerNode2 != null) {
+ workerNode2.shutdown();
+ }
+ }
+ }
+
+ @Test
+ public void testPendingJobScheduledAfterRunningJobCanceled() {
+ String testClusterName =
+
"SplitClusterPendingJobLifecycleFailoverIT_testPendingJobScheduledAfterRunningJobCanceled";
+ HazelcastInstanceImpl masterNode = null;
+ HazelcastInstanceImpl workerNode = null;
+ SeaTunnelClient engineClient = null;
+ ClientJobProxy holderJob = null;
+ ClientJobProxy pendingJob = null;
+
+ SeaTunnelConfig masterNodeConfig = getSeaTunnelConfig(testClusterName);
+ SeaTunnelConfig workerNodeConfig = getSeaTunnelConfig(testClusterName);
+ configurePendingLifecycleTest(masterNodeConfig);
+ configurePendingLifecycleTest(workerNodeConfig);
+
+ try {
+ masterNode =
SeaTunnelServerStarter.createMasterHazelcastInstance(masterNodeConfig);
+ workerNode =
SeaTunnelServerStarter.createWorkerHazelcastInstance(workerNodeConfig);
+
+ HazelcastInstanceImpl finalMasterNode = masterNode;
+ Awaitility.await()
+ .atMost(10, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertEquals(
+ 2,
finalMasterNode.getCluster().getMembers().size()));
+
+ Common.setDeployMode(DeployMode.CLUSTER);
+ ClientConfig clientConfig =
ConfigProvider.locateAndGetClientConfig();
+
clientConfig.setClusterName(TestUtils.getClusterName(testClusterName));
+ engineClient = new SeaTunnelClient(clientConfig);
+
+ holderJob =
+ submitJob(
+ engineClient,
+ masterNodeConfig,
+ "pending_job_lifecycle_holder_cancel",
+ TestUtils.getResource(JOB_CONFIG_FILE));
+ assertJobStatusWithTimeout(holderJob, JobStatus.RUNNING, 120);
+
+ HazelcastInstanceImpl activeMaster =
waitAndFindActiveMaster(masterNode, null);
+ assertPendingQueueState(activeMaster, null, 0);
+
+ pendingJob =
+ submitJob(
+ engineClient,
+ masterNodeConfig,
+ "pending_job_lifecycle_pending_after_cancel",
+ TestUtils.getResource(JOB_CONFIG_FILE));
+ long pendingJobId = pendingJob.getJobId();
+ assertJobStatusWithTimeout(pendingJob, JobStatus.PENDING, 120);
+ assertPendingQueueState(activeMaster, pendingJobId, 1);
+
+ holderJob.cancelJob();
+ assertJobStatusWithTimeout(holderJob, JobStatus.CANCELED, 120);
+
+ assertJobStatusWithTimeout(pendingJob, JobStatus.RUNNING, 180);
+ assertPendingQueueNotContainsJob(activeMaster, pendingJobId);
+
+ pendingJob.cancelJob();
+ assertJobStatusWithTimeout(pendingJob, JobStatus.CANCELED, 120);
+ } finally {
+ if (engineClient != null) {
+ engineClient.close();
+ }
+ if (masterNode != null) {
+ masterNode.shutdown();
+ }
+ if (workerNode != null) {
+ workerNode.shutdown();
+ }
+ }
+ }
+
+ @NotNull private static SeaTunnelConfig getSeaTunnelConfig(String
testClusterName) {
+ SeaTunnelConfig seaTunnelConfig =
ConfigProvider.locateAndGetSeaTunnelConfig();
+ seaTunnelConfig
+ .getHazelcastConfig()
+ .setClusterName(TestUtils.getClusterName(testClusterName));
+ return seaTunnelConfig;
+ }
+
+ private static void configurePendingLifecycleTest(SeaTunnelConfig
seaTunnelConfig) {
+
seaTunnelConfig.getEngineConfig().getSlotServiceConfig().setDynamicSlot(false);
+ seaTunnelConfig.getEngineConfig().getSlotServiceConfig().setSlotNum(4);
+
seaTunnelConfig.getEngineConfig().setScheduleStrategy(ScheduleStrategy.WAIT);
+ seaTunnelConfig.getEngineConfig().getHttpConfig().setEnabled(false);
+ }
+
+ private static ClientJobProxy submitJob(
+ SeaTunnelClient engineClient,
+ SeaTunnelConfig seaTunnelConfig,
+ String jobName,
+ String jobConfigFile) {
+ JobConfig jobConfig = new JobConfig();
+ jobConfig.setName(jobName);
+ ClientJobExecutionEnvironment jobExecutionEnv =
+ engineClient.createExecutionContext(jobConfigFile, jobConfig,
seaTunnelConfig);
+ try {
+ return jobExecutionEnv.execute();
+ } catch (ExecutionException e) {
+ throw new RuntimeException("Failed to submit job " + jobName, e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted when submitting job " +
jobName, e);
+ }
+ }
+
+ private static void assertJobStatusWithTimeout(
+ ClientJobProxy clientJobProxy, JobStatus expectedStatus, long
timeoutSeconds) {
+ Awaitility.await()
+ .atMost(timeoutSeconds, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertEquals(
+ expectedStatus,
clientJobProxy.getJobStatus()));
+ }
+
+ private static HazelcastInstanceImpl waitAndFindActiveMaster(
+ HazelcastInstanceImpl masterNode1, HazelcastInstanceImpl
masterNode2) {
+ final HazelcastInstanceImpl[] activeMasterRef = new
HazelcastInstanceImpl[1];
+ Awaitility.await()
+ .atMost(30, TimeUnit.SECONDS)
+ .untilAsserted(
+ () -> {
+ activeMasterRef[0] = findActiveMaster(masterNode1,
masterNode2);
+ Assertions.assertNotNull(
+ activeMasterRef[0],
+ "Should find active master after
coordinator initialization");
+ });
+ return activeMasterRef[0];
+ }
+
+ private static HazelcastInstanceImpl findActiveMaster(
+ HazelcastInstanceImpl masterNode1, HazelcastInstanceImpl
masterNode2) {
+ if (isCoordinatorActive(masterNode1)) {
+ return masterNode1;
+ }
+ if (isCoordinatorActive(masterNode2)) {
+ return masterNode2;
+ }
+ return null;
+ }
+
+ private static boolean isCoordinatorActive(HazelcastInstanceImpl
masterNode) {
+ if (masterNode == null ||
!masterNode.getLifecycleService().isRunning()) {
+ return false;
+ }
+ SeaTunnelServer server =
+
masterNode.node.getNodeEngine().getService(SeaTunnelServer.SERVICE_NAME);
+ try {
+ return server.getCoordinatorService().isCoordinatorActive();
+ } catch (SeaTunnelEngineException e) {
+ return false;
+ }
+ }
+
+ private static void assertPendingQueueState(
+ HazelcastInstanceImpl masterNode, Long expectedJobIdInQueue, int
expectedPendingCount) {
+ SeaTunnelServer server =
+
masterNode.node.getNodeEngine().getService(SeaTunnelServer.SERVICE_NAME);
+ Awaitility.await()
+ .atMost(20, TimeUnit.SECONDS)
+ .untilAsserted(
+ () -> {
+ Assertions.assertTrue(
+
server.getCoordinatorService().isCoordinatorActive(),
+ "Coordinator should be active when
asserting pending queue");
+ Assertions.assertEquals(
+ expectedPendingCount,
+
server.getCoordinatorService().getPendingJobCount());
+ if (expectedJobIdInQueue != null) {
+ Assertions.assertTrue(
+ server.getCoordinatorService()
+ .getPendingJobQueue()
+
.contains(expectedJobIdInQueue),
+ "Expected pending job should remain in
pending queue");
+ }
+ });
+ }
+
+ private static void assertPendingQueueContainsJob(
+ HazelcastInstanceImpl masterNode,
+ long expectedJobIdInQueue,
+ int minExpectedPendingCount) {
+ SeaTunnelServer server =
+
masterNode.node.getNodeEngine().getService(SeaTunnelServer.SERVICE_NAME);
+ Awaitility.await()
+ .atMost(20, TimeUnit.SECONDS)
+ .untilAsserted(
+ () -> {
+ Assertions.assertTrue(
+
server.getCoordinatorService().isCoordinatorActive(),
+ "Coordinator should be active when
asserting pending queue");
+ Assertions.assertTrue(
+
server.getCoordinatorService().getPendingJobCount()
+ >= minExpectedPendingCount,
+ "Pending queue count should be at least "
+ + minExpectedPendingCount);
+ Assertions.assertTrue(
+ server.getCoordinatorService()
+ .getPendingJobQueue()
+ .contains(expectedJobIdInQueue),
+ "Expected pending job should remain in
pending queue");
+ });
+ }
+
+ private static void assertPendingQueueNotContainsJob(
+ HazelcastInstanceImpl masterNode, long expectedRemovedJobId) {
+ SeaTunnelServer server =
+
masterNode.node.getNodeEngine().getService(SeaTunnelServer.SERVICE_NAME);
+ Awaitility.await()
+ .atMost(20, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertFalse(
+ server.getCoordinatorService()
+ .getPendingJobQueue()
+
.contains(expectedRemovedJobId),
+ "Pending queue should not contain job "
+ + expectedRemovedJobId));
+ }
+}
diff --git
a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/pending_jobs_streaming_lifecycle.conf
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/pending_jobs_streaming_lifecycle.conf
new file mode 100644
index 0000000000..4317278dd2
--- /dev/null
+++
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/pending_jobs_streaming_lifecycle.conf
@@ -0,0 +1,42 @@
+#
+# 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.
+#
+
+env {
+ job.mode = "STREAMING"
+}
+
+source {
+ FakeSource {
+ parallelism = 2
+ row.num = 1000000
+ split.read-interval = 100
+ schema = {
+ fields {
+ c_int = int
+ }
+ }
+ }
+}
+
+transform {
+}
+
+sink {
+ InMemory {
+ writer_sleep = true
+ }
+}
diff --git
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/CoordinatorService.java
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/CoordinatorService.java
index fffe655cf8..a79ff5f2f1 100644
---
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/CoordinatorService.java
+++
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/CoordinatorService.java
@@ -223,17 +223,7 @@ public class CoordinatorService {
this.nodeEngine = nodeEngine;
this.engineConfig = engineConfig;
this.logger = nodeEngine.getLogger(getClass());
- this.executorService =
- new ThreadPoolExecutor(
-
engineConfig.getCoordinatorServiceConfig().getCoreThreadNum(),
-
engineConfig.getCoordinatorServiceConfig().getMaxThreadNum(),
- 60L,
- TimeUnit.SECONDS,
- new SynchronousQueue<>(),
- new ThreadFactoryBuilder()
-
.setNameFormat("seatunnel-coordinator-service-%d")
- .build(),
- new ThreadPoolStatus.RejectionCountingHandler());
+ this.executorService = createCoordinatorExecutor();
this.seaTunnelServer = seaTunnelServer;
masterActiveListener = Executors.newSingleThreadScheduledExecutor();
@@ -251,9 +241,19 @@ public class CoordinatorService {
TimeUnit.SECONDS);
scheduleStrategy = engineConfig.getScheduleStrategy();
isWaitStrategy = scheduleStrategy.equals(ScheduleStrategy.WAIT);
- logger.info("Start pending job schedule thread");
- // start pending job schedule thread
- startPendingJobScheduleThread();
+ }
+
+ private ExecutorService createCoordinatorExecutor() {
+ return new ThreadPoolExecutor(
+ engineConfig.getCoordinatorServiceConfig().getCoreThreadNum(),
+ engineConfig.getCoordinatorServiceConfig().getMaxThreadNum(),
+ 60L,
+ TimeUnit.SECONDS,
+ new SynchronousQueue<>(),
+ new ThreadFactoryBuilder()
+ .setNameFormat("seatunnel-coordinator-service-%d")
+ .build(),
+ new ThreadPoolStatus.RejectionCountingHandler());
}
/**
@@ -264,10 +264,11 @@ public class CoordinatorService {
* does not terminate future pending-job processing.
*/
private void startPendingJobScheduleThread() {
+ logger.info("Start pending job schedule thread");
Runnable pendingJobScheduleTask =
() -> {
Thread.currentThread().setName("pending-job-schedule-runner");
- while (true) {
+ while (isActive) {
try {
pendingJobSchedule();
} catch (InterruptedException interrupted) {
@@ -1010,15 +1011,12 @@ public class CoordinatorService {
if (!isActive && this.seaTunnelServer.isMasterNode()) {
logger.info(
"This node become a new active master node, begin init
coordinator service");
- if (this.executorService.isShutdown()) {
- this.executorService =
- Executors.newCachedThreadPool(
- new ThreadFactoryBuilder()
-
.setNameFormat("seatunnel-coordinator-service-%d")
- .build());
+ if (this.executorService.isShutdown() ||
this.executorService.isTerminated()) {
+ this.executorService = createCoordinatorExecutor();
}
initCoordinatorService();
isActive = true;
+ startPendingJobScheduleThread();
} else if (isActive && !this.seaTunnelServer.isMasterNode()) {
isActive = false;
logger.info(
@@ -1555,6 +1553,7 @@ public class CoordinatorService {
}
public void shutdown() {
+ isActive = false;
if (masterActiveListener != null) {
masterActiveListener.shutdownNow();
}
diff --git
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/CoordinatorServiceTest.java
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/CoordinatorServiceTest.java
index 195e0b878a..484546676c 100644
---
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/CoordinatorServiceTest.java
+++
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/CoordinatorServiceTest.java
@@ -20,18 +20,25 @@ package org.apache.seatunnel.engine.server;
import org.apache.seatunnel.common.utils.ReflectionUtils;
import org.apache.seatunnel.engine.common.Constant;
import org.apache.seatunnel.engine.common.config.ConfigProvider;
+import org.apache.seatunnel.engine.common.config.EngineConfig;
import org.apache.seatunnel.engine.common.config.SeaTunnelConfig;
+import org.apache.seatunnel.engine.common.config.server.ScheduleStrategy;
import org.apache.seatunnel.engine.common.exception.SeaTunnelEngineException;
+import org.apache.seatunnel.engine.common.job.JobResult;
import org.apache.seatunnel.engine.common.job.JobStatus;
+import org.apache.seatunnel.engine.common.utils.PassiveCompletableFuture;
import org.apache.seatunnel.engine.common.utils.concurrent.CompletableFuture;
import org.apache.seatunnel.engine.core.dag.logical.LogicalDag;
import org.apache.seatunnel.engine.core.job.JobDAGInfo;
import org.apache.seatunnel.engine.core.job.JobImmutableInformation;
import org.apache.seatunnel.engine.core.job.JobInfo;
import org.apache.seatunnel.engine.core.job.PipelineStatus;
+import org.apache.seatunnel.engine.server.dag.physical.PhysicalPlan;
import org.apache.seatunnel.engine.server.dag.physical.PipelineLocation;
import org.apache.seatunnel.engine.server.dag.physical.SubPlan;
import org.apache.seatunnel.engine.server.execution.ExecutionState;
+import org.apache.seatunnel.engine.server.execution.PendingJobInfo;
+import org.apache.seatunnel.engine.server.execution.PendingSourceState;
import org.apache.seatunnel.engine.server.execution.TaskGroupContext;
import org.apache.seatunnel.engine.server.execution.TaskGroupLocation;
import org.apache.seatunnel.engine.server.execution.TaskLocation;
@@ -51,6 +58,7 @@ import org.mockito.Mockito;
import com.hazelcast.instance.impl.HazelcastInstanceImpl;
import com.hazelcast.internal.serialization.Data;
+import com.hazelcast.logging.ILogger;
import com.hazelcast.map.IMap;
import com.hazelcast.spi.exception.RetryableHazelcastException;
import com.hazelcast.spi.impl.NodeEngineImpl;
@@ -70,7 +78,10 @@ import java.util.concurrent.CompletionException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import static
org.apache.seatunnel.engine.core.classloader.DefaultClassLoaderService.SKIP_CHECK_JAR;
import static org.awaitility.Awaitility.await;
@@ -157,10 +168,13 @@ public class CoordinatorServiceTest {
await().atMost(120, TimeUnit.SECONDS)
.untilAsserted(
- () ->
- Assertions.assertEquals(
- JobStatus.CANCELED,
-
coordinatorService.getJobStatus(jobId)));
+ () -> {
+ JobStatus status =
coordinatorService.getJobStatus(jobId);
+ Assertions.assertTrue(
+ status == JobStatus.CANCELING || status ==
JobStatus.CANCELED,
+ "Expected job status to be CANCELING or
CANCELED, but got "
+ + status);
+ });
// Ensure the real execution is gone before injecting stale zombie
metadata.
await().atMost(30, TimeUnit.SECONDS)
@@ -258,7 +272,7 @@ public class CoordinatorServiceTest {
return false;
}
} catch (Exception e) {
- return true;
+ continue;
}
}
return true;
@@ -310,6 +324,175 @@ public class CoordinatorServiceTest {
instance2.shutdown();
}
+ @Test
+ void testCheckNewActiveMasterCanSchedulePendingQueue() throws Exception {
+ AtomicBoolean masterFlag = new AtomicBoolean(false);
+ SeaTunnelServer server = Mockito.mock(SeaTunnelServer.class);
+ Mockito.when(server.isMasterNode()).thenAnswer(invocation ->
masterFlag.get());
+ CoordinatorService coordinatorService =
newMockCoordinatorService(server);
+ try {
+ CountDownLatch runLatch = new CountDownLatch(1);
+ JobMaster jobMaster = enqueueMockPendingJob(coordinatorService,
10001L, runLatch);
+
+ masterFlag.set(true);
+ invokeCheckNewActiveMaster(coordinatorService);
+
+ await().atMost(5, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
Assertions.assertTrue(coordinatorService.isCoordinatorActive()));
+ await().atMost(5, TimeUnit.SECONDS)
+ .untilAsserted(() -> Assertions.assertEquals(0L,
runLatch.getCount()));
+ Mockito.verify(jobMaster, Mockito.times(1)).run();
+ } finally {
+ coordinatorService.shutdown();
+ }
+ }
+
+ @Test
+ void testFailoverStopsOldPendingQueueAndNewCoordinatorCanSchedule() throws
Exception {
+ AtomicBoolean oldMasterFlag = new AtomicBoolean(false);
+ SeaTunnelServer oldServer = Mockito.mock(SeaTunnelServer.class);
+ Mockito.when(oldServer.isMasterNode()).thenAnswer(invocation ->
oldMasterFlag.get());
+ CoordinatorService oldCoordinator =
newMockCoordinatorService(oldServer);
+ try {
+ oldMasterFlag.set(true);
+ invokeCheckNewActiveMaster(oldCoordinator);
+
+ await().atMost(15, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
Assertions.assertTrue(oldCoordinator.isCoordinatorActive()));
+
+ oldMasterFlag.set(false);
+ invokeCheckNewActiveMaster(oldCoordinator);
+
Assertions.assertTrue(getCoordinatorExecutor(oldCoordinator).isShutdown());
+
+ CountDownLatch oldRunLatch = new CountDownLatch(1);
+ JobMaster oldPendingJob = enqueueMockPendingJob(oldCoordinator,
20001L, oldRunLatch);
+
Assertions.assertTrue(oldCoordinator.getPendingJobQueue().contains(20001L));
+ await().during(1, TimeUnit.SECONDS)
+ .atMost(2, TimeUnit.SECONDS)
+ .untilAsserted(() -> Assertions.assertEquals(1L,
oldRunLatch.getCount()));
+ Mockito.verify(oldPendingJob, Mockito.never()).run();
+ Assertions.assertTrue(
+ oldCoordinator.getPendingJobQueue().contains(20001L),
+ "old coordinator pending queue should not be consumed
after failover");
+
+ AtomicBoolean newMasterFlag = new AtomicBoolean(true);
+ SeaTunnelServer newServer = Mockito.mock(SeaTunnelServer.class);
+ Mockito.when(newServer.isMasterNode()).thenAnswer(invocation ->
newMasterFlag.get());
+ CoordinatorService newCoordinator =
newMockCoordinatorService(newServer);
+ try {
+ CountDownLatch newRunLatch = new CountDownLatch(1);
+ JobMaster newPendingJob =
+ enqueueMockPendingJob(newCoordinator, 30001L,
newRunLatch);
+
+ invokeCheckNewActiveMaster(newCoordinator);
+
+ await().atMost(5, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
Assertions.assertTrue(newCoordinator.isCoordinatorActive()));
+ await().atMost(5, TimeUnit.SECONDS)
+ .untilAsserted(() -> Assertions.assertEquals(0L,
newRunLatch.getCount()));
+ Mockito.verify(newPendingJob, Mockito.times(1)).run();
+
Assertions.assertFalse(newCoordinator.getPendingJobQueue().contains(30001L));
+ } finally {
+ newCoordinator.shutdown();
+ }
+ } finally {
+ if (!getCoordinatorExecutor(oldCoordinator).isShutdown()) {
+ oldCoordinator.shutdown();
+ }
+ }
+ }
+
+ @Test
+ void testCheckNewActiveMasterIsIdempotentWhenAlreadyActive() throws
Exception {
+ AtomicBoolean masterFlag = new AtomicBoolean(true);
+ SeaTunnelServer server = Mockito.mock(SeaTunnelServer.class);
+ Mockito.when(server.isMasterNode()).thenAnswer(invocation ->
masterFlag.get());
+ CoordinatorService coordinatorService =
newMockCoordinatorService(server);
+ try {
+ CountDownLatch runLatch = new CountDownLatch(1);
+ JobMaster jobMaster = enqueueMockPendingJob(coordinatorService,
40001L, runLatch);
+
+ invokeCheckNewActiveMaster(coordinatorService);
+ await().atMost(5, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
Assertions.assertTrue(coordinatorService.isCoordinatorActive()));
+ await().atMost(5, TimeUnit.SECONDS)
+ .untilAsserted(() -> Assertions.assertEquals(0L,
runLatch.getCount()));
+
+ invokeCheckNewActiveMaster(coordinatorService);
+ await().during(1, TimeUnit.SECONDS)
+ .atMost(2, TimeUnit.SECONDS)
+ .untilAsserted(() -> Mockito.verify(jobMaster,
Mockito.times(1)).run());
+ } finally {
+ coordinatorService.shutdown();
+ }
+ }
+
+ @Test
+ void testPendingJobWithInsufficientResourceRespectsWaitStrategy() throws
Exception {
+ AtomicBoolean masterFlag = new AtomicBoolean(true);
+ SeaTunnelServer server = Mockito.mock(SeaTunnelServer.class);
+ Mockito.when(server.isMasterNode()).thenAnswer(invocation ->
masterFlag.get());
+
+ EngineConfig engineConfig = new EngineConfig();
+ engineConfig.setScheduleStrategy(ScheduleStrategy.WAIT);
+ CoordinatorService coordinatorService =
newMockCoordinatorService(server, engineConfig);
+ try {
+ CountDownLatch runLatch = new CountDownLatch(1);
+ JobMaster jobMaster =
+ enqueueMockPendingJob(coordinatorService, 50001L,
runLatch, false);
+
+ invokeCheckNewActiveMaster(coordinatorService);
+ await().atMost(5, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
Assertions.assertTrue(coordinatorService.isCoordinatorActive()));
+ await().during(1, TimeUnit.SECONDS)
+ .atMost(2, TimeUnit.SECONDS)
+ .untilAsserted(() -> Assertions.assertEquals(1L,
runLatch.getCount()));
+
+ Mockito.verify(jobMaster, Mockito.never()).run();
+
Assertions.assertTrue(coordinatorService.getPendingJobQueue().contains(50001L));
+ } finally {
+ coordinatorService.shutdown();
+ }
+ }
+
+ @Test
+ void testPendingJobWithInsufficientResourceRespectsRejectStrategy() throws
Exception {
+ AtomicBoolean masterFlag = new AtomicBoolean(true);
+ SeaTunnelServer server = Mockito.mock(SeaTunnelServer.class);
+ Mockito.when(server.isMasterNode()).thenAnswer(invocation ->
masterFlag.get());
+
+ EngineConfig engineConfig = new EngineConfig();
+ engineConfig.setScheduleStrategy(ScheduleStrategy.REJECT);
+ CoordinatorService coordinatorService =
newMockCoordinatorService(server, engineConfig);
+ try {
+ CountDownLatch runLatch = new CountDownLatch(1);
+ JobMaster jobMaster =
+ enqueueMockPendingJob(coordinatorService, 60001L,
runLatch, false);
+
+ invokeCheckNewActiveMaster(coordinatorService);
+ await().atMost(5, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
Assertions.assertTrue(coordinatorService.isCoordinatorActive()));
+ await().atMost(5, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertFalse(
+ coordinatorService
+ .getPendingJobQueue()
+ .contains(60001L)));
+
+ Mockito.verify(jobMaster, Mockito.never()).run();
+ Assertions.assertEquals(1L, runLatch.getCount());
+ } finally {
+ coordinatorService.shutdown();
+ }
+ }
+
private HazelcastInstanceImpl createHazelcastInstanceWithJoinPortTryCount(
String clusterName, int joinPortTryCount) {
SeaTunnelConfig seaTunnelConfig =
ConfigProvider.locateAndGetSeaTunnelConfig();
@@ -320,6 +503,82 @@ public class CoordinatorServiceTest {
return SeaTunnelServerStarter.createHazelcastInstance(seaTunnelConfig);
}
+ private CoordinatorService newMockCoordinatorService(SeaTunnelServer
server) {
+ return newMockCoordinatorService(server, new EngineConfig());
+ }
+
+ private CoordinatorService newMockCoordinatorService(
+ SeaTunnelServer server, EngineConfig engineConfig) {
+ NodeEngineImpl nodeEngine = Mockito.mock(NodeEngineImpl.class);
+ ILogger logger = Mockito.mock(ILogger.class);
+ HazelcastInstanceImpl hazelcastInstance =
Mockito.mock(HazelcastInstanceImpl.class);
+ IMap<Object, Object> map = Mockito.mock(IMap.class);
+ Mockito.when(map.entrySet()).thenReturn(Collections.emptySet());
+
Mockito.when(nodeEngine.getLogger(Mockito.any(Class.class))).thenReturn(logger);
+
Mockito.when(nodeEngine.getHazelcastInstance()).thenReturn(hazelcastInstance);
+
Mockito.when(hazelcastInstance.getMap(Mockito.anyString())).thenReturn(map);
+
+ CoordinatorService coordinatorService =
+ new CoordinatorService(nodeEngine, server, engineConfig);
+ stopCoordinatorSchedulers(coordinatorService);
+ return coordinatorService;
+ }
+
+ private void stopCoordinatorSchedulers(CoordinatorService
coordinatorService) {
+ ReflectionUtils.getField(coordinatorService, "masterActiveListener")
+ .map(ScheduledExecutorService.class::cast)
+ .ifPresent(ScheduledExecutorService::shutdownNow);
+ ReflectionUtils.getField(coordinatorService,
"pipelineCleanupScheduler")
+ .map(ScheduledExecutorService.class::cast)
+ .ifPresent(ScheduledExecutorService::shutdownNow);
+ }
+
+ private JobMaster enqueueMockPendingJob(
+ CoordinatorService coordinatorService, long jobId, CountDownLatch
runLatch) {
+ return enqueueMockPendingJob(coordinatorService, jobId, runLatch,
true);
+ }
+
+ private JobMaster enqueueMockPendingJob(
+ CoordinatorService coordinatorService,
+ long jobId,
+ CountDownLatch runLatch,
+ boolean preApplyResourceResult) {
+ JobMaster jobMaster = Mockito.mock(JobMaster.class);
+ PhysicalPlan physicalPlan = Mockito.mock(PhysicalPlan.class);
+ @SuppressWarnings("unchecked")
+ PassiveCompletableFuture<JobResult> completionFuture =
+ Mockito.mock(PassiveCompletableFuture.class);
+
+ Mockito.when(jobMaster.getJobId()).thenReturn(jobId);
+
Mockito.when(jobMaster.preApplyResources()).thenReturn(preApplyResourceResult);
+ Mockito.when(jobMaster.getPhysicalPlan()).thenReturn(physicalPlan);
+
Mockito.when(jobMaster.getJobMasterCompleteFuture()).thenReturn(completionFuture);
+ Mockito.when(completionFuture.isCancelled()).thenReturn(false);
+
Mockito.when(completionFuture.isCompletedExceptionally()).thenReturn(false);
+ Mockito.when(physicalPlan.getJobFullName()).thenReturn("mock-job-" +
jobId);
+ Mockito.doAnswer(
+ invocation -> {
+ runLatch.countDown();
+ return null;
+ })
+ .when(jobMaster)
+ .run();
+
+ coordinatorService
+ .getPendingJobQueue()
+ .put(new PendingJobInfo(PendingSourceState.SUBMIT, jobMaster));
+ return jobMaster;
+ }
+
+ private ThreadPoolExecutor getCoordinatorExecutor(CoordinatorService
coordinatorService) {
+ return ReflectionUtils.getField(coordinatorService, "executorService")
+ .map(ThreadPoolExecutor.class::cast)
+ .orElseThrow(
+ () ->
+ new AssertionError(
+ "Failed to get coordinator
executorService by reflection"));
+ }
+
@Test
public void
testSeaTunnelEngineRetryableExceptionOperationCanBeRetryByHazelcast() {
@@ -795,6 +1054,13 @@ public class CoordinatorServiceTest {
method.invoke(coordinatorService, jobId, jobInfo);
}
+ private void invokeCheckNewActiveMaster(CoordinatorService
coordinatorService)
+ throws Exception {
+ Method method =
CoordinatorService.class.getDeclaredMethod("checkNewActiveMaster");
+ method.setAccessible(true);
+ method.invoke(coordinatorService);
+ }
+
@Test
public void testClearCoordinatorService() {
JobInformation jobInformation =
@@ -903,13 +1169,17 @@ public class CoordinatorServiceTest {
coordinatorService, "runningJobInfoIMap",
runningJobInfoIMap);
}
+ await().atMost(10, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertTrue(
+
coordinatorService.getPendingJobQueue().contains(jobId)
+ ||
getRunningJobMasterMap(coordinatorService)
+
.containsKey(jobId)));
Long[] jobStateTimestamps =
runningJobStateTimestampsIMap.get(jobId);
Assertions.assertNotNull(jobStateTimestamps);
Assertions.assertEquals(
initializationTimestamp,
jobStateTimestamps[JobStatus.INITIALIZING.ordinal()]);
- Assertions.assertTrue(
- coordinatorService.getPendingJobQueue().contains(jobId)
- ||
getRunningJobMasterMap(coordinatorService).containsKey(jobId));
} finally {
instance.shutdown();
}