You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@doris.apache.org by yi...@apache.org on 2026/10/14 06:45:16 UTC

(doris) branch branch-4.1 updated: branch-4.1: [feature](fe) Support S3 notification-based streaming ingestion #68876 (#68992)

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

yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new bea4d0d5dbd branch-4.1: [feature](fe) Support S3 notification-based streaming ingestion #68876 (#68992)
bea4d0d5dbd is described below

commit bea4d0d5dbd61dedd72886d23d4c2121c21fd13c
Author: wudi <wu...@selectdb.com>
AuthorDate: Wed Oct 14 14:45:06 2026 +0800

    branch-4.1: [feature](fe) Support S3 notification-based streaming ingestion #68876 (#68992)
    
    Cherry-picked from #68876
---
 fe/fe-core/pom.xml                                 |    4 +
 .../java/org/apache/doris/common/util/S3Util.java  |   39 +-
 .../insert/streaming/StreamingInsertJob.java       |   14 +-
 .../insert/streaming/StreamingInsertTask.java      |   19 +-
 .../insert/streaming/StreamingJobProperties.java   |   56 +-
 .../doris/job/offset/SourceOffsetProvider.java     |   15 +-
 .../job/offset/SourceOffsetProviderFactory.java    |    6 +-
 .../s3/{S3Offset.java => S3EventOffset.java}       |   58 +-
 .../job/offset/s3/S3EventSourceOffsetProvider.java |  879 +++++++++++++++
 .../org/apache/doris/job/offset/s3/S3Offset.java   |    8 +-
 .../job/offset/s3/S3SourceOffsetProvider.java      |   91 +-
 .../java/org/apache/doris/metric/MetricRepo.java   |   19 +-
 .../trees/expressions/functions/table/S3.java      |   30 +-
 .../trees/plans/commands/AlterJobCommand.java      |    8 +-
 .../commands/insert/InsertIntoTableCommand.java    |   10 +
 .../ExternalFileTableValuedFunction.java           |    8 +
 .../doris/tablefunction/S3TableValuedFunction.java |   11 +-
 .../org/apache/doris/common/util/S3UtilTest.java   |   55 +-
 .../streaming/StreamingInsertJobLagTest.java       |   15 +
 .../StreamingInsertJobOffsetPersistenceTest.java   |    4 +-
 .../StreamingInsertJobStatusTransitionTest.java    |   66 ++
 .../streaming/StreamingInsertTaskAuditTest.java    |  137 ++-
 .../streaming/StreamingJobPropertiesTest.java      |   65 +-
 .../doris/job/offset/s3/S3EventOffsetTest.java     |   56 +
 .../offset/s3/S3EventSourceOffsetProviderTest.java | 1185 ++++++++++++++++++++
 .../job/offset/s3/S3SourceOffsetProviderTest.java  |   67 +-
 .../java/org/apache/doris/metric/MetricsTest.java  |   29 +
 .../tablefunction/S3TableValuedFunctionTest.java   |  147 +++
 .../test_streaming_insert_job_s3_event_aksk.out    |   20 +
 ...test_streaming_insert_job_s3_event_iam_role.out |    7 +
 ...st_streaming_insert_job_s3_event_restart_fe.out |   14 +
 regression-test/framework/pom.xml                  |    4 +
 .../test_streaming_insert_job_s3_event_aksk.groovy |  217 ++++
 ...t_streaming_insert_job_s3_event_iam_role.groovy |  128 +++
 ...streaming_insert_job_s3_event_restart_fe.groovy |  152 +++
 .../test_streaming_insert_job_s3_once.groovy       |    6 +-
 36 files changed, 3508 insertions(+), 141 deletions(-)

diff --git a/fe/fe-core/pom.xml b/fe/fe-core/pom.xml
index 848a731d4ba..2b928de3bde 100644
--- a/fe/fe-core/pom.xml
+++ b/fe/fe-core/pom.xml
@@ -632,6 +632,10 @@ under the License.
             <groupId>software.amazon.awssdk</groupId>
             <artifactId>s3-transfer-manager</artifactId>
         </dependency>
+        <dependency>
+            <groupId>software.amazon.awssdk</groupId>
+            <artifactId>sqs</artifactId>
+        </dependency>
         <dependency>
             <groupId>software.amazon.awssdk</groupId>
             <artifactId>sts</artifactId>
diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/util/S3Util.java b/fe/fe-core/src/main/java/org/apache/doris/common/util/S3Util.java
index f63f0be3c7a..7b2d213697d 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/common/util/S3Util.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/common/util/S3Util.java
@@ -577,7 +577,7 @@ public class S3Util {
 
     /**
      * Convert range patterns to brace enumeration patterns for glob matching.
-     * Parts containing negative numbers or non-numeric characters are skipped.
+     * Literal alternatives are preserved; ranges containing negative bounds are skipped.
      * eg(valid):
      *    -> "file{1..3}" => "file{1,2,3}"
      *    -> "file_{1..3,4,5..6}" => "file_{1,2,3,4,5,6}"
@@ -594,13 +594,12 @@ public class S3Util {
 
         while (braceMatcher.find()) {
             String braceContent = braceMatcher.group(1);
-            String[] parts = braceContent.split(",");
-            List<Integer> allNumbers = new ArrayList<>();
+            String[] parts = braceContent.split(",", -1);
+            List<String> values = new ArrayList<>();
             Pattern rangePattern = Pattern.compile("^(-?\\d+)\\.\\.(-?\\d+)$");
 
             for (String part : parts) {
-                part = part.trim();
-                Matcher rangeMatcher = rangePattern.matcher(part);
+                Matcher rangeMatcher = rangePattern.matcher(part.trim());
 
                 if (rangeMatcher.matches()) {
                     int start = Integer.parseInt(rangeMatcher.group(1));
@@ -617,39 +616,35 @@ public class S3Util {
                         end = temp;
                     }
                     for (int i = start; i <= end; i++) {
-                        if (!allNumbers.contains(i)) {
-                            allNumbers.add(i);
+                        String value = String.valueOf(i);
+                        if (!values.contains(value)) {
+                            values.add(value);
                         }
                     }
-                } else if (part.matches("^\\d+$")) {
-                    // This is a single non-negative number like "4"
-                    int num = Integer.parseInt(part);
-                    if (!allNumbers.contains(num)) {
-                        allNumbers.add(num);
-                    }
                 } else {
-                    // Not a valid number or range (e.g., negative number, or contains non-numeric chars)
-                    // Just skip this part and continue processing other parts
-                    continue;
+                    // Preserve literal file names, including leading zeros and non-numeric characters.
+                    if (!values.contains(part)) {
+                        values.add(part);
+                    }
                 }
             }
 
-            // If no valid numbers found after filtering, keep original content
-            if (allNumbers.isEmpty()) {
-                braceMatcher.appendReplacement(result, "{" + braceContent + "}");
+            // If all alternatives were skipped, keep the original content.
+            if (values.isEmpty()) {
+                braceMatcher.appendReplacement(result, Matcher.quoteReplacement("{" + braceContent + "}"));
                 continue;
             }
 
             // Build comma-separated result
             StringBuilder sb = new StringBuilder("{");
-            for (int i = 0; i < allNumbers.size(); i++) {
+            for (int i = 0; i < values.size(); i++) {
                 if (i > 0) {
                     sb.append(",");
                 }
-                sb.append(allNumbers.get(i));
+                sb.append(values.get(i));
             }
             sb.append("}");
-            braceMatcher.appendReplacement(result, sb.toString());
+            braceMatcher.appendReplacement(result, Matcher.quoteReplacement(sb.toString()));
         }
         braceMatcher.appendTail(result);
 
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 972be838e19..938e6b99a46 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
@@ -563,6 +563,7 @@ public class StreamingInsertJob extends AbstractJob<StreamingJobSchedulerTask, M
                 clearRunningStreamTask(status);
             }
             if (isFinalStatus()) {
+                closeOffsetProvider();
                 Env.getCurrentGlobalTransactionMgr().getCallbackFactory().removeCallback(getJobId());
             }
             log.info("Streaming insert job {} update status to {}", getJobId(), getJobStatus());
@@ -777,7 +778,7 @@ public class StreamingInsertJob extends AbstractJob<StreamingJobSchedulerTask, M
             // when fe restart, offsetProvider.jobId may be null
             Map<String, String> props = getProviderProps();
             offsetProvider.ensureInitialized(getJobId(), props);
-            offsetProvider.fetchRemoteMeta(props);
+            offsetProvider.fetchRemoteMeta(jobProperties, props);
         } catch (Exception ex) {
             log.warn("fetch remote meta failed, job id: {}", getJobId(), ex);
             if (tryPauseJob(new FailureReason(InternalErrorCode.GET_REMOTE_DATA_ERROR,
@@ -1036,6 +1037,10 @@ public class StreamingInsertJob extends AbstractJob<StreamingJobSchedulerTask, M
         return offsetProvider != null ? offsetProvider.getLastSourceEventTimestampSeconds() : 0;
     }
 
+    public long getLagMessages() {
+        return offsetProvider != null ? offsetProvider.getLagMessages() : -1;
+    }
+
     public long getLastTaskSuccessTimeSeconds() {
         return lastTaskSuccessTime / 1000L;
     }
@@ -1704,12 +1709,19 @@ public class StreamingInsertJob extends AbstractJob<StreamingJobSchedulerTask, M
         return offsetProvider != null && offsetProvider.hasReachedEnd();
     }
 
+    private void closeOffsetProvider() {
+        if (offsetProvider != null) {
+            offsetProvider.close();
+        }
+    }
+
     /**
      * 1. Clean offset info in ms (s3 tvf)
      * 2. Clean chunk info in meta table (jdbc)
      */
     public void cleanup() throws JobException {
         log.info("cleanup streaming job {}", getJobId());
+        closeOffsetProvider();
 
         // s3 tvf clean offset
         if (tvfType != null && Config.isCloudMode()) {
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 f6589d730c2..ec923146c93 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
@@ -32,11 +32,11 @@ import org.apache.doris.job.extensions.insert.InsertTask;
 import org.apache.doris.job.offset.SourceOffsetProvider;
 import org.apache.doris.load.loadv2.LoadJob;
 import org.apache.doris.nereids.StatementContext;
-import org.apache.doris.nereids.analyzer.UnboundTVFRelation;
 import org.apache.doris.nereids.glue.LogicalPlanAdapter;
 import org.apache.doris.nereids.parser.NereidsParser;
 import org.apache.doris.nereids.trees.plans.commands.info.BaseViewInfo;
 import org.apache.doris.nereids.trees.plans.commands.insert.InsertIntoTableCommand;
+import org.apache.doris.nereids.trees.plans.logical.LogicalTVFRelation;
 import org.apache.doris.nereids.util.SqlLiteralUtils;
 import org.apache.doris.qe.AuditLogHelper;
 import org.apache.doris.qe.ConnectContext;
@@ -118,11 +118,13 @@ public class StreamingInsertTask extends AbstractStreamingTask {
         log.info("streaming insert task {} get running offset: {}", taskId, runningOffset.toString());
         InsertIntoTableCommand baseCommand = (InsertIntoTableCommand) new NereidsParser().parseSingle(sql);
         baseCommand.setJobId(getTaskId());
-        StmtExecutor baseStmtExecutor =
-                new StmtExecutor(ctx, new LogicalPlanAdapter(baseCommand, ctx.getStatementContext()));
-        baseCommand.initPlan(ctx, baseStmtExecutor, false);
-        if (!baseCommand.getParsedPlan().isPresent()) {
-            throw new JobException("Can not get Parsed plan");
+        if (!S3TableValuedFunction.NAME.equalsIgnoreCase(offsetProvider.getSourceType())) {
+            StmtExecutor baseStmtExecutor =
+                    new StmtExecutor(ctx, new LogicalPlanAdapter(baseCommand, ctx.getStatementContext()));
+            baseCommand.initPlan(ctx, baseStmtExecutor, false);
+            if (!baseCommand.getParsedPlan().isPresent()) {
+                throw new JobException("Can not get Parsed plan");
+            }
         }
         this.taskCommand = offsetProvider.rewriteTvfParams(baseCommand, runningOffset, getTaskId());
         this.taskCommand.setLabelName(Optional.of(labelName));
@@ -175,10 +177,11 @@ public class StreamingInsertTask extends AbstractStreamingTask {
     private String buildAuditSql() {
         TreeMap<Pair<Integer, Integer>, String> replacements = new TreeMap<>(new Pair.PairComparator<>());
         new NereidsParser().parseForEncryption(sql, replacements);
-        List<UnboundTVFRelation> tvfRelations = taskCommand.getAllTVFRelation();
+        List<LogicalTVFRelation> tvfRelations = taskCommand.getLogicalQuery()
+                .collectToList(LogicalTVFRelation.class::isInstance);
         Preconditions.checkState(replacements.size() == 1 && tvfRelations.size() == 1,
                 "S3 streaming insert must contain exactly one TVF");
-        String rewrittenProperties = tvfRelations.get(0).getProperties().getMap().entrySet().stream()
+        String rewrittenProperties = tvfRelations.get(0).getFunction().getTVFProperties().getMap().entrySet().stream()
                 .map(entry -> SqlLiteralUtils.quoteStringLiteral(entry.getKey()) + " = "
                         + SqlLiteralUtils.quoteStringLiteral(entry.getValue()))
                 .collect(Collectors.joining(", "));
diff --git a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobProperties.java b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobProperties.java
index d0ea8ec6055..dba78d506c2 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobProperties.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobProperties.java
@@ -26,6 +26,7 @@ import org.apache.doris.qe.VariableMgr;
 
 import com.google.common.base.Strings;
 import lombok.Data;
+import org.apache.commons.lang3.StringUtils;
 import org.json.simple.JSONObject;
 
 import java.util.ArrayList;
@@ -43,15 +44,19 @@ public class StreamingJobProperties implements JobProperties {
     public static final String S3_MAX_BATCH_FILES_PROPERTY = "s3.max_batch_files";
     public static final String S3_MAX_BATCH_BYTES_PROPERTY = "s3.max_batch_bytes";
     public static final String S3_INGESTION_MODE_PROPERTY = "s3.ingestion_mode";
-    public static final String S3_INGESTION_MODE_LEXICAL = "LEXICAL";
-    public static final String S3_INGESTION_MODE_ONCE = "ONCE";
+    public static final String S3_EVENT_SOURCE_PROPERTY = "s3.event.source";
+    public static final String S3_SQS_QUEUE_URL_PROPERTY = "s3.sqs.queue_url";
+    public static final String S3_INGESTION_MODE_ORDERED_LIST = "ORDERED_LIST";
+    public static final String S3_INGESTION_MODE_NOTIFICATION = "NOTIFICATION";
+    public static final String S3_INGESTION_MODE_ONE_TIME = "ONE_TIME";
+    public static final String S3_EVENT_SOURCE_SQS = "SQS";
     public static final String SESSION_VAR_PREFIX = "session.";
     public static final String INTERNAL_KEY_PREFIX = "__";
     public static final String OFFSET_PROPERTY = "offset";
     public static final String COMPUTE_GROUP_PROPERTY = "compute_group";
     public static final List<String> SUPPORT_STREAM_JOB_PROPS = Arrays.asList(MAX_INTERVAL_SECOND_PROPERTY,
             S3_MAX_BATCH_FILES_PROPERTY, S3_MAX_BATCH_BYTES_PROPERTY, S3_INGESTION_MODE_PROPERTY,
-            OFFSET_PROPERTY, COMPUTE_GROUP_PROPERTY);
+            S3_EVENT_SOURCE_PROPERTY, S3_SQS_QUEUE_URL_PROPERTY, OFFSET_PROPERTY, COMPUTE_GROUP_PROPERTY);
 
     public static final long DEFAULT_MAX_INTERVAL_SECOND = 10;
     public static final long DEFAULT_MAX_S3_BATCH_FILES = 256;
@@ -129,12 +134,31 @@ public class StreamingJobProperties implements JobProperties {
                         + properties.get(StreamingJobProperties.S3_MAX_BATCH_BYTES_PROPERTY));
 
         String ingestionMode = getS3IngestionMode();
-        if (!S3_INGESTION_MODE_LEXICAL.equals(ingestionMode)
-                && !S3_INGESTION_MODE_ONCE.equals(ingestionMode)) {
+        if (!S3_INGESTION_MODE_ORDERED_LIST.equals(ingestionMode)
+                && !S3_INGESTION_MODE_ONE_TIME.equals(ingestionMode)
+                && !S3_INGESTION_MODE_NOTIFICATION.equals(ingestionMode)) {
             throw new AnalysisException("Unsupported s3.ingestion_mode: " + ingestionMode);
         }
-        if (S3_INGESTION_MODE_ONCE.equals(ingestionMode) && properties.containsKey(OFFSET_PROPERTY)) {
-            throw new AnalysisException("offset is not supported when s3.ingestion_mode is ONCE");
+        if (S3_INGESTION_MODE_ONE_TIME.equals(ingestionMode) && properties.containsKey(OFFSET_PROPERTY)) {
+            throw new AnalysisException("offset is not supported when s3.ingestion_mode is ONE_TIME");
+        }
+
+        if (S3_INGESTION_MODE_NOTIFICATION.equals(ingestionMode)) {
+            if (!S3_EVENT_SOURCE_SQS.equalsIgnoreCase(getS3EventSource())) {
+                throw new AnalysisException("s3.event.source must be SQS when s3.ingestion_mode is NOTIFICATION");
+            }
+            if (StringUtils.isBlank(getS3SqsQueueUrl())) {
+                throw new AnalysisException("s3.sqs.queue_url is required when s3.ingestion_mode is NOTIFICATION");
+            }
+            if (properties.containsKey(OFFSET_PROPERTY)) {
+                throw new AnalysisException("offset is not supported when s3.ingestion_mode is NOTIFICATION");
+            }
+            if (maxIntervalSecond >= 7200) {
+                throw new AnalysisException("max_interval must be less than 7200 seconds for NOTIFICATION mode");
+            }
+        } else if (properties.containsKey(S3_EVENT_SOURCE_PROPERTY)
+                || properties.containsKey(S3_SQS_QUEUE_URL_PROPERTY)) {
+            throw new AnalysisException("s3.event.source and s3.sqs.queue_url require s3.ingestion_mode=NOTIFICATION");
         }
 
         // validate session variables
@@ -220,11 +244,23 @@ public class StreamingJobProperties implements JobProperties {
     }
 
     public String getS3IngestionMode() {
-        return properties.getOrDefault(S3_INGESTION_MODE_PROPERTY, S3_INGESTION_MODE_LEXICAL)
+        return properties.getOrDefault(S3_INGESTION_MODE_PROPERTY, S3_INGESTION_MODE_ORDERED_LIST)
                 .trim().toUpperCase(Locale.ROOT);
     }
 
-    public boolean isS3OnceMode() {
-        return S3_INGESTION_MODE_ONCE.equals(getS3IngestionMode());
+    public boolean isS3OneTimeMode() {
+        return S3_INGESTION_MODE_ONE_TIME.equals(getS3IngestionMode());
+    }
+
+    public boolean isS3NotificationMode() {
+        return S3_INGESTION_MODE_NOTIFICATION.equals(getS3IngestionMode());
+    }
+
+    public String getS3EventSource() {
+        return properties.get(S3_EVENT_SOURCE_PROPERTY);
+    }
+
+    public String getS3SqsQueueUrl() {
+        return properties.get(S3_SQS_QUEUE_URL_PROPERTY);
     }
 }
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 be70fdf83bb..e2c70b228ae 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
@@ -44,6 +44,9 @@ public interface SourceOffsetProvider {
      */
     default void ensureInitialized(Long jobId, Map<String, String> originTvfProps) throws JobException {}
 
+    /** Permanently close runtime resources and prevent subsequent metadata requests. */
+    default void close() {}
+
     /**
      * One-time initialization on fresh job creation (not on FE restart). Subclasses may
      * initialize split progress, fetch initial splits, or open remote readers.
@@ -102,6 +105,11 @@ public interface SourceOffsetProvider {
      */
     void fetchRemoteMeta(Map<String, String> properties) throws Exception;
 
+    default void fetchRemoteMeta(StreamingJobProperties jobProperties, Map<String, String> properties)
+            throws Exception {
+        fetchRemoteMeta(properties);
+    }
+
     /**
      * Whether there is more data to consume
      *
@@ -211,7 +219,12 @@ public interface SourceOffsetProvider {
         return -1;
     }
 
-    /** Get the source event timestamp at the committed offset as Unix seconds, or 0 if unavailable. */
+    /** Get the observed message backlog, or -1 if unavailable or not applicable. */
+    default long getLagMessages() {
+        return -1;
+    }
+
+    /** Get the source event timestamp of the latest committed batch as Unix seconds, or 0 if unavailable. */
     default long getLastSourceEventTimestampSeconds() {
         return 0;
     }
diff --git a/fe/fe-core/src/main/java/org/apache/doris/job/offset/SourceOffsetProviderFactory.java b/fe/fe-core/src/main/java/org/apache/doris/job/offset/SourceOffsetProviderFactory.java
index 631162d27fe..442ed187ba8 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/job/offset/SourceOffsetProviderFactory.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/job/offset/SourceOffsetProviderFactory.java
@@ -20,6 +20,7 @@ package org.apache.doris.job.offset;
 import org.apache.doris.job.exception.JobException;
 import org.apache.doris.job.extensions.insert.streaming.StreamingJobProperties;
 import org.apache.doris.job.offset.jdbc.JdbcTvfSourceOffsetProvider;
+import org.apache.doris.job.offset.s3.S3EventSourceOffsetProvider;
 import org.apache.doris.job.offset.s3.S3SourceOffsetProvider;
 
 import lombok.extern.log4j.Log4j2;
@@ -39,7 +40,10 @@ public class SourceOffsetProviderFactory {
     public static SourceOffsetProvider createSourceOffsetProvider(
             String sourceType, StreamingJobProperties jobProperties) {
         try {
-            if ("s3".equalsIgnoreCase(sourceType) && jobProperties.isS3OnceMode()) {
+            if ("s3".equalsIgnoreCase(sourceType) && jobProperties.isS3NotificationMode()) {
+                return new S3EventSourceOffsetProvider(jobProperties.getS3SqsQueueUrl());
+            }
+            if ("s3".equalsIgnoreCase(sourceType) && jobProperties.isS3OneTimeMode()) {
                 return new S3SourceOffsetProvider(jobProperties);
             }
             Class<? extends SourceOffsetProvider> cla = map.get(sourceType.toLowerCase());
diff --git a/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3Offset.java b/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3EventOffset.java
similarity index 51%
copy from fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3Offset.java
copy to fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3EventOffset.java
index 44df1756e02..47cad957499 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3Offset.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3EventOffset.java
@@ -19,23 +19,46 @@ package org.apache.doris.job.offset.s3;
 
 import org.apache.doris.job.offset.Offset;
 import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.thrift.TBrokerFileStatus;
 
 import com.google.gson.annotations.SerializedName;
-import lombok.Getter;
-import lombok.Setter;
 import org.apache.commons.lang3.StringUtils;
 
-@Getter
-@Setter
-public class S3Offset implements Offset {
-    String startFile;
-    @SerializedName("endFile")
-    String endFile;
-    // s3://bucket/path/{1.csv,2.csv}
-    String fileLists;
-    int fileNum;
-    @SerializedName("lastBatch")
-    boolean lastBatch;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class S3EventOffset implements Offset {
+    static final int TARGET_BATCH_SERIALIZED_BYTES = 64 * 1024;
+
+    @SerializedName("files")
+    private List<String> files;
+    private transient List<TBrokerFileStatus> fileStatuses;
+
+    public S3EventOffset() {
+    }
+
+    public S3EventOffset(List<String> files) {
+        this.files = new ArrayList<>(files);
+    }
+
+    public S3EventOffset(List<String> files, List<TBrokerFileStatus> fileStatuses) {
+        this(files);
+        this.fileStatuses = Collections.unmodifiableList(new ArrayList<>(fileStatuses));
+    }
+
+    public List<TBrokerFileStatus> getFileStatuses() {
+        return fileStatuses;
+    }
+
+    public List<String> getFiles() {
+        return files == null ? Collections.emptyList() : Collections.unmodifiableList(files);
+    }
+
+    public int serializedSize() {
+        return toSerializedJson().getBytes(StandardCharsets.UTF_8).length;
+    }
 
     @Override
     public String toSerializedJson() {
@@ -44,22 +67,21 @@ public class S3Offset implements Offset {
 
     @Override
     public boolean isEmpty() {
-        return StringUtils.isEmpty(fileLists);
+        return files == null || files.isEmpty();
     }
 
     @Override
     public boolean isValidOffset() {
-        return StringUtils.isNotBlank(endFile);
+        return !isEmpty() && files.stream().noneMatch(StringUtils::isBlank);
     }
 
     @Override
     public String showRange() {
-        return "{\"startFileName\":\"" + startFile + "\",\"endFileName\":\"" + endFile + "\"}";
+        return toSerializedJson();
     }
 
     @Override
     public String toString() {
-        return "{\"startFileName\":\"" + startFile + "\","
-                + "\"endFileName\":\"" + endFile + "\",\"fileNum\":" + fileNum + "}";
+        return toSerializedJson();
     }
 }
diff --git a/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3EventSourceOffsetProvider.java b/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3EventSourceOffsetProvider.java
new file mode 100644
index 00000000000..1a949a9eb59
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3EventSourceOffsetProvider.java
@@ -0,0 +1,879 @@
+// 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.doris.job.offset.s3;
+
+import org.apache.doris.common.util.S3Util;
+import org.apache.doris.datasource.property.storage.S3Properties;
+import org.apache.doris.datasource.property.storage.StorageProperties;
+import org.apache.doris.fs.FileSystemFactory;
+import org.apache.doris.fs.obj.S3ObjStorage;
+import org.apache.doris.fs.remote.ObjFileSystem;
+import org.apache.doris.fs.remote.RemoteFileSystem;
+import org.apache.doris.job.exception.JobException;
+import org.apache.doris.job.extensions.insert.streaming.StreamingInsertJob;
+import org.apache.doris.job.extensions.insert.streaming.StreamingJobProperties;
+import org.apache.doris.job.offset.Offset;
+import org.apache.doris.job.offset.SourceOffsetProvider;
+import org.apache.doris.nereids.trees.plans.commands.insert.InsertIntoTableCommand;
+import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.thrift.TBrokerFileStatus;
+
+import com.amazonaws.services.s3.event.S3EventNotification;
+import com.amazonaws.services.s3.event.S3EventNotification.S3EventNotificationRecord;
+import com.amazonaws.util.json.Jackson;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.base.Preconditions;
+import com.google.gson.JsonObject;
+import lombok.extern.log4j.Log4j2;
+import org.apache.commons.lang3.StringUtils;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.sqs.SqsClient;
+import software.amazon.awssdk.services.sqs.model.BatchResultErrorEntry;
+import software.amazon.awssdk.services.sqs.model.ChangeMessageVisibilityBatchRequest;
+import software.amazon.awssdk.services.sqs.model.ChangeMessageVisibilityBatchRequestEntry;
+import software.amazon.awssdk.services.sqs.model.ChangeMessageVisibilityBatchResponse;
+import software.amazon.awssdk.services.sqs.model.DeleteMessageBatchRequest;
+import software.amazon.awssdk.services.sqs.model.DeleteMessageBatchRequestEntry;
+import software.amazon.awssdk.services.sqs.model.DeleteMessageBatchResponse;
+import software.amazon.awssdk.services.sqs.model.GetQueueAttributesRequest;
+import software.amazon.awssdk.services.sqs.model.Message;
+import software.amazon.awssdk.services.sqs.model.QueueAttributeName;
+import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest;
+import software.amazon.awssdk.services.sqs.model.ReceiveMessageResponse;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystems;
+import java.nio.file.PathMatcher;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+/**
+ * Consumes S3 object-created notifications from SQS for streaming INSERT jobs.
+ */
+@Log4j2
+public class S3EventSourceOffsetProvider implements SourceOffsetProvider {
+    private static final int SQS_BATCH_SIZE = 10;
+    private static final int MIN_VISIBILITY_SECONDS = 120;
+    private static final int VISIBILITY_INTERVAL_MULTIPLIER = 6;
+
+    private final String queueUrl;
+    private transient Long jobId;
+    private transient String sourceBucket;
+    private transient PathMatcher sourceKeyMatcher;
+    // Only one task batch is retained, either ready for scheduling or assigned to a task.
+    private transient TaskBatch readyBatch;
+    private transient TaskBatch runningBatch;
+    // Includes both committed task messages and skipped messages whose deletion must be retried.
+    private transient List<String> receiptsToDelete = new ArrayList<>();
+    private transient S3EventOffset currentOffset;
+    // File -> message ID -> receipt; an expired receipt stays null until that message is received again.
+    private transient Map<String, Map<String, String>> unackedCommittedFiles;
+    // Display-only statistic; unavailable after restart until another batch commits.
+    private transient long lastSourceEventTimestampSeconds;
+    // Approximate visible backlog only; null means the queue statistic is unavailable.
+    private transient Long approximateNumberOfMessages;
+    private transient volatile boolean closed;
+    // S3 and SQS share a refreshable credentials provider, which must stay open until SQS closes.
+    private transient RemoteFileSystem fileSystem;
+    private transient SqsClient sqsClient;
+    private transient Map<String, String> clientProperties;
+
+    public S3EventSourceOffsetProvider(String queueUrl) {
+        this.queueUrl = queueUrl;
+    }
+
+    @Override
+    public String getSourceType() {
+        return "s3";
+    }
+
+    @Override
+    public synchronized void ensureInitialized(Long jobId, Map<String, String> originTvfProps) throws JobException {
+        try {
+            this.jobId = jobId;
+            Map<String, String> copiedProps = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+            copiedProps.putAll(originTvfProps);
+            StorageProperties storageProperties = StorageProperties.createPrimary(copiedProps);
+            if (!(storageProperties instanceof S3Properties)) {
+                throw new JobException("S3 NOTIFICATION mode currently supports AWS S3 only");
+            }
+            S3Properties s3Properties = (S3Properties) storageProperties;
+            String uri = storageProperties.validateAndGetUri(copiedProps);
+            String normalizedUri = storageProperties.validateAndNormalizeUri(uri);
+            // Notification keys preserve literal '?' and '#'; URI query/fragment parsing would truncate them.
+            String[] bucketAndKey = normalizedUri.substring("s3://".length()).split("/", 2);
+            sourceBucket = bucketAndKey[0];
+            if (sourceKeyMatcher == null) {
+                String key = bucketAndKey.length == 1 ? "" : bucketAndKey[1].replaceFirst("^/+", "");
+                sourceKeyMatcher = FileSystems.getDefault()
+                        .getPathMatcher("glob:" + Paths.get(S3Util.extendGlobs(key)));
+            }
+
+            if (StringUtils.isBlank(s3Properties.getRegion())) {
+                throw new JobException("s3.region is required for S3 NOTIFICATION mode");
+            }
+        } catch (JobException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new JobException("Failed to initialize S3 EVENT source: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public synchronized S3EventOffset getNextOffset(
+            StreamingJobProperties jobProperties, Map<String, String> properties) {
+        if (runningBatch != null) {
+            return runningBatch.offset;
+        }
+        Preconditions.checkState(readyBatch != null, "No ready S3 event batch");
+        runningBatch = readyBatch;
+        readyBatch = null;
+        return runningBatch.offset;
+    }
+
+    @Override
+    public synchronized String getShowCurrentOffset() {
+        String fileName = lastFileName(currentOffset);
+        return fileName == null ? null : GsonUtils.GSON.toJson(Collections.singletonMap("fileName", fileName));
+    }
+
+    @Override
+    public synchronized long getLastSourceEventTimestampSeconds() {
+        return lastSourceEventTimestampSeconds;
+    }
+
+    @Override
+    public synchronized long getLagMessages() {
+        return approximateNumberOfMessages == null ? -1 : approximateNumberOfMessages;
+    }
+
+    @Override
+    public synchronized String getShowMaxOffset() {
+        // Only accepted files count as observed progress; overflow and skipped messages do not.
+        S3EventOffset observedOffset = readyBatch != null ? readyBatch.offset
+                : runningBatch != null ? runningBatch.offset : currentOffset;
+        String fileName = lastFileName(observedOffset);
+        JsonObject result = new JsonObject();
+        if (fileName != null) {
+            result.addProperty("fileName", fileName);
+        }
+        if (approximateNumberOfMessages != null) {
+            result.addProperty("lagMessages", approximateNumberOfMessages);
+        }
+        return result.size() == 0 ? null : GsonUtils.GSON.toJson(result);
+    }
+
+    private String lastFileName(S3EventOffset offset) {
+        if (offset == null || offset.isEmpty()) {
+            return null;
+        }
+        // Receive order is not lexical order; this is a display value, not a recovery cursor.
+        List<String> files = offset.getFiles();
+        return files.get(files.size() - 1);
+    }
+
+    @Override
+    public InsertIntoTableCommand rewriteTvfParams(
+            InsertIntoTableCommand originCommand, Offset runningOffset, long taskId) {
+        S3EventOffset offset = (S3EventOffset) runningOffset;
+        return S3SourceOffsetProvider.rewriteS3Tvf(originCommand, offset.getFileStatuses());
+    }
+
+    @Override
+    public synchronized void updateOffset(Offset offset) {
+        currentOffset = (S3EventOffset) offset;
+    }
+
+    @Override
+    public void close() {
+        RemoteFileSystem fileSystemToClose;
+        SqsClient sqsClientToClose;
+        synchronized (this) {
+            closed = true;
+            fileSystemToClose = fileSystem;
+            sqsClientToClose = sqsClient;
+            fileSystem = null;
+            sqsClient = null;
+            clientProperties = null;
+        }
+        closeClientResources(fileSystemToClose, sqsClientToClose);
+    }
+
+    private void closeClientResources(RemoteFileSystem fileSystemToClose, SqsClient sqsClientToClose) {
+        if (sqsClientToClose != null) {
+            try {
+                sqsClientToClose.close();
+            } catch (RuntimeException e) {
+                log.warn("Failed to close SQS client for job {}", jobId, e);
+            }
+        }
+        if (fileSystemToClose != null) {
+            try {
+                fileSystemToClose.close();
+            } catch (IOException | RuntimeException e) {
+                log.warn("Failed to close S3 filesystem for job {}", jobId, e);
+            }
+        }
+    }
+
+    @Override
+    public void fetchRemoteMeta(
+            StreamingJobProperties jobProperties, Map<String, String> properties) {
+        // The job scheduler serializes fetches; task callbacks still access batch state concurrently.
+        TaskBatch batch = null;
+        try {
+            SqsClient client;
+            List<String> receipts;
+            synchronized (this) {
+                if (closed) {
+                    return;
+                }
+                Map<String, String> copiedProps = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+                copiedProps.putAll(properties);
+                // A pre-pause fetch may have old properties after ALTER/RESUME.
+                if (sqsClient != null && !copiedProps.equals(clientProperties)) {
+                    closeClientResources(fileSystem, sqsClient);
+                    fileSystem = null;
+                    sqsClient = null;
+                    clientProperties = null;
+                }
+                if (sqsClient == null) {
+                    try {
+                        fileSystem = FileSystemFactory.get(StorageProperties.createPrimary(copiedProps));
+                        S3Client s3Client = ((S3ObjStorage) ((ObjFileSystem) fileSystem).getObjStorage()).getClient();
+                        sqsClient = createSqsClient(s3Client);
+                        clientProperties = copiedProps;
+                    } catch (RuntimeException e) {
+                        closeClientResources(fileSystem, sqsClient);
+                        fileSystem = null;
+                        sqsClient = null;
+                        throw e;
+                    }
+                }
+                client = sqsClient;
+                batch = readyBatch != null ? readyBatch : runningBatch;
+                receipts = new ArrayList<>(batch == null ? receiptsToDelete : batch.receiptHandles);
+            }
+            int visibilityTimeoutSeconds = (int) Math.max(MIN_VISIBILITY_SECONDS,
+                    VISIBILITY_INTERVAL_MULTIPLIER * jobProperties.getMaxIntervalSecond());
+            try {
+                if (batch != null) {
+                    if (!receipts.isEmpty()) {
+                        renewBatchVisibility(client, batch, receipts, visibilityTimeoutSeconds);
+                    }
+                    return;
+                }
+                if (!receipts.isEmpty()) {
+                    deletePendingMessages(client, receipts);
+                }
+                receiveBatch(client, jobProperties, visibilityTimeoutSeconds);
+            } finally {
+                if (!closed) {
+                    refreshApproximateNumberOfMessages(client);
+                }
+            }
+        } catch (RuntimeException e) {
+            synchronized (this) {
+                if (closed) {
+                    return;
+                }
+                if (batch != null && batch != readyBatch && batch != runningBatch) {
+                    log.warn("SQS visibility update failed after the S3 EVENT batch committed", e);
+                    return;
+                }
+            }
+            throw e;
+        }
+    }
+
+    private void refreshApproximateNumberOfMessages(SqsClient sqsClient) {
+        Long count = null;
+        try {
+            String value = sqsClient.getQueueAttributes(GetQueueAttributesRequest.builder()
+                    .queueUrl(queueUrl)
+                    .attributeNames(QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES)
+                    .build()).attributes().get(QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES);
+            count = Long.parseLong(value);
+        } catch (RuntimeException e) {
+            // Monitoring must not block ingestion or report a stale count as current backlog.
+            if (!closed) {
+                log.warn("Failed to fetch SQS pending message count for queue {}", queueUrl, e);
+            }
+        }
+        synchronized (this) {
+            if (!closed) {
+                approximateNumberOfMessages = count;
+            }
+        }
+    }
+
+    private SqsClient createSqsClient(S3Client s3Client) {
+        return SqsClient.builder()
+                .region(s3Client.serviceClientConfiguration().region())
+                .credentialsProvider(s3Client.serviceClientConfiguration().credentialsProvider())
+                .build();
+    }
+
+    @Override
+    public void fetchRemoteMeta(Map<String, String> properties) {
+        throw new UnsupportedOperationException("S3 EVENT metadata fetch requires streaming job properties");
+    }
+
+    private void renewBatchVisibility(
+            SqsClient sqsClient, TaskBatch batch, List<String> receipts, int visibilityTimeoutSeconds) {
+        boolean renewed = true;
+        Set<String> invalidReceipts = new HashSet<>();
+        try {
+            for (int start = 0; start < receipts.size(); start += SQS_BATCH_SIZE) {
+                List<String> chunk = receipts.subList(start, Math.min(start + SQS_BATCH_SIZE, receipts.size()));
+                List<BatchResultErrorEntry> failures =
+                        changeMessageVisibility(sqsClient, chunk, visibilityTimeoutSeconds);
+                for (BatchResultErrorEntry failure : failures) {
+                    if (isInvalidReceipt(failure)) {
+                        invalidReceipts.add(chunk.get(Integer.parseInt(failure.id())));
+                    } else {
+                        renewed = false;
+                    }
+                }
+            }
+        } catch (RuntimeException e) {
+            throw new IllegalStateException("Failed to renew visibility for the current S3 EVENT batch", e);
+        } finally {
+            if (!invalidReceipts.isEmpty()) {
+                synchronized (this) {
+                    batch.receiptsExpired = true;
+                    batch.receiptHandles.removeAll(invalidReceipts);
+                    invalidateReceipts(batch.fileReceipts, invalidReceipts);
+                    // Commit may have completed while the renewal RPC was in progress.
+                    if (unackedCommittedFiles != null) {
+                        invalidateReceipts(unackedCommittedFiles, invalidReceipts);
+                    }
+                    receiptsToDelete.removeAll(invalidReceipts);
+                }
+            }
+        }
+        if (!invalidReceipts.isEmpty()) {
+            throw new IllegalStateException("S3 EVENT batch receipts expired; receive the batch again before retrying");
+        }
+        if (!renewed) {
+            throw new IllegalStateException(
+                    "Failed to renew visibility for some messages in the current S3 EVENT batch");
+        }
+    }
+
+    private void receiveBatch(
+            SqsClient sqsClient, StreamingJobProperties jobProperties,
+            int visibilityTimeoutSeconds) {
+        List<String> files = new ArrayList<>();
+        List<String> receipts = new ArrayList<>();
+        Map<String, Map<String, String>> fileReceipts = new HashMap<>();
+        List<TBrokerFileStatus> fileStatuses = new ArrayList<>();
+        Set<String> selectedFiles = new HashSet<>();
+        long fileBytes = 0;
+        long serializedSize = new S3EventOffset(Collections.emptyList()).serializedSize();
+        long batchEventTimestampSeconds = 0;
+        int receivedMessages = 0;
+        int skippedMessages = 0;
+        ReceiveMessageRequest request = ReceiveMessageRequest.builder()
+                .queueUrl(queueUrl)
+                .maxNumberOfMessages(SQS_BATCH_SIZE)
+                .waitTimeSeconds(1)
+                .visibilityTimeout(visibilityTimeoutSeconds)
+                .build();
+        // Cap consecutive receives per fetch; this does not interrupt an ongoing SQS request.
+        long maxFetchDurationNanos = Math.min(TimeUnit.SECONDS.toNanos(jobProperties.getMaxIntervalSecond()) / 3,
+                TimeUnit.SECONDS.toNanos(5));
+        long fetchEndTimeNanos = System.nanoTime() + maxFetchDurationNanos;
+
+        do {
+            ReceiveMessageResponse response = sqsClient.receiveMessage(request);
+            if (closed) {
+                return;
+            }
+            List<Message> messages = response.messages();
+            receivedMessages += messages.size();
+            if (messages.isEmpty()) {
+                break;
+            }
+
+            List<ParsedMessage> candidates = parseMessages(sqsClient, messages, receipts);
+            // Delete skipped messages before filtering them out or stopping at the batch limit.
+            // Messages selected for INSERT are deleted only after the task commits.
+            deleteSkippedMessages(sqsClient, candidates, receipts);
+            candidates.removeIf(message -> message.files.isEmpty());
+            skippedMessages += messages.size() - candidates.size();
+
+            // Thresholds stop the next receive; every selected message from this receive stays in this batch.
+            for (ParsedMessage candidate : candidates) {
+                for (int i = 0; i < candidate.files.size(); i++) {
+                    String key = candidate.files.get(i);
+                    fileReceipts.computeIfAbsent(key, ignored -> new HashMap<>())
+                            .put(candidate.messageId, candidate.receiptHandle);
+                    // SQS may deliver the same key more than once; scan it once but acknowledge every receipt.
+                    if (selectedFiles.add(key)) {
+                        TBrokerFileStatus file = candidate.fileStatuses.get(i);
+                        files.add(key);
+                        fileStatuses.add(file);
+                        fileBytes = Math.addExact(fileBytes, file.getSize());
+                        serializedSize += GsonUtils.GSON.toJson(key).getBytes(StandardCharsets.UTF_8).length + 1;
+                    }
+                }
+                receipts.add(candidate.receiptHandle);
+                batchEventTimestampSeconds = Math.max(batchEventTimestampSeconds, candidate.eventTimestampSeconds);
+            }
+            if (files.size() >= jobProperties.getS3BatchFiles()
+                    || fileBytes >= jobProperties.getS3BatchBytes()
+                    || serializedSize >= S3EventOffset.TARGET_BATCH_SERIALIZED_BYTES) {
+                break;
+            }
+        } while (!closed && System.nanoTime() < fetchEndTimeNanos);
+
+        if (!files.isEmpty()) {
+            makeReadyBatch(files, fileStatuses, receipts, fileReceipts, batchEventTimestampSeconds);
+        }
+        if (receivedMessages > 0) {
+            log.info("S3 EVENT receive batch: queue={}, receivedMessages={}, skippedMessages={}, "
+                            + "selectedMessages={}, selectedFiles={}",
+                    queueUrl, receivedMessages, skippedMessages, receipts.size(), files.size());
+        }
+    }
+
+    private List<ParsedMessage> parseMessages(
+            SqsClient sqsClient, List<Message> messages, List<String> acceptedReceipts) {
+        try {
+            List<ParsedMessage> parsedMessages = new ArrayList<>(messages.size());
+            for (Message message : messages) {
+                if (StringUtils.isBlank(message.receiptHandle())) {
+                    throw new IllegalArgumentException("SQS message has no receipt handle");
+                }
+                parsedMessages.add(parseMessage(message));
+            }
+            return parsedMessages;
+        } catch (RuntimeException e) {
+            // No batch will be published; make uncommitted messages available for retry.
+            List<String> handles = new ArrayList<>(acceptedReceipts);
+            handles.addAll(messages.stream().map(Message::receiptHandle)
+                    .filter(StringUtils::isNotBlank).collect(Collectors.toList()));
+            releaseMessages(sqsClient, handles);
+            throw e;
+        }
+    }
+
+    private void deleteSkippedMessages(SqsClient sqsClient, List<ParsedMessage> messages,
+            List<String> acceptedReceipts) {
+        List<String> skippedReceipts = messages.stream()
+                .filter(message -> message.files.isEmpty())
+                .map(message -> message.receiptHandle)
+                .collect(Collectors.toList());
+        if (skippedReceipts.isEmpty()) {
+            return;
+        }
+        synchronized (this) {
+            receiptsToDelete.addAll(skippedReceipts);
+        }
+        try {
+            deletePendingMessages(sqsClient, skippedReceipts);
+        } catch (RuntimeException e) {
+            releaseMessages(sqsClient, collectReceiptsToRelease(acceptedReceipts, messages));
+            throw e;
+        }
+    }
+
+    private ParsedMessage parseMessage(Message message) {
+        S3EventNotification notification;
+        try {
+            JsonNode json = Jackson.getObjectMapper().reader()
+                    .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(message.body());
+            if (json == null || !json.isObject()) {
+                log.warn("Skip invalid S3 EVENT message: jobId={}, messageId={}, reason=Expected a JSON object",
+                        jobId, message.messageId());
+                return ParsedMessage.skipped(message.receiptHandle());
+            }
+            if ("s3:TestEvent".equals(json.path("Event").asText())) {
+                return ParsedMessage.skipped(message.receiptHandle());
+            }
+            notification = Jackson.getObjectMapper().treeToValue(json, S3EventNotification.class);
+        } catch (JsonProcessingException e) {
+            // Only decoding failures are skipped; remote operation failures must retain the message.
+            log.warn("Skip invalid S3 EVENT message: jobId={}, messageId={}, reason={}",
+                    jobId, message.messageId(), e.getMessage());
+            return ParsedMessage.skipped(message.receiptHandle());
+        }
+        if (notification.getRecords() == null || notification.getRecords().isEmpty()) {
+            log.warn("Skip invalid S3 EVENT message: jobId={}, messageId={}, reason=No S3 event records",
+                    jobId, message.messageId());
+            return ParsedMessage.skipped(message.receiptHandle());
+        }
+        List<String> files = new ArrayList<>(notification.getRecords().size());
+        List<TBrokerFileStatus> fileStatuses = new ArrayList<>();
+        long eventTimestampSeconds = 0;
+        for (int i = 0; i < notification.getRecords().size(); i++) {
+            S3EventNotificationRecord record = notification.getRecords().get(i);
+            String skipReason = null;
+            if (record == null) {
+                skipReason = "Null S3 event record";
+            } else if (!"aws:s3".equals(record.getEventSource())
+                    || !StringUtils.startsWith(record.getEventName(), "ObjectCreated:")) {
+                skipReason = "Unsupported S3 event";
+            } else if (record.getS3() == null || record.getS3().getBucket() == null
+                    || record.getS3().getObject() == null) {
+                skipReason = "Incomplete S3 event";
+            } else if (StringUtils.isBlank(record.getS3().getBucket().getName())
+                    || StringUtils.isBlank(record.getS3().getObject().getKey())) {
+                skipReason = "Missing S3 bucket or object key";
+            } else if (record.getS3().getObject().getSizeAsLong() == null
+                    || record.getS3().getObject().getSizeAsLong() < 0) {
+                skipReason = "Invalid S3 object size";
+            }
+            if (skipReason != null) {
+                log.warn("Skip invalid S3 EVENT record: jobId={}, messageId={}, recordIndex={}, reason={}",
+                        jobId, message.messageId(), i, skipReason);
+                continue;
+            }
+            String bucket = record.getS3().getBucket().getName();
+            String key;
+            try {
+                key = record.getS3().getObject().getUrlDecodedKey();
+                if (!sourceBucket.equals(bucket) || !sourceKeyMatcher.matches(Paths.get(key))) {
+                    continue;
+                }
+            } catch (IllegalArgumentException e) {
+                log.warn("Skip invalid S3 EVENT record: jobId={}, messageId={}, recordIndex={}, reason={}",
+                        jobId, message.messageId(), i, e.getMessage());
+                continue;
+            }
+            if (isCommitted(key, message.messageId(), message.receiptHandle())) {
+                continue;
+            }
+            files.add(key);
+            // The object must remain unchanged until ingestion completes; notifications identify keys, not versions.
+            fileStatuses.add(new TBrokerFileStatus("s3://" + bucket + "/" + key, false,
+                    record.getS3().getObject().getSizeAsLong(), true));
+            if (record.getEventTime() != null) {
+                eventTimestampSeconds = Math.max(eventTimestampSeconds, record.getEventTime().getMillis() / 1000);
+            }
+        }
+        if (files.isEmpty()) {
+            return ParsedMessage.skipped(message.receiptHandle());
+        }
+        return new ParsedMessage(message.messageId(), message.receiptHandle(),
+                files, fileStatuses, eventTimestampSeconds);
+    }
+
+    private synchronized boolean isCommitted(String file, String messageId, String receiptHandle) {
+        boolean committed = (unackedCommittedFiles != null && unackedCommittedFiles.containsKey(file))
+                || (currentOffset != null && currentOffset.getFiles().contains(file));
+        if (committed) {
+            if (unackedCommittedFiles == null) {
+                unackedCommittedFiles = new HashMap<>();
+            }
+            unackedCommittedFiles.computeIfAbsent(file, ignored -> new HashMap<>()).put(messageId, receiptHandle);
+        }
+        return committed;
+    }
+
+    private synchronized void makeReadyBatch(List<String> files, List<TBrokerFileStatus> fileStatuses,
+            List<String> receipts, Map<String, Map<String, String>> fileReceipts, long eventTimestampSeconds) {
+        if (closed) {
+            return;
+        }
+        Preconditions.checkState(!files.isEmpty() && !receipts.isEmpty(),
+                "Cannot create an empty S3 EVENT batch");
+        S3EventOffset offset = new S3EventOffset(files, fileStatuses);
+        readyBatch = new TaskBatch(offset, new ArrayList<>(receipts), fileReceipts, eventTimestampSeconds);
+    }
+
+    private List<String> collectReceiptsToRelease(
+            List<String> acceptedReceipts, List<ParsedMessage> parsedMessages) {
+        List<String> handles = new ArrayList<>(acceptedReceipts);
+        handles.addAll(parsedMessages.stream().filter(message -> !message.files.isEmpty())
+                .map(message -> message.receiptHandle).collect(Collectors.toList()));
+        return handles;
+    }
+
+    private void releaseMessages(SqsClient sqsClient, List<String> handles) {
+        if (handles.isEmpty()) {
+            return;
+        }
+        try {
+            for (int start = 0; start < handles.size(); start += SQS_BATCH_SIZE) {
+                List<String> chunk = handles.subList(start, Math.min(start + SQS_BATCH_SIZE, handles.size()));
+                changeMessageVisibility(sqsClient, chunk, 0);
+            }
+        } catch (RuntimeException e) {
+            log.warn("Failed to release messages from a failed S3 EVENT receive batch", e);
+        }
+    }
+
+    private List<BatchResultErrorEntry> changeMessageVisibility(
+            SqsClient sqsClient, List<String> handles, int visibilityTimeout) {
+        List<ChangeMessageVisibilityBatchRequestEntry> entries = new ArrayList<>(handles.size());
+        for (int i = 0; i < handles.size(); i++) {
+            entries.add(ChangeMessageVisibilityBatchRequestEntry.builder()
+                    .id(String.valueOf(i))
+                    .receiptHandle(handles.get(i))
+                    .visibilityTimeout(visibilityTimeout)
+                    .build());
+        }
+        ChangeMessageVisibilityBatchResponse response = sqsClient.changeMessageVisibilityBatch(
+                ChangeMessageVisibilityBatchRequest.builder()
+                        .queueUrl(queueUrl)
+                        .entries(entries)
+                        .build());
+        logBatchFailures("ChangeMessageVisibilityBatch", response.failed());
+        if (visibilityTimeout == 0) {
+            log.info("S3 EVENT release messages: queue={}, requestedMessages={}, "
+                            + "succeededMessages={}, failedMessages={}",
+                    queueUrl, handles.size(), response.successful().size(), response.failed().size());
+        }
+        return response.failed();
+    }
+
+    private void deletePendingMessages(SqsClient sqsClient, List<String> pending) {
+        List<String> failed = new ArrayList<>();
+        for (int start = 0; start < pending.size(); start += SQS_BATCH_SIZE) {
+            int end = Math.min(start + SQS_BATCH_SIZE, pending.size());
+            List<String> chunk = pending.subList(start, end);
+            try {
+                DeleteMessageBatchResponse response = sqsClient.deleteMessageBatch(DeleteMessageBatchRequest.builder()
+                        .queueUrl(queueUrl)
+                        .entries(buildDeleteEntries(chunk))
+                        .build());
+                logBatchFailures("DeleteMessageBatch", response.failed());
+                log.info("S3 EVENT delete messages: queue={}, requestedMessages={}, "
+                                + "succeededMessages={}, failedMessages={}",
+                        queueUrl, chunk.size(), response.successful().size(), response.failed().size());
+                synchronized (this) {
+                    if (unackedCommittedFiles != null && !unackedCommittedFiles.isEmpty()) {
+                        Set<String> deletedReceipts = response.successful().stream()
+                                .map(entry -> chunk.get(Integer.parseInt(entry.id())))
+                                .collect(Collectors.toSet());
+                        unackedCommittedFiles.entrySet().removeIf(entry -> {
+                            Map<String, String> messageReceipts = entry.getValue();
+                            boolean removed = messageReceipts.values().removeAll(deletedReceipts);
+                            return removed && messageReceipts.isEmpty();
+                        });
+                    }
+                }
+                failed.addAll(getRetryableReceiptHandles(chunk, response.failed()));
+            } catch (RuntimeException e) {
+                // Retain current and unattempted chunks; the request outcome is unknown.
+                failed.addAll(pending.subList(start, pending.size()));
+                synchronized (this) {
+                    receiptsToDelete = failed;
+                }
+                throw new IllegalStateException("Failed to delete S3 EVENT messages", e);
+            }
+        }
+        synchronized (this) {
+            // Fetch is serialized and no task batch exists during deletion.
+            receiptsToDelete = failed;
+        }
+        if (!failed.isEmpty()) {
+            throw new IllegalStateException("Failed to delete some S3 EVENT messages");
+        }
+    }
+
+    private boolean isInvalidReceipt(BatchResultErrorEntry failure) {
+        // InvalidParameterValue also covers invalid visibility durations, so only match explicit receipt expiry.
+        return "ReceiptHandleIsInvalid".equals(failure.code())
+                || "MessageNotInflight".equals(failure.code())
+                || ("InvalidParameterValue".equals(failure.code())
+                        && StringUtils.containsIgnoreCase(failure.message(), "receipt handle has expired"));
+    }
+
+    private void logBatchFailures(String operation, List<BatchResultErrorEntry> failures) {
+        for (BatchResultErrorEntry failure : failures) {
+            log.warn("SQS {} failed: id={}, code={}, senderFault={}",
+                    operation, failure.id(), failure.code(), failure.senderFault());
+            if (isInvalidReceipt(failure)) {
+                // Keep committed files until a fresh receipt confirms deletion.
+                log.warn("SQS {} receipt is no longer usable: id={}; message deletion is not confirmed",
+                        operation, failure.id());
+            }
+        }
+    }
+
+    private List<DeleteMessageBatchRequestEntry> buildDeleteEntries(List<String> handles) {
+        List<DeleteMessageBatchRequestEntry> entries = new ArrayList<>(handles.size());
+        for (int i = 0; i < handles.size(); i++) {
+            entries.add(DeleteMessageBatchRequestEntry.builder()
+                    .id(String.valueOf(i))
+                    .receiptHandle(handles.get(i))
+                    .build());
+        }
+        return entries;
+    }
+
+    private List<String> getRetryableReceiptHandles(List<String> handles, List<BatchResultErrorEntry> failures) {
+        return failures.stream().filter(failure -> !isInvalidReceipt(failure))
+                .map(failure -> handles.get(Integer.parseInt(failure.id())))
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    public synchronized boolean hasMoreDataToConsume() {
+        return readyBatch != null || runningBatch != null;
+    }
+
+    @Override
+    public synchronized String getPersistInfo() {
+        return currentOffset == null ? null : currentOffset.toSerializedJson();
+    }
+
+    @Override
+    public synchronized void restoreFromPersistInfo(String persistInfo) {
+        if (persistInfo == null) {
+            return;
+        }
+        try {
+            currentOffset = GsonUtils.GSON.fromJson(persistInfo, S3EventOffset.class);
+        } catch (Exception e) {
+            log.warn("Failed to restore S3 EVENT offset from persist info", e);
+        }
+    }
+
+    @Override
+    public synchronized void replayIfNeed(StreamingInsertJob job) {
+        if (currentOffset == null) {
+            restoreFromPersistInfo(job.getOffsetProviderPersist());
+        }
+        // Capture after transaction replay; subsequent commits and resumes must not replace this snapshot.
+        if (unackedCommittedFiles == null) {
+            unackedCommittedFiles = new HashMap<>();
+            if (currentOffset != null) {
+                for (String file : currentOffset.getFiles()) {
+                    unackedCommittedFiles.put(file, new HashMap<>());
+                }
+            }
+        }
+        // The scheduler serializes this resume hook with fetches, after transaction progress is replayed.
+        TaskBatch batch = readyBatch != null ? readyBatch : runningBatch;
+        if (batch != null) {
+            boolean committed = currentOffset != null && currentOffset.getFiles().equals(batch.offset.getFiles());
+            if (committed) {
+                rememberCommittedBatch(batch);
+                receiptsToDelete.addAll(batch.receiptHandles);
+            }
+            readyBatch = null;
+            runningBatch = null;
+            log.info("S3 EVENT resume batch: jobId={}, committed={}, files={}, receipts={}",
+                    jobId, committed, batch.offset.getFiles().size(), batch.receiptHandles.size());
+        }
+    }
+
+    @Override
+    public Offset deserializeOffset(String offset) {
+        return GsonUtils.GSON.fromJson(offset, S3EventOffset.class);
+    }
+
+    @Override
+    public Offset deserializeOffsetProperty(String offset) {
+        return null;
+    }
+
+    @Override
+    public void validateAlterOffset(String offset) throws JobException {
+        throw new JobException("offset cannot be altered when s3.ingestion_mode is NOTIFICATION");
+    }
+
+    @Override
+    public synchronized String getCommitOffsetJson(Offset runningOffset, long taskId, List<Long> scanBackendIds) {
+        Preconditions.checkState(runningBatch != null, "No running S3 EVENT batch");
+        Preconditions.checkState(!runningBatch.receiptsExpired, "S3 EVENT batch receipts expired before commit");
+        S3EventOffset offset = (S3EventOffset) runningOffset;
+        Preconditions.checkState(runningBatch.offset.getFiles().equals(offset.getFiles()),
+                "S3 EVENT running offset changed before commit");
+        return offset.toSerializedJson();
+    }
+
+    @Override
+    public synchronized void onTaskCommitted(long scannedRows, long loadBytes) {
+        Preconditions.checkState(runningBatch != null, "No running S3 EVENT batch to commit");
+        Preconditions.checkState(receiptsToDelete.isEmpty(), "Previous S3 EVENT message deletion is pending");
+        Preconditions.checkState(currentOffset != null
+                        && currentOffset.getFiles().equals(runningBatch.offset.getFiles()),
+                "Committed S3 EVENT offset does not match the running batch");
+        rememberCommittedBatch(runningBatch);
+        receiptsToDelete = new ArrayList<>(runningBatch.receiptHandles);
+        lastSourceEventTimestampSeconds = runningBatch.eventTimestampSeconds;
+        runningBatch = null;
+    }
+
+    private void rememberCommittedBatch(TaskBatch batch) {
+        if (unackedCommittedFiles == null) {
+            unackedCommittedFiles = new HashMap<>();
+        }
+        batch.fileReceipts.forEach((file, receipts) ->
+                unackedCommittedFiles.computeIfAbsent(file, ignored -> new HashMap<>()).putAll(receipts));
+    }
+
+    private static void invalidateReceipts(Map<String, Map<String, String>> files, Set<String> invalidReceipts) {
+        files.values().forEach(receipts -> receipts.replaceAll(
+                (messageId, receipt) -> invalidReceipts.contains(receipt) ? null : receipt));
+    }
+
+    private static class TaskBatch {
+        private final S3EventOffset offset;
+        private final List<String> receiptHandles;
+        private final Map<String, Map<String, String>> fileReceipts;
+        private final long eventTimestampSeconds;
+        private boolean receiptsExpired;
+
+        private TaskBatch(S3EventOffset offset, List<String> receiptHandles,
+                Map<String, Map<String, String>> fileReceipts, long eventTimestampSeconds) {
+            this.offset = offset;
+            this.receiptHandles = receiptHandles;
+            this.fileReceipts = fileReceipts;
+            this.eventTimestampSeconds = eventTimestampSeconds;
+        }
+    }
+
+    private static class ParsedMessage {
+        private final String messageId;
+        private final String receiptHandle;
+        private final List<String> files;
+        private final List<TBrokerFileStatus> fileStatuses;
+        private final long eventTimestampSeconds;
+
+        private ParsedMessage(String messageId, String receiptHandle, List<String> files,
+                List<TBrokerFileStatus> fileStatuses, long eventTimestampSeconds) {
+            this.messageId = messageId;
+            this.receiptHandle = receiptHandle;
+            this.files = files;
+            this.fileStatuses = fileStatuses;
+            this.eventTimestampSeconds = eventTimestampSeconds;
+        }
+
+        private static ParsedMessage skipped(String receiptHandle) {
+            return new ParsedMessage(null, receiptHandle, Collections.emptyList(), Collections.emptyList(), 0);
+        }
+    }
+}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3Offset.java b/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3Offset.java
index 44df1756e02..eccba403772 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3Offset.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3Offset.java
@@ -19,21 +19,23 @@ package org.apache.doris.job.offset.s3;
 
 import org.apache.doris.job.offset.Offset;
 import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.thrift.TBrokerFileStatus;
 
 import com.google.gson.annotations.SerializedName;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.commons.lang3.StringUtils;
 
+import java.util.List;
+
 @Getter
 @Setter
 public class S3Offset implements Offset {
     String startFile;
     @SerializedName("endFile")
     String endFile;
-    // s3://bucket/path/{1.csv,2.csv}
-    String fileLists;
     int fileNum;
+    private transient List<TBrokerFileStatus> fileStatuses;
     @SerializedName("lastBatch")
     boolean lastBatch;
 
@@ -44,7 +46,7 @@ public class S3Offset implements Offset {
 
     @Override
     public boolean isEmpty() {
-        return StringUtils.isEmpty(fileLists);
+        return fileStatuses == null || fileStatuses.isEmpty();
     }
 
     @Override
diff --git a/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3SourceOffsetProvider.java b/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3SourceOffsetProvider.java
index a89affb9536..02b22a95ed5 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3SourceOffsetProvider.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3SourceOffsetProvider.java
@@ -30,11 +30,14 @@ import org.apache.doris.job.offset.Offset;
 import org.apache.doris.job.offset.SourceOffsetProvider;
 import org.apache.doris.nereids.analyzer.UnboundTVFRelation;
 import org.apache.doris.nereids.trees.expressions.Properties;
-import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.expressions.functions.table.S3;
 import org.apache.doris.nereids.trees.plans.commands.insert.InsertIntoTableCommand;
-import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalTVFRelation;
 import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.thrift.TBrokerFileStatus;
 
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
@@ -42,25 +45,25 @@ import lombok.extern.log4j.Log4j2;
 import org.apache.commons.lang3.StringUtils;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Optional;
 import java.util.stream.Collectors;
 
 @Log4j2
 public class S3SourceOffsetProvider implements SourceOffsetProvider {
-    private final boolean onceMode;
+    private final boolean oneTimeMode;
     private volatile S3Offset noMoreFilesAfterOffset;
     volatile S3Offset currentOffset;
     volatile String maxEndFile;
 
     public S3SourceOffsetProvider() {
-        this.onceMode = false;
+        this.oneTimeMode = false;
     }
 
     public S3SourceOffsetProvider(StreamingJobProperties jobProperties) {
-        this.onceMode = jobProperties.isS3OnceMode();
+        this.oneTimeMode = jobProperties.isS3OneTimeMode();
     }
 
     @Override
@@ -90,28 +93,17 @@ public class S3SourceOffsetProvider implements SourceOffsetProvider {
 
             if (!rfiles.isEmpty()) {
                 String bucket = globListResult.getBucket();
-                String prefix = globListResult.getPrefix();
-
                 String bucketBase = "s3://" + bucket + "/";
-                // Get the path of the last directory
-                int lastSlash = prefix.lastIndexOf('/');
-                String basePrefix = (lastSlash >= 0) ? prefix.substring(0, lastSlash + 1) : "";
-                String filePathBase = bucketBase + basePrefix;
-                String joined = rfiles.stream()
-                        .map(path -> path.getName().replace(filePathBase, ""))
-                        .collect(Collectors.joining(","));
-
-                String normalizedPrefix = basePrefix.endsWith("/")
-                        ? basePrefix.substring(0, basePrefix.length() - 1) : basePrefix;
-                String finalFileLists = String.format("s3://%s/%s/{%s}", bucket, normalizedPrefix, joined);
                 String beginFile = rfiles.get(0).getName().replace(bucketBase, "");
                 String lastFile = rfiles.get(rfiles.size() - 1).getName().replace(bucketBase, "");
-                offset.setFileLists(finalFileLists);
+                offset.setFileStatuses(rfiles.stream().map(file -> new TBrokerFileStatus(
+                        file.getName(), file.isDirectory(), file.getSize(), !file.isDirectory()))
+                        .collect(Collectors.toList()));
                 offset.setStartFile(beginFile);
                 offset.setEndFile(lastFile);
                 offset.setFileNum(rfiles.size());
                 maxEndFile = globListResult.getMaxFile();
-                offset.setLastBatch(onceMode && lastFile.equals(globListResult.getMaxFile()));
+                offset.setLastBatch(oneTimeMode && lastFile.equals(globListResult.getMaxFile()));
             } else {
                 throw new RuntimeException("No new files found in path: " + filePath);
             }
@@ -146,29 +138,54 @@ public class S3SourceOffsetProvider implements SourceOffsetProvider {
     public InsertIntoTableCommand rewriteTvfParams(InsertIntoTableCommand originCommand,
             Offset runningOffset, long taskId) {
         S3Offset offset = (S3Offset) runningOffset;
-        Map<String, String> props = Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
-        // rewrite plan
-        Plan rewritePlan = originCommand.getParsedPlan().get().rewriteUp(plan -> {
+        return rewriteS3Tvf(originCommand, offset.getFileStatuses());
+    }
+
+    // Render the same literal batch in task properties and audit SQL; execution uses file statuses.
+    public static String buildFileListUri(List<TBrokerFileStatus> files) {
+        Preconditions.checkArgument(!files.isEmpty(), "S3 task batch must contain files");
+        String[] paths = files.stream().map(TBrokerFileStatus::getPath).toArray(String[]::new);
+        String commonPrefix = StringUtils.getCommonPrefix(paths);
+        String baseUri = commonPrefix.substring(0, commonPrefix.lastIndexOf('/') + 1);
+        String joined = Arrays.stream(paths).map(path -> escapeGlob(path.substring(baseUri.length())))
+                .collect(Collectors.joining(","));
+        return escapeGlob(baseUri) + "{" + joined + "}";
+    }
+
+    // Selected object keys are literals; only the surrounding braces and separators are glob syntax.
+    private static String escapeGlob(String value) {
+        StringBuilder escaped = new StringBuilder(value.length());
+        for (int i = 0; i < value.length(); i++) {
+            char c = value.charAt(i);
+            if ("\\*?[]{},".indexOf(c) >= 0) {
+                escaped.append('\\');
+            }
+            escaped.append(c);
+        }
+        return escaped.toString();
+    }
+
+    static InsertIntoTableCommand rewriteS3Tvf(InsertIntoTableCommand originCommand,
+            List<TBrokerFileStatus> fileStatuses) {
+        Preconditions.checkNotNull(fileStatuses, "S3 task batch must contain exact file statuses");
+        String fileLists = buildFileListUri(fileStatuses);
+        return originCommand.rewriteQuery(plan -> {
             if (plan instanceof UnboundTVFRelation) {
-                UnboundTVFRelation originTvfRel = (UnboundTVFRelation) plan;
-                Map<String, String> oriMap = originTvfRel.getProperties().getMap();
-                props.putAll(oriMap);
-                props.put("uri", offset.getFileLists());
-                return new UnboundTVFRelation(
-                        originTvfRel.getRelationId(), originTvfRel.getFunctionName(), new Properties(props));
+                UnboundTVFRelation tvf = (UnboundTVFRelation) plan;
+                Map<String, String> props = Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
+                props.putAll(tvf.getProperties().getMap());
+                props.put("uri", fileLists);
+                return new LogicalTVFRelation(tvf.getRelationId(), S3.withFiles(new Properties(props), fileStatuses),
+                        ImmutableList.of());
             }
             return plan;
         });
-        InsertIntoTableCommand insertIntoTableCommand = new InsertIntoTableCommand((LogicalPlan) rewritePlan,
-                Optional.empty(), Optional.empty(), Optional.empty(), true, Optional.empty());
-        insertIntoTableCommand.setJobId(originCommand.getJobId());
-        return insertIntoTableCommand;
     }
 
     @Override
     public void updateOffset(Offset offset) {
         this.currentOffset = (S3Offset) offset;
-        this.currentOffset.setFileLists(null);
+        this.currentOffset.setFileStatuses(null);
     }
 
     @Override
@@ -193,7 +210,7 @@ public class S3SourceOffsetProvider implements SourceOffsetProvider {
                         ? globListResult.getStatus().getErrMsg() : "null result";
                 throw new Exception("Failed to list S3 files: " + errMsg);
             }
-            if (onceMode) {
+            if (oneTimeMode) {
                 noMoreFilesAfterOffset = objects.isEmpty() ? offsetAtScan : null;
             }
             if (!objects.isEmpty() && StringUtils.isNotEmpty(globListResult.getMaxFile())) {
@@ -220,7 +237,7 @@ public class S3SourceOffsetProvider implements SourceOffsetProvider {
     @Override
     public boolean hasReachedEnd() {
         S3Offset offset = currentOffset;
-        return onceMode && offset != null && (offset.isLastBatch() || noMoreFilesAfterOffset == offset);
+        return oneTimeMode && offset != null && (offset.isLastBatch() || noMoreFilesAfterOffset == offset);
     }
 
     @Override
diff --git a/fe/fe-core/src/main/java/org/apache/doris/metric/MetricRepo.java b/fe/fe-core/src/main/java/org/apache/doris/metric/MetricRepo.java
index a89e5ed0e03..2e5484f0d41 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/metric/MetricRepo.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/metric/MetricRepo.java
@@ -101,6 +101,7 @@ public final class MetricRepo {
     public static final String STREAMING_JOB_PER_JOB_SUCCEED_TASK_COUNT = "streaming_job_per_job_succeed_task_count";
     public static final String STREAMING_JOB_PER_JOB_FAILED_TASK_COUNT = "streaming_job_per_job_failed_task_count";
     public static final String STREAMING_JOB_PER_JOB_LAG_BYTES = "streaming_job_per_job_lag_bytes";
+    public static final String STREAMING_JOB_PER_JOB_LAG_MESSAGES = "streaming_job_per_job_lag_messages";
     public static final String STREAMING_JOB_PER_JOB_LAST_SOURCE_EVENT_TIMESTAMP_SECONDS =
             "streaming_job_per_job_last_source_event_timestamp_seconds";
     public static final String STREAMING_JOB_PER_JOB_LAST_TASK_SUCCESS_TIME_SECONDS =
@@ -1355,6 +1356,7 @@ public final class MetricRepo {
         DORIS_METRIC_REGISTER.removeMetrics(STREAMING_JOB_PER_JOB_SUCCEED_TASK_COUNT);
         DORIS_METRIC_REGISTER.removeMetrics(STREAMING_JOB_PER_JOB_FAILED_TASK_COUNT);
         DORIS_METRIC_REGISTER.removeMetrics(STREAMING_JOB_PER_JOB_LAG_BYTES);
+        DORIS_METRIC_REGISTER.removeMetrics(STREAMING_JOB_PER_JOB_LAG_MESSAGES);
         DORIS_METRIC_REGISTER.removeMetrics(STREAMING_JOB_PER_JOB_LAST_SOURCE_EVENT_TIMESTAMP_SECONDS);
         DORIS_METRIC_REGISTER.removeMetrics(STREAMING_JOB_PER_JOB_LAST_TASK_SUCCESS_TIME_SECONDS);
 
@@ -1452,10 +1454,25 @@ public final class MetricRepo {
                         .addLabel(new MetricLabel("job_name", jobName));
                 DORIS_METRIC_REGISTER.addMetrics(lag);
 
+                long lagMessages = sJob.getLagMessages();
+                if (lagMessages >= 0) {
+                    GaugeMetric<Long> messageLag = new GaugeMetric<Long>(
+                            STREAMING_JOB_PER_JOB_LAG_MESSAGES, MetricUnit.NOUNIT,
+                            "last observed approximate visible message backlog, excluding in-flight messages") {
+                        @Override
+                        public Long getValue() {
+                            return lagMessages;
+                        }
+                    };
+                    messageLag.addLabel(new MetricLabel("job_id", jobId))
+                            .addLabel(new MetricLabel("job_name", jobName));
+                    DORIS_METRIC_REGISTER.addMetrics(messageLag);
+                }
+
                 long lastSourceEventTimestampSeconds = sJob.getLastSourceEventTimestampSeconds();
                 GaugeMetric<Long> lastSourceEventTimestamp = new GaugeMetric<Long>(
                         STREAMING_JOB_PER_JOB_LAST_SOURCE_EVENT_TIMESTAMP_SECONDS, MetricUnit.SECONDS,
-                        "timestamp of the latest source binlog or WAL event recorded in the job's committed offset"
+                        "source event timestamp of the latest committed batch"
                                 + " as Unix seconds, 0 means unavailable") {
                     @Override
                     public Long getValue() {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/table/S3.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/table/S3.java
index 3c4c924d7f4..34e89336baf 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/table/S3.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/table/S3.java
@@ -19,6 +19,7 @@ package org.apache.doris.nereids.trees.expressions.functions.table;
 
 import org.apache.doris.catalog.FunctionSignature;
 import org.apache.doris.nereids.exceptions.AnalysisException;
+import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.expressions.Properties;
 import org.apache.doris.nereids.trees.expressions.visitor.ExpressionVisitor;
 import org.apache.doris.nereids.trees.plans.logical.SupportPruneNestedColumn;
@@ -26,13 +27,40 @@ import org.apache.doris.nereids.trees.plans.logical.SupportPruneNestedColumnForm
 import org.apache.doris.nereids.types.coercion.AnyDataType;
 import org.apache.doris.tablefunction.S3TableValuedFunction;
 import org.apache.doris.tablefunction.TableValuedFunctionIf;
+import org.apache.doris.thrift.TBrokerFileStatus;
 
+import com.google.common.collect.ImmutableList;
+
+import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 
 /** s3 */
 public class S3 extends TableValuedFunction implements SupportPruneNestedColumn {
+    private final List<TBrokerFileStatus> fileStatuses;
+
     public S3(Properties properties) {
+        this(properties, null);
+    }
+
+    // Task-only inputs must not register another SQL function overload.
+    private S3(Properties properties, List<TBrokerFileStatus> fileStatuses) {
         super("s3", properties);
+        this.fileStatuses = fileStatuses == null ? null : ImmutableList.copyOf(fileStatuses);
+    }
+
+    public static S3 withFiles(Properties properties, List<TBrokerFileStatus> fileStatuses) {
+        return new S3(properties, fileStatuses);
+    }
+
+    @Override
+    protected boolean extraEquals(Expression that) {
+        return super.extraEquals(that) && Objects.equals(fileStatuses, ((S3) that).fileStatuses);
+    }
+
+    @Override
+    public int computeHashCode() {
+        return Objects.hash(super.computeHashCode(), fileStatuses);
     }
 
     @Override
@@ -44,7 +72,7 @@ public class S3 extends TableValuedFunction implements SupportPruneNestedColumn
     protected TableValuedFunctionIf toCatalogFunction() {
         try {
             Map<String, String> arguments = getTVFProperties().getMap();
-            return new S3TableValuedFunction(arguments);
+            return new S3TableValuedFunction(arguments, fileStatuses);
         } catch (Throwable t) {
             throw new AnalysisException("Can not build s3(): " + t.getMessage(), t);
         }
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterJobCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterJobCommand.java
index e31af87bb36..fda03d8bd8f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterJobCommand.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterJobCommand.java
@@ -284,8 +284,12 @@ public class AlterJobCommand extends AlterCommand implements ForwardWithSync, Ne
         mergedProperties.putAll(properties);
         StreamingJobProperties updatedJobProperties = new StreamingJobProperties(mergedProperties);
         updatedJobProperties.validate();
-        if (!originJobProperties.getS3IngestionMode().equals(updatedJobProperties.getS3IngestionMode())) {
-            throw new AnalysisException("s3.ingestion_mode cannot be altered");
+        if (!originJobProperties.getS3IngestionMode().equals(updatedJobProperties.getS3IngestionMode())
+                || !StringUtils.equalsIgnoreCase(originJobProperties.getS3EventSource(),
+                        updatedJobProperties.getS3EventSource())
+                || !Objects.equals(originJobProperties.getS3SqsQueueUrl(),
+                        updatedJobProperties.getS3SqsQueueUrl())) {
+            throw new AnalysisException("s3.ingestion_mode, s3.event.source and s3.sqs.queue_url cannot be altered");
         }
         String offset = properties.get(StreamingJobProperties.OFFSET_PROPERTY);
         if (offset != null) {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
index 97c58141256..2bd2ce18c4a 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java
@@ -103,6 +103,7 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Consumer;
+import java.util.function.Function;
 import java.util.function.Supplier;
 
 /**
@@ -187,6 +188,15 @@ public class InsertIntoTableCommand extends Command implements NeedAuditEncrypti
         return logicalQuery.orElse(originLogicalQuery);
     }
 
+    /** Rewrite the original query before planning, preserving INSERT options and the WITH clause. */
+    public InsertIntoTableCommand rewriteQuery(Function<Plan, Plan> rewrite) {
+        InsertIntoTableCommand command = new InsertIntoTableCommand(
+                (LogicalPlan) originLogicalQuery.rewriteUp(rewrite), labelName, insertCtx,
+                cte, needNormalizePlan, branchName);
+        command.setJobId(jobId);
+        return command;
+    }
+
     public Optional<Plan> getParsedPlan() {
         return parsedPlan;
     }
diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/ExternalFileTableValuedFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/ExternalFileTableValuedFunction.java
index 62d2d465544..41466e0648a 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/ExternalFileTableValuedFunction.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/ExternalFileTableValuedFunction.java
@@ -199,6 +199,10 @@ public abstract class ExternalFileTableValuedFunction extends TableValuedFunctio
     }
 
     protected void parseFile() throws AnalysisException {
+        parseFile(null);
+    }
+
+    protected void parseFile(List<TBrokerFileStatus> files) throws AnalysisException {
         long startAt = System.currentTimeMillis();
         String path = getFilePath();
         BrokerDesc brokerDesc = getBrokerDesc();
@@ -209,6 +213,10 @@ public abstract class ExternalFileTableValuedFunction extends TableValuedFunctio
                 String endpoint = storageProperties.getEndpoint();
                 S3Util.validateAndTestEndpoint(endpoint);
             }
+            if (files != null) {
+                fileStatuses.addAll(files);
+                return;
+            }
             BrokerUtil.parseFile(path, brokerDesc, fileStatuses);
         } catch (UserException e) {
             throw new AnalysisException("parse file failed, err: " + e.getMessage(), e);
diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/S3TableValuedFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/S3TableValuedFunction.java
index 75e811f4b2c..91e79dda10e 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/S3TableValuedFunction.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/S3TableValuedFunction.java
@@ -25,6 +25,7 @@ import org.apache.doris.common.UserException;
 import org.apache.doris.datasource.lance.metadata.LanceMetadataLoader;
 import org.apache.doris.datasource.lance.metadata.LanceTableMetadata;
 import org.apache.doris.datasource.property.storage.StorageProperties;
+import org.apache.doris.thrift.TBrokerFileStatus;
 import org.apache.doris.thrift.TFileType;
 
 import java.util.Collections;
@@ -46,6 +47,11 @@ public class S3TableValuedFunction extends ExternalFileTableValuedFunction {
     public static final String NAME = "s3";
 
     public S3TableValuedFunction(Map<String, String> properties) throws AnalysisException {
+        this(properties, null);
+    }
+
+    public S3TableValuedFunction(Map<String, String> properties, List<TBrokerFileStatus> files)
+            throws AnalysisException {
         // 1. analyze common properties
         Map<String, String> props = super.parseCommonProperties(properties);
         try {
@@ -61,6 +67,9 @@ public class S3TableValuedFunction extends ExternalFileTableValuedFunction {
             // Just check
             // Fixme wait to be done  #50320
             // FileSystemFactory.get(storageProperties);
+            if (files != null) {
+                this.fileStatuses.addAll(files);
+            }
             return;
         }
         if (isLanceFormat()) {
@@ -75,7 +84,7 @@ public class S3TableValuedFunction extends ExternalFileTableValuedFunction {
                         e);
             }
         } else {
-            parseFile();
+            parseFile(files);
         }
     }
 
diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/util/S3UtilTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/util/S3UtilTest.java
index 6e61893ed3b..ed3cdd56328 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/common/util/S3UtilTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/common/util/S3UtilTest.java
@@ -20,6 +20,9 @@ package org.apache.doris.common.util;
 import org.junit.Assert;
 import org.junit.Test;
 
+import java.nio.file.FileSystems;
+import java.nio.file.PathMatcher;
+import java.nio.file.Paths;
 import java.util.Arrays;
 import java.util.List;
 
@@ -108,9 +111,9 @@ public class S3UtilTest {
 
     @Test
     public void testExtendGlobNumberRange_invalidCharacters() {
-        // Test invalid characters {Refrain,1..3}
+        // Preserve literal file names alongside numeric ranges.
         String input = "file_{Refrain,1..3}.csv";
-        String expected = "file_{1,2,3}.csv";
+        String expected = "file_{Refrain,1,2,3}.csv";
         String result = S3Util.extendGlobNumberRange(input);
         Assert.assertEquals(expected, result);
     }
@@ -224,6 +227,54 @@ public class S3UtilTest {
         Assert.assertEquals(expected, result);
     }
 
+    @Test
+    public void testExtendGlobNumberRange_preservesPaddedValues() {
+        for (String pattern : Arrays.asList("base/day/001", "base/day/{001}",
+                "base/day/{001,002}", "base/day/{001,1}", "base/day/{2147483648}")) {
+            Assert.assertEquals(pattern, S3Util.extendGlobs(pattern));
+        }
+        Assert.assertEquals("base/day/{001,1,2}",
+                S3Util.extendGlobs("base/day/{001,1..2}"));
+    }
+
+    @Test
+    public void testExtendGlobNumberRange_matchesPaddedFileNames() {
+        PathMatcher matcher = FileSystems.getDefault().getPathMatcher(
+                "glob:" + S3Util.extendGlobs("base/day/{001,002}"));
+        Assert.assertTrue(matcher.matches(Paths.get("base/day/001")));
+        Assert.assertTrue(matcher.matches(Paths.get("base/day/002")));
+        Assert.assertFalse(matcher.matches(Paths.get("base/day/1")));
+        Assert.assertFalse(matcher.matches(Paths.get("base/day/2")));
+    }
+
+    @Test
+    public void testExtendGlobNumberRange_preservesDistinctHeadPaths() {
+        Assert.assertEquals(Arrays.asList("base/day/001", "base/day/1"),
+                S3Util.expandBracePatterns(S3Util.extendGlobs("base/day/{001,1,001,1}")));
+        Assert.assertEquals(Arrays.asList("base/day/1"),
+                S3Util.expandBracePatterns(S3Util.extendGlobs("base/day/{1,1}")));
+    }
+
+    @Test
+    public void testExtendGlobNumberRange_preservesMixedFileNames() {
+        Assert.assertEquals("base/day/{001,abc}", S3Util.extendGlobs("base/day/{001,abc}"));
+        Assert.assertEquals("base/day/{abc,1,2}", S3Util.extendGlobs("base/day/{abc,1..2}"));
+        Assert.assertEquals(Arrays.asList("base/day/001", "base/day/abc.csv"),
+                S3Util.expandBracePatterns(S3Util.extendGlobs("base/day/{001,abc.csv,001,abc.csv}")));
+        PathMatcher matcher = FileSystems.getDefault().getPathMatcher(
+                "glob:" + S3Util.extendGlobs("base/day/{001,abc.csv}"));
+        Assert.assertTrue(matcher.matches(Paths.get("base/day/001")));
+        Assert.assertTrue(matcher.matches(Paths.get("base/day/abc.csv")));
+    }
+
+    @Test
+    public void testExtendGlobNumberRange_preservesLiteralCharacters() {
+        for (String pattern : Arrays.asList("base/day/{001,$file}", "base/day/{001,\\file}",
+                "base/day/{001, abc }", "base/day/{abc,}", "base/day/{$file}")) {
+            Assert.assertEquals(pattern, S3Util.extendGlobs(pattern));
+        }
+    }
+
     @Test
     public void testGetLongestPrefix_withGlobPattern() {
         // Test getLongestPrefix with glob patterns
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 e1a64931c8b..a52a5825bf4 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
@@ -23,6 +23,7 @@ import org.apache.doris.job.base.TimerDefinition;
 import org.apache.doris.job.cdc.split.BinlogSplit;
 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.junit.Assert;
 import org.junit.Test;
@@ -33,6 +34,20 @@ import java.util.Map;
 
 public class StreamingInsertJobLagTest {
 
+    @Test
+    public void testMessageLagUsesOffsetProvider() {
+        StreamingInsertJob job = Deencapsulation.newInstance(StreamingInsertJob.class);
+        Assert.assertEquals(-1L, job.getLagMessages());
+        Deencapsulation.setField(job, "offsetProvider", new JdbcSourceOffsetProvider());
+        Assert.assertEquals(-1L, job.getLagMessages());
+
+        S3EventSourceOffsetProvider provider = new S3EventSourceOffsetProvider("test-queue");
+        Deencapsulation.setField(job, "offsetProvider", provider);
+        Assert.assertEquals(-1L, job.getLagMessages());
+        Deencapsulation.setField(provider, "approximateNumberOfMessages", 12L);
+        Assert.assertEquals(12L, job.getLagMessages());
+    }
+
     @Test
     public void testLastSourceEventTimestampUsesOffsetProvider() {
         StreamingInsertJob job = Deencapsulation.newInstance(StreamingInsertJob.class);
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 fde21328560..fce3a18b359 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
@@ -171,7 +171,7 @@ public class StreamingInsertJobOffsetPersistenceTest {
 
     @Test
     public void testS3OnceLastBatchFinishesOnlyAfterSuccess() throws Exception {
-        StreamingJobProperties properties = new StreamingJobProperties(ImmutableMap.of("s3.ingestion_mode", "ONCE"));
+        StreamingJobProperties properties = new StreamingJobProperties(ImmutableMap.of("s3.ingestion_mode", "ONE_TIME"));
         TestStreamingInsertJob failedJob = newJob(new S3SourceOffsetProvider(properties), 1017L);
         NoopStreamingMultiTblTask failedTask =
                 (NoopStreamingMultiTblTask) Deencapsulation.getField(failedJob, "runningStreamTask");
@@ -241,7 +241,7 @@ public class StreamingInsertJobOffsetPersistenceTest {
 
     @Test
     public void testCloudReplayRefreshesPersistedOffset() {
-        Map<String, String> properties = ImmutableMap.of("s3.ingestion_mode", "ONCE");
+        Map<String, String> properties = ImmutableMap.of("s3.ingestion_mode", "ONE_TIME");
         S3SourceOffsetProvider provider = new S3SourceOffsetProvider(new StreamingJobProperties(properties));
         StreamingInsertJob job = new StreamingInsertJob();
         job.offsetProvider = provider;
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 aedc3bd15c1..30bf7ce12e5 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
@@ -17,14 +17,35 @@
 
 package org.apache.doris.job.extensions.insert.streaming;
 
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.jmockit.Deencapsulation;
 import org.apache.doris.job.common.JobStatus;
 import org.apache.doris.job.exception.JobException;
+import org.apache.doris.job.offset.SourceOffsetProvider;
+import org.apache.doris.job.offset.s3.S3EventSourceOffsetProvider;
+import org.apache.doris.transaction.GlobalTransactionMgrIface;
+import org.apache.doris.transaction.TxnStateCallbackFactory;
 
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.util.Collections;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
 
 public class StreamingInsertJobStatusTransitionTest {
 
+    private static StreamingInsertJob newJob(JobStatus status) {
+        StreamingInsertJob job = Deencapsulation.newInstance(StreamingInsertJob.class);
+        Deencapsulation.setField(job, "lock", new ReentrantReadWriteLock(true));
+        Deencapsulation.setField(job, "jobId", 8001L);
+        Deencapsulation.setField(job, "jobName", "test_job");
+        Deencapsulation.setField(job, "jobStatus", status);
+        return job;
+    }
+
+
     @Test
     public void testFinishedStatusCannotBeOverwritten() {
         StreamingInsertJob job = new StreamingInsertJob();
@@ -33,4 +54,49 @@ public class StreamingInsertJobStatusTransitionTest {
         Assertions.assertThrows(JobException.class, () -> job.updateJobStatus(JobStatus.PAUSED));
         Assertions.assertEquals(JobStatus.FINISHED, job.getJobStatus());
     }
+
+    @Test
+    public void testPauseAndResumeKeepOffsetProviderOpen() throws Exception {
+        StreamingInsertJob job = newJob(JobStatus.RUNNING);
+        SourceOffsetProvider provider = Mockito.mock(SourceOffsetProvider.class);
+        Deencapsulation.setField(job, "offsetProvider", provider);
+
+        job.updateJobStatus(JobStatus.PAUSED);
+        job.updateJobStatus(JobStatus.PENDING);
+
+        Mockito.verify(provider, Mockito.never()).close();
+    }
+
+    @Test
+    public void testFinalStatusesCloseOffsetProvider() throws Exception {
+        try (MockedStatic<Env> env = Mockito.mockStatic(Env.class)) {
+            GlobalTransactionMgrIface transactionMgr = Mockito.mock(GlobalTransactionMgrIface.class);
+            TxnStateCallbackFactory callbackFactory = Mockito.mock(TxnStateCallbackFactory.class);
+            env.when(Env::getCurrentGlobalTransactionMgr).thenReturn(transactionMgr);
+            Mockito.when(transactionMgr.getCallbackFactory()).thenReturn(callbackFactory);
+            for (JobStatus status : new JobStatus[] {JobStatus.STOPPED, JobStatus.FINISHED}) {
+                StreamingInsertJob job = newJob(JobStatus.RUNNING);
+                SourceOffsetProvider provider = Mockito.mock(SourceOffsetProvider.class);
+                Deencapsulation.setField(job, "offsetProvider", provider);
+
+                job.updateJobStatus(status);
+
+                Mockito.verify(provider).close();
+            }
+        }
+    }
+
+    @Test
+    public void testDropPendingJobPreventsLateMetadataFetch() throws Exception {
+        StreamingInsertJob job = newJob(JobStatus.PENDING);
+        S3EventSourceOffsetProvider provider = new S3EventSourceOffsetProvider("unused-queue");
+        Deencapsulation.setField(job, "offsetProvider", provider);
+
+        job.cleanup();
+        job.updateJobStatus(JobStatus.RUNNING);
+
+        // An old scheduler may still hold the removed job; it must not create clients with these invalid properties.
+        Assertions.assertDoesNotThrow(() -> provider.fetchRemoteMeta(
+                new StreamingJobProperties(Collections.emptyMap()), Collections.emptyMap()));
+    }
 }
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 520ad9c1005..29145af4174 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
@@ -29,6 +29,7 @@ import org.apache.doris.job.extensions.insert.InsertTask;
 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.S3Offset;
 import org.apache.doris.job.offset.s3.S3SourceOffsetProvider;
 import org.apache.doris.nereids.StatementContext;
@@ -36,17 +37,23 @@ import org.apache.doris.nereids.analyzer.UnboundTVFRelation;
 import org.apache.doris.nereids.glue.LogicalPlanAdapter;
 import org.apache.doris.nereids.parser.NereidsParser;
 import org.apache.doris.nereids.trees.expressions.Properties;
+import org.apache.doris.nereids.trees.expressions.functions.table.S3;
 import org.apache.doris.nereids.trees.plans.RelationId;
 import org.apache.doris.nereids.trees.plans.commands.insert.InsertIntoTableCommand;
-import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalTVFRelation;
 import org.apache.doris.plugin.AuditEvent;
 import org.apache.doris.qe.AuditLogHelper;
 import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.qe.QueryState;
 import org.apache.doris.qe.StmtExecutor;
 import org.apache.doris.resource.workloadschedpolicy.WorkloadRuntimeStatusMgr;
+import org.apache.doris.thrift.TBrokerFileStatus;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 import org.mockito.MockedConstruction;
@@ -55,11 +62,30 @@ import org.mockito.Mockito;
 
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.atomic.AtomicLong;
 
 public class StreamingInsertTaskAuditTest {
+    private ConnectContext previousContext;
+
+    @BeforeEach
+    public void setUp() {
+        previousContext = ConnectContext.get();
+        ConnectContext ctx = new ConnectContext();
+        ctx.setThreadLocalInfo();
+        ctx.setStatementContext(new StatementContext());
+    }
+
+    @AfterEach
+    public void tearDown() {
+        ConnectContext.remove();
+        if (previousContext != null) {
+            previousContext.setThreadLocalInfo();
+        }
+    }
+
     private static final String ORIGIN_URI = "s3://bucket/input/*.csv";
     private static final String RESOLVED_URI = "s3://bucket/input/{1.csv,2.csv}";
     private static final String S3_SQL = "insert into target_table select * from s3("
@@ -88,6 +114,7 @@ public class StreamingInsertTaskAuditTest {
         Assertions.assertEquals(StmtType.INSERT.name(), auditEvent.stmtType);
         Assertions.assertFalse(auditEvent.stmt.contains(ORIGIN_URI));
         Assertions.assertTrue(auditEvent.stmt.contains(RESOLVED_URI));
+        Assertions.assertFalse(auditEvent.stmt.contains("WITH LABEL"));
         Assertions.assertFalse(auditEvent.stmt.contains("private-value"));
         Assertions.assertEquals("OK", auditEvent.state);
         Assertions.assertTrue(auditEvent.isInternal);
@@ -101,9 +128,27 @@ public class StreamingInsertTaskAuditTest {
         Assertions.assertEquals(StmtType.INSERT.name(), auditEvent.stmtType);
         Assertions.assertEquals("ERR", auditEvent.state);
         Assertions.assertTrue(auditEvent.errorMessage.contains("insert failed"));
+        Assertions.assertFalse(auditEvent.stmt.contains("WITH LABEL"));
         Assertions.assertTrue(auditEvent.isInternal);
     }
 
+    @Test
+    public void testAuditPreservesOriginalInsertClauses() throws Exception {
+        for (String target : new String[] {
+                "target_table(c1)",
+                "`db`.`target table` PARTITION (p0) (c1)",
+                "target_table WITH LABEL old_label (c1)",
+                "target_table [shuffle]",
+                "target_table /* target comment */ (c1)"}) {
+            AuditEvent event = runTask(null, S3_SQL.replace("target_table", target),
+                    new S3SourceOffsetProvider(), new S3Offset(), true);
+            Assertions.assertTrue(event.stmt.contains(target));
+            Assertions.assertFalse(event.stmt.contains("WITH LABEL `1_2`"));
+            Assertions.assertTrue(event.stmt.contains(RESOLVED_URI));
+            Assertions.assertFalse(event.stmt.contains("private-value"));
+        }
+    }
+
     @Test
     public void testCdcRunDoesNotSubmitAuditEvent() throws Exception {
         String sql = "insert into target_table select * from cdc_stream("
@@ -130,13 +175,11 @@ public class StreamingInsertTaskAuditTest {
         SourceOffsetProvider provider = Mockito.mock(SourceOffsetProvider.class);
         Mockito.when(provider.getSourceType()).thenReturn("s3");
         S3Offset offset = new S3Offset();
-        offset.setFileLists(RESOLVED_URI);
         Mockito.when(provider.getNextOffset(Mockito.eq(properties), Mockito.anyMap())).thenReturn(offset);
         InsertIntoTableCommand baseCommand = Mockito.mock(InsertIntoTableCommand.class);
-        Mockito.when(baseCommand.getParsedPlan()).thenReturn(Optional.of(Mockito.mock(LogicalPlan.class)));
         InsertIntoTableCommand taskCommand = Mockito.mock(InsertIntoTableCommand.class);
-        UnboundTVFRelation tvf = Mockito.mock(UnboundTVFRelation.class);
-        Mockito.when(taskCommand.getAllTVFRelation()).thenReturn(Collections.singletonList(tvf));
+        Mockito.when(taskCommand.getLogicalQuery()).thenReturn(auditRelation());
+        offset.setFileStatuses(batchFiles());
         Mockito.when(provider.rewriteTvfParams(Mockito.eq(baseCommand), Mockito.eq(offset), Mockito.anyLong()))
                 .thenReturn(taskCommand);
         Mockito.doAnswer(invocation -> {
@@ -158,7 +201,7 @@ public class StreamingInsertTaskAuditTest {
                 MockedStatic<AuditLogHelper> audit = Mockito.mockStatic(AuditLogHelper.class)) {
             insertTask.when(() -> InsertTask.makeConnectContext(UserIdentity.ROOT, "test_db")).thenReturn(ctx);
             StreamingInsertTask task = new StreamingInsertTask(1L, 2L, S3_SQL, provider, "test_db", properties,
-                    Collections.emptyMap(), UserIdentity.ROOT, null);
+                    ImmutableMap.of("URI", ORIGIN_URI), UserIdentity.ROOT, null);
             // A retry must not audit files from an earlier attempt after preparation fails.
             Deencapsulation.setField(task, "auditSql", "stale audit SQL from a previous attempt");
             task.before();
@@ -168,7 +211,9 @@ public class StreamingInsertTaskAuditTest {
             Mockito.verify(parsers.constructed().get(1)).parseForEncryption(Mockito.eq(S3_SQL), Mockito.anyMap());
             task.run();
             Assertions.assertEquals(QueryState.MysqlStateType.OK, state.getStateType());
-            Mockito.verify(taskCommand).run(ctx, executors.constructed().get(1));
+            Assertions.assertEquals(1, executors.constructed().size());
+            Mockito.verify(baseCommand, Mockito.never()).initPlan(Mockito.any(), Mockito.any(), Mockito.anyBoolean());
+            Mockito.verify(taskCommand).run(ctx, executors.constructed().get(0));
             audit.verifyNoInteractions();
         }
     }
@@ -179,28 +224,85 @@ public class StreamingInsertTaskAuditTest {
         originProperties.put("URI", ORIGIN_URI);
         UnboundTVFRelation originTvf = new UnboundTVFRelation(
                 new RelationId(1), "s3", new Properties(originProperties));
-        InsertIntoTableCommand originCommand = Mockito.mock(InsertIntoTableCommand.class);
-        Mockito.when(originCommand.getParsedPlan()).thenReturn(Optional.of(originTvf));
 
         S3Offset offset = new S3Offset();
-        offset.setFileLists(RESOLVED_URI);
         Env env = Mockito.mock(Env.class);
         Mockito.when(env.isMaster()).thenReturn(false);
         try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
             mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+            InsertIntoTableCommand originCommand = new InsertIntoTableCommand(originTvf,
+                    Optional.of("original_label"), Optional.empty(), Optional.empty());
+            originCommand.setJobId(42L);
+            offset.setFileStatuses(batchFiles());
             InsertIntoTableCommand rewritten = new S3SourceOffsetProvider()
                     .rewriteTvfParams(originCommand, offset, 1L);
 
             Map<String, String> rewrittenProperties =
-                    rewritten.getAllTVFRelation().get(0).getProperties().getMap();
+                    ((LogicalTVFRelation) rewritten.getLogicalQuery()).getFunction().getTVFProperties().getMap();
+            Assertions.assertEquals(Optional.of("original_label"), rewritten.getLabelName());
+            Assertions.assertEquals(42L, rewritten.getJobId());
             Assertions.assertEquals(1, rewrittenProperties.size());
             Assertions.assertEquals(RESOLVED_URI, rewrittenProperties.get("URI"));
         }
     }
 
+    @Test
+    public void testExactFileRewritePreservesWithClause() {
+        ConnectContext.get().setDatabase("test_db");
+        Env env = Mockito.mock(Env.class);
+        CatalogMgr catalogMgr = Mockito.mock(CatalogMgr.class);
+        InternalCatalog catalog = Mockito.mock(InternalCatalog.class);
+        Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr);
+        Mockito.when(catalogMgr.getCatalog(Mockito.anyString())).thenReturn(catalog);
+        Mockito.when(catalog.getName()).thenReturn("internal");
+        Mockito.when(env.isMaster()).thenReturn(false);
+        try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+            mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+            InsertIntoTableCommand command = (InsertIntoTableCommand) new NereidsParser().parseSingle(
+                    "with input as (select 1 as c) insert into target_table select * from s3(\"uri\"=\""
+                            + ORIGIN_URI + "\") cross join input");
+            S3Offset offset = new S3Offset();
+            offset.setFileStatuses(batchFiles());
+            InsertIntoTableCommand rewritten = new S3SourceOffsetProvider().rewriteTvfParams(command, offset, 1L);
+            Optional<?> originalCte = Deencapsulation.getField(command, "cte");
+            Optional<?> rewrittenCte = Deencapsulation.getField(rewritten, "cte");
+            Assertions.assertTrue(originalCte.isPresent());
+            Assertions.assertEquals(originalCte, rewrittenCte);
+            List<LogicalTVFRelation> relations = rewritten.getLogicalQuery()
+                    .collectToList(LogicalTVFRelation.class::isInstance);
+            Assertions.assertEquals(1, relations.size());
+            Assertions.assertEquals(RESOLVED_URI,
+                    relations.get(0).getFunction().getTVFProperties().getMap().get("uri"));
+            Assertions.assertEquals(batchFiles(),
+                    Deencapsulation.getField(relations.get(0).getFunction(), "fileStatuses"));
+        }
+    }
+
+    @Test
+    public void testNotificationAuditUsesExactBatch() throws Exception {
+        SourceOffsetProvider provider = Mockito.mock(SourceOffsetProvider.class);
+        Mockito.when(provider.getSourceType()).thenReturn("s3");
+        AuditEvent event = runTask(null, S3_SQL, provider,
+                new S3EventOffset(ImmutableList.of("input/1.csv", "input/2.csv"), batchFiles()), true);
+        Assertions.assertTrue(event.stmt.contains(RESOLVED_URI));
+        Assertions.assertFalse(event.stmt.contains(ORIGIN_URI));
+        Assertions.assertFalse(event.stmt.contains("private-value"));
+    }
+
+    private static LogicalTVFRelation auditRelation() {
+        Map<String, String> properties = ImmutableMap.of(
+                "uri", RESOLVED_URI, "s3.secret_key", "private-value", "enclose", "\"");
+        return new LogicalTVFRelation(new RelationId(1), S3.withFiles(new Properties(properties), batchFiles()),
+                ImmutableList.of());
+    }
+
+    private static List<TBrokerFileStatus> batchFiles() {
+        return ImmutableList.of(new TBrokerFileStatus("s3://bucket/input/1.csv", false, 10, true),
+                new TBrokerFileStatus("s3://bucket/input/2.csv", false, 20, true));
+    }
+
     private AuditEvent runS3Task(RuntimeException commandFailure) throws Exception {
         S3Offset offset = new S3Offset();
-        offset.setFileLists(RESOLVED_URI);
         return runTask(commandFailure, S3_SQL, new S3SourceOffsetProvider(), offset, true);
     }
 
@@ -223,13 +325,7 @@ public class StreamingInsertTaskAuditTest {
             ctx.getState().setOk();
             InsertIntoTableCommand command = Mockito.mock(InsertIntoTableCommand.class);
             if (expectAudit) {
-                Map<String, String> rewrittenTvfProps = new HashMap<>();
-                rewrittenTvfProps.put("uri", RESOLVED_URI);
-                rewrittenTvfProps.put("s3.secret_key", "private-value");
-                rewrittenTvfProps.put("enclose", "\"");
-                UnboundTVFRelation tvf = Mockito.mock(UnboundTVFRelation.class);
-                Mockito.when(tvf.getProperties()).thenReturn(new Properties(rewrittenTvfProps));
-                Mockito.when(command.getAllTVFRelation()).thenReturn(Collections.singletonList(tvf));
+                Mockito.when(command.getLogicalQuery()).thenReturn(auditRelation());
             }
             AtomicLong commandStartTime = new AtomicLong();
             Mockito.doAnswer(invocation -> {
@@ -249,7 +345,8 @@ public class StreamingInsertTaskAuditTest {
 
             StreamingInsertTask task = new StreamingInsertTask(
                     1L, 2L, sql, offsetProvider, "test_db", null,
-                    Collections.emptyMap(), UserIdentity.ROOT, null);
+                    ImmutableMap.of("URI", ORIGIN_URI, "s3.secret_key", "private-value", "enclose", "\""),
+                    UserIdentity.ROOT, null);
             Deencapsulation.setField(task, "ctx", ctx);
             Deencapsulation.setField(task, "taskCommand", command);
             Deencapsulation.setField(task, "stmtExecutor", executor);
diff --git a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobPropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobPropertiesTest.java
index 2e42c9f0c10..0e8c68bd74e 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobPropertiesTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobPropertiesTest.java
@@ -22,6 +22,9 @@ import org.apache.doris.common.jmockit.Deencapsulation;
 import org.apache.doris.job.common.JobStatus;
 import org.apache.doris.job.exception.JobException;
 import org.apache.doris.job.extensions.insert.InsertTask;
+import org.apache.doris.job.offset.SourceOffsetProviderFactory;
+import org.apache.doris.job.offset.s3.S3EventSourceOffsetProvider;
+import org.apache.doris.job.offset.s3.S3SourceOffsetProvider;
 import org.apache.doris.nereids.trees.plans.commands.AlterJobCommand;
 import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.qe.SessionVariable;
@@ -33,17 +36,65 @@ import org.junit.Test;
 import java.util.HashMap;
 
 public class StreamingJobPropertiesTest {
+    @Test
+    public void testS3ProviderSelectionDefaultsToOrderedList() throws AnalysisException {
+        StreamingJobProperties orderedList = new StreamingJobProperties(new HashMap<>());
+        orderedList.validate();
+        Assert.assertTrue(SourceOffsetProviderFactory.createSourceOffsetProvider("s3", orderedList)
+                instanceof S3SourceOffsetProvider);
+
+        HashMap<String, String> props = new HashMap<>();
+        props.put("s3.ingestion_mode", "notification");
+        props.put("s3.event.source", "sqs");
+        props.put("s3.sqs.queue_url", "https://sqs.us-west-2.amazonaws.com/123456789012/events");
+        StreamingJobProperties event = new StreamingJobProperties(props);
+        event.validate();
+        Assert.assertTrue(SourceOffsetProviderFactory.createSourceOffsetProvider("s3", event)
+                instanceof S3EventSourceOffsetProvider);
+    }
+
+    @Test
+    public void testS3ModeNames() {
+        Assert.assertEquals("ORDERED_LIST", new StreamingJobProperties(ImmutableMap.of()).getS3IngestionMode());
+        Assert.assertEquals("ORDERED_LIST",
+                new StreamingJobProperties(ImmutableMap.of("s3.ingestion_mode", " ordered_list ")).getS3IngestionMode());
+        Assert.assertTrue(new StreamingJobProperties(
+                ImmutableMap.of("s3.ingestion_mode", "NOTIFICATION")).isS3NotificationMode());
+        Assert.assertTrue(new StreamingJobProperties(
+                ImmutableMap.of("s3.ingestion_mode", "ONE_TIME")).isS3OneTimeMode());
+        for (String mode : new String[] {"LEXICAL", "EVENT", "ONCE"}) {
+            Assert.assertThrows(AnalysisException.class,
+                    () -> new StreamingJobProperties(ImmutableMap.of("s3.ingestion_mode", mode)).validate());
+        }
+    }
+
+    @Test
+    public void testNotificationRequiresQueueAndRejectsManualOffset() throws AnalysisException {
+        HashMap<String, String> props = new HashMap<>();
+        props.put("s3.ingestion_mode", "NOTIFICATION");
+        Assert.assertThrows(AnalysisException.class, () -> new StreamingJobProperties(props).validate());
+        props.put("s3.event.source", "SQS");
+        Assert.assertThrows(AnalysisException.class, () -> new StreamingJobProperties(props).validate());
+        props.put("s3.sqs.queue_url", "https://sqs.us-west-2.amazonaws.com/123456789012/events");
+        new StreamingJobProperties(props).validate();
+        props.put("offset", "logs/a.csv");
+        Assert.assertThrows(AnalysisException.class, () -> new StreamingJobProperties(props).validate());
+        props.remove("offset");
+        props.put("max_interval", "7200");
+        Assert.assertThrows(AnalysisException.class, () -> new StreamingJobProperties(props).validate());
+    }
+
 
     @Test
-    public void testS3OnceModeValidationAndAlter() throws Exception {
-        StreamingJobProperties properties = new StreamingJobProperties(ImmutableMap.of("s3.ingestion_mode", " once "));
+    public void testS3OneTimeModeValidationAndAlter() throws Exception {
+        StreamingJobProperties properties = new StreamingJobProperties(ImmutableMap.of("s3.ingestion_mode", " one_time "));
         properties.validate();
-        Assert.assertTrue(properties.isS3OnceMode());
+        Assert.assertTrue(properties.isS3OneTimeMode());
         Assert.assertThrows(AnalysisException.class,
                 () -> new StreamingJobProperties(ImmutableMap.of("s3.ingestion_mode", "invalid")).validate());
         Assert.assertThrows(AnalysisException.class,
                 () -> new StreamingJobProperties(
-                        ImmutableMap.of("s3.ingestion_mode", "ONCE", "offset", "{\"fileName\":\"a.csv\"}"))
+                        ImmutableMap.of("s3.ingestion_mode", "ONE_TIME", "offset", "{\"fileName\":\"a.csv\"}"))
                         .validate());
 
         StreamingInsertJob job = new StreamingInsertJob();
@@ -51,7 +102,7 @@ public class StreamingJobPropertiesTest {
         AlterJobCommand alterBatch = new AlterJobCommand("job", ImmutableMap.of("s3.max_batch_files", "1"),
                 null, null, null, ImmutableMap.of(), ImmutableMap.of());
         Deencapsulation.invoke(alterBatch, "validateProps", job);
-        AlterJobCommand alterMode = new AlterJobCommand("job", ImmutableMap.of("s3.ingestion_mode", "LEXICAL"),
+        AlterJobCommand alterMode = new AlterJobCommand("job", ImmutableMap.of("s3.ingestion_mode", "ORDERED_LIST"),
                 null, null, null, ImmutableMap.of(), ImmutableMap.of());
         Assert.assertThrows(AnalysisException.class,
                 () -> Deencapsulation.invoke(alterMode, "validateProps", job));
@@ -62,9 +113,9 @@ public class StreamingJobPropertiesTest {
     }
 
     @Test
-    public void testS3OnceModeRestoredWithLegacyOffset() throws Exception {
+    public void testS3OneTimeModeRestoredWithLegacyOffset() throws Exception {
         StreamingInsertJob job = new StreamingInsertJob();
-        Deencapsulation.setField(job, "properties", ImmutableMap.of("s3.ingestion_mode", "ONCE"));
+        Deencapsulation.setField(job, "properties", ImmutableMap.of("s3.ingestion_mode", "ONE_TIME"));
         Deencapsulation.setField(job, "tvfType", "s3");
         job.setOffsetProviderPersist("{\"endFile\":\"data/a.csv\"}");
         job.gsonPostProcess();
diff --git a/fe/fe-core/src/test/java/org/apache/doris/job/offset/s3/S3EventOffsetTest.java b/fe/fe-core/src/test/java/org/apache/doris/job/offset/s3/S3EventOffsetTest.java
new file mode 100644
index 00000000000..6f464ac8247
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/job/offset/s3/S3EventOffsetTest.java
@@ -0,0 +1,56 @@
+// 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.doris.job.offset.s3;
+
+import org.apache.doris.persist.gson.GsonUtils;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+public class S3EventOffsetTest {
+    @Test
+    public void testSerializedOffsetPreservesKeysAndCountsUtf8Bytes() {
+        List<String> files = Arrays.asList("logs/中文.csv", "logs/a\"b\\c.csv");
+        S3EventOffset offset = new S3EventOffset(files);
+        String expected = "{\"files\":[\"logs/中文.csv\",\"logs/a\\\"b\\\\c.csv\"]}";
+
+        Assertions.assertEquals(expected, offset.toSerializedJson());
+        Assertions.assertEquals(expected, offset.showRange());
+        Assertions.assertEquals(expected.getBytes(StandardCharsets.UTF_8).length, offset.serializedSize());
+        Assertions.assertEquals(files, GsonUtils.GSON.fromJson(expected, S3EventOffset.class).getFiles());
+        Assertions.assertTrue(offset.isValidOffset());
+    }
+
+    @Test
+    public void testTaskFilesCannotBeChangedThroughCallerList() {
+        List<String> files = new ArrayList<>(Collections.singletonList("logs/a.csv"));
+        S3EventOffset offset = new S3EventOffset(files);
+        files.add("logs/b.csv");
+
+        Assertions.assertEquals(Collections.singletonList("logs/a.csv"), offset.getFiles());
+        Assertions.assertThrows(UnsupportedOperationException.class, () -> offset.getFiles().add("logs/c.csv"));
+        Assertions.assertFalse(GsonUtils.GSON.fromJson("{}", S3EventOffset.class).isValidOffset());
+        Assertions.assertFalse(new S3EventOffset(Collections.singletonList(" ")).isValidOffset());
+    }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/job/offset/s3/S3EventSourceOffsetProviderTest.java b/fe/fe-core/src/test/java/org/apache/doris/job/offset/s3/S3EventSourceOffsetProviderTest.java
new file mode 100644
index 00000000000..1bca291f532
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/job/offset/s3/S3EventSourceOffsetProviderTest.java
@@ -0,0 +1,1185 @@
+// 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.doris.job.offset.s3;
+
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.job.extensions.insert.streaming.StreamingInsertJob;
+import org.apache.doris.job.extensions.insert.streaming.StreamingJobProperties;
+import org.apache.doris.persist.gson.GsonUtils;
+
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
+import software.amazon.awssdk.identity.spi.IdentityProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.S3ClientBuilder;
+import software.amazon.awssdk.services.s3.S3ServiceClientConfiguration;
+import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
+import software.amazon.awssdk.services.sqs.SqsClient;
+import software.amazon.awssdk.services.sqs.SqsClientBuilder;
+import software.amazon.awssdk.services.sqs.model.BatchResultErrorEntry;
+import software.amazon.awssdk.services.sqs.model.ChangeMessageVisibilityBatchRequest;
+import software.amazon.awssdk.services.sqs.model.ChangeMessageVisibilityBatchResponse;
+import software.amazon.awssdk.services.sqs.model.DeleteMessageBatchRequest;
+import software.amazon.awssdk.services.sqs.model.DeleteMessageBatchResponse;
+import software.amazon.awssdk.services.sqs.model.DeleteMessageBatchResultEntry;
+import software.amazon.awssdk.services.sqs.model.GetQueueAttributesRequest;
+import software.amazon.awssdk.services.sqs.model.GetQueueAttributesResponse;
+import software.amazon.awssdk.services.sqs.model.Message;
+import software.amazon.awssdk.services.sqs.model.QueueAttributeName;
+import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest;
+import software.amazon.awssdk.services.sqs.model.ReceiveMessageResponse;
+import software.amazon.awssdk.services.sts.StsClient;
+import software.amazon.awssdk.services.sts.StsClientBuilder;
+import software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider;
+import software.amazon.awssdk.services.sts.model.AssumeRoleRequest;
+import software.amazon.awssdk.services.sts.model.AssumeRoleResponse;
+import software.amazon.awssdk.services.sts.model.Credentials;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+public class S3EventSourceOffsetProviderTest {
+    private static final String QUEUE_URL = "https://sqs.us-west-2.amazonaws.com/123456789012/events";
+
+    private S3EventSourceOffsetProvider provider;
+    private Map<String, String> tvfProperties;
+    private SqsClient sqsClient;
+    private SqsClientBuilder sqsBuilder;
+    private MockedStatic<SqsClient> sqsStatic;
+    private S3Client s3Client;
+    private S3ClientBuilder s3Builder;
+    private AwsCredentialsProvider s3CredentialsProvider;
+    private MockedStatic<S3Client> s3Static;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        tvfProperties = new HashMap<>();
+        tvfProperties.put("uri", "s3://bucket/logs/**");
+        tvfProperties.put("s3.endpoint", "s3.us-west-2.amazonaws.com");
+        tvfProperties.put("s3.region", "us-west-2");
+        tvfProperties.put("s3.access_key", "test-ak");
+        tvfProperties.put("s3.secret_key", "test-sk");
+        provider = new S3EventSourceOffsetProvider(QUEUE_URL);
+        provider.ensureInitialized(1L, tvfProperties);
+        sqsClient = Mockito.mock(SqsClient.class);
+        sqsBuilder = Mockito.mock(SqsClientBuilder.class, Mockito.RETURNS_SELF);
+        Mockito.when(sqsBuilder.build()).thenReturn(sqsClient);
+        sqsStatic = Mockito.mockStatic(SqsClient.class);
+        sqsStatic.when(SqsClient::builder).thenReturn(sqsBuilder);
+        s3Client = Mockito.mock(S3Client.class);
+        s3Builder = Mockito.mock(S3ClientBuilder.class, Mockito.RETURNS_SELF);
+        Mockito.when(s3Builder.build()).thenReturn(s3Client);
+        Mockito.when(s3Builder.credentialsProvider(Mockito.any(AwsCredentialsProvider.class)))
+                .thenAnswer(invocation -> {
+                    s3CredentialsProvider = invocation.getArgument(0);
+                    return s3Builder;
+                });
+        Mockito.when(s3Client.serviceClientConfiguration()).thenAnswer(invocation ->
+                S3ServiceClientConfiguration.builder().region(Region.US_WEST_2)
+                        .credentialsProvider(s3CredentialsProvider).build());
+        s3Static = Mockito.mockStatic(S3Client.class);
+        s3Static.when(S3Client::builder).thenReturn(s3Builder);
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(ReceiveMessageResponse.builder().build());
+        Mockito.when(sqsClient.deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class)))
+                .thenAnswer(invocation -> {
+                    DeleteMessageBatchRequest request = invocation.getArgument(0);
+                    return DeleteMessageBatchResponse.builder().successful(request.entries().stream()
+                            .map(entry -> DeleteMessageBatchResultEntry.builder().id(entry.id()).build())
+                            .collect(Collectors.toList())).build();
+                });
+        Mockito.when(sqsClient.changeMessageVisibilityBatch(Mockito.any(ChangeMessageVisibilityBatchRequest.class)))
+                .thenReturn(ChangeMessageVisibilityBatchResponse.builder().build());
+        Mockito.when(sqsClient.getQueueAttributes(Mockito.any(GetQueueAttributesRequest.class)))
+                .thenReturn(GetQueueAttributesResponse.builder().attributes(Collections.singletonMap(
+                        QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES, "0")).build());
+    }
+
+    @AfterEach
+    public void tearDown() {
+        provider.close();
+        s3Static.close();
+        sqsStatic.close();
+    }
+
+    @Test
+    public void testClientsAreReusedUntilClosed() {
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Mockito.verify(s3Builder).build();
+        Mockito.verify(sqsBuilder).build();
+        Mockito.verify(sqsClient, Mockito.never()).close();
+        Mockito.verify(s3Client, Mockito.never()).close();
+
+        provider.close();
+        provider.close();
+        InOrder closeOrder = Mockito.inOrder(sqsClient, s3Client);
+        closeOrder.verify(sqsClient).close();
+        closeOrder.verify(s3Client).close();
+    }
+
+    @Test
+    public void testChangedCredentialsReplaceClientsAndReuseReplacement() throws Exception {
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        tvfProperties.put("s3.access_key", "new-ak");
+        tvfProperties.put("s3.secret_key", "new-sk");
+        provider.ensureInitialized(1L, tvfProperties);
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Mockito.verify(s3Builder, Mockito.times(2)).build();
+        Mockito.verify(sqsBuilder, Mockito.times(2)).build();
+        Mockito.verify(sqsClient).close();
+        Mockito.verify(s3Client).close();
+        ArgumentCaptor<AwsCredentialsProvider> credentials = ArgumentCaptor.forClass(AwsCredentialsProvider.class);
+        Mockito.verify(sqsBuilder, Mockito.times(2)).credentialsProvider(
+                (IdentityProvider<? extends AwsCredentialsIdentity>) credentials.capture());
+        Assertions.assertEquals("test-ak", credentials.getAllValues().get(0).resolveCredentials().accessKeyId());
+        Assertions.assertEquals("new-ak", credentials.getAllValues().get(1).resolveCredentials().accessKeyId());
+    }
+
+    @Test
+    public void testCloseWaitsForClientCreation() throws Exception {
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        try {
+            CountDownLatch closeStarted = new CountDownLatch(1);
+            AtomicReference<Future<?>> closeFuture = new AtomicReference<>();
+            Mockito.when(sqsBuilder.build()).thenAnswer(invocation -> {
+                closeFuture.set(executor.submit(() -> {
+                    closeStarted.countDown();
+                    provider.close();
+                }));
+                Assertions.assertTrue(closeStarted.await(5, TimeUnit.SECONDS));
+                Assertions.assertFalse(closeFuture.get().isDone());
+                Mockito.verify(s3Client, Mockito.never()).close();
+                return sqsClient;
+            });
+            Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                    .thenAnswer(invocation -> {
+                        closeFuture.get().get(5, TimeUnit.SECONDS);
+                        return response(filesMessage("late", "logs/a.csv"));
+                    });
+            provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+            closeFuture.get().get(5, TimeUnit.SECONDS);
+            Assertions.assertFalse(provider.hasMoreDataToConsume());
+            Mockito.verify(sqsClient).close();
+            Mockito.verify(s3Client).close();
+            provider.ensureInitialized(1L, tvfProperties);
+            provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+            Mockito.verify(sqsBuilder).build();
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    public void testCloseDuringReceiveDiscardsLateBatch() throws Exception {
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        try {
+            Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                    .thenAnswer(invocation -> {
+                        executor.submit(() -> provider.close()).get(5, TimeUnit.SECONDS);
+                        Mockito.verify(sqsClient).close();
+                        return response(filesMessage("in-flight", "logs/a.csv"));
+                    });
+            provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+            Assertions.assertFalse(provider.hasMoreDataToConsume());
+            Mockito.verify(sqsClient).close();
+            Mockito.verify(s3Client).close();
+            provider.ensureInitialized(1L, tvfProperties);
+            provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+            Mockito.verify(sqsBuilder).build();
+            Mockito.verify(sqsClient, Mockito.never()).deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class));
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    public void testCloseDuringReceiveSuppressesRequestFailure() {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenAnswer(invocation -> {
+                    provider.close();
+                    throw new IllegalStateException("client closed");
+                });
+
+        Assertions.assertDoesNotThrow(() -> provider.fetchRemoteMeta(jobProperties(1), tvfProperties));
+
+        Assertions.assertFalse(provider.hasMoreDataToConsume());
+        Mockito.verify(sqsClient).close();
+        Mockito.verify(s3Client).close();
+        Mockito.verify(sqsClient, Mockito.never()).getQueueAttributes(Mockito.any(GetQueueAttributesRequest.class));
+    }
+
+    @Test
+    public void testClientCreationFailureReleasesFileSystemAndAllowsRetry() {
+        Mockito.when(sqsBuilder.build()).thenThrow(new IllegalStateException("client creation failed"))
+                .thenReturn(sqsClient);
+        Assertions.assertThrows(IllegalStateException.class,
+                () -> provider.fetchRemoteMeta(jobProperties(1), tvfProperties));
+        Mockito.verify(s3Client).close();
+
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Mockito.verify(s3Builder, Mockito.times(2)).build();
+        Mockito.verify(sqsBuilder, Mockito.times(2)).build();
+    }
+
+    @Test
+    public void testSqsCloseFailureStillClosesFileSystem() {
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Mockito.doThrow(new IllegalStateException("close failed")).when(sqsClient).close();
+        provider.close();
+        Mockito.verify(s3Client).close();
+    }
+
+    @Test
+    public void testNotificationMetadataDoesNotRequireHead() {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(message("literal", record("bucket", "logs/a%2Cb%5B1%5D.csv", 123))));
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+
+        S3EventOffset offset = nextOffset();
+        Assertions.assertEquals(Collections.singletonList("logs/a,b[1].csv"), offset.getFiles());
+        Assertions.assertEquals("s3://bucket/logs/a,b[1].csv", offset.getFileStatuses().get(0).getPath());
+        Assertions.assertEquals(123, offset.getFileStatuses().get(0).getSize());
+        Assertions.assertFalse(offset.toSerializedJson().contains("fileStatuses"));
+        Mockito.verify(s3Client, Mockito.never()).headObject(Mockito.any(HeadObjectRequest.class));
+        Mockito.verify(sqsClient, Mockito.never()).deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class));
+    }
+
+    @Test
+    public void testNotificationMatchesFilesystemGlobSyntax() throws Exception {
+        tvfProperties.put("uri", "s3://bucket/logs/{001,abc,1..2}.csv");
+        provider = new S3EventSourceOffsetProvider(QUEUE_URL);
+        provider.ensureInitialized(1L, tvfProperties);
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("glob", "logs/001.csv", "logs/abc.csv",
+                        "logs/1.csv", "logs/2.csv", "logs/3.csv", "logs//1.csv")));
+
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+
+        S3EventOffset offset = nextOffset();
+        Assertions.assertEquals(Arrays.asList("logs/001.csv", "logs/abc.csv",
+                "logs/1.csv", "logs/2.csv", "logs//1.csv"), offset.getFiles());
+        Assertions.assertEquals("s3://bucket/logs//1.csv", offset.getFileStatuses().get(4).getPath());
+    }
+
+    @Test
+    public void testNotificationPreservesRepeatedSlashesInObjectKey() throws Exception {
+        assertSelection("logs/*.csv", "logs//a.csv", true);
+    }
+
+    @Test
+    public void testNotificationSkipsNegativeRangeInMixedAlternatives() throws Exception {
+        assertSelection("logs/{x,-1..1}.csv", "logs/0.csv", false);
+    }
+
+    @Test
+    public void testNotificationPreservesSpacesInLiteralAlternatives() throws Exception {
+        assertSelection("logs/{1..2, abc }.csv", "logs/ abc .csv", true);
+    }
+
+    @Test
+    public void testNotificationUsesFilesystemCaretCharacterClass() throws Exception {
+        assertSelection("logs/[^a].csv", "logs/a.csv", true);
+    }
+
+    @Test
+    public void testQuestionMarkGlobPreservesMatchingNotificationsUntilCommit() throws Exception {
+        tvfProperties.put("uri", "s3://bucket/logs/file?.csv");
+        provider = new S3EventSourceOffsetProvider(QUEUE_URL);
+        provider.ensureInitialized(1L, tvfProperties);
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("keep", "logs/file1.csv"),
+                        filesMessage("skip", "logs/file12.csv")), ReceiveMessageResponse.builder().build());
+
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+
+        Assertions.assertTrue(provider.hasMoreDataToConsume());
+        S3EventOffset offset = nextOffset();
+        Assertions.assertEquals(Collections.singletonList("logs/file1.csv"), offset.getFiles());
+        Assertions.assertEquals("s3://bucket/logs/file1.csv", offset.getFileStatuses().get(0).getPath());
+        ArgumentCaptor<DeleteMessageBatchRequest> deletes = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+        Mockito.verify(sqsClient).deleteMessageBatch(deletes.capture());
+        Assertions.assertEquals(Collections.singletonList("skip"), deleteHandles(deletes.getValue()));
+
+        provider.updateOffset(offset);
+        provider.onTaskCommitted(1, 1);
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Mockito.verify(sqsClient, Mockito.times(2)).deleteMessageBatch(deletes.capture());
+        Assertions.assertEquals(Collections.singletonList("keep"), deleteHandles(deletes.getValue()));
+    }
+
+    @Test
+    public void testNotificationMatchesLiteralHashInSourceKey() throws Exception {
+        tvfProperties.put("uri", "s3://bucket/logs/file#1.csv");
+        provider = new S3EventSourceOffsetProvider(QUEUE_URL);
+        provider.ensureInitialized(1L, tvfProperties);
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("keep", "logs/file%231.csv")));
+
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+
+        Assertions.assertTrue(provider.hasMoreDataToConsume());
+        S3EventOffset offset = nextOffset();
+        Assertions.assertEquals(Collections.singletonList("logs/file#1.csv"), offset.getFiles());
+        Assertions.assertEquals("s3://bucket/logs/file#1.csv", offset.getFileStatuses().get(0).getPath());
+        Mockito.verify(sqsClient, Mockito.never()).deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class));
+    }
+
+    @Test
+    public void testDisplayTracksAcceptedAndCommittedBatches() {
+        Assertions.assertNull(provider.getShowCurrentOffset());
+        Assertions.assertNull(provider.getShowMaxOffset());
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("first", "logs/z.csv", "logs/a%22b.csv"),
+                        filesMessage("overflow", "logs/overflow.csv")),
+                        ReceiveMessageResponse.builder().build());
+        provider.fetchRemoteMeta(jobProperties(2), tvfProperties);
+        String endOffset = "{\"fileName\":\"logs/overflow.csv\",\"lagMessages\":0}";
+        Assertions.assertNull(provider.getShowCurrentOffset());
+        Assertions.assertEquals(endOffset, provider.getShowMaxOffset());
+        S3EventOffset offset = nextOffset();
+        Assertions.assertEquals(endOffset, provider.getShowMaxOffset());
+
+        Mockito.clearInvocations(sqsClient);
+        provider.updateOffset(provider.deserializeOffset(
+                provider.getCommitOffsetJson(offset, 1L, Collections.emptyList())));
+        provider.onTaskCommitted(3, 3);
+        Assertions.assertEquals("{\"fileName\":\"logs/overflow.csv\"}", provider.getShowCurrentOffset());
+        Assertions.assertEquals(endOffset, provider.getShowMaxOffset());
+        // Compact display must not truncate the durable file list or issue SQS calls.
+        Assertions.assertEquals("{\"files\":[\"logs/z.csv\",\"logs/a\\\"b.csv\",\"logs/overflow.csv\"]}",
+                provider.getPersistInfo());
+        Mockito.verifyNoInteractions(sqsClient);
+        provider.fetchRemoteMeta(jobProperties(2), tvfProperties);
+        Assertions.assertEquals(endOffset, provider.getShowMaxOffset());
+    }
+
+    @Test
+    public void testLagMessagesUsesVisibleCountAndSupportsLargeBacklogs() {
+        Assertions.assertEquals(-1L, provider.getLagMessages());
+        Map<QueueAttributeName, String> attributes = new HashMap<>();
+        attributes.put(QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES, "3000000000");
+        attributes.put(QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES_NOT_VISIBLE, "12");
+        attributes.put(QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES_DELAYED, "34");
+        Mockito.when(sqsClient.getQueueAttributes(Mockito.any(GetQueueAttributesRequest.class)))
+                .thenReturn(GetQueueAttributesResponse.builder().attributes(attributes).build());
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertNull(provider.getShowCurrentOffset());
+        Assertions.assertEquals("{\"lagMessages\":3000000000}", provider.getShowMaxOffset());
+        Assertions.assertEquals(3000000000L, provider.getLagMessages());
+        ArgumentCaptor<GetQueueAttributesRequest> request = ArgumentCaptor.forClass(GetQueueAttributesRequest.class);
+        Mockito.verify(sqsClient).getQueueAttributes(request.capture());
+        Assertions.assertEquals(QUEUE_URL, request.getValue().queueUrl());
+        Assertions.assertEquals(Collections.singletonList(QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES),
+                request.getValue().attributeNames());
+    }
+
+    @Test
+    public void testLagMessageFailureClearsStatisticWithoutBlockingBatch() {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("first", "logs/a.csv")));
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertEquals("{\"fileName\":\"logs/a.csv\",\"lagMessages\":0}", provider.getShowMaxOffset());
+        Assertions.assertEquals(0L, provider.getLagMessages());
+        Mockito.when(sqsClient.getQueueAttributes(Mockito.any(GetQueueAttributesRequest.class)))
+                .thenThrow(new IllegalStateException("Access denied"))
+                .thenReturn(GetQueueAttributesResponse.builder().attributes(Collections.singletonMap(
+                        QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES, "7")).build());
+
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertEquals("{\"fileName\":\"logs/a.csv\"}", provider.getShowMaxOffset());
+        Assertions.assertEquals(-1L, provider.getLagMessages());
+        Assertions.assertEquals(Collections.singletonList("logs/a.csv"), nextOffset().getFiles());
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertEquals("{\"fileName\":\"logs/a.csv\",\"lagMessages\":7}", provider.getShowMaxOffset());
+        Assertions.assertEquals(7L, provider.getLagMessages());
+        Mockito.verify(sqsClient, Mockito.times(1)).receiveMessage(Mockito.any(ReceiveMessageRequest.class));
+    }
+
+    @Test
+    public void testSourceEventTimestampTracksOnlyCommittedBatchAndIsNotPersisted() {
+        String latest = "2026-01-01T11:00:00.000Z";
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(message("first",
+                                record("bucket", "logs/a.csv", 1, latest),
+                                record("bucket", "logs/b.csv", 1, "2026-01-01T09:00:00.000Z"),
+                                record("other", "logs/ignored.csv", 1, "2026-01-01T12:00:00.000Z"),
+                                record("bucket", "other/ignored.csv", 1, "2026-01-01T12:00:00.000Z")),
+                        message("second", record("bucket", "logs/c.csv", 1, "2026-01-01T11:30:00.000+02:00")),
+                        message("overflow",
+                                record("bucket", "logs/d.csv", 1, "2026-01-01T11:00:00.000Z"),
+                                record("bucket", "logs/e.csv", 1, "2026-01-01T11:00:00.000Z"))));
+
+        Assertions.assertEquals(0L, provider.getLastSourceEventTimestampSeconds());
+        provider.fetchRemoteMeta(jobProperties(4), tvfProperties);
+        Assertions.assertEquals(0L, provider.getLastSourceEventTimestampSeconds());
+        S3EventOffset offset = nextOffset();
+        Assertions.assertEquals(0L, provider.getLastSourceEventTimestampSeconds());
+        String committedJson = provider.getCommitOffsetJson(offset, 1L, Collections.emptyList());
+        Assertions.assertEquals("{\"files\":[\"logs/a.csv\",\"logs/b.csv\",\"logs/c.csv\","
+                + "\"logs/d.csv\",\"logs/e.csv\"]}", committedJson);
+        provider.updateOffset(provider.deserializeOffset(committedJson));
+        provider.onTaskCommitted(5, 5);
+        Assertions.assertEquals(Instant.parse(latest).getEpochSecond(), provider.getLastSourceEventTimestampSeconds());
+        Mockito.verify(sqsClient, Mockito.never())
+                .changeMessageVisibilityBatch(Mockito.any(ChangeMessageVisibilityBatchRequest.class));
+
+        S3EventSourceOffsetProvider restored = new S3EventSourceOffsetProvider(QUEUE_URL);
+        restored.restoreFromPersistInfo(provider.getPersistInfo());
+        Assertions.assertEquals(provider.getShowCurrentOffset(), restored.getShowCurrentOffset());
+        Assertions.assertEquals(0L, restored.getLastSourceEventTimestampSeconds());
+
+        // A newly received batch must not replace the timestamp until it commits.
+        // Out-of-order notifications report that batch's time, not a historical maximum.
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("next", "logs/next.csv")));
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertEquals(Instant.parse(latest).getEpochSecond(), provider.getLastSourceEventTimestampSeconds());
+        provider.updateOffset(nextOffset());
+        provider.onTaskCommitted(1, 1);
+        Assertions.assertEquals(Instant.parse("2026-01-01T00:00:00Z").getEpochSecond(),
+                provider.getLastSourceEventTimestampSeconds());
+    }
+
+    @Test
+    public void testMissingSourceEventTimestampIsUnavailable() {
+        String noTimestamp = record("bucket", "logs/a.csv", 1)
+                .replace("\"eventTime\":\"2026-01-01T00:00:00.000Z\",", "");
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(message("missing-time", noTimestamp)));
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        provider.updateOffset(nextOffset());
+        provider.onTaskCommitted(1, 1);
+        Assertions.assertEquals(0L, provider.getLastSourceEventTimestampSeconds());
+    }
+
+    @Test
+    public void testFiltersRecordsAndDeletesOnlySkippedMessages() {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(message("keep", record("bucket", "logs/a+b%2Bc.csv", 1),
+                                record("other-bucket", "logs/wrong.csv", 1),
+                                record("bucket", "other/wrong.csv", 1)),
+                        Message.builder().receiptHandle("test-event")
+                                .body("{\"Event\":\"s3:TestEvent\"}").build()),
+                        ReceiveMessageResponse.builder().build());
+
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+
+        Assertions.assertEquals(Collections.singletonList("logs/a b+c.csv"), nextOffset().getFiles());
+        ArgumentCaptor<DeleteMessageBatchRequest> deleted = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+        Mockito.verify(sqsClient).deleteMessageBatch(deleted.capture());
+        Assertions.assertEquals(Collections.singletonList("test-event"), deleteHandles(deleted.getValue()));
+    }
+
+    @Test
+    public void testDuplicateKeysAreScannedOnceAndAllReceiptsAreCommitted() {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("first", "logs/a.csv", "logs/a.csv")),
+                        response(filesMessage("second", "logs/a.csv", "logs/b.csv")));
+        provider.fetchRemoteMeta(jobProperties(2), tvfProperties);
+        S3EventOffset offset = nextOffset();
+        Assertions.assertEquals(Arrays.asList("logs/a.csv", "logs/b.csv"), offset.getFiles());
+        Assertions.assertEquals(2, offset.getFileStatuses().size());
+        Mockito.verify(sqsClient, Mockito.never()).deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class));
+        provider.updateOffset(provider.deserializeOffset(provider.getCommitOffsetJson(offset, 1L,
+                Collections.emptyList())));
+        provider.onTaskCommitted(2, 2);
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(ReceiveMessageResponse.builder().build());
+        provider.fetchRemoteMeta(jobProperties(2), tvfProperties);
+        ArgumentCaptor<DeleteMessageBatchRequest> deleted = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+        Mockito.verify(sqsClient).deleteMessageBatch(deleted.capture());
+        Assertions.assertEquals(Arrays.asList("first", "second"), deleteHandles(deleted.getValue()));
+    }
+
+    @Test
+    public void testFileLimitAcceptsWholeReceive() {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(message("first", record("bucket", "logs/a.csv", 1)),
+                        message("overflow", record("bucket", "logs/b.csv", 1),
+                                record("bucket", "logs/c.csv", 1))));
+
+        provider.fetchRemoteMeta(jobProperties(2), tvfProperties);
+
+        Assertions.assertEquals(Arrays.asList("logs/a.csv", "logs/b.csv", "logs/c.csv"), nextOffset().getFiles());
+        Mockito.verify(sqsClient, Mockito.never())
+                .changeMessageVisibilityBatch(Mockito.any(ChangeMessageVisibilityBatchRequest.class));
+        Mockito.verify(sqsClient, Mockito.never()).deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class));
+    }
+
+    @Test
+    public void testByteLimitAcceptsWholeReceive() {
+        Map<String, String> properties = new HashMap<>();
+        properties.put("s3.max_batch_bytes", "104857600");
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(message("first", record("bucket", "logs/a.csv", 104857600)),
+                        message("overflow", record("bucket", "logs/b.csv", 1))));
+
+        provider.fetchRemoteMeta(new StreamingJobProperties(properties), tvfProperties);
+
+        Assertions.assertEquals(Arrays.asList("logs/a.csv", "logs/b.csv"), nextOffset().getFiles());
+        Mockito.verify(sqsClient, Mockito.never())
+                .changeMessageVisibilityBatch(Mockito.any(ChangeMessageVisibilityBatchRequest.class));
+        Mockito.verify(sqsClient).receiveMessage(Mockito.any(ReceiveMessageRequest.class));
+    }
+
+    @Test
+    public void testSerializedLimitAcceptsWholeReceive() {
+        // Each key is below S3's key limit; each message is about 36 KiB of selected keys.
+        String[] firstKeys = longKeys("a", 40);
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("first", firstKeys),
+                        filesMessage("overflow", longKeys("b", 40))));
+
+        provider.fetchRemoteMeta(jobProperties(256), tvfProperties);
+
+        S3EventOffset offset = nextOffset();
+        List<String> expected = new ArrayList<>(Arrays.asList(firstKeys));
+        expected.addAll(Arrays.asList(longKeys("b", 40)));
+        Assertions.assertEquals(expected, offset.getFiles());
+        Assertions.assertTrue(offset.serializedSize() > 64 * 1024);
+        Mockito.verify(sqsClient, Mockito.never())
+                .changeMessageVisibilityBatch(Mockito.any(ChangeMessageVisibilityBatchRequest.class));
+    }
+
+    @Test
+    public void testOversizedReceiveIsAcceptedWhole() {
+        String[] keys = longKeys("a", 80);
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("large", keys),
+                        message("overflow", record("bucket", "logs/next.csv", 1))));
+
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+
+        S3EventOffset offset = nextOffset();
+        List<String> expected = new ArrayList<>(Arrays.asList(keys));
+        expected.add("logs/next.csv");
+        Assertions.assertEquals(expected, offset.getFiles());
+        Assertions.assertTrue(offset.serializedSize() > 64 * 1024);
+        Mockito.verify(sqsClient, Mockito.never())
+                .changeMessageVisibilityBatch(Mockito.any(ChangeMessageVisibilityBatchRequest.class));
+    }
+
+    @Test
+    public void testUnmatchedMessageIsDeletedWithoutAdvancingOffset() {
+        provider.restoreFromPersistInfo("{\"files\":[\"logs/committed.csv\"]}");
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("unmatched", "other/ignored.csv")),
+                        ReceiveMessageResponse.builder().build());
+
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+
+        Assertions.assertFalse(provider.hasMoreDataToConsume());
+        Assertions.assertEquals("{\"files\":[\"logs/committed.csv\"]}", provider.getPersistInfo());
+        ArgumentCaptor<DeleteMessageBatchRequest> deleted = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+        Mockito.verify(sqsClient).deleteMessageBatch(deleted.capture());
+        Assertions.assertEquals(Collections.singletonList("unmatched"), deleteHandles(deleted.getValue()));
+    }
+
+    @Test
+    public void testInvalidMessagesAreDeletedWithoutPublishingBatch() {
+        String validEvent = message("invalid", record("bucket", "logs/a.csv", 1)).body();
+        String unsupportedEvent = validEvent.replace("ObjectCreated:Put", "ObjectRemoved:Delete");
+        for (String body : Arrays.asList("", " ", "not-json", "null", "[]", "{}", "{\"Records\":\"invalid\"}",
+                "{\"Records\":[null]}", "{\"Event\":\"s3:TestEvent\"}",
+                validEvent + " {}", validEvent + " garbage", unsupportedEvent)) {
+            Mockito.clearInvocations(sqsClient);
+            Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                    .thenReturn(response(Message.builder().receiptHandle("invalid").body(body).build()),
+                            ReceiveMessageResponse.builder().build());
+
+            provider.fetchRemoteMeta(jobProperties(2), tvfProperties);
+
+            Assertions.assertFalse(provider.hasMoreDataToConsume());
+            Assertions.assertNull(provider.getPersistInfo());
+            ArgumentCaptor<DeleteMessageBatchRequest> deleted = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+            Mockito.verify(sqsClient).deleteMessageBatch(deleted.capture());
+            Assertions.assertEquals(Collections.singletonList("invalid"), deleteHandles(deleted.getValue()));
+        }
+    }
+
+    @Test
+    public void testInvalidRecordsDoNotDiscardValidFilesAndDeletionWaitsForCommit() {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(message("mixed", record("bucket", "logs/a.csv", 1), "null",
+                        record("bucket", "logs/deleted.csv", 1).replace("ObjectCreated:Put", "ObjectRemoved:Delete"),
+                        "{\"eventSource\":\"aws:s3\",\"eventName\":\"ObjectCreated:Put\"}",
+                        record("bucket", "", 1), record("bucket", "logs/invalid.csv", -1),
+                        record("bucket", "logs/invalid%ZZ.csv", 1))),
+                        ReceiveMessageResponse.builder().build());
+
+        provider.fetchRemoteMeta(jobProperties(2), tvfProperties);
+
+        S3EventOffset offset = nextOffset();
+        Assertions.assertEquals(Collections.singletonList("logs/a.csv"), offset.getFiles());
+        Mockito.verify(sqsClient, Mockito.never()).deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class));
+        provider.updateOffset(offset);
+        provider.onTaskCommitted(1, 1);
+        Mockito.verify(sqsClient, Mockito.never()).deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class));
+        provider.fetchRemoteMeta(jobProperties(2), tvfProperties);
+        ArgumentCaptor<DeleteMessageBatchRequest> deleted = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+        Mockito.verify(sqsClient).deleteMessageBatch(deleted.capture());
+        Assertions.assertEquals(Collections.singletonList("mixed"), deleteHandles(deleted.getValue()));
+    }
+
+    @Test
+    public void testCommitDefersDeletionAndPartialFailureBlocksNextReceive() {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(message("a", record("bucket", "logs/a.csv", 1)),
+                        message("b", record("bucket", "logs/b.csv", 1))),
+                        ReceiveMessageResponse.builder().build());
+        provider.fetchRemoteMeta(jobProperties(2), tvfProperties);
+        S3EventOffset offset = nextOffset();
+        Assertions.assertSame(offset, nextOffset());
+        Assertions.assertNull(provider.getPersistInfo());
+        Mockito.clearInvocations(sqsClient);
+
+        String committedJson = provider.getCommitOffsetJson(offset, 1L, Collections.emptyList());
+        provider.updateOffset(provider.deserializeOffset(committedJson));
+        provider.onTaskCommitted(2, 2);
+        Mockito.verifyNoInteractions(sqsClient);
+        Assertions.assertFalse(provider.hasMoreDataToConsume());
+        Assertions.assertEquals("{\"files\":[\"logs/a.csv\",\"logs/b.csv\"]}", provider.getPersistInfo());
+
+        Mockito.when(sqsClient.deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class)))
+                .thenReturn(DeleteMessageBatchResponse.builder().failed(BatchResultErrorEntry.builder()
+                        .id("1").code("RequestThrottled").senderFault(false).build()).build(),
+                        DeleteMessageBatchResponse.builder().build());
+        Assertions.assertThrows(IllegalStateException.class,
+                () -> provider.fetchRemoteMeta(jobProperties(2), tvfProperties));
+        Mockito.verify(sqsClient, Mockito.never()).receiveMessage(Mockito.any(ReceiveMessageRequest.class));
+        Assertions.assertFalse(provider.hasMoreDataToConsume());
+
+        provider.fetchRemoteMeta(jobProperties(2), tvfProperties);
+        ArgumentCaptor<DeleteMessageBatchRequest> deleted = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+        InOrder order = Mockito.inOrder(sqsClient);
+        order.verify(sqsClient, Mockito.times(2)).deleteMessageBatch(deleted.capture());
+        order.verify(sqsClient).receiveMessage(Mockito.any(ReceiveMessageRequest.class));
+        Assertions.assertEquals(Arrays.asList("a", "b"), deleteHandles(deleted.getAllValues().get(0)));
+        Assertions.assertEquals(Collections.singletonList("b"), deleteHandles(deleted.getAllValues().get(1)));
+    }
+
+    @Test
+    public void testDeleteRequestExceptionRetainsUnattemptedChunks() {
+        Message[] first = IntStream.range(0, 10)
+                .mapToObj(i -> filesMessage("h" + i, "logs/" + i + ".csv")).toArray(Message[]::new);
+        Message[] second = IntStream.range(10, 13)
+                .mapToObj(i -> filesMessage("h" + i, "logs/" + i + ".csv")).toArray(Message[]::new);
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(first), response(second), ReceiveMessageResponse.builder().build());
+        provider.fetchRemoteMeta(jobProperties(13), tvfProperties);
+        Assertions.assertEquals(13, nextOffset().getFiles().size());
+        provider.updateOffset(nextOffset());
+        provider.onTaskCommitted(13, 13);
+        Mockito.clearInvocations(sqsClient);
+        RuntimeException failure = new IllegalStateException("connection failed");
+        Mockito.when(sqsClient.deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class)))
+                .thenThrow(failure).thenReturn(DeleteMessageBatchResponse.builder().build());
+
+        IllegalStateException thrown = Assertions.assertThrows(IllegalStateException.class,
+                () -> provider.fetchRemoteMeta(jobProperties(13), tvfProperties));
+        Assertions.assertSame(failure, thrown.getCause());
+        Mockito.verify(sqsClient, Mockito.never()).receiveMessage(Mockito.any(ReceiveMessageRequest.class));
+
+        provider.fetchRemoteMeta(jobProperties(13), tvfProperties);
+        ArgumentCaptor<DeleteMessageBatchRequest> deleted = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+        Mockito.verify(sqsClient, Mockito.times(3)).deleteMessageBatch(deleted.capture());
+        Assertions.assertEquals(10, deleted.getAllValues().get(0).entries().size());
+        Assertions.assertEquals(deleteHandles(deleted.getAllValues().get(0)),
+                deleteHandles(deleted.getAllValues().get(1)));
+        Assertions.assertEquals(Arrays.asList("h10", "h11", "h12"), deleteHandles(deleted.getAllValues().get(2)));
+    }
+
+    @Test
+    public void testCommitCanProceedDuringRenewalAndExpiredReceiptIsNotDeleted() throws Exception {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("old", "logs/a.csv")), ReceiveMessageResponse.builder().build());
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        S3EventOffset offset = nextOffset();
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        try {
+            Mockito.when(sqsClient.changeMessageVisibilityBatch(Mockito.any(ChangeMessageVisibilityBatchRequest.class)))
+                    .thenAnswer(invocation -> {
+                        ChangeMessageVisibilityBatchRequest request = invocation.getArgument(0);
+                        Assertions.assertTrue(request.entries().get(0).visibilityTimeout() > 10);
+                        // Another thread must finish the commit while this SQS call is still in progress.
+                        executor.submit(() -> {
+                            Assertions.assertEquals("{\"files\":[\"logs/a.csv\"]}",
+                                    provider.getCommitOffsetJson(offset, 1L, Collections.emptyList()));
+                            provider.updateOffset(offset);
+                            provider.onTaskCommitted(1, 1);
+                        }).get(5, TimeUnit.SECONDS);
+                        return ChangeMessageVisibilityBatchResponse.builder().failed(BatchResultErrorEntry.builder()
+                                .id("0").code("ReceiptHandleIsInvalid").senderFault(true).build()).build();
+                    });
+            provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+            Assertions.assertFalse(provider.hasMoreDataToConsume());
+            provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+            Mockito.verify(sqsClient, Mockito.never()).deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class));
+            Mockito.verify(sqsClient, Mockito.times(2)).receiveMessage(Mockito.any(ReceiveMessageRequest.class));
+            Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                    .thenReturn(response(filesMessage("c", "logs/c.csv")),
+                            response(filesMessage("fresh-a", "logs/a.csv").toBuilder().messageId("id-old").build()),
+                            ReceiveMessageResponse.builder().build());
+            provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+            provider.updateOffset(nextOffset());
+            provider.onTaskCommitted(1, 1);
+            provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+            Assertions.assertFalse(provider.hasMoreDataToConsume());
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    public void testResumeDiscardsReadyBatchAndReceivesFreshReceipt() {
+        assertResumeReceivesFreshReceipt(false);
+    }
+
+    @Test
+    public void testResumeDiscardsRunningBatchAndReceivesFreshReceipt() {
+        assertResumeReceivesFreshReceipt(true);
+    }
+
+    private void assertResumeReceivesFreshReceipt(boolean running) {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("old", "logs/a.csv")),
+                        response(filesMessage("fresh", "logs/a.csv")));
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        if (running) {
+            nextOffset();
+        }
+        provider.replayIfNeed(Mockito.mock(StreamingInsertJob.class));
+        Assertions.assertFalse(provider.hasMoreDataToConsume());
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertEquals(Collections.singletonList("logs/a.csv"), nextOffset().getFiles());
+        provider.updateOffset(nextOffset());
+        provider.onTaskCommitted(1, 1);
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(ReceiveMessageResponse.builder().build());
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Mockito.verify(sqsClient, Mockito.never())
+                .changeMessageVisibilityBatch(Mockito.any(ChangeMessageVisibilityBatchRequest.class));
+        ArgumentCaptor<DeleteMessageBatchRequest> deleted = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+        Mockito.verify(sqsClient).deleteMessageBatch(deleted.capture());
+        Assertions.assertEquals(Collections.singletonList("fresh"), deleteHandles(deleted.getValue()));
+    }
+
+    @Test
+    public void testExpiredReceiptPreventsCommit() {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("old", "logs/a.csv"), filesMessage("valid", "logs/b.csv")));
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        S3EventOffset offset = nextOffset();
+        Mockito.when(sqsClient.changeMessageVisibilityBatch(Mockito.any(ChangeMessageVisibilityBatchRequest.class)))
+                .thenReturn(ChangeMessageVisibilityBatchResponse.builder().failed(BatchResultErrorEntry.builder()
+                        .id("0").code("ReceiptHandleIsInvalid").build()).build());
+        Assertions.assertThrows(IllegalStateException.class,
+                () -> provider.fetchRemoteMeta(jobProperties(1), tvfProperties));
+        Assertions.assertThrows(IllegalStateException.class,
+                () -> provider.getCommitOffsetJson(offset, 1, Collections.emptyList()));
+    }
+
+    @Test
+    public void testExpiredCommittedReceiptSurvivesLaterCommit() {
+        provider.replayIfNeed(Mockito.mock(StreamingInsertJob.class));
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("b", "logs/b.csv")),
+                        response(filesMessage("c", "logs/c.csv")),
+                        response(filesMessage("fresh-b", "logs/b.csv").toBuilder().messageId("id-b").build()),
+                        ReceiveMessageResponse.builder().build());
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        provider.updateOffset(nextOffset());
+        provider.onTaskCommitted(1, 1);
+        Mockito.when(sqsClient.deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class)))
+                .thenAnswer(invocation -> {
+                    DeleteMessageBatchRequest request = invocation.getArgument(0);
+                    if ("b".equals(request.entries().get(0).receiptHandle())) {
+                        return DeleteMessageBatchResponse.builder().failed(BatchResultErrorEntry.builder()
+                                .id("0").code("ReceiptHandleIsInvalid").build()).build();
+                    }
+                    return DeleteMessageBatchResponse.builder().successful(request.entries().stream()
+                            .map(entry -> DeleteMessageBatchResultEntry.builder().id(entry.id()).build())
+                            .collect(Collectors.toList())).build();
+                });
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertEquals(Collections.singletonList("logs/c.csv"), nextOffset().getFiles());
+        provider.updateOffset(nextOffset());
+        provider.onTaskCommitted(1, 1);
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertFalse(provider.hasMoreDataToConsume());
+        Assertions.assertEquals("{\"files\":[\"logs/c.csv\"]}", provider.getPersistInfo());
+    }
+
+    @Test
+    public void testResumeAcknowledgesBatchWhoseCommitWasReplayed() {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("committed", "logs/a.csv")),
+                        ReceiveMessageResponse.builder().build());
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        S3EventOffset offset = nextOffset();
+        // Cloud replay can restore the commit before the task callback updates transient state.
+        provider.updateOffset(offset);
+        provider.replayIfNeed(Mockito.mock(StreamingInsertJob.class));
+        Assertions.assertFalse(provider.hasMoreDataToConsume());
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertEquals("{\"files\":[\"logs/a.csv\"]}", provider.getPersistInfo());
+        ArgumentCaptor<DeleteMessageBatchRequest> deleted = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+        Mockito.verify(sqsClient).deleteMessageBatch(deleted.capture());
+        Assertions.assertEquals(Collections.singletonList("committed"), deleteHandles(deleted.getValue()));
+        Mockito.verify(sqsClient, Mockito.never())
+                .changeMessageVisibilityBatch(Mockito.any(ChangeMessageVisibilityBatchRequest.class));
+    }
+
+    @Test
+    public void testPartialDeletionKeepsDuplicateKeyUntilEveryMessageIsAcknowledged() {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("first", "logs/a.csv"),
+                        filesMessage("second", "logs/a.csv", "logs/b.csv")),
+                        response(filesMessage("c", "logs/c.csv")),
+                        response(filesMessage("fresh", "logs/a.csv", "logs/b.csv")
+                                .toBuilder().messageId("id-second").build()),
+                        ReceiveMessageResponse.builder().build());
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertEquals(Arrays.asList("logs/a.csv", "logs/b.csv"), nextOffset().getFiles());
+        provider.updateOffset(nextOffset());
+        provider.onTaskCommitted(2, 2);
+        Mockito.when(sqsClient.deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class)))
+                .thenAnswer(invocation -> {
+                    DeleteMessageBatchRequest request = invocation.getArgument(0);
+                    if ("first".equals(request.entries().get(0).receiptHandle())) {
+                        return DeleteMessageBatchResponse.builder()
+                                .successful(DeleteMessageBatchResultEntry.builder().id("0").build())
+                                .failed(BatchResultErrorEntry.builder().id("1")
+                                        .code("ReceiptHandleIsInvalid").build()).build();
+                    }
+                    return DeleteMessageBatchResponse.builder().successful(request.entries().stream()
+                            .map(entry -> DeleteMessageBatchResultEntry.builder().id(entry.id()).build())
+                            .collect(Collectors.toList())).build();
+                });
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertEquals(Collections.singletonList("logs/c.csv"), nextOffset().getFiles());
+        provider.updateOffset(nextOffset());
+        provider.onTaskCommitted(1, 1);
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertFalse(provider.hasMoreDataToConsume());
+        // Successful acknowledgement removes the old keys rather than retaining them forever.
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("new-event", "logs/a.csv", "logs/b.csv")));
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertEquals(Arrays.asList("logs/a.csv", "logs/b.csv"), nextOffset().getFiles());
+    }
+
+    @Test
+    public void testInvalidVisibilityDurationDoesNotDiscardReceipt() {
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("running", "logs/a.csv")));
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        S3EventOffset offset = nextOffset();
+        Mockito.when(sqsClient.changeMessageVisibilityBatch(Mockito.any(ChangeMessageVisibilityBatchRequest.class)))
+                .thenReturn(ChangeMessageVisibilityBatchResponse.builder().failed(BatchResultErrorEntry.builder()
+                        .id("0").code("InvalidParameterValue").message("Visibility timeout exceeds the maximum")
+                        .senderFault(true).build()).build());
+
+        Assertions.assertThrows(IllegalStateException.class,
+                () -> provider.fetchRemoteMeta(jobProperties(1), tvfProperties));
+        Assertions.assertThrows(IllegalStateException.class,
+                () -> provider.fetchRemoteMeta(jobProperties(1), tvfProperties));
+        Assertions.assertSame(offset, nextOffset());
+        Mockito.verify(sqsClient, Mockito.times(2))
+                .changeMessageVisibilityBatch(Mockito.any(ChangeMessageVisibilityBatchRequest.class));
+        Mockito.verify(sqsClient).receiveMessage(Mockito.any(ReceiveMessageRequest.class));
+    }
+
+    @Test
+    public void testRecoveredFilesSurviveLaterCommitsAndResume() throws Exception {
+        StreamingInsertJob job = Mockito.mock(StreamingInsertJob.class);
+        provider.restoreFromPersistInfo("{\"files\":[\"logs/a.csv\"]}");
+        provider.replayIfNeed(job);
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("b", "logs/b.csv")),
+                        response(filesMessage("c", "logs/c.csv")),
+                        response(filesMessage("late-a", "logs/a.csv").toBuilder().messageId("id-a").build()),
+                        ReceiveMessageResponse.builder().build());
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        provider.updateOffset(nextOffset());
+        provider.onTaskCommitted(1, 1);
+        provider.replayIfNeed(job);
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        provider.updateOffset(nextOffset());
+        provider.onTaskCommitted(1, 1);
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+
+        Assertions.assertFalse(provider.hasMoreDataToConsume());
+        Assertions.assertEquals("{\"files\":[\"logs/c.csv\"]}", provider.getPersistInfo());
+        Assertions.assertEquals("{\"fileName\":\"logs/c.csv\"}", provider.getShowCurrentOffset());
+        ArgumentCaptor<DeleteMessageBatchRequest> deleted = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+        Mockito.verify(sqsClient, Mockito.times(3)).deleteMessageBatch(deleted.capture());
+        Assertions.assertEquals(Collections.singletonList("late-a"), deleteHandles(deleted.getAllValues().get(2)));
+
+        provider.replayIfNeed(job);
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("overwrite-a", "logs/a.csv")));
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertEquals(Collections.singletonList("logs/a.csv"), nextOffset().getFiles());
+    }
+
+    @Test
+    public void testRecoverySnapshotUsesLatestReplayedOffsetAndDoesNotAccumulate() {
+        provider.restoreFromPersistInfo("{\"files\":[\"logs/image.csv\"]}");
+        // Transaction replay advances the image offset before the first scheduling pass.
+        provider.updateOffset(new S3EventOffset(Collections.singletonList("logs/a.csv")));
+        provider.replayIfNeed(Mockito.mock(StreamingInsertJob.class));
+        provider.updateOffset(new S3EventOffset(Collections.singletonList("logs/b.csv")));
+        provider.replayIfNeed(Mockito.mock(StreamingInsertJob.class));
+        provider.updateOffset(new S3EventOffset(Collections.singletonList("logs/c.csv")));
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("mixed", "logs/image.csv", "logs/a.csv",
+                        "logs/b.csv", "logs/c.csv", "logs/new.csv")));
+
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+
+        Assertions.assertEquals(Arrays.asList("logs/image.csv", "logs/b.csv", "logs/new.csv"),
+                nextOffset().getFiles());
+        Assertions.assertEquals(3, nextOffset().getFileStatuses().size());
+        Mockito.verify(sqsClient, Mockito.never()).deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class));
+        Map<String, Map<String, String>> recovered = Deencapsulation.getField(provider, "unackedCommittedFiles");
+        Assertions.assertEquals(Collections.singletonMap("id-mixed", "mixed"), recovered.get("logs/a.csv"));
+        provider.updateOffset(nextOffset());
+        provider.onTaskCommitted(3, 3);
+        Assertions.assertEquals(Collections.singletonMap("id-mixed", "mixed"), recovered.get("logs/a.csv"));
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(ReceiveMessageResponse.builder().build());
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        ArgumentCaptor<DeleteMessageBatchRequest> deleted = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+        Mockito.verify(sqsClient).deleteMessageBatch(deleted.capture());
+        Assertions.assertEquals(Collections.singletonList("mixed"), deleteHandles(deleted.getValue()));
+        Assertions.assertTrue(recovered.isEmpty());
+    }
+
+    @Test
+    public void testRecoveredFilesAreRemovedOnlyAfterSuccessfulDeletion() {
+        provider.restoreFromPersistInfo("{\"files\":[\"logs/a.csv\",\"logs/b.csv\",\"logs/unseen.csv\"]}");
+        provider.replayIfNeed(Mockito.mock(StreamingInsertJob.class));
+        provider.updateOffset(new S3EventOffset(Collections.singletonList("logs/current.csv")));
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("a", "logs/a.csv"), filesMessage("b", "logs/b.csv")));
+        Mockito.when(sqsClient.deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class)))
+                .thenReturn(DeleteMessageBatchResponse.builder()
+                        .successful(DeleteMessageBatchResultEntry.builder().id("0").build())
+                        .failed(BatchResultErrorEntry.builder().id("1").code("RequestThrottled").build()).build());
+
+        Assertions.assertThrows(IllegalStateException.class,
+                () -> provider.fetchRemoteMeta(jobProperties(1), tvfProperties));
+        Map<String, Map<String, String>> recovered = Deencapsulation.getField(provider, "unackedCommittedFiles");
+        Assertions.assertFalse(recovered.containsKey("logs/a.csv"));
+        Assertions.assertEquals(Collections.singletonMap("id-b", "b"), recovered.get("logs/b.csv"));
+        Assertions.assertTrue(recovered.containsKey("logs/unseen.csv"));
+
+        Mockito.when(sqsClient.deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class)))
+                .thenThrow(new IllegalStateException("delete failed"))
+                .thenReturn(DeleteMessageBatchResponse.builder()
+                        .successful(DeleteMessageBatchResultEntry.builder().id("0").build()).build());
+        Assertions.assertThrows(IllegalStateException.class,
+                () -> provider.fetchRemoteMeta(jobProperties(1), tvfProperties));
+        Assertions.assertEquals(Collections.singletonMap("id-b", "b"), recovered.get("logs/b.csv"));
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(ReceiveMessageResponse.builder().build());
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+        Assertions.assertFalse(recovered.containsKey("logs/b.csv"));
+        Assertions.assertEquals(Collections.singleton("logs/unseen.csv"), recovered.keySet());
+        Assertions.assertEquals("{\"files\":[\"logs/current.csv\"]}", provider.getPersistInfo());
+    }
+
+    @Test
+    public void testFreshJobDoesNotCaptureLaterCommitAsRecoverySnapshot() {
+        StreamingInsertJob job = Mockito.mock(StreamingInsertJob.class);
+        provider.replayIfNeed(job);
+        provider.updateOffset(new S3EventOffset(Collections.singletonList("logs/a.csv")));
+        provider.replayIfNeed(job);
+        provider.updateOffset(new S3EventOffset(Collections.singletonList("logs/b.csv")));
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("old", "logs/a.csv")));
+
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+
+        Assertions.assertEquals(Collections.singletonList("logs/a.csv"), nextOffset().getFiles());
+    }
+
+    @Test
+    public void testRestoreKeepsCommittedFilesButNotReceiptHandles() {
+        provider.restoreFromPersistInfo("{\"files\":[\"logs/committed.csv\"]}");
+        StreamingInsertJob job = Mockito.mock(StreamingInsertJob.class);
+        Mockito.when(job.getOffsetProviderPersist()).thenReturn("{\"files\":[\"logs/older.csv\"]}");
+        provider.replayIfNeed(job);
+        Assertions.assertFalse(provider.hasMoreDataToConsume());
+        Assertions.assertEquals("{\"files\":[\"logs/committed.csv\"]}", provider.getPersistInfo());
+        Assertions.assertEquals("{\"fileName\":\"logs/committed.csv\"}", provider.getShowCurrentOffset());
+        Assertions.assertEquals("{\"fileName\":\"logs/committed.csv\"}", provider.getShowMaxOffset());
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("new-handle", "logs/committed.csv"),
+                        filesMessage("next", "logs/next.csv")));
+
+        provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+
+        Assertions.assertEquals(Collections.singletonList("logs/next.csv"), nextOffset().getFiles());
+        ArgumentCaptor<DeleteMessageBatchRequest> deleted = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+        InOrder order = Mockito.inOrder(sqsClient);
+        order.verify(sqsClient).receiveMessage(Mockito.any(ReceiveMessageRequest.class));
+        order.verify(sqsClient).deleteMessageBatch(deleted.capture());
+        Assertions.assertEquals(Collections.singletonList("new-handle"), deleteHandles(deleted.getValue()));
+    }
+
+    @Test
+    public void testSqsUsesIamRoleWithoutStaticKeys() throws Exception {
+        tvfProperties.remove("s3.access_key");
+        tvfProperties.remove("s3.secret_key");
+        tvfProperties.put("s3.role_arn", "arn:aws:iam::123456789012:role/event-reader");
+        tvfProperties.put("s3.external_id", "event-external-id");
+        tvfProperties.put("s3.credentials_provider_type", "instance_profile");
+        StsClient stsClient = Mockito.mock(StsClient.class);
+        StsClientBuilder stsBuilder = Mockito.mock(StsClientBuilder.class, Mockito.RETURNS_SELF);
+        Mockito.when(stsBuilder.build()).thenReturn(stsClient);
+        Mockito.when(stsClient.assumeRole(Mockito.any(AssumeRoleRequest.class)))
+                .thenReturn(AssumeRoleResponse.builder().credentials(Credentials.builder()
+                        .accessKeyId("role-ak").secretAccessKey("role-sk").sessionToken("role-token")
+                        .expiration(Instant.now().plusSeconds(3600)).build()).build());
+        try (MockedStatic<StsClient> stsStatic = Mockito.mockStatic(StsClient.class)) {
+            stsStatic.when(StsClient::builder).thenReturn(stsBuilder);
+            provider = new S3EventSourceOffsetProvider(QUEUE_URL);
+            provider.ensureInitialized(1L, tvfProperties);
+            provider.fetchRemoteMeta(jobProperties(1), tvfProperties);
+
+            ArgumentCaptor<AwsCredentialsProvider> credentials = ArgumentCaptor.forClass(AwsCredentialsProvider.class);
+            Mockito.verify(sqsBuilder).credentialsProvider(
+                    (IdentityProvider<? extends AwsCredentialsIdentity>) credentials.capture());
+            // Keep the real role provider: only STS and SQS network boundaries are mocked.
+            try (StsAssumeRoleCredentialsProvider roleProvider =
+                    (StsAssumeRoleCredentialsProvider) credentials.getValue()) {
+                AwsSessionCredentials resolved = (AwsSessionCredentials) roleProvider.resolveCredentials();
+                Assertions.assertEquals("role-ak", resolved.accessKeyId());
+                Assertions.assertEquals("role-sk", resolved.secretAccessKey());
+                Assertions.assertEquals("role-token", resolved.sessionToken());
+            }
+            ArgumentCaptor<AssumeRoleRequest> assumed = ArgumentCaptor.forClass(AssumeRoleRequest.class);
+            Mockito.verify(stsClient).assumeRole(assumed.capture());
+            Assertions.assertEquals("arn:aws:iam::123456789012:role/event-reader", assumed.getValue().roleArn());
+            Assertions.assertEquals("event-external-id", assumed.getValue().externalId());
+            Mockito.verify(sqsBuilder).region(Region.US_WEST_2);
+            Mockito.verify(sqsClient, Mockito.never()).close();
+        }
+    }
+
+    private void assertSelection(String pattern, String key, boolean expected) throws Exception {
+        provider.close();
+        tvfProperties.put("uri", "s3://bucket/" + pattern);
+        provider = new S3EventSourceOffsetProvider(QUEUE_URL);
+        provider.ensureInitialized(1L, tvfProperties);
+        Mockito.when(sqsClient.receiveMessage(Mockito.any(ReceiveMessageRequest.class)))
+                .thenReturn(response(filesMessage("selection", key)), ReceiveMessageResponse.builder().build());
+
+        provider.fetchRemoteMeta(jobProperties(256), tvfProperties);
+
+        Assertions.assertEquals(expected, provider.hasMoreDataToConsume());
+        if (expected) {
+            S3EventOffset offset = nextOffset();
+            Assertions.assertEquals(Collections.singletonList(key), offset.getFiles());
+            Assertions.assertEquals("s3://bucket/" + key, offset.getFileStatuses().get(0).getPath());
+            Mockito.verify(sqsClient, Mockito.never()).deleteMessageBatch(Mockito.any(DeleteMessageBatchRequest.class));
+        } else {
+            ArgumentCaptor<DeleteMessageBatchRequest> deletes = ArgumentCaptor.forClass(DeleteMessageBatchRequest.class);
+            Mockito.verify(sqsClient).deleteMessageBatch(deletes.capture());
+            Assertions.assertEquals(Collections.singletonList("selection"), deleteHandles(deletes.getValue()));
+        }
+    }
+
+    private StreamingJobProperties jobProperties(int maxFiles) {
+        Map<String, String> properties = new HashMap<>();
+        properties.put("s3.max_batch_files", String.valueOf(maxFiles));
+        return new StreamingJobProperties(properties);
+    }
+
+    private S3EventOffset nextOffset() {
+        return provider.getNextOffset(jobProperties(256), tvfProperties);
+    }
+
+    private static List<String> deleteHandles(DeleteMessageBatchRequest request) {
+        Assertions.assertEquals(QUEUE_URL, request.queueUrl());
+        return request.entries().stream().map(entry -> entry.receiptHandle()).collect(Collectors.toList());
+    }
+
+    private static ReceiveMessageResponse response(Message... messages) {
+        return ReceiveMessageResponse.builder().messages(messages).build();
+    }
+
+    private static Message message(String handle, String... records) {
+        return Message.builder().messageId("id-" + handle).receiptHandle(handle)
+                .body("{\"Records\":[" + String.join(",", records) + "]}").build();
+    }
+
+    private static Message filesMessage(String handle, String... keys) {
+        return message(handle, Arrays.stream(keys).map(key -> record("bucket", key, 1)).toArray(String[]::new));
+    }
+
+    private static String[] longKeys(String prefix, int count) {
+        return IntStream.range(0, count).mapToObj(i -> "logs/" + prefix + i + StringUtils.repeat('x', 900) + ".csv")
+                .toArray(String[]::new);
+    }
+
+    private static String record(String bucket, String encodedKey, long size) {
+        return record(bucket, encodedKey, size, "2026-01-01T00:00:00.000Z");
+    }
+
+    private static String record(String bucket, String encodedKey, long size, String eventTime) {
+        return "{\"eventVersion\":\"2.1\",\"eventSource\":\"aws:s3\",\"awsRegion\":\"us-west-2\","
+                + "\"eventTime\":" + GsonUtils.GSON.toJson(eventTime) + ",\"eventName\":\"ObjectCreated:Put\","
+                + "\"userIdentity\":{\"principalId\":\"test\"},"
+                + "\"requestParameters\":{\"sourceIPAddress\":\"127.0.0.1\"},"
+                + "\"responseElements\":{\"x-amz-request-id\":\"request\",\"x-amz-id-2\":\"host\"},"
+                + "\"s3\":{\"s3SchemaVersion\":\"1.0\",\"configurationId\":\"test\","
+                + "\"bucket\":{\"name\":" + GsonUtils.GSON.toJson(bucket)
+                + ",\"ownerIdentity\":{\"principalId\":\"owner\"},\"arn\":\"arn:aws:s3:::" + bucket + "\"},"
+                + "\"object\":{\"key\":" + GsonUtils.GSON.toJson(encodedKey) + ",\"size\":" + size
+                + ",\"eTag\":\"etag\",\"sequencer\":\"001\"}}}";
+    }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/job/offset/s3/S3SourceOffsetProviderTest.java b/fe/fe-core/src/test/java/org/apache/doris/job/offset/s3/S3SourceOffsetProviderTest.java
index a2f75c9aae7..897537366a5 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/job/offset/s3/S3SourceOffsetProviderTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/job/offset/s3/S3SourceOffsetProviderTest.java
@@ -18,13 +18,17 @@
 package org.apache.doris.job.offset.s3;
 
 import org.apache.doris.backup.Status;
+import org.apache.doris.common.util.S3URI;
+import org.apache.doris.common.util.S3Util;
 import org.apache.doris.datasource.property.storage.StorageProperties;
 import org.apache.doris.fs.FileSystemFactory;
 import org.apache.doris.fs.GlobListResult;
 import org.apache.doris.fs.remote.RemoteFile;
 import org.apache.doris.fs.remote.RemoteFileSystem;
 import org.apache.doris.job.extensions.insert.streaming.StreamingJobProperties;
+import org.apache.doris.thrift.TBrokerFileStatus;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -32,15 +36,21 @@ import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 import org.mockito.stubbing.Answer;
 
+import java.nio.file.FileSystems;
+import java.nio.file.PathMatcher;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 public class S3SourceOffsetProviderTest {
     private static final Map<String, String> TVF_PROPS = ImmutableMap.of(
             "uri", "s3://bucket/data/*.csv", "s3.endpoint", "s3.us-east-1.amazonaws.com",
             "s3.region", "us-east-1", "s3.access_key", "ak", "s3.secret_key", "sk");
     private static final StreamingJobProperties ONCE_PROPS = new StreamingJobProperties(
-            ImmutableMap.of("s3.ingestion_mode", "ONCE", "s3.max_batch_files", "1"));
+            ImmutableMap.of("s3.ingestion_mode", "ONE_TIME", "s3.max_batch_files", "1"));
 
     @Test
     public void testOnceMarksLastBatchAndRecovers() throws Exception {
@@ -68,6 +78,10 @@ public class S3SourceOffsetProviderTest {
             Assertions.assertTrue(provider.hasMoreDataToConsume());
             S3Offset first = provider.getNextOffset(ONCE_PROPS, TVF_PROPS);
             Assertions.assertEquals("data/a.csv", first.getEndFile());
+            Assertions.assertEquals("s3://bucket/data/a.csv", first.getFileStatuses().get(0).getPath());
+            Assertions.assertFalse(first.toSerializedJson().contains("fileStatuses"));
+            Assertions.assertFalse(first.isEmpty());
+            Assertions.assertTrue(provider.deserializeOffset(first.toSerializedJson()).isEmpty());
             Assertions.assertFalse(first.isLastBatch());
             Assertions.assertEquals("data/a.csv", provider.getNextOffset(ONCE_PROPS, TVF_PROPS).getEndFile());
             provider.updateOffset(provider.deserializeOffset(first.toSerializedJson()));
@@ -148,4 +162,55 @@ public class S3SourceOffsetProviderTest {
             return new GlobListResult(Status.OK, maxFile, "bucket", "data/");
         };
     }
+
+    @Test
+    void testOrdinaryFileListIsUnchanged() {
+        Assertions.assertEquals("s3://bucket/prefix/{001,normal.csv}",
+                S3SourceOffsetProvider.buildFileListUri(statuses("prefix/", Arrays.asList("001", "normal.csv"))));
+    }
+
+    @Test
+    void testFileListsAtBucketRootAndAcrossDirectories() {
+        Assertions.assertEquals("s3://bucket/{a.csv,b.csv}",
+                S3SourceOffsetProvider.buildFileListUri(statuses("", ImmutableList.of("a.csv", "b.csv"))));
+        Assertions.assertEquals("s3://bucket/logs/{a/1.csv,b/2.csv}",
+                S3SourceOffsetProvider.buildFileListUri(statuses("logs/", ImmutableList.of("a/1.csv", "b/2.csv"))));
+        Assertions.assertEquals("s3://bucket/logs/a/{1.csv}",
+                S3SourceOffsetProvider.buildFileListUri(statuses("logs/a/", ImmutableList.of("1.csv"))));
+    }
+
+    @Test
+    void testCommaFileDoesNotSelectOtherObjects() throws Exception {
+        assertLiteralFiles("prefix/", Arrays.asList("comma,a.csv", "normal.csv"),
+                Arrays.asList("prefix/comma", "prefix/a.csv", "prefix/comma,a.csv.bak"));
+        assertLiteralFiles("prefix/", Collections.singletonList("comma,a.csv"),
+                Arrays.asList("prefix/comma", "prefix/a.csv"));
+    }
+
+    @Test
+    void testLiteralGlobCharactersInDirectoriesAndFiles() throws Exception {
+        assertLiteralFiles("prefix/[dir],{part}/",
+                Arrays.asList("a[b].csv", "a{b,c}.csv", "a*.csv", "a\\b.csv", "$1.csv", "001"),
+                Arrays.asList("prefix/[dir],{part}/ab.csv", "prefix/[dir],{part}/other.csv",
+                        "prefix/[dir],{part}/1", "prefix/d,part/001"));
+    }
+
+    private static List<TBrokerFileStatus> statuses(String prefix, List<String> files) {
+        return files.stream().map(file -> new TBrokerFileStatus("s3://bucket/" + prefix + file, false, 10, true))
+                .collect(Collectors.toList());
+    }
+
+    private void assertLiteralFiles(String prefix, List<String> files, List<String> otherKeys) throws Exception {
+        // Check the audit URI with the LIST matcher; execution uses the original file statuses.
+        String uri = S3SourceOffsetProvider.buildFileListUri(statuses(prefix, files));
+        String keyPattern = S3URI.create(uri).getKey();
+        PathMatcher matcher = FileSystems.getDefault()
+                .getPathMatcher("glob:" + Paths.get(S3Util.extendGlobs(keyPattern)));
+        for (String file : files) {
+            Assertions.assertTrue(matcher.matches(Paths.get(prefix + file)), "Missing file: " + file);
+        }
+        for (String key : otherKeys) {
+            Assertions.assertFalse(matcher.matches(Paths.get(key)), "Unexpected file: " + key);
+        }
+    }
 }
diff --git a/fe/fe-core/src/test/java/org/apache/doris/metric/MetricsTest.java b/fe/fe-core/src/test/java/org/apache/doris/metric/MetricsTest.java
index 737647e1f84..f7359fa7843 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/metric/MetricsTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/metric/MetricsTest.java
@@ -33,6 +33,7 @@ import org.apache.doris.job.extensions.insert.streaming.StreamingInsertJob;
 import org.apache.doris.job.manager.JobManager;
 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.metric.Metric.MetricUnit;
 import org.apache.doris.monitor.jvm.JvmService;
 import org.apache.doris.monitor.jvm.JvmStats;
@@ -191,6 +192,34 @@ public class MetricsTest {
                     "doris_fe_streaming_job_per_job_last_task_success_time_seconds"
                             + "{job_id=\"1787039821000\", job_name=\"streaming_metric_job\"} 1787039821"));
             Assert.assertFalse(metricResult.contains("doris_fe_streaming_job_per_job_lag{"));
+            String messageLag = "doris_fe_streaming_job_per_job_lag_messages"
+                    + "{job_id=\"1787039821000\", job_name=\"streaming_metric_job\"}";
+            Assert.assertFalse(metricResult.contains(messageLag));
+
+            S3EventSourceOffsetProvider eventProvider = new S3EventSourceOffsetProvider("test-queue");
+            Deencapsulation.setField(job, "offsetProvider", eventProvider);
+            MetricRepo.updateStreamingJobPerJobMetrics();
+            Assert.assertFalse(getPrometheusMetrics().contains(messageLag));
+
+            Deencapsulation.setField(eventProvider, "approximateNumberOfMessages", 12L);
+            Deencapsulation.setField(eventProvider, "lastSourceEventTimestampSeconds", 1787039800L);
+            MetricRepo.updateStreamingJobPerJobMetrics();
+            metricResult = getPrometheusMetrics();
+            Assert.assertTrue(metricResult.contains(messageLag + " 12"));
+            Assert.assertTrue(metricResult.contains(
+                    "doris_fe_streaming_job_per_job_last_source_event_timestamp_seconds"
+                            + "{job_id=\"1787039821000\", job_name=\"streaming_metric_job\"} 1787039800"));
+
+            Deencapsulation.setField(eventProvider, "approximateNumberOfMessages", null);
+            MetricRepo.updateStreamingJobPerJobMetrics();
+            Assert.assertFalse(getPrometheusMetrics().contains(messageLag));
+
+            Deencapsulation.setField(eventProvider, "approximateNumberOfMessages", 0L);
+            MetricRepo.updateStreamingJobPerJobMetrics();
+            Assert.assertTrue(getPrometheusMetrics().contains(messageLag + " 0"));
+            jobMap.remove(job.getJobId());
+            MetricRepo.updateStreamingJobPerJobMetrics();
+            Assert.assertFalse(getPrometheusMetrics().contains(messageLag));
         } finally {
             jobMap.remove(job.getJobId());
             MetricRepo.updateStreamingJobPerJobMetrics();
diff --git a/fe/fe-core/src/test/java/org/apache/doris/tablefunction/S3TableValuedFunctionTest.java b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/S3TableValuedFunctionTest.java
new file mode 100644
index 00000000000..2d79b5a776a
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/S3TableValuedFunctionTest.java
@@ -0,0 +1,147 @@
+// 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.doris.tablefunction;
+
+import org.apache.doris.common.AnalysisException;
+import org.apache.doris.common.FeConstants;
+import org.apache.doris.common.util.S3Util;
+import org.apache.doris.fs.FileSystemFactory;
+import org.apache.doris.nereids.StatementContext;
+import org.apache.doris.nereids.trees.expressions.Properties;
+import org.apache.doris.nereids.trees.expressions.functions.table.S3;
+import org.apache.doris.proto.InternalService.PFetchTableSchemaResult;
+import org.apache.doris.proto.Types.PStatus;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.rpc.BackendServiceProxy;
+import org.apache.doris.system.Backend;
+import org.apache.doris.thrift.TBrokerFileStatus;
+import org.apache.doris.thrift.TStatusCode;
+
+import com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+public class S3TableValuedFunctionTest {
+    private static final Map<String, String> PROPERTIES = ImmutableMap.of(
+            "uri", "s3://bucket/logs/*", "format", "csv", "s3.endpoint", "s3.us-west-2.amazonaws.com",
+            "s3.region", "us-west-2", "s3.access_key", "ak", "s3.secret_key", "sk");
+    private boolean runningUnitTest;
+    private ConnectContext previousContext;
+
+    @BeforeEach
+    public void setUp() {
+        runningUnitTest = FeConstants.runningUnitTest;
+        FeConstants.runningUnitTest = true;
+        previousContext = ConnectContext.get();
+        ConnectContext ctx = new ConnectContext();
+        ctx.setStatementContext(new StatementContext());
+        ctx.setThreadLocalInfo();
+    }
+
+    @AfterEach
+    public void tearDown() {
+        FeConstants.runningUnitTest = runningUnitTest;
+        ConnectContext.remove();
+        if (previousContext != null) {
+            previousContext.setThreadLocalInfo();
+        }
+    }
+
+    @Test
+    public void testExactFilesKeepLiteralPathsAndSkipListing() throws Exception {
+        FeConstants.runningUnitTest = false;
+        TBrokerFileStatus file = file("a,b[1]*?.csv", 123);
+        try (MockedStatic<S3Util> s3 = Mockito.mockStatic(S3Util.class);
+                MockedStatic<FileSystemFactory> filesystems = Mockito.mockStatic(FileSystemFactory.class)) {
+            S3TableValuedFunction tvf = new S3TableValuedFunction(PROPERTIES, Collections.singletonList(file));
+            Assertions.assertEquals(Collections.singletonList(file), tvf.getFileStatuses());
+            s3.verify(() -> S3Util.validateAndTestEndpoint("s3.us-west-2.amazonaws.com"));
+            filesystems.verifyNoInteractions();
+        }
+    }
+
+    @Test
+    public void testSchemaFailureDoesNotSkipFiles() throws Exception {
+        assertSchemaFailure(TStatusCode.NOT_FOUND);
+        assertSchemaFailure(TStatusCode.INTERNAL_ERROR);
+    }
+
+    @Test
+    public void testExactFilesAreScopedToTvf() throws Exception {
+        S3TableValuedFunction first = new S3TableValuedFunction(PROPERTIES,
+                Collections.singletonList(file("a.csv", 10)));
+        S3TableValuedFunction second = new S3TableValuedFunction(PROPERTIES,
+                Collections.singletonList(file("b.csv", 20)));
+        Assertions.assertEquals("s3://bucket/logs/a.csv", first.getFileStatuses().get(0).getPath());
+        Assertions.assertEquals("s3://bucket/logs/b.csv", second.getFileStatuses().get(0).getPath());
+        Assertions.assertTrue(new S3TableValuedFunction(PROPERTIES).getFileStatuses().isEmpty());
+    }
+
+    @Test
+    public void testExactFilesParticipateInExpressionEquality() {
+        S3 first = S3.withFiles(new Properties(PROPERTIES), Collections.singletonList(file("a.csv", 10)));
+        S3 same = S3.withFiles(new Properties(PROPERTIES), Collections.singletonList(file("a.csv", 10)));
+        S3 other = S3.withFiles(new Properties(PROPERTIES), Collections.singletonList(file("b.csv", 10)));
+        Assertions.assertEquals(first, same);
+        Assertions.assertEquals(first.hashCode(), same.hashCode());
+        Assertions.assertNotEquals(first, other);
+        Assertions.assertNotEquals(first, new S3(new Properties(PROPERTIES)));
+    }
+
+    private void assertSchemaFailure(TStatusCode code) throws Exception {
+        S3TableValuedFunction tvf = tvf();
+        BackendServiceProxy proxy = Mockito.mock(BackendServiceProxy.class);
+        Mockito.when(proxy.fetchTableStructureAsync(Mockito.any(), Mockito.any()))
+                .thenReturn(CompletableFuture.completedFuture(result(code)));
+        try (MockedStatic<BackendServiceProxy> service = Mockito.mockStatic(BackendServiceProxy.class)) {
+            service.when(BackendServiceProxy::getInstance).thenReturn(proxy);
+            Assertions.assertThrows(AnalysisException.class, tvf::getTableColumns);
+            Mockito.verify(proxy).fetchTableStructureAsync(Mockito.any(), Mockito.any());
+            Assertions.assertEquals(2, tvf.getFileStatuses().size());
+        }
+    }
+
+    private S3TableValuedFunction tvf() throws Exception {
+        S3TableValuedFunction tvf = Mockito.spy(new S3TableValuedFunction(PROPERTIES,
+                Arrays.asList(file("a.csv", 10), file("b.csv", 10))));
+        Backend backend = Mockito.mock(Backend.class);
+        Mockito.when(backend.getHost()).thenReturn("127.0.0.1");
+        Mockito.when(backend.getBrpcPort()).thenReturn(8060);
+        Mockito.doReturn(backend).when(tvf).getBackend();
+        return tvf;
+    }
+
+    private static TBrokerFileStatus file(String key, long size) {
+        return new TBrokerFileStatus("s3://bucket/logs/" + key, false, size, true);
+    }
+
+    private static PFetchTableSchemaResult result(TStatusCode code) {
+        return PFetchTableSchemaResult.newBuilder()
+                .setStatus(PStatus.newBuilder().setStatusCode(code.getValue()).addErrorMsgs("schema unavailable"))
+                .setColumnNums(0).build();
+    }
+}
diff --git a/regression-test/data/job_p0/streaming_job/test_streaming_insert_job_s3_event_aksk.out b/regression-test/data/job_p0/streaming_job/test_streaming_insert_job_s3_event_aksk.out
new file mode 100644
index 00000000000..429651b0252
--- /dev/null
+++ b/regression-test/data/job_p0/streaming_job/test_streaming_insert_job_s3_event_aksk.out
@@ -0,0 +1,20 @@
+-- This file is automatically generated. You should know what you did if you want to edit this
+-- !first --
+1	first
+
+-- !second --
+1	first
+2	second
+
+-- !final --
+1	first	1
+2	second	1
+3	third	1
+4	fourth	1
+
+-- !event_comma --
+1	first
+2	second
+3	third
+4	fourth
+5	comma
diff --git a/regression-test/data/job_p0/streaming_job/test_streaming_insert_job_s3_event_iam_role.out b/regression-test/data/job_p0/streaming_job/test_streaming_insert_job_s3_event_iam_role.out
new file mode 100644
index 00000000000..5cbddfb9f10
--- /dev/null
+++ b/regression-test/data/job_p0/streaming_job/test_streaming_insert_job_s3_event_iam_role.out
@@ -0,0 +1,7 @@
+-- This file is automatically generated. You should know what you did if you want to edit this
+-- !first --
+1	role_first
+
+-- !final --
+1	role_first	1
+2	role_second	1
diff --git a/regression-test/data/job_p0/streaming_job/test_streaming_insert_job_s3_event_restart_fe.out b/regression-test/data/job_p0/streaming_job/test_streaming_insert_job_s3_event_restart_fe.out
new file mode 100644
index 00000000000..017b53fba42
--- /dev/null
+++ b/regression-test/data/job_p0/streaming_job/test_streaming_insert_job_s3_event_restart_fe.out
@@ -0,0 +1,14 @@
+-- This file is automatically generated. You should know what you did if you want to edit this
+-- !before --
+1	before_restart
+2	before_restart
+
+-- !restored --
+1	before_restart
+2	before_restart
+
+-- !final --
+1	before_restart	1
+2	before_restart	1
+3	after_restart	1
+4	after_restart	1
diff --git a/regression-test/framework/pom.xml b/regression-test/framework/pom.xml
index 51547d11faa..d5cbdc0670c 100644
--- a/regression-test/framework/pom.xml
+++ b/regression-test/framework/pom.xml
@@ -350,6 +350,10 @@ under the License.
         </dependency>-->
         <!---->
         <!-- aws ram -->
+        <dependency>
+            <groupId>software.amazon.awssdk</groupId>
+            <artifactId>sqs</artifactId>
+        </dependency>
         <dependency>
             <groupId>software.amazon.awssdk</groupId>
             <artifactId>iam</artifactId>
diff --git a/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_s3_event_aksk.groovy b/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_s3_event_aksk.groovy
new file mode 100644
index 00000000000..6b519b621a8
--- /dev/null
+++ b/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_s3_event_aksk.groovy
@@ -0,0 +1,217 @@
+// 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.
+
+import com.amazonaws.auth.AWSStaticCredentialsProvider
+import com.amazonaws.auth.BasicAWSCredentials
+import com.amazonaws.client.builder.AwsClientBuilder
+import com.amazonaws.services.s3.AmazonS3ClientBuilder
+import groovy.json.JsonOutput
+import org.awaitility.Awaitility
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
+import software.amazon.awssdk.regions.Region
+import software.amazon.awssdk.services.sqs.SqsClient
+import software.amazon.awssdk.services.sqs.model.GetQueueAttributesRequest
+import software.amazon.awssdk.services.sqs.model.QueueAttributeName
+import software.amazon.awssdk.services.sqs.model.SendMessageRequest
+
+import static java.util.concurrent.TimeUnit.SECONDS
+
+suite("test_streaming_insert_job_s3_event_aksk") {
+    String queueUrl = getConf("s3EventQueueUrl", "")
+    if (!queueUrl) {
+        logger.info("Skip ${name}: s3EventQueueUrl is not configured")
+        return
+    }
+    String eventPrefix = getConf("s3EventPrefix", "")
+    def config = context.config
+    ["awsEndpoint", "awsRegion", "awsBucket", "awsAccessKey", "awsSecretKey"].each { key ->
+        if (!config[key]?.trim()) {
+            throw new IllegalArgumentException("${key} is required for ${name}")
+        }
+    }
+    if (!eventPrefix) {
+        throw new IllegalArgumentException("s3EventPrefix is required for ${name}")
+    }
+    def client = AmazonS3ClientBuilder.standard()
+            .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(config.awsEndpoint, config.awsRegion))
+            .withCredentials(new AWSStaticCredentialsProvider(
+                    new BasicAWSCredentials(config.awsAccessKey, config.awsSecretKey))).build()
+    String bucket = config.awsBucket
+    String prefix = "${eventPrefix.replaceAll('/+$', '')}/${name}/${UUID.randomUUID()}"
+    String jobName = name
+    def sqsClient = SqsClient.builder().region(Region.of(config.awsRegion))
+            .credentialsProvider(StaticCredentialsProvider.create(
+                    AwsBasicCredentials.create(config.awsAccessKey, config.awsSecretKey))).build()
+    def queueCounts = {
+        sqsClient.getQueueAttributes(GetQueueAttributesRequest.builder().queueUrl(queueUrl)
+                .attributeNames(QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES,
+                        QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES_NOT_VISIBLE).build()).attributes()
+    }
+    def awaitQueueEmpty = {
+        // Wait for acknowledgement and eventual consistency before measuring a new backlog.
+        Awaitility.await().pollInSameThread().atMost(180, SECONDS).pollInterval(1, SECONDS).until {
+            def counts = queueCounts()
+            counts[QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES].toLong() == 0 &&
+                    counts[QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES_NOT_VISIBLE].toLong() == 0
+        }
+    }
+    def upload = { String file, String content ->
+        String key = "${prefix}/${file}"
+        client.putObject(bucket, key, content)
+    }
+    def awaitRows = { int rows ->
+        Awaitility.await().pollInSameThread().atMost(300, SECONDS).pollInterval(1, SECONDS).until {
+            def job = sql("SELECT Status, ErrorMsg FROM jobs(\"type\"=\"insert\") WHERE Name='${jobName}'")
+            if (job && job[0][0] == "PAUSED") {
+                throw new IllegalStateException("S3 EVENT job paused: ${job[0][1]}")
+            }
+            sql("SELECT COUNT(*) FROM test_s3_event_aksk")[0][0] as long == rows
+        }
+    }
+    def awaitOffsets = { List<String> files ->
+        Awaitility.await().pollInSameThread().atMost(120, SECONDS).pollInterval(1, SECONDS).until {
+            def rows = sql("SELECT CurrentOffset, EndOffset, LastSourceEventTimestamp FROM jobs(\"type\"=\"insert\") WHERE Name='${jobName}'")
+            if (!rows || !rows[0][0] || !rows[0][1]) {
+                return false
+            }
+            def current = parseJson(rows[0][0])
+            def end = parseJson(rows[0][1])
+            // Queue counts are approximate; check their shape and range, not an exact backlog.
+            files.any { current == [fileName: "${prefix}/${it}".toString()] } &&
+                    end.keySet() == ["fileName", "lagMessages"].toSet() &&
+                    end.fileName == current.fileName && end.lagMessages instanceof Number &&
+                    end.lagMessages >= 0 &&
+                    rows[0][2]?.toString()?.isLong() &&
+                    rows[0][2].toLong() > 0 && rows[0][2].toLong() < 10_000_000_000L
+        }
+    }
+    try {
+        sql "DROP JOB IF EXISTS WHERE jobname='${jobName}'"
+        sql "DROP TABLE IF EXISTS test_s3_event_aksk"
+        sql """CREATE TABLE test_s3_event_aksk (id INT, value STRING)
+               UNIQUE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 1
+               PROPERTIES("replication_num"="1")"""
+
+        // Upload before CREATE JOB so TVF analysis has a real file to inspect.
+        upload("z.csv", "1,first\n")
+        upload("ignored.txt", "999,excluded\n")
+        sql """CREATE JOB ${jobName}
+               PROPERTIES(
+                   "s3.ingestion_mode" = "NOTIFICATION",
+                   "s3.event.source" = "SQS",
+                   "s3.sqs.queue_url" = ${JsonOutput.toJson(queueUrl)},
+                   "s3.max_batch_files" = "1",
+                   "max_interval" = "1"
+               )
+               ON STREAMING DO INSERT INTO test_s3_event_aksk
+               SELECT * FROM S3(
+                   "uri" = ${JsonOutput.toJson("s3://${bucket}/${prefix}/{z,a,b,c,comma*}.csv")},
+                   "format" = "csv",
+                   "provider" = "S3",
+                   "column_separator" = ",",
+                   "s3.endpoint" = ${JsonOutput.toJson(config.awsEndpoint)},
+                   "s3.region" = ${JsonOutput.toJson(config.awsRegion)},
+                   "s3.access_key" = ${JsonOutput.toJson(config.awsAccessKey)},
+                   "s3.secret_key" = ${JsonOutput.toJson(config.awsSecretKey)}
+               )"""
+        awaitRows(1)
+        awaitOffsets(["z.csv"])
+        order_qt_first "SELECT * FROM test_s3_event_aksk"
+
+        // A smaller key uploaded after the first commit must still be discovered in NOTIFICATION mode.
+        upload("a.csv", "2,second\n")
+        awaitRows(2)
+        awaitOffsets(["a.csv"])
+        order_qt_second "SELECT * FROM test_s3_event_aksk"
+
+        awaitQueueEmpty()
+        sql "PAUSE JOB WHERE jobname='${jobName}'"
+        def pausedProgress = sql("SELECT CurrentOffset, LastSourceEventTimestamp FROM jobs(\"type\"=\"insert\") WHERE Name='${jobName}'")[0]
+        upload("b.csv", "3,third\n")
+        upload("c.csv", "4,fourth\n")
+        // Uploaded files must not advance the paused job; RESUME below verifies they are eventually consumed.
+        Awaitility.await().pollInSameThread().atMost(120, SECONDS).pollInterval(1, SECONDS).until {
+            def job = sql("SELECT Status, CurrentOffset, LastSourceEventTimestamp FROM jobs(\"type\"=\"insert\") WHERE Name='${jobName}'")
+            job[0][0] == "PAUSED" && job[0][1] == pausedProgress[0] && job[0][2] == pausedProgress[1] &&
+                    (sql("SELECT COUNT(*) FROM test_s3_event_aksk")[0][0] as long) == 2
+        }
+        sql "RESUME JOB WHERE jobname='${jobName}'"
+        awaitRows(4)
+        // SQS does not guarantee the receive order of these two uploads.
+        awaitOffsets(["b.csv", "c.csv"])
+        order_qt_final "SELECT id, value, COUNT(*) FROM test_s3_event_aksk GROUP BY id, value"
+        awaitQueueEmpty()
+
+        // These decoys do not match the job glob. They must not be read when the comma key is rewritten.
+        upload("comma", "9001,wrong_first\n")
+        upload("other.csv", "9002,wrong_second\n")
+        upload("comma,other.csv", "5,comma\n")
+        awaitRows(5)
+        awaitOffsets(["comma,other.csv"])
+        awaitQueueEmpty()
+        order_qt_event_comma "SELECT * FROM test_s3_event_aksk"
+
+        def committedProgress = sql("SELECT Id, LastSourceEventTimestamp FROM jobs(\"type\"=\"insert\") WHERE Name='${jobName}'")[0]
+        String jobId = committedProgress[0].toString()
+        long committedEventTime = committedProgress[1].toLong()
+        Awaitility.await().pollInSameThread().atMost(120, SECONDS).pollInterval(1, SECONDS).until {
+            boolean metricsReady = false
+            httpTest {
+                endpoint getMasterIp() + ":" + getMasterPort("http")
+                uri "/metrics?type=json"
+                op "get"
+                check { code, body ->
+                    assertEquals(200, code)
+                    def metrics = parseJson(body).findAll {
+                        it.tags?.job_id == jobId && it.tags?.job_name == jobName
+                    }
+                    def lag = metrics.find { it.tags.metric == "doris_fe_streaming_job_per_job_lag_messages" }
+                    def timestamp = metrics.find {
+                        it.tags.metric == "doris_fe_streaming_job_per_job_last_source_event_timestamp_seconds"
+                    }
+                    metricsReady = lag?.value != null && new BigDecimal(lag.value.toString()).signum() >= 0 &&
+                            timestamp?.value != null &&
+                            new BigDecimal(timestamp.value.toString()).compareTo(BigDecimal.valueOf(committedEventTime)) == 0
+                }
+            }
+            metricsReady
+        }
+
+        String committedOffset = sql("SELECT CurrentOffset FROM jobs(\"type\"=\"insert\") WHERE Name='${jobName}'")[0][0]
+        // A message without S3 records is acknowledged without advancing the job.
+        sql "PAUSE JOB WHERE jobname='${jobName}'"
+        sqsClient.sendMessage(SendMessageRequest.builder().queueUrl(queueUrl).messageBody("{}").build())
+        Awaitility.await().pollInSameThread().atMost(120, SECONDS).pollInterval(1, SECONDS).until {
+            queueCounts()[QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES].toLong() > 0
+        }
+        sql "RESUME JOB WHERE jobname='${jobName}'"
+        awaitQueueEmpty()
+        Awaitility.await().pollInSameThread().atMost(120, SECONDS).pollInterval(1, SECONDS).until {
+            def job = sql("SELECT Status, CurrentOffset FROM jobs(\"type\"=\"insert\") WHERE Name='${jobName}'")
+            job[0][0] == "RUNNING" && job[0][1] == committedOffset &&
+                    (sql("SELECT COUNT(*) FROM test_s3_event_aksk")[0][0] as long) == 5
+        }
+    } finally {
+        try {
+            sql "DROP JOB IF EXISTS WHERE jobname='${jobName}'"
+        } finally {
+            sqsClient.close()
+            client.shutdown()
+        }
+    }
+}
diff --git a/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_s3_event_iam_role.groovy b/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_s3_event_iam_role.groovy
new file mode 100644
index 00000000000..8dfb30d0d2b
--- /dev/null
+++ b/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_s3_event_iam_role.groovy
@@ -0,0 +1,128 @@
+// 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.
+
+import com.amazonaws.auth.AWSStaticCredentialsProvider
+import com.amazonaws.auth.BasicAWSCredentials
+import com.amazonaws.client.builder.AwsClientBuilder
+import com.amazonaws.services.s3.AmazonS3ClientBuilder
+import groovy.json.JsonOutput
+import org.awaitility.Awaitility
+
+import static java.util.concurrent.TimeUnit.SECONDS
+
+suite("test_streaming_insert_job_s3_event_iam_role") {
+    String queueUrl = getConf("s3EventIamRoleQueueUrl", "")
+    if (!queueUrl) {
+        logger.info("Skip ${name}: s3EventIamRoleQueueUrl is not configured")
+        return
+    }
+    String eventPrefix = getConf("s3EventIamRolePrefix", "")
+    def config = context.config
+    ["awsEndpoint", "awsRegion", "awsBucket", "awsAccessKey", "awsSecretKey"].each { key ->
+        if (!config[key]?.trim()) {
+            throw new IllegalArgumentException("${key} is required for ${name}")
+        }
+    }
+    if (!eventPrefix) {
+        throw new IllegalArgumentException("s3EventIamRolePrefix is required for ${name}")
+    }
+    if (!config.awsRoleArn?.trim()) {
+        throw new IllegalArgumentException("awsRoleArn is required for ${name}")
+    }
+    // Upload with test AKSK; the Doris TVF uses only IAM Role properties.
+    def client = AmazonS3ClientBuilder.standard()
+            .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(config.awsEndpoint, config.awsRegion))
+            .withCredentials(new AWSStaticCredentialsProvider(
+                    new BasicAWSCredentials(config.awsAccessKey, config.awsSecretKey))).build()
+    String bucket = config.awsBucket
+    String prefix = "${eventPrefix.replaceAll('/+$', '')}/${name}/${UUID.randomUUID()}"
+    String jobName = name
+    def upload = { String file, String content ->
+        String key = "${prefix}/${file}"
+        client.putObject(bucket, key, content)
+    }
+    def awaitRows = { int rows ->
+        Awaitility.await().pollInSameThread().atMost(300, SECONDS).pollInterval(1, SECONDS).until {
+            def job = sql("SELECT Status, ErrorMsg FROM jobs(\"type\"=\"insert\") WHERE Name='${jobName}'")
+            if (job && job[0][0] == "PAUSED") {
+                throw new IllegalStateException("S3 EVENT job paused: ${job[0][1]}")
+            }
+            sql("SELECT COUNT(*) FROM test_s3_event_iam_role")[0][0] as long == rows
+        }
+    }
+    def awaitOffsets = { String file ->
+        Awaitility.await().pollInSameThread().atMost(120, SECONDS).pollInterval(1, SECONDS).until {
+            def rows = sql("SELECT CurrentOffset, EndOffset, LastSourceEventTimestamp FROM jobs(\"type\"=\"insert\") WHERE Name='${jobName}'")
+            if (!rows || !rows[0][0] || !rows[0][1]) {
+                return false
+            }
+            def current = parseJson(rows[0][0])
+            def end = parseJson(rows[0][1])
+            // A numeric backlog also verifies GetQueueAttributes access through the assumed role.
+            current == [fileName: "${prefix}/${file}".toString()] &&
+                    end.keySet() == ["fileName", "lagMessages"].toSet() &&
+                    end.fileName == current.fileName && end.lagMessages instanceof Number &&
+                    end.lagMessages >= 0 &&
+                    rows[0][2]?.toString()?.isLong() &&
+                    rows[0][2].toLong() > 0 && rows[0][2].toLong() < 10_000_000_000L
+        }
+    }
+    try {
+        sql "DROP JOB IF EXISTS WHERE jobname='${jobName}'"
+        sql "DROP TABLE IF EXISTS test_s3_event_iam_role"
+        sql """CREATE TABLE test_s3_event_iam_role (id INT, value STRING)
+               UNIQUE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 1
+               PROPERTIES("replication_num"="1")"""
+        upload("z.csv", "1,role_first\n")
+        sql """CREATE JOB ${jobName}
+               PROPERTIES(
+                   "s3.ingestion_mode" = "NOTIFICATION",
+                   "s3.event.source" = "SQS",
+                   "s3.sqs.queue_url" = ${JsonOutput.toJson(queueUrl)},
+                   "s3.max_batch_files" = "1",
+                   "max_interval" = "1"
+               )
+               ON STREAMING DO INSERT INTO test_s3_event_iam_role
+               SELECT * FROM S3(
+                   "uri" = ${JsonOutput.toJson("s3://${bucket}/${prefix}/*.csv")},
+                   "format" = "csv",
+                   "provider" = "S3",
+                   "column_separator" = ",",
+                   "s3.endpoint" = ${JsonOutput.toJson(config.awsEndpoint)},
+                   "s3.region" = ${JsonOutput.toJson(config.awsRegion)},
+                   "s3.role_arn" = ${JsonOutput.toJson(config.awsRoleArn)}
+                   ${config.awsExternalId ? ', "s3.external_id"=' + JsonOutput.toJson(config.awsExternalId) : ''}
+               )"""
+        awaitRows(1)
+        awaitOffsets("z.csv")
+        order_qt_first "SELECT * FROM test_s3_event_iam_role"
+
+        // The next batch also exercises message acknowledgement with the assumed role.
+        upload("a.csv", "2,role_second\n")
+        awaitRows(2)
+        awaitOffsets("a.csv")
+        order_qt_final "SELECT id, value, COUNT(*) FROM test_s3_event_iam_role GROUP BY id, value"
+        // Let the next fetchMeta acknowledge the final committed SQS message before dropping the job.
+        sleep(5_000)
+    } finally {
+        try {
+            sql "DROP JOB IF EXISTS WHERE jobname='${jobName}'"
+        } finally {
+            client.shutdown()
+        }
+    }
+}
diff --git a/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_s3_event_restart_fe.groovy b/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_s3_event_restart_fe.groovy
new file mode 100644
index 00000000000..b3b412c6258
--- /dev/null
+++ b/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_s3_event_restart_fe.groovy
@@ -0,0 +1,152 @@
+// 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.
+
+import com.amazonaws.auth.AWSStaticCredentialsProvider
+import com.amazonaws.auth.BasicAWSCredentials
+import com.amazonaws.client.builder.AwsClientBuilder
+import com.amazonaws.services.s3.AmazonS3ClientBuilder
+import groovy.json.JsonOutput
+import org.apache.doris.regression.suite.ClusterOptions
+import org.awaitility.Awaitility
+
+import static java.util.concurrent.TimeUnit.SECONDS
+
+suite("test_streaming_insert_job_s3_event_restart_fe", "docker") {
+    String queueUrl = getConf("s3EventQueueUrl", "")
+    if (!queueUrl) {
+        logger.info("Skip ${name}: s3EventQueueUrl is not configured")
+        return
+    }
+    String eventPrefix = getConf("s3EventPrefix", "")
+    def config = context.config
+    ["awsEndpoint", "awsRegion", "awsBucket", "awsAccessKey", "awsSecretKey"].each { key ->
+        if (!config[key]?.trim()) {
+            throw new IllegalArgumentException("${key} is required for ${name}")
+        }
+    }
+    if (!eventPrefix) {
+        throw new IllegalArgumentException("s3EventPrefix is required for ${name}")
+    }
+    def options = new ClusterOptions()
+    options.setFeNum(1)
+    options.setBeNum(1)
+    options.cloudMode = null
+    // Run separately from the ordinary AKSK case because both use the AKSK queue.
+    docker(options) {
+        def client = AmazonS3ClientBuilder.standard()
+                .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(config.awsEndpoint, config.awsRegion))
+                .withCredentials(new AWSStaticCredentialsProvider(
+                        new BasicAWSCredentials(config.awsAccessKey, config.awsSecretKey))).build()
+        String bucket = config.awsBucket
+        String prefix = "${eventPrefix.replaceAll('/+$', '')}/${name}/${UUID.randomUUID()}"
+        String jobName = name
+        def upload = { String file, String content ->
+            String key = "${prefix}/${file}"
+            client.putObject(bucket, key, content)
+        }
+        def awaitRows = { int rows ->
+            Awaitility.await().pollInSameThread().atMost(300, SECONDS).pollInterval(1, SECONDS).until {
+                def job = sql("SELECT Status, ErrorMsg FROM jobs(\"type\"=\"insert\") WHERE Name='${jobName}'")
+                if (job && job[0][0] == "PAUSED") {
+                    throw new IllegalStateException("S3 EVENT job paused: ${job[0][1]}")
+                }
+                sql("SELECT COUNT(*) FROM test_s3_event_restart_fe")[0][0] as long == rows
+            }
+        }
+        def awaitOffsets = { String file, boolean expectTimestamp = true ->
+            Awaitility.await().pollInSameThread().atMost(120, SECONDS).pollInterval(1, SECONDS).until {
+                def rows = sql("SELECT CurrentOffset, EndOffset, LastSourceEventTimestamp FROM jobs(\"type\"=\"insert\") WHERE Name='${jobName}'")
+                if (!rows || !rows[0][0] || !rows[0][1]) {
+                    return false
+                }
+                def current = parseJson(rows[0][0])
+                def end = parseJson(rows[0][1])
+                // After restart, the file is restored and the queue statistic must be fetched again.
+                current == [fileName: "${prefix}/${file}".toString()] &&
+                        end.keySet() == ["fileName", "lagMessages"].toSet() &&
+                        end.fileName == current.fileName && end.lagMessages instanceof Number &&
+                        end.lagMessages >= 0 &&
+                        (expectTimestamp ? (rows[0][2]?.toString()?.isLong() &&
+                                rows[0][2].toLong() > 0 && rows[0][2].toLong() < 10_000_000_000L) : !rows[0][2])
+            }
+        }
+        try {
+            sql "DROP JOB IF EXISTS WHERE jobname='${jobName}'"
+            sql "DROP TABLE IF EXISTS test_s3_event_restart_fe"
+            sql """CREATE TABLE test_s3_event_restart_fe (id INT, value STRING)
+                   UNIQUE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 1
+                   PROPERTIES("replication_num"="1")"""
+            upload("z.csv", "1,before_restart\n")
+            sql """CREATE JOB ${jobName}
+                   PROPERTIES(
+                       "s3.ingestion_mode" = "NOTIFICATION",
+                       "s3.event.source" = "SQS",
+                       "s3.sqs.queue_url" = ${JsonOutput.toJson(queueUrl)},
+                       "s3.max_batch_files" = "1",
+                       "max_interval" = "1"
+                   )
+                   ON STREAMING DO INSERT INTO test_s3_event_restart_fe
+                   SELECT * FROM S3(
+                       "uri" = ${JsonOutput.toJson("s3://${bucket}/${prefix}/*.csv")},
+                       "format" = "csv",
+                       "provider" = "S3",
+                       "column_separator" = ",",
+                       "s3.endpoint" = ${JsonOutput.toJson(config.awsEndpoint)},
+                       "s3.region" = ${JsonOutput.toJson(config.awsRegion)},
+                       "s3.access_key" = ${JsonOutput.toJson(config.awsAccessKey)},
+                       "s3.secret_key" = ${JsonOutput.toJson(config.awsSecretKey)}
+                   )"""
+            awaitRows(1)
+            awaitOffsets("z.csv")
+            // A second committed task ensures the first batch's acknowledgement has completed.
+            upload("y.csv", "2,before_restart\n")
+            awaitRows(2)
+            awaitOffsets("y.csv")
+            def offsetSql = "SELECT CurrentOffset FROM jobs(\"type\"=\"insert\") WHERE Name='${jobName}'"
+            String committedOffset = sql(offsetSql)[0][0]
+            order_qt_before "SELECT * FROM test_s3_event_restart_fe"
+
+            cluster.restartFrontends()
+            Awaitility.await().pollInSameThread().atMost(120, SECONDS).pollInterval(1, SECONDS).ignoreExceptions().until {
+                context.reconnectFe()
+                sql("SELECT 1")[0][0] == 1
+            }
+            Awaitility.await().pollInSameThread().atMost(120, SECONDS).pollInterval(1, SECONDS).until {
+                def rows = sql(offsetSql)
+                rows && rows[0][0] == committedOffset
+            }
+            awaitOffsets("y.csv", false)
+            order_qt_restored "SELECT * FROM test_s3_event_restart_fe"
+
+            upload("a.csv", "3,after_restart\n")
+            awaitRows(3)
+            awaitOffsets("a.csv")
+            upload("b.csv", "4,after_restart\n")
+            awaitRows(4)
+            awaitOffsets("b.csv")
+            order_qt_final "SELECT id, value, COUNT(*) FROM test_s3_event_restart_fe GROUP BY id, value"
+            // Let the next fetchMeta acknowledge the final committed SQS message before dropping the job.
+            sleep(5_000)
+        } finally {
+            try {
+                sql "DROP JOB IF EXISTS WHERE jobname='${jobName}'"
+            } finally {
+                client.shutdown()
+            }
+        }
+    }
+}
diff --git a/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_s3_once.groovy b/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_s3_once.groovy
index 799312f3de9..9699259cee9 100644
--- a/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_s3_once.groovy
+++ b/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_s3_once.groovy
@@ -38,7 +38,7 @@ suite("test_streaming_insert_job_s3_once") {
         sql """
             CREATE JOB test_streaming_insert_job_s3_once
             PROPERTIES (
-                "s3.ingestion_mode" = "ONCE",
+                "s3.ingestion_mode" = "ONE_TIME",
                 "offset" = '{"fileName":"regression/load/data/example_0.csv"}'
             )
             ON STREAMING DO INSERT INTO test_streaming_insert_job_s3_once_tbl
@@ -53,13 +53,13 @@ suite("test_streaming_insert_job_s3_once") {
                 "s3.secret_key" = "${getS3SK()}"
             )
         """
-        exception "offset is not supported when s3.ingestion_mode is ONCE"
+        exception "offset is not supported when s3.ingestion_mode is ONE_TIME"
     }
 
     sql """
         CREATE JOB test_streaming_insert_job_s3_once
         PROPERTIES (
-            "s3.ingestion_mode" = "ONCE",
+            "s3.ingestion_mode" = "ONE_TIME",
             "s3.max_batch_files" = "1"
         )
         ON STREAMING DO INSERT INTO test_streaming_insert_job_s3_once_tbl


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@doris.apache.org
For additional commands, e-mail: commits-help@doris.apache.org