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 9dc9dc75661 [feature](ivm) Report how a refresh task refreshed an IVM
MV, and count the refresh tasks on the Master (#69856)
9dc9dc75661 is described below
commit 9dc9dc75661dd0a1e5031b629bdc025a19c76839
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Mon Nov 2 09:41:47 2026 +0000
[feature](ivm) Report how a refresh task refreshed an IVM MV, and count the
refresh tasks on the Master (#69856)
### What problem does this PR solve?
Issue Number: #69907
Related PR: None
Problem Summary:
**In short.** A refresh of an IVM materialized view can keep the MV up
incrementally or quietly recompute it, and nothing tells the two apart:
`tasks("type"="mv")` does not say which kinds of work a task committed,
and `/metrics` has no IVM series at all. This PR makes each refresh task
of an IVM MV report how it refreshed the MV (`IvmRefreshMethod` in
`tasks()`) and why it failed or fell back, keeps a refresh summary on
the MV for the IVM status table that follows, and counts the tasks on
the Master in five new counters.
**Background.** An IVM MV is refreshed by a task that runs one or more
attempts: the incremental attempt (the delta of its base tables'
streams, after rebuilding the MV partitions an invalidation marked), a
partition rebuild, and a whole-MV rebuild. A request that may fall back
(AUTO, INCREMENTAL FALLBACK, PARTITIONS FALLBACK) moves on to the next
attempt when one gives up, for example to COMPLETE after the delta meets
a MIN/MAX boundary. `tasks()` already reports `RefreshMode` (which
partitions the task covered), `IvmFallbackReason` (why the incremental
attempt gave up, or why a strict request failed) and
`IvmRebuiltPartitions`.
**The problem, and what it cost.**
- An operator cannot tell a refresh that kept the MV up incrementally
from one that recomputed it. `RefreshMode` says `COMPLETE` for any
whole-MV scope, and `IvmFallbackReason` is set even when the attempt
that replaced the delta wrote nothing, or when a strict request failed.
- No alert can watch IVM refreshes. The asynchronous MV counters
(`async_materialized_view_task_success_num`, `_failed_num`, `_skip_num`)
cover every MV and say nothing about the method, the fallbacks, or why
tasks fail. An MV that falls back to COMPLETE on every run, the
expensive failure mode of IVM, goes unnoticed until the load of the
cluster shows it.
- A task that the full MV task queue turns away never runs, and nothing
counts it.
- The task history keeps only `max_persistence_task_count` tasks, so how
many times in a row an MV has failed, or when it last fell back, cannot
be read back from it.
**How this PR fixes it.**
- `MTMVTask` notes each batch that commits data (the delta, a partition
rebuild, a whole-MV rebuild) and decides `IvmRefreshMethod` once, as the
task publishes its result: `NONE`, `INCREMENTAL`, `PARTITION_REBUILD`,
`COMPLETE`, or `MIXED` when more than one kind committed, typically the
rebuild of invalidated partitions before the delta. `tasks("type"="mv")`
gets the `IvmRefreshMethod` column, NULL while a task runs and for a
task of an MV that is not an IVM MV.
- A task falls back when the incremental attempt gives up and an attempt
replacing it commits data. Not fallbacks: building the first baseline of
an MV no refresh has succeeded on, the rebuild of invalidated partitions
before the delta, an explicit COMPLETE or PARTITIONS request, and a
fallback that commits nothing.
- A failed task reports why: the `IvmFailureReason` a request that may
not fall back failed with, or else the stage it failed in,
`QUERY_UNUSABLE`, `PARTITION_SYNC_FAILED`, `EXECUTION_FAILED` or
`OTHER`, told apart by where the failure was thrown rather than by its
message.
- The method, the failure reason and whether the task fell back are
journaled with the task, as new Gson fields that an older FE ignores.
The MV keeps an `IvmRefreshSummary`, updated from each task result in
`MTMV.addTaskResult`, which the Master and the replay of the same
journal record run alike, from what the record carries: consecutive
failures, the latest success and failure (with the reason and the error
message, cut to 512 characters), the latest method and run time, and the
fallback count with the latest reason. A canceled task leaves it as it
is. Nothing reads it in this PR; `information_schema.ivm_mv_status` and
the per-MV series of a follow-up PR do.
- Five counters, counted on the Master as each task of an IVM MV ends.
Every label combination is registered up front, so each of the 61 series
exists from 0:
| Series | Type | Labels | Value |
| --- | --- | --- | --- |
| `doris_fe_ivm_refresh_tasks_total` | counter |
`result`=success/no_change/failed/canceled,
`method`=none/incremental/partition_rebuild/complete/mixed | refresh
tasks that ended; `no_change` is a SUCCESS that committed nothing |
| `doris_fe_ivm_refresh_duration_seconds_total` | counter | `method` |
the run time, StartTime to FinishTime, of the tasks that succeeded or
failed; a canceled task has no finish time |
| `doris_fe_ivm_fallbacks_total` | counter | `reason`=the
`IvmFailureReason` values, `incomplete_refresh_snapshot` | tasks that
fell back |
| `doris_fe_ivm_refresh_failures_total` | counter | `reason`=the
`IvmFailureReason` values, the four stages | tasks that failed |
| `doris_fe_ivm_schedule_rejected_total` | counter | | tasks the full MV
task queue turned away |
Like the other FE event counters they live in the registry of every FE
and only the Master counts in them, so a query sums them over the FEs; a
restart or a new Master starts them from 0.
- `AbstractTask.onRejected`, a new hook that `TaskProcessor.addTask`
calls when the executor queue rejects a task. Both the instant dispatch
(`JobScheduler`) and the timer dispatch (`DispatchTaskHandler`) add
tasks through it, and `MTMVTask` counts the rejection for an IVM MV. A
timer-dispatched task that is turned away is canceled as before, so it
also counts as `canceled`.
**Results.** Before this PR, `tasks()` has no method column and
`/metrics` has none of these series. After it, on a local cluster, one
IVM MV refreshed through each case (regression
`test_ivm_refresh_method_metrics`):
| Refresh | `tasks()`: Status, IvmRefreshMethod, IvmFallbackReason |
Counters that moved, besides the run time |
| --- | --- | --- |
| first AUTO refresh of a new MV | SUCCESS, COMPLETE, NULL |
`tasks{success,complete}` |
| INCREMENTAL after an insert | SUCCESS, INCREMENTAL, NULL |
`tasks{success,incremental}` |
| INCREMENTAL with nothing new | SUCCESS, NONE, NULL |
`tasks{no_change,none}` |
| INCREMENTAL after deleting the row that holds a group's minimum |
FAILED, NONE, MIN_MAX_BOUNDARY_HIT | `tasks{failed,none}`,
`failures{min_max_boundary_hit}` |
| INCREMENTAL FALLBACK in the same state | SUCCESS, COMPLETE,
MIN_MAX_BOUNDARY_HIT | `tasks{success,complete}`,
`fallbacks{min_max_boundary_hit}` |
| COMPLETE | SUCCESS, COMPLETE, NULL | `tasks{success,complete}` |
| INCREMENTAL after truncating a base partition and inserting into
another | SUCCESS, MIXED, NULL | `tasks{success,mixed}` |
The first refresh of a new MV finds every partition still to be filled
and goes straight to COMPLETE; it counts no fallback. The MV holds what
its base table does after each case.
**Classes, and how they call each other.**
- `IvmRefreshMethod`, `IvmRefreshFailureStage`
(`org.apache.doris.mtmv.ivm`): the methods, and the stages a failure is
reported by.
- `MTMVTask`: notes committed batches (`recordCommitted`), decides
whether giving up the incremental attempt is a fallback
(`givingUpTheIncrementalAttemptIsAFallback`), moves the failure stage
along, settles the method and the failure reason in `after()`, and
counts the task (`recordIvmRefreshMetrics`) and a rejection
(`onRejected`).
- `IvmRefreshSummary` (`org.apache.doris.mtmv.ivm`): persisted on `MTMV`
and updated by `update(MTMVTask)` from `MTMV.addTaskResult`;
`MTMV.hasSucceededIvmRefresh` tells the first baseline apart.
- `MetricRepo`: registers the five counters. `DoubleCounterMetric` (new)
keeps a total in seconds without rounding each addition.
- `AbstractTask.onRejected` (new, empty by default), called by
`TaskProcessor.addTask`.
```
MTMVTask.run() worker thread
├─ executeIvmAttempt
│ ├─ rebuild invalidated partitions ──►
recordCommitted(PARTITION_REBUILD)
│ └─ delta (IvmIncrRefreshManager) ───► recordCommitted(INCREMENTAL)
│ gave up ─► fallingBack = givingUpTheIncrementalAttemptIsAFallback()
├─ PARTITIONS / COMPLETE attempts ───────► recordCommitted(...)
ivmFallback once fallingBack
└─ failureStage: QUERY_UNUSABLE ─► PARTITION_SYNC_FAILED ─►
EXECUTION_FAILED, or an IvmFailureReason
onSuccess / onFail / cancel ─► after()
├─ ivmRefreshMethod, ivmFailureReason settled
├─ recordIvmRefreshMetrics() ─► MetricRepo.COUNTER_IVM_*
Master only
└─ Env.addMTMVTaskResult ─► MTMV.addTaskResult ─►
IvmRefreshSummary.update(task)
(journaled as ADD_TASK; the replay calls
addTaskResult the same way)
TaskProcessor.addTask ── queue full ─► task.onRejected() ─► MTMVTask:
COUNTER_IVM_SCHEDULE_REJECTED
```
Nothing else changes: no thrift or proto change, no new config, and the
existing columns and series stay as they are.
---
.../main/java/org/apache/doris/catalog/MTMV.java | 23 ++
.../apache/doris/job/executor/TaskProcessor.java | 1 +
.../apache/doris/job/extensions/mtmv/MTMVTask.java | 162 ++++++++++-
.../org/apache/doris/job/task/AbstractTask.java | 7 +
.../apache/doris/metric/DoubleCounterMetric.java | 43 +++
.../java/org/apache/doris/metric/MetricRepo.java | 67 +++++
.../doris/mtmv/ivm/IvmRefreshFailureStage.java | 37 +++
.../apache/doris/mtmv/ivm/IvmRefreshMethod.java | 55 ++++
.../apache/doris/mtmv/ivm/IvmRefreshSummary.java | 142 ++++++++++
.../doris/job/executor/TaskProcessorTest.java | 46 +++
.../apache/doris/metric/IvmRefreshMetricsTest.java | 161 +++++++++++
.../java/org/apache/doris/mtmv/MTMVTaskTest.java | 307 +++++++++++++++++++++
.../test/java/org/apache/doris/mtmv/MTMVTest.java | 93 +++++++
.../doris/mtmv/ivm/IvmBaselineRebuildTest.java | 3 +
.../doris/mtmv/ivm/IvmRefreshSummaryTest.java | 163 +++++++++++
.../ivm/test_ivm_refresh_method_metrics.out | 40 +++
.../ivm/test_ivm_refresh_method_metrics.groovy | 233 ++++++++++++++++
17 files changed, 1581 insertions(+), 2 deletions(-)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
b/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
index dad9765cf9b..c76ccd636e1 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
@@ -56,6 +56,7 @@ import org.apache.doris.mtmv.MTMVSnapshotIf;
import org.apache.doris.mtmv.MTMVStatus;
import org.apache.doris.mtmv.MTMVUtil;
import org.apache.doris.mtmv.ivm.IvmInfo;
+import org.apache.doris.mtmv.ivm.IvmRefreshSummary;
import org.apache.doris.mtmv.ivm.IvmUtil;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.rules.analysis.SessionVarGuardRewriter;
@@ -126,6 +127,10 @@ public class MTMV extends OlapTable {
*/
@SerializedName("pst")
private Map<String, MTMVPartitionState> partitionStates =
Maps.newLinkedHashMap();
+ // What the refresh tasks of an IVM MV have come to; updated with each
task result, see addTaskResult. An MV
+ // that is not an IVM MV leaves it empty.
+ @SerializedName("irs")
+ private IvmRefreshSummary ivmRefreshSummary = new IvmRefreshSummary();
// Increased every time rewrite cache is invalidated to prevent publishing
stale in-flight cache builds.
private transient long rewriteCacheGeneration;
private long schemaChangeVersion;
@@ -320,6 +325,19 @@ public class MTMV extends OlapTable {
return getIvmInfo().isEnableIvm();
}
+ /**
+ * Whether a refresh task of this IVM MV has ever succeeded. Until one
has, the MV has no baseline for the
+ * incremental refresh to keep up, and the refresh that builds it is not
the incremental refresh falling back.
+ */
+ public boolean hasSucceededIvmRefresh() {
+ readMvLock();
+ try {
+ return ivmRefreshSummary.hasSucceeded();
+ } finally {
+ readMvUnlock();
+ }
+ }
+
/**
* Whether this MV is an IVM MV built as UNIQUE KEY + merge-on-write,
which is one of the two table
* models: it is maintained through the hidden row-id, and the layout its
refresh reads is pinned
@@ -447,6 +465,11 @@ public class MTMV extends OlapTable {
this.status.setRefreshState(MTMVRefreshState.FAIL);
}
this.jobInfo.addHistoryTask(task);
+ if (ivmInfo.isEnableIvm()) {
+ // From the task as the journal carries it, here and in the
replay alike, so that the summary does
+ // not depend on how many tasks the history keeps and a
follower that takes over has the same one.
+ ivmRefreshSummary.update(task);
+ }
compatiblePctSnapshot(partitionSnapshots);
// What this task wrote is described by the epochs just recorded,
so a partition the result
// left dirty is left out: its snapshot would otherwise come back
after an invalidation
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/job/executor/TaskProcessor.java
b/fe/fe-core/src/main/java/org/apache/doris/job/executor/TaskProcessor.java
index 5a46ef13864..b8d00abf4f0 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/job/executor/TaskProcessor.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/job/executor/TaskProcessor.java
@@ -53,6 +53,7 @@ public class TaskProcessor {
return true;
} catch (RejectedExecutionException e) {
log.warn("Failed to add task to executor, task id: {}",
task.getTaskId(), e);
+ task.onRejected();
return false;
}
}
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 055d9fef675..9886cf718ea 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
@@ -72,6 +72,8 @@ import org.apache.doris.mtmv.ivm.IvmIncrRefreshContext;
import org.apache.doris.mtmv.ivm.IvmIncrRefreshManager;
import org.apache.doris.mtmv.ivm.IvmIncrRefreshResult;
import org.apache.doris.mtmv.ivm.IvmPlanSignature;
+import org.apache.doris.mtmv.ivm.IvmRefreshFailureStage;
+import org.apache.doris.mtmv.ivm.IvmRefreshMethod;
import org.apache.doris.mtmv.ivm.IvmRewriteContext;
import org.apache.doris.mtmv.ivm.IvmUtil;
import org.apache.doris.nereids.NereidsPlanner;
@@ -104,6 +106,7 @@ import org.apache.logging.log4j.Logger;
import java.math.BigDecimal;
import java.math.RoundingMode;
+import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
@@ -114,9 +117,11 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
+import java.util.stream.Stream;
public class MTMVTask extends AbstractTask {
private static final Logger LOG = LogManager.getLogger(MTMVTask.class);
@@ -125,6 +130,17 @@ public class MTMVTask extends AbstractTask {
"MTMVTask.syncPartitionsIfNeeded.skip";
public static final String DEBUG_POINT_SKIP_PARTITION_SYNC_FILTER =
"MTMVTask.syncPartitionsIfNeeded.skip.filter";
+ // The fallback reason of an incremental attempt that found no refresh
snapshot to catch up from.
+ public static final String INCOMPLETE_REFRESH_SNAPSHOT =
"INCOMPLETE_REFRESH_SNAPSHOT";
+ // Every value ivmFallbackReason can take, and every value
ivmFailureReason can take, each of which the IVM
+ // refresh metrics register a series for.
+ public static final ImmutableList<String> IVM_FALLBACK_REASONS =
Stream.concat(
+ Arrays.stream(IvmFailureReason.values()).map(Enum::name),
Stream.of(INCOMPLETE_REFRESH_SNAPSHOT))
+ .collect(ImmutableList.toImmutableList());
+ public static final ImmutableList<String> IVM_FAILURE_REASONS =
Stream.concat(
+ Arrays.stream(IvmFailureReason.values()).map(Enum::name),
+ Arrays.stream(IvmRefreshFailureStage.values()).map(Enum::name))
+ .collect(ImmutableList.toImmutableList());
private static final Gson GSON = new Gson();
@@ -150,7 +166,8 @@ public class MTMVTask extends AbstractTask {
new Column("LastQueryId", ScalarType.createStringType()),
new Column("ComputeGroup", ScalarType.createStringType()),
new Column("IvmFallbackReason", ScalarType.createStringType()),
- new Column("IvmRebuiltPartitions", ScalarType.createStringType()));
+ new Column("IvmRebuiltPartitions", ScalarType.createStringType()),
+ new Column("IvmRefreshMethod", ScalarType.createStringType()));
public static final ImmutableMap<String, Integer> COLUMN_TO_INDEX;
@@ -304,6 +321,28 @@ public class MTMVTask extends AbstractTask {
// INCREMENTAL request reports so that "the request was incremental but
the work was not" is visible.
@SerializedName("irp")
private int ivmRebuiltPartitions;
+ // How this task refreshed an IVM MV, by the kinds of work that committed
data. Decided once, as the task
+ // ends (see after()), so it is null while the task runs and for a task of
a non-IVM MV.
+ @SerializedName("irm")
+ private IvmRefreshMethod ivmRefreshMethod;
+ // The reason a failed task of an IVM MV reports: the IvmFailureReason a
request that may not fall back failed
+ // with, or else the IvmRefreshFailureStage the failure happened in. Null
for any other task.
+ @SerializedName("ifl")
+ private String ivmFailureReason;
+ // Whether this task fell back from the incremental refresh and an attempt
that replaced it committed data. Its
+ // reason is ivmFallbackReason. A fallback that committed nothing did not
change how the MV was refreshed.
+ @SerializedName("ifb")
+ private boolean ivmFallback;
+ // The kinds of work this task has committed, which ivmRefreshMethod is
decided from. Concurrent because a STOP
+ // decides it from the cancel thread while the worker may still be
committing.
+ private transient Set<IvmRefreshMethod> committedRefreshKinds =
ConcurrentHashMap.newKeySet();
+ // Set once the incremental attempt has given up for a reason that counts
as a fallback: what an attempt
+ // commits after that is the fallback's work. Only the worker thread reads
and writes it.
+ private transient boolean fallingBack;
+ // What this task reports as its failure reason if it fails at this point;
see ivmFailureReason. Moved along as
+ // the task passes its stages, and set to an IvmFailureReason where a
request that may not fall back fails.
+ // Only the worker thread writes it, and it is read as the worker reports
the failure.
+ private transient String failureStage =
IvmRefreshFailureStage.OTHER.name();
private long mtmvSchemaChangeVersion;
// Published only after a signature-mismatch fallback succeeds and its
task result is accepted.
private transient String refreshedIvmPlanSignature;
@@ -343,6 +382,7 @@ public class MTMVTask extends AbstractTask {
}
// Every time a task is run, the relation is regenerated because
baseTables and baseViews may change,
// such as deleting a table and creating a view with the same name
+ failureStage = IvmRefreshFailureStage.QUERY_UNUSABLE.name();
MTMVPlanUtil.QueryAnalysisResult queryAnalysis =
MTMVPlanUtil.getBaseTableFromQuery(
mtmv.getQuerySql(), ctx);
this.relation =
MTMVPlanUtil.generateMTMVRelation(queryAnalysis.getAllLevelTables(),
@@ -356,12 +396,15 @@ public class MTMVTask extends AbstractTask {
// its base-table schema and partition definition. It is not part
of
// refresh fallback: incompatible MV definitions must fail
directly.
ensureQueryUsableIfNeeded(ctx, tableIfs);
+ failureStage = IvmRefreshFailureStage.OTHER.name();
RefreshRequest request = resolveRefreshRequest();
+ failureStage = IvmRefreshFailureStage.PARTITION_SYNC_FAILED.name();
try {
syncPartitionsIfNeeded(ctx, tableIfs);
} catch (PartitionPlanningException e) {
throw new JobException(e.getMessage(), e);
}
+ failureStage = IvmRefreshFailureStage.OTHER.name();
// Partition sync has decided which partitions exist, and nothing
has read a base table yet:
// this is the point where an entry and the partition it describes
become the same thing.
// Doing it any later would let a partition that sync has just
added be refreshed without an
@@ -372,6 +415,7 @@ public class MTMVTask extends AbstractTask {
List<RefreshAttemptType> attempts = buildAttempts(request,
queryAnalysis.containsOneRowRelation());
MTMVRefreshContext refreshContext = buildRefreshContext(tableIfs);
boolean disablePartitionRefresh = false;
+ failureStage = IvmRefreshFailureStage.EXECUTION_FAILED.name();
for (RefreshAttemptType attemptType : attempts) {
switch (attemptType) {
case IVM:
@@ -379,6 +423,7 @@ public class MTMVTask extends AbstractTask {
if (ivmResult == AttemptResultType.SUCCESS) {
return;
}
+ fallingBack =
givingUpTheIncrementalAttemptIsAFallback();
if (ivmResult ==
AttemptResultType.FALLBACK_TO_COMPLETE) {
disablePartitionRefresh = true;
}
@@ -407,6 +452,7 @@ public class MTMVTask extends AbstractTask {
throw new JobException("Unsupported refresh attempt
type: " + attemptType);
}
}
+ failureStage = IvmRefreshFailureStage.OTHER.name();
throw new JobException("No refresh attempt succeeded for mv=" +
mtmv.getName());
} catch (Throwable e) {
if (getStatus() == TaskStatus.RUNNING) {
@@ -603,6 +649,8 @@ public class MTMVTask extends AbstractTask {
if (request.allowFallback && mtmv.isIvm() &&
attempts.contains(RefreshAttemptType.IVM)
&& hasUnusableIvmStream()) {
ivmFallbackReason = IvmFailureReason.STREAM_UNSUPPORTED.name();
+ // The incremental attempt is given up before it runs, which is a
fallback all the same.
+ fallingBack = true;
LOG.warn("IVM stream is unusable, mv={}, taskId={}. Continuing
with COMPLETE refresh.",
mtmv.getName(), getTaskId());
return Lists.newArrayList(RefreshAttemptType.COMPLETE);
@@ -659,6 +707,15 @@ public class MTMVTask extends AbstractTask {
ivmRebuiltPartitions = Math.max(ivmRebuiltPartitions, replaced);
}
+ /**
+ * Whether the incremental attempt giving up makes this a task that falls
back: the attempts after it rebuild what
+ * the incremental refresh could not catch up. The exception is an MV no
refresh has succeeded on yet, which has no
+ * baseline to catch up -- building the first one is not the incremental
refresh giving up.
+ */
+ private boolean givingUpTheIncrementalAttemptIsAFallback() {
+ return !INCOMPLETE_REFRESH_SNAPSHOT.equals(ivmFallbackReason) ||
mtmv.hasSucceededIvmRefresh();
+ }
+
private boolean shouldUseCompleteForInitialIvmRefresh(boolean
containsOneRowRelation) {
if (!mtmv.isIvm() || mtmv.hasRefreshSnapshot()) {
return false;
@@ -807,7 +864,7 @@ public class MTMVTask extends AbstractTask {
// A strict INCREMENTAL request must reach IVM so the stream can
establish its initial baseline.
// Only fallback-enabled requests may rebuild a missing or invalidated
snapshot with COMPLETE.
if (!mtmv.hasRefreshSnapshot() && request.allowFallback) {
- ivmFallbackReason = "INCOMPLETE_REFRESH_SNAPSHOT";
+ ivmFallbackReason = INCOMPLETE_REFRESH_SNAPSHOT;
LOG.warn("IVM refresh fell back for mv={},
reason=INCOMPLETE_REFRESH_SNAPSHOT, taskId={}. "
+ "Continuing with COMPLETE refresh.", mtmv.getName(),
getTaskId());
return AttemptResultType.FALLBACK_TO_COMPLETE;
@@ -893,12 +950,14 @@ public class MTMVTask extends AbstractTask {
adoptPartitionsCreatedByTheRetry(dirtyPartitions);
currentRefreshContext = buildRefreshContext(tableIfs);
} catch (Exception e) {
+ failureStage =
IvmRefreshFailureStage.PARTITION_SYNC_FAILED.name();
throw new JobException("Failed to synchronize MV partitions
before IVM retry for mv="
+ mtmv.getName(), e);
}
LOG.warn("Retrying IVM refresh after synchronizing MV partitions,
mv={}, attempt={}/{}, taskId={}",
mtmv.getName(), partitionSyncRetryCount + 1,
ivmAttemptLimit, getTaskId());
}
+ failureStage = IvmFailureReason.MV_PARTITION_NOT_FOUND.name();
throw new JobException("IVM refresh could not recover missing MV
partition for mv="
+ mtmv.getName() + ", detail=" + ivmResult.getDetailMessage());
}
@@ -961,6 +1020,7 @@ public class MTMVTask extends AbstractTask {
+ ", detail=" + Util.getRootCauseMessage(e), e);
}
if (ivmResult.isSuccess()) {
+ recordCommitted(IvmRefreshMethod.INCREMENTAL);
this.partitionSnapshots.putAll(capturedSnapshots);
recordRefreshCompleted(incrementalScope);
commitCapturedEpochs(capturedEpochs);
@@ -1014,6 +1074,18 @@ public class MTMVTask extends AbstractTask {
completedPartitions.addAll(partitions);
}
+ /**
+ * Notes that a batch of this kind of work committed data, which is what
ivmRefreshMethod is decided from. What
+ * commits once the incremental attempt has given up is the fallback's
work, and it is what makes this a task
+ * that fell back: a fallback that commits nothing leaves the MV as the
incremental refresh would have.
+ */
+ private void recordCommitted(IvmRefreshMethod kind) {
+ committedRefreshKinds.add(kind);
+ if (fallingBack) {
+ ivmFallback = true;
+ }
+ }
+
/**
* Says durably that the parts of the scope that do not name a rebuild
requirement have to be rebuilt, and
* brings the epochs this phase records in line with it.
@@ -1206,6 +1278,7 @@ public class MTMVTask extends AbstractTask {
throws JobException {
ivmFallbackReason = ivmResult.getFailureReason().name();
if (!request.allowFallback) {
+ failureStage = ivmResult.getFailureReason().name();
throw new JobException(
"IVM incremental refresh failed for mv=" + mtmv.getName()
+ ", reason=" + ivmResult.getFailureReason()
@@ -1246,6 +1319,7 @@ public class MTMVTask extends AbstractTask {
if (mtmv.isIvm()
&& hasUnusableIvmStreamForPartitions(partitionPlan.context,
partitionPlan.partitions)) {
if (!request.allowFallback) {
+ failureStage = IvmFailureReason.STREAM_UNSUPPORTED.name();
throw new JobException("IVM stream is unusable for the
partitions of this refresh, mv="
+ mtmv.getName());
}
@@ -1349,6 +1423,8 @@ public class MTMVTask extends AbstractTask {
mtmv.getName(), getTaskId(), e);
throw new JobException(e.getMessage(), e);
}
+ recordCommitted(refreshMode == RefreshMode.COMPLETE
+ ? IvmRefreshMethod.COMPLETE :
IvmRefreshMethod.PARTITION_REBUILD);
recordRefreshCompleted(execPartitionNames);
// What this batch may record: everything it replaced, unless the
read behind it answered with a
// base table the MV does not partition by as of an older state
than that table is in now -- which
@@ -1905,6 +1981,8 @@ public class MTMVTask extends AbstractTask {
trow.addToColumnValue(new TCell().setStringVal(
ivmFallbackReason == null ? FeConstants.null_string :
ivmFallbackReason));
trow.addToColumnValue(new
TCell().setStringVal(String.valueOf(ivmRebuiltPartitions)));
+ trow.addToColumnValue(new TCell().setStringVal(
+ ivmRefreshMethod == null ? FeConstants.null_string :
ivmRefreshMethod.name()));
return trow;
}
@@ -1926,6 +2004,16 @@ public class MTMVTask extends AbstractTask {
private void after() {
if (mtmv != null) {
+ if (mtmv.isIvm()) {
+ // Decided here, once, and before the result is journaled: a
STOP publishes from the cancel thread
+ // while the worker may still commit a batch, and what the
journal carries has to be what the result
+ // was recorded and counted with.
+ ivmRefreshMethod = IvmRefreshMethod.of(committedRefreshKinds);
+ if (getStatus() == TaskStatus.FAILED) {
+ ivmFailureReason = failureStage;
+ }
+ recordIvmRefreshMetrics();
+ }
// A copy, because this is not always the thread that fills the
map: a STOP publishes from the
// cancel thread and `cancel(false)` does not wait for the
execution to stop, so the batches the
// worker commits afterwards would otherwise land in a result that
was already built -- and
@@ -1940,6 +2028,60 @@ public class MTMVTask extends AbstractTask {
}
+ /**
+ * Counts this task in the IVM refresh metrics as it ends. Only the Master
runs a task, and the replay of its
+ * result does not come here, so a follower never counts it again.
+ */
+ private void recordIvmRefreshMetrics() {
+ if (!MetricRepo.isInit) {
+ return;
+ }
+ String result;
+ switch (getStatus()) {
+ case SUCCESS:
+ result = ivmRefreshMethod == IvmRefreshMethod.NONE
+ ? MetricRepo.IVM_REFRESH_RESULT_NO_CHANGE :
MetricRepo.IVM_REFRESH_RESULT_SUCCESS;
+ break;
+ case FAILED:
+ result = MetricRepo.IVM_REFRESH_RESULT_FAILED;
+ break;
+ case CANCELED:
+ result = MetricRepo.IVM_REFRESH_RESULT_CANCELED;
+ break;
+ default:
+ throw new IllegalStateException("A task cannot end as " +
getStatus() + ", taskId=" + getTaskId());
+ }
+
MetricRepo.COUNTER_IVM_REFRESH_TASKS.get(result).get(ivmRefreshMethod).increase(1L);
+ if (getStatus() == TaskStatus.CANCELED) {
+ // A canceled task has no finish time to measure it by, and a
fallback it started is not one it finished.
+ return;
+ }
+ MetricRepo.COUNTER_IVM_REFRESH_DURATION_SECONDS.get(ivmRefreshMethod)
+ .increase((getFinishTimeMs() - getStartTimeMs()) / 1000.0);
+ if (ivmFallback) {
+
MetricRepo.COUNTER_IVM_FALLBACKS.get(ivmFallbackReason).increase(1L);
+ }
+ if (getStatus() == TaskStatus.FAILED) {
+
MetricRepo.COUNTER_IVM_REFRESH_FAILURES.get(ivmFailureReason).increase(1L);
+ }
+ }
+
+ /** Counts a task of an IVM MV that the full task queue turned away: it
never runs, so nothing else counts it. */
+ @Override
+ public void onRejected() {
+ MTMV rejectedMv;
+ try {
+ rejectedMv = MTMVUtil.getMTMV(dbId, mtmvId);
+ } catch (UserException e) {
+ // dropped since the task was created
+ LOG.warn("can not find mv of the rejected task {}", getTaskId(),
e);
+ return;
+ }
+ if (MetricRepo.isInit && rejectedMv.isIvm()) {
+ MetricRepo.COUNTER_IVM_SCHEDULE_REJECTED.increase(1L);
+ }
+ }
+
@Override
protected void closeOrReleaseResources() {
if (null != mtmv) {
@@ -2009,6 +2151,22 @@ public class MTMVTask extends AbstractTask {
return refreshedIvmPlanSignature;
}
+ public IvmRefreshMethod getIvmRefreshMethod() {
+ return ivmRefreshMethod;
+ }
+
+ public String getIvmFailureReason() {
+ return ivmFailureReason;
+ }
+
+ public boolean isIvmFallback() {
+ return ivmFallback;
+ }
+
+ public String getIvmFallbackReason() {
+ return ivmFallbackReason;
+ }
+
@Override
public String toString() {
return "MTMVTask{"
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/job/task/AbstractTask.java
b/fe/fe-core/src/main/java/org/apache/doris/job/task/AbstractTask.java
index f597998c0c1..94b6cff01af 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/job/task/AbstractTask.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/job/task/AbstractTask.java
@@ -163,6 +163,13 @@ public abstract class AbstractTask implements Task {
}
}
+ /**
+ * Called when the executor turns this task away because its queue is
full, so the task never runs. The caller
+ * goes on to fail or cancel it as it would any other task.
+ */
+ public void onRejected() {
+ }
+
/**
* Abstract method for implementing the task-specific cancellation logic.
* Subclasses must override this method to provide their own
implementation of how a task should be canceled.
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/metric/DoubleCounterMetric.java
b/fe/fe-core/src/main/java/org/apache/doris/metric/DoubleCounterMetric.java
new file mode 100644
index 00000000000..7597fac3a54
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/metric/DoubleCounterMetric.java
@@ -0,0 +1,43 @@
+// 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.metric;
+
+import java.util.concurrent.atomic.DoubleAdder;
+
+/**
+ * A counter of a quantity with a fractional part, such as a total in seconds,
which a LongCounterMetric would have to
+ * round each addition of.
+ */
+public class DoubleCounterMetric extends CounterMetric<Double> {
+
+ public DoubleCounterMetric(String name, MetricUnit unit, String
description) {
+ super(name, unit, description);
+ }
+
+ private final DoubleAdder value = new DoubleAdder();
+
+ @Override
+ public void increase(Double delta) {
+ value.add(delta);
+ }
+
+ @Override
+ public Double getValue() {
+ return value.sum();
+ }
+}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/metric/MetricRepo.java
b/fe/fe-core/src/main/java/org/apache/doris/metric/MetricRepo.java
index c54a93eaefe..e49eba0a02d 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/metric/MetricRepo.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/metric/MetricRepo.java
@@ -37,6 +37,7 @@ import org.apache.doris.job.common.JobStatus;
import org.apache.doris.job.common.TaskStatus;
import org.apache.doris.job.extensions.insert.streaming.StreamingInsertJob;
import org.apache.doris.job.extensions.insert.streaming.StreamingJobStatistic;
+import org.apache.doris.job.extensions.mtmv.MTMVTask;
import org.apache.doris.load.EtlJobType;
import org.apache.doris.load.loadv2.JobState;
import org.apache.doris.load.loadv2.LoadManager;
@@ -50,6 +51,7 @@ import
org.apache.doris.mtmv.MTMVPartitionInfo.MTMVPartitionType;
import org.apache.doris.mtmv.MTMVRefreshEnum.RefreshMethod;
import org.apache.doris.mtmv.MTMVRefreshEnum.RefreshTrigger;
import org.apache.doris.mtmv.MTMVUtil;
+import org.apache.doris.mtmv.ivm.IvmRefreshMethod;
import org.apache.doris.mysql.privilege.Auth;
import org.apache.doris.nereids.NereidsPlanner;
import org.apache.doris.persist.EditLog;
@@ -272,6 +274,20 @@ public final class MetricRepo {
public static GaugeMetric<Integer>
GAUGE_ASYNC_MATERIALIZED_VIEW_TASK_RUNNING_NUM;
public static GaugeMetric<Integer>
GAUGE_ASYNC_MATERIALIZED_VIEW_TASK_PENDING_NUM;
+ // The refresh tasks of IVM materialized views, counted on the Master as
each one ends.
+ // The result label of COUNTER_IVM_REFRESH_TASKS: no_change is a SUCCESS
that committed nothing.
+ public static final String IVM_REFRESH_RESULT_SUCCESS = "success";
+ public static final String IVM_REFRESH_RESULT_NO_CHANGE = "no_change";
+ public static final String IVM_REFRESH_RESULT_FAILED = "failed";
+ public static final String IVM_REFRESH_RESULT_CANCELED = "canceled";
+ // by result, then by IvmRefreshMethod
+ public static Map<String, Map<IvmRefreshMethod, LongCounterMetric>>
COUNTER_IVM_REFRESH_TASKS;
+ public static Map<IvmRefreshMethod, DoubleCounterMetric>
COUNTER_IVM_REFRESH_DURATION_SECONDS;
+ // by MTMVTask.IVM_FALLBACK_REASONS and MTMVTask.IVM_FAILURE_REASONS
+ public static Map<String, LongCounterMetric> COUNTER_IVM_FALLBACKS;
+ public static Map<String, LongCounterMetric> COUNTER_IVM_REFRESH_FAILURES;
+ public static LongCounterMetric COUNTER_IVM_SCHEDULE_REJECTED;
+
// Statistics
public static GaugeMetric<Double> GAUGE_STATISTICS_UNHEALTHY_TABLE_RATE;
public static GaugeMetric<Double> GAUGE_STATISTICS_UNHEALTHY_COLUMN_RATE;
@@ -1146,6 +1162,44 @@ public final class MetricRepo {
}
};
DORIS_METRIC_REGISTER.addMetrics(GAUGE_ASYNC_MATERIALIZED_VIEW_TASK_PENDING_NUM);
+
+ // IVM refresh tasks, counted by MTMVTask as each task of an IVM MV
ends. Every combination of labels is
+ // registered here, so that each series exists from 0 rather than
appearing with its first event.
+ COUNTER_IVM_REFRESH_TASKS = Maps.newHashMap();
+ for (String result : new String[] {IVM_REFRESH_RESULT_SUCCESS,
IVM_REFRESH_RESULT_NO_CHANGE,
+ IVM_REFRESH_RESULT_FAILED, IVM_REFRESH_RESULT_CANCELED}) {
+ Map<IvmRefreshMethod, LongCounterMetric> byMethod =
Maps.newEnumMap(IvmRefreshMethod.class);
+ for (IvmRefreshMethod method : IvmRefreshMethod.values()) {
+ LongCounterMetric counter = new
LongCounterMetric("ivm_refresh_tasks_total", MetricUnit.NOUNIT,
+ "number of the refresh tasks of IVM materialized views
that ended, by result and by the"
+ + " kinds of work that committed data");
+ counter.addLabel(new MetricLabel("result", result))
+ .addLabel(new MetricLabel("method",
method.name().toLowerCase()));
+ DORIS_METRIC_REGISTER.addMetrics(counter);
+ byMethod.put(method, counter);
+ }
+ COUNTER_IVM_REFRESH_TASKS.put(result, byMethod);
+ }
+ COUNTER_IVM_REFRESH_DURATION_SECONDS =
Maps.newEnumMap(IvmRefreshMethod.class);
+ for (IvmRefreshMethod method : IvmRefreshMethod.values()) {
+ DoubleCounterMetric counter = new
DoubleCounterMetric("ivm_refresh_duration_seconds_total",
+ MetricUnit.SECONDS, "how long the refresh tasks of IVM
materialized views that succeeded or"
+ + " failed ran, from start to finish, in seconds");
+ counter.addLabel(new MetricLabel("method",
method.name().toLowerCase()));
+ DORIS_METRIC_REGISTER.addMetrics(counter);
+ COUNTER_IVM_REFRESH_DURATION_SECONDS.put(method, counter);
+ }
+ COUNTER_IVM_FALLBACKS =
addIvmRefreshReasonCounters("ivm_fallbacks_total",
+ "number of the refresh tasks of IVM materialized views that
fell back from the incremental refresh"
+ + " and committed data in the refresh that replaced
it, by the reason",
+ MTMVTask.IVM_FALLBACK_REASONS);
+ COUNTER_IVM_REFRESH_FAILURES =
addIvmRefreshReasonCounters("ivm_refresh_failures_total",
+ "number of the refresh tasks of IVM materialized views that
failed, by the reason",
+ MTMVTask.IVM_FAILURE_REASONS);
+ COUNTER_IVM_SCHEDULE_REJECTED = new
LongCounterMetric("ivm_schedule_rejected_total", MetricUnit.NOUNIT,
+ "number of the refresh tasks of IVM materialized views that
the full task queue turned away");
+ DORIS_METRIC_REGISTER.addMetrics(COUNTER_IVM_SCHEDULE_REJECTED);
+
GAUGE_MAX_TABLE_SIZE_BYTES = new
GaugeMetricImpl<>("max_table_size_bytes", MetricUnit.BYTES, "", 0L);
DORIS_METRIC_REGISTER.addMetrics(GAUGE_MAX_TABLE_SIZE_BYTES);
@@ -1761,6 +1815,19 @@ public final class MetricRepo {
return visitor.finish();
}
+ // One counter per reason, keyed by the reason as the task records it and
labeled with it in lower case.
+ private static Map<String, LongCounterMetric>
addIvmRefreshReasonCounters(String name, String description,
+ List<String> reasons) {
+ Map<String, LongCounterMetric> counters = Maps.newHashMap();
+ for (String reason : reasons) {
+ LongCounterMetric counter = new LongCounterMetric(name,
MetricUnit.NOUNIT, description);
+ counter.addLabel(new MetricLabel("reason", reason.toLowerCase()));
+ DORIS_METRIC_REGISTER.addMetrics(counter);
+ counters.put(reason, counter);
+ }
+ return counters;
+ }
+
public static <M extends Metric<?>> AutoMappedMetric<M>
addLabeledMetrics(String label, Supplier<M> metric) {
return new AutoMappedMetric<>(value -> {
M m = metric.get();
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRefreshFailureStage.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRefreshFailureStage.java
new file mode 100644
index 00000000000..2da597b088d
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRefreshFailureStage.java
@@ -0,0 +1,37 @@
+// 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.mtmv.ivm;
+
+/**
+ * The stage of a refresh task of an IVM MV that a failure happened in, which
is the reason a failed task reports
+ * when no {@link IvmFailureReason} names it.
+ *
+ * <p>An {@link IvmFailureReason} names the failures of the incremental
refresh itself, which a request that may
+ * not fall back fails with. Every other failure is told apart by where it was
thrown rather than by its message,
+ * so that the reasons stay a fixed set.
+ */
+public enum IvmRefreshFailureStage {
+ /** The MV's query could not be analyzed against its base tables, or can
no longer be used. */
+ QUERY_UNUSABLE,
+ /** The MV's partitions could not be synchronized with the partitions of
its base tables. */
+ PARTITION_SYNC_FAILED,
+ /** A refresh attempt failed while it ran. */
+ EXECUTION_FAILED,
+ /** Any other stage: resolving the request, deciding the attempts, or
after every attempt gave up. */
+ OTHER
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRefreshMethod.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRefreshMethod.java
new file mode 100644
index 00000000000..3ba894099c4
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRefreshMethod.java
@@ -0,0 +1,55 @@
+// 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.mtmv.ivm;
+
+import java.util.Set;
+
+/**
+ * How a refresh task of an IVM MV actually refreshed it, judged by the kinds
of work that committed data.
+ *
+ * <p>The request does not say it: an AUTO or INCREMENTAL FALLBACK request may
end in the incremental refresh, a
+ * partition rebuild, a whole-MV rebuild, or several of them, and which of
them wrote data is what tells a refresh
+ * that kept the MV up incrementally from one that recomputed it. The refresh
mode a task reports is a different
+ * question -- which partitions it covers -- so the two are reported side by
side.
+ */
+public enum IvmRefreshMethod {
+ /** Nothing was committed: every partition was up to date, or the task
ended before writing anything. */
+ NONE,
+ /** Only the incremental refresh committed data. */
+ INCREMENTAL,
+ /**
+ * Only partition rebuilds committed data. The rebuild an incremental
attempt runs first, for the partitions the
+ * criterion says cannot be caught up, is one of them.
+ */
+ PARTITION_REBUILD,
+ /** Only a whole-MV rebuild committed data. */
+ COMPLETE,
+ /** More than one kind committed data, typically the rebuild of
invalidated partitions and then the delta. */
+ MIXED;
+
+ /** The method of a task whose committed work was of these kinds, which
are any of the three single kinds. */
+ public static IvmRefreshMethod of(Set<IvmRefreshMethod> committedKinds) {
+ if (committedKinds.isEmpty()) {
+ return NONE;
+ }
+ if (committedKinds.size() > 1) {
+ return MIXED;
+ }
+ return committedKinds.iterator().next();
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRefreshSummary.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRefreshSummary.java
new file mode 100644
index 00000000000..b0951b1ad9f
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRefreshSummary.java
@@ -0,0 +1,142 @@
+// 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.mtmv.ivm;
+
+import org.apache.doris.job.extensions.mtmv.MTMVTask;
+
+import com.google.gson.annotations.SerializedName;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * What the refresh tasks of an IVM MV have come to: consecutive failures, the
latest success and failure, how the
+ * latest task refreshed the MV and how long it took, and how often a refresh
fell back from the incremental path.
+ *
+ * <p>Kept on the MV rather than read from its task history, which holds only
the latest
+ * max_persistence_task_count tasks. It is updated from each task result as
the MV records it, on the Master and in
+ * the replay of the same journal record alike, and only from what that record
carries, so a follower that takes
+ * over keeps the same summary.
+ *
+ * <p>A canceled task leaves it as it is: it neither succeeded nor failed, and
it has no finish time to measure it
+ * by.
+ */
+public class IvmRefreshSummary {
+ // What the latest failure says, cut to this many characters.
+ public static final int MAX_ERROR_MESSAGE_LENGTH = 512;
+
+ // Failed tasks since the latest successful one.
+ @SerializedName("cf")
+ private int consecutiveFailures;
+ // Finish times in milliseconds, 0 when there has been none.
+ @SerializedName("lst")
+ private long lastSuccessTimeMs;
+ @SerializedName("lft")
+ private long lastFailureTimeMs;
+ // An IvmFailureReason or an IvmRefreshFailureStage; null for a failure
recorded before the task carried it.
+ @SerializedName("lfr")
+ private String lastFailureReason;
+ @SerializedName("lem")
+ private String lastErrorMessage;
+ // Of the latest task that succeeded or failed.
+ @SerializedName("lrm")
+ private IvmRefreshMethod lastRefreshMethod;
+ @SerializedName("lrd")
+ private long lastRefreshDurationMs;
+ @SerializedName("fc")
+ private long fallbackCount;
+ @SerializedName("lfbr")
+ private String lastFallbackReason;
+ @SerializedName("lfbt")
+ private long lastFallbackTimeMs;
+ // Whether a refresh has ever succeeded: until one has, the MV has no
baseline for the incremental refresh to
+ // keep up, and building it is not the incremental refresh falling back.
+ @SerializedName("s")
+ private boolean succeeded;
+
+ /** Takes in the result of a task, which has ended as SUCCESS, FAILED or
CANCELED. */
+ public void update(MTMVTask task) {
+ switch (task.getStatus()) {
+ case SUCCESS:
+ consecutiveFailures = 0;
+ lastSuccessTimeMs = task.getFinishTimeMs();
+ succeeded = true;
+ break;
+ case FAILED:
+ consecutiveFailures++;
+ lastFailureTimeMs = task.getFinishTimeMs();
+ lastFailureReason = task.getIvmFailureReason();
+ lastErrorMessage = StringUtils.abbreviate(task.getErrMsg(),
MAX_ERROR_MESSAGE_LENGTH);
+ break;
+ case CANCELED:
+ return;
+ default:
+ throw new IllegalStateException("A task result cannot be " +
task.getStatus()
+ + ", taskId=" + task.getTaskId());
+ }
+ lastRefreshMethod = task.getIvmRefreshMethod();
+ lastRefreshDurationMs = task.getFinishTimeMs() - task.getStartTimeMs();
+ if (task.isIvmFallback()) {
+ fallbackCount++;
+ lastFallbackReason = task.getIvmFallbackReason();
+ lastFallbackTimeMs = task.getFinishTimeMs();
+ }
+ }
+
+ public int getConsecutiveFailures() {
+ return consecutiveFailures;
+ }
+
+ public long getLastSuccessTimeMs() {
+ return lastSuccessTimeMs;
+ }
+
+ public long getLastFailureTimeMs() {
+ return lastFailureTimeMs;
+ }
+
+ public String getLastFailureReason() {
+ return lastFailureReason;
+ }
+
+ public String getLastErrorMessage() {
+ return lastErrorMessage;
+ }
+
+ public IvmRefreshMethod getLastRefreshMethod() {
+ return lastRefreshMethod;
+ }
+
+ public long getLastRefreshDurationMs() {
+ return lastRefreshDurationMs;
+ }
+
+ public long getFallbackCount() {
+ return fallbackCount;
+ }
+
+ public String getLastFallbackReason() {
+ return lastFallbackReason;
+ }
+
+ public long getLastFallbackTimeMs() {
+ return lastFallbackTimeMs;
+ }
+
+ public boolean hasSucceeded() {
+ return succeeded;
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/job/executor/TaskProcessorTest.java
b/fe/fe-core/src/test/java/org/apache/doris/job/executor/TaskProcessorTest.java
index 526fa76515d..6d9c433c7b6 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/job/executor/TaskProcessorTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/job/executor/TaskProcessorTest.java
@@ -180,6 +180,52 @@ public class TaskProcessorTest {
}
}
+ /** A task the full queue turns away is told so, and only that one: the
others are accepted and run. */
+ @Test
+ public void testATaskTheFullQueueTurnsAwayIsToldSo() throws Exception {
+ CountDownLatch running = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicInteger rejections = new AtomicInteger();
+ TaskProcessor taskProcessor = new TaskProcessor(1, 1, Thread::new);
+ try {
+ Assertions.assertTrue(taskProcessor.addTask(countingRejections(1L,
rejections, () -> {
+ running.countDown();
+ try {
+ release.await(10, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ })));
+ Assertions.assertTrue(running.await(10, TimeUnit.SECONDS));
+ // The only worker is busy, and the queue has room for one more
task.
+ Assertions.assertTrue(taskProcessor.addTask(countingRejections(2L,
rejections, () -> { })));
+ Assertions.assertEquals(0, rejections.get());
+
+
Assertions.assertFalse(taskProcessor.addTask(countingRejections(3L, rejections,
() -> { })));
+ Assertions.assertEquals(1, rejections.get());
+ } finally {
+ release.countDown();
+ taskProcessor.shutdown();
+ }
+ }
+
+ private static AbstractTask countingRejections(long taskId, AtomicInteger
rejections, Runnable body) {
+ AbstractTask task = new InsertTask("task-" + taskId, null, null, null)
{
+ @Override
+ public void runTask() {
+ body.run();
+ }
+
+ @Override
+ public void onRejected() {
+ rejections.incrementAndGet();
+ }
+ };
+ task.setStatus(TaskStatus.PENDING);
+ task.setTaskId(taskId);
+ return task;
+ }
+
private static void installStatementScope(AtomicInteger closeCount,
CountDownLatch scopeInstalled) {
ConnectContext connectContext = new ConnectContext();
connectContext.setThreadLocalInfo();
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/metric/IvmRefreshMetricsTest.java
b/fe/fe-core/src/test/java/org/apache/doris/metric/IvmRefreshMetricsTest.java
new file mode 100644
index 00000000000..bdb5ad61b09
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/metric/IvmRefreshMetricsTest.java
@@ -0,0 +1,161 @@
+// 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.metric;
+
+import org.apache.doris.catalog.MTMV;
+import org.apache.doris.common.FeConstants;
+import org.apache.doris.common.MetaNotFoundException;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.job.common.TaskStatus;
+import org.apache.doris.job.extensions.mtmv.MTMVTask;
+import org.apache.doris.job.extensions.mtmv.MTMVTask.MTMVTaskTriggerMode;
+import org.apache.doris.job.extensions.mtmv.MTMVTaskContext;
+import org.apache.doris.mtmv.MTMVUtil;
+import org.apache.doris.mtmv.ivm.IvmFailureReason;
+import org.apache.doris.mtmv.ivm.IvmRefreshFailureStage;
+import org.apache.doris.mtmv.ivm.IvmRefreshMethod;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+/** The counters of the refresh tasks of IVM materialized views, and how a
task is counted in them as it ends. */
+public class IvmRefreshMetricsTest {
+
+ @BeforeAll
+ public static void setUp() {
+ FeConstants.runningUnitTest = true;
+ MetricRepo.init();
+ }
+
+ /**
+ * Every series is registered up front, so each one is there from 0: the
tasks by every result and method, the
+ * run time by method, and one series per reason a task can fall back or
fail with, labeled in lower case.
+ */
+ @Test
+ public void testEverySeriesIsThereFromZero() {
+ Assertions.assertEquals(4 * IvmRefreshMethod.values().length,
+ MetricRepo.getMetricsByName("ivm_refresh_tasks_total").size());
+ Assertions.assertEquals(IvmRefreshMethod.values().length,
+
MetricRepo.getMetricsByName("ivm_refresh_duration_seconds_total").size());
+ Assertions.assertEquals(IvmFailureReason.values().length + 1,
+ MetricRepo.getMetricsByName("ivm_fallbacks_total").size());
+ Assertions.assertEquals(IvmFailureReason.values().length +
IvmRefreshFailureStage.values().length,
+
MetricRepo.getMetricsByName("ivm_refresh_failures_total").size());
+
+ // Series no other case here counts in, so they are still 0 whatever
ran before.
+ String metrics = getPrometheusMetrics();
+ for (String type : new String[] {"ivm_refresh_tasks_total",
"ivm_refresh_duration_seconds_total",
+ "ivm_fallbacks_total", "ivm_refresh_failures_total",
"ivm_schedule_rejected_total"}) {
+ Assertions.assertTrue(metrics.contains("# TYPE doris_fe_" + type +
" counter\n"), type);
+ }
+ Assertions.assertTrue(metrics.contains(
+ "doris_fe_ivm_refresh_tasks_total{result=\"canceled\",
method=\"mixed\"} 0\n"), metrics);
+ Assertions.assertTrue(metrics.contains(
+
"doris_fe_ivm_refresh_duration_seconds_total{method=\"partition_rebuild\"}
0.0\n"), metrics);
+ Assertions.assertTrue(metrics.contains(
+
"doris_fe_ivm_fallbacks_total{reason=\"incomplete_refresh_snapshot\"} 0\n"),
metrics);
+ Assertions.assertTrue(metrics.contains(
+
"doris_fe_ivm_refresh_failures_total{reason=\"execution_failed\"} 0\n"),
metrics);
+ }
+
+ /**
+ * A task is counted by its result and method as it ends; one that ran to
completion adds its run time, one that
+ * fell back counts its reason, and one that failed counts why. A SUCCESS
that committed nothing is no_change, and
+ * a canceled task has no finish time to add and finished no fallback.
+ */
+ @Test
+ public void testATaskIsCountedAsItEnds() {
+ MTMVTask fellBack = endedTask(TaskStatus.SUCCESS,
IvmRefreshMethod.COMPLETE, 1000L, 3500L);
+ Deencapsulation.setField(fellBack, "ivmFallback", true);
+ Deencapsulation.setField(fellBack, "ivmFallbackReason",
IvmFailureReason.MIN_MAX_BOUNDARY_HIT.name());
+ assertCounted(fellBack,
MetricRepo.COUNTER_IVM_REFRESH_TASKS.get("success").get(IvmRefreshMethod.COMPLETE));
+ Assertions.assertEquals(2.5,
+
MetricRepo.COUNTER_IVM_REFRESH_DURATION_SECONDS.get(IvmRefreshMethod.COMPLETE).getValue(),
1e-9);
+ Assertions.assertEquals(1L,
+
MetricRepo.COUNTER_IVM_FALLBACKS.get(IvmFailureReason.MIN_MAX_BOUNDARY_HIT.name()).getValue());
+
+ assertCounted(endedTask(TaskStatus.SUCCESS, IvmRefreshMethod.NONE,
1000L, 1200L),
+
MetricRepo.COUNTER_IVM_REFRESH_TASKS.get("no_change").get(IvmRefreshMethod.NONE));
+ Assertions.assertEquals(0L,
MetricRepo.COUNTER_IVM_REFRESH_TASKS.get("success").get(IvmRefreshMethod.NONE)
+ .getValue());
+
+ MTMVTask failed = endedTask(TaskStatus.FAILED,
IvmRefreshMethod.INCREMENTAL, 1000L, 2000L);
+ Deencapsulation.setField(failed, "ivmFailureReason",
IvmRefreshFailureStage.QUERY_UNUSABLE.name());
+ assertCounted(failed,
MetricRepo.COUNTER_IVM_REFRESH_TASKS.get("failed").get(IvmRefreshMethod.INCREMENTAL));
+ Assertions.assertEquals(1L,
+
MetricRepo.COUNTER_IVM_REFRESH_FAILURES.get(IvmRefreshFailureStage.QUERY_UNUSABLE.name()).getValue());
+
+ MTMVTask canceled = endedTask(TaskStatus.CANCELED,
IvmRefreshMethod.PARTITION_REBUILD, 1000L, 2000L);
+ Deencapsulation.setField(canceled, "ivmFallback", true);
+ Deencapsulation.setField(canceled, "ivmFallbackReason",
IvmFailureReason.CHANGELOG_BROKEN.name());
+ assertCounted(canceled,
+
MetricRepo.COUNTER_IVM_REFRESH_TASKS.get("canceled").get(IvmRefreshMethod.PARTITION_REBUILD));
+ Assertions.assertEquals(0.0,
+
MetricRepo.COUNTER_IVM_REFRESH_DURATION_SECONDS.get(IvmRefreshMethod.PARTITION_REBUILD).getValue(),
+ 1e-9);
+ Assertions.assertEquals(0L,
+
MetricRepo.COUNTER_IVM_FALLBACKS.get(IvmFailureReason.CHANGELOG_BROKEN.name()).getValue());
+ }
+
+ /**
+ * A task the full task queue turns away never runs, so it is counted
where it is turned away -- if its MV is an
+ * IVM MV, and still there.
+ */
+ @Test
+ public void testATaskOfAnIvmMvTheQueueTurnsAwayIsCounted() {
+ MTMV ivmMv = Mockito.mock(MTMV.class);
+ Mockito.when(ivmMv.isIvm()).thenReturn(true);
+ long rejected = MetricRepo.COUNTER_IVM_SCHEDULE_REJECTED.getValue();
+ try (MockedStatic<MTMVUtil> mtmvUtil =
Mockito.mockStatic(MTMVUtil.class)) {
+ mtmvUtil.when(() -> MTMVUtil.getMTMV(1L, 10L)).thenReturn(ivmMv);
+ mtmvUtil.when(() -> MTMVUtil.getMTMV(1L,
20L)).thenReturn(Mockito.mock(MTMV.class));
+ mtmvUtil.when(() -> MTMVUtil.getMTMV(1L, 30L)).thenThrow(new
MetaNotFoundException("dropped"));
+
+ new MTMVTask(1L, 10L, new
MTMVTaskContext(MTMVTaskTriggerMode.SYSTEM)).onRejected();
+ Assertions.assertEquals(rejected + 1,
MetricRepo.COUNTER_IVM_SCHEDULE_REJECTED.getValue());
+ new MTMVTask(1L, 20L, new
MTMVTaskContext(MTMVTaskTriggerMode.SYSTEM)).onRejected();
+ new MTMVTask(1L, 30L, new
MTMVTaskContext(MTMVTaskTriggerMode.SYSTEM)).onRejected();
+ Assertions.assertEquals(rejected + 1,
MetricRepo.COUNTER_IVM_SCHEDULE_REJECTED.getValue());
+ }
+ }
+
+ private static void assertCounted(MTMVTask task, LongCounterMetric
counter) {
+ long before = counter.getValue();
+ Deencapsulation.invoke(task, "recordIvmRefreshMetrics");
+ Assertions.assertEquals(before + 1, counter.getValue());
+ }
+
+ private static MTMVTask endedTask(TaskStatus status, IvmRefreshMethod
method, long startTimeMs,
+ long finishTimeMs) {
+ MTMVTask task = new MTMVTask();
+ task.setStatus(status);
+ task.setStartTimeMs(startTimeMs);
+ task.setFinishTimeMs(finishTimeMs);
+ Deencapsulation.setField(task, "ivmRefreshMethod", method);
+ return task;
+ }
+
+ private static String getPrometheusMetrics() {
+ MetricVisitor visitor = new PrometheusMetricVisitor();
+ MetricRepo.DORIS_METRIC_REGISTER.accept(visitor);
+ return visitor.finish();
+ }
+}
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 44b70074c4f..496e44696b9 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
@@ -53,6 +53,8 @@ import org.apache.doris.mtmv.ivm.IvmIncrRefreshResult;
import org.apache.doris.mtmv.ivm.IvmInfo;
import org.apache.doris.mtmv.ivm.IvmPlanSignature;
import org.apache.doris.mtmv.ivm.IvmPlanSignatureGenerator;
+import org.apache.doris.mtmv.ivm.IvmRefreshFailureStage;
+import org.apache.doris.mtmv.ivm.IvmRefreshMethod;
import org.apache.doris.mtmv.ivm.IvmRewriteResult;
import org.apache.doris.mtmv.ivm.IvmUtil;
import org.apache.doris.nereids.CascadesContext;
@@ -663,6 +665,8 @@ public class MTMVTaskTest {
Assertions.assertEquals(Lists.newArrayList("COMPLETE"),
toNames(attempts));
Assertions.assertEquals(IvmFailureReason.STREAM_UNSUPPORTED.name(),
Deencapsulation.getField(task, "ivmFallbackReason"));
+ // The incremental attempt is given up before it runs: what COMPLETE
commits is a fallback.
+ Assertions.assertTrue((boolean) Deencapsulation.getField(task,
"fallingBack"));
}
@Test
@@ -1859,6 +1863,8 @@ public class MTMVTaskTest {
Assertions.assertTrue(failure.getMessage().contains(ChangelogUtils.CHANGELOG_OFFSET_EXPIRED));
Assertions.assertEquals(IvmFailureReason.CHANGELOG_BROKEN.name(),
Deencapsulation.getField(task, "ivmFallbackReason"));
+ // and that is the reason the failed task reports, rather than the
stage the rebuild ran in
+ Assertions.assertEquals(IvmFailureReason.CHANGELOG_BROKEN.name(),
Deencapsulation.getField(task, "failureStage"));
}
/**
@@ -2246,4 +2252,305 @@ public class MTMVTaskTest {
task.registerExecutor(null);
Assertions.assertNull(Deencapsulation.getField(task, "executor"));
}
+
+ /**
+ * The rebuild an incremental attempt runs first, for an invalidated
partition, commits data of its own. Followed
+ * by the delta, the task refreshed the MV in two ways, which is MIXED --
and it is not a fallback, because the
+ * incremental refresh never gave up.
+ */
+ @Test
+ public void testARebuildBeforeTheDeltaIsAMixedRefreshAndNotAFallback()
throws Exception {
+ Mockito.when(mtmv.isIvm()).thenReturn(true);
+ // An append-only detail MV captures no plan signature, which keeps
the mocked batch from needing one.
+ Mockito.when(mtmv.isIvmAppendOnlyDupTable()).thenReturn(true);
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+
Mockito.when(mtmv.getDatabase()).thenReturn(Mockito.mock(Database.class));
+ Mockito.when(mtmv.getRefreshPartitionNum()).thenReturn(1);
+
Mockito.when(mtmvPartitionInfo.getPctInfos()).thenReturn(Collections.emptyList());
+
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());
+ mtmvPartitionUtilStatic.when(() ->
MTMVPartitionUtil.getMTMVNeedRefreshPartitions(
+ Mockito.same(refreshContext), Mockito.nullable(Set.class)))
+ .thenReturn(Lists.newArrayList(poneName, ptwoName));
+ MTMVTask task = new MTMVTask(mtmv, relation,
MTMVTaskContext.of(MTMVTaskTriggerMode.MANUAL, null,
+ RefreshMode.INCREMENTAL, false, null));
+
+ 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, (mock, context) ->
Mockito.when(mock.doRefresh(Mockito.any()))
+ .thenReturn(IvmIncrRefreshResult.success()))) {
+ mtmvPlanUtilStatic.when(() ->
MTMVPlanUtil.createMTMVContext(Mockito.eq(mtmv), Mockito.anyList()))
+ .thenAnswer(invocation -> contextWithQueryId());
+ updateMvStatic.when(() -> UpdateMvByPartitionCommand.from(
+ Mockito.eq(mtmv), Mockito.anySet(), Mockito.anyMap(),
Mockito.any(StatementContext.class)))
+ .thenReturn(command);
+
+ Object request = Deencapsulation.invoke(task,
"resolveRefreshRequest");
+ Object result = Deencapsulation.invoke(task, "executeIvmAttempt",
refreshContext, request,
+ contextWithQueryId(), Lists.newArrayList());
+
+ Assertions.assertEquals("SUCCESS", result.toString());
+ Assertions.assertEquals(1, delta.constructed().size());
+ }
+
+
Assertions.assertEquals(Sets.newHashSet(IvmRefreshMethod.PARTITION_REBUILD,
IvmRefreshMethod.INCREMENTAL),
+ Deencapsulation.getField(task, "committedRefreshKinds"));
+ endTask(task, TaskStatus.SUCCESS);
+ Assertions.assertEquals(IvmRefreshMethod.MIXED,
task.getIvmRefreshMethod());
+ Assertions.assertFalse(task.isIvmFallback());
+ }
+
+ /** An incremental attempt that finds every partition up to date commits
nothing, and reports NONE. */
+ @Test
+ public void testAnIncrementalAttemptWithNothingToCatchUpCommitsNothing()
throws Exception {
+ Mockito.when(mtmv.isIvm()).thenReturn(true);
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+ MTMVRefreshContext refreshContext =
Mockito.mock(MTMVRefreshContext.class);
+ mtmvPartitionUtilStatic.when(() ->
MTMVPartitionUtil.getMTMVNeedRefreshPartitions(
+ Mockito.same(refreshContext),
Mockito.nullable(Set.class))).thenReturn(Lists.newArrayList());
+ MTMVTask task = new MTMVTask(mtmv, relation, new
MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
+
+ try (MockedConstruction<IvmIncrRefreshManager> delta =
Mockito.mockConstruction(IvmIncrRefreshManager.class)) {
+ Object request = Deencapsulation.invoke(task,
"resolveRefreshRequest");
+ Object result = Deencapsulation.invoke(task, "executeIvmAttempt",
refreshContext, request,
+ new ConnectContext(), Lists.newArrayList());
+
+ Assertions.assertEquals("SUCCESS", result.toString());
+ Assertions.assertTrue(delta.constructed().isEmpty());
+ }
+
+ endTask(task, TaskStatus.SUCCESS);
+ Assertions.assertEquals(IvmRefreshMethod.NONE,
task.getIvmRefreshMethod());
+ }
+
+ /**
+ * Each batch the partition executor commits notes the kind of work it was
-- a rebuild of the partitions it names,
+ * or of the whole MV -- and a batch that fails notes nothing.
+ */
+ @Test
+ public void testEachCommittedBatchNotesItsKindOfWork() throws Exception {
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+
Mockito.when(mtmv.getDatabase()).thenReturn(Mockito.mock(Database.class));
+ Mockito.when(mtmv.getRefreshPartitionNum()).thenReturn(1);
+
Mockito.when(mtmvPartitionInfo.getPctInfos()).thenReturn(Collections.emptyList());
+ MTMVRefreshContext refreshContext =
Mockito.mock(MTMVRefreshContext.class);
+ UpdateMvByPartitionCommand command =
Mockito.mock(UpdateMvByPartitionCommand.class);
+ MTMVTask task = new MTMVTask(mtmv, relation, new
MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
+ try (MockedStatic<MTMVPlanUtil> mtmvPlanUtilStatic =
Mockito.mockStatic(MTMVPlanUtil.class);
+ MockedStatic<UpdateMvByPartitionCommand> updateMvStatic
+ =
Mockito.mockStatic(UpdateMvByPartitionCommand.class)) {
+ mtmvPlanUtilStatic.when(() ->
MTMVPlanUtil.createMTMVContext(Mockito.eq(mtmv), Mockito.anyList()))
+ .thenAnswer(invocation -> contextWithQueryId());
+ updateMvStatic.when(() -> UpdateMvByPartitionCommand.from(
+ Mockito.eq(mtmv), Mockito.anySet(), Mockito.anyMap(),
Mockito.any(StatementContext.class)))
+ .thenReturn(command);
+ AtomicInteger batches = new AtomicInteger();
+ mtmvPlanUtilStatic.when(() ->
MTMVPlanUtil.executeCommand(Mockito.any(ConnectContext.class),
+ Mockito.eq(command), Mockito.any(StatementContext.class),
Mockito.anyString(),
+ Mockito.any(Consumer.class))).thenAnswer(invocation -> {
+ if (batches.incrementAndGet() == 1) {
+ throw new RuntimeException("the first batch
failed");
+ }
+ return null;
+ });
+
+ Assertions.assertThrows(JobException.class, () ->
Deencapsulation.invoke(task,
+ "executePartitionBasedRefresh", refreshContext,
RefreshMode.PARTITIONS, new ConnectContext(),
+ Lists.newArrayList(poneName)));
+ Assertions.assertTrue(((Set<?>) Deencapsulation.getField(task,
"committedRefreshKinds")).isEmpty());
+
+ Deencapsulation.invoke(task, "executePartitionBasedRefresh",
refreshContext, RefreshMode.COMPLETE,
+ new ConnectContext(), Lists.newArrayList(poneName,
ptwoName));
+ Assertions.assertEquals(3, batches.get());
+ Deencapsulation.invoke(task, "executePartitionBasedRefresh",
refreshContext, RefreshMode.PARTITIONS,
+ new ConnectContext(), Lists.newArrayList(ptwoName));
+ }
+
+ Assertions.assertEquals(Sets.newHashSet(IvmRefreshMethod.COMPLETE,
IvmRefreshMethod.PARTITION_REBUILD),
+ Deencapsulation.getField(task, "committedRefreshKinds"));
+ }
+
+ /**
+ * A task falls back when the incremental attempt has given up and an
attempt replacing it commits data. What
+ * committed before it gave up -- the rebuild of invalidated partitions --
does not make it one, and neither does a
+ * fallback that commits nothing.
+ */
+ @Test
+ public void
testOnlyWhatCommitsAfterTheIncrementalAttemptGaveUpIsAFallback() {
+ MTMVTask task = new MTMVTask(mtmv, relation, new
MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
+
+ Deencapsulation.invoke(task, "recordCommitted",
IvmRefreshMethod.PARTITION_REBUILD);
+ Assertions.assertFalse(task.isIvmFallback());
+
+ Deencapsulation.setField(task, "fallingBack", true);
+ Assertions.assertFalse(task.isIvmFallback());
+ Deencapsulation.invoke(task, "recordCommitted",
IvmRefreshMethod.COMPLETE);
+ Assertions.assertTrue(task.isIvmFallback());
+
Assertions.assertEquals(Sets.newHashSet(IvmRefreshMethod.PARTITION_REBUILD,
IvmRefreshMethod.COMPLETE),
+ Deencapsulation.getField(task, "committedRefreshKinds"));
+ }
+
+ /**
+ * Giving up the incremental attempt is a fallback, except where the MV
has no baseline yet: an MV no refresh has
+ * succeeded on builds its first one, which is what
INCOMPLETE_REFRESH_SNAPSHOT says there. Once a refresh has
+ * succeeded, the same reason means the baseline was lost, and rebuilding
it is a fallback.
+ */
+ @Test
+ public void testBuildingTheFirstBaselineIsNotAFallback() {
+ MTMVTask task = new MTMVTask(mtmv, relation, new
MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
+
+ Deencapsulation.setField(task, "ivmFallbackReason",
IvmFailureReason.MIN_MAX_BOUNDARY_HIT.name());
+ Assertions.assertTrue((boolean) Deencapsulation.invoke(task,
"givingUpTheIncrementalAttemptIsAFallback"));
+
+ Deencapsulation.setField(task, "ivmFallbackReason",
MTMVTask.INCOMPLETE_REFRESH_SNAPSHOT);
+ Mockito.when(mtmv.hasSucceededIvmRefresh()).thenReturn(false);
+ Assertions.assertFalse((boolean) Deencapsulation.invoke(task,
"givingUpTheIncrementalAttemptIsAFallback"));
+ Mockito.when(mtmv.hasSucceededIvmRefresh()).thenReturn(true);
+ Assertions.assertTrue((boolean) Deencapsulation.invoke(task,
"givingUpTheIncrementalAttemptIsAFallback"));
+ }
+
+ /**
+ * A failure that no IvmFailureReason names reports the stage it happened
in: an MV whose query no longer analyzes,
+ * one whose partitions cannot be aligned with its base tables', and a
refresh statement that fails.
+ */
+ @Test
+ public void testAFailureReportsTheStageItHappenedIn() throws Exception {
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+ Mockito.when(mtmv.getRefreshPartitionNum()).thenReturn(1);
+
Mockito.when(mtmvPartitionInfo.getPctInfos()).thenReturn(Collections.emptyList());
+ UpdateMvByPartitionCommand command =
Mockito.mock(UpdateMvByPartitionCommand.class);
+ try (MockedStatic<MTMVPlanUtil> mtmvPlanUtilStatic =
Mockito.mockStatic(MTMVPlanUtil.class);
+ MockedStatic<MTMVRefreshContext> refreshContextStatic =
Mockito.mockStatic(MTMVRefreshContext.class);
+ MockedStatic<UpdateMvByPartitionCommand> updateMvStatic
+ =
Mockito.mockStatic(UpdateMvByPartitionCommand.class)) {
+ mtmvPlanUtilStatic.when(() ->
MTMVPlanUtil.createMTMVContext(Mockito.eq(mtmv), Mockito.anyList()))
+ .thenAnswer(invocation -> contextWithQueryId());
+ mtmvPlanUtilStatic.when(() ->
MTMVPlanUtil.generateMTMVRelation(Mockito.anySet(), Mockito.anySet()))
+ .thenReturn(relation);
+ refreshContextStatic.when(() -> MTMVRefreshContext.buildContext(
+ Mockito.eq(mtmv), Mockito.anyMap(), Mockito.anyMap()))
+ .thenReturn(Mockito.mock(MTMVRefreshContext.class));
+ updateMvStatic.when(() -> UpdateMvByPartitionCommand.from(
+ Mockito.eq(mtmv), Mockito.anySet(), Mockito.anyMap(),
Mockito.any(StatementContext.class)))
+ .thenReturn(command);
+ mtmvPlanUtilStatic.when(() ->
MTMVPlanUtil.executeCommand(Mockito.any(ConnectContext.class),
+ Mockito.eq(command), Mockito.any(StatementContext.class),
Mockito.anyString(),
+ Mockito.any(Consumer.class))).thenThrow(new
RuntimeException("the refresh statement failed"));
+
+ mtmvPlanUtilStatic.when(() ->
MTMVPlanUtil.getBaseTableFromQuery(Mockito.any(), Mockito.any()))
+ .thenThrow(new
org.apache.doris.nereids.exceptions.AnalysisException("Unknown table"));
+
Assertions.assertEquals(IvmRefreshFailureStage.QUERY_UNUSABLE.name(),
failureStageOfARunThatFails());
+
+ mtmvPlanUtilStatic.when(() ->
MTMVPlanUtil.getBaseTableFromQuery(Mockito.any(), Mockito.any()))
+ .thenReturn(new
MTMVPlanUtil.QueryAnalysisResult(Sets.newHashSet(), Sets.newHashSet(), false));
+ mtmvPartitionUtilStatic.when(() ->
MTMVPartitionUtil.alignMvPartition(Mockito.eq(mtmv), Mockito.anyMap()))
+ .thenThrow(new AnalysisException("the partition column
changed"));
+
Assertions.assertEquals(IvmRefreshFailureStage.PARTITION_SYNC_FAILED.name(),
failureStageOfARunThatFails());
+
+ mtmvPartitionUtilStatic.when(() ->
MTMVPartitionUtil.alignMvPartition(Mockito.eq(mtmv), Mockito.anyMap()))
+ .thenReturn(null);
+
Assertions.assertEquals(IvmRefreshFailureStage.EXECUTION_FAILED.name(),
failureStageOfARunThatFails());
+ }
+ }
+
+ /**
+ * How an IVM MV was refreshed, and why a failed task failed, are decided
as the task ends, from what it committed
+ * and the stage it failed in. A task of an MV that is not an IVM MV
reports neither.
+ */
+ @Test
+ public void testTheRefreshReportIsDecidedAsTheTaskEnds() {
+ Mockito.when(mtmv.isIvm()).thenReturn(true);
+ MTMVTask succeeded = new MTMVTask(mtmv, relation, new
MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
+ Assertions.assertNull(succeeded.getIvmRefreshMethod());
+ Deencapsulation.invoke(succeeded, "recordCommitted",
IvmRefreshMethod.INCREMENTAL);
+ endTask(succeeded, TaskStatus.SUCCESS);
+ Assertions.assertEquals(IvmRefreshMethod.INCREMENTAL,
succeeded.getIvmRefreshMethod());
+ Assertions.assertNull(succeeded.getIvmFailureReason());
+
+ MTMVTask failed = new MTMVTask(mtmv, relation, new
MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
+ Deencapsulation.setField(failed, "failureStage",
IvmFailureReason.MIN_MAX_BOUNDARY_HIT.name());
+ endTask(failed, TaskStatus.FAILED);
+ Assertions.assertEquals(IvmRefreshMethod.NONE,
failed.getIvmRefreshMethod());
+ Assertions.assertEquals(IvmFailureReason.MIN_MAX_BOUNDARY_HIT.name(),
failed.getIvmFailureReason());
+
+ Mockito.when(mtmv.isIvm()).thenReturn(false);
+ MTMVTask plain = new MTMVTask(mtmv, relation, new
MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
+ Deencapsulation.invoke(plain, "recordCommitted",
IvmRefreshMethod.COMPLETE);
+ endTask(plain, TaskStatus.FAILED);
+ Assertions.assertNull(plain.getIvmRefreshMethod());
+ Assertions.assertNull(plain.getIvmFailureReason());
+ }
+
+ @Test
+ public void testTaskInfoContainsIvmRefreshMethodColumn() {
+ Mockito.when(mtmv.getQualifiedDbName()).thenReturn("test_db");
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+ MTMVTask task = new MTMVTask(mtmv, relation, new
MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
+ int columnIndex = MTMVTask.COLUMN_TO_INDEX.get("ivmrefreshmethod");
+
+ List<TCell> cells = task.getTvfInfo("job").getColumnValue();
+ Assertions.assertEquals(MTMVTask.SCHEMA.size(), cells.size());
+ // a task that has not ended, or of an MV that is not an IVM MV
+ Assertions.assertEquals(FeConstants.null_string,
cells.get(columnIndex).getStringVal());
+
+ Deencapsulation.setField(task, "ivmRefreshMethod",
IvmRefreshMethod.PARTITION_REBUILD);
+ cells = task.getTvfInfo("job").getColumnValue();
+ Assertions.assertEquals("PARTITION_REBUILD",
cells.get(columnIndex).getStringVal());
+ }
+
+ /** What the refresh report is decided from stays out of the journal; the
report itself goes into it. */
+ @Test
+ public void testTheRefreshReportIsJournaledWithTheTask() {
+ MTMVTask task = new MTMVTask(mtmv, relation, new
MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
+ Deencapsulation.setField(task, "ivmRefreshMethod",
IvmRefreshMethod.COMPLETE);
+ Deencapsulation.setField(task, "ivmFailureReason",
IvmRefreshFailureStage.EXECUTION_FAILED.name());
+ Deencapsulation.setField(task, "ivmFallback", true);
+ Deencapsulation.setField(task, "fallingBack", true);
+ Deencapsulation.invoke(task, "recordCommitted",
IvmRefreshMethod.INCREMENTAL);
+
+ String json = GsonUtils.GSON.toJson(task);
+ Assertions.assertFalse(json.contains("committedRefreshKinds"), json);
+ Assertions.assertFalse(json.contains("fallingBack"), json);
+ Assertions.assertFalse(json.contains("failureStage"), json);
+ MTMVTask replayed = GsonUtils.GSON.fromJson(json, MTMVTask.class);
+ Assertions.assertEquals(IvmRefreshMethod.COMPLETE,
replayed.getIvmRefreshMethod());
+
Assertions.assertEquals(IvmRefreshFailureStage.EXECUTION_FAILED.name(),
replayed.getIvmFailureReason());
+ Assertions.assertTrue(replayed.isIvmFallback());
+ }
+
+ /** Runs a task that fails and returns the failure stage it reached. */
+ private String failureStageOfARunThatFails() {
+ MTMVTask task = new MTMVTask(mtmv, relation, new
MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
+ task.setStatus(TaskStatus.RUNNING);
+ Assertions.assertThrows(JobException.class, task::run);
+ return Deencapsulation.getField(task, "failureStage");
+ }
+
+ /** Ends the task as {@code status} and has it publish its result, the way
onSuccess, onFail and a STOP do. */
+ private void endTask(MTMVTask task, TaskStatus status) {
+ Mockito.when(mtmv.getQualifiedDbName()).thenReturn("test_db");
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+ task.setStatus(status);
+ task.setStartTimeMs(1000L);
+ task.setFinishTimeMs(3500L);
+ try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+
mockedEnv.when(Env::getCurrentEnv).thenReturn(Mockito.mock(Env.class));
+ Deencapsulation.invoke(task, "after");
+ }
+ }
+
+ private static ConnectContext contextWithQueryId() {
+ ConnectContext context = new ConnectContext();
+ context.setQueryId(new TUniqueId(1L, 2L));
+ return context;
+ }
}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTest.java
index 62dd652f68f..3a99d13af1c 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTest.java
@@ -44,6 +44,10 @@ 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.MTMVRefreshEnum.RefreshTrigger;
+import org.apache.doris.mtmv.ivm.IvmFailureReason;
+import org.apache.doris.mtmv.ivm.IvmRefreshFailureStage;
+import org.apache.doris.mtmv.ivm.IvmRefreshMethod;
+import org.apache.doris.mtmv.ivm.IvmRefreshSummary;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.persist.AlterMTMV;
import org.apache.doris.persist.EditLog;
@@ -490,6 +494,8 @@ public class MTMVTest {
MTMVRelation relation = mtmv.getRelation();
MTMVTask task = new MTMVTask(mtmv, relation, null);
task.setStatus(TaskStatus.FAILED);
+ task.setStartTimeMs(1000L);
+ task.setFinishTimeMs(2000L);
AlterMTMV alterMTMV = new AlterMTMV(new TableNameInfo("db1", "mv1"),
MTMVAlterOpType.ADD_TASK);
alterMTMV.setTask(task);
alterMTMV.setRelation(relation);
@@ -505,6 +511,88 @@ public class MTMVTest {
Mockito.verify(editLogItem).await();
}
+ /**
+ * The refresh summary of an IVM MV follows each task result the MV
records, and a follower replaying the same
+ * journal records ends with the same summary -- however few tasks the
history keeps.
+ */
+ @Test
+ public void testTheRefreshSummaryIsTheSameOnTheMasterAndInTheReplay() {
+ MTMV master = buildSerializableMTMV();
+ master.getIvmInfo().setEnableIvm(true);
+ MTMV follower = buildSerializableMTMV();
+ follower.getIvmInfo().setEnableIvm(true);
+ List<AlterMTMV> journaled = Lists.newArrayList();
+ int maxPersistenceTaskCount = Config.max_persistence_task_count;
+ try {
+ Config.max_persistence_task_count = 1;
+ withMockedEditLog(journaled, () -> {
+ for (MTMVTask task : Lists.newArrayList(
+ endedTask(master, TaskStatus.SUCCESS,
IvmRefreshMethod.COMPLETE, null, null),
+ endedTask(master, TaskStatus.FAILED,
IvmRefreshMethod.NONE,
+ IvmFailureReason.MIN_MAX_BOUNDARY_HIT.name(),
null),
+ endedTask(master, TaskStatus.CANCELED,
IvmRefreshMethod.PARTITION_REBUILD, null, null),
+ endedTask(master, TaskStatus.FAILED,
IvmRefreshMethod.NONE,
+
IvmRefreshFailureStage.EXECUTION_FAILED.name(), null),
+ endedTask(master, TaskStatus.SUCCESS,
IvmRefreshMethod.MIXED, null,
+ IvmFailureReason.CHANGELOG_BROKEN.name()))) {
+
Assertions.assertTrue(master.addTaskResult(taskResult(master, task), false));
+ }
+ for (AlterMTMV record : journaled) {
+ AlterMTMV replayed =
GsonUtils.GSON.fromJson(GsonUtils.GSON.toJson(record), AlterMTMV.class);
+ Assertions.assertTrue(follower.addTaskResult(replayed,
true));
+ }
+ });
+ } finally {
+ Config.max_persistence_task_count = maxPersistenceTaskCount;
+ }
+
+ Assertions.assertEquals(5, journaled.size());
+ IvmRefreshSummary summary = Deencapsulation.getField(master,
"ivmRefreshSummary");
+ Assertions.assertEquals(0, summary.getConsecutiveFailures());
+
Assertions.assertEquals(IvmRefreshFailureStage.EXECUTION_FAILED.name(),
summary.getLastFailureReason());
+ Assertions.assertEquals(IvmRefreshMethod.MIXED,
summary.getLastRefreshMethod());
+ Assertions.assertEquals(1L, summary.getFallbackCount());
+ Assertions.assertEquals(IvmFailureReason.CHANGELOG_BROKEN.name(),
summary.getLastFallbackReason());
+ Assertions.assertTrue(master.hasSucceededIvmRefresh());
+ IvmRefreshSummary replayed = Deencapsulation.getField(follower,
"ivmRefreshSummary");
+ Assertions.assertEquals(GsonUtils.GSON.toJson(summary),
GsonUtils.GSON.toJson(replayed));
+ }
+
+ /** An MV that is not an IVM MV records its task results without a refresh
summary. */
+ @Test
+ public void testAPlainMvKeepsNoRefreshSummary() {
+ MTMV mtmv = buildSerializableMTMV();
+
+ withMockedEditLog(Lists.newArrayList(), () ->
Assertions.assertTrue(mtmv.addTaskResult(taskResult(mtmv,
+ endedTask(mtmv, TaskStatus.FAILED, null,
IvmRefreshFailureStage.OTHER.name(), null)), false)));
+
+ IvmRefreshSummary summary = Deencapsulation.getField(mtmv,
"ivmRefreshSummary");
+ Assertions.assertEquals(0, summary.getConsecutiveFailures());
+ Assertions.assertEquals(0L, summary.getLastFailureTimeMs());
+ }
+
+ /** A task of the MV that has ended as {@code status}, with what it
reports about its refresh. */
+ private static MTMVTask endedTask(MTMV mtmv, TaskStatus status,
IvmRefreshMethod method, String failureReason,
+ String fallbackReason) {
+ MTMVTask task = new MTMVTask(mtmv, mtmv.getRelation(), null);
+ task.setStatus(status);
+ task.setStartTimeMs(1000L);
+ task.setFinishTimeMs(2000L);
+ Deencapsulation.setField(task, "ivmRefreshMethod", method);
+ Deencapsulation.setField(task, "ivmFailureReason", failureReason);
+ Deencapsulation.setField(task, "ivmFallback", fallbackReason != null);
+ Deencapsulation.setField(task, "ivmFallbackReason", fallbackReason);
+ return task;
+ }
+
+ private static AlterMTMV taskResult(MTMV mtmv, MTMVTask task) {
+ AlterMTMV alterMTMV = new AlterMTMV(new TableNameInfo("db1", "mv1"),
MTMVAlterOpType.ADD_TASK);
+ alterMTMV.setTask(task);
+ alterMTMV.setRelation(mtmv.getRelation());
+ alterMTMV.setPartitionSnapshots(Map.of());
+ return alterMTMV;
+ }
+
@Test
public void testRefreshPublishAdvancesCacheGeneration() {
MTMVCacheManager manager = new MTMVCacheManager();
@@ -1384,6 +1472,9 @@ public class MTMVTest {
withMockedEditLog(journaled, () -> {
MTMVTask task = new MTMVTask(mtmv, mtmv.getRelation(), null);
task.setStatus(TaskStatus.FAILED);
+ // A task that has failed has run, and has the times to say so.
+ task.setStartTimeMs(1000L);
+ task.setFinishTimeMs(2000L);
// Set on the task rather than through its getter: the getter
hands out a detached copy, which
// is the point of it -- a cancelled task's worker may still be
merging into the field.
Map<String, Long> taskCapturedEpochs = Maps.newConcurrentMap();
@@ -1435,6 +1526,8 @@ public class MTMVTest {
EditLogItem editLogItem = Mockito.mock(EditLogItem.class);
Mockito.when(env.getEditLog()).thenReturn(editLog);
Mockito.when(env.getMtmvService()).thenReturn(Mockito.mock(MTMVService.class));
+ // a successful task result invalidates the rewrite cache
+ Mockito.when(env.getMtmvCacheManager()).thenReturn(new
MTMVCacheManager());
Mockito.when(editLog.submitEdit(Mockito.eq(OperationType.OP_ALTER_MTMV),
Mockito.any(AlterMTMV.class)))
.thenAnswer(invocation -> {
journaled.add(invocation.getArgument(1));
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmBaselineRebuildTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmBaselineRebuildTest.java
index 9cd74995cf1..0ce6ee5b229 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmBaselineRebuildTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmBaselineRebuildTest.java
@@ -1546,6 +1546,9 @@ public class IvmBaselineRebuildTest extends
TestWithFeService {
private AlterMTMV taskResult(MTMV mtmv, TaskStatus status, long
schemaChangeVersion) {
MTMVTask task = new MTMVTask();
task.setStatus(status);
+ // A task that has ended has run, and has the times to say so.
+ task.setStartTimeMs(1000L);
+ task.setFinishTimeMs(2000L);
Deencapsulation.setField(task, "mtmvSchemaChangeVersion",
schemaChangeVersion);
AlterMTMV result = new AlterMTMV(
new TableNameInfo(mtmv.getQualifiedDbName(), mtmv.getName()),
MTMVAlterOpType.ADD_TASK);
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmRefreshSummaryTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmRefreshSummaryTest.java
new file mode 100644
index 00000000000..6e06f666121
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmRefreshSummaryTest.java
@@ -0,0 +1,163 @@
+// 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.mtmv.ivm;
+
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.job.common.TaskStatus;
+import org.apache.doris.job.extensions.mtmv.MTMVTask;
+
+import com.google.common.base.Strings;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class IvmRefreshSummaryTest {
+
+ /**
+ * Consecutive failures are the failed tasks since the latest successful
one: a success clears them, including one
+ * that had nothing to commit, and a canceled task leaves them as they are.
+ */
+ @Test
+ public void testConsecutiveFailuresAreTheFailuresSinceTheLatestSuccess() {
+ IvmRefreshSummary summary = new IvmRefreshSummary();
+
+ summary.update(failed(1000L, 2000L,
IvmRefreshFailureStage.EXECUTION_FAILED.name(), "first"));
+ summary.update(failed(3000L, 4000L,
IvmRefreshFailureStage.EXECUTION_FAILED.name(), "second"));
+ Assertions.assertEquals(2, summary.getConsecutiveFailures());
+ summary.update(canceled());
+ Assertions.assertEquals(2, summary.getConsecutiveFailures());
+
+ summary.update(succeeded(5000L, 6000L, IvmRefreshMethod.NONE));
+ Assertions.assertEquals(0, summary.getConsecutiveFailures());
+ summary.update(failed(7000L, 8000L,
IvmRefreshFailureStage.OTHER.name(), "third"));
+ Assertions.assertEquals(1, summary.getConsecutiveFailures());
+ }
+
+ /**
+ * The latest failure keeps its time, reason and message, and the message
is cut to a bounded length; the latest
+ * success does not clear them. How the latest task refreshed the MV, and
how long it took, follow every task that
+ * succeeded or failed.
+ */
+ @Test
+ public void testTheLatestSuccessAndFailureAreKept() {
+ IvmRefreshSummary summary = new IvmRefreshSummary();
+
+ String longMessage = Strings.repeat("x",
IvmRefreshSummary.MAX_ERROR_MESSAGE_LENGTH * 2);
+ summary.update(failed(1000L, 1500L,
IvmFailureReason.MIN_MAX_BOUNDARY_HIT.name(), longMessage));
+ Assertions.assertEquals(1500L, summary.getLastFailureTimeMs());
+ Assertions.assertEquals(IvmFailureReason.MIN_MAX_BOUNDARY_HIT.name(),
summary.getLastFailureReason());
+ Assertions.assertEquals(IvmRefreshSummary.MAX_ERROR_MESSAGE_LENGTH,
summary.getLastErrorMessage().length());
+
Assertions.assertTrue(longMessage.startsWith(summary.getLastErrorMessage().substring(0,
100)));
+ Assertions.assertEquals(IvmRefreshMethod.NONE,
summary.getLastRefreshMethod());
+ Assertions.assertEquals(500L, summary.getLastRefreshDurationMs());
+ Assertions.assertEquals(0L, summary.getLastSuccessTimeMs());
+
+ summary.update(succeeded(2000L, 4500L, IvmRefreshMethod.COMPLETE));
+ Assertions.assertEquals(4500L, summary.getLastSuccessTimeMs());
+ Assertions.assertEquals(IvmRefreshMethod.COMPLETE,
summary.getLastRefreshMethod());
+ Assertions.assertEquals(2500L, summary.getLastRefreshDurationMs());
+ Assertions.assertEquals(1500L, summary.getLastFailureTimeMs());
+ Assertions.assertEquals(IvmFailureReason.MIN_MAX_BOUNDARY_HIT.name(),
summary.getLastFailureReason());
+
+ // A canceled task has no finish time, so it changes neither the
latest method nor the latest duration.
+ summary.update(canceled());
+ Assertions.assertEquals(IvmRefreshMethod.COMPLETE,
summary.getLastRefreshMethod());
+ Assertions.assertEquals(2500L, summary.getLastRefreshDurationMs());
+ }
+
+ /** Fallbacks count the tasks that succeeded or failed after falling back;
a canceled one is not counted. */
+ @Test
+ public void testFallbacksCountTheTasksThatFellBack() {
+ IvmRefreshSummary summary = new IvmRefreshSummary();
+
+ summary.update(succeeded(1000L, 2000L, IvmRefreshMethod.INCREMENTAL));
+ Assertions.assertEquals(0L, summary.getFallbackCount());
+
+ MTMVTask fellBack = succeeded(3000L, 4000L, IvmRefreshMethod.COMPLETE);
+ fellBack(fellBack, IvmFailureReason.MIN_MAX_BOUNDARY_HIT.name());
+ summary.update(fellBack);
+ MTMVTask fellBackAndFailed = failed(5000L, 6000L,
IvmRefreshFailureStage.EXECUTION_FAILED.name(), "failed");
+ fellBack(fellBackAndFailed, IvmFailureReason.CHANGELOG_BROKEN.name());
+ summary.update(fellBackAndFailed);
+ MTMVTask fellBackAndCanceled = canceled();
+ fellBack(fellBackAndCanceled,
IvmFailureReason.PLAN_SIGNATURE_MISMATCH.name());
+ summary.update(fellBackAndCanceled);
+
+ Assertions.assertEquals(2L, summary.getFallbackCount());
+ Assertions.assertEquals(IvmFailureReason.CHANGELOG_BROKEN.name(),
summary.getLastFallbackReason());
+ Assertions.assertEquals(6000L, summary.getLastFallbackTimeMs());
+ }
+
+ /** Only a task that succeeded gives the MV the baseline the incremental
refresh keeps up. */
+ @Test
+ public void testOnlyASuccessMarksTheMvRefreshed() {
+ IvmRefreshSummary summary = new IvmRefreshSummary();
+
+ summary.update(failed(1000L, 2000L,
IvmRefreshFailureStage.OTHER.name(), "failed"));
+ summary.update(canceled());
+ Assertions.assertFalse(summary.hasSucceeded());
+
+ summary.update(succeeded(3000L, 4000L, IvmRefreshMethod.COMPLETE));
+ summary.update(failed(5000L, 6000L,
IvmRefreshFailureStage.OTHER.name(), "failed"));
+ Assertions.assertTrue(summary.hasSucceeded());
+ }
+
+ /** A result is a task that has ended. */
+ @Test
+ public void testATaskThatHasNotEndedIsNotAResult() {
+ MTMVTask running = new MTMVTask();
+ running.setStatus(TaskStatus.RUNNING);
+
+ Assertions.assertThrows(IllegalStateException.class, () -> new
IvmRefreshSummary().update(running));
+ }
+
+ private static MTMVTask succeeded(long startTimeMs, long finishTimeMs,
IvmRefreshMethod method) {
+ MTMVTask task = ended(TaskStatus.SUCCESS, startTimeMs, finishTimeMs);
+ Deencapsulation.setField(task, "ivmRefreshMethod", method);
+ return task;
+ }
+
+ private static MTMVTask failed(long startTimeMs, long finishTimeMs, String
reason, String message) {
+ MTMVTask task = ended(TaskStatus.FAILED, startTimeMs, finishTimeMs);
+ Deencapsulation.setField(task, "ivmRefreshMethod",
IvmRefreshMethod.NONE);
+ Deencapsulation.setField(task, "ivmFailureReason", reason);
+ task.setErrMsg(message);
+ return task;
+ }
+
+ // A STOP sets neither a finish time nor a failure reason.
+ private static MTMVTask canceled() {
+ MTMVTask task = new MTMVTask();
+ task.setStatus(TaskStatus.CANCELED);
+ task.setStartTimeMs(1L);
+ Deencapsulation.setField(task, "ivmRefreshMethod",
IvmRefreshMethod.NONE);
+ return task;
+ }
+
+ private static MTMVTask ended(TaskStatus status, long startTimeMs, long
finishTimeMs) {
+ MTMVTask task = new MTMVTask();
+ task.setStatus(status);
+ task.setStartTimeMs(startTimeMs);
+ task.setFinishTimeMs(finishTimeMs);
+ return task;
+ }
+
+ private static void fellBack(MTMVTask task, String reason) {
+ Deencapsulation.setField(task, "ivmFallback", true);
+ Deencapsulation.setField(task, "ivmFallbackReason", reason);
+ }
+}
diff --git
a/regression-test/data/mtmv_p0/ivm/test_ivm_refresh_method_metrics.out
b/regression-test/data/mtmv_p0/ivm/test_ivm_refresh_method_metrics.out
new file mode 100644
index 00000000000..b20ae419b55
--- /dev/null
+++ b/regression-test/data/mtmv_p0/ivm/test_ivm_refresh_method_metrics.out
@@ -0,0 +1,40 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !first_baseline_task --
+SUCCESS COMPLETE NONE
+
+-- !incremental_task --
+SUCCESS INCREMENTAL NONE
+
+-- !no_change_task --
+SUCCESS NONE NONE
+
+-- !strict_failure_task --
+FAILED NONE MIN_MAX_BOUNDARY_HIT
+
+-- !fallback_task --
+SUCCESS COMPLETE MIN_MAX_BOUNDARY_HIT
+
+-- !mv_after_fallback --
+2026-01-10 20 20 1
+2026-02-10 30 30 1
+2026-03-10 40 50 2
+
+-- !base_after_fallback --
+2026-01-10 20 20 1
+2026-02-10 30 30 1
+2026-03-10 40 50 2
+
+-- !complete_task --
+SUCCESS COMPLETE NONE
+
+-- !mixed_task --
+SUCCESS MIXED NONE
+
+-- !mv_after_mixed --
+2026-02-10 30 60 2
+2026-03-10 40 50 2
+
+-- !base_after_mixed --
+2026-02-10 30 60 2
+2026-03-10 40 50 2
+
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_refresh_method_metrics.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_refresh_method_metrics.groovy
new file mode 100644
index 00000000000..21f52437a05
--- /dev/null
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_refresh_method_metrics.groovy
@@ -0,0 +1,233 @@
+// 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.apache.doris.regression.util.Http
+import org.awaitility.Awaitility
+
+import static java.util.concurrent.TimeUnit.SECONDS
+
+/**
+ * How a refresh task of an IVM MV says the way it refreshed the MV, and how
the Master FE counts these tasks.
+ *
+ * <p>tasks("type"="mv") reports IvmRefreshMethod, the kinds of work that
committed data: INCREMENTAL, PARTITION_REBUILD,
+ * COMPLETE, MIXED when more than one of them did, NONE when nothing did. The
Master counts each task as it ends in
+ * doris_fe_ivm_refresh_tasks_total{result,method}, adds how long it ran to
doris_fe_ivm_refresh_duration_seconds_total,
+ * counts a task that fell back from the incremental refresh -- and committed
data in the refresh that replaced it -- in
+ * doris_fe_ivm_fallbacks_total{reason}, and a failed one in
doris_fe_ivm_refresh_failures_total{reason}.
+ *
+ * <p>Cases, in order, on one MV:
+ * <ol>
+ * <li>the first refresh finds every partition still to be filled and builds
the MV with COMPLETE, which is not a
+ * fallback;</li>
+ * <li>an incremental refresh of new rows is INCREMENTAL, and one with
nothing new commits nothing (no_change);</li>
+ * <li>a strict INCREMENTAL refresh that meets a MIN/MAX boundary fails for
that reason and commits nothing;</li>
+ * <li>the same refresh allowed to fall back rebuilds the MV, which is a
fallback for that reason;</li>
+ * <li>an explicit COMPLETE refresh is COMPLETE, and not a fallback;</li>
+ * <li>a truncated base partition makes its MV partition need a rebuild,
which an incremental refresh does before the
+ * delta: MIXED, and not a fallback either.</li>
+ * </ol>
+ *
+ * <p>nonConcurrent: the counters are the Master's, for every IVM MV, so the
exact moves asserted here hold only while no
+ * other suite refreshes one.
+ */
+suite("test_ivm_refresh_method_metrics", "nonConcurrent") {
+ // The MV is checked against its base table below, so a query must not be
answered from it; and a base-table write
+ // must not be redirected into it, or the incremental path under test goes
unused.
+ sql "SET enable_materialized_view_rewrite = false"
+ sql "SET enable_dml_materialized_view_rewrite = false"
+
+ String mvName = "ivm_refresh_method_metrics_mv"
+ String masterMetricsUrl =
"http://${getMasterIp()}:${getMasterPort()}/metrics"
+
+ // The value of every doris_fe_ivm_ series the Master exports, by the
series.
+ def ivmSeries = {
+ Map<String, Double> values = [:]
+ for (String line : Http.GET(masterMetricsUrl, false,
false).readLines()) {
+ if (line.startsWith("doris_fe_ivm_")) {
+ int split = line.lastIndexOf(' ')
+ values.put(line.substring(0, split),
Double.parseDouble(line.substring(split + 1)))
+ }
+ }
+ return values
+ }
+
+ def latestTaskId = {
+ def tasks = sql """
+ SELECT TaskId FROM tasks('type'='mv')
+ WHERE MvDatabaseName = '${context.dbName}' AND MvName = '${mvName}'
+ ORDER BY CreateTime DESC, TaskId DESC LIMIT 1
+ """
+ return tasks.isEmpty() ? null : tasks[0][0].toString()
+ }
+
+ def waitForNewTask = { 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].TaskId.toString()
+ }
+
+ // Runs a refresh and waits for its task to end. Returns the task, the
counters that moved meanwhile and by how much,
+ // and the run times added, which vary from run to run and are returned
apart, by method.
+ def refresh = { String statement ->
+ String previousTaskId = latestTaskId()
+ Map<String, Double> before = ivmSeries()
+ sql statement
+ String taskId = waitForNewTask(previousTaskId)
+ Map<String, Double> after = ivmSeries()
+ Map<String, Double> counted = [:]
+ Map<String, Double> seconds = [:]
+ String durationPrefix =
'doris_fe_ivm_refresh_duration_seconds_total{method="'
+ after.each { series, value ->
+ double moved = value - before[series]
+ if (moved == 0) {
+ return
+ }
+ if (series.startsWith(durationPrefix)) {
+ seconds.put(series.substring(durationPrefix.length(),
series.length() - 2), moved)
+ } else {
+ counted.put(series, moved)
+ }
+ }
+ return [taskId, counted, seconds]
+ }
+
+ // How the task ended and refreshed the MV, in one row. An unset column
comes back as the literal two-character
+ // string "\N", which does not survive the .out round trip, so it is
folded into a printable token: a method or a
+ // reason is an enum name, which "\N" is not.
+ def taskQuery = { String taskId ->
+ """
+ SELECT Status,
+ CASE WHEN IvmRefreshMethod REGEXP '^[A-Z_]+\$' THEN
IvmRefreshMethod ELSE 'UNSET' END,
+ CASE WHEN IvmFallbackReason REGEXP '^[A-Z_]+\$' THEN
IvmFallbackReason ELSE 'NONE' END
+ FROM tasks('type'='mv')
+ WHERE TaskId = '${taskId}'
+ """
+ }
+
+ def tasks = { String result, String method ->
+ "doris_fe_ivm_refresh_tasks_total{result=\"${result}\",
method=\"${method}\"}".toString()
+ }
+
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+ sql """DROP TABLE IF EXISTS ivm_refresh_method_metrics_t"""
+ sql """
+ CREATE TABLE ivm_refresh_method_metrics_t (
+ dt DATE NOT NULL,
+ id BIGINT NOT NULL,
+ v INT
+ )
+ UNIQUE KEY(dt, id)
+ PARTITION BY RANGE(dt) (
+ PARTITION p1 VALUES [('2026-01-01'), ('2026-02-01')),
+ PARTITION p2 VALUES [('2026-02-01'), ('2026-03-01')),
+ PARTITION p3 VALUES [('2026-03-01'), ('2026-04-01'))
+ )
+ 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"
+ )
+ """
+ sql """INSERT INTO ivm_refresh_method_metrics_t VALUES
+ ('2026-01-10', 1, 10), ('2026-01-10', 2, 20),
+ ('2026-02-10', 3, 30),
+ ('2026-03-10', 4, 40)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${mvName}
+ BUILD DEFERRED REFRESH INCREMENTAL FALLBACK ON MANUAL
+ PARTITION BY(dt)
+ DISTRIBUTED BY HASH(dt) BUCKETS 1
+ PROPERTIES ("replication_num" = "1")
+ AS SELECT dt, MIN(v) AS min_v, MAX(v) AS max_v, COUNT(*) AS cnt
+ FROM ivm_refresh_method_metrics_t
+ GROUP BY dt
+ """
+
+ // Every series is there from 0, before a task has counted in it.
+ Map<String, Double> initial = ivmSeries()
+ assertTrue(initial.containsKey(tasks("canceled", "mixed")),
initial.toString())
+
assertTrue(initial.containsKey('doris_fe_ivm_fallbacks_total{reason="incomplete_refresh_snapshot"}'))
+
assertTrue(initial.containsKey('doris_fe_ivm_refresh_failures_total{reason="partition_sync_failed"}'))
+ assertTrue(initial.containsKey('doris_fe_ivm_schedule_rejected_total'))
+
+ // 1. No partition of a new MV has been filled, so the first refresh goes
straight to COMPLETE: it builds the first
+ // baseline, and the incremental refresh never gave up on anything.
+ def (baselineTask, baselineCounted, baselineSeconds) = refresh("REFRESH
MATERIALIZED VIEW ${mvName} AUTO")
+ qt_first_baseline_task taskQuery(baselineTask)
+ assertEquals([(tasks("success", "complete")): 1.0d], baselineCounted)
+ assertEquals(["complete"], baselineSeconds.keySet().toList())
+
+ // 2. New rows are caught up incrementally, and a refresh with nothing new
to catch up commits nothing.
+ sql """INSERT INTO ivm_refresh_method_metrics_t VALUES ('2026-03-10', 5,
50)"""
+ def (deltaTask, deltaCounted, deltaSeconds) = refresh("REFRESH
MATERIALIZED VIEW ${mvName} INCREMENTAL")
+ qt_incremental_task taskQuery(deltaTask)
+ assertEquals([(tasks("success", "incremental")): 1.0d], deltaCounted)
+ assertEquals(["incremental"], deltaSeconds.keySet().toList())
+
+ def (idleTask, idleCounted, idleSeconds) = refresh("REFRESH MATERIALIZED
VIEW ${mvName} INCREMENTAL")
+ qt_no_change_task taskQuery(idleTask)
+ assertEquals([(tasks("no_change", "none")): 1.0d], idleCounted)
+
+ // 3. Deleting the row that holds a group's minimum leaves the delta
unable to say what the new minimum is. A strict
+ // INCREMENTAL request fails for that reason, and has committed nothing.
+ sql """DELETE FROM ivm_refresh_method_metrics_t WHERE dt = '2026-01-10'
AND id = 1"""
+ def (strictTask, strictCounted, strictSeconds) = refresh("REFRESH
MATERIALIZED VIEW ${mvName} INCREMENTAL")
+ qt_strict_failure_task taskQuery(strictTask)
+ assertEquals([(tasks("failed", "none")): 1.0d,
+
'doris_fe_ivm_refresh_failures_total{reason="min_max_boundary_hit"}': 1.0d],
strictCounted)
+
+ // 4. Allowed to fall back, the same refresh rebuilds the MV: a fallback,
for the reason the delta gave up.
+ def (fallbackTask, fallbackCounted, fallbackSeconds) =
+ refresh("REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL FALLBACK")
+ qt_fallback_task taskQuery(fallbackTask)
+ assertEquals([(tasks("success", "complete")): 1.0d,
+
'doris_fe_ivm_fallbacks_total{reason="min_max_boundary_hit"}': 1.0d],
fallbackCounted)
+ order_qt_mv_after_fallback """SELECT dt, min_v, max_v, cnt FROM
${mvName}"""
+ order_qt_base_after_fallback """
+ SELECT dt, MIN(v), MAX(v), COUNT(*) FROM ivm_refresh_method_metrics_t
GROUP BY dt
+ """
+
+ // 5. A COMPLETE request rebuilds the MV because it was asked to, which is
not a fallback.
+ def (completeTask, completeCounted, completeSeconds) = refresh("REFRESH
MATERIALIZED VIEW ${mvName} COMPLETE")
+ qt_complete_task taskQuery(completeTask)
+ assertEquals([(tasks("success", "complete")): 1.0d], completeCounted)
+
+ // 6. Truncating a base partition leaves its MV partition needing a
rebuild, which no delta can make: an incremental
+ // refresh rebuilds it first and then catches up the other partitions,
refreshing the MV in two ways at once.
+ sql """TRUNCATE TABLE ivm_refresh_method_metrics_t PARTITION(p1)"""
+ sql """INSERT INTO ivm_refresh_method_metrics_t VALUES ('2026-02-10', 6,
60)"""
+ def (mixedTask, mixedCounted, mixedSeconds) = refresh("REFRESH
MATERIALIZED VIEW ${mvName} INCREMENTAL")
+ qt_mixed_task taskQuery(mixedTask)
+ assertEquals([(tasks("success", "mixed")): 1.0d], mixedCounted)
+ assertEquals(["mixed"], mixedSeconds.keySet().toList())
+ order_qt_mv_after_mixed """SELECT dt, min_v, max_v, cnt FROM ${mvName}"""
+ order_qt_base_after_mixed """
+ SELECT dt, MIN(v), MAX(v), COUNT(*) FROM ivm_refresh_method_metrics_t
GROUP BY dt
+ """
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]