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-12199-55cbbe7fedcc876881b47215623b6bb223e2c215 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 059b95d7d8f94de54e4af31664eee4e1547161b3 Author: Daniel <[email protected]> AuthorDate: Wed Sep 9 05:29:36 2026 +0000 [Test][Zeta] Add E2E coverage for job behavior after a checkpoint-trigger failure (apache/seatunnel#10442) (#12199) Co-authored-by: Claude Sonnet 5 <[email protected]> --- .../e2e/CheckpointCoordinatorFailoverIT.java | 234 +++++++++++++++++++++ ...eckpoint_trigger_dispatch_failure_template.conf | 71 +++++++ 2 files changed, 305 insertions(+) diff --git a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/CheckpointCoordinatorFailoverIT.java b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/CheckpointCoordinatorFailoverIT.java index aa2361ea3d..4d1aed50a6 100644 --- a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/CheckpointCoordinatorFailoverIT.java +++ b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/CheckpointCoordinatorFailoverIT.java @@ -29,15 +29,20 @@ import org.apache.seatunnel.engine.common.Constant; 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.job.JobResult; import org.apache.seatunnel.engine.common.job.JobStatus; import org.apache.seatunnel.engine.server.SeaTunnelServer; import org.apache.seatunnel.engine.server.SeaTunnelServerStarter; +import org.apache.seatunnel.engine.server.checkpoint.CheckpointCloseReason; import org.apache.seatunnel.engine.server.checkpoint.CheckpointCoordinator; import org.apache.seatunnel.engine.server.checkpoint.CheckpointManager; import org.apache.seatunnel.engine.server.checkpoint.StateStoreCheckpointIDCounter; import org.apache.seatunnel.engine.server.common.statestore.counter.CounterStateStore; import org.apache.seatunnel.engine.server.dag.physical.PhysicalPlan; +import org.apache.seatunnel.engine.server.dag.physical.PipelineLocation; +import org.apache.seatunnel.engine.server.execution.TaskGroupLocation; import org.apache.seatunnel.engine.server.master.JobMaster; +import org.apache.seatunnel.engine.server.resourcemanager.resource.SlotProfile; import org.awaitility.Awaitility; import org.junit.jupiter.api.Assertions; @@ -70,6 +75,9 @@ public class CheckpointCoordinatorFailoverIT { private static final String CLOSE_HANDSHAKE_TEMPLATE_CONF = "batch_fake_to_localfile_close_handshake_failover_template.conf"; + private static final String TRIGGER_DISPATCH_FAILURE_TEMPLATE_CONF = + "stream_fake_to_localfile_checkpoint_trigger_dispatch_failure_template.conf"; + private static final String DYNAMIC_TEST_CASE_NAME = "dynamic_test_case_name"; /** Must match the parallelism value set in the conf templates (env.parallelism). */ @@ -608,6 +616,232 @@ public class CheckpointCoordinatorFailoverIT { } } + /** + * Regression test for the checkpoint-trigger-failure bug reported in <a + * href="https://github.com/apache/seatunnel/issues/10442">#10442</a> and fixed by <a + * href="https://github.com/apache/seatunnel/pull/10448">#10448</a> ("[Fix][Zeta] make the job + * failed when triggering checkpoint fails (apache#10442)"). + * + * <p>Before that fix, {@code CheckpointCoordinator#startTriggerPendingCheckpoint} (see {@code + * seatunnel-engine-server/.../checkpoint/CheckpointCoordinator.java} around lines 942-971) + * wrapped the checkpoint-barrier dispatch call like this: + * + * <pre> + * try { + * CompletableFuture.allOf(completableFutureArray).get(); + * } catch (InterruptedException e) { + * throw new RuntimeException(e); + * } catch (Exception e) { + * LOG.error(ExceptionUtils.getMessage(e)); + * return; + * } + * </pre> + * + * A {@code pendingCounter} field is incremented unconditionally right before this block ever + * runs (line ~1003, {@code pendingCounter.incrementAndGet();}) and is only ever decremented + * once a checkpoint fully completes (line ~1377). Before the fix, a dispatch failure here just + * logged and returned: {@code pendingCounter} stayed stuck above zero forever, and every later + * scheduled trigger attempt ({@code tryTriggerPendingCheckpoint}, line ~800: {@code if + * (pendingCounter.get() > 0) { scheduleTriggerPendingCheckpoint(...); return; }}) would just + * reschedule itself and bail out without ever calling {@code createPendingCheckpoint} again. + * The job kept reporting {@code RUNNING} with no error and no further checkpoints, forever. + * + * <p>The fix (verified against the current {@code dev} HEAD before writing this test) replaces + * both catch blocks with a call to {@code handleCoordinatorError(..., CheckpointCloseReason + * .CHECKPOINT_INSIDE_ERROR)}, which marks the coordinator {@code FAILED}, calls {@code + * checkpointManager.handleCheckpointError(pipelineId, false)} (cancelling the pipeline via + * {@code SubPlan#handleCheckpointError()}), and resets {@code pendingCounter} to 0 as part of + * {@code cleanPendingCheckpoint}. Traced end to end for a single-pipeline job with restore + * disabled ({@code job.retry.times = 0}): {@code SubPlan#getPipelineEndState()} sees {@code + * canceledTaskNum > 0} and, because the checkpoint coordinator's own state is already {@code + * FAILED} by the time it calls {@code cancelCheckpoint()}, upgrades the pipeline's end state + * from {@code CANCELED} to {@code FAILED}; with restore disabled ({@code + * SubPlan#canRestorePipeline()} is false), {@code PhysicalPlan#addPipelineEndCallback} then + * fails the whole (single-pipeline) job. So the documented, current behavior this test asserts + * is: the job reaches a terminal {@code FAILED} state -- not silent-forever-{@code RUNNING}. + * + * <h2>Trigger mechanism</h2> + * + * <p>{@code CheckpointCoordinator#triggerCheckpoint} (line ~1120) is the only code that can + * make {@code startTriggerPendingCheckpoint}'s {@code CompletableFuture.allOf(...).get()} throw + * *synchronously*, as opposed to a per-task RPC merely failing later (a dead-letter scenario + * this same {@code allOf} bug never even notices, since it only waits for {@code + * triggerCheckpoint()} to return, not for the per-task futures inside its result to complete). + * {@code triggerCheckpoint} maps every starting subtask through {@code + * checkpointManager::sendOperationToMemberNode} (CheckpointManager.java:386-400), which calls + * {@code jobMaster.queryTaskGroupAddress(...)} (JobMaster.java:977-994) *before* issuing the + * RPC. That method does exactly one thing that can throw: {@code + * ownedSlotProfilesIMap.get(pipelineLocation)} returning {@code null}, which throws {@code + * IllegalArgumentException("can't find task group address from taskGroupLocation: ...")}. + * + * <p>A repo-wide search confirms {@code ownedSlotProfilesIMap}'s only entry-removal call site + * ({@code JobMaster#releasePipelineResource}, line ~949) runs only after a pipeline has + * *already* left {@code RUNNING}, by which point {@code cleanPendingCheckpoint} has already + * cancelled this coordinator's own scheduler (line ~1203, {@code scheduler.shutdownNow()}), so + * nothing in the running system naturally races this lookup against a live, scheduled trigger. + * Killing or isolating a worker -- this class's usual technique elsewhere -- does not help + * either: a graceful leave fails the *task* directly via {@code + * CoordinatorService#failedTaskOnMemberRemoved} without ever touching this map, while an + * ungraceful one leaves a *stale but present* entry (the RPC itself fails later, asynchronously + * -- exactly the dead-letter case {@code allOf} does not notice, and a different bug/test than + * this one). + * + * <p>So this test reaches for a different, still entirely real, lever instead of cluster + * membership: {@code ownedSlotProfilesIMap} is a plain, named Hazelcast {@code IMap} ({@code + * Constant#IMAP_OWNED_SLOT_PROFILES}), obtained the exact same way this class's own {@link + * #getReadyToCloseCount} already reads {@code Constant#IMAP_RUNNING_JOB_STATE} directly, and + * the same way the engine-server module's own {@code EngineStateStoreMetricExportsTest} pokes + * this exact map in its unit tests. Removing this job's entry from that live, shared map is not + * a mock and not a reflected exception injected into production code: it is the same real, + * unmodified, running {@code JobMaster#queryTaskGroupAddress} that throws its own real {@code + * IllegalArgumentException} the moment it next executes, exactly as it would if this + * bookkeeping ever went missing for any other reason. A check of every other reader of this map + * (metrics export, pipeline cleanup, {@code PhysicalVertex#checkTaskGroupIsExecuting} -- itself + * only reachable via master-failover restore, never during steady-state RUNNING) confirms all + * of them null-check and skip gracefully, so this removal cannot trip any other code path + * first. + * + * <p>This is deterministic, not a narrow-window race like a worker kill: the entry is left + * removed permanently (this pipeline is about to fail anyway), so unlike catching a kill at the + * exact moment a barrier is dispatched, the very next scheduled trigger attempt that has not + * already started -- or the one after that -- is guaranteed to observe the missing entry once + * the removal completes, with no timing window to miss. To also demonstrate the fault lands on + * a previously healthy coordinator (not one that was simply never able to checkpoint at all), + * the test first waits for the checkpoint-id counter to reach 2, which -- since {@code + * tryTriggerPendingCheckpoint} never allocates a new id while {@code pendingCounter > 0} (line + * ~800) -- can only happen after checkpoint id 1 has fully completed and been acknowledged. + * + * <p><b>What this test proves:</b> a real checkpoint-barrier dispatch failure, on a coordinator + * that was previously checkpointing successfully, fails the job (terminal {@code + * JobStatus.FAILED}, with an error message traceable to {@code CheckpointCloseReason + * #CHECKPOINT_INSIDE_ERROR}) within a bounded window. <b>What it implicitly also proves:</b> + * the pre-fix silent-forever-{@code RUNNING} behavior from #10442 no longer occurs -- had it, + * the bounded {@code Awaitility} wait below for {@code JobStatus.FAILED} would time out and + * fail this test, since the old code left the job {@code RUNNING} with no further checkpoints + * and no error, forever. + */ + @Test + public void testStreamJobFailsAfterCheckpointTriggerDispatchFailure() throws Exception { + String testCaseName = "testStreamJobFailsAfterCheckpointTriggerDispatchFailure"; + String testClusterName = "CheckpointCoordinatorFailoverIT_" + testCaseName; + // Single-pipeline job (one FakeSource, one LocalFile sink): PipelineGenerator assigns + // pipeline ids starting at 1, so this is the fixed key identifying this job's sole + // pipeline in ownedSlotProfilesIMap. + int pipelineId = 1; + + HazelcastInstanceImpl node = null; + SeaTunnelClient engineClient = null; + + SeaTunnelConfig config = ConfigProvider.locateAndGetSeaTunnelConfig(); + config.getHazelcastConfig().setClusterName(TestUtils.getClusterName(testClusterName)); + config.getEngineConfig().getHttpConfig().setEnabled(false); + + try { + node = SeaTunnelServerStarter.createHazelcastInstance(config); + + Common.setDeployMode(DeployMode.CLUSTER); + ImmutablePair<String, String> testResources = + createTestResources(testCaseName, TRIGGER_DISPATCH_FAILURE_TEMPLATE_CONF); + JobConfig jobConfig = new JobConfig(); + jobConfig.setName(testCaseName); + + ClientConfig clientConfig = ConfigProvider.locateAndGetClientConfig(); + clientConfig.setClusterName(TestUtils.getClusterName(testClusterName)); + engineClient = new SeaTunnelClient(clientConfig); + ClientJobExecutionEnvironment jobExecutionEnv = + engineClient.createExecutionContext( + testResources.getRight(), jobConfig, config); + ClientJobProxy clientJobProxy = jobExecutionEnv.execute(); + long jobId = clientJobProxy.getJobId(); + + Awaitility.await() + .atMost(2, TimeUnit.MINUTES) + .pollInterval(500, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> { + Assertions.assertEquals( + JobStatus.RUNNING, clientJobProxy.getJobStatus()); + Assertions.assertTrue( + FileUtils.getFileLineNumberFromDir(testResources.getLeft()) + > 0, + "Waiting for the source to start producing rows"); + }); + + // Prove checkpointing is healthy before injecting the fault: the id counter can only + // reach 2 once checkpoint id 1 has been fully acknowledged -- see the class javadoc + // above for why (tryTriggerPendingCheckpoint never allocates a new id while + // pendingCounter is still above zero). + CounterStateStore<String> checkpointCounterStore = checkpointCounterStore(node); + String checkpointIdKey = + StateStoreCheckpointIDCounter.convertLongIntToBase64(jobId, pipelineId); + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .pollInterval(200, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> { + Long currentId = checkpointCounterStore.get(checkpointIdKey); + Assertions.assertNotNull( + currentId, + "waiting for the first checkpoint id to be allocated"); + Assertions.assertTrue( + currentId >= 2, + "waiting for checkpoint id 1 to be fully acknowledged" + + " before injecting the fault"); + }); + + // Real-fault injection: remove this pipeline's entry from the same live, shared, + // named Hazelcast IMap (engine_ownedSlotProfilesIMap) that + // JobMaster#queryTaskGroupAddress consults on every checkpoint-barrier dispatch. See + // the class javadoc above for why this is real (not mocked/reflected), + // deterministic, and cannot be short-circuited by any other code path. + IMap<PipelineLocation, Map<TaskGroupLocation, SlotProfile>> ownedSlotProfilesIMap = + node.getMap(Constant.IMAP_OWNED_SLOT_PROFILES); + PipelineLocation pipelineLocation = new PipelineLocation(jobId, pipelineId); + Map<TaskGroupLocation, SlotProfile> removedSlotProfiles = + ownedSlotProfilesIMap.remove(pipelineLocation); + Assertions.assertNotNull( + removedSlotProfiles, + "the running task's slot-profile bookkeeping should exist before injection"); + log.info( + "Job {} checkpoint id counter reached 2; removed pipeline {}'s slot-profile" + + " bookkeeping ({} task group(s)) so the next checkpoint-barrier" + + " dispatch hits CheckpointCoordinator's real, unmodified" + + " queryTaskGroupAddress failure path.", + jobId, + pipelineId, + removedSlotProfiles.size()); + + Awaitility.await() + .atMost(60, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> + Assertions.assertEquals( + JobStatus.FAILED, clientJobProxy.getJobStatus())); + + JobResult jobResult = clientJobProxy.waitForJobCompleteV2(); + Assertions.assertEquals(JobStatus.FAILED, jobResult.getStatus()); + Assertions.assertNotNull( + jobResult.getError(), "a FAILED job should carry a non-null error message"); + Assertions.assertTrue( + jobResult + .getError() + .contains(CheckpointCloseReason.CHECKPOINT_INSIDE_ERROR.message()), + () -> + "Expected the job failure to be attributed to the checkpoint" + + " coordinator's CHECKPOINT_INSIDE_ERROR path (see" + + " CheckpointCoordinator#handleCoordinatorError), but got: " + + jobResult.getError()); + } finally { + if (engineClient != null) { + engineClient.close(); + } + if (node != null) { + node.shutdown(); + } + } + } + private ImmutablePair<String, String> createTestResources( @NonNull String testCaseName, String templateConf) throws IOException { Map<String, String> valueMap = new HashMap<>(); diff --git a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/stream_fake_to_localfile_checkpoint_trigger_dispatch_failure_template.conf b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/stream_fake_to_localfile_checkpoint_trigger_dispatch_failure_template.conf new file mode 100644 index 0000000000..8c49b55db7 --- /dev/null +++ b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/stream_fake_to_localfile_checkpoint_trigger_dispatch_failure_template.conf @@ -0,0 +1,71 @@ +# +# 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 + # Single source task, single pipeline: CheckpointCoordinatorFailoverIT# + # testStreamJobFailsAfterCheckpointTriggerDispatchFailure targets this job's sole pipeline + # (fixed pipelineId = 1) by directly removing its entry from the live ownedSlotProfilesIMap, so + # a single, unambiguous pipeline keeps the injected fault precise. + parallelism = 1 + # Short interval: the test first waits for one full checkpoint cycle to complete (proving + # checkpointing is healthy) and then, after injecting the fault, waits for the next scheduled + # trigger to fail. Both need to happen quickly for a fast test. + checkpoint.interval = 2000 + # Deliberately generous relative to checkpoint.interval. This test's failure is a synchronous + # exception thrown while dispatching the checkpoint barrier (JobMaster#queryTaskGroupAddress + # throwing IllegalArgumentException), not a checkpoint timing out -- so CHECKPOINT_EXPIRED + # should never actually be what fires here. Kept large purely to rule out an unrelated timeout + # race from confusing the result. + checkpoint.timeout = 30000 + # Disables pipeline restore so the checkpoint-trigger failure this test injects produces one + # deterministic terminal FAILED job instead of a restore attempt, matching the conf added by + # the #10442 fix in #10448 (stream_fake_to_console_checkpoint_barrier_trigger_error.conf). + job.retry.times = 0 +} + +source { + FakeSource { + plugin_output = table1 + row.num = 1000000 + split.num = 1 + split.read-interval = 50 + schema = { + fields { + c_string = string + c_int = int + } + } + } +} + +transform { +} + +sink { + LocalFile { + plugin_input = table1 + path = "/tmp/hive/warehouse/${dynamic_test_case_name}" + field_delimiter = "\t" + row_delimiter = "\n" + file_name_expression = "${transactionId}_${now}" + file_format_type = "text" + filename_time_format = "yyyy.MM.dd" + is_enable_transaction = true + save_mode = "error" + } +}
