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 86d62ae00c9 [fix](ivm) Fall back to COMPLETE when an IVM stream offset
has expired (#69772)
86d62ae00c9 is described below
commit 86d62ae00c9cc0d7f8644de2912e0e13469b280e
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Wed Oct 28 15:22:14 2026 +0000
[fix](ivm) Fall back to COMPLETE when an IVM stream offset has expired
(#69772)
### What problem does this PR solve?
Issue Number: None
Related PR: None
Problem Summary:
**In short.** Once a base table's `changelog.ttl_seconds` expires the
offset of an IVM materialized view's internal stream, every refresh of
that MV fails, including refreshes that are allowed to fall back to
COMPLETE, until someone runs a manual COMPLETE refresh. This PR
classifies that failure as `CHANGELOG_BROKEN`, so a refresh that may
fall back rebuilds the MV with COMPLETE (the only attempt that resets
the stream baselines) and a strict `INCREMENTAL` refresh fails with that
reason.
**Background.** An IVM MV reads each unique-key (merge-on-write) base
table through an internal table stream created with `MIN_DELTA`.
Planning a `MIN_DELTA` read refuses an offset that is older than what
the table still retains: `ChangelogUtils.effectiveStartTso` throws
`Changelog offset has expired according to changelog.ttl_seconds`. The
incremental refresh turns a failure into a fallback only when
`IvmFailureClassifier` recognizes its message.
**The problem, and what it cost.**
- The expired-offset message was not one the classifier knew, so
`IvmIncrRefreshManager.doRefresh` rethrew it and the task failed even
for `REFRESH ... AUTO`, `INCREMENTAL FALLBACK`, or the scheduled refresh
of a `REFRESH INCREMENTAL FALLBACK` MV.
- A failed refresh does not move the offset, so while the TTL stays as
it is every later refresh fails the same way, and the MV keeps serving
old rows.
- The delta is not the only read of that offset. When some MV partitions
need a rebuild (for example after a `TRUNCATE` of a base partition), the
incremental attempt first rebuilds them with a partition refresh, which
reads the tables the MV does not partition by through their streams at
the offsets (`StreamReadMode.SNAPSHOT`). With such an offset expired
that rebuild failed the same way, before the delta ran.
- `IvmFailureReason.CHANGELOG_BROKEN` had no producer in production
code, although `MTMVTask.handleIvmFallbackResult` already sends it
straight to COMPLETE; the comment and log of that branch described it as
"previous run incomplete".
**How this PR fixes it.**
- `IvmFailureClassifier` maps a failure carrying
`ChangelogUtils.CHANGELOG_OFFSET_EXPIRED` to `CHANGELOG_BROKEN`
(matching the constant the read throws with, not a copy of it).
- `MTMVTask.executeIvmAttempt` answers that failure of the rebuild
before the delta through `handleIvmFallbackResult`, the way the delta's
failure is answered; any other rebuild failure still fails the attempt.
- The `CHANGELOG_BROKEN` branch of `handleIvmFallbackResult` now
explains why it goes directly to COMPLETE (a partition refresh still
reads the streams at their offsets) and logs the detail with the offset
and cutoff TSO.
**Results** (local shared-nothing cluster):
| Refresh after the offset expired | Before | After |
| --- | --- | --- |
| `REFRESH ... INCREMENTAL` (strict) | FAILED, `IvmFallbackReason` empty
| FAILED, `IvmFallbackReason = CHANGELOG_BROKEN`, error says
`reason=CHANGELOG_BROKEN` with both TSOs |
| `REFRESH ... AUTO` | FAILED, MV keeps the old rows | SUCCESS through
COMPLETE, `IvmFallbackReason = CHANGELOG_BROKEN` |
| the next `REFRESH ... AUTO` | fails again while the TTL is unchanged |
SUCCESS, incremental |
| `REFRESH ... AUTO` that first rebuilds a dirty MV partition while a
joined table's offset has expired | FAILED | SUCCESS through COMPLETE,
`IvmFallbackReason = CHANGELOG_BROKEN` |
**Classes, and how they call each other.**
- `MTMVTask.run` → `executeIvmAttempt` → (dirty partitions)
`executePartitionBasedRefresh` → on an expired offset
`handleIvmFallbackResult(CHANGELOG_BROKEN)`
- `executeIvmAttempt` → `executeSingleIvmAttempt` →
`IvmIncrRefreshManager.doRefresh` →
`IvmFailureClassifier.classifyExecutionFailure` →
`IvmIncrRefreshResult.fallback(CHANGELOG_BROKEN)` →
`handleIvmFallbackResult`
- `handleIvmFallbackResult`: strict request → `JobException` with the
reason; otherwise `FALLBACK_TO_COMPLETE` → `executeCompleteAttempt`,
which resets the stream baselines.
Not changed: the planner's TTL check itself, the PARTITIONS attempt (it
reads a changed table at an old offset only after a partition refresh
that failed part-way: a plan covering every partition reads the joined
tables with `RESET`), and the persisted task fields.
---
.../apache/doris/job/extensions/mtmv/MTMVTask.java | 23 ++-
.../doris/mtmv/ivm/IvmFailureClassifier.java | 7 +
.../apache/doris/mtmv/ivm/IvmFailureReason.java | 1 +
.../java/org/apache/doris/mtmv/MTMVTaskTest.java | 86 ++++++++
.../doris/mtmv/ivm/IvmIncrRefreshManagerTest.java | 21 ++
.../ivm/test_ivm_changelog_offset_expired.out | 57 ++++++
.../ivm/test_ivm_changelog_offset_expired.groovy | 221 +++++++++++++++++++++
7 files changed, 411 insertions(+), 5 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 6de3a886bea..86d8661db1d 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
@@ -66,6 +66,7 @@ import org.apache.doris.mtmv.MTMVRefreshPartitionSnapshot;
import org.apache.doris.mtmv.MTMVRelatedTableIf;
import org.apache.doris.mtmv.MTMVRelation;
import org.apache.doris.mtmv.MTMVUtil;
+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;
@@ -828,6 +829,17 @@ public class MTMVTask extends AbstractTask {
this.refreshMode = generateRefreshMode(toRebuild);
try {
executePartitionBasedRefresh(refreshContext,
RefreshMode.PARTITIONS, ctx, toRebuild);
+ } 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.
+ String detail = Util.getRootCauseMessage(e);
+ if (IvmFailureClassifier.classifyExecutionFailure(detail)
+ .filter(reason -> reason ==
IvmFailureReason.CHANGELOG_BROKEN).isEmpty()) {
+ throw e;
+ }
+ return handleIvmFallbackResult(
+
IvmIncrRefreshResult.fallback(IvmFailureReason.CHANGELOG_BROKEN, detail),
request);
} finally {
recordRebuiltPartitions(request);
}
@@ -1184,11 +1196,12 @@ public class MTMVTask extends AbstractTask {
+ ", detail=" + ivmResult.getDetailMessage());
}
if (ivmResult.getFailureReason() == IvmFailureReason.CHANGELOG_BROKEN)
{
- // The previous task already entered the IVM execution phase. If
- // fallback is allowed, jump directly to COMPLETE recovery instead
of
- // trying PARTITIONS first.
- LOG.warn("IVM previous run incomplete for mv={}, taskId={}.
Continuing with COMPLETE recovery.",
- mtmv.getName(), getTaskId());
+ // 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()) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmFailureClassifier.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmFailureClassifier.java
index c58fc4f3394..e0c7244ac78 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmFailureClassifier.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmFailureClassifier.java
@@ -17,6 +17,8 @@
package org.apache.doris.mtmv.ivm;
+import org.apache.doris.changelog.ChangelogUtils;
+
import java.util.Objects;
import java.util.Optional;
@@ -48,6 +50,11 @@ public final class IvmFailureClassifier {
if (detail.contains(MV_PARTITION_NOT_FOUND_MSG)) {
return Optional.of(IvmFailureReason.MV_PARTITION_NOT_FOUND);
}
+ // Planning a MIN_DELTA read refuses an offset that
changelog.ttl_seconds has expired: the changelog
+ // the stream has to read from is gone, so no delta can catch the MV
up.
+ if (detail.contains(ChangelogUtils.CHANGELOG_OFFSET_EXPIRED)) {
+ return Optional.of(IvmFailureReason.CHANGELOG_BROKEN);
+ }
return Optional.empty();
}
}
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 1db33550941..9b2a5154548 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,6 +20,7 @@ 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. */
CHANGELOG_BROKEN,
// ATTN(changelog-rename): formerly "BINLOG_NOT_ENABLED"
CHANGELOG_NOT_ENABLED,
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 3bed5bd078f..006ff1fd780 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
@@ -27,6 +27,7 @@ import org.apache.doris.catalog.OlapTable;
import org.apache.doris.catalog.Partition;
import org.apache.doris.catalog.info.TableNameInfo;
import org.apache.doris.catalog.stream.OlapTableStream;
+import org.apache.doris.changelog.ChangelogUtils;
import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.Config;
import org.apache.doris.common.DdlException;
@@ -1775,6 +1776,91 @@ public class MTMVTaskTest {
}
}
+ /**
+ * The rebuild an incremental attempt runs first, for the MV partitions
that need one, reads the tables the MV
+ * does not partition by through their streams at the offsets. An offset
changelog.ttl_seconds has expired stops
+ * it the way it stops the delta, so the answer is the same: a request
that may fall back goes to COMPLETE, the
+ * only attempt that resets the stream baselines.
+ */
+ @Test
+ public void
testExpiredOffsetInTheRebuildBeforeTheDeltaFallsBackToComplete() throws
Exception {
+ MTMVTask task = new MTMVTask(mtmv, relation,
MTMVTaskContext.of(MTMVTaskTriggerMode.MANUAL, null,
+ RefreshMode.INCREMENTAL, true, null));
+
+ Object result = runIvmAttemptWhoseRebuildReadsAnExpiredOffset(task);
+
+ Assertions.assertEquals("FALLBACK_TO_COMPLETE", result.toString());
+ Assertions.assertEquals(IvmFailureReason.CHANGELOG_BROKEN.name(),
+ Deencapsulation.getField(task, "ivmFallbackReason"));
+ }
+
+ /** A request that may not fall back fails instead, with the reason it
would have fallen back for. */
+ @Test
+ public void
testExpiredOffsetInTheRebuildBeforeTheDeltaFailsAStrictRequest() {
+ MTMVTask task = new MTMVTask(mtmv, relation,
MTMVTaskContext.of(MTMVTaskTriggerMode.MANUAL, null,
+ RefreshMode.INCREMENTAL, false, null));
+
+ JobException failure = Assertions.assertThrows(JobException.class,
+ () -> runIvmAttemptWhoseRebuildReadsAnExpiredOffset(task));
+
+ Assertions.assertTrue(failure.getMessage().contains("reason=" +
IvmFailureReason.CHANGELOG_BROKEN));
+
Assertions.assertTrue(failure.getMessage().contains(ChangelogUtils.CHANGELOG_OFFSET_EXPIRED));
+ Assertions.assertEquals(IvmFailureReason.CHANGELOG_BROKEN.name(),
+ Deencapsulation.getField(task, "ivmFallbackReason"));
+ }
+
+ /**
+ * 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.
+ */
+ private Object runIvmAttemptWhoseRebuildReadsAnExpiredOffset(MTMVTask
task) throws Exception {
+ Mockito.when(mtmv.isIvm()).thenReturn(true);
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+ Mockito.when(mtmv.hasRefreshSnapshot()).thenReturn(true);
+ Mockito.when(mtmv.getRefreshPartitionNum()).thenReturn(1);
+
Mockito.when(mtmvPartitionInfo.getPctInfos()).thenReturn(Collections.emptyList());
+
Mockito.when(mtmv.getPartitionNames()).thenReturn(Sets.newHashSet(poneName,
ptwoName));
+
Mockito.when(mtmv.getPartitionStates()).thenReturn(Maps.newHashMap(Map.of(
+ poneName, new MTMVPartitionState(1, 2),
+ ptwoName, new MTMVPartitionState(2, 2))));
+ MTMVRefreshContext refreshContext =
Mockito.mock(MTMVRefreshContext.class);
+
Mockito.when(refreshContext.getByPartitionName(Mockito.anyString())).thenReturn(Collections.emptyMap());
+ 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));
+ mtmvCtx.setThreadLocalInfo();
+ UpdateMvByPartitionCommand command =
Mockito.mock(UpdateMvByPartitionCommand.class);
+ try (MockedStatic<MTMVPlanUtil> mtmvPlanUtilStatic =
Mockito.mockStatic(MTMVPlanUtil.class);
+ MockedStatic<UpdateMvByPartitionCommand> updateMvStatic
+ = Mockito.mockStatic(UpdateMvByPartitionCommand.class);
+ MockedConstruction<IvmIncrRefreshManager> delta
+ =
Mockito.mockConstruction(IvmIncrRefreshManager.class)) {
+ mtmvPlanUtilStatic.when(() ->
MTMVPlanUtil.createMTMVContext(Mockito.eq(mtmv), Mockito.anyList()))
+ .thenReturn(mtmvCtx);
+ updateMvStatic.when(() -> UpdateMvByPartitionCommand.from(
+ Mockito.eq(mtmv), Mockito.anySet(), Mockito.anyMap(),
Mockito.any(StatementContext.class)))
+ .thenReturn(command);
+ mtmvPlanUtilStatic.when(() -> MTMVPlanUtil.executeCommand(
+ Mockito.eq(mtmvCtx), Mockito.eq(command),
Mockito.any(StatementContext.class),
+ Mockito.anyString(),
Mockito.any(Consumer.class))).thenThrow(expired);
+
+ Object request = Deencapsulation.invoke(task,
"resolveRefreshRequest");
+ try {
+ return Deencapsulation.invoke(task, "executeIvmAttempt",
refreshContext, request, mtmvCtx,
+ Lists.newArrayList());
+ } finally {
+ Assertions.assertTrue(delta.constructed().isEmpty());
+ }
+ } finally {
+ ConnectContext.remove();
+ }
+ }
+
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmIncrRefreshManagerTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmIncrRefreshManagerTest.java
index 37d49924fa3..ef318686683 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmIncrRefreshManagerTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmIncrRefreshManagerTest.java
@@ -20,9 +20,11 @@ package org.apache.doris.mtmv.ivm;
import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.MTMV;
import org.apache.doris.catalog.info.TableNameInfo;
+import org.apache.doris.changelog.ChangelogUtils;
import org.apache.doris.mtmv.MTMVPlanUtil;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.analyzer.UnboundTableSink;
+import org.apache.doris.nereids.exceptions.AnalysisException;
import org.apache.doris.nereids.trees.plans.commands.Command;
import
org.apache.doris.nereids.trees.plans.commands.insert.InsertIntoTableCommand;
import org.apache.doris.qe.ConnectContext;
@@ -172,6 +174,25 @@ public class IvmIncrRefreshManagerTest {
IvmFailureClassifier.NON_DETERMINISTIC_ROW_ID_MSG_PREFIX);
}
+ @Test
+ public void
testManagerReturnsChangelogBrokenFallbackForExpiredStreamOffset() throws
Exception {
+ // What planning a MIN_DELTA read throws once changelog.ttl_seconds
has expired the stream offset.
+ AnalysisException expired =
Assertions.assertThrows(AnalysisException.class,
+ () -> ChangelogUtils.effectiveStartTso(99L, 100L, true));
+ MTMV mtmv = mockMtmv();
+ Command cmd = Mockito.mock(Command.class);
+ TestIvmIncrRefreshManager manager = new
TestIvmIncrRefreshManager(newContext(mtmv), makeCommands(cmd));
+ manager.failureMessage = expired.getMessage();
+
+ IvmIncrRefreshResult result = manager.doRefresh(manager.context);
+
+ Assertions.assertFalse(result.isSuccess());
+ Assertions.assertEquals(IvmFailureReason.CHANGELOG_BROKEN,
result.getFailureReason());
+ // A strict refresh reports this detail, so it has to keep the offset
and the cutoff.
+
Assertions.assertTrue(result.getDetailMessage().contains(expired.getMessage()));
+ Assertions.assertTrue(manager.executeCalled);
+ }
+
@Test
public void testManagerReturnsChangelogNotEnabledFallbackOnIvmException()
throws Exception {
MTMV mtmv = mockMtmv();
diff --git
a/regression-test/data/mtmv_p0/ivm/test_ivm_changelog_offset_expired.out
b/regression-test/data/mtmv_p0/ivm/test_ivm_changelog_offset_expired.out
new file mode 100644
index 00000000000..d5b8e92972c
--- /dev/null
+++ b/regression-test/data/mtmv_p0/ivm/test_ivm_changelog_offset_expired.out
@@ -0,0 +1,57 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !baseline_task --
+SUCCESS COMPLETE NONE false false
+
+-- !baseline_mv --
+1 10
+2 20
+
+-- !strict_task --
+FAILED NONE CHANGELOG_BROKEN true true
+
+-- !mv_after_strict --
+1 10
+2 20
+
+-- !fallback_task --
+SUCCESS COMPLETE CHANGELOG_BROKEN false false
+
+-- !mv_after_fallback --
+1 10
+2 20
+3 30
+
+-- !task_after_fallback --
+SUCCESS NONE NONE false false
+
+-- !mv_after_a_further_refresh --
+1 10
+2 20
+3 30
+4 40
+
+-- !partitioned_baseline_mv --
+1 2026-01-10 100 10
+2 2026-02-10 200 10
+
+-- !rebuild_strict_task --
+FAILED PARTIAL CHANGELOG_BROKEN true true
+
+-- !partitioned_mv_after_strict --
+1 2026-01-10 100 10
+2 2026-02-10 200 10
+
+-- !rebuild_fallback_task --
+SUCCESS COMPLETE CHANGELOG_BROKEN false false
+
+-- !partitioned_mv_after_fallback --
+1 2026-01-10 100 20
+2 2026-02-10 200 20
+
+-- !partitioned_task_after_fallback --
+SUCCESS NONE NONE false false
+
+-- !partitioned_mv_after_a_further_refresh --
+1 2026-01-10 100 30
+2 2026-02-10 200 20
+
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_changelog_offset_expired.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_changelog_offset_expired.groovy
new file mode 100644
index 00000000000..6bba1acb236
--- /dev/null
+++
b/regression-test/suites/mtmv_p0/ivm/test_ivm_changelog_offset_expired.groovy
@@ -0,0 +1,221 @@
+// 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 whose stream offset the base table's
changelog.ttl_seconds has expired.
+ *
+ * <p>An IVM MV reads a unique-key base table through an internal stream
created with MIN_DELTA, and planning
+ * such a read refuses to start from an offset older than the changelog the
table still retains. That refusal is
+ * the stream being broken for the incremental attempt, not a failure of the
refresh: the attempt reports it as
+ * CHANGELOG_BROKEN, and only COMPLETE, which resets the stream baselines, can
bring the MV back.
+ *
+ * <p>Cases pinned here:
+ * <ol>
+ * <li>the delta reads the expired offset: a strict INCREMENTAL refresh
fails with that reason and leaves the
+ * MV as it was, an AUTO refresh, which may fall back, rebuilds the MV
with COMPLETE and records the
+ * reason, and the refresh after it is incremental again;</li>
+ * <li>the rebuild an incremental attempt runs first for an MV partition
that needs one reads the expired
+ * offset of a table the MV does not partition by: the same answer,
before the delta runs.</li>
+ * </ol>
+ *
+ * <p>nonConcurrent: the offsets expire by sleeping past a one-second TTL.
+ */
+suite("test_ivm_changelog_offset_expired", "nonConcurrent") {
+ 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_mv"""
+ sql """DROP MATERIALIZED VIEW IF EXISTS ivm_offset_expired_pmv"""
+ sql """DROP TABLE IF EXISTS ivm_offset_expired_t"""
+ sql """DROP TABLE IF EXISTS ivm_offset_expired_f"""
+ sql """DROP TABLE IF EXISTS ivm_offset_expired_d"""
+
+ // 1. The delta reads the expired offset.
+ sql """
+ CREATE TABLE ivm_offset_expired_t (
+ id BIGINT NOT NULL,
+ v1 BIGINT
+ )
+ UNIQUE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true",
+ "changelog.enable" = "true",
+ "changelog.need_historical_value" = "true",
+ "changelog.ttl_seconds" = "86400"
+ )
+ """
+ sql """INSERT INTO ivm_offset_expired_t VALUES (1, 10), (2, 20)"""
+
+ sql """
+ CREATE MATERIALIZED VIEW ivm_offset_expired_mv
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ("replication_num" = "1")
+ AS SELECT id, v1 FROM ivm_offset_expired_t
+ """
+
+ sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_mv INCREMENTAL"""
+ def task = waitForNewTask('ivm_offset_expired_mv', null)
+ qt_baseline_task taskQuery(task.TaskId.toString())
+ order_qt_baseline_mv """SELECT id, v1 FROM ivm_offset_expired_mv ORDER BY
id"""
+
+ // The offset the baseline recorded falls out of the retention once the
TTL is shorter than its age. The row
+ // written after that is what gives the stream something to read, so the
next refresh plans the read.
+ sql """ALTER TABLE ivm_offset_expired_t SET ("changelog.ttl_seconds" =
"1")"""
+ sleep(1500)
+ sql """INSERT INTO ivm_offset_expired_t VALUES (3, 30)"""
+
+ sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_mv INCREMENTAL"""
+ task = waitForNewTask('ivm_offset_expired_mv', task.TaskId.toString())
+ qt_strict_task taskQuery(task.TaskId.toString())
+ order_qt_mv_after_strict """SELECT id, v1 FROM ivm_offset_expired_mv ORDER
BY id"""
+
+ sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_mv AUTO"""
+ task = waitForNewTask('ivm_offset_expired_mv', task.TaskId.toString())
+ qt_fallback_task taskQuery(task.TaskId.toString())
+ order_qt_mv_after_fallback """SELECT id, v1 FROM ivm_offset_expired_mv
ORDER BY id"""
+
+ // With the TTL back to a day, the offsets the rebuild left are well
inside the retention again.
+ sql """ALTER TABLE ivm_offset_expired_t SET ("changelog.ttl_seconds" =
"86400")"""
+ sql """INSERT INTO ivm_offset_expired_t VALUES (4, 40)"""
+ sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_mv AUTO"""
+ task = waitForNewTask('ivm_offset_expired_mv', task.TaskId.toString())
+ qt_task_after_fallback taskQuery(task.TaskId.toString())
+ order_qt_mv_after_a_further_refresh """SELECT id, v1 FROM
ivm_offset_expired_mv ORDER BY id"""
+
+ // 2. The rebuild before the delta reads the expired offset. The MV is
partitioned by the fact table and joins a
+ // dimension: rebuilding some of its partitions is a read of a subset of
the MV, which reads the dimension
+ // through its stream at the offset.
+ sql """
+ CREATE TABLE ivm_offset_expired_f (
+ k BIGINT NOT NULL,
+ dt DATE NOT NULL,
+ amount INT
+ )
+ UNIQUE KEY(k, dt)
+ PARTITION BY RANGE(dt) (
+ PARTITION p202601 VALUES [('2026-01-01'), ('2026-02-01')),
+ PARTITION p202602 VALUES [('2026-02-01'), ('2026-03-01'))
+ )
+ DISTRIBUTED BY HASH(k) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true",
+ "changelog.enable" = "true",
+ "changelog.need_historical_value" = "true",
+ "changelog.ttl_seconds" = "86400"
+ )
+ """
+ sql """
+ CREATE TABLE ivm_offset_expired_d (
+ k BIGINT NOT NULL,
+ v INT
+ )
+ UNIQUE KEY(k)
+ DISTRIBUTED BY HASH(k) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true",
+ "changelog.enable" = "true",
+ "changelog.need_historical_value" = "true",
+ "changelog.ttl_seconds" = "86400"
+ )
+ """
+ sql """INSERT INTO ivm_offset_expired_f VALUES (1, '2026-01-10', 100), (2,
'2026-02-10', 200)"""
+ sql """INSERT INTO ivm_offset_expired_d VALUES (1, 10), (2, 10)"""
+
+ sql """
+ CREATE MATERIALIZED VIEW ivm_offset_expired_pmv
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+ KEY(k, dt)
+ PARTITION BY(dt)
+ DISTRIBUTED BY HASH(k) BUCKETS 1
+ PROPERTIES ("replication_num" = "1")
+ AS SELECT f.k, f.dt, f.amount, d.v
+ FROM ivm_offset_expired_f f JOIN ivm_offset_expired_d d ON f.k = d.k
+ """
+
+ sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_pmv COMPLETE"""
+ task = waitForNewTask('ivm_offset_expired_pmv', null)
+ order_qt_partitioned_baseline_mv """SELECT k, dt, amount, v FROM
ivm_offset_expired_pmv ORDER BY k"""
+
+ // The dimension changes after its offset and the offset expires; the
truncate is a change no delta can
+ // repair, so the MV partition it reaches needs a rebuild.
+ sql """INSERT INTO ivm_offset_expired_d VALUES (1, 20), (2, 20)"""
+ sql """ALTER TABLE ivm_offset_expired_d SET ("changelog.ttl_seconds" =
"1")"""
+ sleep(1500)
+ sql """TRUNCATE TABLE ivm_offset_expired_f PARTITION(p202601)"""
+ sql """INSERT INTO ivm_offset_expired_f VALUES (1, '2026-01-10', 100)"""
+
+ sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_pmv INCREMENTAL"""
+ task = waitForNewTask('ivm_offset_expired_pmv', task.TaskId.toString())
+ qt_rebuild_strict_task taskQuery(task.TaskId.toString())
+ order_qt_partitioned_mv_after_strict """SELECT k, dt, amount, v FROM
ivm_offset_expired_pmv ORDER BY k"""
+
+ sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_pmv AUTO"""
+ task = waitForNewTask('ivm_offset_expired_pmv', task.TaskId.toString())
+ qt_rebuild_fallback_task taskQuery(task.TaskId.toString())
+ order_qt_partitioned_mv_after_fallback """SELECT k, dt, amount, v FROM
ivm_offset_expired_pmv ORDER BY k"""
+
+ sql """ALTER TABLE ivm_offset_expired_d SET ("changelog.ttl_seconds" =
"86400")"""
+ sql """INSERT INTO ivm_offset_expired_d VALUES (1, 30)"""
+ sql """REFRESH MATERIALIZED VIEW ivm_offset_expired_pmv AUTO"""
+ task = waitForNewTask('ivm_offset_expired_pmv', task.TaskId.toString())
+ qt_partitioned_task_after_fallback taskQuery(task.TaskId.toString())
+ order_qt_partitioned_mv_after_a_further_refresh """
+ SELECT k, dt, amount, v FROM ivm_offset_expired_pmv ORDER BY k
+ """
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]