Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
0dab488
[refactor](fe) Build a partition predicate from the key at a given po…
yujun777 Oct 14, 2026
c905ab6
[improvement](fe) Read the base partitions an MV partition is recorde…
yujun777 Oct 14, 2026
0578b8a
[refactor](fe) Name the base tables a refresh scopes by BaseTableInfo
yujun777 Oct 14, 2026
2ec5b28
[fix](fe) Pin a scoped read to the whole key of each base partition i…
yujun777 Oct 14, 2026
ddc9f6e
[fix](fe) Read the rows of a default list partition and of a key with…
yujun777 Oct 14, 2026
9a0fd79
[fix](fe) Read and record a list table's default partition in every M…
yujun777 Oct 14, 2026
60ce6cc
[fix](fe) Keep every MV partition a base partition is read by, and pi…
yujun777 Oct 14, 2026
141e1e8
[fix](fe) Pin the partitions a union compensation reads the way a ref…
yujun777 Oct 14, 2026
bb0a3a1
[fix](fe) Answer no MV partition for a query whose base partitions th…
yujun777 Oct 14, 2026
e87cde5
[fix](fe) Read a default partition's rows through the MV ranges the c…
yujun777 Oct 14, 2026
958c19c
[fix](fe) Keep the compensation, the explain and the rewrite to the p…
yujun777 Oct 14, 2026
590c8f8
[fix](fe) Leave an expired explicit partition out of a default-partit…
yujun777 Oct 14, 2026
25d37d3
[fix](fe) Keep a default-partition table's read and record one set by…
yujun777 Oct 14, 2026
a961cc9
[fix](fe) Merge the MV partitions a list table's keys make across eve…
yujun777 Oct 14, 2026
889b957
[fix](fe) Group an MV partition's list keys before the query filter, …
yujun777 Oct 14, 2026
77ab4b5
[fix](fe) Do not answer from an MV over a base table whose list parti…
yujun777 Oct 14, 2026
08bdc17
[fix](fe) Filter a query's partitions after the MV partition descs ar…
yujun777 Oct 14, 2026
7230896
[fix](fe) Write a list desc's keys in one canonical order
yujun777 Oct 14, 2026
6ad384e
[fix](regression) Follow the partition coverage the refresh and rewri…
yujun777 Oct 14, 2026
199988c
[fix](fe) Keep the coverage guard for an MV-only rewrite
yujun777 Oct 14, 2026
2779248
[fix](fe) Do not answer from a newly aligned MV partition before it h…
yujun777 Oct 14, 2026
a8c0eb8
[fix](fe) Read no rows over an MV partition the mapping does not desc…
yujun777 Oct 14, 2026
9a80de8
[fix](fe) Tell an MV partition the alignment missed from one nothing …
yujun777 Oct 14, 2026
7a4ad42
[test](fe) Follow the partition-mapping call the refresh context makes
yujun777 Oct 14, 2026
e281cff
[fix](fe) Let grace answer for a written MV partition whose snapshot …
yujun777 Oct 14, 2026
31cb7f7
[fix](fe) Keep the partitions a union compensation reads with the tab…
yujun777 Oct 14, 2026
8b88015
[fix](fe) Read a query's uncovered partitions from the base table in …
yujun777 Oct 14, 2026
59a89ef
[fix](fe) Let grace read the partition's version, not its name in the…
yujun777 Oct 14, 2026
9925e45
[fix](fe) Take out the MV partitions a union compensation cannot coun…
yujun777 Oct 14, 2026
030ff4e
[fix](fe) Read the partition's version for grace, and every PCT table…
yujun777 Oct 14, 2026
ba6f031
[fix](fe) Read the grace partitions' versions in one bulk call, and p…
yujun777 Oct 14, 2026
740e22b
[fix](fe) Prepare the snapshots of every MV partition the sync check …
yujun777 Oct 14, 2026
92e1da5
[fix](fe) Judge an undescribed MV partition only where the descriptor…
yujun777 Oct 14, 2026
6bc3560
[fix](fe) Keep the undescribed-partition check for a batch with an ex…
yujun777 Oct 14, 2026
e6a298e
[test](fe) Give the uncovered-query case a partition grace would admit
yujun777 Oct 14, 2026
ebb3031
[fix](fe) Decide grace for the preload from the same read the answer …
yujun777 Oct 14, 2026
142a13c
[fix](fe) Read a scoped range table inside the MV partition the batch…
yujun777 Oct 14, 2026
dbaa4f5
[fix](fe) Filter a query's unread range partitions before the roll-up
yujun777 Oct 14, 2026
b55558a
[fix](fe) Write a scoped range read's MV range once, not once per bas…
yujun777 Oct 14, 2026
8181555
[test](regression) Cover the read of a range partition that ends past…
yujun777 Oct 14, 2026
c80a092
[fix](fe) Write a scoped range read's MV bound at the column's scale
yujun777 Oct 14, 2026
2b06171
[fix](fe) Take out a plan's MV partitions when a refresh read an unco…
yujun777 Oct 14, 2026
f80acf9
[fix](fe) Read an uncovered table's partitions from the base, and tak…
yujun777 Oct 15, 2026
6c17a81
[fix](fe) Take the undescribed-partition check where its reading can …
yujun777 Oct 15, 2026
ef660f7
[fix](fe) Keep a merged component's partitions when a pruned query re…
yujun777 Oct 15, 2026
ac19d76
[test](regression) Cover an unmapped table and a pruned merged component
yujun777 Oct 15, 2026
071fb0d
[fix](fe) Compensate a join's PCT tables together, and every table wh…
yujun777 Oct 15, 2026
44c6a7c
[fix](fe) Narrow a compensated table's read only where one input read…
yujun777 Oct 15, 2026
2c9c305
[fix](fe) Read a compensation's input count from the query, not the r…
yujun777 Oct 15, 2026
6219e6e
[fix](regression) Refresh once in the ivm window suite, so the waited…
yujun777 Oct 15, 2026
c358a6a
[refactor](fe) Remove a compensation read that no entry can reach
yujun777 Oct 19, 2026
f724170
[test](mtmv) Cover a union compensation read of a fractional list key
yujun777 Oct 19, 2026
8206571
[refactor](fe) Take a merged LIST desc once per group, not once per s…
yujun777 Oct 23, 2026
6b2222a
[doc](fe) Attach the undescribed-partition javadoc to its method
yujun777 Oct 23, 2026
972f8b8
[test](fe) Publish the mocked partitions a refreshed MV is tested with
yujun777 Oct 23, 2026
dc1d04c
[fix](fe) Answer from the base table when a compensated group can split
yujun777 Oct 23, 2026
1de7a0f
[fix](fe) Keep a disjoint RANGE aggregate union rewrite
yujun777 Oct 23, 2026
e57d08a
[fix](fe) Fall back when a compensated group expression spans keys
yujun777 Oct 23, 2026
a3cdee3
[fix](fe) Judge a compensated aggregate union by its grouping
yujun777 Oct 24, 2026
927f012
[fix](fe) Reach a compensated group's partition column through a join…
yujun777 Oct 24, 2026
2ce15a6
[fix](fe) Reach a compensated group's partition column through an equ…
yujun777 Oct 24, 2026
cac7778
[fix](fe) Keep a compensated group that is the view's partition trans…
yujun777 Oct 24, 2026
8d1ab2b
[fix](fe) Read an unmapped IVM PCT input as no rows
yujun777 Oct 24, 2026
d880803
[fix](fe) Scope an external PCT read to its recorded partitions
yujun777 Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import com.google.common.collect.Lists;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

// Describe the partition key values in create table or add partition clause
Expand Down Expand Up @@ -64,6 +65,33 @@ public static PartitionKeyDesc createLessThan(List<PartitionValue> upperValues)
return desc;
}

/**
* The keys of a list desc in a canonical order, so that the same set of keys is the same desc wherever it
* is written down. {@link #equals} compares this list, and a hash set of keys iterates in the order it was
* filled in whenever two of them hash alike -- "Aa" and "BB" do -- so the set a desc was read from and the
* set another was built by unioning cannot be relied on to produce the same list.
*/
public static List<List<PartitionValue>> sortedInValues(Collection<List<PartitionValue>> inValues) {
List<List<PartitionValue>> res = Lists.newArrayList(inValues);
res.sort(PartitionKeyDesc::compareKeys);
return res;
}

private static int compareKeys(List<PartitionValue> left, List<PartitionValue> right) {
for (int i = 0; i < Math.min(left.size(), right.size()); i++) {
PartitionValue leftValue = left.get(i);
PartitionValue rightValue = right.get(i);
int cmp = Boolean.compare(leftValue.isNullPartition(), rightValue.isNullPartition());
if (cmp == 0) {
cmp = leftValue.getStringValue().compareTo(rightValue.getStringValue());
}
if (cmp != 0) {
return cmp;
}
}
return Integer.compare(left.size(), right.size());
}

public static PartitionKeyDesc createIn(List<List<PartitionValue>> inValues) {
PartitionKeyDesc desc = new PartitionKeyDesc();
desc.inValues = inValues;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public PartitionItem getIntersect(PartitionItem newItem) {
public PartitionKeyDesc toPartitionKeyDesc() {
List<List<PartitionValue>> inValues = partitionKeys.stream().map(PartitionInfo::toPartitionValue)
.collect(Collectors.toList());
return PartitionKeyDesc.createIn(inValues);
return PartitionKeyDesc.createIn(PartitionKeyDesc.sortedInValues(inValues));
}

@Override
Expand All @@ -100,7 +100,7 @@ public PartitionKeyDesc toPartitionKeyDesc(int pos) throws AnalysisException {
}
res.add(Lists.newArrayList(values.get(pos)));
}
return PartitionKeyDesc.createIn(Lists.newArrayList(res));
return PartitionKeyDesc.createIn(PartitionKeyDesc.sortedInValues(res));
}

@Override
Expand Down
86 changes: 84 additions & 2 deletions fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
Original file line number Diff line number Diff line change
Expand Up @@ -1679,6 +1679,23 @@ public Map<String, Map<MTMVRelatedTableIf, Set<String>>> calculatePartitionMappi
public Map<String, Map<MTMVRelatedTableIf, Set<String>>> calculatePartitionMappings(
Map<List<String>, Set<String>> queryUsedBaseTablePartitionMap,
Map<MvccTableInfo, MvccSnapshot> pinnedSnapshots) throws AnalysisException {
return calculatePartitionMappings(queryUsedBaseTablePartitionMap, pinnedSnapshots, Sets.newHashSet());
}

/**
* The same, with every MV partition no base partition describes added to {@code undescribedMvPartitions}.
*
* <p>Such a partition is one the alignment has not caught up with: a base {@code ADD PARTITION} widens the
* keys an MV partition covers, so the alignment replaces it with an empty partition of the wider keys, and
* until that is done its own descriptor is one the base partitions no longer produce. Reading it as no
* table feeding it would write no rows over the rows it holds, so a refresh has to tell it apart from a
* partition that really is fed by nothing -- and the default list partition of a table is named in every
* MV partition's mapping, which is why the mapping being non-empty says nothing about this.
*/
public Map<String, Map<MTMVRelatedTableIf, Set<String>>> calculatePartitionMappings(
Map<List<String>, Set<String>> queryUsedBaseTablePartitionMap,
Map<MvccTableInfo, MvccSnapshot> pinnedSnapshots,
Set<String> undescribedMvPartitions) throws AnalysisException {
if (mvPartitionInfo.getPartitionType() == MTMVPartitionType.SELF_MANAGE) {
return Maps.newHashMap();
}
Expand All @@ -1697,9 +1714,15 @@ public Map<String, Map<MTMVRelatedTableIf, Set<String>>> calculatePartitionMappi
Map<PartitionKeyDesc, Map<MTMVRelatedTableIf, Set<String>>> pctPartitionDescs = MTMVPartitionUtil
.generateRelatedPartitionDescs(mvPartitionInfo, mvProperties, getPartitionColumns(),
effectiveFilter, pinnedSnapshots);
Map<MTMVRelatedTableIf, String> defaultListPartitions = defaultListPartitionsOf();
for (Entry<String, PartitionItem> entry : mvPartitionItems.entrySet()) {
res.put(entry.getKey(),
pctPartitionDescs.getOrDefault(entry.getValue().toPartitionKeyDesc(), Maps.newHashMap()));
Map<MTMVRelatedTableIf, Set<String>> matched =
pctPartitionDescs.get(entry.getValue().toPartitionKeyDesc());
if (matched == null) {
undescribedMvPartitions.add(entry.getKey());
}
res.put(entry.getKey(), withDefaultListPartitions(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Check every MV partition mapped from a default LIST base partition before allowing rewrite. This fan-out puts p_default in multiple MV mappings, but MTMVRewriteUtil.getPctToMv inverts them into one String and keeps only an arbitrary destination. With explicit LIST tuples (2020,2) and (2021,2) plus default rows for (2020,3) and (2021,3), an insert into p_default dirties both MV partitions. Refresh only the inverse-selected partition, then query the other default-only tuple: Filter(d=other AND k=3) -> Scan(base[p_default]) can rewrite to Filter(d=other AND k=3) -> Scan(MV[other stale partition]) because eligibility checks only the refreshed destination. Setting enable_materialized_view_union_rewrite=false skips the later invalid-partition removal, so this can return stale data. Preserve the full inverse set or reject ambiguous default mappings for rewrite.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5ba21b7, by preserving the whole inverse set. MTMVRewriteUtil.getPctToMv answers each base partition with the set of MV partitions that read it now, and the rewrite's partition selection adds all of them, so a query that reads a default partition is held against every MV partition its rows can be in rather than against whichever one the map happened to keep. The UT covers the fan-out (t1_default read by mv_a and mv_b, answered with both).

That is the other half of the fan-out the previous commit introduced, and it is what the mapping's own contract says: the mapping is what the read and the record agree on, so anything inverting it has to keep all of it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0dbc408, on the side that cannot drop rows. Preserving the whole inverse set made every MV partition that reads a base partition a candidate, but the eligibility still answered a query when only some of those partitions were valid. With enable_materialized_view_union_rewrite off there is nothing to compensate the others: the MV alone answers the query and reads the partitions that are not valid as they are.

getMTMVCanRewritePartitions now returns no partition for a query unless every MV partition its base partitions are mapped from is valid, when the union rewrite is off. Read as a subset rather than as a count, since a partition inside its grace period is answered usable without having been compared and is not necessarily one the query reads. With the union rewrite on (the default) a partially valid answer is still a complete one, and the valid partitions still answer.

matched == null ? Maps.newHashMap() : matched, defaultListPartitions));
}
if (LOG.isDebugEnabled()) {
LOG.debug("calculatePartitionMappings use [{}] mills, mvName is [{}]",
Expand All @@ -1708,6 +1731,65 @@ public Map<String, Map<MTMVRelatedTableIf, Set<String>>> calculatePartitionMappi
return res;
}

/**
* The list partition each base table of this MV has that takes the rows no other partition of it claims,
* by table, or none for a table that has no such partition.
*
* <p>Read once per mapping rather than per MV partition: the mapping describes every MV partition and the
* answer is the table's, not the partition's. The table's partitions are read under its read lock, so
* that a concurrent ADD or DROP PARTITION cannot be seen half applied -- its name list and the items the
* walk resolves against it have to come from one state of the table -- and so that this walk is not one
* more reader of a tree another thread is modifying.
*/
private Map<MTMVRelatedTableIf, String> defaultListPartitionsOf() throws AnalysisException {
Map<MTMVRelatedTableIf, String> res = Maps.newHashMap();
for (MTMVRelatedTableIf pctTable : mvPartitionInfo.getPctTables()) {
if (!(pctTable instanceof OlapTable)) {
continue;
}
OlapTable olapTable = (OlapTable) pctTable;
if (!(olapTable.getPartitionInfo() instanceof ListPartitionInfo)) {
continue;
}
olapTable.readLock();
try {
for (String partitionName : olapTable.getPartitionNames()) {
if (olapTable.getPartitionItemOrAnalysisException(partitionName).isDefaultPartition()) {
res.put(pctTable, partitionName);
break;
}
}
} finally {
olapTable.readUnlock();
}
}
return res;
}

/**
* One MV partition's mapping, with every base table's default list partition named in it.
*
* <p>Such a partition holds rows for every key its table can be read by, so it belongs to every MV
* partition that reads the table -- not only to the one its own key, the sentinel those rows were placed
* by, maps to. Naming it everywhere is what the read and the record have to agree on: the refresh reads
* the rows of it that belong to the MV partition being refreshed, and the partition is recorded among the
* ones that partition is read through, so an insert into it leaves that MV partition out of sync instead
* of changing nothing the MV compares.
*/
private Map<MTMVRelatedTableIf, Set<String>> withDefaultListPartitions(
Map<MTMVRelatedTableIf, Set<String>> mapping, Map<MTMVRelatedTableIf, String> defaultListPartitions) {
if (defaultListPartitions.isEmpty()) {
return mapping;
}
Map<MTMVRelatedTableIf, Set<String>> res = Maps.newHashMap(mapping);
for (Entry<MTMVRelatedTableIf, String> entry : defaultListPartitions.entrySet()) {
Set<String> partitions = Sets.newHashSet(res.getOrDefault(entry.getKey(), Sets.newHashSet()));
partitions.add(entry.getValue());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Cover default LIST rows during base-side union compensation. In a two-table join MV partitioned by left keys 1 and 3, let the right table's default partition hold rows for both keys. After a key-3 insert, refresh only MV key1, then query both keys with union rewrite enabled. This fan-out makes key1 valid and key3 stale, so compensation removes MV key3 and unions r_default; PredicateAdder filters that base scan with the default item's synthetic MIN key (right.k IN (MIN)), making the key-3 join branch empty. This is distinct from the earlier direct-rewrite inverse issue. Filter default rows by the stale MV key range or reject this partial union rewrite.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7ef1678, in the direction you point at and one scope short of it. The compensation now carries the MV partitions it removes into the predicate context, and a default partition is read through the ranges of those MV partitions rather than through its own key, so the join branch for a key that lives only in the default partition is no longer empty.

The shortfall to be straight about: the ranges are written on one base column, so this covers an MV whose partition column is that column -- mtmv.getPartitionColumns().size() == 1. The two-table join in your example partitions by the left key while the default partition is on the right, so the right table's ranges would have to be written at that column's position in the MV's partition columns; that is where a multi-column MV is read from, and the read stays as it was there rather than being narrowed to a guess. Making it position-aware is the next step, not something this commit claims.

Verification, as with the last two threads: the rewrite suites and the unit tests are green (they say the covered case still rewrites), but I could not build a local case that reaches this branch, since a partly usable MV is refused by checkMaterializationPattern in this build (View struct info is invalid).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly fixed in 0dbc408, and one part still deliberately left out.

What is fixed is the case where the base key is not the MV's own column name: mvPartitionsToReadThrough identified the column by comparing the MV partition column's name with the base column's, so an MV that aliases it (the l.k AS k shape) was skipped and its default partition was read through the partition's own sentinel key -- none of its rows. The table is now identified by the base table the MV's partition info names, so the aliased shape goes through the MV partition's ranges like the unaliased one.

What is still out is the multi-column MV (getPartitionColumns().size() > 1), as in my earlier reply: the key position of this base column among the MV's partition columns is not something the partition info carries, and writing it at the wrong position would narrow the read to the wrong values. That is still the next step, and it is why the guard is kept rather than guessed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An update on the multi-column half I called the next step: it turns out there is nothing to make position-aware, because that shape cannot be created.

PARTITION BY (d, k) is rejected by the parser -- mismatched input ',' expecting ')' -- and MTMVPartitionDefinition.partitionCol carries a single column, so getPartitionColumns() is one column for every materialized view that can be built. The getPartitionColumns().size() != 1 guard in mvPartitionsToReadThrough is therefore not a case left half answered but the shape to revisit if the grammar ever accepts several columns: at that point the key position of the base column among the MV's partition columns has to come from somewhere, and it is not in BaseColInfo, which is what the guard says.

So the aliased-column case is what this thread had left, and it is fixed in 0dbc408.

res.put(entry.getKey(), partitions);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Give default-only keys an MV destination before treating this fanout as complete. For a LIST(k) base with p1=(1) and p_default, a committed k=2 row lands in p_default. Descriptor generation gives the MV only key-1 and synthetic-minimum partitions; adding p_default to both mappings records a dependency, but COMPLETE refresh filters each scan by its MV key, so neither reads k=2. The partitions can still appear synchronized; an unrestricted query using both base partitions can rewrite to this MV and omit the committed k=2 group. The added regression covers a default row sharing an explicit MV key; cover this distinct key and either represent it in the MV or refuse the rewrite. This representation gap predates the fanout change but remains in this path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and I agree it is a representation gap rather than anything this PR introduced -- but I do not think it is reachable by the direction this PR takes, so I have left it alone this round rather than guessed.

Why the merge and the read-scope work do not reach it: the MV's own partition set comes from the base partitions' keys projected onto the MV's partition column. A key that only ever lived in the default partition has no base partition describing it, so it has no MV partition -- and the refresh reads each MV partition through its own key range, so a key with no range is read by nobody. That is your k=2: LIST(k) with p1=(1) and p_default, a committed k=2 row in p_default, an MV partitioned by k whose partitions are key 1 and the sentinel one. Reading the row is not the hard part; routing it is: INSERT OVERWRITE puts a row in the MV partition its key names, and there is no such partition to put it in. Materializing it into the sentinel partition would make it invisible to a query for k=2 that prunes to the MV's partitions.

So it is one of two deliberate choices, and both are user-visible enough that I would rather ask than take one:

  1. Refuse the rewrite for a materialized view whose PCT table has a list partition's default partition. The MV keeps working for direct queries and keeps refreshing; queries over that table are answered from the base table. Narrow, and it cannot serve a row it does not hold; the cost is losing the rewrite for these MVs.
  2. Refuse the shape at CREATE -- an MV partitioned on a base table that has a list partition's default partition -- which is what r4140476259 offered as the alternative when the fan-out went in. Cheaper to keep sound, but it takes away the shape rather than the rewrite.

I did not touch either this round. If you want one in this PR, 1 is the smaller change and the one I would take; tell me and I will do it in the next round.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took your first direction in 67048fe: the rewrite is refused, the shape is not.

Confirmed first, and it is worse than a narrow corner -- measured on LIST(k) with p1=(1) and p_default, a committed k=2 row in p_default, and an MV partitioned by k: the MV holds key 1 alone, and a plain select k, sum(amount) from t group by k came back with the k=1 row only before this change. The rewrite answered from the view and lost committed data.

getMTMVCanRewritePartitions now answers with no partition when any PCT table of the MV has a list partition's default partition (MTMVPartitionUtil#hasDefaultListPartition, read under the table's read lock). The same query now returns both rows and the plan is a base table scan; the MV itself still builds, refreshes and answers direct queries.

Why the MV rather than the partitions that read that table: which keys the default partition holds is not in the metadata, so "a query whose keys are all covered by the MV's partitions" cannot be decided. Scoping it to the partitions that read that table would not help either -- the k=2 row is read out of p_default by any query over the table, and once an explicit partition is added over that key the row stays where it is (the ADD-over-default case from the thread above), so the MV still does not hold it.

The regression case in test_mtmv_base_partition_read_scope asserts both sides: what the view holds (the k=1 row alone) and that the query over the base table is answered whole with the rewrite on.

}
return res;
}

public ConcurrentLinkedQueue<MTMVTask> getHistoryTasks() {
return jobInfo.getHistoryTasks();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1293,6 +1293,13 @@ private void executePartitionBasedRefresh(MTMVRefreshContext context, RefreshMod
int end = start + refreshPartitionNum;
Set<String> execPartitionNames = Sets.newHashSet(partitions
.subList(start, Math.min(end, partitions.size())));
// What this batch reads and what it records are the same set, both decided here from the one
// mapping the snapshots below are generated from; see MTMVPartitionUtil#mappedBasePartitions.
Map<BaseTableInfo, Set<String>> readableBasePartitions = mtmv.isIvm()
? null : MTMVPartitionUtil.mappedBasePartitions(tableWithPartKey, context, execPartitionNames);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep absent IVM PCT inputs inside the recorded batch scope

The IVM exemption is unsafe for LIST components newly admitted by this PR. Use binlog-enabled OLAP UNIQUE(d,k) MOW tables partitioned by LIST(d): A.kept={2020,2038}, C.kept={2020,2038,2039}, B.old={2020}, B.future={2040} (dates are January 1). Populate one 2020 row in each table and create an IVM MV partitioned by d over SELECT a.d, COUNT(*) FROM A a JOIN B b ON a.d=b.d JOIN C c ON a.d=c.d GROUP BY a.d, with a 2 YEAR sync limit and one MV partition per refresh batch. Transfer now merges A/C into M={2020,2038,2039}; previously their overlapping descriptors were rejected.

For COMPLETE on M, the mapping records B={} and collectPctResetPartitionIds omits B. With this null scope, IvmFullRefreshMTMV sees a PCT input without reset IDs and returns its ordinary scan:

InsertOverwrite(M)
  Aggregate(a.d, count(*))
    Join(a.d=b.d AND a.d=c.d)
      RESET Scan A[kept]
      Filter(b.d IN M) -> Scan B[old]
      RESET Scan C[kept]

M therefore stores the 2020 join row without recording B.old. After DELETE FROM B WHERE d='2020-01-01', A/C and B.future are unchanged and M's current/recorded B sets are both empty. executeSingleIvmAttempt reports success at its all-synchronized early exit, preserving the deleted row; even a forced delta excludes B.old through generateRelatedBasePartitionIds. Ordinary DELETE does not invoke partition-DDL baseline invalidation. This is distinct from the existing external-PCT finding: the unsafe input is an OLAP IVM scan and its row binlog is excluded. Preserve an explicit empty scope/reset set for every absent PCT input, and cover COMPLETE -> DELETE -> INCREMENTAL on this merged component.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed by reading the three links, and it goes on the follow-up list with the external-PCT thread above rather than into this change, because closing it is a decision about the mapping that the two share:

  • MTMV#calculatePartitionMappings gives an MV partition that no base partition describes an entry in undescribedMvPartitions and an empty inner map, so the mapping names no table for it at all;
  • MTMVTask#collectPctResetPartitionIds walks only the tables the mapping names, so a PCT table all of whose partitions the window left out has no entry there;
  • IvmFullRefreshMTMV#rewriteScan then reads getFullRefreshResetPartitionIds as empty for that table, and since it is a PCT table it returns the ordinary scan -- the batch reads it and records nothing.

Both ways to close it change what the mapping means, which is why I would rather settle it with a test than here: scoping that read to the batch's mapping makes the MV partition lose rows the join does produce for the keys it does cover, while recording what the read takes makes the mapping -- and the incremental delta that reads it -- admit a partition the window dropped. That is the same fork as the external-PCT finding above, whose read is unscoped for the same reason (mappedBasePartitions scopes OlapTable only), so both want a COMPLETE -> DELETE -> INCREMENTAL case on a merged component before either side is picked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closed here rather than in a follow-up: 8d1ab2b preserves an explicit empty reset set for every absent PCT input.

  • MTMVTask#collectPctResetPartitionIds pre-seeds every OLAP PCT input with an empty set, the same way generateRelatedBasePartitionIds seeds the delta's scope, so a table absent from the batch's mapping is a stated empty read rather than an unscoped one.
  • IvmFullRefreshMTMV answers an empty reset set with a LogicalEmptyRelation before any stream lookup: the table feeds the batch with no partitions, so it is read as no rows -- and a table read as nothing needs no usable stream.

The regression is mtmv_p0/ivm/test_ivm_absent_pct_input_read_scope, on the same merged-component shape: A.kept={2020,2038}, C.kept={2020,2038,2039}, B.old={2020}, B.future={2040}, one 2020 row in each, plus a 2045 component all three tables name so the MV is proven built rather than trivially empty; PARTITION BY(d), partition_sync_limit=2 YEAR, refresh_partition_num=1. COMPLETE on M records B={} and reads B as no rows, so the 2020 join row is not materialized (with the old code it is), while M still holds the 2045 component every input feeds. After DELETE FROM B WHERE d='2020-01-01', REFRESH ... INCREMENTAL exits at the all-synchronized check with the 2020 row still absent, where the old code preserved it.

So the fork I described earlier is settled on the read side: the batch's scope is the mapping, and a table the mapping does not name is read as nothing -- the MV loses no row the batch's keys produce, because the regression's other component is still built with all three inputs read.

if (!mtmv.isIvm() && hasOlapPctTable()) {
requireDescribedPartitions(context, execPartitionNames);
}
Map<BaseTableInfo, Set<Long>> batchResetPartitionIds = useIvmFallbackStreams
? collectPctResetPartitionIds(context, execPartitionNames) : Maps.newHashMap();
Optional<IvmRewriteContext> rewriteContext = Optional.empty();
Expand All @@ -1313,7 +1320,8 @@ private void executePartitionBasedRefresh(MTMVRefreshContext context, RefreshMod
execPartitionNames);
try {
IvmPlanSignature batchPlanSignature = refreshPartitionsWithRetry(
execPartitionNames, tableWithPartKey, rewriteContext, refreshMode);
execPartitionNames, tableWithPartKey, readableBasePartitions, rewriteContext,
refreshMode);
if (capturePlanSignature) {
batchPlanSignature = Objects.requireNonNull(batchPlanSignature,
"IVM COMPLETE refresh did not produce a plan signature");
Expand Down Expand Up @@ -1400,10 +1408,11 @@ private boolean hasUnusableIvmStream() {
* Whether a stream this partition refresh will read is missing or unusable.
*
* <p>The set is narrower than the plan, and which tables are in it depends on the partitions being
* refreshed: a PCT table that no refreshed partition's mapping names keeps its place in the plan as
* an ordinary scan, so its stream is never read, and a table outside the plan's own tables is not
* scanned at all. Judging the whole plan instead sends a partition refresh that would have worked to
* a full rebuild whenever such an unread stream is missing.
* refreshed: a PCT table that no refreshed partition's mapping names is read as no rows at all -- its
* reset set is empty, and the rewriter answers it with an empty relation rather than a stream scan --
* so its stream is never read, and a table outside the plan's own tables is not scanned at all.
* Judging the whole plan instead sends a partition refresh that would have worked to a full rebuild
* whenever such an unread stream is missing.
*/
private boolean hasUnusableIvmStreamForPartitions(MTMVRefreshContext context,
List<String> mvPartitionNames) {
Expand Down Expand Up @@ -1496,9 +1505,67 @@ private void reconcileIvmStreams(ConnectContext ctx) throws JobException {
}
}

/**
* Whether any of the tables the MV is partitioned on is an olap one, which is where the check below can
* read anything. It reads a partition no base partition describes as the alignment having fallen behind an
* olap table's change, and that is a state only an olap table has: an external table describes its
* partitions from the snapshot the read is taken at, and one it cannot describe -- a row whose partition
* key is null, say -- is not a state the alignment can catch up with at all. So the check is taken where
* its reading can be true, and an MV partitioned on external tables alone is left to the read's own scope.
*/
private boolean hasOlapPctTable() throws AnalysisException {
for (MTMVRelatedTableIf pctTable : mtmv.getMvPartitionInfo().getPctTables()) {
if (pctTable instanceof OlapTable) {
return true;
}
}
return false;
}

/**
* Whether each MV partition of this batch is one the mapping describes, i.e. one that some base partition's
* keys name. A partition nothing describes is one the alignment has not caught up with: a base `ADD
* PARTITION` widens the keys an MV partition covers, which replaces it with an empty partition of the new
* keys, and if that lands between the alignment and this mapping the partition is read as no table feeding
* it and the batch writes no rows over the rows it holds. The batch is not read at all then: the task tries
* again, and trying again realigns.
*/
private void requireDescribedPartitions(MTMVRefreshContext context, Set<String> execPartitionNames)
throws AnalysisException {
for (String mvPartitionName : execPartitionNames) {
if (context.getUndescribedMvPartitions().contains(mvPartitionName)) {
throw new AnalysisException("MV partition " + mvPartitionName + " of " + mtmv.getName()
+ " is described by no base partition: the alignment has not caught up with a base "
+ "table change. Refreshing it would write no rows over the rows it holds.");
}
}
}

/**
* The partitions each PCT input of this batch is read from, by table, with an explicit empty set for a
* table the batch's mapping does not name.
*
* <p>An absent table is one whose retained base partitions share no key with the MV partitions this
* batch refreshes -- after a sync window dropped them, say -- so the batch must read no rows of it.
* Leaving the entry out instead is what makes the read differ from the record: the rewriter has no
* reset for the table and falls back to its ordinary scan, which reads the partitions the window
* dropped, while the mapping records none of them. The rows land in the MV partition and the snapshot
* says the table was not read, so a later change to them is never compared and a later delta never
* looks at them.
*
* <p>Seeded the same way {@link MTMVPartitionUtil#generateRelatedBasePartitionIds} seeds the delta's
* scope, and for the same reason: the empty set says "this table may not be read at all", which is not
* the same statement as the table's absence. The pre-seeded set is not looked up for a table the MV
* excludes from trigger tracking -- the rewriter returns its scan before consulting the reset.
*/
private Map<BaseTableInfo, Set<Long>> collectPctResetPartitionIds(MTMVRefreshContext context,
Set<String> execPartitionNames) throws AnalysisException {
Map<BaseTableInfo, Set<Long>> resetPartitionIds = Maps.newHashMap();
for (MTMVRelatedTableIf pctTable : mtmv.getMvPartitionInfo().getPctTables()) {
if (pctTable instanceof OlapTable) {
resetPartitionIds.put(new BaseTableInfo(pctTable), Sets.newHashSet());
}
}
for (String mvPartitionName : execPartitionNames) {
for (Entry<MTMVRelatedTableIf, Set<String>> entry
: context.getByPartitionName(mvPartitionName).entrySet()) {
Expand All @@ -1517,11 +1584,11 @@ private Map<BaseTableInfo, Set<Long>> collectPctResetPartitionIds(MTMVRefreshCon
}

private IvmPlanSignature refreshPartitionsWithRetry(Set<String> execPartitionNames,
Map<TableIf, String> tableWithPartKey,
Map<TableIf, String> tableWithPartKey, Map<BaseTableInfo, Set<String>> readableBasePartitions,
Optional<IvmRewriteContext> rewriteContext, RefreshMode refreshMode)
throws Exception {
return executeWithRetry(() -> refreshPartitions(execPartitionNames, tableWithPartKey,
rewriteContext, refreshMode),
readableBasePartitions, rewriteContext, refreshMode),
"partition refresh, execPartitionNames=" + execPartitionNames);
}

Expand Down Expand Up @@ -1577,7 +1644,7 @@ private boolean needRetry(Exception exception) {
}

private IvmPlanSignature refreshPartitions(Set<String> refreshPartitionNames,
Map<TableIf, String> tableWithPartKey,
Map<TableIf, String> tableWithPartKey, Map<BaseTableInfo, Set<String>> readableBasePartitions,
Optional<IvmRewriteContext> rewriteContext, RefreshMode refreshMode)
throws Exception {
// Create the MTMV context before parsing the MV definition SQL so SET_VAR hints
Expand All @@ -1601,7 +1668,8 @@ private IvmPlanSignature refreshPartitions(Set<String> refreshPartitionNames,
// if SELF_MANAGE mv, only have default partition, will not have partitionItem, so we give empty set
UpdateMvByPartitionCommand command = UpdateMvByPartitionCommand
.from(mtmv, mtmv.getMvPartitionInfo().getPartitionType() != MTMVPartitionType.SELF_MANAGE
? refreshPartitionNames : Sets.newHashSet(), tableWithPartKey, statementContext);
? refreshPartitionNames : Sets.newHashSet(), tableWithPartKey, statementContext,
readableBasePartitions);
setupComputeGroup(mtmvCtx);
AtomicReference<IvmPlanSignature> signatureRef = new AtomicReference<>();
try {
Expand Down
Loading
Loading