Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -377,6 +377,12 @@ public static boolean isSyncWithPartitions(MTMVRefreshContext context, String mt
/**
* like p_00000101_20170201
*
* The generated name must be deterministic: the same {@link PartitionKeyDesc} must always yield the same
* name, because the name is generated when the MTMV is created and re-generated later when validating
* a partition refresh request. A time-based suffix (e.g. System.currentTimeMillis()) makes the two
* generations diverge for long names (e.g. TIMESTAMPTZ(6) bounded range boundaries), causing
* "partition not exist" even when the user passes the real partition name from SHOW PARTITIONS.
*
* @param desc
* @return
*/
Expand All @@ -385,8 +391,10 @@ public static String generatePartitionName(PartitionKeyDesc desc) {
String prefix = hasNullPartitionValue(desc) ? PARTITION_NAME_NULL_PREFIX : PARTITION_NAME_PREFIX;
String partitionName = prefix + matcher.replaceAll("").replaceAll("\\,", "_");
if (partitionName.length() > 50) {
partitionName = partitionName.substring(0, 30) + Math.abs(Objects.hash(partitionName))
+ "_" + System.currentTimeMillis();
// truncate and append a stable hash of the full name; no time suffix so repeated generation
// (e.g. MTMV creation vs. partition refresh validation) always produces the same name.
// Cast to long before abs so Integer.MIN_VALUE (whose abs stays negative) cannot slip through.
partitionName = partitionName.substring(0, 30) + Math.abs((long) Objects.hash(partitionName));

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] Make the deterministic partition identity collision-safe

The suffix has only 32 bits. Two valid long values with the same first 30 sanitized characters and the standard Java Aa/BB hash collision generate the same physical name. Initial MTMV creation then fails with a duplicate partition name; if the second base partition appears later, the IF NOT EXISTS add silently no-ops and leaves it permanently unrepresented. Please use a collision-resistant identity and explicitly reject a same-name/different-description add.

}
return partitionName;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package org.apache.doris.nereids.rules.analysis;

import org.apache.doris.nereids.CascadesContext;
import org.apache.doris.nereids.exceptions.UnboundException;
import org.apache.doris.nereids.pattern.Pattern;
import org.apache.doris.nereids.rules.Rule;
import org.apache.doris.nereids.rules.expression.ExpressionPatternMatcher;
Expand All @@ -29,9 +30,12 @@
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.NeedSessionVarGuard;
import org.apache.doris.nereids.trees.expressions.SessionVarGuardExpr;
import org.apache.doris.nereids.trees.expressions.Slot;
import org.apache.doris.nereids.trees.expressions.literal.Literal;
import org.apache.doris.nereids.trees.expressions.visitor.DefaultExpressionRewriter;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.visitor.DefaultPlanRewriter;
import org.apache.doris.nereids.types.TimeStampTzType;

import com.google.common.collect.ImmutableList;

Expand Down Expand Up @@ -104,7 +108,7 @@ public AddSessionVarGuardRewriter(Map<String, String> var) {
@Override
public Expression visit(Expression expr, Boolean insideGuard) {
Expression rewritten = rewriteChildren(this, expr, Boolean.FALSE);
if (rewritten instanceof NeedSessionVarGuard && !Boolean.TRUE.equals(insideGuard)) {
if (needsSessionVarGuard(rewritten) && !Boolean.TRUE.equals(insideGuard)) {

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] Apply the guard rewriter outside aliases

rewritePlanTree reaches Filter, Join, Aggregate, and TopN expressions, but its executor's only rule matches Alias. A plan such as Project(id AS id) -> Filter(date_trunc(ts, 'day') = ...) -> Scan therefore leaves the predicate unchanged when the projected alias is unrelated. An MV built in UTC can remain structurally eligible in +08 even though rows around midnight differ. Please apply the visitor to every expression owned by these plan nodes, preserving named-output identity.

if (sessionVar == null) {
return expr;
}
Expand All @@ -121,6 +125,30 @@ public Expression visitSessionVarGuardExpr(SessionVarGuardExpr expr, Boolean con
}
return expr;
}

/**
* An expression needs a session variable guard when either
* 1. it implements {@link NeedSessionVarGuard} (its value depends on some session variable), or
* 2. it is time-zone sensitive: it operates on a TIMESTAMPTZ value. TIMESTAMPTZ is stored as UTC and
* any expression that transforms a TIMESTAMPTZ operand (e.g. date_trunc, cast to varchar/datetime,
* floor functions) yields a value that depends on the session time zone. Without a guard, a
* materialized view built in one time zone could be rewritten in a session with a different time
* zone, returning stale materialized values.
*/
private static boolean needsSessionVarGuard(Expression expr) {
return expr instanceof NeedSessionVarGuard || isTimeZoneSensitive(expr);
}

private static boolean isTimeZoneSensitive(Expression expr) {
if (expr instanceof Slot || expr instanceof Literal) {
return false;
}
try {
return expr.anyMatch(e -> ((Expression) e).getDataType() instanceof TimeStampTzType);

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] Classify the actual operation and nested type dependency

This top-level descendant test is wrong in both directions. It guards zone-invariant scalar operations such as COUNT(ts), MIN/MAX(ts), and ts IS NULL, disabling safe rewrites and rescanning nested trees repeatedly. Conversely, no node in array_join(array_sort(arr), '|') over ARRAY<TIMESTAMPTZ> has top-level TimeStampTzType, so the zone-dependent string conversion gets no guard at all; existing array output shows nested values rendered in the session zone. A UTC-materialized string can therefore rewrite in +08 and return the stored UTC rendering. Please model the operations that actually depend on timezone, including nested complex-type conversions.

} catch (UnboundException e) {
return false;
}
}
}

/** rewrite plan tree */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ private void checkPartitionExist(MTMV mtmv) throws org.apache.doris.common.Analy
"The partition method of this asynchronous materialized view "
+ "does not support refreshing by partition");
}
// First validate against the real physical partition names already stored in the MTMV metadata.
// SHOW PARTITIONS returns these names, and MVs created before partition name generation was made
// deterministic may carry a historical time suffix, so regenerating names here could produce a
// different string than the stored one and wrongly reject a valid refresh request.
Set<String> existPartitionNames = mtmv.getPartitionNames();
// Secondly validate against the partition names that would be generated (and aligned) from the
// related base table partition descs, so that refreshing a not-yet-created partition is allowed.
List<AllPartitionDesc> partitionDescs = MTMVPartitionUtil.getPartitionDescsByRelatedTable(
mtmv.getTableProperty().getProperties(), mtmv.getMvPartitionInfo(), mtmv.getMvProperties(),
mtmv.getPartitionColumns());
Expand All @@ -105,7 +112,8 @@ private void checkPartitionExist(MTMV mtmv) throws org.apache.doris.common.Analy
shouldExistPartitionNames.add(((SinglePartitionDesc) desc).getPartitionName());
});
for (String partition : partitions) {
if (!shouldExistPartitionNames.contains(partition)) {
if (!existPartitionNames.contains(partition)

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] Revalidate manual partitions after alignment

Accepting any stored physical name also admits a stale one. If its base partition was dropped, or changes between analysis and the async task, alignMvPartition removes the MV partition but the manual request keeps the old name; the rebuilt mapping returns null and snapshot generation dereferences it. Please require the physical name's descriptor to remain current and revalidate the manual set after alignment before constructing snapshots or the overwrite sink.

&& !shouldExistPartitionNames.contains(partition)) {
throw new org.apache.doris.common.AnalysisException("partition not exist: " + partition);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1340,7 +1340,13 @@ public void checkQuerySlotCount(String slotCnt) {
public int netReadTimeout = 600;

// The current time zone
@VarAttrDef.VarAttr(name = TIME_ZONE, needForward = true, affectQueryResultInExecution = true)
// affectQueryResultInPlan is required: TIMESTAMPTZ expressions (date_trunc/cast/floor on timestamptz)
// are evaluated in the session time zone, so the time zone must be captured when persisting session
// variables for views / materialized views / generated columns, and must be compared when deciding

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] Preserve the captured zone through stored-expression execution

Adding time_zone to the persisted map changes binding, but not runtime materialization. BindSink adds a guard around a generated/sync-MV DateTrunc; the mandatory final MergeGuardExpr removes it because DateTrunc does not implement NeedSessionVarGuard, translation also unwraps guards, and BE receives the current insert session's zone. Thus an expression created in UTC and written in +08 materializes +08 values. Please make the creation-zone semantics survive execution, or reject unsupported stored expressions, and cover post-creation cross-zone writes.

// whether a materialized view can be used for rewrite. Otherwise a MV built in one time zone may be

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] Fence both metadata upgrade directions

Pre-change objects have no time_zone key: a new FE overlays the old map onto a fresh system-default session for background refresh, and old empty maps are treated as unconditional matches. In the reverse direction, an old read-serving FE accepts new metadata but neither registers this key nor detects TIMESTAMPTZ functions; its nominal mismatch cache stays unguarded and can rewrite across zones. Please introduce an explicit compatibility/migration fence (or require rebuild/recreation) and test old-metadata/new-FE plus new-metadata/old-FE operation.

// rewritten in a session with a different time zone and return stale materialized values.
@VarAttrDef.VarAttr(name = TIME_ZONE, needForward = true, affectQueryResultInPlan = true,

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.

[P2] Scope mismatches to the session dependency that changed

The complete maps are compared as raw strings and reduced to one mismatch Boolean. This treats equivalent spellings such as UTC, Etc/UTC, GMT, and +00:00 as different. A genuinely different but expression-irrelevant zone also activates every older NeedSessionVarGuard; for example, integer SUM has no TIMESTAMPTZ dependency but loses rewrite across zones. Please canonicalize timezone identity and propagate per-variable dependency differences instead of enabling all guard families from any map mismatch.

affectQueryResultInExecution = true)
public String timeZone = TimeUtils.getSystemTimeZone().getID();

@VarAttrDef.VarAttr(name = LC_TIME_NAMES, needForward = true, affectQueryResultInExecution = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,38 @@ public void testGeneratePartitionName() {
Assert.assertEquals("p_1_2", rangeName);
}

/**
* TIMESTAMPTZ(6) bounded range boundaries produce a cleaned name longer than 50 chars, which goes
* through the truncate+hash branch of {@link MTMVPartitionUtil#generatePartitionName}. The generated
* name must be deterministic: the same desc must always yield the same name, otherwise the name
* generated when creating the MTMV differs from the one generated when validating a partition refresh,
* causing "partition not exist" for a partition returned by SHOW PARTITIONS.
*/
@Test
public void testGeneratePartitionNameLongTimestamptzDeterministic() {
PartitionKeyDesc tzDesc = PartitionKeyDesc.createFixed(
Lists.newArrayList(new PartitionValue("2024-01-01 00:00:00.000000+00:00")),
Lists.newArrayList(new PartitionValue("2024-01-02 00:00:00.000000+00:00"))
);
// ensure the desc really goes through the long-name branch (> 50 chars)
String name = MTMVPartitionUtil.generatePartitionName(tzDesc);
Assert.assertTrue("generated name should be shorter than the cleaned long name",
name.length() < tzDesc.toSql().length());
Assert.assertTrue(name.length() <= 50);
// repeated generation must produce the identical name (no time-based suffix)
for (int i = 0; i < 10; i++) {
Assert.assertEquals("partition name must be deterministic", name,
MTMVPartitionUtil.generatePartitionName(tzDesc));
}
// two different descs must not collide
PartitionKeyDesc tzDesc2 = PartitionKeyDesc.createFixed(
Lists.newArrayList(new PartitionValue("2024-01-02 00:00:00.000000+00:00")),
Lists.newArrayList(new PartitionValue("2024-01-03 00:00:00.000000+00:00"))
);
String name2 = MTMVPartitionUtil.generatePartitionName(tzDesc2);
Assert.assertNotEquals(name, name2);
}

@Test
public void testIsTableExcluded() {
Set<TableNameInfo> excludedTriggerTables = Sets.newHashSet(new TableNameInfo("table1"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@
import org.apache.doris.nereids.rules.expression.ExpressionRewriteContext;
import org.apache.doris.nereids.rules.expression.ExpressionRewriteTestHelper;
import org.apache.doris.nereids.rules.expression.MergeGuardExpr;
import org.apache.doris.nereids.trees.expressions.functions.scalar.DateTrunc;
import org.apache.doris.nereids.trees.expressions.literal.VarcharLiteral;
import org.apache.doris.nereids.trees.plans.RelationId;
import org.apache.doris.nereids.types.BooleanType;
import org.apache.doris.nereids.types.DateTimeV2Type;
import org.apache.doris.nereids.types.TimeStampTzType;
import org.apache.doris.nereids.types.VarcharType;
import org.apache.doris.nereids.util.MemoTestUtils;

import com.google.common.collect.ImmutableList;
Expand Down Expand Up @@ -212,4 +217,47 @@ public void testMergeGuardExprDeepNesting() {
Assertions.assertEquals(child, resultGuard.child());
Assertions.assertEquals(sessionVars, resultGuard.getSessionVars());
}

/**
* A time-zone sensitive expression (an expression that operates on a TIMESTAMPTZ value, such as
* date_trunc on a timestamptz column) must be guarded when the persisted session variables differ
* from the current session. Otherwise a materialized view built in one time zone could be rewritten
* in a session with a different time zone and return stale materialized values.
*/
@Test
public void testTimeZoneSensitiveExprGetsGuard() {
Map<String, String> persistSessionVars = ImmutableMap.of("time_zone", "+00:00");
SessionVarGuardRewriter.AddSessionVarGuardRewriter rewriter =
new SessionVarGuardRewriter.AddSessionVarGuardRewriter(persistSessionVars);
SlotReference tzSlot = new SlotReference("ts", TimeStampTzType.of(6));
Expression dateTruncOnTz = new DateTrunc(tzSlot, new VarcharLiteral("day"));
Expression rewritten = dateTruncOnTz.accept(rewriter, Boolean.FALSE);
Assertions.assertTrue(rewritten instanceof SessionVarGuardExpr,
"date_trunc on TIMESTAMPTZ should be guarded when session vars differ");
Assertions.assertEquals(dateTruncOnTz, ((SessionVarGuardExpr) rewritten).child());

// cast of a timestamptz value is also time-zone sensitive
Expression castOnTz = new Cast(tzSlot, VarcharType.SYSTEM_DEFAULT);
Expression rewrittenCast = castOnTz.accept(rewriter, Boolean.FALSE);
Assertions.assertTrue(rewrittenCast instanceof SessionVarGuardExpr,
"cast of TIMESTAMPTZ should be guarded when session vars differ");
}

/**
* The same function applied to a plain DATETIME/DATE column is NOT time-zone sensitive, so it must not
* be guarded; guarding it would unnecessarily disable materialized view rewrite for a value that does
* not depend on the session time zone.
*/
@Test
public void testDateTimeExprNotGuarded() {
Map<String, String> persistSessionVars = ImmutableMap.of("time_zone", "+00:00");
SessionVarGuardRewriter.AddSessionVarGuardRewriter rewriter =
new SessionVarGuardRewriter.AddSessionVarGuardRewriter(persistSessionVars);
SlotReference dtSlot = new SlotReference("dt", DateTimeV2Type.SYSTEM_DEFAULT);
Expression dateTruncOnDt = new DateTrunc(dtSlot, new VarcharLiteral("day"));
Expression rewritten = dateTruncOnDt.accept(rewriter, Boolean.FALSE);
Assertions.assertFalse(rewritten instanceof SessionVarGuardExpr,
"date_trunc on DATETIME should not be guarded");
Assertions.assertEquals(dateTruncOnDt, rewritten);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// 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.junit.Assert;

/**
* For a TIMESTAMPTZ(6) bounded-range partition table, the MTMV physical partition name auto-generated by
* Doris is longer than the internal threshold and used to be appended with System.currentTimeMillis().
* That made the name generated when validating a partition refresh differ from the real stored name, so
* refreshing by the real partition name returned by SHOW PARTITIONS failed with "partition not exist".
* The generated name must be deterministic and the refresh validation must accept the real stored name.
*/
suite("test_timestamptz_mtmv_partition_refresh_name","mtmv") {
def dbName = "timestamptz_mtmv_partition_refresh_name"
def tableName = "timestamptz_mtmv_partition_refresh_name_table"
def mvName = "timestamptz_mtmv_partition_refresh_name_mv"

sql "DROP DATABASE IF EXISTS ${dbName}"
sql "CREATE DATABASE ${dbName}"
sql "USE ${dbName}"

sql "SET enable_nereids_planner = true"
sql "SET enable_fallback_to_original_planner = false"
sql "SET time_zone = '+00:00'"

// TIMESTAMPTZ(6) with two bounded range partitions: the generated MTMV partition name is long.
sql "DROP TABLE IF EXISTS ${tableName}"
sql """
CREATE TABLE ${tableName} (
id INT,
ts TIMESTAMPTZ(6) NOT NULL
)
DUPLICATE KEY(id)
PARTITION BY RANGE(ts) (
PARTITION p1 VALUES [('2024-01-01 00:00:00+00:00'), ('2024-01-02 00:00:00+00:00')),
PARTITION p2 VALUES [('2024-01-02 00:00:00+00:00'), ('2024-01-03 00:00:00+00:00'))
)
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES('replication_num' = '1')
"""
sql """
INSERT INTO ${tableName} VALUES
(1, '2024-01-01 00:30:00+00:00'),
(2, '2024-01-02 00:30:00+00:00')
"""
sql "sync"

sql "DROP MATERIALIZED VIEW IF EXISTS ${mvName}"
sql """
CREATE MATERIALIZED VIEW ${mvName}
BUILD DEFERRED REFRESH AUTO ON MANUAL
PARTITION BY(ts)
DISTRIBUTED BY RANDOM BUCKETS 1
PROPERTIES('replication_num' = '1')
AS SELECT id, ts FROM ${tableName}
"""

// The real physical partition names returned by SHOW PARTITIONS.
def partitions = sql "SHOW PARTITIONS FROM ${mvName}"
Assert.assertTrue("expected at least one partition, got " + partitions.size(), partitions.size() >= 1)
def realName = partitions[0][1].toString()
Assert.assertFalse(realName.isEmpty())

// Refreshing by the real partition name must be accepted (previously "partition not exist").
sql "REFRESH MATERIALIZED VIEW ${mvName} PARTITIONS(`" + realName + "`)"
waitingMTMVTaskFinishedByMvName(mvName, dbName)

// Only the refreshed partition carries data (BUILD DEFERRED + partial refresh).
def mvRes = sql "SELECT id, CAST(ts AS STRING) FROM ${mvName} ORDER BY id"
Assert.assertEquals(1, mvRes.size())
Assert.assertTrue(mvRes[0][1].toString().contains("2024-01-01 00:30:00.000000+00:00"))

// AUTO refresh fills the remaining partition(s).
sql "REFRESH MATERIALIZED VIEW ${mvName} AUTO"
waitingMTMVTaskFinishedByMvName(mvName, dbName)
def mvResAuto = sql "SELECT id, CAST(ts AS STRING) FROM ${mvName} ORDER BY id"
Assert.assertEquals(2, mvResAuto.size())
Assert.assertTrue(mvResAuto[0][1].toString().contains("2024-01-01 00:30:00.000000+00:00"))
Assert.assertTrue(mvResAuto[1][1].toString().contains("2024-01-02 00:30:00.000000+00:00"))

// DATETIME control group: refresh by real partition name also works.
def dtTable = "timestamptz_mtmv_partition_refresh_name_dt_table"
def dtMv = "timestamptz_mtmv_partition_refresh_name_dt_mv"
sql "DROP TABLE IF EXISTS ${dtTable}"
sql """
CREATE TABLE ${dtTable} (
id INT,
ts DATETIME(6) NOT NULL
)
DUPLICATE KEY(id)
PARTITION BY RANGE(ts) (
PARTITION p1 VALUES [('2024-01-01 00:00:00'), ('2024-01-02 00:00:00')),
PARTITION p2 VALUES [('2024-01-02 00:00:00'), ('2024-01-03 00:00:00'))
)
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES('replication_num' = '1')
"""
sql """
INSERT INTO ${dtTable} VALUES
(1, '2024-01-01 00:30:00'),
(2, '2024-01-02 00:30:00')
"""
sql "sync"
sql "DROP MATERIALIZED VIEW IF EXISTS ${dtMv}"
sql """
CREATE MATERIALIZED VIEW ${dtMv}
BUILD DEFERRED REFRESH AUTO ON MANUAL
PARTITION BY(ts)
DISTRIBUTED BY RANDOM BUCKETS 1
PROPERTIES('replication_num' = '1')
AS SELECT id, ts FROM ${dtTable}
"""
def dtPartitions = sql "SHOW PARTITIONS FROM ${dtMv}"
def dtRealName = dtPartitions[0][1].toString()
sql "REFRESH MATERIALIZED VIEW ${dtMv} PARTITIONS(`" + dtRealName + "`)"
waitingMTMVTaskFinishedByMvName(dtMv, dbName)
def dtRes = sql "SELECT COUNT(*) FROM ${dtMv}"
Assert.assertEquals(1, dtRes[0][0])
}
Loading
Loading