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-12030-3a7c1226a5370aabcc1f851893acbc783f28f580 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 20900262eeea837b1321138a9bccb0437abefc54 Author: David Zollo <[email protected]> AuthorDate: Wed Sep 9 05:06:11 2026 +0000 [Test][E2E] Add regression coverage for task stuck in CANCELING when worker crashes mid-cancel (#12030) --- .../engine/e2e/SplitClusterFaultToleranceIT.java | 246 ++++++++++++++++++++- 1 file changed, 237 insertions(+), 9 deletions(-) diff --git a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/SplitClusterFaultToleranceIT.java b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/SplitClusterFaultToleranceIT.java index 71d76ed713..665bb340a1 100644 --- a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/SplitClusterFaultToleranceIT.java +++ b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/SplitClusterFaultToleranceIT.java @@ -29,7 +29,9 @@ 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.exception.SeaTunnelEngineException; +import org.apache.seatunnel.engine.common.job.JobResult; import org.apache.seatunnel.engine.common.job.JobStatus; +import org.apache.seatunnel.engine.core.job.PipelineStatus; import org.apache.seatunnel.engine.server.SeaTunnelServer; import org.apache.seatunnel.engine.server.SeaTunnelServerStarter; import org.apache.seatunnel.engine.server.dag.physical.PhysicalPlan; @@ -54,6 +56,7 @@ import lombok.extern.slf4j.Slf4j; import java.io.File; import java.io.IOException; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; @@ -333,6 +336,240 @@ public class SplitClusterFaultToleranceIT { } } + /** + * Regression test for the CANCELING-stuck-forever bug fixed by <a + * href="https://github.com/apache/seatunnel/pull/10729">#10729</a> ("[Fix][Zeta] prevent cancel + * stuck and downgrade tmp cleanup failure"). Before that fix, {@code + * PhysicalVertex#noticeTaskExecutionServiceCancel} sent a {@code CancelTaskOperation} to the + * worker owning the task and retried while that worker remained a cluster member, but once the + * worker actually left the cluster before acking the cancel, the retry loop simply exited + * without ever resolving the task's state: the vertex - and therefore its pipeline and job - + * stayed CANCELING forever, because nothing else in that method drove it to a terminal state. + * The fix added a fallback right after the retry loop: if the loop exits with the ack still + * missing and the vertex is still CANCELING, mark it CANCELED locally. + * + * <p>{@code CoordinatorService#failedTaskOnMemberRemoved} also matches CANCELING tasks when a + * member is lost, but it cannot be the one to save this race: {@code PhysicalVertex#cancel}, + * {@code #updateTaskState} and {@code #stateProcess} are all {@code synchronized} on the vertex + * itself, and the whole call chain down into {@code noticeTaskExecutionServiceCancel} runs + * without releasing that lock. A competing {@code failedTaskOnMemberRemoved} call has to go + * through the same {@code synchronized updateTaskState}, so it can only run after the + * cancelling thread has already returned - by which point the fixed code has already resolved + * the vertex to CANCELED, and the end-state consistency check inside {@code updateTaskState} + * rejects any later attempt to move a terminal-state task to FAILED. So the fixed method's own + * fallback is the sole, deterministic mechanism that unsticks this specific race, which is why + * this test asserts CANCELED rather than FAILED as the outcome. + * + * <p>No existing fault-tolerance test combines "cancel requested" with "worker crashes before + * the cancel ack arrives": {@link #testStreamJobRunOk()} cancels a job on a fully healthy + * cluster, and {@link #testStreamJobRestoreInWorkerDown()} kills a worker on a job that is + * simply RUNNING with no cancel in flight, then cancels only after restore has completed. This + * test constructs the narrow race directly: it white-box polls {@code + * PhysicalVertex#getExecutionState()} (same in-JVM technique as {@code + * SplitClusterPendingJobLifecycleFailoverIT#getJobMaster}) in a tight loop right after issuing + * the cancel, and shuts the worker down the instant a task vertex is first observed CANCELING - + * landing squarely inside the RPC-in-flight window instead of guessing at timing. + */ + @Test + public void testStreamJobCancelResolvesWhenWorkerCrashesBeforeCancelAck() throws Exception { + String testCaseName = "testStreamJobCancelResolvesWhenWorkerCrashesBeforeCancelAck"; + String testClusterName = "SplitClusterFaultToleranceIT_" + testCaseName; + HazelcastInstanceImpl masterNode = null; + HazelcastInstanceImpl workerNode = null; + SeaTunnelClient engineClient = null; + + SeaTunnelConfig seaTunnelConfig = getSeaTunnelConfig(testClusterName); + SeaTunnelConfig masterNodeConfig = getSeaTunnelConfig(testClusterName); + SeaTunnelConfig workerNodeConfig = getSeaTunnelConfig(testClusterName); + + try { + masterNode = SeaTunnelServerStarter.createMasterHazelcastInstance(masterNodeConfig); + + workerNode = SeaTunnelServerStarter.createWorkerHazelcastInstance(workerNodeConfig); + + // waiting all node added to cluster + HazelcastInstanceImpl finalMasterNode = masterNode; + Awaitility.await() + .atMost(10000, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> + Assertions.assertEquals( + 2, finalMasterNode.getCluster().getMembers().size())); + + Common.setDeployMode(DeployMode.CLIENT); + JobConfig jobConfig = new JobConfig(); + jobConfig.setName(testCaseName); + + ClientConfig clientConfig = ConfigProvider.locateAndGetClientConfig(); + clientConfig.setClusterName(TestUtils.getClusterName(testClusterName)); + engineClient = new SeaTunnelClient(clientConfig); + ClientJobExecutionEnvironment jobExecutionEnv = + engineClient.createExecutionContext( + TestUtils.getResource("pending_jobs_streaming_lifecycle.conf"), + jobConfig, + seaTunnelConfig); + ClientJobProxy clientJobProxy = jobExecutionEnv.execute(); + long jobId = clientJobProxy.getJobId(); + + Awaitility.await() + .atMost(120, TimeUnit.SECONDS) + .untilAsserted( + () -> + Assertions.assertEquals( + JobStatus.RUNNING, clientJobProxy.getJobStatus())); + // Wait for every task vertex to actually finish deploying before cancelling, so the + // CANCELING transition observed below is unambiguously caused by the cancel request, + // not mixed up with an in-flight DEPLOYING -> RUNNING transition. + assertAllVerticesRunning(masterNode, jobId, 60); + + // CoordinatorService#cancelJob runs JobMaster#cancelJob synchronously on its own + // executor, and the client's cancelJob() blocks (PassiveCompletableFuture#join) until + // that whole per-vertex cancel resolves. Issue it on its own thread so this thread + // stays free to tight-poll for CANCELING and react to it immediately. + CompletableFuture<Void> cancelInvocation = + CompletableFuture.runAsync(clientJobProxy::cancelJob); + + boolean observedCanceling = + awaitAnyVertexCanceling(masterNode, jobId, 10, TimeUnit.SECONDS); + Assertions.assertTrue( + observedCanceling, + "Should have observed a task vertex enter CANCELING before its cancel ack " + + "could possibly arrive; otherwise this test never exercised the " + + "race it targets"); + + // The vertex is CANCELING and still waiting on a CancelTaskOperation ack from + // workerNode. Kill workerNode right now, before that ack can arrive, landing inside + // the exact window PR #10729 fixed: cancel requested, CANCELING set, worker gone + // before the terminal callback. + workerNode.shutdown(); + + CompletableFuture<JobResult> waitForCompleteFuture = + CompletableFuture.supplyAsync(clientJobProxy::waitForJobCompleteV2); + assertEventuallyCanceled(clientJobProxy, waitForCompleteFuture); + + Assertions.assertDoesNotThrow( + () -> cancelInvocation.get(30, TimeUnit.SECONDS), + "cancelJob() should return once the vertex resolves locally, not hang"); + } finally { + if (engineClient != null) { + engineClient.close(); + } + + if (workerNode != null) { + workerNode.shutdown(); + } + + if (masterNode != null) { + masterNode.shutdown(); + } + } + } + + /** + * Waits until every coordinator and task vertex of the job's single pipeline has itself + * reported RUNNING, not just the top-level job status. + */ + private static void assertAllVerticesRunning( + HazelcastInstanceImpl masterNode, long jobId, long timeoutSeconds) { + Awaitility.await() + .atMost(timeoutSeconds, TimeUnit.SECONDS) + .untilAsserted( + () -> { + JobMaster jobMaster = getJobMaster(masterNode, jobId); + Assertions.assertNotNull( + jobMaster, + "Job master should exist before checking task vertex states"); + PhysicalPlan physicalPlan = jobMaster.getPhysicalPlan(); + Assertions.assertEquals(JobStatus.RUNNING, physicalPlan.getJobStatus()); + physicalPlan + .getPipelineList() + .forEach(SplitClusterFaultToleranceIT::assertRunningSubPlan); + }); + } + + private static void assertRunningSubPlan(SubPlan subPlan) { + Assertions.assertEquals(PipelineStatus.RUNNING, subPlan.getPipelineState()); + subPlan.getCoordinatorVertexList() + .forEach(SplitClusterFaultToleranceIT::assertRunningVertex); + subPlan.getPhysicalVertexList().forEach(SplitClusterFaultToleranceIT::assertRunningVertex); + } + + private static void assertRunningVertex(PhysicalVertex physicalVertex) { + Assertions.assertEquals(ExecutionState.RUNNING, physicalVertex.getExecutionState()); + } + + /** + * Tight-polls (no fixed sleep) every coordinator and task vertex of the job for CANCELING, + * returning as soon as any one is observed. The window between a task vertex entering CANCELING + * and its cancel ack arriving from a healthy worker can be sub-millisecond, well under + * Awaitility's default ~100ms poll interval, so this deliberately busy-spins instead. + */ + private static boolean awaitAnyVertexCanceling( + HazelcastInstanceImpl masterNode, long jobId, long timeout, TimeUnit unit) { + long deadline = System.nanoTime() + unit.toNanos(timeout); + while (System.nanoTime() < deadline) { + JobMaster jobMaster = getJobMaster(masterNode, jobId); + if (jobMaster != null) { + PhysicalPlan physicalPlan = jobMaster.getPhysicalPlan(); + if (physicalPlan != null) { + for (SubPlan subPlan : physicalPlan.getPipelineList()) { + if (isAnyVertexCanceling(subPlan.getCoordinatorVertexList()) + || isAnyVertexCanceling(subPlan.getPhysicalVertexList())) { + return true; + } + } + } + } + Thread.yield(); + } + return false; + } + + private static boolean isAnyVertexCanceling(List<PhysicalVertex> vertices) { + for (PhysicalVertex vertex : vertices) { + if (ExecutionState.CANCELING.equals(vertex.getExecutionState())) { + return true; + } + } + return false; + } + + /** + * Reads the current job master from the active SeaTunnel server embedded in the test cluster. + */ + private static JobMaster getJobMaster(HazelcastInstanceImpl masterNode, long jobId) { + SeaTunnelServer server = + masterNode.node.getNodeEngine().getService(SeaTunnelServer.SERVICE_NAME); + return server.getCoordinatorService().getJobMaster(jobId); + } + + /** + * Waits for the job to reach a terminal state and asserts it is CANCELED, using the + * already-in-flight {@code waitForJobCompleteV2()} future so this assertion cannot itself hang + * forever if the CANCELING-stuck regression this test guards against were to reappear. + */ + private static void assertEventuallyCanceled( + ClientJobProxy clientJobProxy, CompletableFuture<JobResult> waitForCompleteFuture) { + Awaitility.await() + .atMost(60, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> { + Assertions.assertTrue( + waitForCompleteFuture.isDone(), + "Job should reach a terminal state instead of staying stuck " + + "in CANCELING after its worker crashed mid-cancel"); + Assertions.assertEquals( + JobStatus.CANCELED, waitForCompleteFuture.get().getStatus()); + }); + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .untilAsserted( + () -> + Assertions.assertEquals( + JobStatus.CANCELED, clientJobProxy.getJobStatus())); + } + @Test public void testBatchJobRestoreInWorkerDown() throws Exception { String testCaseName = "testBatchJobRestoreInWorkerDown"; @@ -1369,15 +1606,6 @@ public class SplitClusterFaultToleranceIT { Assertions.assertEquals(ExecutionState.RUNNING, physicalVertex.getExecutionState()); } - /** - * Reads the current job master from the active SeaTunnel server embedded in the test cluster. - */ - private static JobMaster getJobMaster(HazelcastInstanceImpl activeMaster, long jobId) { - SeaTunnelServer server = - activeMaster.node.getNodeEngine().getService(SeaTunnelServer.SERVICE_NAME); - return server.getCoordinatorService().getJobMaster(jobId); - } - private static HazelcastInstanceImpl waitAndFindActiveMaster( HazelcastInstanceImpl masterNode1, HazelcastInstanceImpl masterNode2) { final HazelcastInstanceImpl[] activeMasterRef = new HazelcastInstanceImpl[1];
