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

github-merge-queue[bot] 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 0821d8a770 [Test][Zeta] Add E2E coverage for savepoint barrier 
injection against a busy source (apache/seatunnel#11473) (#12141)
0821d8a770 is described below

commit 0821d8a770145c79239489a38cc22759b67979df
Author: Daniel <[email protected]>
AuthorDate: Mon Sep 7 02:48:26 2026 +0000

    [Test][Zeta] Add E2E coverage for savepoint barrier injection against a 
busy source (apache/seatunnel#11473) (#12141)
    
    Co-authored-by: Claude Fable 5.1 <[email protected]>
---
 .../engine/e2e/SavepointBusySourceBarrierIT.java   | 368 +++++++++++++++++++++
 ...tream_fake_busy_split_to_console_savepoint.conf |  60 ++++
 2 files changed, 428 insertions(+)

diff --git 
a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/SavepointBusySourceBarrierIT.java
 
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/SavepointBusySourceBarrierIT.java
new file mode 100644
index 0000000000..8b7d4bbb59
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/SavepointBusySourceBarrierIT.java
@@ -0,0 +1,368 @@
+/*
+ * 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.common.utils.ExceptionUtils;
+import org.apache.seatunnel.engine.client.SeaTunnelClient;
+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.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 lombok.extern.slf4j.Slf4j;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * End-to-end regression test for <a
+ * href="https://github.com/apache/seatunnel/issues/11473";>#11473</a> ("[Bug] 
[Zeta] stop-job with
+ * savepoint hangs in DOING_SAVEPOINT"), fixed by <a
+ * href="https://github.com/apache/seatunnel/pull/11489";>#11489</a>.
+ *
+ * <p>The reported job was a plain streaming {@code FakeSource -> Console} 
pipeline whose single
+ * split carried 100,000,000 rows. Injecting a savepoint barrier requires the 
source task's
+ * checkpoint lock ({@code SourceFlowLifeCycle#triggerBarrier}), but {@code
+ * FakeSourceReader#pollNext} used to emit the entire split while holding that 
same lock, so the
+ * barrier trigger dispatched by {@code CheckpointBarrierTriggerOperation} on 
the worker could not
+ * run until the split was drained many minutes later. The savepoint 
checkpoint expired first, and
+ * because {@code JobMaster#savePoint()} had no failure exit, the job sat in 
{@link
+ * JobStatus#DOING_SAVEPOINT} forever with its slots still occupied. #11489 
fixed this on both ends:
+ * the reader now emits at most a bounded batch per {@code pollNext} call and 
requeues the remainder
+ * of the split, releasing the lock between batches so the barrier can be 
injected mid-split, and
+ * {@code JobMaster#savePoint} now leaves {@code DOING_SAVEPOINT} 
deterministically when a savepoint
+ * cannot complete.
+ *
+ * <p>Existing coverage does not exercise this mechanism end to end. {@code 
SavePointBusySourceTest}
+ * (seatunnel-engine-server) reproduces the busy split, but only inside a 
single in-process server,
+ * invoking {@code CoordinatorService#savePoint} directly, so the barrier 
never crosses a node
+ * boundary and no client is involved. {@link SavepointPreconditionRecoveryIT} 
in this package
+ * covers a different branch of the same fix: the precondition rejection 
({@code
+ * TASK_NOT_ALL_READY_WHEN_SAVEPOINT}) that restores the job to RUNNING, which 
never involves a busy
+ * source or a starved barrier at all.
+ *
+ * <p>This test drives the literal #11473 scenario through the real multi-node 
path: a split
+ * master/worker cluster, the reproduction job config from the issue, and a 
real {@link
+ * SeaTunnelClient#savePointJob(Long)} request fired only once job metrics 
prove the source is
+ * actively emitting its huge split. The savepoint must complete within a 
bounded window (pre-fix it
+ * never did), and the job metrics recorded at completion must show that the 
split was still being
+ * emitted when the barrier went through, proving the barrier was injected 
mid-split rather than
+ * after the split drained. It then restores the job from that savepoint 
(which {@code
+ * RestoreMode#SAVEPOINT} only accepts from a savepoint-type checkpoint), 
verifies the restored
+ * source resumes emitting from its restored split state, and takes a second 
stop-with-savepoint
+ * against the restored, still-busy source to prove the savepoint is genuinely 
usable rather than
+ * merely reported as done.
+ */
+@Slf4j
+public class SavepointBusySourceBarrierIT {
+
+    /**
+     * The reproduction job of #11473: a single 100,000,000-row FakeSource 
split streamed into a
+     * Console sink that discards rows.
+     */
+    private static final String CONF_FILE = 
"stream_fake_busy_split_to_console_savepoint.conf";
+
+    /**
+     * Must match {@code row.num} in {@link #CONF_FILE}. Even at millions of 
rows per second the
+     * split cannot be drained within the bounds used below, so a source 
metric strictly below this
+     * value at savepoint completion proves the barrier was injected while the 
split was still in
+     * flight.
+     */
+    private static final long SPLIT_ROW_NUM = 100_000_000L;
+
+    /**
+     * Upper bound for a stop-with-savepoint request to complete. It 
deliberately exceeds the job's
+     * {@code checkpoint.timeout} (60s) so that a starved barrier surfaces as 
a checkpoint expiry
+     * and a failed request rather than as a silent hang past this bound; 
pre-fix the job never left
+     * DOING_SAVEPOINT at all, which this bound converts into a clean failure.
+     */
+    private static final long SAVEPOINT_COMPLETION_TIMEOUT_SECONDS = 120;
+
+    /** Generous bound for job state transitions and metric progress on slow 
CI runners. */
+    private static final long STATE_TIMEOUT_SECONDS = 120;
+
+    @Test
+    public void testStopWithSavepointCompletesWhileSourceEmitsLargeSplit() 
throws Exception {
+        String testCaseName = 
"testStopWithSavepointCompletesWhileSourceEmitsLargeSplit";
+        String testClusterName = "SavepointBusySourceBarrierIT_" + 
testCaseName;
+
+        HazelcastInstanceImpl masterNode = null;
+        HazelcastInstanceImpl workerNode = null;
+        SeaTunnelClient engineClient = null;
+
+        SeaTunnelConfig masterConfig = getSeaTunnelConfig(testClusterName);
+        SeaTunnelConfig workerConfig = getSeaTunnelConfig(testClusterName);
+
+        try {
+            masterNode = 
SeaTunnelServerStarter.createMasterHazelcastInstance(masterConfig);
+            workerNode = 
SeaTunnelServerStarter.createWorkerHazelcastInstance(workerConfig);
+
+            HazelcastInstanceImpl finalMasterNode = masterNode;
+            Awaitility.await()
+                    .atMost(10, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () ->
+                                    Assertions.assertEquals(
+                                            2, 
finalMasterNode.getCluster().getMembers().size()));
+
+            Common.setDeployMode(DeployMode.CLUSTER);
+            String confPath = TestUtils.getResource(CONF_FILE);
+
+            JobConfig jobConfig = new JobConfig();
+            jobConfig.setName(testCaseName);
+
+            ClientConfig clientConfig = 
ConfigProvider.locateAndGetClientConfig();
+            
clientConfig.setClusterName(TestUtils.getClusterName(testClusterName));
+            engineClient = new SeaTunnelClient(clientConfig);
+
+            ClientJobProxy clientJobProxy =
+                    engineClient
+                            .createExecutionContext(confPath, jobConfig, 
masterConfig)
+                            .execute();
+            long jobId = clientJobProxy.getJobId();
+            awaitJobStatus(clientJobProxy, JobStatus.RUNNING);
+
+            // Fire the savepoint only once the source is provably in the 
middle of emitting its
+            // huge split. This is the exact condition that starved the 
barrier pre-fix, and it also
+            // guarantees every subtask has started, so the request cannot be 
rejected by the
+            // unrelated not-all-tasks-ready precondition covered by
+            // SavepointPreconditionRecoveryIT.
+            long rowsBeforeSavepoint = 
awaitSourceActivelyEmitting(engineClient, jobId);
+            long rowsAtSavepoint =
+                    stopWithSavepointWithinBound(
+                            engineClient, clientJobProxy, masterNode, jobId, 
"initial run");
+            log.info(
+                    "Job {}: SourceReceivedCount was {} right before the 
savepoint request and {}"
+                            + " when the pipeline finished (split size {})",
+                    jobId,
+                    rowsBeforeSavepoint,
+                    rowsAtSavepoint,
+                    SPLIT_ROW_NUM);
+
+            // Mid-split proof: the counter recorded when the pipeline 
finished must sit strictly
+            // between the last live sample and the split size. Pre-fix the 
barrier could only pass
+            // once the whole split had been emitted (or never, once the 
checkpoint expired).
+            Assertions.assertTrue(
+                    rowsAtSavepoint >= rowsBeforeSavepoint,
+                    "SourceReceivedCount recorded at savepoint completion ("
+                            + rowsAtSavepoint
+                            + ") must not be lower than the live sample taken 
before the request ("
+                            + rowsBeforeSavepoint
+                            + "); the finished-job metrics were not captured");
+            Assertions.assertTrue(
+                    rowsAtSavepoint < SPLIT_ROW_NUM,
+                    "Savepoint completed only after the whole "
+                            + SPLIT_ROW_NUM
+                            + "-row split drained (SourceReceivedCount="
+                            + rowsAtSavepoint
+                            + "); the barrier was not injected mid-split");
+
+            // Prove the savepoint is usable: RestoreMode.SAVEPOINT only 
accepts a savepoint-type
+            // checkpoint, and the restored reader must pick up the requeued 
remainder of the split
+            // and keep emitting, then survive a second stop-with-savepoint 
while still busy.
+            ClientJobProxy restoredJobProxy =
+                    engineClient
+                            .restoreExecutionContext(confPath, jobConfig, 
masterConfig, jobId)
+                            .execute();
+            awaitJobStatus(restoredJobProxy, JobStatus.RUNNING);
+            long rowsBeforeSecondSavepoint = 
awaitSourceActivelyEmitting(engineClient, jobId);
+            long rowsAtSecondSavepoint =
+                    stopWithSavepointWithinBound(
+                            engineClient, restoredJobProxy, masterNode, jobId, 
"restored run");
+            log.info(
+                    "Job {} (restored): SourceReceivedCount was {} right 
before the second savepoint"
+                            + " request and {} when the pipeline finished",
+                    jobId,
+                    rowsBeforeSecondSavepoint,
+                    rowsAtSecondSavepoint);
+            Assertions.assertTrue(
+                    rowsAtSecondSavepoint > 0 && rowsAtSecondSavepoint < 
SPLIT_ROW_NUM,
+                    "Second savepoint against the restored run must also 
complete mid-split, but"
+                            + " SourceReceivedCount was "
+                            + rowsAtSecondSavepoint);
+        } 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));
+        seaTunnelConfig.getEngineConfig().getHttpConfig().setEnabled(false);
+        return seaTunnelConfig;
+    }
+
+    /**
+     * Issues a real client stop-with-savepoint request and requires it to 
complete within {@link
+     * #SAVEPOINT_COMPLETION_TIMEOUT_SECONDS}. {@link 
SeaTunnelClient#savePointJob(Long)} blocks
+     * until the master's {@code JobMaster#savePoint()} future settles, so it 
is driven from a
+     * separate thread to keep the bound under the test's control rather than 
the client invocation
+     * timeout. A timeout here is the #11473 hang; an exception is the 
deterministic failure exit
+     * added by #11489, which is equally a regression for a source that yields 
its checkpoint lock.
+     *
+     * @return the {@code SourceReceivedCount} recorded in the finished-job 
history once the job has
+     *     reached SAVEPOINT_DONE and been released by the coordinator
+     */
+    private static long stopWithSavepointWithinBound(
+            SeaTunnelClient engineClient,
+            ClientJobProxy jobProxy,
+            HazelcastInstanceImpl masterNode,
+            long jobId,
+            String phase)
+            throws InterruptedException {
+        long requestedAt = System.currentTimeMillis();
+        CompletableFuture<Void> savepointRequest =
+                CompletableFuture.runAsync(() -> 
engineClient.savePointJob(jobId));
+        try {
+            savepointRequest.get(SAVEPOINT_COMPLETION_TIMEOUT_SECONDS, 
TimeUnit.SECONDS);
+            log.info(
+                    "stop-with-savepoint ({}) of job {} completed in {} ms",
+                    phase,
+                    jobId,
+                    System.currentTimeMillis() - requestedAt);
+        } catch (TimeoutException e) {
+            Assertions.fail(
+                    "stop-with-savepoint ("
+                            + phase
+                            + ") did not complete within "
+                            + SAVEPOINT_COMPLETION_TIMEOUT_SECONDS
+                            + "s against a source busy emitting a large split; 
job status is "
+                            + jobProxy.getJobStatus()
+                            + " (#11473 hang)");
+        } catch (ExecutionException e) {
+            Assertions.fail(
+                    "stop-with-savepoint ("
+                            + phase
+                            + ") failed instead of completing: "
+                            + ExceptionUtils.getMessage(e.getCause()));
+        }
+
+        Awaitility.await()
+                .atMost(STATE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+                .pollInterval(200, TimeUnit.MILLISECONDS)
+                .untilAsserted(
+                        () ->
+                                Assertions.assertEquals(
+                                        JobStatus.SAVEPOINT_DONE,
+                                        jobProxy.getJobStatus(),
+                                        "Job must end in SAVEPOINT_DONE after 
the "
+                                                + phase
+                                                + " stop-with-savepoint 
request completed"));
+
+        // The coordinator drops the JobMaster shortly after the terminal 
state is reached. Waiting
+        // for that release makes the metrics read below come from the 
finished-job history and, for
+        // the restore that follows, prevents CoordinatorService#submitJob 
from treating the still
+        // registered job id as already running and silently skipping the 
restore.
+        awaitJobReleasedByCoordinator(masterNode, jobId);
+        return engineClient.getJobMetricsSummary(jobId).getSourceReadCount();
+    }
+
+    /**
+     * Waits until {@code SourceReceivedCount} of {@code jobId} is positive 
and then grows again
+     * between two samples, which proves the reader is actively emitting the 
split at that moment.
+     *
+     * @return the last observed count, taken immediately before the caller's 
next action
+     */
+    private static long awaitSourceActivelyEmitting(SeaTunnelClient 
engineClient, long jobId) {
+        Awaitility.await()
+                .atMost(STATE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+                .pollInterval(200, TimeUnit.MILLISECONDS)
+                .untilAsserted(
+                        () ->
+                                Assertions.assertTrue(
+                                        readSourceReceivedCount(engineClient, 
jobId) > 0,
+                                        "Source has not emitted any rows 
yet"));
+        long firstSample = readSourceReceivedCount(engineClient, jobId);
+        Awaitility.await()
+                .atMost(STATE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+                .pollInterval(200, TimeUnit.MILLISECONDS)
+                .untilAsserted(
+                        () ->
+                                Assertions.assertTrue(
+                                        readSourceReceivedCount(engineClient, 
jobId) > firstSample,
+                                        "Source stopped emitting at " + 
firstSample + " rows"));
+        return readSourceReceivedCount(engineClient, jobId);
+    }
+
+    /**
+     * Reads the aggregated {@code SourceReceivedCount} of {@code jobId}. The 
metrics RPC can fail
+     * transiently while task groups are being deployed or torn down; such a 
failure is logged and
+     * reported as -1 so the surrounding poll simply retries instead of 
aborting on the first miss.
+     */
+    private static long readSourceReceivedCount(SeaTunnelClient engineClient, 
long jobId) {
+        try {
+            return 
engineClient.getJobMetricsSummary(jobId).getSourceReadCount();
+        } catch (RuntimeException e) {
+            log.warn(
+                    "Transient failure reading metrics of job {}: {}",
+                    jobId,
+                    ExceptionUtils.getMessage(e));
+            return -1L;
+        }
+    }
+
+    private static void awaitJobStatus(ClientJobProxy jobProxy, JobStatus 
expected) {
+        Awaitility.await()
+                .atMost(STATE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+                .pollInterval(200, TimeUnit.MILLISECONDS)
+                .untilAsserted(() -> Assertions.assertEquals(expected, 
jobProxy.getJobStatus()));
+    }
+
+    /**
+     * Waits until the master's {@code CoordinatorService} no longer holds a 
JobMaster for {@code
+     * jobId} in either its pending queue or its running map, which is the 
same public lookup {@code
+     * CoordinatorService#submitJob} uses to decide whether a submission is a 
duplicate.
+     */
+    private static void awaitJobReleasedByCoordinator(
+            HazelcastInstanceImpl masterNode, long jobId) {
+        SeaTunnelServer server =
+                
masterNode.node.getNodeEngine().getService(SeaTunnelServer.SERVICE_NAME);
+        Awaitility.await()
+                .atMost(STATE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+                .pollInterval(200, TimeUnit.MILLISECONDS)
+                .untilAsserted(
+                        () ->
+                                Assertions.assertNull(
+                                        
server.getCoordinatorService().getJobMaster(jobId),
+                                        "Coordinator still holds the finished 
job " + jobId));
+    }
+}
diff --git 
a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/stream_fake_busy_split_to_console_savepoint.conf
 
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/stream_fake_busy_split_to_console_savepoint.conf
new file mode 100644
index 0000000000..bf14cad4b7
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/resources/stream_fake_busy_split_to_console_savepoint.conf
@@ -0,0 +1,60 @@
+#
+# 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.
+#
+
+# The reproduction job of https://github.com/apache/seatunnel/issues/11473, 
used by
+# SavepointBusySourceBarrierIT. A single FakeSource split of 100,000,000 rows 
keeps the source
+# reader continuously busy for far longer than the test budget, so every 
stop-with-savepoint
+# request issued by the test lands while the split is still being emitted. The 
Console sink
+# discards rows so that the pipeline is bounded only by the source generation 
rate.
+#
+# checkpoint.timeout is deliberately shorter than the test's savepoint 
completion bound: if the
+# savepoint barrier is starved again, the checkpoint expires first and the 
request fails instead of
+# silently hanging past the bound.
+
+env {
+  parallelism = 1
+  job.mode = "STREAMING"
+  checkpoint.interval = 10000
+  checkpoint.timeout = 60000
+}
+
+source {
+  FakeSource {
+    plugin_output = "fake"
+    row.num = 100000000
+    split.num = 1
+    split.read-interval = 10
+    string.length = 16
+    schema = {
+      fields {
+        id = "bigint"
+        name = "string"
+        age = "int"
+      }
+    }
+  }
+}
+
+transform {
+}
+
+sink {
+  Console {
+    plugin_input = "fake"
+    log.print.data = false
+  }
+}

Reply via email to