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

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


The following commit(s) were added to refs/heads/master by this push:
     new 56b09f28dec Fix schema cache pollution from LAST queries on virtual 
regions (#18694)
56b09f28dec is described below

commit 56b09f28decc17266b446d2dfa8838ca09cb1cb2
Author: Jackie Tien <[email protected]>
AuthorDate: Tue Sep 22 17:16:40 2026 +0800

    Fix schema cache pollution from LAST queries on virtual regions (#18694)
---
 .../db/it/last/IoTDBLastQueryVirtualRegionIT.java  | 137 ++++++++++++
 .../plan/planner/OperatorTreeGenerator.java        |  12 +-
 .../plan/planner/LastQueryCacheTest.java           | 236 +++++++++++++++++++++
 3 files changed, 383 insertions(+), 2 deletions(-)

diff --git 
a/integration-test/src/test/java/org/apache/iotdb/db/it/last/IoTDBLastQueryVirtualRegionIT.java
 
b/integration-test/src/test/java/org/apache/iotdb/db/it/last/IoTDBLastQueryVirtualRegionIT.java
new file mode 100644
index 00000000000..fceb119ae18
--- /dev/null
+++ 
b/integration-test/src/test/java/org/apache/iotdb/db/it/last/IoTDBLastQueryVirtualRegionIT.java
@@ -0,0 +1,137 @@
+/*
+ * 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.iotdb.db.it.last;
+
+import org.apache.iotdb.it.env.EnvFactory;
+import org.apache.iotdb.it.framework.IoTDBTestRunner;
+import org.apache.iotdb.itbase.category.ClusterIT;
+import org.apache.iotdb.itbase.category.LocalStandaloneIT;
+
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+import static org.apache.iotdb.db.it.utils.TestUtils.assertResultSetEqual;
+
+@RunWith(IoTDBTestRunner.class)
+@Category({LocalStandaloneIT.class, ClusterIT.class})
+public class IoTDBLastQueryVirtualRegionIT {
+
+  private static final String[] LAST_HEADER = {"Time", "Timeseries", "Value", 
"DataType"};
+
+  @BeforeClass
+  public static void setUp() throws Exception {
+    EnvFactory.getEnv().getConfig().getCommonConfig().setEnableLastCache(true);
+    EnvFactory.getEnv().initClusterEnvironment();
+  }
+
+  @AfterClass
+  public static void tearDown() throws Exception {
+    EnvFactory.getEnv().cleanClusterEnvironment();
+  }
+
+  @Test
+  public void testNonAlignedDeviceWithoutDataPartition() throws Exception {
+    testDeviceWithoutDataPartition(false);
+  }
+
+  @Test
+  public void testAlignedDeviceWithoutDataPartition() throws Exception {
+    testDeviceWithoutDataPartition(true);
+  }
+
+  private void testDeviceWithoutDataPartition(boolean aligned) throws 
Exception {
+    String prefix = aligned ? "root.last_virtual_aligned" : 
"root.last_virtual_normal";
+    String populatedDatabase = prefix + "_data";
+    String emptyDatabase = prefix + "_empty";
+    String populatedDevice = populatedDatabase + ".d";
+    String emptyDevice = emptyDatabase + ".d";
+    // A separate database guarantees that the empty device has no data 
partition, even if
+    // its series partition slot happens to collide with that of the populated 
device.
+    try (Connection connection =
+            EnvFactory.getEnv()
+                
.getConnectionWithSpecifiedDataNode(EnvFactory.getEnv().getDataNodeWrapper(0));
+        Statement statement = connection.createStatement()) {
+      statement.execute("create database " + populatedDatabase);
+      statement.execute("create database " + emptyDatabase);
+      statement.execute("create timeseries " + populatedDevice + ".s1 INT32");
+      statement.execute("insert into " + populatedDevice + "(time,s1) 
values(1,11)");
+      if (aligned) {
+        statement.execute("create aligned timeseries " + emptyDevice + "(s1 
INT32,s2 INT32)");
+      } else {
+        statement.execute("create timeseries " + emptyDevice + ".s1 INT32");
+        statement.execute("create timeseries " + emptyDevice + ".s2 INT32");
+      }
+
+      String mixedLastQuery =
+          "select last * from "
+              + populatedDatabase
+              + ".**, "
+              + emptyDatabase
+              + ".** order by timeseries";
+      String[] populatedLast = {"1," + populatedDevice + ".s1,11,INT32,"};
+      String[] emptyDeviceHeader = {"Time", emptyDevice + ".s1", emptyDevice + 
".s2"};
+      for (int i = 0; i < 2; i++) {
+        statement.execute("clear schema cache on cluster");
+        // Wildcard schema fetching leaves the device schema cache cold. The 
mixed LAST query
+        // must still execute the populated branch and the empty 
virtual-region branch.
+        assertQuery(statement, mixedLastQuery, LAST_HEADER, populatedLast);
+        assertQuery(
+            statement, "select s1,s2 from " + emptyDevice, emptyDeviceHeader, 
new String[0]);
+        assertQuery(statement, "select last s1,s2 from " + emptyDevice, 
LAST_HEADER, new String[0]);
+        // Repeat with the schema cache warmed by the exact query.
+        assertQuery(statement, mixedLastQuery, LAST_HEADER, populatedLast);
+        assertQuery(
+            statement, "select s1,s2 from " + emptyDevice, emptyDeviceHeader, 
new String[0]);
+      }
+
+      statement.execute(
+          "insert into "
+              + emptyDevice
+              + "(time,s1,s2)"
+              + (aligned ? " aligned" : "")
+              + " values(2,21,22)");
+      assertQuery(
+          statement,
+          "select last s1,s2 from " + emptyDevice + " order by timeseries",
+          LAST_HEADER,
+          new String[] {
+            "2," + emptyDevice + ".s1,21,INT32,", "2," + emptyDevice + 
".s2,22,INT32,"
+          });
+      assertQuery(
+          statement,
+          "select s1,s2 from " + emptyDevice,
+          emptyDeviceHeader,
+          new String[] {"2,21,22,"});
+    }
+  }
+
+  private void assertQuery(Statement statement, String sql, String[] header, 
String[] rows)
+      throws Exception {
+    try (ResultSet resultSet = statement.executeQuery(sql)) {
+      assertResultSetEqual(resultSet, String.join(",", header) + ",", rows);
+    }
+  }
+}
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/OperatorTreeGenerator.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/OperatorTreeGenerator.java
index dc12fab2a7f..b6a57db5445 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/OperatorTreeGenerator.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/OperatorTreeGenerator.java
@@ -243,6 +243,7 @@ import 
org.apache.iotdb.db.queryengine.plan.statement.component.Ordering;
 import org.apache.iotdb.db.queryengine.plan.statement.component.SortItem;
 import org.apache.iotdb.db.queryengine.plan.statement.literal.Literal;
 import org.apache.iotdb.db.queryengine.transformation.dag.udf.UDTFContext;
+import org.apache.iotdb.db.storageengine.dataregion.VirtualDataRegion;
 import org.apache.iotdb.db.storageengine.dataregion.read.QueryDataSourceType;
 import org.apache.iotdb.db.utils.columngenerator.ColumnGenerator;
 import org.apache.iotdb.db.utils.columngenerator.ColumnGeneratorType;
@@ -3007,8 +3008,15 @@ public class OperatorTreeGenerator implements 
PlanVisitor<Operator, LocalExecuti
   @Override
   public Operator visitLastQuery(LastQueryNode node, LocalExecutionPlanContext 
context) {
     Filter globalTimeFilter = context.getGlobalTimeFilter();
-    
context.setNeedUpdateLastCache(LastQueryUtil.needUpdateCache(globalTimeFilter));
-    
context.setNeedUpdateNullEntry(LastQueryUtil.needUpdateNullEntry(globalTimeFilter));
+    // Virtual regions only provide empty data sources. Their placeholder 
database must never be
+    // cached as the owner of a real device, including during last-cache 
initialization below.
+    boolean canUpdateLastCache =
+        !(((DataDriverContext) context.getDriverContext()).getDataRegion()
+                instanceof VirtualDataRegion)
+            && LastQueryUtil.needUpdateCache(globalTimeFilter);
+    context.setNeedUpdateLastCache(canUpdateLastCache);
+    context.setNeedUpdateNullEntry(
+        canUpdateLastCache && 
LastQueryUtil.needUpdateNullEntry(globalTimeFilter));
 
     List<Operator> operatorList =
         node.getChildren().stream()
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/planner/LastQueryCacheTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/planner/LastQueryCacheTest.java
new file mode 100644
index 00000000000..865c882c980
--- /dev/null
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/planner/LastQueryCacheTest.java
@@ -0,0 +1,236 @@
+/*
+ * 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.iotdb.db.queryengine.plan.planner;
+
+import org.apache.iotdb.calc.execution.operator.Operator;
+import org.apache.iotdb.commons.audit.UserEntity;
+import org.apache.iotdb.commons.path.MeasurementPath;
+import org.apache.iotdb.commons.path.PartialPath;
+import org.apache.iotdb.commons.queryengine.common.SessionInfo;
+import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.PlanNodeId;
+import org.apache.iotdb.db.queryengine.common.FragmentInstanceId;
+import org.apache.iotdb.db.queryengine.common.PlanFragmentId;
+import org.apache.iotdb.db.queryengine.common.QueryId;
+import org.apache.iotdb.db.queryengine.common.schematree.ClusterSchemaTree;
+import org.apache.iotdb.db.queryengine.execution.driver.DataDriverContext;
+import org.apache.iotdb.db.queryengine.execution.fragment.DataNodeQueryContext;
+import 
org.apache.iotdb.db.queryengine.execution.fragment.FragmentInstanceContext;
+import 
org.apache.iotdb.db.queryengine.execution.fragment.FragmentInstanceStateMachine;
+import 
org.apache.iotdb.db.queryengine.execution.operator.source.DataSourceOperator;
+import org.apache.iotdb.db.queryengine.plan.analyze.ClusterPartitionFetcher;
+import org.apache.iotdb.db.queryengine.plan.analyze.TypeProvider;
+import 
org.apache.iotdb.db.queryengine.plan.analyze.cache.partition.PartitionCache;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.TimePredicate;
+import 
org.apache.iotdb.db.queryengine.plan.planner.plan.node.process.last.LastQueryNode;
+import 
org.apache.iotdb.db.queryengine.plan.relational.metadata.fetcher.cache.TableDeviceLastCache;
+import 
org.apache.iotdb.db.queryengine.plan.relational.metadata.fetcher.cache.TreeDeviceSchemaCacheManager;
+import org.apache.iotdb.db.storageengine.dataregion.IDataRegionForQuery;
+import org.apache.iotdb.db.storageengine.dataregion.VirtualDataRegion;
+import org.apache.iotdb.db.storageengine.dataregion.read.QueryDataSource;
+
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.read.common.block.TsBlock;
+import org.apache.tsfile.read.filter.basic.Filter;
+import org.apache.tsfile.read.filter.factory.TimeFilterApi;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import java.lang.reflect.Field;
+import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+
+import static 
org.apache.iotdb.db.queryengine.execution.fragment.FragmentInstanceContext.createFragmentInstanceContext;
+import static 
org.apache.iotdb.db.queryengine.execution.operator.AggregationOperatorTest.TEST_TIME_SLICE;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+@RunWith(Parameterized.class)
+public class LastQueryCacheTest {
+
+  private static final String DATABASE = "root.last_query_cache";
+  private final boolean aligned;
+  private final Filter timeFilter;
+  private final boolean updateSchema;
+  private final boolean updateNullEntry;
+  private final TreeDeviceSchemaCacheManager cache = 
TreeDeviceSchemaCacheManager.getInstance();
+  private MeasurementPath path;
+  private PartitionCache partitionCache;
+
+  @Parameterized.Parameters(name = "aligned={0}, filter={1}")
+  public static Collection<Object[]> parameters() {
+    return Arrays.asList(
+        new Object[] {false, null, true, true},
+        new Object[] {true, null, true, true},
+        new Object[] {false, TimeFilterApi.gt(10), true, false},
+        new Object[] {true, TimeFilterApi.gt(10), true, false},
+        new Object[] {false, TimeFilterApi.gtEq(10), true, false},
+        new Object[] {true, TimeFilterApi.gtEq(10), true, false},
+        new Object[] {false, TimeFilterApi.lt(10), false, false},
+        new Object[] {true, TimeFilterApi.lt(10), false, false});
+  }
+
+  public LastQueryCacheTest(
+      boolean aligned, Filter timeFilter, boolean updateSchema, boolean 
updateNullEntry) {
+    this.aligned = aligned;
+    this.timeFilter = timeFilter;
+    this.updateSchema = updateSchema;
+    this.updateNullEntry = updateNullEntry;
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    cache.cleanUp();
+    path = new MeasurementPath(DATABASE + ".d.s", TSDataType.INT32);
+    path.setUnderAlignedEntity(aligned);
+    // Seed database options locally so cache declarations do not contact a 
ConfigNode.
+    Field field = 
ClusterPartitionFetcher.class.getDeclaredField("partitionCache");
+    field.setAccessible(true);
+    partitionCache = (PartitionCache) 
field.get(ClusterPartitionFetcher.getInstance());
+    partitionCache.updateDatabaseCache(
+        new HashSet<>(Arrays.asList(DATABASE, 
VirtualDataRegion.getInstance().getDatabaseName())));
+  }
+
+  @After
+  public void tearDown() {
+    cache.cleanUp();
+    partitionCache.removeFromDatabaseCache();
+  }
+
+  @Test
+  public void testVirtualRegionDoesNotPopulateColdCache() throws Exception {
+    LocalExecutionPlanContext context = 
createContext(VirtualDataRegion.getInstance());
+    try (Operator operator = createOperator(context)) {
+      // Initialization alone used to cache the virtual database as the 
device's owner.
+      assertTrue(cache.getMatchedNormalSchema(path).isEmpty());
+      consumeEmptyResult(operator, context);
+      assertTrue(cache.getMatchedNormalSchema(path).isEmpty());
+      assertNull(cache.getLastCache(path));
+    }
+    putSchema();
+    assertEquals(DATABASE, 
cache.getMatchedNormalSchema(path).getBelongedDatabase(path));
+  }
+
+  @Test
+  public void testVirtualRegionDoesNotUpdateWarmCache() throws Exception {
+    putSchema();
+    cache.declareLastCache(DATABASE, path);
+    LocalExecutionPlanContext context = 
createContext(VirtualDataRegion.getInstance());
+    try (Operator operator = createOperator(context)) {
+      consumeEmptyResult(operator, context);
+      assertEquals(DATABASE, 
cache.getMatchedNormalSchema(path).getBelongedDatabase(path));
+      // An empty virtual scan must not turn a pending cache entry into a 
cached empty result.
+      assertNull(cache.getLastCache(path));
+    }
+  }
+
+  @Test
+  public void testRealRegionRetainsEmptyResultCachePolicy() throws Exception {
+    IDataRegionForQuery dataRegion = mock(IDataRegionForQuery.class);
+    when(dataRegion.getDatabaseName()).thenReturn(DATABASE);
+    LocalExecutionPlanContext context = createContext(dataRegion);
+    try (Operator operator = createOperator(context)) {
+      if (updateSchema) {
+        assertEquals(DATABASE, 
cache.getMatchedNormalSchema(path).getBelongedDatabase(path));
+      } else {
+        assertTrue(cache.getMatchedNormalSchema(path).isEmpty());
+      }
+      consumeEmptyResult(operator, context);
+      if (updateNullEntry) {
+        assertEquals(TableDeviceLastCache.PLACEHOLDER_EMPTY_COLUMN, 
cache.getLastCache(path));
+      } else {
+        assertNull(cache.getLastCache(path));
+      }
+    }
+  }
+
+  private void putSchema() {
+    ClusterSchemaTree tree = new ClusterSchemaTree();
+    tree.appendSingleMeasurementPath(path);
+    tree.setDatabases(Collections.singleton(DATABASE));
+    cache.put(tree);
+  }
+
+  private LocalExecutionPlanContext createContext(IDataRegionForQuery 
dataRegion) {
+    QueryId queryId = new QueryId("last_query_cache_test");
+    FragmentInstanceId instanceId =
+        new FragmentInstanceId(new PlanFragmentId(queryId, 0), "instance");
+    DataNodeQueryContext queryContext = new DataNodeQueryContext(1);
+    HashMap<QueryId, DataNodeQueryContext> queryContexts = new HashMap<>();
+    queryContexts.put(queryId, queryContext);
+    FragmentInstanceContext instanceContext =
+        spy(
+            createFragmentInstanceContext(
+                instanceId,
+                new FragmentInstanceStateMachine(instanceId, Runnable::run),
+                new SessionInfo(
+                    1, new UserEntity(666, "test", "127.0.0.1"), 
ZoneId.systemDefault()),
+                dataRegion,
+                (TimePredicate) null,
+                queryContexts,
+                Long.MAX_VALUE,
+                false,
+                false));
+    when(instanceContext.getGlobalTimeFilter()).thenReturn(timeFilter);
+    return new LocalExecutionPlanContext(new TypeProvider(), instanceContext, 
queryContext);
+  }
+
+  private Operator createOperator(LocalExecutionPlanContext context) {
+    LastQueryNode node = new LastQueryNode(new PlanNodeId("last"), null, 
false);
+    node.addDeviceLastQueryScanNode(
+        new PlanNodeId("scan"),
+        new PartialPath(path.getDevicePath().getNodes()),
+        aligned,
+        Collections.singletonList(path.getMeasurementSchema()),
+        null,
+        false,
+        null);
+    return node.accept(new OperatorTreeGenerator(), context);
+  }
+
+  private void consumeEmptyResult(Operator operator, LocalExecutionPlanContext 
context)
+      throws Exception {
+    context
+        .getDriverContext()
+        .getOperatorContexts()
+        .forEach(operatorContext -> 
operatorContext.setMaxRunTime(TEST_TIME_SLICE));
+    for (DataSourceOperator source :
+        ((DataDriverContext) context.getDriverContext()).getSourceOperators()) 
{
+      source.initQueryDataSource(
+          new QueryDataSource(Collections.emptyList(), 
Collections.emptyList()));
+    }
+    int iterations = 0;
+    while (operator.hasNext()) {
+      assertTrue("Empty LAST scan did not finish", iterations++ < 10);
+      assertTrue(operator.isBlocked().isDone());
+      TsBlock block = operator.next();
+      assertTrue(block == null || block.isEmpty());
+    }
+  }
+}

Reply via email to