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

Jackie-Jiang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new 3101abc90f7 Cache immutable and stable scalar calls on literal 
arguments in MSE (#19821)
3101abc90f7 is described below

commit 3101abc90f74f279a6e77e4c1370e203fe7b4b16
Author: Thota Shashank <[email protected]>
AuthorDate: Fri Oct 16 03:59:57 2026 +0530

    Cache immutable and stable scalar calls on literal arguments in MSE (#19821)
---
 .../runtime/operator/operands/FunctionOperand.java |  22 +-
 .../runtime/function/FunctionOperandTest.java      | 266 +++++++++++++++++++++
 2 files changed, 284 insertions(+), 4 deletions(-)

diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/operands/FunctionOperand.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/operands/FunctionOperand.java
index c9d01d0f8cd..48bcb880259 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/operands/FunctionOperand.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/operands/FunctionOperand.java
@@ -31,12 +31,13 @@ import org.apache.pinot.common.utils.DataSchema;
 import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
 import org.apache.pinot.query.planner.logical.RexExpression;
 import org.apache.pinot.query.runtime.operator.utils.TypeUtils;
+import org.apache.pinot.spi.annotations.FunctionVolatility;
 import org.apache.pinot.spi.utils.PinotDataType;
 
 
-/*
- * FunctionOperands are generated from {@link RexExpression}s.
- */
+/// Evaluates scalar function calls generated from [RexExpression]s. Immutable 
and stable calls on literal arguments
+/// cache their result on first use, including null. Like [LiteralOperand], 
cached results are shared and must not be
+/// mutated. Instances are scoped to one query and operator chain and are not 
thread-safe.
 public class FunctionOperand implements TransformOperand {
   private final ColumnDataType _resultType;
   private final QueryFunctionInvoker _functionInvoker;
@@ -44,6 +45,9 @@ public class FunctionOperand implements TransformOperand {
   private final boolean _needsConversion;
   private final List<TransformOperand> _operands;
   private final Object[] _reusableOperandHolder;
+  private final boolean _cacheable;
+  private boolean _resultCached;
+  private Object _cachedResult;
 
   public FunctionOperand(RexExpression.FunctionCall functionCall, DataSchema 
dataSchema) {
     _resultType = functionCall.getDataType();
@@ -106,6 +110,8 @@ public class FunctionOperand implements TransformOperand {
       _operands.add(TransformOperandFactory.getTransformOperand(operand, 
dataSchema));
     }
     _reusableOperandHolder = new Object[numOperands];
+    _cacheable = functionInfo.getVolatility() != FunctionVolatility.VOLATILE
+        && _operands.stream().allMatch(LiteralOperand.class::isInstance);
   }
 
   @Override
@@ -116,6 +122,9 @@ public class FunctionOperand implements TransformOperand {
   @Nullable
   @Override
   public Object apply(List<Object> row) {
+    if (_resultCached) {
+      return _cachedResult;
+    }
     for (int i = 0; i < _operands.size(); i++) {
       TransformOperand operand = _operands.get(i);
       Object value = operand.apply(row);
@@ -130,7 +139,12 @@ public class FunctionOperand implements TransformOperand {
       }
       result = _functionInvoker.invoke(_reusableOperandHolder);
     }
-    return result != null ? 
TypeUtils.convert(_functionInvokerResultType.toInternal(result),
+    Object convertedResult = result != null ? 
TypeUtils.convert(_functionInvokerResultType.toInternal(result),
         _resultType.getStoredType()) : null;
+    if (_cacheable) {
+      _cachedResult = convertedResult;
+      _resultCached = true;
+    }
+    return convertedResult;
   }
 }
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/function/FunctionOperandTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/function/FunctionOperandTest.java
new file mode 100644
index 00000000000..79bad234470
--- /dev/null
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/function/FunctionOperandTest.java
@@ -0,0 +1,266 @@
+/**
+ * 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.pinot.query.runtime.function;
+
+import java.util.List;
+import java.util.Map;
+import org.apache.pinot.common.function.FunctionInfo;
+import org.apache.pinot.common.function.FunctionRegistry;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.query.planner.logical.RexExpression;
+import org.apache.pinot.query.planner.plannode.PlanNode;
+import org.apache.pinot.query.planner.plannode.ProjectNode;
+import org.apache.pinot.query.runtime.blocks.MseBlock;
+import org.apache.pinot.query.runtime.operator.MultiStageOperator;
+import org.apache.pinot.query.runtime.operator.OperatorTestUtil;
+import org.apache.pinot.query.runtime.operator.TransformOperator;
+import org.apache.pinot.query.runtime.operator.operands.FunctionOperand;
+import org.apache.pinot.spi.annotations.FunctionVolatility;
+import org.apache.pinot.spi.annotations.ScalarFunction;
+import org.apache.pinot.spi.exception.QueryException;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotSame;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.expectThrows;
+
+
+/// Tests lazy, per-operand caching. Fixtures live in a function package for 
registry discovery; invocation counters
+/// are thread-local so parallel test methods do not interfere with each other.
+public class FunctionOperandTest {
+  private static final DataSchema INPUT_SCHEMA =
+      new DataSchema(new String[]{"value"}, new 
ColumnDataType[]{ColumnDataType.INT});
+  private static final ThreadLocal<int[]> INVOCATIONS = 
ThreadLocal.withInitial(() -> new int[1]);
+
+  @BeforeMethod
+  public void setUp() {
+    INVOCATIONS.get()[0] = 0;
+  }
+
+  @AfterMethod
+  public void tearDown() {
+    INVOCATIONS.remove();
+  }
+
+  @DataProvider
+  public Object[][] cacheableFunctions() {
+    return new Object[][]{
+        {"operandCacheIdentity", List.of(literal(7)), 7},
+        {"operandCacheStable", List.of(literal(7)), 7},
+        {"operandCacheNonFoldable", List.of(literal(7)), 7},
+        {"operandCacheVarargs", List.of(literal(3), literal(4)), 7},
+        {"operandCacheZeroArgs", List.of(), 7}
+    };
+  }
+
+  @Test(dataProvider = "cacheableFunctions")
+  public void testLazyCaching(String name, List<RexExpression> arguments, int 
expected) {
+    FunctionOperand operand = operand(name, ColumnDataType.LONG, arguments);
+    assertEquals(INVOCATIONS.get()[0], 0, "Construction must not evaluate the 
function");
+    for (int i = 0; i < 3; i++) {
+      assertEquals(operand.apply(List.of(i)), (long) expected, "Cache the 
converted result");
+    }
+    assertEquals(INVOCATIONS.get()[0], 1);
+  }
+
+  @Test
+  public void testImmutableNonFoldableMetadata() {
+    FunctionInfo info = 
FunctionRegistry.lookupFunctionInfo("operandcachenonfoldable", 1);
+    assertFalse(info.isDeterministic());
+    assertEquals(info.getVolatility(), FunctionVolatility.IMMUTABLE);
+  }
+
+  @Test
+  public void testNullResultIsCached() {
+    FunctionOperand operand = operand("operandCacheIdentity", 
ColumnDataType.INT,
+        List.of(new RexExpression.Literal(ColumnDataType.INT, null)));
+    assertNull(operand.apply(List.of(1)));
+    assertNull(operand.apply(List.of(2)));
+    assertEquals(INVOCATIONS.get()[0], 1, "A computed null must not mean 
uninitialized");
+  }
+
+  @Test
+  public void testStableCacheIsolation() {
+    FunctionOperand first = operand("operandCacheStable", ColumnDataType.INT, 
List.of(literal(7)));
+    FunctionOperand second = operand("operandCacheStable", ColumnDataType.INT, 
List.of(literal(7)));
+    assertEquals(INVOCATIONS.get()[0], 0);
+    assertEquals(first.apply(List.of(1)), 7);
+    assertEquals(first.apply(List.of(2)), 7);
+    assertEquals(INVOCATIONS.get()[0], 1);
+    assertEquals(second.apply(List.of(3)), 7);
+    assertEquals(second.apply(List.of(4)), 7);
+    assertEquals(INVOCATIONS.get()[0], 2, "Each operand must evaluate its own 
STABLE result");
+    assertEquals(first.apply(List.of(5)), 7);
+    assertEquals(INVOCATIONS.get()[0], 2);
+  }
+
+  @Test
+  public void testVolatileFunctionIsNotCached() {
+    FunctionOperand operand = operand("operandCacheVolatile", 
ColumnDataType.INT, List.of());
+    assertEquals(operand.apply(List.of(1)), 1);
+    assertEquals(operand.apply(List.of(2)), 2);
+    assertEquals(INVOCATIONS.get()[0], 2);
+  }
+
+  @DataProvider
+  public Object[][] rowDependentFunctions() {
+    return new Object[][]{{"operandCacheIdentity"}, {"operandCacheStable"}};
+  }
+
+  @Test(dataProvider = "rowDependentFunctions")
+  public void testColumnArgumentIsNotCached(String name) {
+    FunctionOperand operand = operand(name, ColumnDataType.INT, List.of(new 
RexExpression.InputRef(0)));
+    assertEquals(operand.apply(List.of(3)), 3);
+    assertEquals(operand.apply(List.of(4)), 4);
+    assertEquals(INVOCATIONS.get()[0], 2);
+  }
+
+  @Test
+  public void testNestedFunctionIsNotTreatedAsLiteral() {
+    RexExpression child = new RexExpression.FunctionCall(ColumnDataType.INT, 
"operandCacheVolatile", List.of());
+    FunctionOperand operand = operand("operandCacheIdentity", 
ColumnDataType.INT, List.of(child));
+    assertEquals(operand.apply(List.of()), 1);
+    assertEquals(operand.apply(List.of()), 3);
+    assertEquals(INVOCATIONS.get()[0], 4);
+  }
+
+  @Test
+  public void testArgumentConversion() {
+    FunctionOperand operand = operand("operandCacheIdentity", 
ColumnDataType.INT,
+        List.of(new RexExpression.Literal(ColumnDataType.LONG, 7L)));
+    assertEquals(operand.apply(List.of()), 7);
+    assertEquals(operand.apply(List.of()), 7);
+    assertEquals(INVOCATIONS.get()[0], 1);
+  }
+
+  @Test
+  public void testFailureRemainsLazyAndIsNotCached() {
+    FunctionOperand operand = operand("operandCacheIdentity", 
ColumnDataType.INT, List.of(literal(-1)));
+    assertEquals(INVOCATIONS.get()[0], 0);
+    for (int i = 0; i < 2; i++) {
+      QueryException error = expectThrows(QueryException.class, () -> 
operand.apply(List.of()));
+      assertEquals(error.getCause().getMessage(), "negative value: -1");
+    }
+    assertEquals(INVOCATIONS.get()[0], 2);
+  }
+
+  @Test
+  public void testArrayResultAndCacheIsolation() {
+    FunctionOperand first = operand("operandCacheArray", 
ColumnDataType.INT_ARRAY, List.of(literal(7)));
+    FunctionOperand second = operand("operandCacheArray", 
ColumnDataType.INT_ARRAY, List.of(literal(8)));
+    Object firstResult = first.apply(List.of());
+    Object secondResult = second.apply(List.of());
+    assertEquals((int[]) firstResult, new int[]{7});
+    assertEquals((int[]) secondResult, new int[]{8});
+    assertNotSame(firstResult, secondResult);
+    assertSame(first.apply(List.of()), firstResult);
+    assertSame(second.apply(List.of()), secondResult);
+    assertEquals(INVOCATIONS.get()[0], 2);
+  }
+
+  @Test
+  public void testCachingAcrossBlocks() {
+    MultiStageOperator input = mock(MultiStageOperator.class);
+    when(input.nextBlock()).thenReturn(OperatorTestUtil.block(INPUT_SCHEMA),
+        OperatorTestUtil.block(INPUT_SCHEMA, new Object[]{1}, new Object[]{2}),
+        OperatorTestUtil.block(INPUT_SCHEMA, new Object[]{3}));
+    DataSchema resultSchema = new DataSchema(new String[]{"array"}, new 
ColumnDataType[]{ColumnDataType.INT_ARRAY});
+    List<RexExpression> projects = List.of(
+        new RexExpression.FunctionCall(ColumnDataType.INT_ARRAY, 
"operandCacheArray", List.of(literal(7))));
+    TransformOperator operator = new 
TransformOperator(OperatorTestUtil.getTracingContext(), input, INPUT_SCHEMA,
+        new ProjectNode(-1, resultSchema, PlanNode.NodeHint.EMPTY, List.of(), 
projects));
+    assertEquals(INVOCATIONS.get()[0], 0);
+    assertEquals(((MseBlock.Data) 
operator.nextBlock()).asRowHeap().getRows().size(), 0);
+    assertEquals(INVOCATIONS.get()[0], 0, "Empty input must not evaluate the 
function");
+    List<Object[]> first = ((MseBlock.Data) 
operator.nextBlock()).asRowHeap().getRows();
+    List<Object[]> second = ((MseBlock.Data) 
operator.nextBlock()).asRowHeap().getRows();
+    assertEquals(first.size(), 2);
+    assertEquals(second.size(), 1);
+    assertEquals((int[]) first.get(0)[0], new int[]{7});
+    assertSame(first.get(1)[0], first.get(0)[0]);
+    assertSame(second.get(0)[0], first.get(0)[0]);
+    assertEquals(INVOCATIONS.get()[0], 1);
+  }
+
+  private static RexExpression literal(int value) {
+    return new RexExpression.Literal(ColumnDataType.INT, value);
+  }
+
+  private static FunctionOperand operand(String name, ColumnDataType 
resultType, List<RexExpression> arguments) {
+    return new FunctionOperand(new RexExpression.FunctionCall(resultType, 
name, arguments), INPUT_SCHEMA);
+  }
+
+  @ScalarFunction(nullableParameters = true)
+  public static Integer operandCacheIdentity(Integer value) {
+    INVOCATIONS.get()[0]++;
+    if (value != null && value < 0) {
+      throw new IllegalArgumentException("negative value: " + value);
+    }
+    return value;
+  }
+
+  @ScalarFunction
+  public static int[] operandCacheArray(int value) {
+    INVOCATIONS.get()[0]++;
+    return new int[]{value};
+  }
+
+  @ScalarFunction(isVarArg = true)
+  public static int operandCacheVarargs(Object... values) {
+    INVOCATIONS.get()[0]++;
+    return (Integer) values[0] + (Integer) values[1];
+  }
+
+  @ScalarFunction
+  public static int operandCacheZeroArgs() {
+    INVOCATIONS.get()[0]++;
+    return 7;
+  }
+
+  @ScalarFunction(volatility = FunctionVolatility.STABLE)
+  public static int operandCacheStable(int value) {
+    INVOCATIONS.get()[0]++;
+    return value;
+  }
+
+  @ScalarFunction(volatility = FunctionVolatility.VOLATILE)
+  public static int operandCacheVolatile() {
+    return ++INVOCATIONS.get()[0];
+  }
+
+  /// Registers an immutable function that opts out of planner folding, 
independently of the legacy annotation hint.
+  @ScalarFunction
+  public static class NonFoldableFunction extends 
FunctionRegistry.ArgumentCountBasedScalarFunction {
+    public NonFoldableFunction()
+        throws NoSuchMethodException {
+      super("operandCacheNonFoldable", Map.of(1,
+          new 
FunctionInfo(FunctionOperandTest.class.getMethod("operandCacheIdentity", 
Integer.class),
+              FunctionOperandTest.class, true, false, 
FunctionVolatility.IMMUTABLE)));
+    }
+  }
+}


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

Reply via email to