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

JNSimba pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new a27bdb7acb2 [fix](streaming job) Harden alter and commit handling 
(#68270)
a27bdb7acb2 is described below

commit a27bdb7acb201eaea8a189327f7d0a8ce983ebb5
Author: wudi <[email protected]>
AuthorDate: Wed Oct 14 10:26:22 2026 +0800

    [fix](streaming job) Harden alter and commit handling (#68270)
    
    ### What problem does this PR solve?
    
    Problem Summary:
    
    This PR fixes streaming job lifecycle, ALTER, and commit handling:
    
    - ALTER could partially mutate job state when source-property conversion
    or validation failed, and changing an unrelated property reset the
    current data-quality sampling window.
    - CDC ALTER accepted offsets that did not match the source type,
    allowing invalid PostgreSQL LSN or MySQL/OceanBase binlog positions to
    fail later at runtime.
    - Pausing or canceling a streaming job could leave internal scheduler
    tasks registered and prevent subsequent scheduling.
    - Journal replay could update the job's backend binding without
    synchronizing the offset provider.
    - TVF transaction commit retries could fetch a different or empty offset
    after the first fetch consumed the task's result.
    
    The fix validates ALTER inputs before mutating source state, resets
    cloud offset before changing local offset state, preserves the sampling
    window unless `max_interval` changes, and validates source-specific CDC
    offset locations including GTID-only recovery. Internal scheduler tasks
    are canceled and cleared without affecting user-visible task statistics.
    Replay synchronizes the offset provider's backend binding, and each TVF
    task caches its valid commit offset so transaction retries reuse the
    same attachment data.
    
    PostgreSQL stream recovery retains the missing-slot check and does not
    recreate a missing slot. It does not reject recovery solely because
    `restart_lsn` exceeds the saved stream offset: replication feedback can
    advance slot positions during low traffic without advancing the saved
    record offset. This comparison cannot reliably identify a recreated
    slot. Detecting external recreation of a slot with the same name is
    outside this change's guarantees.
---
 .../doris/job/executor/DispatchTaskHandler.java    |   7 +-
 .../insert/streaming/StreamingInsertJob.java       | 148 ++++++++++++---------
 .../insert/streaming/StreamingInsertTask.java      |  17 +++
 .../org/apache/doris/job/manager/JobManager.java   |   1 -
 .../doris/job/offset/SourceOffsetProvider.java     |   4 +-
 .../job/offset/jdbc/JdbcSourceOffsetProvider.java  |  42 +++++-
 .../offset/jdbc/JdbcTvfSourceOffsetProvider.java   |   7 +-
 .../streaming/StreamingInsertJobLagTest.java       |  10 +-
 .../StreamingInsertJobOffsetPersistenceTest.java   | 114 +++++++++++++++-
 .../StreamingInsertJobStatusTransitionTest.java    |  11 ++
 .../streaming/StreamingInsertTaskAuditTest.java    |  19 +++
 .../jdbc/JdbcSourceOffsetProviderOffsetTest.java   |  71 ++++++++++
 .../source/reader/JdbcIncrementalSourceReader.java |   6 +-
 .../source/reader/mysql/MySqlSourceReader.java     |   6 +
 .../reader/postgres/PostgresSourceReader.java      |  41 +-----
 .../source/reader/mysql/MySqlSourceReaderTest.java |  40 ++++++
 16 files changed, 429 insertions(+), 115 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/executor/DispatchTaskHandler.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/executor/DispatchTaskHandler.java
index 1db0cd50928..85223772779 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/executor/DispatchTaskHandler.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/executor/DispatchTaskHandler.java
@@ -65,7 +65,12 @@ public class DispatchTaskHandler<T extends AbstractJob> 
implements WorkHandler<T
                 JobType jobType = event.getJob().getJobType();
                 for (AbstractTask task : tasks) {
                     if (!disruptorMap.get(jobType).addTask(task)) {
-                        task.cancel(true);
+                        try {
+                            task.cancel(true);
+                        } finally {
+                            // A rejected task cannot run and must not block 
subsequent scheduling.
+                            event.getJob().getRunningTasks().remove(task);
+                        }
                         continue;
                     }
                     log.info("dispatch timer job success, job id is {},  task 
id is {}",
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
index 780ef176e09..b5ede2873c0 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
@@ -433,6 +433,7 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
                     && S3TableValuedFunction.NAME.equalsIgnoreCase(tvfType)) {
                 Offset offset = 
validateOffset(jobProperties.getOffsetProperty());
                 this.offsetProvider.updateOffset(offset);
+                this.offsetProviderPersist = offsetProvider.getPersistInfo();
             }
         } catch (Exception ex) {
             log.warn("init streaming insert job failed, sql: {}", 
getExecuteSql(), ex);
@@ -486,6 +487,9 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
      */
     public void validateAlterOffset(String offset) throws AnalysisException {
         try {
+            if (tvfType != null) {
+                offsetProvider.ensureInitialized(getJobId(), 
getOriginTvfProps());
+            }
             offsetProvider.validateAlterOffset(offset);
         } catch (Exception ex) {
             throw new AnalysisException(ex.getMessage());
@@ -510,45 +514,74 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
     }
 
     public void alterJob(AlterJobCommand alterJobCommand) throws 
AnalysisException, JobException {
-        List<String> logParts = new ArrayList<>();
-        // update sql
-        if (StringUtils.isNotEmpty(alterJobCommand.getSql())) {
-            setExecuteSql(alterJobCommand.getSql());
-            initLogicalPlan(true);
-            // refresh cached TVF props so fetchMeta and 
createStreamingInsertTask
-            // pick up the new credentials (e.g. aksk) from the updated SQL
-            this.originTvfProps = getCurrentTvf().getProperties().getMap();
-            String encryptedSql = generateEncryptedSql();
-            logParts.add("sql: " + encryptedSql);
-        }
+        writeLock();
+        try {
+            if (!JobStatus.PAUSED.equals(getJobStatus())) {
+                throw new AnalysisException("Only PAUSED job can be altered");
+            }
+            List<String> logParts = new ArrayList<>();
+
+            validateComputeGroupProperty(alterJobCommand.getProperties());
+
+            // Prepare the merged source properties before mutating the job.
+            Map<String, String> mergedSourceProperties = null;
+            Map<String, String> newConvertedSourceProperties = null;
+            if (!alterJobCommand.getSourceProperties().isEmpty()) {
+                mergedSourceProperties = new HashMap<>(this.sourceProperties);
+                
mergedSourceProperties.putAll(alterJobCommand.getSourceProperties());
+                if 
(alterJobCommand.getSourceProperties().containsKey(DataSourceConfigKeys.SERVER_ID)
+                        || alterJobCommand.getSourceProperties()
+                                
.containsKey(DataSourceConfigKeys.SNAPSHOT_PARALLELISM)) {
+                    
DataSourceConfigValidator.validateServerIdConfig(mergedSourceProperties);
+                }
+                newConvertedSourceProperties = 
buildConvertedSourceProperties(mergedSourceProperties);
+            }
 
-        validateComputeGroupProperty(alterJobCommand.getProperties());
+            // update sql
+            if (StringUtils.isNotEmpty(alterJobCommand.getSql())) {
+                setExecuteSql(alterJobCommand.getSql());
+                initLogicalPlan(true);
+                // refresh cached TVF props so fetchMeta and 
createStreamingInsertTask
+                // pick up the new credentials (e.g. aksk) from the updated SQL
+                this.originTvfProps = getCurrentTvf().getProperties().getMap();
+                String encryptedSql = generateEncryptedSql();
+                logParts.add("sql: " + encryptedSql);
+            }
 
-        // update properties
-        if (!alterJobCommand.getProperties().isEmpty()) {
-            modifyPropertiesInternal(alterJobCommand.getProperties());
-            logParts.add("properties: " + alterJobCommand.getProperties());
-        }
+            // update properties
+            if (!alterJobCommand.getProperties().isEmpty()) {
+                String offsetProperty = 
alterJobCommand.getProperties().get(StreamingJobProperties.OFFSET_PROPERTY);
+                if (StringUtils.isNotEmpty(offsetProperty)) {
+                    Offset offset = validateOffset(offsetProperty);
+                    if (Config.isCloudMode()) {
+                        resetCloudProgress(offset);
+                    }
+                    offsetProvider.updateOffset(offset);
+                    offsetProvider.resetLag();
+                    this.offsetProviderPersist = 
offsetProvider.getPersistInfo();
+                    log.info("Alter streaming job {} offset to {}", 
getJobId(), offsetProperty);
+                }
+                modifyPropertiesInternal(alterJobCommand.getProperties());
+                logParts.add("properties: " + alterJobCommand.getProperties());
+            }
 
-        // update source properties
-        if (!alterJobCommand.getSourceProperties().isEmpty()) {
-            // Convert on a merged copy first; validateSource() only checks 
ssl_rootcert is
-            // non-empty, so cert-file lookup can still fail here. Commit to 
fields only on success.
-            Map<String, String> mergedSourceProperties = new 
HashMap<>(this.sourceProperties);
-            
mergedSourceProperties.putAll(alterJobCommand.getSourceProperties());
-            Map<String, String> newConvertedSourceProperties =
-                    buildConvertedSourceProperties(mergedSourceProperties);
-            this.sourceProperties = mergedSourceProperties;
-            this.convertedSourceProperties = newConvertedSourceProperties;
-            logParts.add("source properties: " + 
alterJobCommand.getSourceProperties());
-        }
+            // update source properties
+            if (mergedSourceProperties != null) {
+                this.sourceProperties = mergedSourceProperties;
+                this.convertedSourceProperties = newConvertedSourceProperties;
+                logParts.add("source properties: " + 
alterJobCommand.getSourceProperties());
+            }
 
-        // update target properties
-        if (!alterJobCommand.getTargetProperties().isEmpty()) {
-            
this.targetProperties.putAll(alterJobCommand.getTargetProperties());
-            logParts.add("target properties: " + 
alterJobCommand.getTargetProperties());
+            // update target properties
+            if (!alterJobCommand.getTargetProperties().isEmpty()) {
+                
this.targetProperties.putAll(alterJobCommand.getTargetProperties());
+                logParts.add("target properties: " + 
alterJobCommand.getTargetProperties());
+            }
+            log.info("Alter streaming job {}, {}", getJobId(), String.join(", 
", logParts));
+            logUpdateOperation();
+        } finally {
+            writeUnlock();
         }
-        log.info("Alter streaming job {}, {}", getJobId(), String.join(", ", 
logParts));
     }
 
     @Override
@@ -1087,6 +1120,19 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
         }
         try {
             modifyPropertiesInternal(replayJob.getProperties());
+            if (replayJob.getOffsetProviderPersist() != null) {
+                setOffsetProviderPersist(replayJob.getOffsetProviderPersist());
+                if (tvfType != null && !Config.isCloudMode()) {
+                    
offsetProvider.restoreFromPersistInfo(offsetProviderPersist);
+                }
+            } else {
+                // Legacy journals lack provider state; keep local offset 
replay without resetting MS.
+                String offset = 
replayJob.getProperties().get(StreamingJobProperties.OFFSET_PROPERTY);
+                if (StringUtils.isNotEmpty(offset)) {
+                    offsetProvider.updateOffset(validateOffset(offset));
+                    this.offsetProviderPersist = 
offsetProvider.getPersistInfo();
+                }
+            }
             // When the pause state is restarted, it also needs to be updated
             if (Config.isCloudMode()) {
                 replayOnCloudMode();
@@ -1095,9 +1141,6 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
             // should not happen
             log.error("replay modify streaming insert job properties failed, 
job id: {}", getJobId(), e);
         }
-        if (replayJob.getOffsetProviderPersist() != null) {
-            setOffsetProviderPersist(replayJob.getOffsetProviderPersist());
-        }
         if (replayJob.getNonTxnJobStatistic() != null) {
             setNonTxnJobStatistic(replayJob.getNonTxnJobStatistic());
         }
@@ -1119,24 +1162,11 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
         setLastTaskSuccessTime(replayJob.getLastTaskSuccessTime());
         setStartTimeMs(replayJob.getStartTimeMs());
         this.boundBackendId = replayJob.boundBackendId;
+        offsetProvider.setBoundBackendId(boundBackendId);
     }
 
-    /**
-     * When updating offset, you need to reset the currentOffset
-     */
-    private void modifyPropertiesInternal(Map<String, String> inputProperties) 
throws AnalysisException, JobException {
-        StreamingJobProperties inputStreamProps = new 
StreamingJobProperties(inputProperties);
-        if (StringUtils.isNotEmpty(inputStreamProps.getOffsetProperty())) {
-            Offset offset = 
validateOffset(inputStreamProps.getOffsetProperty());
-            this.offsetProvider.updateOffset(offset);
-            this.offsetProvider.resetLag();
-            this.offsetProviderPersist = offsetProvider.getPersistInfo();
-            log.info("modifyPropertiesInternal: offset updated to {}, job {}",
-                    inputStreamProps.getOffsetProperty(), getJobId());
-            if (Config.isCloudMode()) {
-                resetCloudProgress(offset);
-            }
-        }
+    /** Updates configuration without executing offset reset instructions 
retained in replayed properties. */
+    private void modifyPropertiesInternal(Map<String, String> inputProperties) 
{
         if 
(inputProperties.containsKey(StreamingJobProperties.COMPUTE_GROUP_PROPERTY)) {
             this.cloudCluster = 
inputProperties.get(StreamingJobProperties.COMPUTE_GROUP_PROPERTY);
             offsetProvider.setCloudCluster(this.cloudCluster);
@@ -1406,15 +1436,7 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
             LoadJob loadJob = loadJobs.get(0);
             LoadStatistic loadStatistic = loadJob.getLoadStatistic();
 
-            String offsetJson = offsetProvider.getCommitOffsetJson(
-                    runningStreamTask.getRunningOffset(),
-                    runningStreamTask.getTaskId(),
-                    runningStreamTask.getScanBackendIds());
-
-            if (StringUtils.isBlank(offsetJson)) {
-                throw new TransactionException("Cannot find offset for 
attachment, load job id is "
-                        + runningStreamTask.getTaskId());
-            }
+            String offsetJson = ((StreamingInsertTask) 
runningStreamTask).getCommitOffsetJson();
             txnState.setTxnCommitAttachment(new 
StreamingTaskTxnCommitAttachment(
                         getJobId(),
                         runningStreamTask.getTaskId(),
@@ -1729,8 +1751,6 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
         if (offsetProvider != null) {
             // when fe restart, offsetProvider.jobId/sourceProperties may be 
null
             offsetProvider.ensureInitialized(getJobId(), getProviderProps());
-            // replayOnUpdated skips the transient provider; resync routing BE.
-            offsetProvider.setBoundBackendId(boundBackendId);
             offsetProvider.replayIfNeed(this);
         }
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertTask.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertTask.java
index ec923146c93..a2642376937 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertTask.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertTask.java
@@ -30,6 +30,7 @@ import org.apache.doris.job.common.TaskStatus;
 import org.apache.doris.job.exception.JobException;
 import org.apache.doris.job.extensions.insert.InsertTask;
 import org.apache.doris.job.offset.SourceOffsetProvider;
+import org.apache.doris.job.offset.s3.S3EventSourceOffsetProvider;
 import org.apache.doris.load.loadv2.LoadJob;
 import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.glue.LogicalPlanAdapter;
@@ -47,6 +48,7 @@ import org.apache.doris.tablefunction.S3TableValuedFunction;
 import org.apache.doris.thrift.TCell;
 import org.apache.doris.thrift.TRow;
 import org.apache.doris.thrift.TStatusCode;
+import org.apache.doris.transaction.TransactionException;
 
 import com.google.common.base.Preconditions;
 import lombok.Getter;
@@ -72,6 +74,7 @@ public class StreamingInsertTask extends 
AbstractStreamingTask {
     private StreamingJobProperties jobProperties;
     private Map<String, String> originTvfProps;
     private String cloudCluster;
+    private String commitOffsetJson;
     private String auditSql;
     private final boolean auditEnabled;
     SourceOffsetProvider offsetProvider;
@@ -98,6 +101,7 @@ public class StreamingInsertTask extends 
AbstractStreamingTask {
 
     @Override
     public void before() throws Exception {
+        commitOffsetJson = null;
         auditSql = null;
         if (getIsCanceled().get()) {
             log.info("streaming insert task has been canceled, task id is {}", 
getTaskId());
@@ -198,6 +202,18 @@ public class StreamingInsertTask extends 
AbstractStreamingTask {
         return Collections.emptyList();
     }
 
+    public String getCommitOffsetJson() throws TransactionException {
+        // Reuse offsets across retries, but revalidate notification batch 
receipts on every attempt.
+        if (commitOffsetJson == null || offsetProvider instanceof 
S3EventSourceOffsetProvider) {
+            String offsetJson = 
offsetProvider.getCommitOffsetJson(runningOffset, getTaskId(), 
getScanBackendIds());
+            if (StringUtils.isBlank(offsetJson)) {
+                throw new TransactionException("Cannot find offset for 
attachment, load job id is " + getTaskId());
+            }
+            commitOffsetJson = offsetJson;
+        }
+        return commitOffsetJson;
+    }
+
     @Override
     public boolean onSuccess() throws JobException {
         if (getIsCanceled().get()) {
@@ -237,6 +253,7 @@ public class StreamingInsertTask extends 
AbstractStreamingTask {
 
     @Override
     public void closeOrReleaseResources() {
+        commitOffsetJson = null;
         if (null != stmtExecutor) {
             stmtExecutor = null;
         }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/manager/JobManager.java 
b/fe/fe-core/src/main/java/org/apache/doris/job/manager/JobManager.java
index 8c55856da57..d36b472ceb5 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/job/manager/JobManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/job/manager/JobManager.java
@@ -267,7 +267,6 @@ public class JobManager<T extends AbstractJob<?, C>, C> 
implements Writable {
             } else {
                 throw new JobException("Unsupported job type for ALTER:" + 
job.getJobType());
             }
-            job.logUpdateOperation();
         } finally {
             writeUnlock();
         }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/SourceOffsetProvider.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/SourceOffsetProvider.java
index 0fbd5755c03..624e6a431dd 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/SourceOffsetProvider.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/SourceOffsetProvider.java
@@ -151,9 +151,7 @@ public interface SourceOffsetProvider {
     }
 
     /**
-     * Restore offset from persisted string during image load 
(gsonPostProcess).
-     * Called immediately after the provider is created so that even PAUSED 
jobs
-     * have the correct offset state.
+     * Restore offset from an image or job update journal, including for 
PAUSED jobs.
      */
     default void restoreFromPersistInfo(String persistInfo) {
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProvider.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProvider.java
index 0eb327af571..94cfd4fbbac 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProvider.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProvider.java
@@ -529,13 +529,51 @@ public class JdbcSourceOffsetProvider implements 
SourceOffsetProvider {
     }
 
     @Override
-    public void validateAlterOffset(String offset) throws Exception {
-        if (!DataSourceConfigValidator.isJsonOffset(offset)) {
+    public void validateAlterOffset(String offset) throws AnalysisException {
+        JsonNode offsetNode;
+        try {
+            offsetNode = objectMapper.readTree(offset);
+        } catch (Exception e) {
+            offsetNode = null;
+        }
+        if (offsetNode == null || !offsetNode.isObject()) {
             throw new AnalysisException(
                     "ALTER JOB for CDC only supports JSON specific offset, "
                     + "e.g. '{\"file\":\"binlog.000001\",\"pos\":\"154\"}' for 
MySQL "
                     + "or '{\"lsn\":\"12345678\"}' for PostgreSQL");
         }
+
+        if ((sourceType == DataSourceType.MYSQL || sourceType == 
DataSourceType.OCEANBASE)
+                && offsetNode.has("kind") && 
!"SPECIFIC".equals(offsetNode.path("kind").asText())) {
+            throw new AnalysisException("ALTER JOB offset kind must be 
SPECIFIC when specified");
+        }
+
+        boolean valid = switch (sourceType) {
+            case POSTGRES -> isNonNegativeLong(offsetNode.get("lsn"));
+            case MYSQL, OCEANBASE -> offsetNode.has("file") && 
offsetNode.has("pos")
+                    ? hasText(offsetNode.path("file")) && 
isNonNegativeLong(offsetNode.get("pos"))
+                    : hasText(offsetNode.path("gtids"));
+            default -> throw new AnalysisException(
+                    "Unsupported CDC source type for ALTER JOB offset: " + 
sourceType);
+        };
+        if (!valid) {
+            throw new AnalysisException("Invalid offset for " + sourceType);
+        }
+    }
+
+    private static boolean hasText(JsonNode value) {
+        return value.isTextual() && !value.asText().trim().isEmpty();
+    }
+
+    private static boolean isNonNegativeLong(JsonNode value) {
+        if (value == null || (!value.isTextual() && 
!value.isIntegralNumber())) {
+            return false;
+        }
+        try {
+            return Long.parseLong(value.asText()) >= 0;
+        } catch (NumberFormatException e) {
+            return false;
+        }
     }
 
     /**
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcTvfSourceOffsetProvider.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcTvfSourceOffsetProvider.java
index 9d0c88bbdcc..84cc3a94a53 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcTvfSourceOffsetProvider.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcTvfSourceOffsetProvider.java
@@ -437,8 +437,7 @@ public class JdbcTvfSourceOffsetProvider extends 
JdbcSourceOffsetProvider {
     }
 
     /**
-     * Restore chw/bop/ts from the image-persisted JSON. Called by 
gsonPostProcess on FE startup
-     * before any journal replay; recovers state lost when pre-checkpoint 
journal is GC'd.
+     * Restore chw/bop/ts from image or job update journal JSON; preserves 
cumulative snapshot progress.
      */
     @Override
     public void restoreFromPersistInfo(String persistInfo) {
@@ -450,6 +449,10 @@ public class JdbcTvfSourceOffsetProvider extends 
JdbcSourceOffsetProvider {
             this.chunkHighWatermarkMap = tmp.getChunkHighWatermarkMap();
             this.binlogOffsetPersist = tmp.getBinlogOffsetPersist();
             this.tableSchemas = tmp.getTableSchemas();
+            if (MapUtils.isNotEmpty(binlogOffsetPersist)) {
+                this.currentOffset = new JdbcOffset(
+                        Collections.singletonList(new 
BinlogSplit(binlogOffsetPersist)));
+            }
             log.info("Restored TVF offset provider from persist: chw={}, 
bop={}, ts.len={}",
                     chunkHighWatermarkMap == null ? 0 : 
chunkHighWatermarkMap.size(),
                     binlogOffsetPersist == null ? 0 : 
binlogOffsetPersist.size(),
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobLagTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobLagTest.java
index 67ffc5f0170..a87b6881bf5 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobLagTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobLagTest.java
@@ -21,12 +21,15 @@ import org.apache.doris.common.jmockit.Deencapsulation;
 import org.apache.doris.job.base.JobExecutionConfiguration;
 import org.apache.doris.job.base.TimerDefinition;
 import org.apache.doris.job.cdc.split.BinlogSplit;
+import org.apache.doris.job.common.JobStatus;
 import org.apache.doris.job.offset.jdbc.JdbcOffset;
 import org.apache.doris.job.offset.jdbc.JdbcSourceOffsetProvider;
 import org.apache.doris.job.offset.s3.S3EventSourceOffsetProvider;
+import org.apache.doris.nereids.trees.plans.commands.AlterJobCommand;
 
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
 
 import java.util.Collections;
 import java.util.HashMap;
@@ -68,7 +71,9 @@ public class StreamingInsertJobLagTest {
 
     @Test
     public void testExplicitOffsetChangeInvalidatesLastObservedLag() throws 
Exception {
-        StreamingInsertJob job = 
Deencapsulation.newInstance(StreamingInsertJob.class);
+        StreamingInsertJob job = Mockito.spy(new StreamingInsertJob());
+        Mockito.doNothing().when(job).logUpdateOperation();
+        job.setJobStatus(JobStatus.PAUSED);
         Map<String, String> properties = new HashMap<>();
         properties.put(StreamingJobProperties.MAX_INTERVAL_SECOND_PROPERTY, 
"10");
         Deencapsulation.setField(job, "properties", properties);
@@ -84,7 +89,8 @@ public class StreamingInsertJobLagTest {
 
         Map<String, String> alterProperties = new HashMap<>();
         alterProperties.put(StreamingJobProperties.OFFSET_PROPERTY, 
"{\"lsn\":\"200\"}");
-        Deencapsulation.invoke(job, "modifyPropertiesInternal", 
alterProperties);
+        job.alterJob(new AlterJobCommand("test_job", alterProperties, null, 
null, null,
+                Collections.emptyMap(), Collections.emptyMap()));
 
         Assertions.assertEquals(-1, provider.getLagBytes());
     }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobOffsetPersistenceTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobOffsetPersistenceTest.java
index f8e9bda0e6f..6fedf56cc8b 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobOffsetPersistenceTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobOffsetPersistenceTest.java
@@ -18,8 +18,10 @@
 package org.apache.doris.job.extensions.insert.streaming;
 
 import org.apache.doris.catalog.Env;
+import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.job.cdc.DataSourceConfigKeys;
 import org.apache.doris.job.cdc.request.CommitOffsetRequest;
 import org.apache.doris.job.cdc.split.SnapshotSplit;
 import org.apache.doris.job.common.JobStatus;
@@ -29,10 +31,15 @@ import org.apache.doris.job.manager.JobManager;
 import org.apache.doris.job.manager.StreamingTaskManager;
 import org.apache.doris.job.offset.SourceOffsetProvider;
 import org.apache.doris.job.offset.jdbc.JdbcSourceOffsetProvider;
+import org.apache.doris.job.offset.jdbc.JdbcTvfSourceOffsetProvider;
 import org.apache.doris.job.offset.s3.S3Offset;
 import org.apache.doris.job.offset.s3.S3SourceOffsetProvider;
+import org.apache.doris.nereids.trees.plans.commands.AlterJobCommand;
 import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.tablefunction.CdcStreamTableValuedFunction;
+import org.apache.doris.tablefunction.S3TableValuedFunction;
 import org.apache.doris.transaction.GlobalTransactionMgrIface;
+import org.apache.doris.transaction.TransactionState;
 import org.apache.doris.transaction.TxnStateCallbackFactory;
 
 import org.junit.jupiter.api.Assertions;
@@ -47,6 +54,63 @@ import java.util.concurrent.locks.ReentrantReadWriteLock;
 
 public class StreamingInsertJobOffsetPersistenceTest {
 
+    @Test
+    public void testAlterChecksStatusAndPersistsUnderJobLock() throws 
Exception {
+        TestStreamingInsertJob job = Mockito.spy(newJob(new 
JdbcSourceOffsetProvider(), 1017L));
+        job.setJobStatus(JobStatus.PAUSED);
+        ReentrantReadWriteLock jobLock = Deencapsulation.getField(job, "lock");
+        Mockito.doAnswer(invocation -> {
+            Assertions.assertTrue(jobLock.isWriteLockedByCurrentThread());
+            return invocation.callRealMethod();
+        }).when(job).getJobStatus();
+        Mockito.doAnswer(invocation -> {
+            Assertions.assertTrue(jobLock.isWriteLockedByCurrentThread());
+            Assertions.assertEquals("1", 
job.getTargetProperties().get("replication_num"));
+            return invocation.callRealMethod();
+        }).when(job).logUpdateOperation();
+        HashMap<String, String> targetProperties = new HashMap<>();
+        targetProperties.put("replication_num", "1");
+        AlterJobCommand command = new AlterJobCommand("test_job", 
Collections.emptyMap(), null,
+                "mysql", null, Collections.emptyMap(), targetProperties);
+
+        job.alterJob(command);
+
+        Assertions.assertEquals(1, job.journalCount);
+        Assertions.assertFalse(jobLock.isWriteLocked());
+
+        // A resume between command validation and execution must reject ALTER 
without mutation.
+        job.setJobStatus(JobStatus.PENDING);
+        targetProperties.put("replication_num", "3");
+        AnalysisException exception = 
Assertions.assertThrows(AnalysisException.class, () -> job.alterJob(command));
+        Assertions.assertEquals("Only PAUSED job can be altered", 
exception.getDetailMessage());
+        Assertions.assertEquals("1", 
job.getTargetProperties().get("replication_num"));
+        Assertions.assertEquals(1, job.journalCount);
+        Assertions.assertFalse(jobLock.isWriteLocked());
+    }
+
+    @Test
+    public void testPausedTvfJobValidatesAlterOffsetAfterRestore() throws 
Exception {
+        StreamingInsertJob job = new StreamingInsertJob();
+        Deencapsulation.setField(job, "jobId", 9001L);
+        job.setJobStatus(JobStatus.PAUSED);
+        Deencapsulation.setField(job, "tvfType", 
CdcStreamTableValuedFunction.NAME);
+        HashMap<String, String> tvfProperties = new HashMap<>();
+        tvfProperties.put(DataSourceConfigKeys.TYPE, "postgres");
+        tvfProperties.put(DataSourceConfigKeys.JDBC_URL, 
"jdbc:postgresql://127.0.0.1:5432/source_db");
+        tvfProperties.put(DataSourceConfigKeys.TABLE, "source_table");
+        Deencapsulation.setField(job, "originTvfProps", tvfProperties);
+
+        job.gsonPostProcess();
+        JdbcTvfSourceOffsetProvider provider = Deencapsulation.getField(job, 
"offsetProvider");
+        Assertions.assertNull(Deencapsulation.getField(provider, 
"sourceType"));
+
+        job.validateAlterOffset("{\"lsn\":\"300\"}");
+
+        Assertions.assertEquals(JobStatus.PAUSED, job.getJobStatus());
+        Assertions.assertThrows(AnalysisException.class,
+                () -> 
job.validateAlterOffset("{\"file\":\"mysql-bin.000001\",\"pos\":\"154\"}"));
+    }
+
     @Test
     public void testFirstSnapshotCommitPersistsImmediately() throws Exception {
         JdbcSourceOffsetProvider provider = new JdbcSourceOffsetProvider();
@@ -133,7 +197,7 @@ public class StreamingInsertJobOffsetPersistenceTest {
 
         HashMap<String, String> properties = new HashMap<>();
         properties.put(StreamingJobProperties.OFFSET_PROPERTY, 
"{\"lsn\":\"300\"}");
-        Deencapsulation.invoke(job, "modifyPropertiesInternal", properties);
+        alterOffset(job, 
properties.get(StreamingJobProperties.OFFSET_PROPERTY));
 
         Assertions.assertTrue(job.getOffsetProviderPersist().contains("300"));
         Assertions.assertTrue(provider.getFinishedSplits().isEmpty());
@@ -297,6 +361,50 @@ public class StreamingInsertJobOffsetPersistenceTest {
         Assertions.assertEquals(1234L, job.getStartTimeMs());
     }
 
+    @Test
+    public void 
testReplayUpdatedRestoresProgressWithoutReapplyingAlterOffset() throws 
Exception {
+        S3SourceOffsetProvider provider = new S3SourceOffsetProvider();
+        StreamingInsertJob job = new StreamingInsertJob();
+        Deencapsulation.setField(job, "jobId", 9001L);
+        Deencapsulation.setField(job, "offsetProvider", provider);
+        Deencapsulation.setField(job, "properties", new HashMap<String, 
String>());
+        Deencapsulation.setField(job, "jobProperties", new 
StreamingJobProperties(job.getProperties()));
+        Deencapsulation.setField(job, "tvfType", S3TableValuedFunction.NAME);
+        alterOffset(job, "{\"fileName\":\"100.csv\"}");
+
+        TransactionState txnState = Mockito.mock(TransactionState.class);
+        Mockito.when(txnState.getTxnCommitAttachment()).thenReturn(new 
StreamingTaskTxnCommitAttachment(
+                9001L, 1017L, 0L, 0L, 0L, 0L, 0L, 
"{\"endFile\":\"500.csv\"}"));
+        job.replayOnCommitted(txnState);
+        job.setJobStatus(JobStatus.PAUSED);
+        StreamingInsertJob pauseRecord = 
GsonUtils.GSON.fromJson(GsonUtils.GSON.toJson(job), StreamingInsertJob.class);
+
+        job.replayOnUpdated(pauseRecord);
+
+        Assertions.assertEquals("{\"fileName\":\"500.csv\"}", 
provider.getShowCurrentOffset());
+        
Assertions.assertTrue(job.getOffsetProviderPersist().contains("500.csv"));
+        StreamingInsertJob restored = 
GsonUtils.GSON.fromJson(GsonUtils.GSON.toJson(job), StreamingInsertJob.class);
+        
Assertions.assertTrue(restored.getOffsetProviderPersist().contains("500.csv"));
+        SourceOffsetProvider restoredProvider = 
Deencapsulation.getField(restored, "offsetProvider");
+        Assertions.assertEquals("{\"fileName\":\"500.csv\"}", 
restoredProvider.getShowCurrentOffset());
+
+        alterOffset(pauseRecord, "{\"fileName\":\"100.csv\"}");
+        job.replayOnUpdated(pauseRecord);
+        Assertions.assertEquals("{\"fileName\":\"100.csv\"}", 
provider.getShowCurrentOffset());
+    }
+
+    private static void alterOffset(StreamingInsertJob job, String offset) 
throws Exception {
+        job.setJobStatus(JobStatus.PAUSED);
+        AlterJobCommand command = new AlterJobCommand("test_job",
+                
Collections.singletonMap(StreamingJobProperties.OFFSET_PROPERTY, offset),
+                null, null, null, Collections.emptyMap(), 
Collections.emptyMap());
+        try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+            Env env = Mockito.mock(Env.class, Mockito.RETURNS_DEEP_STUBS);
+            envMockedStatic.when(Env::getCurrentEnv).thenReturn(env);
+            job.alterJob(command);
+        }
+    }
+
     private static TestStreamingInsertJob newJob(SourceOffsetProvider 
provider, long taskId) {
         TestStreamingInsertJob job = new TestStreamingInsertJob();
         Deencapsulation.setField(job, "lock", new 
ReentrantReadWriteLock(true));
@@ -304,7 +412,9 @@ public class StreamingInsertJobOffsetPersistenceTest {
         Deencapsulation.setField(job, "jobName", "test_job");
         Deencapsulation.setField(job, "jobStatus", JobStatus.RUNNING);
         Deencapsulation.setField(job, "offsetProvider", provider);
-        Deencapsulation.setField(job, "properties", new HashMap<String, 
String>());
+        HashMap<String, String> properties = new HashMap<>();
+        Deencapsulation.setField(job, "properties", properties);
+        Deencapsulation.setField(job, "jobProperties", new 
StreamingJobProperties(properties));
         Deencapsulation.setField(job, "targetProperties", new HashMap<String, 
String>());
         Deencapsulation.setField(job, "runningStreamTask", new 
NoopStreamingMultiTblTask(taskId));
         return job;
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobStatusTransitionTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobStatusTransitionTest.java
index e15de71ac3a..5d8909d6202 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobStatusTransitionTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobStatusTransitionTest.java
@@ -73,6 +73,17 @@ public class StreamingInsertJobStatusTransitionTest {
         Assertions.assertEquals(JobStatus.STOPPED, job.getJobStatus());
     }
 
+    @Test
+    public void testFailedCommitCallbackReleasesWriteLock() throws Exception {
+        StreamingInsertJob job = newJob(JobStatus.RUNNING);
+        ReentrantReadWriteLock lock = Deencapsulation.getField(job, "lock");
+        lock.writeLock().lock();
+
+        job.afterCommitted(null, false);
+
+        Assertions.assertEquals(0, lock.getWriteHoldCount());
+    }
+
     @Test
     public void testFinishedStatusCannotBeOverwritten() {
         StreamingInsertJob job = newJob(JobStatus.FINISHED);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertTaskAuditTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertTaskAuditTest.java
index f171296fb6e..bb132542380 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertTaskAuditTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertTaskAuditTest.java
@@ -30,6 +30,7 @@ import org.apache.doris.job.offset.Offset;
 import org.apache.doris.job.offset.SourceOffsetProvider;
 import org.apache.doris.job.offset.jdbc.JdbcTvfSourceOffsetProvider;
 import org.apache.doris.job.offset.s3.S3EventOffset;
+import org.apache.doris.job.offset.s3.S3EventSourceOffsetProvider;
 import org.apache.doris.job.offset.s3.S3Offset;
 import org.apache.doris.job.offset.s3.S3SourceOffsetProvider;
 import org.apache.doris.nereids.StatementContext;
@@ -105,6 +106,24 @@ public class StreamingInsertTaskAuditTest {
         Assertions.assertFalse(s3Task.isNoRetry());
     }
 
+    @Test
+    public void testCommitRetryRevalidatesNotificationReceipts() throws 
Exception {
+        S3EventSourceOffsetProvider provider = 
Mockito.mock(S3EventSourceOffsetProvider.class);
+        Mockito.when(provider.getSourceType()).thenReturn("s3");
+        S3EventOffset offset = new 
S3EventOffset(Collections.singletonList("input/a.csv"));
+        String offsetJson = offset.toSerializedJson();
+        Mockito.when(provider.getCommitOffsetJson(offset, 2L, 
Collections.emptyList()))
+                .thenReturn(offsetJson)
+                .thenThrow(new IllegalStateException("S3 EVENT batch receipts 
expired before commit"));
+        StreamingInsertTask task = new StreamingInsertTask(1L, 2L, "", 
provider, "test_db", null,
+                Collections.emptyMap(), UserIdentity.ROOT, null);
+        Deencapsulation.setField(task, "runningOffset", offset);
+
+        Assertions.assertEquals(offsetJson, task.getCommitOffsetJson());
+        Assertions.assertThrows(IllegalStateException.class, 
task::getCommitOffsetJson);
+        Mockito.verify(provider, Mockito.times(2)).getCommitOffsetJson(offset, 
2L, Collections.emptyList());
+    }
+
     @Test
     public void testS3RunSubmitsAuditEvent() throws Exception {
         AuditEvent auditEvent = runS3Task(null);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderOffsetTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderOffsetTest.java
index ed80084ef5f..8adac456c01 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderOffsetTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderOffsetTest.java
@@ -17,11 +17,13 @@
 
 package org.apache.doris.job.offset.jdbc;
 
+import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.jmockit.Deencapsulation;
 import org.apache.doris.job.cdc.DataSourceConfigKeys;
 import org.apache.doris.job.cdc.split.BinlogSplit;
 import org.apache.doris.job.cdc.split.SnapshotSplit;
+import org.apache.doris.job.common.DataSourceType;
 import org.apache.doris.job.extensions.insert.streaming.StreamingInsertJob;
 
 import org.junit.jupiter.api.Assertions;
@@ -34,6 +36,60 @@ import java.util.Map;
 
 public class JdbcSourceOffsetProviderOffsetTest {
 
+    @Test
+    public void testValidateAlterOffsetAcceptsSourceSpecificPositions() throws 
Exception {
+        JdbcSourceOffsetProvider postgres = provider(DataSourceType.POSTGRES);
+        postgres.validateAlterOffset("{\"lsn\":12345678,\"txId\":\"7\"}");
+        postgres.validateAlterOffset("{\"lsn\":\"12345678\"}");
+
+        JdbcSourceOffsetProvider mysql = provider(DataSourceType.MYSQL);
+        
mysql.validateAlterOffset("{\"file\":\"mysql-bin.000001\",\"pos\":154,\"kind\":\"SPECIFIC\"}");
+        
mysql.validateAlterOffset("{\"file\":\"mysql-bin.000001\",\"pos\":\"154\"}");
+        
mysql.validateAlterOffset("{\"gtids\":\"24bc7850-2c16-11ef-a0c9-0242ac120002:1-9\"}");
+        mysql.validateAlterOffset(
+                
"{\"gtids\":\"24bc7850-2c16-11ef-a0c9-0242ac120002:1-9\",\"kind\":\"SPECIFIC\"}");
+
+        JdbcSourceOffsetProvider oceanBase = 
provider(DataSourceType.OCEANBASE);
+        
oceanBase.validateAlterOffset("{\"file\":\"mysql-bin.000001\",\"pos\":\"154\"}");
+        
oceanBase.validateAlterOffset("{\"gtids\":\"24bc7850-2c16-11ef-a0c9-0242ac120002:1-9\"}");
+    }
+
+    @Test
+    public void testValidateAlterOffsetRejectsInvalidSourceSpecificPositions() 
{
+        assertInvalidAlterOffsets(provider(DataSourceType.POSTGRES),
+                "{}",
+                "{\"file\":\"mysql-bin.000001\",\"pos\":\"154\"}",
+                "{\"lsn\":null}",
+                "{\"lsn\":\"abc\"}",
+                "{\"lsn\":\"-1\"}",
+                "{\"lsn\":\"9223372036854775808\"}");
+
+        assertInvalidAlterOffsets(provider(DataSourceType.MYSQL),
+                "{}",
+                "{\"lsn\":\"12345678\"}",
+                "{\"file\":\"mysql-bin.000001\"}",
+                "{\"file\":\"\",\"pos\":\"154\"}",
+                "{\"file\":\"mysql-bin.000001\",\"pos\":null}",
+                "{\"file\":\"mysql-bin.000001\",\"pos\":\"abc\"}",
+                "{\"file\":\"mysql-bin.000001\",\"pos\":\"-1\"}",
+                
"{\"file\":\"mysql-bin.000001\",\"pos\":\"9223372036854775808\"}",
+                "{\"gtids\":\"\"}",
+                "{\"gtids\":123}",
+                
"{\"gtids\":\"uuid:1-10\",\"file\":\"mysql-bin.000001\",\"pos\":\"abc\"}");
+    }
+
+    @Test
+    public void testValidateAlterOffsetRejectsNonSpecificKind() {
+        for (DataSourceType type : new DataSourceType[] {DataSourceType.MYSQL, 
DataSourceType.OCEANBASE}) {
+            assertInvalidAlterOffsets(provider(type),
+                    
"{\"file\":\"mysql-bin.000001\",\"pos\":154,\"kind\":\"LATEST\"}",
+                    
"{\"file\":\"mysql-bin.000001\",\"pos\":154,\"kind\":\"EARLIEST\"}",
+                    
"{\"file\":\"mysql-bin.000001\",\"pos\":154,\"kind\":\"TIMESTAMP\"}",
+                    "{\"gtids\":\"uuid:1-10\",\"kind\":\"BOGUS\"}",
+                    "{\"gtids\":\"uuid:1-10\",\"kind\":null}");
+        }
+    }
+
     @Test
     public void testTaskParallelismKeepsUncommittedSplitsForNextTask() {
         JdbcSourceOffsetProvider provider = new JdbcSourceOffsetProvider();
@@ -192,6 +248,8 @@ public class JdbcSourceOffsetProviderOffsetTest {
                 new BinlogSplit(Collections.singletonMap("lsn", "200")))));
         StreamingInsertJob job = 
mockJobWithPersistInfo(source.getPersistInfo());
         JdbcTvfSourceOffsetProvider restored = new 
JdbcTvfSourceOffsetProvider();
+        restored.updateOffset(new JdbcOffset(Collections.singletonList(
+                new BinlogSplit(Collections.singletonMap("lsn", "100")))));
 
         restored.restoreFromPersistInfo(source.getPersistInfo());
         restored.replayIfNeed(job);
@@ -327,6 +385,19 @@ public class JdbcSourceOffsetProviderOffsetTest {
         Assertions.assertNull(progress.getNextSplitId());
     }
 
+    private static JdbcSourceOffsetProvider provider(DataSourceType 
sourceType) {
+        JdbcSourceOffsetProvider provider = new JdbcSourceOffsetProvider();
+        provider.setSourceType(sourceType);
+        return provider;
+    }
+
+    private static void assertInvalidAlterOffsets(JdbcSourceOffsetProvider 
provider, String... offsets) {
+        for (String offset : offsets) {
+            Assertions.assertThrows(AnalysisException.class,
+                    () -> provider.validateAlterOffset(offset), offset);
+        }
+    }
+
     private static class TestJdbcSourceOffsetProvider extends 
JdbcSourceOffsetProvider {
         private final int compareResult;
 
diff --git 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/JdbcIncrementalSourceReader.java
 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/JdbcIncrementalSourceReader.java
index 0592d83c54b..60be92b455e 100644
--- 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/JdbcIncrementalSourceReader.java
+++ 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/JdbcIncrementalSourceReader.java
@@ -444,7 +444,7 @@ public abstract class JdbcIncrementalSourceReader extends 
AbstractCdcSourceReade
 
         // Rebuild path: fail loudly if the source position is gone (e.g. slot 
dropped) instead of
         // silently re-locating from a lost offset.
-        validateStreamSource(offsetMeta, baseReq);
+        validateStreamSource(newStreamSplit.getStartingOffset(), baseReq);
 
         this.streamReader = getBinlogSplitReader(baseReq);
 
@@ -470,8 +470,8 @@ public abstract class JdbcIncrementalSourceReader extends 
AbstractCdcSourceReade
     }
 
     // Source-specific check before (re)building the stream reader; default 
no-op.
-    protected void validateStreamSource(
-            Map<String, Object> offsetMeta, JobBaseRecordRequest baseReq) 
throws Exception {}
+    protected void validateStreamSource(Offset startingOffset, 
JobBaseRecordRequest baseReq)
+            throws Exception {}
 
     @Override
     public Iterator<SourceRecord> pollRecords() throws Exception {
diff --git 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReader.java
 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReader.java
index 1e920969875..ed8acebd373 100644
--- 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReader.java
+++ 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReader.java
@@ -808,6 +808,12 @@ public class MySqlSourceReader extends 
AbstractCdcSourceReader {
                 binlogSplit.getStartingOffset() == null
                         ? new HashMap<>()
                         : new HashMap<>(binlogSplit.getStartingOffset());
+        if (!lastOffsetMap.containsKey(BinlogOffset.BINLOG_FILENAME_OFFSET_KEY)
+                && lastOffsetMap.containsKey(BinlogOffset.GTID_SET_KEY)) {
+            
BinlogOffset.ofGtidSet(lastOffsetMap.get(BinlogOffset.GTID_SET_KEY))
+                    .getOffset()
+                    .forEach(lastOffsetMap::putIfAbsent);
+        }
         // ALTER offset may not contain "kind", supplement it for Flink CDC
         if (lastOffsetMap.containsKey(BinlogOffset.BINLOG_FILENAME_OFFSET_KEY)
                 && !lastOffsetMap.containsKey(BinlogOffset.OFFSET_KIND_KEY)) {
diff --git 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/postgres/PostgresSourceReader.java
 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/postgres/PostgresSourceReader.java
index e52502e5576..c6b89720676 100644
--- 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/postgres/PostgresSourceReader.java
+++ 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/reader/postgres/PostgresSourceReader.java
@@ -540,12 +540,13 @@ public class PostgresSourceReader extends 
JdbcIncrementalSourceReader {
         }
     }
 
-    // Detect a replication slot that was dropped (or dropped and recreated) 
out from under us while
-    // the job was paused/retrying. Recreating it silently would resume from a 
position whose WAL is
-    // already gone -> data loss. Fail with a fixed marker so FE classifies it 
as non-resumable.
+    // A missing slot cannot safely resume from the committed position; do not 
recreate it.
+    // Do not compare restart_lsn with the saved offset: replication feedback 
can advance slot
+    // positions beyond it during low traffic. This cannot reliably 
distinguish normal advancement
+    // from a slot dropped and recreated with the same name.
     @Override
-    protected void validateStreamSource(
-            Map<String, Object> offsetMeta, JobBaseRecordRequest baseReq) 
throws Exception {
+    protected void validateStreamSource(Offset startingOffset, 
JobBaseRecordRequest baseReq)
+            throws Exception {
         PostgresSourceConfig sourceConfig = getSourceConfig(baseReq);
         PostgresDialect dialect = new PostgresDialect(sourceConfig);
         try (PostgresConnection connection = dialect.openJdbcConnection()) {
@@ -560,36 +561,6 @@ public class PostgresSourceReader extends 
JdbcIncrementalSourceReader {
                                         + " committed position without data 
loss.",
                                 baseReq.getJobId(), dialect.getSlotName()));
             }
-            Lsn requestedLsn = extractRequestedLsn(offsetMeta);
-            Lsn restartLsn = slotState.slotRestartLsn();
-            // restart_lsn must stay <= committed position; a higher one means 
the slot was
-            // recreated
-            // and the WAL between them was discarded, so resuming would 
silently skip data.
-            if (requestedLsn != null
-                    && requestedLsn.asLong() > 0
-                    && restartLsn != null
-                    && restartLsn.compareTo(requestedLsn) > 0) {
-                throw new CdcClientException(
-                        String.format(
-                                "Replication slot invalidated for job %s: slot 
%s restart_lsn %s is"
-                                        + " ahead of the committed position %s 
(slot recreated),"
-                                        + " cannot resume without data loss.",
-                                baseReq.getJobId(),
-                                dialect.getSlotName(),
-                                restartLsn,
-                                requestedLsn));
-            }
-        }
-    }
-
-    private Lsn extractRequestedLsn(Map<String, Object> offsetMeta) {
-        if (offsetMeta == null || offsetMeta.get(SourceInfo.LSN_KEY) == null) {
-            return null;
-        }
-        try {
-            return 
Lsn.valueOf(Long.parseLong(String.valueOf(offsetMeta.get(SourceInfo.LSN_KEY))));
-        } catch (NumberFormatException ex) {
-            return null;
         }
     }
 
diff --git 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReaderTest.java
 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReaderTest.java
index 8905e0379e2..013ab9d2836 100644
--- 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReaderTest.java
+++ 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/reader/mysql/MySqlSourceReaderTest.java
@@ -29,8 +29,11 @@ import org.apache.doris.job.cdc.DataSourceConfigKeys;
 import org.apache.doris.job.cdc.request.JobBaseConfig;
 
 import com.github.shyiko.mysql.binlog.GtidSet;
+import org.apache.flink.api.java.tuple.Tuple2;
 import org.apache.flink.cdc.connectors.mysql.source.config.MySqlSourceConfig;
 import org.apache.flink.cdc.connectors.mysql.source.offset.BinlogOffset;
+import org.apache.flink.cdc.connectors.mysql.source.offset.BinlogOffsetKind;
+import org.apache.flink.cdc.connectors.mysql.source.split.MySqlBinlogSplit;
 import org.apache.flink.cdc.connectors.mysql.source.split.MySqlSnapshotSplit;
 import org.junit.jupiter.api.Test;
 
@@ -113,6 +116,43 @@ public class MySqlSourceReaderTest {
         assertNotNull(off.getOffsetKind());
     }
 
+    @Test
+    void binlogSplitUsesGtidOnlyOffsetWithoutFallingBack() throws Exception {
+        String gtid = SERVER_UUID + ":1-3:5-7";
+        MySqlSourceReader reader = new MySqlSourceReader();
+        reader.setTableSchemas(new HashMap<>());
+        JobBaseConfig config =
+                new JobBaseConfig(
+                        "job-1",
+                        "MYSQL",
+                        Map.of(
+                                DataSourceConfigKeys.JDBC_URL, 
"jdbc:mysql://localhost:3306/testdb",
+                                DataSourceConfigKeys.USER, "u",
+                                DataSourceConfigKeys.PASSWORD, "p",
+                                DataSourceConfigKeys.DATABASE, "testdb",
+                                DataSourceConfigKeys.TABLE, "t_test",
+                                DataSourceConfigKeys.OFFSET, "initial"),
+                        null);
+        Method method =
+                MySqlSourceReader.class.getDeclaredMethod(
+                        "createBinlogSplit", Map.class, JobBaseConfig.class);
+        method.setAccessible(true);
+        Tuple2<?, ?> result =
+                (Tuple2<?, ?>)
+                        method.invoke(
+                                reader,
+                                Map.of("startingOffset", Map.of("gtids", gtid, 
"event", "7", "row", "3")),
+                                config);
+        BinlogOffset offset = ((MySqlBinlogSplit) 
result.f0).getStartingOffset();
+
+        assertEquals(gtid, offset.getGtidSet());
+        assertEquals(BinlogOffsetKind.SPECIFIC, offset.getOffsetKind());
+        assertEquals("", offset.getFilename());
+        assertEquals(0L, offset.getPosition());
+        assertEquals(7L, offset.getRestartSkipEvents());
+        assertEquals(3L, offset.getRestartSkipRows());
+    }
+
     @Test
     void mysqlConfigIncludesSchemaChanges() throws Exception {
         MySqlSourceConfig config = sourceConfig("initial");


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to