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

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


The following commit(s) were added to refs/heads/master by this push:
     new 468afdc2a52 [fix](ivm) Reject an expired stream offset for an IVM read 
of a duplicate-key table (#69785)
468afdc2a52 is described below

commit 468afdc2a52d696ef974d7e4761cc47e86ce0be4
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Thu Oct 29 04:00:20 2026 +0000

    [fix](ivm) Reject an expired stream offset for an IVM read of a 
duplicate-key table (#69785)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #69772
    
    Problem Summary:
    
    **In short.** An IVM materialized view over a duplicate-key base table
    silently misses rows once the table's `changelog.ttl_seconds` has
    expired its stream offset: the refresh succeeds, the rows written
    between the offset and the cutoff are never read, and a base-table query
    of the MV's shape that transparent rewrite answers from the MV returns
    the same wrong result. This PR makes every read of an IVM refresh reject
    an expired offset, whatever its scan type, so the refresh fails with
    `CHANGELOG_BROKEN` or, when it may fall back, rebuilds the MV with
    COMPLETE (#69772). It also carries the two follow-ups yujun777 suggested
    on #69772.
    
    **Background.** An IVM MV reads each base table through an internal
    table stream. A duplicate-key table is read `APPEND_ONLY`:
    `OlapTableStreamWrapper.getStreamScanType()` returns `APPEND_ONLY` for
    `DUP_KEYS`, whatever type the stream declares.
    `NormalizeOlapTableStreamScan.applyChangelogTtl` decides what an expired
    start means: a `MIN_DELTA` read is rejected (#69772 turns that into
    `CHANGELOG_BROKEN` and COMPLETE), any other read is clipped to the first
    TSO the changelog retains. The clipping is right for a user stream,
    which reads the changelog it retains.
    
    **The problem, and what it cost.**
    - For an IVM refresh the clipping loses data without a trace. The rows
    written between the offset and the cutoff are never read, the refresh
    succeeds, the offset moves past them, and no later incremental refresh
    brings them back.
    - It hits every IVM MV over a duplicate-key table, the aggregate ones
    (unique-key MV) and the append-only detail ones alike, whenever the MV
    is not refreshed within the TTL of a write.
    
    **How this PR fixes it.**
    1. `applyChangelogTtl` rejects an expired start for every read of an IVM
    statement (the statement carries an `IvmRewriteContext`), whatever the
    scan type. The condition is the statement, not the type the stream
    declares: a user stream on a duplicate-key table also declares
    `MIN_DELTA` by default, and it keeps its clipping. An IVM statement
    never reads a user stream: an MV query that scans a stream is rejected
    when the MV is created. The snapshot read of a duplicate-key table is
    bounded by the offset on the base table itself and reads no changelog,
    so it is unchanged.
    2. The follow-ups from the review of #69772, in their own commit and
    without a behavior change:
    - the rebuild an incremental attempt runs before its delta now turns a
    failure into a fallback when its reason's only repair is COMPLETE
    (`IvmFailureReason.requiresCompleteRefresh()`), and passes that reason
    through, instead of naming `CHANGELOG_BROKEN`;
    - the `CHANGELOG_BROKEN` branch of `handleIvmFallbackResult`, which
    returned the same result as the `requiresCompleteRefresh()` branch after
    it, is removed; why only COMPLETE repairs an expired changelog now sits
    on `IvmFailureReason.CHANGELOG_BROKEN`.
    
    **Results** (local shared-nothing cluster; a duplicate-key table, a row
    written after the MV's offset, then `changelog.ttl_seconds` set to 1 and
    two seconds of waiting):
    
    | Refresh after the offset expired | Before | After |
    | --- | --- | --- |
    | `REFRESH ... INCREMENTAL` (strict) | SUCCESS, the row missing from the
    MV | FAILED, `IvmFallbackReason = CHANGELOG_BROKEN`, the MV keeps its
    rows |
    | `REFRESH ... AUTO` | SUCCESS with nothing left to read; the row stays
    missing | SUCCESS through COMPLETE, `IvmFallbackReason =
    CHANGELOG_BROKEN`, the MV matches the base table |
    | the next refresh, with the TTL back to a day | incremental; the row
    missing for good | incremental |
    | a user stream on the same table | reads from the first retained TSO |
    the same |
    
    Both an aggregate MV and an append-only detail MV over the table behave
    as above.
    
    **Classes, and how they call each other.**
    - `IvmIncrRefreshMTMV` → `IvmDeltaRewriteState.createDeltaScan` →
    `LogicalOlapTableStreamScan` →
    `NormalizeOlapTableStreamScan.makeTableStreamScan` →
    `makeIncrementalScanFromChangelog` → `applyChangelogTtl` →
    `ChangelogUtils.effectiveStartTso`, which now rejects for an IVM
    statement.
    - The rejection reaches `IvmIncrRefreshManager.doRefresh` →
    `IvmFailureClassifier.classifyExecutionFailure` → `CHANGELOG_BROKEN` →
    `MTMVTask.handleIvmFallbackResult` → strict: failure with the reason;
    otherwise `FALLBACK_TO_COMPLETE`.
    - The rebuild before the delta: `MTMVTask.executeIvmAttempt` →
    `executePartitionBasedRefresh` → on a failure whose reason
    `requiresCompleteRefresh()`, `handleIvmFallbackResult` with that reason.
    
    Not changed: user streams, direct incremental queries
    (`BindRelation.applyChangelogTtl`), the snapshot and reset reads, and
    anything persisted. A partition with no stream offset is a separate
    problem, fixed by #69778.
---
 .../apache/doris/job/extensions/mtmv/MTMVTask.java |  21 +--
 .../apache/doris/mtmv/ivm/IvmFailureReason.java    |  10 +-
 .../rewrite/NormalizeOlapTableStreamScan.java      |   8 +-
 .../java/org/apache/doris/mtmv/MTMVTaskTest.java   |  37 ++++-
 .../trees/plans/ExplainTableStreamPlanTest.java    |  55 ++++++
 .../ivm/test_ivm_changelog_offset_expired_dup.out  |  75 +++++++++
 .../test_ivm_changelog_offset_expired_dup.groovy   | 184 +++++++++++++++++++++
 7 files changed, 366 insertions(+), 24 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
index 86d8661db1d..302ecd0972b 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
@@ -832,14 +832,16 @@ public class MTMVTask extends AbstractTask {
             } catch (JobException e) {
                 // The rebuild reads the tables the MV does not partition by 
through their streams at the
                 // offsets, so an offset changelog.ttl_seconds has expired 
stops it the way it stops the delta
-                // below -- and it is the same failure of this attempt, 
answered the same way.
+                // below. Only a failure whose one repair is COMPLETE becomes 
a fallback here: the rebuild already
+                // is the partition refresh, and the sync-and-retry the delta 
has for a missing MV partition does
+                // not exist on this path. Any other failure still fails the 
attempt.
                 String detail = Util.getRootCauseMessage(e);
-                if (IvmFailureClassifier.classifyExecutionFailure(detail)
-                        .filter(reason -> reason == 
IvmFailureReason.CHANGELOG_BROKEN).isEmpty()) {
+                Optional<IvmFailureReason> reason = 
IvmFailureClassifier.classifyExecutionFailure(detail)
+                        .filter(IvmFailureReason::requiresCompleteRefresh);
+                if (reason.isEmpty()) {
                     throw e;
                 }
-                return handleIvmFallbackResult(
-                        
IvmIncrRefreshResult.fallback(IvmFailureReason.CHANGELOG_BROKEN, detail), 
request);
+                return 
handleIvmFallbackResult(IvmIncrRefreshResult.fallback(reason.get(), detail), 
request);
             } finally {
                 recordRebuiltPartitions(request);
             }
@@ -1195,15 +1197,6 @@ public class MTMVTask extends AbstractTask {
                     + ", reason=" + ivmResult.getFailureReason()
                     + ", detail=" + ivmResult.getDetailMessage());
         }
-        if (ivmResult.getFailureReason() == IvmFailureReason.CHANGELOG_BROKEN) 
{
-            // changelog.ttl_seconds has expired the offset of a stream this 
attempt reads, so no incremental
-            // attempt can catch the MV up. A partition refresh still reads 
the streams at their offsets, so
-            // go directly to COMPLETE: it is the only attempt that resets the 
stream baselines.
-            LOG.warn("IVM stream changelog expired for mv={}, detail={}, 
taskId={}. "
-                    + "Continuing with COMPLETE refresh.",
-                    mtmv.getName(), ivmResult.getDetailMessage(), getTaskId());
-            return AttemptResultType.FALLBACK_TO_COMPLETE;
-        }
         if (ivmResult.getFailureReason().requiresCompleteRefresh()) {
             LOG.warn("IVM refresh fell back for mv={}, reason={}, detail={}, 
taskId={}. "
                     + "Continuing with COMPLETE refresh.",
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmFailureReason.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmFailureReason.java
index 9b2a5154548..af2f5b358aa 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmFailureReason.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmFailureReason.java
@@ -20,7 +20,11 @@ package org.apache.doris.mtmv.ivm;
 /** Reasons IVM analysis or execution can fail. */
 public enum IvmFailureReason {
     // ATTN(changelog-rename): formerly "BINLOG_BROKEN"
-    /** The changelog a stream has to read from is outside the retention: 
changelog.ttl_seconds expired its offset. */
+    /**
+     * The changelog a stream has to read from is outside the retention: 
changelog.ttl_seconds expired its offset.
+     * A partition refresh still reads the streams at their offsets, so only 
COMPLETE, the one refresh that resets
+     * the stream baselines, repairs it.
+     */
     CHANGELOG_BROKEN,
     // ATTN(changelog-rename): formerly "BINLOG_NOT_ENABLED"
     CHANGELOG_NOT_ENABLED,
@@ -39,6 +43,10 @@ public enum IvmFailureReason {
     MV_COMMIT_NOT_VISIBLE,
     MV_PARTITION_NOT_FOUND;
 
+    /**
+     * Whether COMPLETE is the only refresh that repairs this failure, so that 
a refresh allowed to fall back goes
+     * to it directly instead of trying a partition refresh first.
+     */
     public boolean requiresCompleteRefresh() {
         return this == CHANGELOG_BROKEN
                 || this == STREAM_UNSUPPORTED
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/NormalizeOlapTableStreamScan.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/NormalizeOlapTableStreamScan.java
index 6b8092c9f97..22e2f02d7b5 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/NormalizeOlapTableStreamScan.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/NormalizeOlapTableStreamScan.java
@@ -239,10 +239,16 @@ public class NormalizeOlapTableStreamScan extends 
OneRewriteRuleFactory {
         long cutoffTso = TSOTimestamp.calculateCutoff(
                 
cascadesContext.getStatementContext().getChangelogReferenceTso(),
                 table.getChangelogConfig().getTtlSeconds());
+        // An expired start is rejected for MIN_DELTA and clipped to the first 
retained TSO otherwise. An IVM read
+        // rejects it whatever its scan type: a duplicate-key base table is 
read APPEND_ONLY, and a clipped start
+        // would skip the rows written between the offset and the cutoff while 
the refresh succeeds. The rejection
+        // is what sends the refresh to COMPLETE. A user stream keeps what its 
scan type does.
+        boolean rejectExpiredStart = scanType == 
BaseTableStream.StreamScanType.MIN_DELTA
+                || cascadesContext.getStatementContext().isIvmMTMVRewrite();
         return 
offsetMap.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, entry 
-> {
             Pair<Long, Long> range = entry.getValue();
             return Pair.of(ChangelogUtils.effectiveStartTso(range.first, 
cutoffTso,
-                    range.first != null && scanType == 
BaseTableStream.StreamScanType.MIN_DELTA), range.second);
+                    range.first != null && rejectExpiredStart), range.second);
         }));
     }
 
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java
index 006ff1fd780..f14e0b45d40 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java
@@ -45,6 +45,7 @@ import 
org.apache.doris.mtmv.MTMVPartitionInfo.MTMVPartitionType;
 import org.apache.doris.mtmv.MTMVRefreshEnum.MTMVRefreshState;
 import org.apache.doris.mtmv.MTMVRefreshEnum.MTMVState;
 import org.apache.doris.mtmv.MTMVRefreshEnum.RefreshMethod;
+import org.apache.doris.mtmv.ivm.IvmFailureClassifier;
 import org.apache.doris.mtmv.ivm.IvmFailureReason;
 import org.apache.doris.mtmv.ivm.IvmIncrRefreshContext;
 import org.apache.doris.mtmv.ivm.IvmIncrRefreshManager;
@@ -1787,7 +1788,7 @@ public class MTMVTaskTest {
         MTMVTask task = new MTMVTask(mtmv, relation, 
MTMVTaskContext.of(MTMVTaskTriggerMode.MANUAL, null,
                 RefreshMode.INCREMENTAL, true, null));
 
-        Object result = runIvmAttemptWhoseRebuildReadsAnExpiredOffset(task);
+        Object result = runIvmAttemptWhoseRebuildFails(task, 
expiredOffsetFailure());
 
         Assertions.assertEquals("FALLBACK_TO_COMPLETE", result.toString());
         Assertions.assertEquals(IvmFailureReason.CHANGELOG_BROKEN.name(),
@@ -1801,7 +1802,7 @@ public class MTMVTaskTest {
                 RefreshMode.INCREMENTAL, false, null));
 
         JobException failure = Assertions.assertThrows(JobException.class,
-                () -> runIvmAttemptWhoseRebuildReadsAnExpiredOffset(task));
+                () -> runIvmAttemptWhoseRebuildFails(task, 
expiredOffsetFailure()));
 
         Assertions.assertTrue(failure.getMessage().contains("reason=" + 
IvmFailureReason.CHANGELOG_BROKEN));
         
Assertions.assertTrue(failure.getMessage().contains(ChangelogUtils.CHANGELOG_OFFSET_EXPIRED));
@@ -1810,10 +1811,32 @@ public class MTMVTaskTest {
     }
 
     /**
-     * Runs the incremental attempt with p1 needing a rebuild and p2 caught 
up, the rebuild's read refused the way
-     * planning refuses a MIN_DELTA read from an expired offset. The delta 
must not be reached.
+     * Only a rebuild failure whose one repair is COMPLETE becomes a fallback. 
A missing MV partition is not one: the
+     * sync-and-retry that answers it exists on the delta's path only, so it 
still fails the attempt, even for a
+     * request that may fall back.
      */
-    private Object runIvmAttemptWhoseRebuildReadsAnExpiredOffset(MTMVTask 
task) throws Exception {
+    @Test
+    public void testRebuildFailureCompleteDoesNotRepairStillFailsTheAttempt() {
+        MTMVTask task = new MTMVTask(mtmv, relation, 
MTMVTaskContext.of(MTMVTaskTriggerMode.MANUAL, null,
+                RefreshMode.INCREMENTAL, true, null));
+
+        JobException failure = Assertions.assertThrows(JobException.class, () 
-> runIvmAttemptWhoseRebuildFails(
+                task, new 
RuntimeException(IvmFailureClassifier.MV_PARTITION_NOT_FOUND_MSG)));
+
+        
Assertions.assertTrue(failure.getMessage().contains(IvmFailureClassifier.MV_PARTITION_NOT_FOUND_MSG));
+        Assertions.assertNull(Deencapsulation.getField(task, 
"ivmFallbackReason"));
+    }
+
+    // The failure planning raises for a MIN_DELTA read from an expired offset.
+    private static RuntimeException expiredOffsetFailure() {
+        return Assertions.assertThrows(RuntimeException.class, () -> 
ChangelogUtils.effectiveStartTso(99L, 100L, true));
+    }
+
+    /**
+     * Runs the incremental attempt with p1 needing a rebuild and p2 caught 
up, the rebuild's read failing with
+     * {@code failure}. The delta must not be reached.
+     */
+    private Object runIvmAttemptWhoseRebuildFails(MTMVTask task, 
RuntimeException failure) throws Exception {
         Mockito.when(mtmv.isIvm()).thenReturn(true);
         Mockito.when(mtmv.getName()).thenReturn("test_mv");
         Mockito.when(mtmv.hasRefreshSnapshot()).thenReturn(true);
@@ -1828,8 +1851,6 @@ public class MTMVTaskTest {
         mtmvPartitionUtilStatic.when(() -> 
MTMVPartitionUtil.generatePartitionSnapshots(
                 Mockito.same(refreshContext), Mockito.anySet(), 
Mockito.anySet()))
                 .thenReturn(Collections.emptyMap());
-        RuntimeException expired = 
Assertions.assertThrows(RuntimeException.class,
-                () -> ChangelogUtils.effectiveStartTso(99L, 100L, true));
 
         ConnectContext mtmvCtx = new ConnectContext();
         mtmvCtx.setQueryId(new TUniqueId(1L, 2L));
@@ -1847,7 +1868,7 @@ public class MTMVTaskTest {
                     .thenReturn(command);
             mtmvPlanUtilStatic.when(() -> MTMVPlanUtil.executeCommand(
                     Mockito.eq(mtmvCtx), Mockito.eq(command), 
Mockito.any(StatementContext.class),
-                    Mockito.anyString(), 
Mockito.any(Consumer.class))).thenThrow(expired);
+                    Mockito.anyString(), 
Mockito.any(Consumer.class))).thenThrow(failure);
 
             Object request = Deencapsulation.invoke(task, 
"resolveRefreshRequest");
             try {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/ExplainTableStreamPlanTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/ExplainTableStreamPlanTest.java
index 1ff0b84099f..9b25c8d442c 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/ExplainTableStreamPlanTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/ExplainTableStreamPlanTest.java
@@ -182,6 +182,19 @@ public class ExplainTableStreamPlanTest extends 
TestWithFeService {
                 "create stream if not exists test_stream.s_dup on table 
test_stream.tbl_dup_stream_base\n"
                         + "properties('show_initial_rows' = 'false')";
         createTable(createDuplicateStream);
+
+        // A stream on a duplicate-key table with a TTL, and data after its 
offset. bumpPartitionsAndReplicas
+        // stamps a wall-clock millisecond as the partition TSO, far below a 
TSO composed from the current time,
+        // so under such a reference the offset is expired.
+        createTable("create table test_stream.tbl_dup_stream_ttl_offset (k1 
int, k2 int) "
+                + "duplicate key(k1) distributed by hash(k1) buckets 1 
properties("
+                + "\"replication_num\"=\"1\",\"changelog.enable\"=\"true\","
+                + "\"changelog.ttl_seconds\"=\"10\")");
+        OlapTable duplicateTtlTable = (OlapTable) 
db.getTableOrMetaException("tbl_dup_stream_ttl_offset");
+        bumpPartitionsAndReplicas(duplicateTtlTable, 1001L);
+        createTable("create stream if not exists test_stream.s_dup_ttl"
+                + " on table test_stream.tbl_dup_stream_ttl_offset 
properties('show_initial_rows' = 'false')");
+        bumpPartitionsAndReplicas(duplicateTtlTable, 2002L);
     }
 
     private static void bumpPartitionsAndReplicas(OlapTable table, long 
newVersion) {
@@ -797,6 +810,48 @@ public class ExplainTableStreamPlanTest extends 
TestWithFeService {
         
Assertions.assertTrue(exception.getMessage().contains(ChangelogUtils.CHANGELOG_OFFSET_EXPIRED));
     }
 
+    @Test
+    public void testChangelogTtlClipsExpiredAppendOnlyStreamOffset() {
+        // A user stream on a duplicate-key table is read APPEND_ONLY: its 
expired offset is clipped to the first
+        // retained TSO, and the read goes on from there.
+        long referenceTso = 
TSOTimestamp.composePhysicalTimestamp(System.currentTimeMillis());
+        long firstRetainedTso = 
TSOTimestamp.nextTso(TSOTimestamp.calculateCutoff(referenceTso, 10));
+        Plan plan = normalizeStreamRead("select * from test_stream.s_dup_ttl", 
referenceTso, Optional.empty());
+
+        Set<LogicalOlapScan> changelogScans = plan.collect(node -> node 
instanceof LogicalOlapScan
+                && ((LogicalOlapScan) node).getTable() instanceof 
OlapChangelogTableWrapper);
+        Assertions.assertEquals(1, changelogScans.size());
+        OlapChangelogTableWrapper wrapper = (OlapChangelogTableWrapper) 
changelogScans.iterator().next().getTable();
+        Assertions.assertFalse(wrapper.getPartitionIds().isEmpty());
+        for (Long partitionId : wrapper.getPartitionIds()) {
+            Assertions.assertEquals(firstRetainedTso, 
wrapper.getPartitionOffset(partitionId).first);
+        }
+    }
+
+    @Test
+    public void testChangelogTtlRejectsExpiredAppendOnlyStreamOffsetForIvm() {
+        // The same read inside an IVM refresh is rejected: clipped, it would 
leave out the rows written between
+        // the offset and the cutoff while the refresh succeeds.
+        long referenceTso = 
TSOTimestamp.composePhysicalTimestamp(System.currentTimeMillis());
+        AnalysisException exception = 
Assertions.assertThrows(AnalysisException.class,
+                () -> normalizeStreamRead("select * from 
test_stream.s_dup_ttl", referenceTso,
+                        
Optional.of(IvmRewriteContext.incremental(Mockito.mock(MTMV.class)))));
+        
Assertions.assertTrue(exception.getMessage().contains(ChangelogUtils.CHANGELOG_OFFSET_EXPIRED),
+                exception.getMessage());
+    }
+
+    private Plan normalizeStreamRead(String sql, long referenceTso, 
Optional<IvmRewriteContext> ivmRewriteContext) {
+        PlanChecker checker = PlanChecker.from(connectContext).parse(sql);
+        StatementContext statementContext = 
checker.getCascadesContext().getStatementContext();
+        // Direct rewrite tests bypass the planner's statement reference 
initialization.
+        statementContext.getOrRegisterChangelogReferenceTso(() -> 
referenceTso);
+        checker.analyze();
+        // Installed after the analysis, whose IVM rules would rewrite the 
query as an MV's: only how the stream
+        // read treats its offset inside an IVM statement is under test.
+        statementContext.setIvmRewriteContext(ivmRewriteContext);
+        return checker.applyTopDown(new 
NormalizeOlapTableStreamScan()).getCascadesContext().getRewritePlan();
+    }
+
     @Test
     public void testChangelogTtlDisablesQueryCache() throws Exception {
         ConnectContext ctx = createDefaultCtx();
diff --git 
a/regression-test/data/mtmv_p0/ivm/test_ivm_changelog_offset_expired_dup.out 
b/regression-test/data/mtmv_p0/ivm/test_ivm_changelog_offset_expired_dup.out
new file mode 100644
index 00000000000..69a07645164
--- /dev/null
+++ b/regression-test/data/mtmv_p0/ivm/test_ivm_changelog_offset_expired_dup.out
@@ -0,0 +1,75 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !agg_baseline_task --
+SUCCESS        COMPLETE        NONE    false   false
+
+-- !agg_baseline_mv --
+1      1       10
+2      1       20
+
+-- !detail_baseline_task --
+SUCCESS        COMPLETE        NONE    false   false
+
+-- !detail_baseline_mv --
+1      1       10
+2      2       20
+
+-- !stream_before_expiry --
+3      1       30
+
+-- !stream_after_expiry --
+
+-- !agg_strict_task --
+FAILED NONE    CHANGELOG_BROKEN        true    true
+
+-- !agg_mv_after_strict --
+1      1       10
+2      1       20
+
+-- !agg_fallback_task --
+SUCCESS        COMPLETE        CHANGELOG_BROKEN        false   false
+
+-- !agg_mv_after_fallback --
+1      2       40
+2      1       20
+
+-- !detail_strict_task --
+FAILED NONE    CHANGELOG_BROKEN        true    true
+
+-- !detail_mv_after_strict --
+1      1       10
+2      2       20
+
+-- !detail_fallback_task --
+SUCCESS        COMPLETE        CHANGELOG_BROKEN        false   false
+
+-- !detail_mv_after_fallback --
+1      1       10
+2      2       20
+3      1       30
+
+-- !agg_task_after_fallback --
+SUCCESS        NONE    NONE    false   false
+
+-- !agg_mv_after_a_further_refresh --
+1      2       40
+2      2       60
+
+-- !detail_task_after_fallback --
+SUCCESS        NONE    NONE    false   false
+
+-- !detail_mv_after_a_further_refresh --
+1      1       10
+2      2       20
+3      1       30
+4      2       40
+
+-- !base_agg --
+1      2       40
+2      2       60
+
+-- !base_detail --
+1      1       10
+2      2       20
+3      1       30
+4      2       40
+
diff --git 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_changelog_offset_expired_dup.groovy
 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_changelog_offset_expired_dup.groovy
new file mode 100644
index 00000000000..34dd356b12d
--- /dev/null
+++ 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_changelog_offset_expired_dup.groovy
@@ -0,0 +1,184 @@
+// 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 org.awaitility.Awaitility
+
+import static java.util.concurrent.TimeUnit.SECONDS
+
+/**
+ * An incremental refresh of an IVM MV over a duplicate-key base table whose 
stream offset the table's
+ * changelog.ttl_seconds has expired.
+ *
+ * <p>A duplicate-key table is read through its stream APPEND_ONLY. A user 
stream read that way clips an expired
+ * start to the changelog the table still retains and reads on from there. An 
IVM read may not: the rows written
+ * between the offset and the cutoff would be missing from an MV whose refresh 
succeeded. The read refuses the
+ * offset instead, the way a MIN_DELTA read does, and the refresh answers it 
as CHANGELOG_BROKEN, which only
+ * COMPLETE can repair.
+ *
+ * <p>Cases pinned here, for an aggregate MV and for an append-only detail MV 
over the same table:
+ * <ol>
+ *   <li>a strict INCREMENTAL refresh fails with that reason and leaves the MV 
as it was;</li>
+ *   <li>an AUTO refresh, which may fall back, rebuilds the MV with COMPLETE, 
the row written before the offset
+ *       expired included, and records the reason;</li>
+ *   <li>with the TTL back to a day, the refresh after it is incremental 
again.</li>
+ * </ol>
+ * A user stream on the table still reads past its expired offset, from the 
changelog retained.
+ *
+ * <p>nonConcurrent: the offsets expire by sleeping past a one-second TTL.
+ */
+suite("test_ivm_changelog_offset_expired_dup", "nonConcurrent") {
+    // The base-table queries below check the MVs, so they must not be 
answered from an MV; and a base-table
+    // insert must not be redirected into one, or the incremental path under 
test goes unused.
+    sql "SET enable_materialized_view_rewrite = false"
+    sql "SET enable_dml_materialized_view_rewrite = false"
+
+    def waitForNewTask = { String mvName, previousTaskId ->
+        def taskResult
+        Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS).until({
+            taskResult = sql_return_maparray("""
+                SELECT TaskId, Status
+                FROM tasks('type'='mv')
+                WHERE MvDatabaseName = '${context.dbName}'
+                  AND MvName = '${mvName}'
+                ORDER BY CreateTime DESC, TaskId DESC LIMIT 1
+            """)
+            return !taskResult.isEmpty()
+                    && taskResult[0].TaskId.toString() != previousTaskId
+                    && taskResult[0].Status.toString() != 'PENDING'
+                    && taskResult[0].Status.toString() != 'RUNNING'
+        })
+        return taskResult[0]
+    }
+
+    // How a refresh ended, in one row. Unset RefreshMode / IvmFallbackReason 
come back as the literal
+    // two-character string "\N", which does not survive the .out round trip, 
so fold them into a printable
+    // token; a reason is an enum name, which "\N" is not. The error text 
carries TSOs, so only whether it
+    // names the expired offset and the reason is kept.
+    def taskQuery = { String taskId ->
+        """
+            SELECT Status,
+                   CASE WHEN RefreshMode IN ('COMPLETE', 'PARTIAL', 
'NOT_REFRESH')
+                        THEN RefreshMode ELSE 'NONE' END,
+                   CASE WHEN IvmFallbackReason REGEXP '^[A-Z_]+\$'
+                        THEN IvmFallbackReason ELSE 'NONE' END,
+                   ErrorMsg LIKE '%Changelog offset has expired according to 
changelog.ttl_seconds%',
+                   ErrorMsg LIKE '%reason=CHANGELOG_BROKEN%'
+            FROM tasks('type'='mv')
+            WHERE TaskId = '${taskId}'
+        """
+    }
+
+    sql """DROP MATERIALIZED VIEW IF EXISTS ivm_offset_expired_dup_agg_mv"""
+    sql """DROP MATERIALIZED VIEW IF EXISTS ivm_offset_expired_dup_detail_mv"""
+    sql """DROP STREAM IF EXISTS ivm_offset_expired_dup_stream FORCE"""
+    sql """DROP TABLE IF EXISTS ivm_offset_expired_dup_t"""
+
+    sql """
+        CREATE TABLE ivm_offset_expired_dup_t (
+            id BIGINT NOT NULL,
+            grp INT NOT NULL,
+            v1 BIGINT
+        )
+        DUPLICATE KEY(id)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES (
+            "replication_num" = "1",
+            "changelog.enable" = "true",
+            "changelog.ttl_seconds" = "86400"
+        )
+    """
+    sql """INSERT INTO ivm_offset_expired_dup_t VALUES (1, 1, 10), (2, 2, 
20)"""
+
+    // The aggregate keeps this MV on the unique-key model; the plain 
projection makes the other an append-only
+    // detail MV. Both read the table through an internal stream of their own.
+    sql """
+        CREATE MATERIALIZED VIEW ivm_offset_expired_dup_agg_mv
+        BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+        DISTRIBUTED BY HASH(grp) BUCKETS 1
+        PROPERTIES ("replication_num" = "1")
+        AS SELECT grp, COUNT(*) AS cnt, SUM(v1) AS total FROM 
ivm_offset_expired_dup_t GROUP BY grp
+    """
+    sql """
+        CREATE MATERIALIZED VIEW ivm_offset_expired_dup_detail_mv
+        BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES ("replication_num" = "1")
+        AS SELECT id, grp, v1 FROM ivm_offset_expired_dup_t
+    """
+    sql """
+        CREATE STREAM ivm_offset_expired_dup_stream ON TABLE 
ivm_offset_expired_dup_t
+        PROPERTIES ("show_initial_rows" = "false")
+    """
+
+    sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_dup_agg_mv 
INCREMENTAL"""
+    def aggTask = waitForNewTask('ivm_offset_expired_dup_agg_mv', null)
+    qt_agg_baseline_task taskQuery(aggTask.TaskId.toString())
+    order_qt_agg_baseline_mv """SELECT grp, cnt, total FROM 
ivm_offset_expired_dup_agg_mv"""
+    sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_dup_detail_mv 
INCREMENTAL"""
+    def detailTask = waitForNewTask('ivm_offset_expired_dup_detail_mv', null)
+    qt_detail_baseline_task taskQuery(detailTask.TaskId.toString())
+    order_qt_detail_baseline_mv """SELECT id, grp, v1 FROM 
ivm_offset_expired_dup_detail_mv"""
+
+    // The row written after the offsets is what a read from the retained 
changelog leaves out once the TTL is
+    // shorter than its age.
+    sql """INSERT INTO ivm_offset_expired_dup_t VALUES (3, 1, 30)"""
+    order_qt_stream_before_expiry """SELECT id, grp, v1 FROM 
ivm_offset_expired_dup_stream"""
+    sql """ALTER TABLE ivm_offset_expired_dup_t SET ("changelog.ttl_seconds" = 
"1")"""
+    sleep(1500)
+
+    // A user stream reads past its expired offset: the read starts at the 
first retained TSO, which is after
+    // the row it has not consumed.
+    order_qt_stream_after_expiry """SELECT id, grp, v1 FROM 
ivm_offset_expired_dup_stream"""
+
+    sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_dup_agg_mv 
INCREMENTAL"""
+    aggTask = waitForNewTask('ivm_offset_expired_dup_agg_mv', 
aggTask.TaskId.toString())
+    qt_agg_strict_task taskQuery(aggTask.TaskId.toString())
+    order_qt_agg_mv_after_strict """SELECT grp, cnt, total FROM 
ivm_offset_expired_dup_agg_mv"""
+
+    sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_dup_agg_mv AUTO"""
+    aggTask = waitForNewTask('ivm_offset_expired_dup_agg_mv', 
aggTask.TaskId.toString())
+    qt_agg_fallback_task taskQuery(aggTask.TaskId.toString())
+    order_qt_agg_mv_after_fallback """SELECT grp, cnt, total FROM 
ivm_offset_expired_dup_agg_mv"""
+
+    sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_dup_detail_mv 
INCREMENTAL"""
+    detailTask = waitForNewTask('ivm_offset_expired_dup_detail_mv', 
detailTask.TaskId.toString())
+    qt_detail_strict_task taskQuery(detailTask.TaskId.toString())
+    order_qt_detail_mv_after_strict """SELECT id, grp, v1 FROM 
ivm_offset_expired_dup_detail_mv"""
+
+    sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_dup_detail_mv AUTO"""
+    detailTask = waitForNewTask('ivm_offset_expired_dup_detail_mv', 
detailTask.TaskId.toString())
+    qt_detail_fallback_task taskQuery(detailTask.TaskId.toString())
+    order_qt_detail_mv_after_fallback """SELECT id, grp, v1 FROM 
ivm_offset_expired_dup_detail_mv"""
+
+    // With the TTL back to a day, the offsets the rebuilds left are well 
inside the retention again.
+    sql """ALTER TABLE ivm_offset_expired_dup_t SET ("changelog.ttl_seconds" = 
"86400")"""
+    sql """INSERT INTO ivm_offset_expired_dup_t VALUES (4, 2, 40)"""
+
+    sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_dup_agg_mv AUTO"""
+    aggTask = waitForNewTask('ivm_offset_expired_dup_agg_mv', 
aggTask.TaskId.toString())
+    qt_agg_task_after_fallback taskQuery(aggTask.TaskId.toString())
+    order_qt_agg_mv_after_a_further_refresh """SELECT grp, cnt, total FROM 
ivm_offset_expired_dup_agg_mv"""
+
+    sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_dup_detail_mv AUTO"""
+    detailTask = waitForNewTask('ivm_offset_expired_dup_detail_mv', 
detailTask.TaskId.toString())
+    qt_detail_task_after_fallback taskQuery(detailTask.TaskId.toString())
+    order_qt_detail_mv_after_a_further_refresh """SELECT id, grp, v1 FROM 
ivm_offset_expired_dup_detail_mv"""
+
+    // What the base table answers for the two MV queries.
+    order_qt_base_agg """SELECT grp, COUNT(*), SUM(v1) FROM 
ivm_offset_expired_dup_t GROUP BY grp"""
+    order_qt_base_detail """SELECT id, grp, v1 FROM ivm_offset_expired_dup_t"""
+}


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

Reply via email to