Repository navigation
Fix CASE expression caches leaking across TsBlocks #18826
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
f42db79
b863e7b
dc2ff7d
3d10a44
50c38c1
5e0cb1c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| /* | ||
| * 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.relational.it.schema; | ||
|
|
||
| import org.apache.iotdb.it.env.EnvFactory; | ||
| import org.apache.iotdb.it.framework.IoTDBTestRunner; | ||
| import org.apache.iotdb.itbase.category.TableClusterIT; | ||
| import org.apache.iotdb.itbase.category.TableLocalStandaloneIT; | ||
| import org.apache.iotdb.itbase.env.BaseEnv; | ||
|
|
||
| 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.SQLException; | ||
| import java.sql.Statement; | ||
| import java.util.Arrays; | ||
| import java.util.Collections; | ||
| import java.util.HashSet; | ||
|
|
||
| import static org.apache.iotdb.db.it.utils.TestUtils.assertResultSetEqual; | ||
| import static org.apache.iotdb.db.it.utils.TestUtils.prepareTableData; | ||
|
|
||
| @RunWith(IoTDBTestRunner.class) | ||
| @Category({TableLocalStandaloneIT.class, TableClusterIT.class}) | ||
| public class IoTDBCaseDeviceCacheIT { | ||
| private static final String DATABASE = "case_device_cache"; | ||
| private static final String CASE_RESULT = "CASE WHEN src = 'match' THEN 'match' ELSE 'other' END"; | ||
|
|
||
| @BeforeClass | ||
| public static void setUp() throws Exception { | ||
| // Keep every device in the same schema region and force a new batch for each device. | ||
| EnvFactory.getEnv().getConfig().getCommonConfig().setDefaultSchemaRegionGroupNumPerDatabase(1); | ||
| EnvFactory.getEnv().getConfig().getCommonConfig().setMaxTsBlockLineNumber(1); | ||
| EnvFactory.getEnv().initClusterEnvironment(); | ||
| prepareTableData(new String[] {"CREATE DATABASE " + DATABASE}); | ||
| } | ||
|
|
||
| @AfterClass | ||
| public static void tearDown() throws Exception { | ||
| EnvFactory.getEnv().cleanClusterEnvironment(); | ||
| } | ||
|
|
||
| @Test | ||
| public void testDevicePredicatesAcrossBatches() throws Exception { | ||
| try (Connection connection = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT); | ||
| Statement statement = connection.createStatement()) { | ||
| createTable(statement, "predicate_cache"); | ||
| String predicate = CASE_RESULT + " = src"; | ||
| assertResultSetEqual( | ||
| statement.executeQuery("SHOW DEVICES FROM predicate_cache WHERE " + predicate), | ||
| "id,src,a,b,", | ||
| new HashSet<>( | ||
| Arrays.asList( | ||
| "d0,match,null,null,", | ||
| "d1,match,null,null,", | ||
| "d2,other,null,null,", | ||
| "d3,other,null,null,"))); | ||
| assertResultSetEqual( | ||
| statement.executeQuery("COUNT DEVICES FROM predicate_cache WHERE " + predicate), | ||
| "count(devices),", | ||
| Collections.singleton("4,")); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void testAttributeProjectionAcrossBatches() throws Exception { | ||
| try (Connection connection = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT); | ||
| Statement statement = connection.createStatement()) { | ||
| createTable(statement, "projection_cache"); | ||
| statement.execute("UPDATE projection_cache SET a = " + CASE_RESULT + ", b = src"); | ||
| assertResultSetEqual( | ||
| statement.executeQuery("SHOW DEVICES FROM projection_cache"), | ||
| "id,src,a,b,", | ||
| new HashSet<>( | ||
| Arrays.asList( | ||
| "d0,match,match,match,", | ||
| "d1,match,match,match,", | ||
| "d2,other,other,other,", | ||
| "d3,other,other,other,"))); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void testAttributeCommonExpressionClearedByCase() throws Exception { | ||
| try (Connection connection = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT); | ||
| Statement statement = connection.createStatement()) { | ||
| createTable(statement, "common_cache"); | ||
| statement.execute( | ||
| "UPDATE common_cache SET a = src " | ||
| + "WHERE CASE WHEN src = 'match' THEN true ELSE false END"); | ||
|
Comment on lines
+109
to
+111
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test does not exercise the common-expression path it is named after. A statement that does go through the common-expression path: UPDATE common_cache SET a = upper(src) WHERE CASE WHEN upper(src) = 'MATCH' THEN true ELSE false END
Please also cover the guarded-cast UPDATE from the |
||
| assertResultSetEqual( | ||
| statement.executeQuery("SHOW DEVICES FROM common_cache"), | ||
| "id,src,a,b,", | ||
| new HashSet<>( | ||
| Arrays.asList( | ||
| "d0,match,match,null,", | ||
| "d1,match,match,null,", | ||
| "d2,other,null,null,", | ||
| "d3,other,null,null,"))); | ||
| } | ||
| } | ||
|
|
||
| private static void createTable(Statement statement, String table) throws SQLException { | ||
| statement.execute("USE " + DATABASE); | ||
| statement.execute( | ||
| "CREATE TABLE " | ||
| + table | ||
| + " (id STRING TAG, src STRING ATTRIBUTE, a STRING ATTRIBUTE, b STRING ATTRIBUTE, v DOUBLE FIELD)"); | ||
| statement.execute( | ||
| "INSERT INTO " | ||
| + table | ||
| + "(time, id, src, v) VALUES (1, 'd0', 'match', 1), (2, 'd1', 'match', 1), " | ||
| + "(3, 'd2', 'other', 1), (4, 'd3', 'other', 1)"); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,6 +67,10 @@ public class FilterAndProjectOperator implements ProcessOperator { | |
|
|
||
| private final List<ColumnTransformer> projectOutputTransformerList; | ||
|
|
||
| private final ColumnTransformer[] filterCacheTransformers; | ||
|
|
||
| private final ColumnTransformer[] projectCacheTransformers; | ||
|
|
||
| private final TsBlockBuilder filterTsBlockBuilder; | ||
|
|
||
| private final boolean hasNonMappableUDF; | ||
|
|
@@ -100,6 +104,13 @@ public FilterAndProjectOperator( | |
| this.hasNonMappableUDF = hasNonMappableUDF; | ||
| this.filterTsBlockBuilder = new TsBlockBuilder(8, filterOutputDataTypes); | ||
| this.hasFilter = hasFilter; | ||
| List<ColumnTransformer> filterRoots = new ArrayList<>(commonTransformerList); | ||
| if (filterOutputTransformer != null) { | ||
| filterRoots.add(filterOutputTransformer); | ||
| } | ||
| this.filterCacheTransformers = ColumnTransformer.collectTransformers(filterRoots); | ||
| this.projectCacheTransformers = | ||
| ColumnTransformer.collectTransformers(projectOutputTransformerList); | ||
| } | ||
|
|
||
| public FilterAndProjectOperator( | ||
|
|
@@ -111,6 +122,8 @@ public FilterAndProjectOperator( | |
| this.projectLeafColumnTransformerList = | ||
| filterAndProjectOperator.projectLeafColumnTransformerList; | ||
| this.projectOutputTransformerList = filterAndProjectOperator.projectOutputTransformerList; | ||
| this.filterCacheTransformers = filterAndProjectOperator.filterCacheTransformers; | ||
| this.projectCacheTransformers = filterAndProjectOperator.projectCacheTransformers; | ||
| this.hasNonMappableUDF = filterAndProjectOperator.hasNonMappableUDF; | ||
| this.hasFilter = filterAndProjectOperator.hasFilter; | ||
| this.filterTsBlockBuilder = filterAndProjectOperator.filterTsBlockBuilder; | ||
|
|
@@ -152,6 +165,12 @@ public TsBlock next() throws Exception { | |
| private TsBlock getFilterTsBlock(TsBlock input) { | ||
| final Column originTimeColumn = input.getTimeColumn(); | ||
| final int positionCount = originTimeColumn.getPositionCount(); | ||
| // Short-circuit evaluation can reset cache reference counts when a shared expression is | ||
| // recomputed. Invalidate the whole DAG before feeding a new block, including intermediate | ||
| // expressions whose caches may still have unconsumed references from the previous block. | ||
| for (ColumnTransformer columnTransformer : filterCacheTransformers) { | ||
| columnTransformer.clearCacheForNewInput(); | ||
| } | ||
|
Comment on lines
+171
to
+173
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional (same as base, not blocking): the reset runs when the next block arrives. Columns left over from the previous block therefore stay reachable while the driver is blocked and after the last block; an example is a shared expression recomputed after CASE, which keeps an unconsumed reference. Resetting at the end of |
||
| // feed Filter ColumnTransformer, including TimeStampColumnTransformer and constant | ||
| for (LeafColumnTransformer leafColumnTransformer : filterLeafColumnTransformerList) { | ||
| leafColumnTransformer.initFromTsBlock(input); | ||
|
|
@@ -172,8 +191,8 @@ private TsBlock getFilterTsBlock(TsBlock input) { | |
| if (!hasNonMappableUDF) { | ||
| // get result of calculated common sub expressions | ||
| for (ColumnTransformer columnTransformer : commonTransformerList) { | ||
| // CASE WHEN clause would clear all its cache | ||
| // evaluate again to acquire cache | ||
| // CASE evaluation can clear a common expression after the filter used it. Recompute it | ||
| // before materializing the column for projection within this same input block. | ||
| columnTransformer.tryEvaluate(); | ||
| resultColumns.add(columnTransformer.getColumn()); | ||
|
Comment on lines
+194
to
197
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Recomputing here also re-runs stateful functions on the same block after CASE has cleared them. Before this PR the multi-block case below failed; now it silently returns wrong values: CREATE TABLE dt2 (time TIMESTAMP TIME, dev STRING TAG, x DOUBLE FIELD);
INSERT INTO dt2(time, dev, x) VALUES (1, 'd0', 1.0), (2, 'd0', 3.0), (3, 'd0', 6.0);
FLUSH;
INSERT INTO dt2(time, dev, x) VALUES (4, 'd0', 10.0), (5, 'd0', 11.0);
FLUSH;
SELECT time, x, diff(x) AS d FROM dt2 WHERE CASE WHEN diff(x) > 1.0 THEN 1.0 ELSE 0.0 END > 0.0;(1C1D with
A selection mask (see the |
||
| } | ||
|
|
@@ -263,6 +282,11 @@ public static boolean satisfy(Column filterColumn, int rowIndex) { | |
| private TsBlock getTransformedTsBlock(TsBlock input) { | ||
| final Column originTimeColumn = input.getTimeColumn(); | ||
| final int positionCount = originTimeColumn.getPositionCount(); | ||
| // The flat array was deduplicated at construction; clear each node before evaluating any | ||
| // output. | ||
| for (ColumnTransformer columnTransformer : projectCacheTransformers) { | ||
| columnTransformer.clearCacheForNewInput(); | ||
| } | ||
| // feed pre calculated data | ||
| for (LeafColumnTransformer leafColumnTransformer : projectLeafColumnTransformerList) { | ||
| leafColumnTransformer.initFromTsBlock(input); | ||
|
|
@@ -439,6 +463,8 @@ public long ramBytesUsed() { | |
| return INSTANCE_SIZE | ||
| + MemoryEstimationHelper.getEstimatedSizeOfAccountableObject(inputOperator) | ||
| + MemoryEstimationHelper.getEstimatedSizeOfAccountableObject(operatorContext) | ||
| + RamUsageEstimator.shallowSizeOf(filterCacheTransformers) | ||
| + RamUsageEstimator.shallowSizeOf(projectCacheTransformers) | ||
| + filterTsBlockBuilder.getRetainedSizeInBytes(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -30,6 +30,7 @@ | ||||||||||||||||||||||
| import java.util.ArrayList; | |||||||||||||||||||||||
| import java.util.Arrays; | |||||||||||||||||||||||
| import java.util.List; | |||||||||||||||||||||||
| import java.util.function.Consumer; | |||||||||||||||||||||||
|
|
|||||||||||||||||||||||
| public abstract class AbstractCaseWhenThenColumnTransformer extends ColumnTransformer { | |||||||||||||||||||||||
|
|
|||||||||||||||||||||||
|
|
@@ -234,6 +235,22 @@ protected void checkType() { | ||||||||||||||||||||||
| // do nothing | |||||||||||||||||||||||
| } | |||||||||||||||||||||||
|
|
|||||||||||||||||||||||
| @Override | |||||||||||||||||||||||
| protected boolean shouldClearChildrenCache() { | |||||||||||||||||||||||
| // Clearing a cached CASE during evaluation must preserve pending sibling columns. | |||||||||||||||||||||||
| return false; | |||||||||||||||||||||||
| } | |||||||||||||||||||||||
|
Comment on lines
+239
to
+242
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This preserves a pending sibling column only when the shared node is itself a CASE. CASE's own cleanup (L222-L227) still clears its WHEN/THEN/ELSE subtrees recursively. Any non-CASE node shared with an operand that the parent has already computed but not read yet is therefore wiped within the same block. All of these fail the same way on base and on this PR, with a single input block:
Cause:
The same argument applies to this opt-out itself: "preserve pending sibling columns" holds for every node type, not just CASE. The new tests only use the |
|||||||||||||||||||||||
|
|
|||||||||||||||||||||||
| @Override | |||||||||||||||||||||||
| protected void forEachChild(Consumer<ColumnTransformer> action) { | |||||||||||||||||||||||
| for (Pair<ColumnTransformer, ColumnTransformer> whenThenColumnTransformer : | |||||||||||||||||||||||
| whenThenTransformers) { | |||||||||||||||||||||||
| action.accept(whenThenColumnTransformer.left); | |||||||||||||||||||||||
| action.accept(whenThenColumnTransformer.right); | |||||||||||||||||||||||
| } | |||||||||||||||||||||||
| action.accept(elseTransformer); | |||||||||||||||||||||||
| } | |||||||||||||||||||||||
|
Comment on lines
+245
to
+252
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: CASE now keeps the same child list in three places: here, the clear loop in |
|||||||||||||||||||||||
|
|
|||||||||||||||||||||||
| @Override | |||||||||||||||||||||||
| public void close() { | |||||||||||||||||||||||
| super.close(); | |||||||||||||||||||||||
|
|
|||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,13 @@ | |
| import org.apache.tsfile.read.common.type.Type; | ||
| import org.apache.tsfile.read.common.type.TypeEnum; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.Collections; | ||
| import java.util.IdentityHashMap; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
| import java.util.function.Consumer; | ||
|
|
||
| import static org.apache.iotdb.calc.plan.relational.metadata.CommonMetadataUtils.isNumericType; | ||
|
|
||
| public abstract class ColumnTransformer { | ||
|
|
@@ -112,7 +119,54 @@ public void close() { | |
| // do nothing | ||
| } | ||
|
|
||
| /** | ||
| * Clears caches during evaluation. Subclasses should define child edges via {@link | ||
| * #forEachChild(Consumer)} instead of overriding this method. | ||
| * | ||
| * <p>This legacy entry point remains non-final for compatibility with existing test doubles. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: |
||
| */ | ||
| public void clearCache() { | ||
| this.columnCache.clear(); | ||
| if (shouldClearChildrenCache()) { | ||
| forEachChild(ColumnTransformer::clearCache); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Clears only this node's cached column before initializing a new input block. Call this on every | ||
| * node collected by {@link #collectTransformers(Iterable)} before evaluating any expressions for | ||
| * the new block, when no sibling expression can still need a cached column. | ||
| */ | ||
| public final void clearCacheForNewInput() { | ||
| this.columnCache.clear(); | ||
| } | ||
|
|
||
| /** | ||
| * Collects all nodes reachable from the evaluation phase's roots, including CASE descendants, | ||
| * deduplicating by identity. Collect once after constructing the DAG and retain the flat array so | ||
| * new-input cache clearing needs no graph traversal or visited-set allocation. | ||
| */ | ||
| public static ColumnTransformer[] collectTransformers(Iterable<ColumnTransformer> roots) { | ||
| List<ColumnTransformer> transformers = new ArrayList<>(); | ||
| Set<ColumnTransformer> visited = Collections.newSetFromMap(new IdentityHashMap<>()); | ||
| for (ColumnTransformer root : roots) { | ||
| root.collectTransformers(transformers, visited); | ||
| } | ||
| return transformers.toArray(new ColumnTransformer[0]); | ||
| } | ||
|
|
||
| private void collectTransformers( | ||
| List<ColumnTransformer> transformers, Set<ColumnTransformer> visited) { | ||
| if (visited.add(this)) { | ||
| transformers.add(this); | ||
| forEachChild(child -> child.collectTransformers(transformers, visited)); | ||
| } | ||
| } | ||
|
|
||
| protected boolean shouldClearChildrenCache() { | ||
| return true; | ||
| } | ||
|
|
||
| /** Defines the child edges used by both evaluation-time and new-input cache clearing. */ | ||
| protected abstract void forEachChild(Consumer<ColumnTransformer> action); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All new schema tests (and
DevicePredicateHandlerTest) put the CASE on the left:CASE … = src. The mirrored predicatesrc = CASE … ENDalready fails in the first batch on both base and this PR (301: … "leftColumn" is null; see the comment onshouldClearChildrenCache()). Please cover both orientations once that is fixed.