diff --git a/runtime/planner/BUILD.bazel b/runtime/planner/BUILD.bazel index 043c8fc38..db992882d 100644 --- a/runtime/planner/BUILD.bazel +++ b/runtime/planner/BUILD.bazel @@ -56,6 +56,13 @@ cel_android_library( exports = ["//runtime/src/main/java/dev/cel/runtime/planner:async_completion_coordinator_android"], ) +java_library( + name = "planned_interpretable", + testonly = 1, + visibility = ["//:internal"], + exports = ["//runtime/src/main/java/dev/cel/runtime/planner:planned_interpretable"], +) + java_library( name = "async_call_state_tracker", visibility = ["//:internal"], diff --git a/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel b/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel index 74b17d43e..c93fffaa5 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel +++ b/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel @@ -660,12 +660,15 @@ java_library( "ExecutionFrame.java", "PlannedInterpretable.java", ], + tags = [ + ], deps = [ ":async_call_state_tracker", ":localized_evaluation_exception", "//common:options", "//common/ast", "//common/exceptions:iteration_budget_exceeded", + "//common/values", "//runtime:evaluation_exception", "//runtime:evaluation_listener", "//runtime:function_resolver", @@ -1334,6 +1337,7 @@ cel_android_library( "//common:options", "//common/ast:ast_android", "//common/exceptions:iteration_budget_exceeded", + "//common/values:values_android", "//runtime:evaluation_exception", "//runtime:evaluation_listener_android", "//runtime:interpretable_android", diff --git a/runtime/src/main/java/dev/cel/runtime/planner/EvalOptionalSelectField.java b/runtime/src/main/java/dev/cel/runtime/planner/EvalOptionalSelectField.java index 182aa3f6c..2b6969559 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/EvalOptionalSelectField.java +++ b/runtime/src/main/java/dev/cel/runtime/planner/EvalOptionalSelectField.java @@ -48,8 +48,8 @@ Object evalInternal(GlobalResolver resolver, ExecutionFrame frame) { // The operand arrives already adapted, so re-materializing it would re-scan every entry of a // map for nothing. Traversing keeps that O(1); the selected value is materialized by the - // attribute below. - Object runtimeOperandValue = celValueConverter.toTraversalTarget(operandValue); + // attribute below, which shares this adaptation through the frame if it has the same root. + Object runtimeOperandValue = frame.toTraversalTarget(operandValue, celValueConverter); boolean hasField = false; if (runtimeOperandValue instanceof AccumulatedUnknowns) { Object hasFieldResult = EvalHelpers.evalStrictly(presenceAttribute, resolver, frame); diff --git a/runtime/src/main/java/dev/cel/runtime/planner/ExecutionFrame.java b/runtime/src/main/java/dev/cel/runtime/planner/ExecutionFrame.java index 23dd735d4..3ef19f3e9 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/ExecutionFrame.java +++ b/runtime/src/main/java/dev/cel/runtime/planner/ExecutionFrame.java @@ -19,6 +19,7 @@ import dev.cel.common.CelOptions; import dev.cel.common.exceptions.CelIterationLimitExceededException; +import dev.cel.common.values.CelValueConverter; import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelEvaluationListener; import dev.cel.runtime.CelFunctionResolver; @@ -39,6 +40,9 @@ final class ExecutionFrame { private final @Nullable AsyncCallStateTracker asyncTracker; private int iterationCount; private @Nullable BlockMemoizer blockMemoizer; + // The last select chain root that adapted to a new object in this evaluation, and that object. + private @Nullable Object lastRoot; + private @Nullable Object lastTraversalTarget; static ExecutionFrame create( CelFunctionResolver functionResolver, @@ -92,6 +96,22 @@ BlockMemoizer getBlockMemoizer() { return blockMemoizer; } + /** Adapts a select chain root, reusing the last adaptation if the root is the same object. */ + @SuppressWarnings("ReferenceEquality") // Identity is the point: value equality would be O(size). + Object toTraversalTarget(Object root, CelValueConverter celValueConverter) { + // The converter isn't part of the key: a program plans all its chains with one converter. + if (root == lastRoot) { + return lastTraversalTarget; + } + Object adapted = celValueConverter.toTraversalTarget(root); + // Roots that adapt to themselves (maps, CelValues) mustn't evict a reusable wrapper. + if (adapted != root) { + lastRoot = root; + lastTraversalTarget = adapted; + } + return adapted; + } + boolean isAsync() { return asyncTracker != null; } diff --git a/runtime/src/main/java/dev/cel/runtime/planner/NamespacedAttribute.java b/runtime/src/main/java/dev/cel/runtime/planner/NamespacedAttribute.java index 0adc3e47f..52ec72c89 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/NamespacedAttribute.java +++ b/runtime/src/main/java/dev/cel/runtime/planner/NamespacedAttribute.java @@ -90,7 +90,7 @@ public Object resolve(long exprId, GlobalResolver ctx, ExecutionFrame frame) { } if (value != null) { - return applyQualifiers(value, celValueConverter, qualifiers); + return applyQualifiers(value, celValueConverter, qualifiers, frame); } // Attempt to resolve the qualify type name if the name is not a variable identifier @@ -183,7 +183,10 @@ public NamespacedAttribute addQualifier(Qualifier qualifier) { } static Object applyQualifiers( - Object value, CelValueConverter celValueConverter, ImmutableList qualifiers) { + Object value, + CelValueConverter celValueConverter, + ImmutableList qualifiers, + ExecutionFrame frame) { if (value instanceof AccumulatedUnknowns) { return value; } @@ -191,7 +194,7 @@ static Object applyQualifiers( // adapting on the way in and only the terminal value is materialized on the way out. Object obj = value; if (!qualifiers.isEmpty()) { - obj = celValueConverter.toTraversalTarget(obj); + obj = frame.toTraversalTarget(obj, celValueConverter); // Avoid enhanced for loop to prevent UnmodifiableIterator from being allocated for (int i = 0; i < qualifiers.size(); i++) { obj = qualifiers.get(i).qualify(obj); diff --git a/runtime/src/main/java/dev/cel/runtime/planner/RelativeAttribute.java b/runtime/src/main/java/dev/cel/runtime/planner/RelativeAttribute.java index 2293d2057..2d0bc6a36 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/RelativeAttribute.java +++ b/runtime/src/main/java/dev/cel/runtime/planner/RelativeAttribute.java @@ -33,7 +33,7 @@ final class RelativeAttribute implements Attribute { @Override public Object resolve(long exprId, GlobalResolver ctx, ExecutionFrame frame) { Object obj = EvalHelpers.evalStrictly(operand, ctx, frame); - return NamespacedAttribute.applyQualifiers(obj, celValueConverter, qualifiers); + return NamespacedAttribute.applyQualifiers(obj, celValueConverter, qualifiers, frame); } @Override diff --git a/runtime/src/test/java/dev/cel/runtime/planner/BUILD.bazel b/runtime/src/test/java/dev/cel/runtime/planner/BUILD.bazel index d66d90b81..886937527 100644 --- a/runtime/src/test/java/dev/cel/runtime/planner/BUILD.bazel +++ b/runtime/src/test/java/dev/cel/runtime/planner/BUILD.bazel @@ -65,6 +65,7 @@ java_library( "//runtime/planner:async_completion_coordinator", "//runtime/planner:async_gate", "//runtime/planner:optimized_select_planner", + "//runtime/planner:planned_interpretable", "//runtime/planner:planned_program", "//runtime/planner:program_planner", "//runtime/standard:type", diff --git a/runtime/src/test/java/dev/cel/runtime/planner/ExecutionFrameTest.java b/runtime/src/test/java/dev/cel/runtime/planner/ExecutionFrameTest.java new file mode 100644 index 000000000..a1a5d3c26 --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/planner/ExecutionFrameTest.java @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed 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 +// +// https://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 dev.cel.runtime.planner; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableMap; +import dev.cel.common.CelOptions; +import dev.cel.common.values.CelValueConverter; +import dev.cel.runtime.CelFunctionResolver; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class ExecutionFrameTest { + + @Test + public void toTraversalTarget_sameRootAfterSelfAdaptingRoot_reusesAdaptation() { + ExecutionFrame frame = + ExecutionFrame.create( + CelFunctionResolver.EMPTY, + CelOptions.DEFAULT, + /* partialVars= */ null, + /* listener= */ null); + CelValueConverter converter = CelValueConverter.getDefaultInstance(); + // Adapts to a new OptionalValue each time, while a map adapts to itself. + Optional root = Optional.of("a"); + Object adapted = frame.toTraversalTarget(root, converter); + frame.toTraversalTarget(ImmutableMap.of(), converter); + + Object readapted = frame.toTraversalTarget(root, converter); + + assertThat(readapted).isSameInstanceAs(adapted); + } +}