Skip to content
Open
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 @@ -695,4 +695,54 @@ public void testThenWithBinarySameConstant() {
String[] retArray = new String[] {"0,", "11,", "22,", "33,"};
tableResultSetEqualTest(sql, expectedHeader, retArray, DATABASE);
}

@Test
public void testCaseWhenReusesAggregationAcrossBlocks() {
prepareTableData(
new String[] {
"USE " + DATABASE,
"CREATE TABLE case_aggregation_cache (dev STRING TAG, c DOUBLE FIELD, q INT32 FIELD)",
"INSERT INTO case_aggregation_cache(time, dev, c, q) VALUES "
+ "(0, 'd0', 1.0, 0), (0, 'd1', 1.0, 0), (3600000, 'd1', 1.0, 0)"
});

// The non-pushdown predicate and the different device/window shapes make the aggregation
// produce multiple blocks. CASE clears the shared aggregate before the next output uses it.
String suffix = " FROM case_aggregation_cache WHERE q % 2 < 1 GROUP BY dev, date_bin(1h, time)";
for (String projection :
new String[] {
"CASE WHEN sum(c) > 0 THEN 1.0 END AS case_result, CAST(sum(c) AS DOUBLE) AS total",
"CASE WHEN sum(c) > 0 THEN 1.0 END AS case_result, sum(c) + 0 AS total",
"CASE WHEN sum(c) > 0 THEN CAST(sum(c) AS DOUBLE) / CAST(sum(c) AS DOUBLE) END "
+ "AS case_result, CAST(sum(c) AS DOUBLE) AS total"
}) {
tableResultSetEqualTest(
"SELECT " + projection + suffix,
new String[] {"case_result", "total"},
new String[] {"1.0,1.0,", "1.0,1.0,", "1.0,1.0,"},
DATABASE);
}
}

@Test
public void testReusingCasePreservesPendingSiblingColumn() {
prepareTableData(
new String[] {
"USE " + DATABASE,
"CREATE TABLE case_sibling_cache (dev STRING TAG, x DOUBLE FIELD)",
"INSERT INTO case_sibling_cache(time, dev, x) VALUES "
+ "(0, 'd0', -3.0), (1, 'd0', 0.0), (2, 'd0', 2.0)"
});

String sharedCase = "CASE WHEN x > 0 THEN 1.0 ELSE 0.0 END";
tableResultSetEqualTest(
"SELECT "
+ sharedCase
+ " AS a, x + CASE WHEN ("
+ sharedCase
+ ") > 0 THEN 2.0 ELSE 3.0 END AS b FROM case_sibling_cache ORDER BY time",
new String[] {"a", "b"},
new String[] {"0.0,0.0,", "0.0,3.0,", "1.0,4.0,"},
DATABASE);
}
}
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";

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.

All new schema tests (and DevicePredicateHandlerTest) put the CASE on the left: CASE … = src. The mirrored predicate src = CASE … END already fails in the first batch on both base and this PR (301: … "leftColumn" is null; see the comment on shouldClearChildrenCache()). Please cover both orientations once that is fixed.

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

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.

This test does not exercise the common-expression path it is named after. src is a bare column reference: ColumnTransformerBuilder.visitSymbolReference turns it into a leaf without consulting hasSeen, so commonTransformerList stays empty. The same UPDATE already returns the correct result on base (checked with max_tsblock_line_number=1 and one schema region). The unit test at DevicePredicateHandlerTest L167 likewise uses a leaf as the common transformer, which the planner never produces.

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
  • base: 305: internal error. statemachine throws a runtime exception: java.lang.NullPointerException: Cannot invoke "org.apache.tsfile.block.column.Column.isNull(int)" because "curColumn" is null
  • this PR: d0/d1 get MATCH, d2/d3 stay NULL

Please also cover the guarded-cast UPDATE from the DeviceAttributeUpdater comment.

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
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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;
Expand Down Expand Up @@ -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

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.

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 next() (in a finally) would release them as soon as the output block is built.

// feed Filter ColumnTransformer, including TimeStampColumnTransformer and constant
for (LeafColumnTransformer leafColumnTransformer : filterLeafColumnTransformerList) {
leafColumnTransformer.initFromTsBlock(input);
Expand All @@ -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

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.

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 max_tsblock_line_number=1, so [1, 3, 6] and [10, 11] reach this operator as separate blocks)

  • expected: d = 2.0, 3.0, 4.0 at time 2, 3, 4
  • base: 301: Index 2 out of bounds for length 2
  • this PR: time 4 returns d = -1.0. The first WHEN already computed diff for [10, 11] and advanced its state to 11. CASE then clears that column, and this line runs diff again on the same block (10 - 11).
  • With ELSE 1.0 (every row passes), time 1 returns -5.0 instead of NULL.

A selection mask (see the DeviceAttributeUpdater comment) doesn't help here, because DIFF evaluates every row anyway. The column computed by the first WHEN was complete and valid; it is lost only because CASE clears its WHEN subtree (see the root-cause note in the review summary).

}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Up @@ -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 {

Expand Down Expand Up @@ -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

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.

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:

SQL expected actual
SELECT s1 + CASE WHEN s1 > 0 THEN 2 ELSE 3 END FROM p (s1 = 1, -2) 3.0, 1.0 NULL, NULL (tree model too)
SELECT time, s1 FROM p WHERE s1 + CASE WHEN s1 > 0 THEN 2 ELSE 3 END > 0 2 rows empty set
SELECT s1 > CASE WHEN s1 > 0 THEN 2 ELSE -3 END FROM p false, true 301: … "leftColumn" is null
SHOW DEVICES / COUNT DEVICES … WHERE src = CASE WHEN src = 'match' THEN 'match' ELSE 'other' END all devices 301: … "leftColumn" is null
UPDATE t SET a = concat(src, CASE WHEN src = 'match' THEN '-m' ELSE '-o' END) match-m / other-o succeeds, but every a becomes NULL (old value lost)
greatest(s1, CASE …), s1 BETWEEN -5 AND CASE …, substring(s, 1, CASE …), CASE WHEN s IS NOT NULL THEN substring(s, 1, length(s) - 1) END values NULL

Cause: BinaryColumnTransformer.evaluate(), TernaryColumnTransformer.evaluate() / evaluateWithSelection(), MultiColumnTransformer.evaluate() and MappableUDFColumnTransformer.evaluate() / evaluateWithSelection() evaluate all children first and call getColumn() afterwards. When a later child is (or contains) a CASE, its cleanup clears the shared node, and the earlier child's read returns null / 0 positions.

BinaryColumnTransformer.evaluateWithSelection() already avoids this by reading the left column before evaluating the right one (see its comment). I applied that rule everywhere: read each child's column and position count right after evaluating it, before evaluating the next sibling. In my local run that fixes every row above, and the calc-commons tests and datanode transformer tests still pass.

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 CASE … = src orientation, which happens to avoid the problem (see the IT comment).


@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

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.

Nit: CASE now keeps the same child list in three places: here, the clear loop in doTransform() (L222-L227) and close() (L255-L263). Since the goal is a single child-edge definition, consider forEachChild(ColumnTransformer::clearCache) in doTransform() and forEachChild(ColumnTransformer::close) in close(). The same applies to the close() methods of Unary/Binary/Ternary/Multi/MappableUDF. That way a new child edge cannot be added to one list and missed in another.


@Override
public void close() {
super.close();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.

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.

Nit: clearCache() is still the main evaluation-time cleanup (CASE and the Unary/Binary/Ternary/Multi selection paths call it), so "legacy entry point" is misleading. Maybe: "Kept non-final so existing Mockito-based tests can stub it."

*/
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
import org.apache.tsfile.read.common.block.column.NullColumn;
import org.apache.tsfile.read.common.type.Type;

import java.util.function.Consumer;

/**
* when evaluate of FailFunctionColumnTransformer is called, throw Exception to inform the user with
* specified errorMsg
Expand Down Expand Up @@ -59,4 +61,9 @@ public void evaluateWithSelection(boolean[] selection) {
protected void checkType() {
// do nothing
}

@Override
protected void forEachChild(Consumer<ColumnTransformer> action) {
// The fail function has no children.
}
}
Loading
Loading