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

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


The following commit(s) were added to refs/heads/master by this push:
     new 3b44cc3857d perf: reuse expression selectors to reduce CPU and memory 
usage (#20450)
3b44cc3857d is described below

commit 3b44cc3857d0635df431aaeee55e778364462b59
Author: Frank Chen <[email protected]>
AuthorDate: Wed Oct 14 14:05:50 2026 +0800

    perf: reuse expression selectors to reduce CPU and memory usage (#20450)
    
    * perf: cache incremental-index expression plans
    
    * fix: avoid sharing mutable expression state
    
    * fix: return fresh expressions from plan cache
    
    * test: use daemon executor for expression cache concurrency
    
    * docs: explain expression plan cache array
    
    * perf: reuse expression selectors during ingestion
    
    * test: cover expression selector reuse with arrays
    
    * test: clarify expression ingestion expectation
    
    * perf: add expression ingestion benchmark
    
    * fix: reevaluate non-deterministic ingestion expressions
---
 .../indexing/ExpressionIngestionBenchmark.java     | 136 ++++++++++
 .../incremental/OnheapIncrementalIndex.java        |  26 +-
 .../segment/virtual/ExprEvalSelectorCache.java     |  42 +++
 .../druid/segment/virtual/ExpressionSelectors.java |  21 +-
 .../RowBasedExpressionColumnValueSelector.java     |  20 +-
 .../incremental/OnheapIncrementalIndexTest.java    | 300 ++++++++++++++++++++-
 .../segment/virtual/ExpressionSelectorsTest.java   |  43 +++
 7 files changed, 571 insertions(+), 17 deletions(-)

diff --git 
a/benchmarks/src/test/java/org/apache/druid/benchmark/indexing/ExpressionIngestionBenchmark.java
 
b/benchmarks/src/test/java/org/apache/druid/benchmark/indexing/ExpressionIngestionBenchmark.java
new file mode 100644
index 00000000000..1295e361976
--- /dev/null
+++ 
b/benchmarks/src/test/java/org/apache/druid/benchmark/indexing/ExpressionIngestionBenchmark.java
@@ -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.druid.benchmark.indexing;
+
+import org.apache.druid.data.input.InputRow;
+import org.apache.druid.data.input.MapBasedInputRow;
+import org.apache.druid.data.input.impl.DimensionsSpec;
+import org.apache.druid.data.input.impl.StringDimensionSchema;
+import org.apache.druid.math.expr.ExprMacroTable;
+import org.apache.druid.math.expr.ExpressionProcessing;
+import org.apache.druid.query.aggregation.AggregatorFactory;
+import org.apache.druid.query.aggregation.DoubleSumAggregatorFactory;
+import org.apache.druid.query.aggregation.LongSumAggregatorFactory;
+import org.apache.druid.segment.incremental.IncrementalIndexSchema;
+import org.apache.druid.segment.incremental.OnheapIncrementalIndex;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Measures on-heap incremental ingestion of 100,000 rows into 25,000 rollup 
keys using three expression metrics.
+ * Input-row construction is performed once per trial and excluded from the 
measured invocation.
+ */
+@State(Scope.Benchmark)
+@Fork(3)
+@Warmup(iterations = 3)
+@Measurement(iterations = 7)
+@BenchmarkMode(Mode.SingleShotTime)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+public class ExpressionIngestionBenchmark
+{
+  private static final int ROW_COUNT = 100_000;
+  private static final int ROLLUP_KEY_COUNT = 25_000;
+
+  private List<InputRow> rows;
+  private AggregatorFactory[] metrics;
+
+  @Setup(Level.Trial)
+  public void setup()
+  {
+    ExpressionProcessing.initializeForTests();
+    rows = makeRows();
+
+    final ExprMacroTable macroTable = ExprMacroTable.nil();
+    metrics = new AggregatorFactory[]{
+        new DoubleSumAggregatorFactory(
+            "exprDouble1",
+            null,
+            "if(x > 0, x * y + z, y - z)",
+            macroTable
+        ),
+        new DoubleSumAggregatorFactory(
+            "exprDouble2",
+            null,
+            "x + y * z",
+            macroTable
+        ),
+        new LongSumAggregatorFactory(
+            "exprLong",
+            null,
+            "if(x > 0, x + y, z)",
+            macroTable
+        )
+    };
+  }
+
+  @Benchmark
+  public int ingest()
+  {
+    try (
+        final OnheapIncrementalIndex index = (OnheapIncrementalIndex) new 
OnheapIncrementalIndex.Builder()
+            .setIndexSchema(
+                new IncrementalIndexSchema.Builder()
+                    .withDimensionsSpec(
+                        new DimensionsSpec(Collections.singletonList(new 
StringDimensionSchema("key")))
+                    )
+                    .withMetrics(metrics)
+                    .withRollup(true)
+                    .build()
+            )
+            .setMaxRowCount(ROW_COUNT + 1)
+            .build()
+    ) {
+      for (final InputRow row : rows) {
+        index.add(row);
+      }
+      return index.numRows();
+    }
+  }
+
+  private static List<InputRow> makeRows()
+  {
+    final List<InputRow> rows = new ArrayList<>(ROW_COUNT);
+    for (int rowNumber = 0; rowNumber < ROW_COUNT; rowNumber++) {
+      final Map<String, Object> event = new HashMap<>();
+      event.put("key", "key-" + rowNumber % ROLLUP_KEY_COUNT);
+      event.put("x", (long) (rowNumber % 1_000) - 500L);
+      event.put("y", (long) (rowNumber % 100));
+      event.put("z", (long) (rowNumber % 17));
+      rows.add(new MapBasedInputRow(0L, Collections.singletonList("key"), 
event));
+    }
+    return rows;
+  }
+}
diff --git 
a/processing/src/main/java/org/apache/druid/segment/incremental/OnheapIncrementalIndex.java
 
b/processing/src/main/java/org/apache/druid/segment/incremental/OnheapIncrementalIndex.java
index f9d6ff965c3..3fcfaeff706 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/incremental/OnheapIncrementalIndex.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/incremental/OnheapIncrementalIndex.java
@@ -34,6 +34,8 @@ import org.apache.druid.java.util.common.StringUtils;
 import org.apache.druid.java.util.common.io.Closer;
 import org.apache.druid.java.util.common.logger.Logger;
 import org.apache.druid.java.util.common.parsers.ParseException;
+import org.apache.druid.math.expr.Expr;
+import org.apache.druid.math.expr.ExprEval;
 import org.apache.druid.query.aggregation.Aggregator;
 import org.apache.druid.query.aggregation.AggregatorAndSize;
 import org.apache.druid.query.aggregation.AggregatorFactory;
@@ -52,6 +54,8 @@ import org.apache.druid.segment.column.ValueType;
 import org.apache.druid.segment.projections.AggregateProjectionSchema;
 import org.apache.druid.segment.projections.Projections;
 import org.apache.druid.segment.projections.QueryableProjection;
+import org.apache.druid.segment.virtual.ExprEvalSelectorCache;
+import org.apache.druid.segment.virtual.ExpressionSelectors;
 import org.apache.druid.utils.JvmUtils;
 
 import javax.annotation.Nullable;
@@ -62,6 +66,7 @@ import java.util.Collection;
 import java.util.Comparator;
 import java.util.Deque;
 import java.util.HashMap;
+import java.util.IdentityHashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -670,18 +675,20 @@ public class OnheapIncrementalIndex extends 
IncrementalIndex
   }
 
   /**
-   * Caches references to selector objects for each column instead of creating 
a new object each time in order to save
-   * heap space.
+   * Caches references to selector objects instead of creating them for each 
row key in order to save heap space and
+   * avoid repeated expression planning and selector construction.
    */
-  static class CachingColumnSelectorFactory implements ColumnSelectorFactory
+  static class CachingColumnSelectorFactory implements ColumnSelectorFactory, 
ExprEvalSelectorCache
   {
     private final HashMap<String, ColumnValueSelector<?>> columnSelectorMap;
+    private final IdentityHashMap<Expr, ColumnValueSelector<ExprEval>> 
expressionSelectorMap;
     private final ColumnSelectorFactory delegate;
 
     public CachingColumnSelectorFactory(ColumnSelectorFactory delegate)
     {
       this.delegate = delegate;
       this.columnSelectorMap = new HashMap<>();
+      this.expressionSelectorMap = new IdentityHashMap<>();
     }
 
     @Override
@@ -706,6 +713,19 @@ public class OnheapIncrementalIndex extends 
IncrementalIndex
       return existing != null ? existing : columnValueSelector;
     }
 
+    @Override
+    public ColumnValueSelector<ExprEval> getOrCreateExprEvalSelector(final 
Expr expression)
+    {
+      ColumnValueSelector<ExprEval> selector = 
expressionSelectorMap.get(expression);
+      if (selector == null) {
+        // Do not use computeIfAbsent: building the expression selector can 
re-enter makeColumnValueSelector on this
+        // factory through expression bindings.
+        selector = ExpressionSelectors.makeExprEvalSelectorUncached(this, 
expression);
+        expressionSelectorMap.put(expression, selector);
+      }
+      return selector;
+    }
+
     @Nullable
     @Override
     public ColumnCapabilities getColumnCapabilities(String columnName)
diff --git 
a/processing/src/main/java/org/apache/druid/segment/virtual/ExprEvalSelectorCache.java
 
b/processing/src/main/java/org/apache/druid/segment/virtual/ExprEvalSelectorCache.java
new file mode 100644
index 00000000000..bc228d17d4a
--- /dev/null
+++ 
b/processing/src/main/java/org/apache/druid/segment/virtual/ExprEvalSelectorCache.java
@@ -0,0 +1,42 @@
+/*
+ * 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.druid.segment.virtual;
+
+import org.apache.druid.math.expr.Expr;
+import org.apache.druid.math.expr.ExprEval;
+import org.apache.druid.segment.ColumnValueSelector;
+
+/**
+ * Supplies expression selectors cached for the lifetime of an ingestion 
column selector factory.
+ *
+ * Implementations may return one mutable selector shared across callers. 
Callers must only use returned selectors from
+ * the thread that ingests rows. Query-time column selector factories, which 
may be used from multiple threads, must not
+ * implement this interface.
+ */
+public interface ExprEvalSelectorCache
+{
+  /**
+   * Returns a cached or newly created selector for an expression.
+   *
+   * @param expression expression to evaluate
+   * @return cached or newly created selector
+   */
+  ColumnValueSelector<ExprEval> getOrCreateExprEvalSelector(Expr expression);
+}
diff --git 
a/processing/src/main/java/org/apache/druid/segment/virtual/ExpressionSelectors.java
 
b/processing/src/main/java/org/apache/druid/segment/virtual/ExpressionSelectors.java
index dfb97cf8b5f..66e49c2b707 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/virtual/ExpressionSelectors.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/virtual/ExpressionSelectors.java
@@ -133,13 +133,29 @@ public class ExpressionSelectors
       Expr expression
   )
   {
-    ExpressionPlan plan = ExpressionPlanner.plan(
+    if (columnSelectorFactory instanceof ExprEvalSelectorCache) {
+      return ((ExprEvalSelectorCache) 
columnSelectorFactory).getOrCreateExprEvalSelector(expression);
+    }
+    return makeExprEvalSelectorUncached(columnSelectorFactory, expression);
+  }
+
+  /**
+   * Makes an uncached expression selector. Used by {@link 
ExprEvalSelectorCache} implementations to avoid recursively
+   * calling back into the cache.
+   */
+  public static ColumnValueSelector<ExprEval> makeExprEvalSelectorUncached(
+      ColumnSelectorFactory columnSelectorFactory,
+      Expr expression
+  )
+  {
+    final ExpressionPlan plan = ExpressionPlanner.plan(
         columnSelectorFactory,
         Expr.singleThreaded(expression, columnSelectorFactory)
     );
     final RowIdSupplier rowIdSupplier = 
columnSelectorFactory.getRowIdSupplier();
 
-    if (plan.is(ExpressionPlan.Trait.SINGLE_INPUT_SCALAR)) {
+    // Non-deterministic expressions must be evaluated for each row, so they 
cannot use input-value result caches.
+    if (plan.is(ExpressionPlan.Trait.SINGLE_INPUT_SCALAR) && 
!plan.getAnalysis().isNonDeterministic()) {
       final String column = plan.getSingleInputName();
       final ColumnType inputType = plan.getSingleInputType();
       if (inputType.is(ValueType.LONG)) {
@@ -235,7 +251,6 @@ public class ExpressionSelectors
     }
   }
 
-
   /**
    * Returns whether an expression can be applied to unique values of a 
particular column (like those in a dictionary)
    * rather than being applied to each row individually.
diff --git 
a/processing/src/main/java/org/apache/druid/segment/virtual/RowBasedExpressionColumnValueSelector.java
 
b/processing/src/main/java/org/apache/druid/segment/virtual/RowBasedExpressionColumnValueSelector.java
index 085510aaccf..0ca0394f966 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/virtual/RowBasedExpressionColumnValueSelector.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/virtual/RowBasedExpressionColumnValueSelector.java
@@ -20,15 +20,15 @@
 package org.apache.druid.segment.virtual;
 
 import com.google.common.collect.Lists;
-import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap;
-import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
 import org.apache.druid.math.expr.Expr;
 import org.apache.druid.math.expr.ExprEval;
 import org.apache.druid.math.expr.Parser;
 import org.apache.druid.segment.RowIdSupplier;
 
 import javax.annotation.Nullable;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 
 /**
@@ -44,7 +44,7 @@ public class RowBasedExpressionColumnValueSelector extends 
BaseExpressionColumnV
   private final Expr expression;
   private final List<String> unknownColumns;
   private final Expr.BindingAnalysis baseBindingAnalysis;
-  private final Int2ObjectMap<Expr> transformedCache;
+  private final Map<List<String>, Expr> transformedCache;
 
   public RowBasedExpressionColumnValueSelector(
       ExpressionPlan plan,
@@ -60,7 +60,7 @@ public class RowBasedExpressionColumnValueSelector extends 
BaseExpressionColumnV
                               .filter(x -> 
!plan.getAnalysis().getArrayBindings().contains(x))
                               .collect(Collectors.toList());
     this.baseBindingAnalysis = plan.getAnalysis();
-    this.transformedCache = new Int2ObjectArrayMap<>(unknownColumns.size());
+    this.transformedCache = new HashMap<>();
   }
 
   @Override
@@ -78,13 +78,13 @@ public class RowBasedExpressionColumnValueSelector extends 
BaseExpressionColumnV
     // if there are arrays, we need to transform the expression to one that 
applies each value of the array to the
     // base expression, we keep a cache of transformed expressions to minimize 
extra work
     if (!arrayBindings.isEmpty()) {
-      final int key = arrayBindings.hashCode();
-      if (transformedCache.containsKey(key)) {
-        return transformedCache.get(key).eval(bindings);
+      final Expr transformed = transformedCache.get(arrayBindings);
+      if (transformed != null) {
+        return transformed.eval(bindings);
       }
-      final Expr transformed = Parser.applyUnappliedBindings(expression, 
baseBindingAnalysis, arrayBindings);
-      transformedCache.put(key, transformed);
-      return transformed.eval(bindings);
+      final Expr newTransformed = Parser.applyUnappliedBindings(expression, 
baseBindingAnalysis, arrayBindings);
+      transformedCache.put(arrayBindings, newTransformed);
+      return newTransformed.eval(bindings);
     }
     // no arrays for this row, evaluate base expression
     return expression.eval(bindings);
diff --git 
a/processing/src/test/java/org/apache/druid/segment/incremental/OnheapIncrementalIndexTest.java
 
b/processing/src/test/java/org/apache/druid/segment/incremental/OnheapIncrementalIndexTest.java
index d4997bd008c..f626740d847 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/incremental/OnheapIncrementalIndexTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/incremental/OnheapIncrementalIndexTest.java
@@ -21,7 +21,9 @@ package org.apache.druid.segment.incremental;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.collect.ImmutableMap;
 import nl.jqno.equalsverifier.EqualsVerifier;
+import org.apache.druid.data.input.InputRow;
 import org.apache.druid.data.input.MapBasedInputRow;
 import org.apache.druid.data.input.impl.AggregateProjectionSpec;
 import org.apache.druid.data.input.impl.DimensionsSpec;
@@ -31,23 +33,37 @@ import 
org.apache.druid.data.input.impl.StringDimensionSchema;
 import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.DateTimes;
 import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.math.expr.Expr;
+import org.apache.druid.math.expr.ExprEval;
+import org.apache.druid.math.expr.ExprMacroTable;
+import org.apache.druid.math.expr.Parser;
+import org.apache.druid.query.aggregation.Aggregator;
 import org.apache.druid.query.aggregation.AggregatorFactory;
 import org.apache.druid.query.aggregation.DoubleSumAggregatorFactory;
 import org.apache.druid.query.aggregation.LongSumAggregatorFactory;
 import org.apache.druid.query.expression.TestExprMacroTable;
+import org.apache.druid.segment.ColumnSelectorFactory;
+import org.apache.druid.segment.ColumnValueSelector;
 import org.apache.druid.segment.IndexBuilder;
+import org.apache.druid.segment.TestColumnSelectorFactory;
 import org.apache.druid.segment.TestHelper;
+import org.apache.druid.segment.TestObjectColumnSelector;
 import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.virtual.ExpressionSelectors;
 import org.apache.druid.segment.virtual.ExpressionVirtualColumn;
+import org.apache.druid.testing.InitializedNullHandlingTest;
 import org.joda.time.DateTime;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
-public class OnheapIncrementalIndexTest
+public class OnheapIncrementalIndexTest extends InitializedNullHandlingTest
 {
   private static final ObjectMapper MAPPER = TestHelper.makeJsonMapper();
 
@@ -508,4 +524,286 @@ public class OnheapIncrementalIndexTest
         t.getMessage()
     );
   }
+
+  @Test
+  public void testExpressionSelectorCacheUsesExpressionIdentity()
+  {
+    final OnheapIncrementalIndex.CachingColumnSelectorFactory selectorFactory 
= makeCachingColumnSelectorFactory();
+    final Expr expression = Parser.parse("value", TestExprMacroTable.INSTANCE);
+    final Expr structurallyEqualExpression = Parser.parse("value", 
TestExprMacroTable.INSTANCE);
+    final Expr differentExpression = Parser.parse("other", 
TestExprMacroTable.INSTANCE);
+
+    Assertions.assertNotSame(expression, structurallyEqualExpression);
+
+    final ColumnValueSelector<ExprEval> firstSelector = 
selectorFactory.getOrCreateExprEvalSelector(expression);
+    Assertions.assertSame(firstSelector, 
selectorFactory.getOrCreateExprEvalSelector(expression));
+
+    final ColumnValueSelector<ExprEval> structurallyEqualSelector =
+        
selectorFactory.getOrCreateExprEvalSelector(structurallyEqualExpression);
+    Assertions.assertNotSame(firstSelector, structurallyEqualSelector);
+    Assertions.assertSame(
+        structurallyEqualSelector,
+        
selectorFactory.getOrCreateExprEvalSelector(structurallyEqualExpression)
+    );
+
+    final ColumnValueSelector<ExprEval> differentSelector =
+        selectorFactory.getOrCreateExprEvalSelector(differentExpression);
+    Assertions.assertNotSame(structurallyEqualSelector, differentSelector);
+    Assertions.assertSame(differentSelector, 
selectorFactory.getOrCreateExprEvalSelector(differentExpression));
+  }
+
+  @Test
+  public void testExpressionSelectorCacheReusesSelector()
+  {
+    final OnheapIncrementalIndex.CachingColumnSelectorFactory selectorFactory 
= makeCachingColumnSelectorFactory();
+    final Expr expression = Parser.parse("value + 1", 
TestExprMacroTable.INSTANCE);
+
+    final ColumnValueSelector<?> firstSelector = 
ExpressionSelectors.makeExprEvalSelector(selectorFactory, expression);
+    final ColumnValueSelector<?> secondSelector = 
ExpressionSelectors.makeExprEvalSelector(selectorFactory, expression);
+    Assertions.assertSame(firstSelector, secondSelector);
+  }
+
+  @Test
+  public void testExpressionSelectorCacheReusesConstantSelector()
+  {
+    final OnheapIncrementalIndex.CachingColumnSelectorFactory selectorFactory 
= makeCachingColumnSelectorFactory();
+    final Expr constantExpression = Parser.parse("'constant'", 
TestExprMacroTable.INSTANCE);
+    final ColumnValueSelector<ExprEval> firstConstantSelector =
+        ExpressionSelectors.makeExprEvalSelector(selectorFactory, 
constantExpression);
+    final ColumnValueSelector<ExprEval> secondConstantSelector =
+        ExpressionSelectors.makeExprEvalSelector(selectorFactory, 
constantExpression);
+    Assertions.assertSame(firstConstantSelector, secondConstantSelector);
+  }
+
+  @Test
+  public void testExpressionSelectorCacheDoesNotShareAggregatorState()
+  {
+    final OnheapIncrementalIndex.CachingColumnSelectorFactory selectorFactory 
= makeCachingColumnSelectorFactory();
+    final AggregatorFactory aggregatorFactory = new LongSumAggregatorFactory(
+        "sum",
+        null,
+        "value + 1",
+        TestExprMacroTable.INSTANCE
+    );
+    final Aggregator firstAggregator = 
aggregatorFactory.factorize(selectorFactory);
+    final Aggregator secondAggregator = 
aggregatorFactory.factorize(selectorFactory);
+
+    Assertions.assertNotSame(firstAggregator, secondAggregator);
+    firstAggregator.aggregate();
+    Assertions.assertEquals(2L, firstAggregator.getLong());
+    Assertions.assertTrue(secondAggregator.isNull());
+  }
+
+  @Test
+  public void testExpressionIngestionProducesExpectedResults()
+  {
+    final int rowCount = 10_000;
+    final int rollupKeyCount = 100;
+    final List<InputRow> rows = new ArrayList<>(rowCount);
+    long expectedSum = 0L;
+    for (int rowNumber = 0; rowNumber < rowCount; rowNumber++) {
+      final long value = rowNumber % 100;
+      rows.add(
+          new MapBasedInputRow(
+              0L,
+              Collections.singletonList("key"),
+              ImmutableMap.of(
+                  "key", "key-" + rowNumber % rollupKeyCount,
+                  "value", value
+              )
+          )
+      );
+      // Each row contributes the result of the ingestion expression "value + 
1".
+      expectedSum += value + 1;
+    }
+
+    try (
+        OnheapIncrementalIndex index = (OnheapIncrementalIndex) new 
OnheapIncrementalIndex.Builder()
+            .setIndexSchema(
+                new IncrementalIndexSchema.Builder()
+                    .withDimensionsSpec(new 
DimensionsSpec(Collections.singletonList(new StringDimensionSchema("key"))))
+                    .withMetrics(
+                        new LongSumAggregatorFactory(
+                            "sum",
+                            null,
+                            "value + 1",
+                            TestExprMacroTable.INSTANCE
+                        )
+                    )
+                    .withRollup(true)
+                    .build()
+            )
+            .setMaxRowCount(rowCount + 1)
+            .build()
+    ) {
+      for (final InputRow row : rows) {
+        index.add(row);
+      }
+
+      long actualSum = 0L;
+      for (final IncrementalIndexRow row : index.getFacts().keySet()) {
+        actualSum += index.getMetricLongValue(row.getRowIndex(), 0);
+      }
+
+      Assertions.assertEquals(rollupKeyCount, index.numRows());
+      Assertions.assertEquals(expectedSum, actualSum);
+    }
+  }
+
+  @Test
+  public void 
testExpressionIngestionWithChangingArrayBindingsAcrossRollupKeys()
+  {
+    final List<InputRow> rows = List.of(
+        new MapBasedInputRow(
+            0L,
+            Collections.singletonList("key"),
+            ImmutableMap.of("key", "key-0",
+                            "Aa", List.of("a1", "a2"),
+                            "BB", "b")
+        ),
+        new MapBasedInputRow(
+            0L,
+            Collections.singletonList("key"),
+            ImmutableMap.of("key", "key-1",
+                            "Aa", "a",
+                            "BB", List.of("b1", "b2"))
+        ),
+        new MapBasedInputRow(
+            0L,
+            Collections.singletonList("key"),
+            ImmutableMap.of("key", "key-2",
+                            "Aa", List.of("x"),
+                            "BB", "yz")
+        ),
+        new MapBasedInputRow(
+            0L,
+            Collections.singletonList("key"),
+            ImmutableMap.of("key", "key-3",
+                            "Aa", "xy",
+                            "BB", List.of("z"))
+        )
+    );
+
+    try (
+        OnheapIncrementalIndex index = (OnheapIncrementalIndex) new 
OnheapIncrementalIndex.Builder()
+            .setIndexSchema(
+                new IncrementalIndexSchema.Builder()
+                    .withDimensionsSpec(new 
DimensionsSpec(Collections.singletonList(new StringDimensionSchema("key"))))
+                    .withMetrics(
+                        new LongSumAggregatorFactory(
+                            "sum",
+                            null,
+                            "strlen(array_to_string(concat(\"Aa\", \"BB\"), 
','))",
+                            TestExprMacroTable.INSTANCE
+                        )
+                    )
+                    .withRollup(true)
+                    .build()
+            )
+            .setMaxRowCount(rows.size() + 1)
+            .build()
+    ) {
+      for (final InputRow row : rows) {
+        index.add(row);
+      }
+
+      long actualSum = 0L;
+      for (final IncrementalIndexRow row : index.getFacts().keySet()) {
+        actualSum += index.getMetricLongValue(row.getRowIndex(), 0);
+      }
+
+      Assertions.assertEquals(rows.size(), index.numRows());
+
+      // Per-row expression results are 7, 7, 3, and 3, for a total of 20.
+      // For example: Row 1: Aa = ["a1", "a2"], BB = "b", concat("Aa", "BB") = 
["a1b", "a2b"], array_to_string = "a1b,a2b", length is 7
+      Assertions.assertEquals(20L, actualSum);
+    }
+  }
+
+  @Test
+  public void 
testExpressionSelectorCacheReevaluatesNonDeterministicExpressionAcrossRollupKeys()
+  {
+    // MapBasedInputRow exposes its timestamp as __time. Use the same value 
for both rollup keys.
+    final long inputTimestamp = 0L;
+    try (
+        OnheapIncrementalIndex index = (OnheapIncrementalIndex) new 
OnheapIncrementalIndex.Builder()
+            .setIndexSchema(
+                new IncrementalIndexSchema.Builder()
+                    .withDimensionsSpec(new 
DimensionsSpec(Collections.singletonList(new StringDimensionSchema("key"))))
+                    .withMetrics(
+                        new LongSumAggregatorFactory(
+                            "expressionResult",
+                            null,
+                            "now() - __time",
+                            ExprMacroTable.nil()
+                        )
+                    )
+                    .withRollup(true)
+                    .build()
+            )
+            .setMaxRowCount(3)
+            .build()
+    ) {
+      index.add(
+          new MapBasedInputRow(
+              inputTimestamp,
+              Collections.singletonList("key"),
+              ImmutableMap.of("key", "key-0")
+          )
+      );
+
+      // Since __time is 0, wait until a new now() evaluation must be greater 
than the first result.
+      final IncrementalIndexRow firstIndexRow = 
index.getFacts().keySet().iterator().next();
+      final long firstExpressionResult = 
index.getMetricLongValue(firstIndexRow.getRowIndex(), 0);
+      final long waitDeadline = System.nanoTime() + 
TimeUnit.SECONDS.toNanos(1);
+      while (System.currentTimeMillis() <= firstExpressionResult) {
+        Assertions.assertTrue(System.nanoTime() < waitDeadline, "Timed out 
waiting for the clock to advance");
+        Thread.onSpinWait();
+      }
+
+      index.add(
+          new MapBasedInputRow(
+              inputTimestamp,
+              Collections.singletonList("key"),
+              ImmutableMap.of("key", "key-1")
+          )
+      );
+
+      final List<Long> expressionResults = new ArrayList<>();
+      for (final IncrementalIndexRow row : index.getFacts().keySet()) {
+        expressionResults.add(index.getMetricLongValue(row.getRowIndex(), 0));
+      }
+
+      Assertions.assertEquals(2, expressionResults.size());
+      Assertions.assertTrue(
+          expressionResults.stream().anyMatch(expressionResult -> 
expressionResult > firstExpressionResult),
+          "The non-deterministic expression should be reevaluated for the 
second rollup key"
+      );
+    }
+  }
+
+  private static OnheapIncrementalIndex.CachingColumnSelectorFactory 
makeCachingColumnSelectorFactory()
+  {
+    final TestObjectColumnSelector<Long> valueSelector = new 
TestObjectColumnSelector<Long>()
+    {
+      @Override
+      public Class<Long> classOfObject()
+      {
+        return Long.class;
+      }
+
+      @Override
+      public Long getObject()
+      {
+        return 1L;
+      }
+    };
+
+    final ColumnSelectorFactory delegate = new TestColumnSelectorFactory()
+        .addColumnSelector("value", valueSelector)
+        .addCapabilities("value", null)
+        .addColumnSelector("other", valueSelector)
+        .addCapabilities("other", null);
+    return new OnheapIncrementalIndex.CachingColumnSelectorFactory(delegate);
+  }
 }
diff --git 
a/processing/src/test/java/org/apache/druid/segment/virtual/ExpressionSelectorsTest.java
 
b/processing/src/test/java/org/apache/druid/segment/virtual/ExpressionSelectorsTest.java
index 05f6c68122b..63e26ceaaff 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/virtual/ExpressionSelectorsTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/virtual/ExpressionSelectorsTest.java
@@ -303,6 +303,49 @@ public class ExpressionSelectorsTest extends 
InitializedNullHandlingTest
     }
   }
 
+  @Test
+  public void 
test_row_based_selector_distinguishes_colliding_array_binding_sets() throws 
IOException
+  {
+    // These distinct binding lists collide when keyed only by their hash 
codes.
+    Assertions.assertEquals(List.of("Aa").hashCode(), 
List.of("BB").hashCode());
+
+    final DateTime timestamp = DateTimes.nowUtc();
+    final List<InputRow> rows = List.of(
+        new MapBasedInputRow(
+            timestamp,
+            List.of("Aa", "BB"),
+            ImmutableMap.of("Aa", List.of("a1", "a2"), "BB", "b")
+        ),
+        new MapBasedInputRow(
+            timestamp.plusMinutes(1),
+            List.of("Aa", "BB"),
+            ImmutableMap.of("Aa", "a", "BB", List.of("b1", "b2"))
+        )
+    );
+
+    try (final Segment segment = new RowBasedSegment<>(
+        Sequences.simple(rows),
+        RowAdapters.standardRow(),
+        RowSignature.empty()
+    )) {
+      final CursorFactory cursorFactory = segment.as(CursorFactory.class);
+      Assertions.assertNotNull(cursorFactory);
+
+      try (final CursorHolder cursorHolder = 
cursorFactory.makeCursorHolder(CursorBuildSpec.FULL_SCAN)) {
+        final Cursor cursor = cursorHolder.asCursor();
+        final ColumnValueSelector<ExprEval> selector = 
ExpressionSelectors.makeExprEvalSelector(
+            cursor.getColumnSelectorFactory(),
+            Parser.parse("concat(\"Aa\", \"BB\")", TestExprMacroTable.INSTANCE)
+        );
+
+        Assertions.assertArrayEquals(new Object[]{"a1b", "a2b"}, 
selector.getObject().asArray());
+
+        cursor.advance();
+        Assertions.assertArrayEquals(new Object[]{"ab1", "ab2"}, 
selector.getObject().asArray());
+      }
+    }
+  }
+
   @Test
   public void test_long_bindings()
   {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to