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

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


The following commit(s) were added to refs/heads/master by this push:
     new 3989a231 [Improve](source) Restore incremental startup from external 
offsets (#708)
3989a231 is described below

commit 3989a231e6a7ad1ec4af4552da2bed10eaa07c3f
Author: wudi <[email protected]>
AuthorDate: Tue Oct 13 10:15:15 2026 +0800

    [Improve](source) Restore incremental startup from external offsets (#708)
    
    Problem Summary
    A new incremental source job currently ignores offsets previously published 
to source.binlog.offset-table: initial repeats its snapshot and from-timestamp 
reuses the configured start time.
    
    Read the configured consumer's saved boundary once during new Source 
initialization and start directly in the incremental phase when a valid offset 
exists. Restored Flink Source state remains authoritative and does not query 
the external table. Without a saved record, initial performs its first 
snapshot, from-timestamp uses its configured time, and latest starts from the 
current Doris timestamp. Connection, permission, duplicate-record, and 
invalid-timestamp failures are propagated.
    
    This change reuses the existing offset configuration and publication path. 
It does not change tablet scheduling, Reader/Enumerator coordination, or 
checkpoint formats.
    
    The S3 TVF integration fixture also uses the Doris Docker Hub MinIO mirror 
to resolve the image-pull authorization failure in CI.
---
 .../source/assigners/DorisSourceSplitAssigner.java |  57 ++++-
 .../flink/source/reader/DorisOffsetPublisher.java  |  56 ++++-
 .../doris/flink/table/DorisConfigOptions.java      |   6 +-
 .../assigners/DorisSourceOffsetRestoreTest.java    | 254 +++++++++++++++++++++
 .../apache/doris/flink/sink/S3TvfSinkITCase.java   |   3 +-
 .../source/AbstractDorisIncrementalITCase.java     |   2 +-
 .../flink/source/DorisIncrementalSourceITCase.java |  59 +++++
 7 files changed, 422 insertions(+), 15 deletions(-)

diff --git 
a/flink-doris-connector/flink-doris-connector-base/src/main/java/org/apache/doris/flink/source/assigners/DorisSourceSplitAssigner.java
 
b/flink-doris-connector/flink-doris-connector-base/src/main/java/org/apache/doris/flink/source/assigners/DorisSourceSplitAssigner.java
index 587f3b71..5fbc594f 100644
--- 
a/flink-doris-connector/flink-doris-connector-base/src/main/java/org/apache/doris/flink/source/assigners/DorisSourceSplitAssigner.java
+++ 
b/flink-doris-connector/flink-doris-connector-base/src/main/java/org/apache/doris/flink/source/assigners/DorisSourceSplitAssigner.java
@@ -21,10 +21,12 @@ import org.apache.flink.annotation.VisibleForTesting;
 
 import org.apache.doris.flink.cfg.DorisOptions;
 import org.apache.doris.flink.cfg.DorisReadOptions;
+import org.apache.doris.flink.connection.SimpleJdbcConnectionProvider;
 import org.apache.doris.flink.exception.DorisRuntimeException;
 import org.apache.doris.flink.rest.RestService;
 import org.apache.doris.flink.source.DorisSourceScanMode;
 import org.apache.doris.flink.source.enumerator.DorisSourceCheckpoint;
+import org.apache.doris.flink.source.reader.DorisOffsetPublisher;
 import org.apache.doris.flink.source.split.DorisSnapshotSplit;
 import org.apache.doris.flink.source.split.DorisSourceSplit;
 import org.apache.doris.flink.source.split.DorisStreamSplit;
@@ -53,14 +55,40 @@ public class DorisSourceSplitAssigner {
 
     public static DorisSourceSplitAssigner create(
             DorisOptions options, DorisReadOptions readOptions, int 
sourceParallelism) {
+        String externalOffset = readExternalOffset(options, 
readOptions).orElse(null);
         Supplier<String> currentTimestampSupplier =
                 () -> RestService.resolveCurrentTimestamp(options, 
readOptions, LOG);
         DorisSnapshotSplitAssigner snapshotSplitAssigner =
-                readOptions.getScanMode().isSnapshotPhaseRequired()
+                externalOffset == null && 
readOptions.getScanMode().isSnapshotPhaseRequired()
                         ? new DorisSnapshotSplitAssigner(options, readOptions)
                         : new 
DorisSnapshotSplitAssigner(Collections.emptyList());
         return create(
-                readOptions, sourceParallelism, currentTimestampSupplier, 
snapshotSplitAssigner);
+                readOptions,
+                sourceParallelism,
+                currentTimestampSupplier,
+                snapshotSplitAssigner,
+                externalOffset);
+    }
+
+    private static Optional<String> readExternalOffset(
+            DorisOptions options, DorisReadOptions readOptions) {
+        if (!readOptions.getScanMode().hasIncrementalPhase()
+                || readOptions.getBinlogOffsetTable() == null) {
+            return Optional.empty();
+        }
+        try (DorisOffsetPublisher publisher =
+                new DorisOffsetPublisher(
+                        new SimpleJdbcConnectionProvider(options),
+                        readOptions.getBinlogOffsetTable(),
+                        readOptions.getBinlogConsumerId())) {
+            Optional<String> offset = publisher.readOffset();
+            LOG.info(
+                    "Read external offset {} from {} for consumer {}.",
+                    offset.orElse("<not found>"),
+                    readOptions.getBinlogOffsetTable(),
+                    readOptions.getBinlogConsumerId());
+            return offset;
+        }
     }
 
     public static DorisSourceSplitAssigner restore(
@@ -86,7 +114,11 @@ public class DorisSourceSplitAssigner {
                         ? new DorisSnapshotSplitAssigner(snapshotSplits)
                         : new 
DorisSnapshotSplitAssigner(Collections.emptyList());
         return create(
-                readOptions, sourceParallelism, currentTimestampSupplier, 
snapshotSplitAssigner);
+                readOptions,
+                sourceParallelism,
+                currentTimestampSupplier,
+                snapshotSplitAssigner,
+                null);
     }
 
     @VisibleForTesting
@@ -102,29 +134,34 @@ public class DorisSourceSplitAssigner {
             DorisReadOptions readOptions,
             int sourceParallelism,
             Supplier<String> currentTimestampSupplier,
-            DorisSnapshotSplitAssigner snapshotSplitAssigner) {
+            DorisSnapshotSplitAssigner snapshotSplitAssigner,
+            @Nullable String externalOffset) {
         DorisSourceScanMode scanMode = readOptions.getScanMode();
         DorisStreamSplitAssigner streamAssigner = null;
         if (scanMode.hasIncrementalPhase()) {
-            String startTimestamp =
-                    scanMode == DorisSourceScanMode.FROM_TIMESTAMP
-                            ? readOptions.getScanTimestamp()
-                            : currentTimestampSupplier.get();
+            String startTimestamp = externalOffset;
+            if (startTimestamp == null) {
+                startTimestamp =
+                        scanMode == DorisSourceScanMode.FROM_TIMESTAMP
+                                ? readOptions.getScanTimestamp()
+                                : currentTimestampSupplier.get();
+            }
             streamAssigner =
                     new DorisStreamSplitAssigner(
                             startTimestamp, Collections.emptyList(), 
currentTimestampSupplier);
         }
         DorisSourceCheckpoint.Phase phase =
-                scanMode.isSnapshotPhaseRequired()
+                externalOffset == null && scanMode.isSnapshotPhaseRequired()
                         ? DorisSourceCheckpoint.Phase.SNAPSHOT
                         : DorisSourceCheckpoint.Phase.STREAM;
         DorisSourceSplitAssigner assigner =
                 new DorisSourceSplitAssigner(
                         scanMode, sourceParallelism, phase, 
snapshotSplitAssigner, streamAssigner);
         LOG.info(
-                "Initialized Doris source assigner in {} mode at phase {} with 
{} pending snapshot splits.",
+                "Initialized Doris source assigner in {} mode at phase {} with 
stream start timestamp {} and {} pending snapshot splits.",
                 scanMode,
                 phase,
+                assigner.nextStreamStartTimestamp(),
                 snapshotSplitAssigner.remainingSplits().size());
         return assigner;
     }
diff --git 
a/flink-doris-connector/flink-doris-connector-base/src/main/java/org/apache/doris/flink/source/reader/DorisOffsetPublisher.java
 
b/flink-doris-connector/flink-doris-connector-base/src/main/java/org/apache/doris/flink/source/reader/DorisOffsetPublisher.java
index 76d6f351..38018db7 100644
--- 
a/flink-doris-connector/flink-doris-connector-base/src/main/java/org/apache/doris/flink/source/reader/DorisOffsetPublisher.java
+++ 
b/flink-doris-connector/flink-doris-connector-base/src/main/java/org/apache/doris/flink/source/reader/DorisOffsetPublisher.java
@@ -18,14 +18,20 @@ package org.apache.doris.flink.source.reader;
 
 import org.apache.doris.flink.connection.JdbcConnectionProvider;
 import org.apache.doris.flink.exception.DorisRuntimeException;
+import org.apache.doris.flink.source.split.DorisStreamSplit;
 
 import java.sql.Connection;
 import java.sql.DatabaseMetaData;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.format.ResolverStyle;
+import java.util.Optional;
 import java.util.function.Consumer;
 
-/** Publishes completed-checkpoint offsets through Doris JDBC. */
+/** Reads startup offsets and publishes completed-checkpoint offsets through 
Doris JDBC. */
 public class DorisOffsetPublisher implements AutoCloseable {
     private final JdbcConnectionProvider connectionProvider;
     private final String database;
@@ -70,6 +76,54 @@ public class DorisOffsetPublisher implements AutoCloseable {
         throw new DorisRuntimeException("Offset table does not exist: " + 
database + "." + table);
     }
 
+    /** Returns empty only when this consumer has no saved offset. */
+    public Optional<String> readOffset() {
+        String selectSql =
+                "SELECT `offset_timestamp` FROM "
+                        + quoteIdentifier(database)
+                        + "."
+                        + quoteIdentifier(table)
+                        + " WHERE `consumer_id` = ?";
+        try (PreparedStatement statement =
+                
connectionProvider.getOrEstablishConnection().prepareStatement(selectSql)) {
+            statement.setString(1, consumerId);
+            try (ResultSet result = statement.executeQuery()) {
+                if (!result.next()) {
+                    return Optional.empty();
+                }
+                String offset = result.getString("offset_timestamp");
+                if (result.next()) {
+                    throw new DorisRuntimeException("Multiple offsets for 
consumer " + consumerId);
+                }
+                String invalidOffset =
+                        "Invalid offset timestamp for consumer " + consumerId 
+ ": " + offset;
+                if (!DorisStreamSplit.isValidTimestamp(offset)) {
+                    throw new DorisRuntimeException(invalidOffset);
+                }
+                try {
+                    LocalDateTime.parse(
+                            offset,
+                            DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss")
+                                    .withResolverStyle(ResolverStyle.STRICT));
+                } catch (DateTimeParseException error) {
+                    throw new DorisRuntimeException(invalidOffset, error);
+                }
+                return Optional.of(offset);
+            }
+        } catch (DorisRuntimeException error) {
+            throw error;
+        } catch (Exception error) {
+            throw new DorisRuntimeException(
+                    "Failed to read offset from "
+                            + database
+                            + "."
+                            + table
+                            + " for consumer "
+                            + consumerId,
+                    error);
+        }
+    }
+
     /** Invokes the callback with null on success or the publication error on 
failure. */
     public void publish(String offset, Consumer<Exception> callback) {
         Exception failure = null;
diff --git 
a/flink-doris-connector/flink-doris-connector-base/src/main/java/org/apache/doris/flink/table/DorisConfigOptions.java
 
b/flink-doris-connector/flink-doris-connector-base/src/main/java/org/apache/doris/flink/table/DorisConfigOptions.java
index 3b159679..3cd66764 100644
--- 
a/flink-doris-connector/flink-doris-connector-base/src/main/java/org/apache/doris/flink/table/DorisConfigOptions.java
+++ 
b/flink-doris-connector/flink-doris-connector-base/src/main/java/org/apache/doris/flink/table/DorisConfigOptions.java
@@ -419,7 +419,8 @@ public class DorisConfigOptions {
             ConfigOptions.key("source.scan.timestamp")
                     .stringType()
                     .noDefaultValue()
-                    .withDescription("Inclusive start timestamp for 
from-timestamp mode");
+                    .withDescription(
+                            "Inclusive first-start timestamp for 
from-timestamp mode; a saved external offset takes precedence");
     public static final ConfigOption<String> SOURCE_BINLOG_INCREMENT_TYPE =
             ConfigOptions.key("source.binlog.increment-type")
                     .stringType()
@@ -443,7 +444,8 @@ public class DorisConfigOptions {
             ConfigOptions.key("source.binlog.offset-table")
                     .stringType()
                     .noDefaultValue()
-                    .withDescription("Doris table used to persist completed 
checkpoint offsets");
+                    .withDescription(
+                            "Doris table used to persist completed checkpoint 
offsets and resume new jobs without restored Source state");
     public static final ConfigOption<String> SOURCE_BINLOG_CONSUMER_ID =
             ConfigOptions.key("source.binlog.consumer-id")
                     .stringType()
diff --git 
a/flink-doris-connector/flink-doris-connector-base/src/test/java/org/apache/doris/flink/source/assigners/DorisSourceOffsetRestoreTest.java
 
b/flink-doris-connector/flink-doris-connector-base/src/test/java/org/apache/doris/flink/source/assigners/DorisSourceOffsetRestoreTest.java
new file mode 100644
index 00000000..dcf39914
--- /dev/null
+++ 
b/flink-doris-connector/flink-doris-connector-base/src/test/java/org/apache/doris/flink/source/assigners/DorisSourceOffsetRestoreTest.java
@@ -0,0 +1,254 @@
+// 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.flink.source.assigners;
+
+import org.apache.doris.flink.cfg.DorisOptions;
+import org.apache.doris.flink.cfg.DorisReadOptions;
+import org.apache.doris.flink.connection.SimpleJdbcConnectionProvider;
+import org.apache.doris.flink.rest.RestService;
+import org.apache.doris.flink.source.DorisSourceScanMode;
+import org.apache.doris.flink.source.enumerator.DorisSourceCheckpoint;
+import org.apache.doris.flink.source.split.DorisStreamSplit;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class DorisSourceOffsetRestoreTest {
+    private static final String SAVED = "2026-10-09 12:00:00";
+    private static final String CONFIGURED = "2026-10-09 10:00:00";
+    private static final String NOW = "2026-10-09 13:00:00";
+    private final DorisOptions options =
+            DorisOptions.builder()
+                    .setFenodes("localhost:8030")
+                    .setTableIdentifier("db.t")
+                    .setJdbcUrl("jdbc:mysql://localhost:9030")
+                    .build();
+    private final Connection connection = mock(Connection.class);
+    private final PreparedStatement statement = mock(PreparedStatement.class);
+    private final ResultSet result = mock(ResultSet.class);
+
+    @BeforeEach
+    void setUp() throws Exception {
+        when(connection.prepareStatement(any())).thenReturn(statement);
+        when(statement.executeQuery()).thenReturn(result);
+        when(result.next()).thenReturn(true, false);
+        when(result.getString("offset_timestamp")).thenReturn(SAVED);
+    }
+
+    @ParameterizedTest
+    @EnumSource(
+            value = DorisSourceScanMode.class,
+            names = {"INITIAL", "LATEST", "FROM_TIMESTAMP"})
+    void resumesEveryIncrementalModeFromSavedBoundary(DorisSourceScanMode 
mode) throws Exception {
+        DorisReadOptions readOptions = readOptions(mode);
+        try (MockedConstruction<SimpleJdbcConnectionProvider> providers = 
providers();
+                MockedStatic<RestService> rest = rest(readOptions)) {
+            DorisSourceSplitAssigner assigner =
+                    DorisSourceSplitAssigner.create(options, readOptions, 2);
+            
assertThat(assigner.getPhase()).isEqualTo(DorisSourceCheckpoint.Phase.STREAM);
+            assertThat(assigner.hasPendingSnapshotSplits()).isFalse();
+            assertThat(assigner.getScanMode()).isEqualTo(mode);
+            
assertThat(assigner.snapshotState(0L).getNextStreamStartTimestamp()).isEqualTo(SAVED);
+            
assigner.onDiscoveredEndTimestamp(assigner.discoverNextStreamEndTimestamp());
+            DorisStreamSplit window = (DorisStreamSplit) 
assigner.getNext().get();
+            assertThat(window.getStartTimestamp()).isEqualTo(SAVED);
+            assertThat(window.getEndTimestamp()).isEqualTo(NOW);
+            rest.verify(
+                    () -> RestService.findPartitions(eq(options), 
eq(readOptions), any()), never());
+            verify(connection)
+                    .prepareStatement(
+                            "SELECT `offset_timestamp` FROM `ops`.`offsets` 
WHERE `consumer_id` = ?");
+            verify(statement).setString(1, "orders-consumer");
+            verify(result).close();
+            verify(statement).close();
+            verify(providers.constructed().get(0)).closeConnection();
+        }
+    }
+
+    @Test
+    void checkpointRestorationDoesNotOpenOffsetConnection() {
+        DorisReadOptions readOptions = 
readOptions(DorisSourceScanMode.INITIAL);
+        DorisSourceCheckpoint checkpoint =
+                new DorisSourceCheckpoint(
+                        DorisSourceCheckpoint.Phase.STREAM, SAVED, 2, 
Collections.emptyList());
+        try (MockedConstruction<SimpleJdbcConnectionProvider> providers = 
providers()) {
+            DorisSourceSplitAssigner assigner =
+                    DorisSourceSplitAssigner.restore(options, readOptions, 
checkpoint, 2);
+            
assertThat(assigner.snapshotState(0L).getNextStreamStartTimestamp()).isEqualTo(SAVED);
+            
assertThat(assigner.getPhase()).isEqualTo(DorisSourceCheckpoint.Phase.STREAM);
+            assertThat(providers.constructed()).isEmpty();
+        }
+    }
+
+    @Test
+    void initialWithoutRecordPlansFirstSnapshot() throws Exception {
+        when(result.next()).thenReturn(false);
+        DorisReadOptions readOptions = 
readOptions(DorisSourceScanMode.INITIAL);
+        try (MockedConstruction<SimpleJdbcConnectionProvider> ignored = 
providers();
+                MockedStatic<RestService> rest = rest(readOptions)) {
+            DorisSourceSplitAssigner assigner =
+                    DorisSourceSplitAssigner.create(options, readOptions, 2);
+            
assertThat(assigner.getPhase()).isEqualTo(DorisSourceCheckpoint.Phase.SNAPSHOT);
+            
assertThat(assigner.snapshotState(0L).getNextStreamStartTimestamp()).isEqualTo(NOW);
+            rest.verify(() -> RestService.findPartitions(eq(options), 
eq(readOptions), any()));
+        }
+    }
+
+    @Test
+    void fromTimestampWithoutRecordUsesConfiguredStart() throws Exception {
+        when(result.next()).thenReturn(false);
+        DorisReadOptions readOptions = 
readOptions(DorisSourceScanMode.FROM_TIMESTAMP);
+        try (MockedConstruction<SimpleJdbcConnectionProvider> ignored = 
providers()) {
+            assertThat(
+                            DorisSourceSplitAssigner.create(options, 
readOptions, 2)
+                                    .snapshotState(0L)
+                                    .getNextStreamStartTimestamp())
+                    .isEqualTo(CONFIGURED);
+        }
+    }
+
+    @Test
+    void latestWithoutRecordStartsFromCurrentTime() throws Exception {
+        when(result.next()).thenReturn(false);
+        DorisReadOptions readOptions = readOptions(DorisSourceScanMode.LATEST);
+        try (MockedConstruction<SimpleJdbcConnectionProvider> ignored = 
providers();
+                MockedStatic<RestService> rest = rest(readOptions)) {
+            DorisSourceSplitAssigner assigner =
+                    DorisSourceSplitAssigner.create(options, readOptions, 2);
+            
assertThat(assigner.getPhase()).isEqualTo(DorisSourceCheckpoint.Phase.STREAM);
+            assertThat(assigner.hasPendingSnapshotSplits()).isFalse();
+            
assertThat(assigner.snapshotState(0L).getNextStreamStartTimestamp()).isEqualTo(NOW);
+            rest.verify(
+                    () -> RestService.findPartitions(eq(options), 
eq(readOptions), any()), never());
+        }
+    }
+
+    @Test
+    void latestWithoutOffsetTableKeepsCurrentTimeStart() {
+        DorisReadOptions readOptions =
+                
DorisReadOptions.builder().setScanMode(DorisSourceScanMode.LATEST).build();
+        try (MockedConstruction<SimpleJdbcConnectionProvider> providers = 
providers();
+                MockedStatic<RestService> ignored = rest(readOptions)) {
+            assertThat(
+                            DorisSourceSplitAssigner.create(options, 
readOptions, 2)
+                                    .snapshotState(0L)
+                                    .getNextStreamStartTimestamp())
+                    .isEqualTo(NOW);
+            assertThat(providers.constructed()).isEmpty();
+        }
+    }
+
+    @Test
+    void duplicateOffsetsFailInsteadOfChoosingOne() throws Exception {
+        when(result.next()).thenReturn(true, true);
+        assertStartupFails(DorisSourceScanMode.INITIAL, "Multiple offsets");
+    }
+
+    @ParameterizedTest
+    @NullAndEmptySource
+    @ValueSource(
+            strings = {
+                "2026-02-30 12:00:00",
+                "2026-10-09 25:00:00",
+                "2026-10-09 12:00:00.123",
+                "not-a-timestamp"
+            })
+    void invalidSavedOffsetsFailInsteadOfStartingSnapshot(String value) throws 
Exception {
+        when(result.getString("offset_timestamp")).thenReturn(value);
+        assertStartupFails(DorisSourceScanMode.INITIAL, "Invalid offset 
timestamp");
+    }
+
+    @Test
+    void queryFailureDoesNotFallBackToConfiguredTimestamp() throws Exception {
+        when(statement.executeQuery()).thenThrow(new SQLException("SELECT 
denied"));
+        assertStartupFails(DorisSourceScanMode.FROM_TIMESTAMP, "Failed to read 
offset");
+    }
+
+    @Test
+    void connectionFailureDoesNotFallBackToSnapshot() throws Exception {
+        DorisReadOptions readOptions = 
readOptions(DorisSourceScanMode.INITIAL);
+        try (MockedConstruction<SimpleJdbcConnectionProvider> ignored =
+                        mockConstruction(
+                                SimpleJdbcConnectionProvider.class,
+                                (provider, context) ->
+                                        
when(provider.getOrEstablishConnection())
+                                                .thenThrow(new 
SQLException("unavailable")));
+                MockedStatic<RestService> rest = rest(readOptions)) {
+            assertThatThrownBy(() -> DorisSourceSplitAssigner.create(options, 
readOptions, 2))
+                    .hasMessageContaining("Failed to read offset");
+        }
+    }
+
+    private void assertStartupFails(DorisSourceScanMode mode, String message) {
+        DorisReadOptions readOptions = readOptions(mode);
+        try (MockedConstruction<SimpleJdbcConnectionProvider> ignored = 
providers();
+                MockedStatic<RestService> rest = rest(readOptions)) {
+            assertThatThrownBy(() -> DorisSourceSplitAssigner.create(options, 
readOptions, 2))
+                    .hasMessageContaining(message);
+        }
+    }
+
+    private MockedConstruction<SimpleJdbcConnectionProvider> providers() {
+        return mockConstruction(
+                SimpleJdbcConnectionProvider.class,
+                (provider, context) ->
+                        
when(provider.getOrEstablishConnection()).thenReturn(connection));
+    }
+
+    private MockedStatic<RestService> rest(DorisReadOptions readOptions) {
+        MockedStatic<RestService> rest = mockStatic(RestService.class);
+        rest.when(() -> RestService.resolveCurrentTimestamp(eq(options), 
eq(readOptions), any()))
+                .thenReturn(NOW);
+        rest.when(() -> RestService.parseIdentifier(eq("db.t"), any()))
+                .thenReturn(new String[] {"db", "t"});
+        rest.when(() -> RestService.findPartitions(eq(options), 
eq(readOptions), any()))
+                .thenReturn(Collections.emptyList());
+        return rest;
+    }
+
+    private DorisReadOptions readOptions(DorisSourceScanMode mode) {
+        return DorisReadOptions.builder()
+                .setScanMode(mode)
+                .setScanTimestamp(mode == DorisSourceScanMode.FROM_TIMESTAMP ? 
CONFIGURED : null)
+                .setBinlogOffsetTable("ops.offsets")
+                .setBinlogConsumerId("orders-consumer")
+                .build();
+    }
+}
diff --git 
a/flink-doris-connector/flink-doris-connector-it/src/test/java/org/apache/doris/flink/sink/S3TvfSinkITCase.java
 
b/flink-doris-connector/flink-doris-connector-it/src/test/java/org/apache/doris/flink/sink/S3TvfSinkITCase.java
index 58858404..b8854936 100644
--- 
a/flink-doris-connector/flink-doris-connector-it/src/test/java/org/apache/doris/flink/sink/S3TvfSinkITCase.java
+++ 
b/flink-doris-connector/flink-doris-connector-it/src/test/java/org/apache/doris/flink/sink/S3TvfSinkITCase.java
@@ -95,7 +95,8 @@ public class S3TvfSinkITCase extends AbstractITCaseService {
     private static final String ACCESS_KEY = "minioadmin";
     private static final String SECRET_KEY = "minioadmin";
     private static final int MINIO_PORT = 9000;
-    private static final String MINIO_IMAGE = 
"quay.io/minio/minio:RELEASE.2024-10-13T13-34-11Z";
+    private static final String MINIO_IMAGE =
+            "doristhirdpartydocker/minio:RELEASE.2024-11-07T00-52-20Z";
 
     private static GenericContainer<?> minio;
     private static S3Client s3Client;
diff --git 
a/flink-doris-connector/flink-doris-connector-it/src/test/java/org/apache/doris/flink/source/AbstractDorisIncrementalITCase.java
 
b/flink-doris-connector/flink-doris-connector-it/src/test/java/org/apache/doris/flink/source/AbstractDorisIncrementalITCase.java
index cc1ae1b3..4f7c470c 100644
--- 
a/flink-doris-connector/flink-doris-connector-it/src/test/java/org/apache/doris/flink/source/AbstractDorisIncrementalITCase.java
+++ 
b/flink-doris-connector/flink-doris-connector-it/src/test/java/org/apache/doris/flink/source/AbstractDorisIncrementalITCase.java
@@ -238,7 +238,7 @@ public abstract class AbstractDorisIncrementalITCase 
extends AbstractITCaseServi
                     return collector.containsAll(expected);
                 },
                 Deadline.fromNow(DEFAULT_TIMEOUT),
-                100L,
+                1000L,
                 "Missing source rows " + expected + "; observed=" + 
collector.getRows());
     }
 
diff --git 
a/flink-doris-connector/flink-doris-connector-it/src/test/java/org/apache/doris/flink/source/DorisIncrementalSourceITCase.java
 
b/flink-doris-connector/flink-doris-connector-it/src/test/java/org/apache/doris/flink/source/DorisIncrementalSourceITCase.java
index 548602a2..565d54c6 100644
--- 
a/flink-doris-connector/flink-doris-connector-it/src/test/java/org/apache/doris/flink/source/DorisIncrementalSourceITCase.java
+++ 
b/flink-doris-connector/flink-doris-connector-it/src/test/java/org/apache/doris/flink/source/DorisIncrementalSourceITCase.java
@@ -167,6 +167,65 @@ public class DorisIncrementalSourceITCase extends 
AbstractDorisIncrementalITCase
         }
     }
 
+    @Test
+    public void testFromTimestampRestartsFromExternalOffsetWithoutFlinkState() 
throws Exception {
+        restartFromExternalOffset("from-timestamp");
+    }
+
+    @Test
+    public void testInitialRestartsWithoutRepeatingSnapshot() throws Exception 
{
+        restartFromExternalOffset("initial");
+    }
+
+    private void restartFromExternalOffset(String scanMode) throws Exception {
+        String table = "tbl_external_offset_restart_" + scanMode.replace('-', 
'_');
+        initializeIncrementalTable(table, "(0,'historical','ignored')");
+        initializeOffsetTable();
+        String configuredStart = resolveCurrentDorisTimestamp(table);
+        waitForDorisTimestampAfter(table, configuredStart);
+        String savedOffset;
+        try (IncrementalResultCollector first =
+                startSourceWithOffsetPersistence(
+                        table,
+                        scanMode,
+                        "from-timestamp".equals(scanMode) ? configuredStart : 
null,
+                        OFFSET_TABLE,
+                        CONSUMER_ID)) {
+            executeDorisSql(
+                    String.format(
+                            "INSERT INTO %s.%s VALUES 
(1,'before-stop','ignored')",
+                            DATABASE, table));
+            checkpointUntilContains(
+                    first, Arrays.asList(ObservedRow.of(RowKind.INSERT, 1, 
"before-stop")));
+            String writtenAt = resolveCurrentDorisTimestamp(table);
+            waitForDorisTimestampAfter(table, writtenAt);
+            savedOffset = checkpointUntilOffsetAfter(first, writtenAt);
+        }
+
+        // These writes must be recovered by a new job with no 
checkpoint/savepoint restore path.
+        executeDorisSql(
+                String.format(
+                        "INSERT INTO %s.%s VALUES 
(2,'while-stopped-a','ignored'),(3,'while-stopped-b','ignored')",
+                        DATABASE, table));
+        waitForDorisTimestampAfter(table, savedOffset);
+        try (IncrementalResultCollector restarted =
+                startSourceWithOffsetPersistence(
+                        table,
+                        scanMode,
+                        "from-timestamp".equals(scanMode) ? configuredStart : 
null,
+                        OFFSET_TABLE,
+                        CONSUMER_ID)) {
+            checkpointUntilContains(
+                    restarted,
+                    Arrays.asList(
+                            ObservedRow.of(RowKind.INSERT, 2, 
"while-stopped-a"),
+                            ObservedRow.of(RowKind.INSERT, 3, 
"while-stopped-b")));
+            Assert.assertFalse(
+                    "Restart replayed rows before the saved boundary: " + 
restarted.getRows(),
+                    restarted.getRows().stream().anyMatch(row -> row.getInt(0) 
< 2));
+        }
+    }
+
     private void initializeOffsetTable() {
         executeDorisSql(
                 String.format("DROP TABLE IF EXISTS %s.%s", DATABASE, 
OFFSET_TABLE),


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

Reply via email to