From b10810399fc5399fb1b3f2d397c21054ef21ca47 Mon Sep 17 00:00:00 2001 From: Sean Huh Date: Mon, 31 Aug 2026 22:56:38 -0700 Subject: [PATCH] Async eval PiperOrigin-RevId: 974283267 --- .bazelrc | 2 +- publish/BUILD.bazel | 4 + runtime/BUILD.bazel | 49 + runtime/planner/BUILD.bazel | 12 + .../dev/cel/runtime/AccumulatedUnknowns.java | 23 +- .../src/main/java/dev/cel/runtime/BUILD.bazel | 128 +- .../java/dev/cel/runtime/CelAsyncCall.java | 40 + .../dev/cel/runtime/CelAsyncDrainAction.java | 57 + .../cel/runtime/CelAsyncDrainStrategy.java | 89 + .../runtime/CelAsyncEvaluationOptions.java | 119 + .../cel/runtime/CelAsyncFunctionOverload.java | 58 + .../dev/cel/runtime/CelAsyncObserver.java | 40 + .../dev/cel/runtime/CelFunctionBinding.java | 113 + .../dev/cel/runtime/CelFunctionResolver.java | 20 + .../main/java/dev/cel/runtime/CelRuntime.java | 22 + .../java/dev/cel/runtime/CelRuntimeImpl.java | 86 +- .../dev/cel/runtime/FunctionBindingImpl.java | 54 +- .../main/java/dev/cel/runtime/Program.java | 170 ++ .../java/dev/cel/runtime/ProgramImpl.java | 22 + .../dev/cel/runtime/planner/AsyncCallKey.java | 100 + .../cel/runtime/planner/AsyncCallRecord.java | 127 + .../planner/AsyncCallStateTracker.java | 222 ++ .../planner/AsyncCompletionCoordinator.java | 150 + .../dev/cel/runtime/planner/AsyncGate.java | 121 + .../java/dev/cel/runtime/planner/BUILD.bazel | 120 +- .../cel/runtime/planner/EvalAsyncCall.java | 104 + .../dev/cel/runtime/planner/EvalFold.java | 68 +- .../dev/cel/runtime/planner/EvalHelpers.java | 2 +- .../runtime/planner/EvalLateBoundCall.java | 28 +- .../cel/runtime/planner/ExecutionFrame.java | 85 +- .../cel/runtime/planner/PlannedProgram.java | 172 +- .../cel/runtime/planner/ProgramPlanner.java | 21 +- .../cel/runtime/AccumulatedUnknownsTest.java | 103 + .../src/test/java/dev/cel/runtime/BUILD.bazel | 3 +- .../runtime/CelAsyncDrainStrategyTest.java | 196 ++ .../CelAsyncEvaluationOptionsTest.java | 93 + .../cel/runtime/CelRuntimeLegacyImplTest.java | 17 +- .../cel/runtime/FunctionBindingImplTest.java | 442 +++ .../cel/runtime/planner/AsyncCallKeyTest.java | 127 + .../runtime/planner/AsyncCallRecordTest.java | 98 + .../planner/AsyncCallStateTrackerTest.java | 439 +++ .../AsyncCompletionCoordinatorTest.java | 269 ++ .../cel/runtime/planner/AsyncGateTest.java | 293 ++ .../java/dev/cel/runtime/planner/BUILD.bazel | 13 + .../planner/ProgramPlannerAsyncTest.java | 2639 +++++++++++++++++ .../runtime/planner/ProgramPlannerTest.java | 69 +- 46 files changed, 7141 insertions(+), 88 deletions(-) create mode 100644 runtime/src/main/java/dev/cel/runtime/CelAsyncCall.java create mode 100644 runtime/src/main/java/dev/cel/runtime/CelAsyncDrainAction.java create mode 100644 runtime/src/main/java/dev/cel/runtime/CelAsyncDrainStrategy.java create mode 100644 runtime/src/main/java/dev/cel/runtime/CelAsyncEvaluationOptions.java create mode 100644 runtime/src/main/java/dev/cel/runtime/CelAsyncFunctionOverload.java create mode 100644 runtime/src/main/java/dev/cel/runtime/CelAsyncObserver.java create mode 100644 runtime/src/main/java/dev/cel/runtime/planner/AsyncCallKey.java create mode 100644 runtime/src/main/java/dev/cel/runtime/planner/AsyncCallRecord.java create mode 100644 runtime/src/main/java/dev/cel/runtime/planner/AsyncCallStateTracker.java create mode 100644 runtime/src/main/java/dev/cel/runtime/planner/AsyncCompletionCoordinator.java create mode 100644 runtime/src/main/java/dev/cel/runtime/planner/AsyncGate.java create mode 100644 runtime/src/main/java/dev/cel/runtime/planner/EvalAsyncCall.java create mode 100644 runtime/src/test/java/dev/cel/runtime/AccumulatedUnknownsTest.java create mode 100644 runtime/src/test/java/dev/cel/runtime/CelAsyncDrainStrategyTest.java create mode 100644 runtime/src/test/java/dev/cel/runtime/CelAsyncEvaluationOptionsTest.java create mode 100644 runtime/src/test/java/dev/cel/runtime/FunctionBindingImplTest.java create mode 100644 runtime/src/test/java/dev/cel/runtime/planner/AsyncCallKeyTest.java create mode 100644 runtime/src/test/java/dev/cel/runtime/planner/AsyncCallRecordTest.java create mode 100644 runtime/src/test/java/dev/cel/runtime/planner/AsyncCallStateTrackerTest.java create mode 100644 runtime/src/test/java/dev/cel/runtime/planner/AsyncCompletionCoordinatorTest.java create mode 100644 runtime/src/test/java/dev/cel/runtime/planner/AsyncGateTest.java create mode 100644 runtime/src/test/java/dev/cel/runtime/planner/ProgramPlannerAsyncTest.java diff --git a/.bazelrc b/.bazelrc index f6e2f39c0..34a59ec39 100644 --- a/.bazelrc +++ b/.bazelrc @@ -16,7 +16,7 @@ build --java_language_version=11 common --javacopt=-Xlint:-options # Remove flag once https://github.com/google/cel-spec/issues/508 and rules_jvm_external is fixed. -common --incompatible_autoload_externally=proto_library,cc_proto_library,java_proto_library,java_test +common --incompatible_autoload_externally=proto_library,cc_proto_library,java_proto_library,java_test,java_import # Limit repository cache size by not caching extracted repository contents build --repo_contents_cache= diff --git a/publish/BUILD.bazel b/publish/BUILD.bazel index 7fd15a769..2fb948cea 100644 --- a/publish/BUILD.bazel +++ b/publish/BUILD.bazel @@ -29,6 +29,10 @@ COMMON_TARGETS = [ # keep sorted RUNTIME_TARGETS = [ "//runtime/src/main/java/dev/cel/runtime", + "//runtime/src/main/java/dev/cel/runtime:async_call", + "//runtime/src/main/java/dev/cel/runtime:async_drain_strategy", + "//runtime/src/main/java/dev/cel/runtime:async_observer", + "//runtime/src/main/java/dev/cel/runtime:async_options", "//runtime/src/main/java/dev/cel/runtime:base", "//runtime/src/main/java/dev/cel/runtime:interpreter", "//runtime/src/main/java/dev/cel/runtime:late_function_binding", diff --git a/runtime/BUILD.bazel b/runtime/BUILD.bazel index c87fadca9..e1acc4261 100644 --- a/runtime/BUILD.bazel +++ b/runtime/BUILD.bazel @@ -9,6 +9,10 @@ package( java_library( name = "runtime", exports = [ + ":async_call", + ":async_drain_strategy", + ":async_observer", + ":async_options", ":descriptor_message_provider", ":evaluation_exception", ":function_overload", @@ -340,6 +344,11 @@ java_library( exports = ["//runtime/src/main/java/dev/cel/runtime:function_overload"], ) +cel_android_library( + name = "function_overload_android", + exports = ["//runtime/src/main/java/dev/cel/runtime:function_overload_android"], +) + java_library( name = "descriptor_message_provider", visibility = ["//:internal"], @@ -379,3 +388,43 @@ cel_android_library( name = "partial_vars_android", exports = ["//runtime/src/main/java/dev/cel/runtime:partial_vars_android"], ) + +java_library( + name = "async_call", + exports = ["//runtime/src/main/java/dev/cel/runtime:async_call"], +) + +cel_android_library( + name = "async_call_android", + exports = ["//runtime/src/main/java/dev/cel/runtime:async_call_android"], +) + +java_library( + name = "async_drain_strategy", + exports = ["//runtime/src/main/java/dev/cel/runtime:async_drain_strategy"], +) + +cel_android_library( + name = "async_drain_strategy_android", + exports = ["//runtime/src/main/java/dev/cel/runtime:async_drain_strategy_android"], +) + +java_library( + name = "async_observer", + exports = ["//runtime/src/main/java/dev/cel/runtime:async_observer"], +) + +cel_android_library( + name = "async_observer_android", + exports = ["//runtime/src/main/java/dev/cel/runtime:async_observer_android"], +) + +java_library( + name = "async_options", + exports = ["//runtime/src/main/java/dev/cel/runtime:async_options"], +) + +cel_android_library( + name = "async_options_android", + exports = ["//runtime/src/main/java/dev/cel/runtime:async_options_android"], +) diff --git a/runtime/planner/BUILD.bazel b/runtime/planner/BUILD.bazel index 860d413a0..c0b3f9222 100644 --- a/runtime/planner/BUILD.bazel +++ b/runtime/planner/BUILD.bazel @@ -21,3 +21,15 @@ java_library( visibility = ["//:internal"], exports = ["//runtime/src/main/java/dev/cel/runtime/planner:planned_program"], ) + +java_library( + name = "async_call_state_tracker", + visibility = ["//:internal"], + exports = ["//runtime/src/main/java/dev/cel/runtime/planner:async_call_state_tracker"], +) + +cel_android_library( + name = "async_call_state_tracker_android", + visibility = ["//:internal"], + exports = ["//runtime/src/main/java/dev/cel/runtime/planner:async_call_state_tracker_android"], +) diff --git a/runtime/src/main/java/dev/cel/runtime/AccumulatedUnknowns.java b/runtime/src/main/java/dev/cel/runtime/AccumulatedUnknowns.java index d4d54c71f..5f66296d3 100644 --- a/runtime/src/main/java/dev/cel/runtime/AccumulatedUnknowns.java +++ b/runtime/src/main/java/dev/cel/runtime/AccumulatedUnknowns.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashSet; import java.util.Set; import org.jspecify.annotations.Nullable; @@ -35,6 +36,7 @@ public final class AccumulatedUnknowns { private static final int MAX_UNKNOWN_ATTRIBUTE_SIZE = 500_000; private final Set exprIds; private final Set attributes; + private final Set callIds; Set exprIds() { return exprIds; @@ -44,6 +46,14 @@ Set attributes() { return attributes; } + public Set callIds() { + return Collections.unmodifiableSet(callIds); + } + + public boolean hasCallIds() { + return !callIds.isEmpty(); + } + /** * Evaluates if the right hand side is an accumulated unknown, and if so, merges it into the * accumulator. @@ -62,6 +72,7 @@ public AccumulatedUnknowns merge(AccumulatedUnknowns arg) { enforceMaxAttributeSize(this.attributes, arg.attributes); this.exprIds.addAll(arg.exprIds); this.attributes.addAll(arg.attributes); + this.callIds.addAll(arg.callIds); return this; } @@ -75,7 +86,14 @@ static AccumulatedUnknowns create(Collection ids) { public static AccumulatedUnknowns create( Collection exprIds, Collection attributes) { - return new AccumulatedUnknowns(new HashSet<>(exprIds), new HashSet<>(attributes)); + return new AccumulatedUnknowns( + new HashSet<>(exprIds), new HashSet<>(attributes), new HashSet<>()); + } + + public static AccumulatedUnknowns createForAsyncCall(long callId) { + HashSet callIds = new HashSet<>(); + callIds.add(callId); + return new AccumulatedUnknowns(new HashSet<>(), new HashSet<>(), callIds); } private static void enforceMaxAttributeSize( @@ -88,8 +106,9 @@ private static void enforceMaxAttributeSize( } } - private AccumulatedUnknowns(Set exprIds, Set attributes) { + private AccumulatedUnknowns(Set exprIds, Set attributes, Set callIds) { this.exprIds = exprIds; this.attributes = attributes; + this.callIds = callIds; } } diff --git a/runtime/src/main/java/dev/cel/runtime/BUILD.bazel b/runtime/src/main/java/dev/cel/runtime/BUILD.bazel index 489bb64d8..f99fdccfa 100644 --- a/runtime/src/main/java/dev/cel/runtime/BUILD.bazel +++ b/runtime/src/main/java/dev/cel/runtime/BUILD.bazel @@ -739,6 +739,7 @@ java_library( "//common/exceptions:overload_not_found", "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:com_google_guava_guava", + "@maven//:org_jspecify_jspecify", ], ) @@ -753,6 +754,7 @@ cel_android_library( "//common/annotations", "//common/exceptions:overload_not_found", "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:org_jspecify_jspecify", "@maven_android//:com_google_guava_guava", ], ) @@ -784,6 +786,7 @@ cel_android_library( java_library( name = "function_overload", srcs = [ + "CelAsyncFunctionOverload.java", "CelFunctionOverload.java", "OptimizedFunctionOverload.java", ], @@ -800,9 +803,12 @@ java_library( cel_android_library( name = "function_overload_android", srcs = [ + "CelAsyncFunctionOverload.java", "CelFunctionOverload.java", "OptimizedFunctionOverload.java", ], + tags = [ + ], deps = [ ":evaluation_exception", ":unknown_attributes_android", @@ -817,6 +823,7 @@ java_library( tags = [ ], deps = [ + ":async_options", ":descriptor_type_resolver", ":dispatcher", ":evaluation_exception", @@ -824,7 +831,6 @@ java_library( ":function_binding", ":function_resolver", ":partial_vars", - ":program", ":proto_message_runtime_equality", ":runtime", ":runtime_equality", @@ -922,6 +928,7 @@ java_library( ], deps = [ ":activation", + ":async_options", ":evaluation_exception", ":evaluation_listener", ":function_binding", @@ -946,6 +953,7 @@ java_library( "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:com_google_guava_guava", "@maven//:com_google_protobuf_protobuf_java", + "@maven//:org_jspecify_jspecify", ], ) @@ -1277,17 +1285,130 @@ cel_android_library( ], ) +java_library( + name = "async_call", + srcs = ["CelAsyncCall.java"], + tags = [ + ], + deps = [ + "@maven//:com_google_guava_guava", + ], +) + +cel_android_library( + name = "async_call_android", + srcs = ["CelAsyncCall.java"], + tags = [ + ], + deps = [ + "@maven_android//:com_google_guava_guava", + ], +) + +java_library( + name = "async_drain_strategy", + srcs = [ + "CelAsyncDrainAction.java", + "CelAsyncDrainStrategy.java", + ], + tags = [ + ], + deps = [ + ":async_call", + "//:auto_value", + "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:com_google_guava_guava", + ], +) + +cel_android_library( + name = "async_drain_strategy_android", + srcs = [ + "CelAsyncDrainAction.java", + "CelAsyncDrainStrategy.java", + ], + tags = [ + ], + deps = [ + ":async_call_android", + "//:auto_value", + "@maven//:com_google_errorprone_error_prone_annotations", + "@maven_android//:com_google_guava_guava", + ], +) + +java_library( + name = "async_observer", + srcs = ["CelAsyncObserver.java"], + tags = [ + ], + deps = [ + ":async_call", + "@maven//:com_google_code_findbugs_annotations", + "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:org_jspecify_jspecify", + ], +) + +cel_android_library( + name = "async_observer_android", + srcs = ["CelAsyncObserver.java"], + tags = [ + ], + deps = [ + ":async_call_android", + "@maven//:com_google_code_findbugs_annotations", + "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:org_jspecify_jspecify", + ], +) + +java_library( + name = "async_options", + srcs = ["CelAsyncEvaluationOptions.java"], + tags = [ + ], + deps = [ + ":async_drain_strategy", + ":async_observer", + "//:auto_value", + "@maven//:com_google_code_findbugs_annotations", + "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:org_jspecify_jspecify", + ], +) + +cel_android_library( + name = "async_options_android", + srcs = ["CelAsyncEvaluationOptions.java"], + tags = [ + ], + deps = [ + ":async_drain_strategy_android", + ":async_observer_android", + "//:auto_value", + "@maven//:com_google_code_findbugs_annotations", + "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:org_jspecify_jspecify", + ], +) + java_library( name = "program", srcs = ["Program.java"], tags = [ ], deps = [ + ":activation", + ":async_options", ":evaluation_exception", ":function_resolver", + ":interpretable", ":partial_vars", ":variable_resolver", "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:com_google_guava_guava", + "@maven//:org_jspecify_jspecify", ], ) @@ -1297,11 +1418,16 @@ cel_android_library( tags = [ ], deps = [ + ":activation_android", + ":async_options_android", ":evaluation_exception", ":function_resolver_android", + ":interpretable_android", ":partial_vars_android", ":variable_resolver", "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:org_jspecify_jspecify", + "@maven_android//:com_google_guava_guava", ], ) diff --git a/runtime/src/main/java/dev/cel/runtime/CelAsyncCall.java b/runtime/src/main/java/dev/cel/runtime/CelAsyncCall.java new file mode 100644 index 000000000..0efe3326a --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/CelAsyncCall.java @@ -0,0 +1,40 @@ +// 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; + +import com.google.common.collect.ImmutableList; +import java.time.Duration; + +/** Describes a pending or completed asynchronous function call. */ +public interface CelAsyncCall { + + /** Returns the unique incremental tracking ID assigned to this call. */ + long callId(); + + /** Returns the AST expression node ID where the call is located. */ + long exprId(); + + /** Returns the name of the function being invoked. */ + String functionName(); + + /** Returns the specific overload ID being invoked. */ + String overloadId(); + + /** Returns the arguments passed to the function call. */ + ImmutableList arguments(); + + /** Returns the elapsed duration of the async function execution. */ + Duration elapsedDuration(); +} diff --git a/runtime/src/main/java/dev/cel/runtime/CelAsyncDrainAction.java b/runtime/src/main/java/dev/cel/runtime/CelAsyncDrainAction.java new file mode 100644 index 000000000..845db25a3 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/CelAsyncDrainAction.java @@ -0,0 +1,57 @@ +// 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; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.auto.value.AutoValue; +import com.google.errorprone.annotations.Immutable; +import java.time.Duration; + +/** Dictates what asynchronous evaluation should do after inspecting completions. */ +@AutoValue +@Immutable +public abstract class CelAsyncDrainAction { + + CelAsyncDrainAction() {} + + /** Indicates that the AST should be re-evaluated immediately. */ + public abstract boolean shouldReevaluate(); + + /** + * Indicates how long the evaluator should wait for additional completions before deciding to + * re-evaluate. A duration of ZERO with reevaluate=false means wait indefinitely for the next + * completion. + */ + public abstract Duration waitDuration(); + + public static CelAsyncDrainAction waitDuration(Duration duration) { + checkNotNull(duration); + checkArgument(!duration.isNegative(), "duration must not be negative"); + if (duration.isZero()) { + return reevaluate(); + } + return new AutoValue_CelAsyncDrainAction(false, duration); + } + + public static CelAsyncDrainAction reevaluate() { + return new AutoValue_CelAsyncDrainAction(true, Duration.ZERO); + } + + public static CelAsyncDrainAction waitForMore() { + return new AutoValue_CelAsyncDrainAction(false, Duration.ZERO); + } +} diff --git a/runtime/src/main/java/dev/cel/runtime/CelAsyncDrainStrategy.java b/runtime/src/main/java/dev/cel/runtime/CelAsyncDrainStrategy.java new file mode 100644 index 000000000..42d29c835 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/CelAsyncDrainStrategy.java @@ -0,0 +1,89 @@ +// 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; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.errorprone.annotations.Immutable; +import java.time.Duration; +import java.util.List; + +/** + * Controls when asynchronous evaluation re-evaluates the AST after async completions. + * + *

The evaluator consults the strategy each time completions are received. + */ +@Immutable +public interface CelAsyncDrainStrategy { + + /** + * Evaluates the current state of asynchronous evaluation and determines the next step. + * + * @param completedBatch The batch of async call completions accumulated so far in this drain + * cycle. + * @param activeCallsCount The number of async calls currently launched but unresolved. + */ + CelAsyncDrainAction nextAction(List completedBatch, int activeCallsCount); + + /** + * Re-evaluates after a debounce window after the first completion, batching completions that + * complete at roughly the same time (CEL-Go default). + */ + static CelAsyncDrainStrategy drainReady(Duration debounce) { + return new DrainReadyStrategy(debounce); + } + + /** Re-evaluates with the default debounce window of 100 microseconds. */ + static CelAsyncDrainStrategy drainReady() { + return drainReady(Duration.ofNanos(100_000)); + } + + /** Re-evaluates immediately as soon as any single call completes. */ + static CelAsyncDrainStrategy drainNone() { + return (completed, active) -> + active == 0 || !completed.isEmpty() + ? CelAsyncDrainAction.reevaluate() + : CelAsyncDrainAction.waitForMore(); + } + + /** Waits for all currently pending calls to finish before re-evaluating. */ + static CelAsyncDrainStrategy drainAll() { + return (completed, active) -> + active == 0 ? CelAsyncDrainAction.reevaluate() : CelAsyncDrainAction.waitForMore(); + } + + /** Internal implementation of the drain ready strategy with configurable debounce duration. */ + @Immutable + final class DrainReadyStrategy implements CelAsyncDrainStrategy { + private final Duration debounce; + + DrainReadyStrategy(Duration debounce) { + this.debounce = checkNotNull(debounce); + checkArgument(!debounce.isNegative(), "debounce duration must not be negative"); + } + + @Override + public CelAsyncDrainAction nextAction(List completedBatch, int activeCallsCount) { + if (activeCallsCount == 0 || debounce.isZero()) { + return CelAsyncDrainAction.reevaluate(); + } + if (completedBatch.isEmpty()) { + return CelAsyncDrainAction.waitForMore(); + } + return CelAsyncDrainAction.waitDuration(debounce); + } + } +} diff --git a/runtime/src/main/java/dev/cel/runtime/CelAsyncEvaluationOptions.java b/runtime/src/main/java/dev/cel/runtime/CelAsyncEvaluationOptions.java new file mode 100644 index 000000000..ee5920e56 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/CelAsyncEvaluationOptions.java @@ -0,0 +1,119 @@ +// 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; + +import com.google.auto.value.AutoValue; +import javax.annotation.concurrent.ThreadSafe; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicLong; +import org.jspecify.annotations.Nullable; + +/** Options for configuring asynchronous CEL evaluation. */ +@AutoValue +@ThreadSafe +public abstract class CelAsyncEvaluationOptions { + + CelAsyncEvaluationOptions() {} + + public static final int DEFAULT_MAX_CONCURRENCY = 100; + public static final int DEFAULT_MAX_ITERATIONS = 1_000; + + /** + * Maximum number of concurrent async function calls in-flight simultaneously. A value <= 0 + * indicates unbounded concurrency. + */ + public abstract int maxConcurrency(); + + /** Strategy governing when to trigger re-evaluation after async call completions. */ + public abstract CelAsyncDrainStrategy drainStrategy(); + + /** Safety cap on the maximum number of AST re-evaluation passes before aborting. */ + public abstract int maxIterations(); + + abstract @Nullable ScheduledExecutorService customScheduledExecutorService(); + + abstract @Nullable CelAsyncObserver customObserver(); + + /** Returns the configured lifecycle observer, if present. */ + public Optional observer() { + return Optional.ofNullable(customObserver()); + } + + /** + * Resolves the {@link ScheduledExecutorService} used for debounce timers, falling back to the + * shared daemon scheduler if not custom-configured. + */ + public ScheduledExecutorService resolveScheduledExecutorService() { + ScheduledExecutorService custom = customScheduledExecutorService(); + return custom != null ? custom : DefaultDebounceSchedulerHolder.INSTANCE; + } + + public abstract Builder toBuilder(); + + public static Builder newBuilder() { + return new AutoValue_CelAsyncEvaluationOptions.Builder() + .setMaxConcurrency(DEFAULT_MAX_CONCURRENCY) + .setDrainStrategy(CelAsyncDrainStrategy.drainReady()) + .setMaxIterations(DEFAULT_MAX_ITERATIONS); + } + + public static Builder builder() { + return newBuilder(); + } + + public static CelAsyncEvaluationOptions defaultOptions() { + return newBuilder().build(); + } + + private static final class DefaultDebounceSchedulerHolder { + private static final AtomicLong counter = new AtomicLong(); + private static final ScheduledExecutorService INSTANCE = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread t = new Thread(r); + t.setName("cel-async-debounce-" + counter.getAndIncrement()); + t.setDaemon(true); + return t; + }); + } + + /** Builder for {@link CelAsyncEvaluationOptions}. */ + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setMaxConcurrency(int maxConcurrency); + + public abstract Builder setDrainStrategy(CelAsyncDrainStrategy drainStrategy); + + public abstract Builder setMaxIterations(int maxIterations); + + abstract Builder setCustomScheduledExecutorService( + @Nullable ScheduledExecutorService scheduledExecutorService); + + abstract Builder setCustomObserver(@Nullable CelAsyncObserver observer); + + public Builder setScheduledExecutorService(ScheduledExecutorService scheduledExecutorService) { + return setCustomScheduledExecutorService(Objects.requireNonNull(scheduledExecutorService)); + } + + public Builder setObserver(CelAsyncObserver observer) { + return setCustomObserver(Objects.requireNonNull(observer)); + } + + public abstract CelAsyncEvaluationOptions build(); + } +} diff --git a/runtime/src/main/java/dev/cel/runtime/CelAsyncFunctionOverload.java b/runtime/src/main/java/dev/cel/runtime/CelAsyncFunctionOverload.java new file mode 100644 index 000000000..b0a53f98a --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/CelAsyncFunctionOverload.java @@ -0,0 +1,58 @@ +// 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; + +import com.google.common.util.concurrent.ListenableFuture; +import com.google.errorprone.annotations.Immutable; + +/** Represents a CEL custom function overload that executes asynchronously. */ +@Immutable +public interface CelAsyncFunctionOverload extends CelFunctionOverload { + + /** Invokes the overload asynchronously with evaluated arguments. */ + @SuppressWarnings("AvoidObjectArrays") // Matches CelFunctionOverload.apply(Object[]) signature + ListenableFuture applyAsync(Object[] args) throws CelEvaluationException; + + /** Optimized overload for single-argument async functions to avoid array allocation. */ + default ListenableFuture applyAsync(Object arg) throws CelEvaluationException { + return applyAsync(new Object[] {arg}); + } + + /** Optimized overload for two-argument async functions to avoid array allocation. */ + default ListenableFuture applyAsync(Object arg1, Object arg2) + throws CelEvaluationException { + return applyAsync(new Object[] {arg1, arg2}); + } + + @Override + default Object apply(Object[] args) throws CelEvaluationException { + throw new UnsupportedOperationException( + "Async overload cannot be evaluated synchronously. Use evalAsync instead."); + } + + /** Helper interface for describing unary async functions. foo */ + @Immutable + @FunctionalInterface + interface Unary { + ListenableFuture apply(T arg) throws CelEvaluationException; + } + + /** Helper interface for describing binary async functions. */ + @Immutable + @FunctionalInterface + interface Binary { + ListenableFuture apply(T1 arg1, T2 arg2) throws CelEvaluationException; + } +} diff --git a/runtime/src/main/java/dev/cel/runtime/CelAsyncObserver.java b/runtime/src/main/java/dev/cel/runtime/CelAsyncObserver.java new file mode 100644 index 000000000..c64fcf0b1 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/CelAsyncObserver.java @@ -0,0 +1,40 @@ +// 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; + +import javax.annotation.concurrent.ThreadSafe; +import org.jspecify.annotations.Nullable; + +/** + * Provides callbacks for monitoring the lifecycle of asynchronous function calls. + * + *

Implementations must be thread-safe: {@code onCallStarted} is invoked from the thread + * dispatching the call, while {@code onCallFinished} is invoked from the call's completion thread. + */ +@ThreadSafe +public interface CelAsyncObserver { + + /** Invoked when an asynchronous function call is first dispatched. */ + void onCallStarted(CelAsyncCall call); + + /** + * Invoked when an asynchronous function call completes with either a result or an exception. + * + * @param call The call description. + * @param result The result of the call if successful, or null if failed. + * @param error The failure cause if the call failed, or null if successful. + */ + void onCallFinished(CelAsyncCall call, @Nullable Object result, @Nullable Throwable error); +} diff --git a/runtime/src/main/java/dev/cel/runtime/CelFunctionBinding.java b/runtime/src/main/java/dev/cel/runtime/CelFunctionBinding.java index 98991d383..cc5d914ef 100644 --- a/runtime/src/main/java/dev/cel/runtime/CelFunctionBinding.java +++ b/runtime/src/main/java/dev/cel/runtime/CelFunctionBinding.java @@ -15,12 +15,17 @@ package dev.cel.runtime; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; +import com.google.common.util.concurrent.ListenableFuture; import com.google.errorprone.annotations.Immutable; import java.util.Collection; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; +import java.util.function.Function; /** * Binding consisting of an overload id, a Java-native argument signature, and an overload @@ -100,6 +105,109 @@ static CelFunctionBinding from( overloadId, ImmutableList.copyOf(argTypes), impl, /* isStrict= */ true); } + /** + * Create an asynchronous unary function binding from the {@code overloadId}, {@code arg}, and + * {@code impl}. + */ + @SuppressWarnings("unchecked") // Safe from CelFunctionOverload.canHandle check before invocation + static CelFunctionBinding fromAsync( + String overloadId, Class arg, CelAsyncFunctionOverload.Unary impl) { + checkNotNull(overloadId); + checkNotNull(arg); + checkNotNull(impl); + return from( + overloadId, + ImmutableList.of(arg), + new CelAsyncFunctionOverload() { + @Override + public ListenableFuture applyAsync(Object[] args) throws CelEvaluationException { + return impl.apply((T) args[0]); + } + + @Override + public ListenableFuture applyAsync(Object arg1) throws CelEvaluationException { + return impl.apply((T) arg1); + } + }); + } + + /** + * Create an asynchronous binary function binding from the {@code overloadId}, {@code arg1}, + * {@code arg2}, and {@code impl}. + */ + @SuppressWarnings("unchecked") // Safe from CelFunctionOverload.canHandle check before invocation + static CelFunctionBinding fromAsync( + String overloadId, + Class arg1, + Class arg2, + CelAsyncFunctionOverload.Binary impl) { + checkNotNull(overloadId); + checkNotNull(arg1); + checkNotNull(arg2); + checkNotNull(impl); + return from( + overloadId, + ImmutableList.of(arg1, arg2), + new CelAsyncFunctionOverload() { + @Override + public ListenableFuture applyAsync(Object[] args) throws CelEvaluationException { + return impl.apply((T1) args[0], (T2) args[1]); + } + + @Override + public ListenableFuture applyAsync(Object a1, Object a2) + throws CelEvaluationException { + return impl.apply((T1) a1, (T2) a2); + } + }); + } + + /** + * Create an asynchronous function binding from the {@code overloadId}, {@code argTypes}, and + * {@code impl}. + */ + static CelFunctionBinding fromAsync( + String overloadId, Iterable> argTypes, CelAsyncFunctionOverload impl) { + checkNotNull(overloadId); + checkNotNull(argTypes); + checkNotNull(impl); + return from(overloadId, argTypes, impl); + } + + /** + * Create an asynchronous unary function binding adapting a {@link CompletableFuture} returning + * implementation. + */ + @SuppressWarnings("Immutable") // The lambda closes over caller-provided impl + static CelFunctionBinding fromCompletableFuture( + String overloadId, Class arg, Function> impl) { + checkNotNull(overloadId); + checkNotNull(arg); + checkNotNull(impl); + return fromAsync( + overloadId, arg, (T a) -> FunctionBindingImpl.toListenableFuture(impl.apply(a))); + } + + /** + * Create an asynchronous binary function binding adapting a {@link CompletableFuture} returning + * implementation. + */ + @SuppressWarnings("Immutable") // The lambda closes over caller-provided impl + static CelFunctionBinding fromCompletableFuture( + String overloadId, + Class arg1, + Class arg2, + BiFunction> impl) { + checkNotNull(overloadId); + checkNotNull(arg1); + checkNotNull(arg2); + checkNotNull(impl); + return fromAsync( + overloadId, + arg1, + arg2, + (T1 a1, T2 a2) -> FunctionBindingImpl.toListenableFuture(impl.apply(a1, a2))); + } /** See {@link #fromOverloads(String, Collection)}. */ static ImmutableSet fromOverloads( @@ -115,6 +223,11 @@ static ImmutableSet fromOverloads( String functionName, Collection overloadBindings) { checkArgument(!Strings.isNullOrEmpty(functionName), "Function name cannot be null or empty"); checkArgument(!overloadBindings.isEmpty(), "You must provide at least one binding."); + for (CelFunctionBinding binding : overloadBindings) { + checkArgument( + !(binding.getDefinition() instanceof CelAsyncFunctionOverload), + "Asynchronous function overloads cannot be grouped using fromOverloads."); + } return FunctionBindingImpl.groupOverloadsToFunction( functionName, ImmutableSet.copyOf(overloadBindings)); diff --git a/runtime/src/main/java/dev/cel/runtime/CelFunctionResolver.java b/runtime/src/main/java/dev/cel/runtime/CelFunctionResolver.java index 2fb136a1a..ec0c92f73 100644 --- a/runtime/src/main/java/dev/cel/runtime/CelFunctionResolver.java +++ b/runtime/src/main/java/dev/cel/runtime/CelFunctionResolver.java @@ -25,6 +25,22 @@ @ThreadSafe public interface CelFunctionResolver { + /** An empty function resolver that resolves no overloads. */ + CelFunctionResolver EMPTY = + new CelFunctionResolver() { + @Override + public Optional findOverloadMatchingArgs( + String functionName, Collection overloadIds, Object[] args) { + return Optional.empty(); + } + + @Override + public Optional findOverloadMatchingArgs( + String functionName, Object[] args) { + return Optional.empty(); + } + }; + /** * Finds a specific function overload to invoke based on given parameters. * @@ -35,6 +51,8 @@ public interface CelFunctionResolver { * @return an optional value of the resolved overload. * @throws CelEvaluationException if the overload resolution is ambiguous, */ + @SuppressWarnings("AvoidObjectArrays") // Low-level interpreter argument passing matches + // CelFunctionOverload.apply(Object[]) Optional findOverloadMatchingArgs( String functionName, Collection overloadIds, Object[] args) throws CelEvaluationException; @@ -48,6 +66,8 @@ Optional findOverloadMatchingArgs( * @return an optional value of the resolved overload. * @throws CelEvaluationException if the overload resolution is ambiguous. */ + @SuppressWarnings("AvoidObjectArrays") // Low-level interpreter argument passing matches + // CelFunctionOverload.apply(Object[]) Optional findOverloadMatchingArgs(String functionName, Object[] args) throws CelEvaluationException; } diff --git a/runtime/src/main/java/dev/cel/runtime/CelRuntime.java b/runtime/src/main/java/dev/cel/runtime/CelRuntime.java index 1e7fdcac8..129d0d181 100644 --- a/runtime/src/main/java/dev/cel/runtime/CelRuntime.java +++ b/runtime/src/main/java/dev/cel/runtime/CelRuntime.java @@ -14,6 +14,8 @@ package dev.cel.runtime; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.Immutable; import javax.annotation.concurrent.ThreadSafe; @@ -42,6 +44,26 @@ interface Program extends dev.cel.runtime.Program { /** Evaluate the expression using {@code message} fields as the source of input variables. */ Object eval(Message message) throws CelEvaluationException; + /** + * Evaluate the expression asynchronously using {@code message} fields as the source of input + * variables. + */ + default ListenableFuture evalAsync(Message message, ListeningExecutorService executor) { + return evalAsync(message, executor, CelAsyncEvaluationOptions.defaultOptions()); + } + + /** + * Evaluate the expression asynchronously using {@code message} fields as the source of input + * variables and custom async options. + */ + default ListenableFuture evalAsync( + Message message, + ListeningExecutorService executor, + CelAsyncEvaluationOptions asyncOptions) { + throw new UnsupportedOperationException( + "evalAsync is not supported by this Program implementation."); + } + /** * Trace evaluates a compiled program without any variables and invokes the listener as * evaluation progresses through the AST. diff --git a/runtime/src/main/java/dev/cel/runtime/CelRuntimeImpl.java b/runtime/src/main/java/dev/cel/runtime/CelRuntimeImpl.java index 5cda25800..699adafbb 100644 --- a/runtime/src/main/java/dev/cel/runtime/CelRuntimeImpl.java +++ b/runtime/src/main/java/dev/cel/runtime/CelRuntimeImpl.java @@ -20,6 +20,8 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.Immutable; import com.google.protobuf.DescriptorProtos; @@ -115,7 +117,7 @@ public Optional findOverloadMatchingArgs( } }; - public Program toRuntimeProgram(dev.cel.runtime.Program program) { + private Program toRuntimeProgram(PlannedProgram program) { return new Program() { @Override @@ -136,10 +138,9 @@ public Object eval(Map mapValue, CelFunctionResolver lateBoundFunctio @Override public Object eval(Message message) throws CelEvaluationException { - PlannedProgram plannedProgram = (PlannedProgram) program; - return plannedProgram.evalOrThrow( - plannedProgram.interpretable(), - ProtoMessageActivationFactory.fromProto(message, plannedProgram.options()), + return program.evalOrThrow( + program.interpretable(), + ProtoMessageActivationFactory.fromProto(message, program.options()), EMPTY_FUNCTION_RESOLVER, /* partialVars= */ null, /* listener= */ null); @@ -164,24 +165,21 @@ public Object eval(PartialVars partialVars) throws CelEvaluationException { @Override public Object trace(CelEvaluationListener listener) throws CelEvaluationException { - return ((PlannedProgram) program) - .trace(GlobalResolver.EMPTY, EMPTY_FUNCTION_RESOLVER, null, listener); + return program.trace(GlobalResolver.EMPTY, EMPTY_FUNCTION_RESOLVER, null, listener); } @Override public Object trace(Map mapValue, CelEvaluationListener listener) throws CelEvaluationException { - return ((PlannedProgram) program) - .trace(Activation.copyOf(mapValue), EMPTY_FUNCTION_RESOLVER, null, listener); + return program.trace(Activation.copyOf(mapValue), EMPTY_FUNCTION_RESOLVER, null, listener); } @Override public Object trace(Message message, CelEvaluationListener listener) throws CelEvaluationException { - PlannedProgram plannedProgram = (PlannedProgram) program; - return plannedProgram.evalOrThrow( - plannedProgram.interpretable(), - ProtoMessageActivationFactory.fromProto(message, plannedProgram.options()), + return program.evalOrThrow( + program.interpretable(), + ProtoMessageActivationFactory.fromProto(message, program.options()), EMPTY_FUNCTION_RESOLVER, /* partialVars= */ null, listener); @@ -190,12 +188,8 @@ public Object trace(Message message, CelEvaluationListener listener) @Override public Object trace(CelVariableResolver resolver, CelEvaluationListener listener) throws CelEvaluationException { - return ((PlannedProgram) program) - .trace( - (name) -> resolver.find(name).orElse(null), - EMPTY_FUNCTION_RESOLVER, - null, - listener); + return program.trace( + (name) -> resolver.find(name).orElse(null), EMPTY_FUNCTION_RESOLVER, null, listener); } @Override @@ -204,12 +198,8 @@ public Object trace( CelFunctionResolver lateBoundFunctionResolver, CelEvaluationListener listener) throws CelEvaluationException { - return ((PlannedProgram) program) - .trace( - (name) -> resolver.find(name).orElse(null), - lateBoundFunctionResolver, - null, - listener); + return program.trace( + (name) -> resolver.find(name).orElse(null), lateBoundFunctionResolver, null, listener); } @Override @@ -218,25 +208,53 @@ public Object trace( CelFunctionResolver lateBoundFunctionResolver, CelEvaluationListener listener) throws CelEvaluationException { - return ((PlannedProgram) program) - .trace(Activation.copyOf(mapValue), lateBoundFunctionResolver, null, listener); + return program.trace( + Activation.copyOf(mapValue), lateBoundFunctionResolver, null, listener); } @Override public Object trace(PartialVars partialVars, CelEvaluationListener listener) throws CelEvaluationException { - return ((PlannedProgram) program) - .trace( - (name) -> partialVars.resolver().find(name).orElse(null), - EMPTY_FUNCTION_RESOLVER, - partialVars, - listener); + return program.trace( + (name) -> partialVars.resolver().find(name).orElse(null), + EMPTY_FUNCTION_RESOLVER, + partialVars, + listener); } @Override - public Object advanceEvaluation(UnknownContext context) throws CelEvaluationException { + public Object advanceEvaluation(UnknownContext context) { throw new UnsupportedOperationException("Unsupported operation."); } + + @Override + public ListenableFuture evalAsync( + GlobalResolver resolver, + CelFunctionResolver lateBoundResolver, + @Nullable PartialVars partialVars, + ListeningExecutorService executor, + CelAsyncEvaluationOptions asyncOptions) { + return program.evalAsync(resolver, lateBoundResolver, partialVars, executor, asyncOptions); + } + + @Override + public ListenableFuture evalAsync( + Message message, ListeningExecutorService executor) { + return evalAsync(message, executor, CelAsyncEvaluationOptions.defaultOptions()); + } + + @Override + public ListenableFuture evalAsync( + Message message, + ListeningExecutorService executor, + CelAsyncEvaluationOptions asyncOptions) { + return program.evalAsync( + ProtoMessageActivationFactory.fromProto(message, program.options()), + EMPTY_FUNCTION_RESOLVER, + /* partialVars= */ null, + executor, + asyncOptions); + } }; } diff --git a/runtime/src/main/java/dev/cel/runtime/FunctionBindingImpl.java b/runtime/src/main/java/dev/cel/runtime/FunctionBindingImpl.java index 7b8efe8fd..6a873322f 100644 --- a/runtime/src/main/java/dev/cel/runtime/FunctionBindingImpl.java +++ b/runtime/src/main/java/dev/cel/runtime/FunctionBindingImpl.java @@ -15,16 +15,56 @@ package dev.cel.runtime; import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; import com.google.errorprone.annotations.Immutable; import dev.cel.common.exceptions.CelOverloadNotFoundException; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.jspecify.annotations.Nullable; @Immutable final class FunctionBindingImpl implements InternalCelFunctionBinding { + @SuppressWarnings("FutureReturnValueIgnored") // Result piped to SettableFuture + static @Nullable ListenableFuture toListenableFuture( + @Nullable CompletableFuture completableFuture) { + if (completableFuture == null) { + return null; + } + SettableFuture settable = SettableFuture.create(); + completableFuture.whenComplete( + (result, throwable) -> { + if (throwable != null) { + Throwable unwrapped = throwable; + while (unwrapped instanceof CompletionException && unwrapped.getCause() != null) { + unwrapped = unwrapped.getCause(); + } + if (unwrapped instanceof CancellationException) { + settable.cancel(false); + } else { + settable.setException(unwrapped); + } + } else { + settable.set(result); + } + }); + settable.addListener( + () -> { + if (settable.isCancelled()) { + completableFuture.cancel(false); + } + }, + directExecutor()); + return settable; + } + private final String functionName; private final String overloadId; @@ -176,8 +216,11 @@ public Object apply(Object[] args) throws CelEvaluationException { public Object apply(Object arg) throws CelEvaluationException { for (CelFunctionBinding overload : overloadBindings) { if (CelFunctionOverload.canHandle(arg, overload.getArgTypes(), overload.isStrict())) { - OptimizedFunctionOverload def = (OptimizedFunctionOverload) overload.getDefinition(); - return def.apply(arg); + CelFunctionOverload def = overload.getDefinition(); + if (def instanceof OptimizedFunctionOverload) { + return ((OptimizedFunctionOverload) def).apply(arg); + } + return def.apply(new Object[] {arg}); } } throw new CelOverloadNotFoundException( @@ -192,8 +235,11 @@ public Object apply(Object arg1, Object arg2) throws CelEvaluationException { for (CelFunctionBinding overload : overloadBindings) { if (CelFunctionOverload.canHandle( arg1, arg2, overload.getArgTypes(), overload.isStrict())) { - OptimizedFunctionOverload def = (OptimizedFunctionOverload) overload.getDefinition(); - return def.apply(arg1, arg2); + CelFunctionOverload def = overload.getDefinition(); + if (def instanceof OptimizedFunctionOverload) { + return ((OptimizedFunctionOverload) def).apply(arg1, arg2); + } + return def.apply(new Object[] {arg1, arg2}); } } throw new CelOverloadNotFoundException( diff --git a/runtime/src/main/java/dev/cel/runtime/Program.java b/runtime/src/main/java/dev/cel/runtime/Program.java index e808a373c..d9480b8f0 100644 --- a/runtime/src/main/java/dev/cel/runtime/Program.java +++ b/runtime/src/main/java/dev/cel/runtime/Program.java @@ -14,8 +14,11 @@ package dev.cel.runtime; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; import com.google.errorprone.annotations.Immutable; import java.util.Map; +import org.jspecify.annotations.Nullable; /** Creates an evaluable {@code Program} instance which is thread-safe and immutable. */ @Immutable @@ -46,4 +49,171 @@ Object eval(CelVariableResolver resolver, CelFunctionResolver lateBoundFunctionR /** Evaluate a compiled program with unknown attribute patterns {@code partialVars}. */ Object eval(PartialVars partialVars) throws CelEvaluationException; + + /** Evaluate the expression asynchronously without any variables on the given executor. */ + default ListenableFuture evalAsync(ListeningExecutorService executor) { + return evalAsync( + GlobalResolver.EMPTY, + CelFunctionResolver.EMPTY, + /* partialVars= */ null, + executor, + CelAsyncEvaluationOptions.defaultOptions()); + } + + /** + * Evaluate the expression asynchronously without any variables on the given executor with custom + * async options. + */ + default ListenableFuture evalAsync( + ListeningExecutorService executor, CelAsyncEvaluationOptions asyncOptions) { + return evalAsync( + GlobalResolver.EMPTY, + CelFunctionResolver.EMPTY, + /* partialVars= */ null, + executor, + asyncOptions); + } + + /** + * Evaluate the expression asynchronously using a {@code mapValue} as the source of input + * variables. + */ + default ListenableFuture evalAsync( + Map mapValue, ListeningExecutorService executor) { + return evalAsync( + Activation.copyOf(mapValue), + CelFunctionResolver.EMPTY, + /* partialVars= */ null, + executor, + CelAsyncEvaluationOptions.defaultOptions()); + } + + /** + * Evaluate the expression asynchronously using a {@code mapValue} as the source of input + * variables with custom async options. + */ + default ListenableFuture evalAsync( + Map mapValue, + ListeningExecutorService executor, + CelAsyncEvaluationOptions asyncOptions) { + return evalAsync( + Activation.copyOf(mapValue), + CelFunctionResolver.EMPTY, + /* partialVars= */ null, + executor, + asyncOptions); + } + + /** Evaluate the expression asynchronously using a {@code mapValue} and late-bound functions. */ + default ListenableFuture evalAsync( + Map mapValue, + CelFunctionResolver lateBoundFunctionResolver, + ListeningExecutorService executor) { + return evalAsync( + mapValue, lateBoundFunctionResolver, executor, CelAsyncEvaluationOptions.defaultOptions()); + } + + /** + * Evaluate the expression asynchronously using a {@code mapValue}, late-bound functions, and + * custom async options. + */ + default ListenableFuture evalAsync( + Map mapValue, + CelFunctionResolver lateBoundFunctionResolver, + ListeningExecutorService executor, + CelAsyncEvaluationOptions asyncOptions) { + return evalAsync( + Activation.copyOf(mapValue), + lateBoundFunctionResolver, + /* partialVars= */ null, + executor, + asyncOptions); + } + + /** Evaluate the expression asynchronously with a custom variable {@code resolver}. */ + default ListenableFuture evalAsync( + CelVariableResolver resolver, ListeningExecutorService executor) { + return evalAsync(resolver, executor, CelAsyncEvaluationOptions.defaultOptions()); + } + + /** + * Evaluate the expression asynchronously with a custom variable {@code resolver} and custom async + * options. + */ + default ListenableFuture evalAsync( + CelVariableResolver resolver, + ListeningExecutorService executor, + CelAsyncEvaluationOptions asyncOptions) { + return evalAsync( + (name) -> resolver.find(name).orElse(null), + CelFunctionResolver.EMPTY, + /* partialVars= */ null, + executor, + asyncOptions); + } + + /** + * Evaluate the expression asynchronously with a custom variable {@code resolver} and late-bound + * functions. + */ + default ListenableFuture evalAsync( + CelVariableResolver resolver, + CelFunctionResolver lateBoundFunctionResolver, + ListeningExecutorService executor) { + return evalAsync( + resolver, lateBoundFunctionResolver, executor, CelAsyncEvaluationOptions.defaultOptions()); + } + + /** + * Evaluate the expression asynchronously with a custom variable {@code resolver}, late-bound + * functions, and custom async options. + */ + default ListenableFuture evalAsync( + CelVariableResolver resolver, + CelFunctionResolver lateBoundFunctionResolver, + ListeningExecutorService executor, + CelAsyncEvaluationOptions asyncOptions) { + return evalAsync( + (name) -> resolver.find(name).orElse(null), + lateBoundFunctionResolver, + /* partialVars= */ null, + executor, + asyncOptions); + } + + /** Evaluate the expression asynchronously with unknown attribute patterns {@code partialVars}. */ + default ListenableFuture evalAsync( + PartialVars partialVars, ListeningExecutorService executor) { + return evalAsync(partialVars, executor, CelAsyncEvaluationOptions.defaultOptions()); + } + + /** + * Evaluate the expression asynchronously with unknown attribute patterns {@code partialVars} and + * custom async options. + */ + default ListenableFuture evalAsync( + PartialVars partialVars, + ListeningExecutorService executor, + CelAsyncEvaluationOptions asyncOptions) { + return evalAsync( + (name) -> partialVars.resolver().find(name).orElse(null), + CelFunctionResolver.EMPTY, + partialVars, + executor, + asyncOptions); + } + + /** + * Advanced asynchronous evaluation entry point supporting custom global resolvers, late-bound + * function resolvers, partial variables, and execution options. + */ + default ListenableFuture evalAsync( + GlobalResolver resolver, + CelFunctionResolver lateBoundResolver, + @Nullable PartialVars partialVars, + ListeningExecutorService executor, + CelAsyncEvaluationOptions asyncOptions) { + throw new UnsupportedOperationException( + "evalAsync is not supported by this Program implementation."); + } } diff --git a/runtime/src/main/java/dev/cel/runtime/ProgramImpl.java b/runtime/src/main/java/dev/cel/runtime/ProgramImpl.java index 2543a9525..3ad1b3442 100644 --- a/runtime/src/main/java/dev/cel/runtime/ProgramImpl.java +++ b/runtime/src/main/java/dev/cel/runtime/ProgramImpl.java @@ -16,12 +16,15 @@ import com.google.auto.value.AutoValue; import com.google.common.base.Preconditions; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; import com.google.errorprone.annotations.Immutable; import com.google.protobuf.Message; import dev.cel.common.CelOptions; import dev.cel.runtime.CelRuntime.Program; import java.util.Map; import java.util.Optional; +import org.jspecify.annotations.Nullable; /** Internal implementation of a {@link CelRuntime.Program} */ @AutoValue @@ -124,6 +127,25 @@ public Object advanceEvaluation(UnknownContext context) throws CelEvaluationExce return evalInternal(context, Optional.empty(), Optional.empty()); } + @Override + public ListenableFuture evalAsync( + GlobalResolver resolver, + CelFunctionResolver lateBoundResolver, + @Nullable PartialVars partialVars, + ListeningExecutorService executor, + CelAsyncEvaluationOptions asyncOptions) { + throw new UnsupportedOperationException( + "evalAsync is not supported by the legacy interpreter. Use" + + " CelRuntimeFactory.plannerRuntimeBuilder()."); + } + + @Override + public ListenableFuture evalAsync( + Message message, ListeningExecutorService executor, CelAsyncEvaluationOptions asyncOptions) { + throw new UnsupportedOperationException( + "evalAsync is not supported by the legacy interpreter. Use CelRuntimeImpl."); + } + private Object evalInternal(GlobalResolver resolver) throws CelEvaluationException { return evalInternal(UnknownContext.create(resolver), Optional.empty(), Optional.empty()); } diff --git a/runtime/src/main/java/dev/cel/runtime/planner/AsyncCallKey.java b/runtime/src/main/java/dev/cel/runtime/planner/AsyncCallKey.java new file mode 100644 index 000000000..b52b476e6 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/planner/AsyncCallKey.java @@ -0,0 +1,100 @@ +// 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 dev.cel.runtime.RuntimeEquality; + +/** + * Unique cache key for an asynchronous function invocation at a given AST expression node. + * + *

Arguments are compared for equality using {@link RuntimeEquality}. Top-level {@link + * Double#NaN} and {@link Float#NaN} arguments are explicitly treated as equivalent across + * evaluation iterations so that re-evaluating the same AST node with literal or computed NaN + * arguments correctly matches existing call records. + */ +final class AsyncCallKey { + private final long exprId; + private final Object[] args; + private final RuntimeEquality runtimeEquality; + private final int hashCode; + + static AsyncCallKey create(long exprId, Object[] args, RuntimeEquality runtimeEquality) { + return new AsyncCallKey(exprId, args, runtimeEquality); + } + + private AsyncCallKey(long exprId, Object[] args, RuntimeEquality runtimeEquality) { + this.exprId = exprId; + this.args = args.clone(); + this.runtimeEquality = runtimeEquality; + this.hashCode = computeHashCode(exprId, this.args, runtimeEquality); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AsyncCallKey)) { + return false; + } + AsyncCallKey other = (AsyncCallKey) o; + if (exprId != other.exprId || args.length != other.args.length) { + return false; + } + for (int i = 0; i < args.length; i++) { + Object a = args[i]; + Object b = other.args[i]; + if (a instanceof Double && b instanceof Double) { + if (Double.isNaN((Double) a) && Double.isNaN((Double) b)) { + continue; + } + } else if (a instanceof Float && b instanceof Float) { + if (Float.isNaN((Float) a) && Float.isNaN((Float) b)) { + continue; + } + } + if (!runtimeEquality.objectEquals(a, b)) { + return false; + } + } + return true; + } + + @Override + public int hashCode() { + return hashCode; + } + + private static int computeHashCode(long exprId, Object[] args, RuntimeEquality runtimeEquality) { + int result = (int) (exprId ^ (exprId >>> 32)); + for (Object arg : args) { + result = 31 * result + hashArg(arg, runtimeEquality); + } + return result; + } + + private static int hashArg(Object arg, RuntimeEquality runtimeEquality) { + if (arg instanceof Number) { + double d = ((Number) arg).doubleValue(); + if (d == 0.0d) { + d = 0.0d; + } else if (Double.isNaN(d)) { + d = Double.NaN; + } + return Double.hashCode(d); + } + return runtimeEquality.hashCode(arg); + } +} diff --git a/runtime/src/main/java/dev/cel/runtime/planner/AsyncCallRecord.java b/runtime/src/main/java/dev/cel/runtime/planner/AsyncCallRecord.java new file mode 100644 index 000000000..330fe1911 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/planner/AsyncCallRecord.java @@ -0,0 +1,127 @@ +// 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 com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.ListenableFuture; +import dev.cel.runtime.CelAsyncCall; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.jspecify.annotations.Nullable; + +/** Tracks the execution state and result of a single asynchronous function call. */ +final class AsyncCallRecord implements CelAsyncCall { + + enum State { + RUNNING, + SUCCESS, + FAILURE + } + + private final long callId; + private final long exprId; + private final String functionName; + private final String overloadId; + private final Object[] args; + + private final AtomicReference state = new AtomicReference<>(State.RUNNING); + private final AtomicBoolean cancelled = new AtomicBoolean(false); + private volatile @Nullable Object result; + private volatile @Nullable Throwable error; + private volatile Duration elapsedDuration = Duration.ZERO; + private volatile @Nullable ListenableFuture inFlightFuture; + + AsyncCallRecord(long callId, long exprId, String functionName, String overloadId, Object[] args) { + this.callId = callId; + this.exprId = exprId; + this.functionName = functionName; + this.overloadId = overloadId; + this.args = args.clone(); + } + + void setInFlightFuture(ListenableFuture future) { + this.inFlightFuture = future; + if (cancelled.get() && !future.isDone()) { + future.cancel(/* mayInterruptIfRunning= */ false); + } + } + + void cancelInFlight() { + this.cancelled.set(true); + ListenableFuture future = this.inFlightFuture; + if (future != null && !future.isDone()) { + future.cancel(/* mayInterruptIfRunning= */ false); + } + } + + boolean isCancelled() { + return cancelled.get(); + } + + void complete(Object result, Duration elapsed) { + this.result = result; + this.elapsedDuration = elapsed; + this.state.set(State.SUCCESS); + } + + void fail(Throwable error, Duration elapsed) { + this.error = error; + this.elapsedDuration = elapsed; + this.state.set(State.FAILURE); + } + + State state() { + return state.get(); + } + + @Nullable Object result() { + return result; + } + + @Nullable Throwable error() { + return error; + } + + @Override + public long callId() { + return callId; + } + + @Override + public long exprId() { + return exprId; + } + + @Override + public String functionName() { + return functionName; + } + + @Override + public String overloadId() { + return overloadId; + } + + @Override + public ImmutableList arguments() { + return ImmutableList.copyOf(args); + } + + @Override + public Duration elapsedDuration() { + return elapsedDuration; + } +} diff --git a/runtime/src/main/java/dev/cel/runtime/planner/AsyncCallStateTracker.java b/runtime/src/main/java/dev/cel/runtime/planner/AsyncCallStateTracker.java new file mode 100644 index 000000000..5beb7a562 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/planner/AsyncCallStateTracker.java @@ -0,0 +1,222 @@ +// 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.util.concurrent.MoreExecutors.directExecutor; +import static java.util.Objects.requireNonNull; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import dev.cel.common.values.CelValueConverter; +import dev.cel.runtime.AccumulatedUnknowns; +import dev.cel.runtime.CelAsyncFunctionOverload; +import dev.cel.runtime.CelAsyncObserver; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.InterpreterUtil; +import dev.cel.runtime.RuntimeEquality; +import java.time.Duration; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; +import org.jspecify.annotations.Nullable; + +/** + * Tracks the registry and cache of all asynchronous function calls made during an expression + * evaluation. + */ +final class AsyncCallStateTracker { + private final AtomicLong callIdGenerator = new AtomicLong(1); + private final ConcurrentMap recordsByKey = + new ConcurrentHashMap<>(); + private final ConcurrentMap recordsById = new ConcurrentHashMap<>(); + private final RuntimeEquality runtimeEquality; + private final LongSupplier nanoTimeSupplier; + + AsyncCallStateTracker(RuntimeEquality runtimeEquality) { + this(runtimeEquality, System::nanoTime); + } + + AsyncCallStateTracker(RuntimeEquality runtimeEquality, LongSupplier nanoTimeSupplier) { + this.runtimeEquality = requireNonNull(runtimeEquality); + this.nanoTimeSupplier = requireNonNull(nanoTimeSupplier); + } + + Object recordOrGet( + long exprId, + String functionName, + String overloadId, + Object[] args, + CelAsyncFunctionOverload overload, + CelValueConverter celValueConverter, + ListeningExecutorService executor, + AsyncGate gate, + AsyncCompletionCoordinator coordinator, + @Nullable CelAsyncObserver observer) + throws CelEvaluationException { + AsyncCallKey key = AsyncCallKey.create(exprId, args, runtimeEquality); + + AsyncCallRecord existing = recordsByKey.get(key); + if (existing != null) { + return resolveRecord(existing, celValueConverter); + } + + long callId = callIdGenerator.getAndIncrement(); + AsyncCallRecord newRecord = new AsyncCallRecord(callId, exprId, functionName, overloadId, args); + AsyncCallRecord raceWinner = recordsByKey.putIfAbsent(key, newRecord); + if (raceWinner != null) { + return resolveRecord(raceWinner, celValueConverter); + } + + recordsById.put(callId, newRecord); + + if (observer != null) { + try { + observer.onCallStarted(newRecord); + } catch (Throwable t) { + // Observers must not disrupt evaluation + } + } + + Runnable task = + () -> { + if (newRecord.isCancelled()) { + gate.releasePermit(executor); + return; + } + long startTimeNanos = nanoTimeSupplier.getAsLong(); + try { + ListenableFuture future = overload.applyAsync(args); + if (future == null) { + throw new CelEvaluationException( + String.format( + "Async function '%s' returned a null ListenableFuture", functionName)); + } + newRecord.setInFlightFuture(future); + Futures.addCallback( + future, + new FutureCallback() { + @Override + public void onSuccess(Object result) { + Duration elapsed = + Duration.ofNanos(nanoTimeSupplier.getAsLong() - startTimeNanos); + newRecord.complete(result, elapsed); + try { + if (observer != null) { + safeNotifyCallFinished(observer, newRecord, result, null); + } + } finally { + gate.releasePermit(executor); + coordinator.notifyCallCompleted(newRecord); + } + } + + @Override + public void onFailure(Throwable t) { + Duration elapsed = + Duration.ofNanos(nanoTimeSupplier.getAsLong() - startTimeNanos); + newRecord.fail(t, elapsed); + try { + if (observer != null) { + safeNotifyCallFinished(observer, newRecord, null, t); + } + } finally { + gate.releasePermit(executor); + coordinator.notifyCallCompleted(newRecord); + } + } + }, + directExecutor()); + } catch (Throwable t) { + Duration elapsed = Duration.ofNanos(nanoTimeSupplier.getAsLong() - startTimeNanos); + newRecord.fail(t, elapsed); + try { + if (observer != null) { + safeNotifyCallFinished(observer, newRecord, null, t); + } + } finally { + gate.releasePermit(executor); + coordinator.notifyCallCompleted(newRecord); + } + } + }; + + gate.dispatch(executor, task); + + if (newRecord.state() != AsyncCallRecord.State.RUNNING) { + return resolveRecord(newRecord, celValueConverter); + } + + return AccumulatedUnknowns.createForAsyncCall(callId); + } + + private Object resolveRecord(AsyncCallRecord record, CelValueConverter celValueConverter) + throws CelEvaluationException { + switch (record.state()) { + case SUCCESS: + return InterpreterUtil.maybeAdaptToAccumulatedUnknowns( + celValueConverter.maybeUnwrap(celValueConverter.toRuntimeValue(record.result()))); + case FAILURE: + Throwable error = record.error(); + if (error instanceof CelEvaluationException) { + throw (CelEvaluationException) error; + } + String errorMessage = + error != null && error.getMessage() != null + ? error.getMessage() + : (error != null ? error.getClass().getSimpleName() : "unknown error"); + throw new CelEvaluationException( + String.format("Async function '%s' failed: %s", record.functionName(), errorMessage), + error); + case RUNNING: + return AccumulatedUnknowns.createForAsyncCall(record.callId()); + } + throw new AssertionError("Unexpected record state: " + record.state()); + } + + boolean hasInFlightCalls() { + for (AsyncCallRecord record : recordsById.values()) { + if (record.state() == AsyncCallRecord.State.RUNNING) { + return true; + } + } + return false; + } + + void cancelInFlight() { + for (AsyncCallRecord record : recordsById.values()) { + if (record.state() == AsyncCallRecord.State.RUNNING) { + record.cancelInFlight(); + } + } + } + + private static void safeNotifyCallFinished( + CelAsyncObserver observer, + AsyncCallRecord record, + @Nullable Object result, + @Nullable Throwable error) { + if (observer == null) { + throw new AssertionError("observer must not be null when notifying call finished"); + } + try { + observer.onCallFinished(record, result, error); + } catch (Throwable obsEx) { + // Ignore observer errors + } + } +} diff --git a/runtime/src/main/java/dev/cel/runtime/planner/AsyncCompletionCoordinator.java b/runtime/src/main/java/dev/cel/runtime/planner/AsyncCompletionCoordinator.java new file mode 100644 index 000000000..a8aae0563 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/planner/AsyncCompletionCoordinator.java @@ -0,0 +1,150 @@ +// 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 java.util.Objects.requireNonNull; +import static java.util.concurrent.TimeUnit.NANOSECONDS; + +import com.google.common.collect.ImmutableList; +import com.google.errorprone.annotations.concurrent.GuardedBy; +import dev.cel.runtime.CelAsyncCall; +import dev.cel.runtime.CelAsyncDrainAction; +import dev.cel.runtime.CelAsyncEvaluationOptions; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import org.jspecify.annotations.Nullable; + +/** + * Coordinates asynchronous call completion notifications, debouncing, and re-evaluation dispatch. + */ +final class AsyncCompletionCoordinator { + private final CelAsyncEvaluationOptions options; + private final AsyncGate gate; + private final Executor executor; + + @GuardedBy("this") + private final List completedBatch = new ArrayList<>(); + + @GuardedBy("this") + private @Nullable Runnable continuation; + + @GuardedBy("this") + private @Nullable ScheduledFuture debounceTimer; + + @GuardedBy("this") + private boolean isWaiting = false; + + AsyncCompletionCoordinator(CelAsyncEvaluationOptions options, AsyncGate gate, Executor executor) { + this.options = options; + this.gate = gate; + this.executor = executor; + } + + synchronized boolean hasPendingBatch() { + return !completedBatch.isEmpty(); + } + + synchronized void notifyCallCompleted(CelAsyncCall call) { + completedBatch.add(call); + if (!isWaiting) { + return; + } + + CelAsyncDrainAction action = + options + .drainStrategy() + .nextAction(ImmutableList.copyOf(completedBatch), gate.activeCount()); + if (action.shouldReevaluate()) { + triggerContinuation(); + } else if (action.waitDuration().isZero()) { + // Indefinite wait for next completion + } else { + scheduleDebounce(action.waitDuration().toNanos()); + } + } + + synchronized void waitForCompletions(Runnable continuationCallback) { + this.continuation = requireNonNull(continuationCallback); + this.isWaiting = true; + + CelAsyncDrainAction action = + options + .drainStrategy() + .nextAction(ImmutableList.copyOf(completedBatch), gate.activeCount()); + if (action.shouldReevaluate()) { + triggerContinuation(); + } else if (!action.waitDuration().isZero()) { + scheduleDebounce(action.waitDuration().toNanos()); + } + } + + synchronized void cancel() { + cancelDebounceTimer(); + isWaiting = false; + continuation = null; + completedBatch.clear(); + } + + @GuardedBy("this") + private synchronized void scheduleDebounce(long nanos) { + if (debounceTimer == null) { + ScheduledExecutorService scheduler = options.resolveScheduledExecutorService(); + debounceTimer = + scheduler.schedule( + () -> { + synchronized (AsyncCompletionCoordinator.this) { + if (isWaiting) { + triggerContinuation(); + } + } + }, + nanos, + NANOSECONDS); + } + } + + @GuardedBy("this") + private synchronized void triggerContinuation() { + cancelDebounceTimer(); + isWaiting = false; + completedBatch.clear(); + Runnable run = continuation; + continuation = null; + executor.execute(run); + } + + @GuardedBy("this") + private synchronized void cancelDebounceTimer() { + if (debounceTimer != null) { + debounceTimer.cancel(false); + debounceTimer = null; + } + } + + synchronized boolean isWaiting() { + return isWaiting; + } + + synchronized boolean hasContinuation() { + return continuation != null; + } + + synchronized boolean hasScheduledDebounceTimer() { + return debounceTimer != null; + } +} diff --git a/runtime/src/main/java/dev/cel/runtime/planner/AsyncGate.java b/runtime/src/main/java/dev/cel/runtime/planner/AsyncGate.java new file mode 100644 index 000000000..4cf763d27 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/planner/AsyncGate.java @@ -0,0 +1,121 @@ +// 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 java.util.Objects.requireNonNull; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.jspecify.annotations.Nullable; + +/** Regulates the number of concurrent asynchronous function executions based on maxConcurrency. */ +final class AsyncGate { + private final @Nullable Semaphore semaphore; + private final Queue pendingTasks; + private final AtomicInteger activeCount = new AtomicInteger(); + private final AtomicBoolean cancelled = new AtomicBoolean(false); + + AsyncGate(int maxConcurrency) { + this(maxConcurrency, new ConcurrentLinkedQueue<>()); + } + + AsyncGate(int maxConcurrency, Queue pendingTasks) { + this(maxConcurrency > 0 ? new Semaphore(maxConcurrency) : null, pendingTasks); + } + + AsyncGate(@Nullable Semaphore semaphore, Queue pendingTasks) { + this.semaphore = semaphore; + this.pendingTasks = requireNonNull(pendingTasks); + } + + void cancel() { + cancelled.set(true); + pendingTasks.clear(); + } + + void dispatch(Executor executor, Runnable task) { + if (cancelled.get()) { + return; + } + + if (semaphore == null) { + activeCount.incrementAndGet(); + try { + task.run(); + } catch (Throwable e) { + activeCount.decrementAndGet(); + throw e; + } + return; + } + + if (semaphore.tryAcquire()) { + activeCount.incrementAndGet(); + try { + task.run(); + } catch (Throwable e) { + activeCount.decrementAndGet(); + semaphore.release(); + throw e; + } + return; + } + + pendingTasks.add(task); + drainPending(executor); + } + + void releasePermit(Executor executor) { + activeCount.decrementAndGet(); + if (semaphore != null) { + semaphore.release(); + drainPending(executor); + } + } + + private void drainPending(Executor executor) { + if (cancelled.get()) { + pendingTasks.clear(); + return; + } + while (!pendingTasks.isEmpty()) { + if (!semaphore.tryAcquire()) { + break; + } + Runnable task = pendingTasks.poll(); + if (task != null) { + activeCount.incrementAndGet(); + try { + executor.execute(task); + } catch (Throwable e) { + activeCount.decrementAndGet(); + semaphore.release(); + throw e; + } + } else { + semaphore.release(); + break; + } + } + } + + int activeCount() { + return activeCount.get(); + } +} 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 ca7665953..7bf97c0fe 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel +++ b/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel @@ -17,6 +17,7 @@ java_library( ":attribute", ":error_metadata", ":eval_and", + ":eval_async_call", ":eval_attribute", ":eval_binary", ":eval_block", @@ -57,6 +58,7 @@ java_library( "//runtime:dispatcher", "//runtime:evaluation_exception", "//runtime:evaluation_exception_builder", + "//runtime:function_overload", "//runtime:program", "//runtime:resolved_overload", "@maven//:com_google_code_findbugs_annotations", @@ -72,6 +74,7 @@ java_library( tags = [ ], deps = [ + ":async_call_state_tracker", ":error_metadata", ":localized_evaluation_exception", ":planned_interpretable", @@ -80,7 +83,9 @@ java_library( "//common/annotations", "//common/exceptions:runtime_exception", "//common/values", + "//runtime:accumulated_unknowns", "//runtime:activation", + "//runtime:async_options", "//runtime:evaluation_exception", "//runtime:evaluation_exception_builder", "//runtime:evaluation_listener", @@ -90,8 +95,11 @@ java_library( "//runtime:partial_vars", "//runtime:program", "//runtime:resolved_overload", + "//runtime:runtime_equality", + "//runtime:runtime_helpers", "//runtime:variable_resolver", "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:com_google_guava_guava", "@maven//:org_jspecify_jspecify", ], ) @@ -186,6 +194,50 @@ java_library( ], ) +java_library( + name = "async_call_state_tracker", + srcs = [ + "AsyncCallKey.java", + "AsyncCallRecord.java", + "AsyncCallStateTracker.java", + "AsyncCompletionCoordinator.java", + "AsyncGate.java", + ], + tags = [ + ], + deps = [ + "//common/values", + "//runtime:accumulated_unknowns", + "//runtime:async_call", + "//runtime:async_drain_strategy", + "//runtime:async_observer", + "//runtime:async_options", + "//runtime:evaluation_exception", + "//runtime:function_overload", + "//runtime:interpreter_util", + "//runtime:runtime_equality", + "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:com_google_guava_guava", + "@maven//:org_jspecify_jspecify", + ], +) + +java_library( + name = "eval_async_call", + srcs = ["EvalAsyncCall.java"], + deps = [ + ":eval_helpers", + ":planned_interpretable", + "//common/ast", + "//common/values", + "//runtime:accumulated_unknowns", + "//runtime:evaluation_exception", + "//runtime:function_overload", + "//runtime:interpretable", + "@maven//:com_google_errorprone_error_prone_annotations", + ], +) + java_library( name = "activation_wrapper", srcs = ["ActivationWrapper.java"], @@ -380,8 +432,10 @@ java_library( "//common/values", "//runtime:accumulated_unknowns", "//runtime:evaluation_exception", + "//runtime:function_overload", "//runtime:interpretable", "//runtime:resolved_overload", + "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:com_google_guava_guava", ], ) @@ -524,10 +578,12 @@ java_library( "PlannedInterpretable.java", ], deps = [ + ":async_call_state_tracker", ":localized_evaluation_exception", "//common:options", "//common/ast", "//common/exceptions:iteration_budget_exceeded", + "//runtime:async_observer", "//runtime:evaluation_exception", "//runtime:evaluation_listener", "//runtime:function_resolver", @@ -536,6 +592,7 @@ java_library( "//runtime:partial_vars", "//runtime:resolved_overload", "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:com_google_guava_guava", "@maven//:org_jspecify_jspecify", ], ) @@ -549,6 +606,7 @@ cel_android_library( ":attribute_android", ":error_metadata_android", ":eval_and_android", + ":eval_async_call_android", ":eval_attribute_android", ":eval_binary_android", ":eval_block_android", @@ -589,8 +647,9 @@ cel_android_library( "//runtime:dispatcher_android", "//runtime:evaluation_exception", "//runtime:evaluation_exception_builder", + "//runtime:function_overload_android", + "//runtime:program_android", "//runtime:resolved_overload_android", - "//runtime/src/main/java/dev/cel/runtime:program_android", "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:org_jspecify_jspecify", "@maven_android//:com_google_guava_guava", @@ -601,6 +660,7 @@ cel_android_library( name = "planned_program_android", srcs = ["PlannedProgram.java"], deps = [ + ":async_call_state_tracker_android", ":error_metadata_android", ":localized_evaluation_exception_android", ":planned_interpretable_android", @@ -609,19 +669,24 @@ cel_android_library( "//common/annotations", "//common/exceptions:runtime_exception", "//common/values:values_android", + "//runtime:accumulated_unknowns_android", "//runtime:activation_android", + "//runtime:async_options_android", "//runtime:evaluation_exception", "//runtime:evaluation_exception_builder", "//runtime:interpretable_android", + "//runtime:program_android", "//runtime:resolved_overload_android", + "//runtime:runtime_equality_android", + "//runtime:runtime_helpers_android", "//runtime:variable_resolver", "//runtime/src/main/java/dev/cel/runtime:evaluation_listener_android", "//runtime/src/main/java/dev/cel/runtime:function_resolver_android", "//runtime/src/main/java/dev/cel/runtime:interpreter_util_android", "//runtime/src/main/java/dev/cel/runtime:partial_vars_android", - "//runtime/src/main/java/dev/cel/runtime:program_android", "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:org_jspecify_jspecify", + "@maven_android//:com_google_guava_guava", ], ) @@ -715,6 +780,50 @@ cel_android_library( ], ) +cel_android_library( + name = "async_call_state_tracker_android", + srcs = [ + "AsyncCallKey.java", + "AsyncCallRecord.java", + "AsyncCallStateTracker.java", + "AsyncCompletionCoordinator.java", + "AsyncGate.java", + ], + tags = [ + ], + deps = [ + "//common/values:values_android", + "//runtime:accumulated_unknowns_android", + "//runtime:async_call_android", + "//runtime:async_drain_strategy_android", + "//runtime:async_observer_android", + "//runtime:async_options_android", + "//runtime:evaluation_exception", + "//runtime:function_overload_android", + "//runtime:interpreter_util_android", + "//runtime:runtime_equality_android", + "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:org_jspecify_jspecify", + "@maven_android//:com_google_guava_guava", + ], +) + +cel_android_library( + name = "eval_async_call_android", + srcs = ["EvalAsyncCall.java"], + deps = [ + ":eval_helpers_android", + ":planned_interpretable_android", + "//common/ast:ast_android", + "//common/values:values_android", + "//runtime:accumulated_unknowns_android", + "//runtime:evaluation_exception", + "//runtime:function_overload_android", + "//runtime:interpretable_android", + "@maven//:com_google_errorprone_error_prone_annotations", + ], +) + cel_android_library( name = "activation_wrapper_android", srcs = ["ActivationWrapper.java"], @@ -907,10 +1016,12 @@ cel_android_library( "//common/ast:ast_android", "//common/exceptions:overload_not_found", "//common/values:values_android", + "//runtime:accumulated_unknowns_android", "//runtime:evaluation_exception", + "//runtime:function_overload_android", "//runtime:interpretable_android", "//runtime:resolved_overload_android", - "//runtime/src/main/java/dev/cel/runtime:accumulated_unknowns_android", + "@maven//:com_google_errorprone_error_prone_annotations", "@maven_android//:com_google_guava_guava", ], ) @@ -1048,10 +1159,12 @@ cel_android_library( "PlannedInterpretable.java", ], deps = [ + ":async_call_state_tracker_android", ":localized_evaluation_exception_android", "//common:options", "//common/ast:ast_android", "//common/exceptions:iteration_budget_exceeded", + "//runtime:async_observer_android", "//runtime:evaluation_exception", "//runtime:evaluation_listener_android", "//runtime:interpretable_android", @@ -1061,5 +1174,6 @@ cel_android_library( "//runtime/src/main/java/dev/cel/runtime:partial_vars_android", "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:org_jspecify_jspecify", + "@maven_android//:com_google_guava_guava", ], ) diff --git a/runtime/src/main/java/dev/cel/runtime/planner/EvalAsyncCall.java b/runtime/src/main/java/dev/cel/runtime/planner/EvalAsyncCall.java new file mode 100644 index 000000000..0dc59f412 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/planner/EvalAsyncCall.java @@ -0,0 +1,104 @@ +// 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 dev.cel.runtime.planner.EvalHelpers.evalStrictly; + +import com.google.errorprone.annotations.Immutable; +import dev.cel.common.ast.CelExpr; +import dev.cel.common.values.CelValueConverter; +import dev.cel.runtime.AccumulatedUnknowns; +import dev.cel.runtime.CelAsyncFunctionOverload; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.GlobalResolver; + +/** Evaluates an asynchronous function call within a planned program. */ +@Immutable +final class EvalAsyncCall extends PlannedInterpretable { + + private final String functionName; + private final String overloadId; + private final CelAsyncFunctionOverload overload; + + @SuppressWarnings("Immutable") // Array not mutated + private final PlannedInterpretable[] args; + + private final CelValueConverter celValueConverter; + + static EvalAsyncCall create( + CelExpr expr, + String functionName, + String overloadId, + CelAsyncFunctionOverload overload, + PlannedInterpretable[] args, + CelValueConverter celValueConverter) { + return new EvalAsyncCall(expr, functionName, overloadId, overload, args, celValueConverter); + } + + private EvalAsyncCall( + CelExpr expr, + String functionName, + String overloadId, + CelAsyncFunctionOverload overload, + PlannedInterpretable[] args, + CelValueConverter celValueConverter) { + super(expr); + this.functionName = functionName; + this.overloadId = overloadId; + this.overload = overload; + this.args = args; + this.celValueConverter = celValueConverter; + } + + @Override + Object evalInternal(GlobalResolver resolver, ExecutionFrame frame) throws CelEvaluationException { + if (!frame.isAsync()) { + throw new CelEvaluationException( + String.format( + "Async function '%s' evaluated in synchronous mode. Asynchronous functions are only" + + " supported via evalAsync.", + functionName)); + } + + Object[] evaluatedArgs = new Object[args.length]; + AccumulatedUnknowns accumulatedUnknowns = null; + + for (int i = 0; i < args.length; i++) { + Object argVal = evalStrictly(args[i], resolver, frame); + if (argVal instanceof AccumulatedUnknowns) { + accumulatedUnknowns = AccumulatedUnknowns.maybeMerge(accumulatedUnknowns, argVal); + } + evaluatedArgs[i] = argVal; + } + + if (accumulatedUnknowns != null) { + return accumulatedUnknowns; + } + + return frame + .asyncTracker() + .recordOrGet( + expr().id(), + functionName, + overloadId, + evaluatedArgs, + overload, + celValueConverter, + frame.asyncExecutor(), + frame.asyncGate(), + frame.asyncCoordinator(), + frame.asyncObserver().orElse(null)); + } +} diff --git a/runtime/src/main/java/dev/cel/runtime/planner/EvalFold.java b/runtime/src/main/java/dev/cel/runtime/planner/EvalFold.java index 1cbe807c2..dd4449687 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/EvalFold.java +++ b/runtime/src/main/java/dev/cel/runtime/planner/EvalFold.java @@ -97,6 +97,7 @@ Object evalInternal(GlobalResolver resolver, ExecutionFrame frame) throws CelEva private Object evalMap(Map iterRange, Folder folder, ExecutionFrame frame) throws CelEvaluationException { + AccumulatedUnknowns accumulatedUnknowns = null; for (Map.Entry entry : iterRange.entrySet()) { frame.incrementIterations(); @@ -107,21 +108,32 @@ private Object evalMap(Map iterRange, Folder folder, ExecutionFrame frame) Object condResult = condition.eval(folder, frame); if (condResult instanceof AccumulatedUnknowns) { - return condResult; - } - if (!(condResult instanceof Boolean)) { - throw new IllegalArgumentException( - String.format("Expected boolean value, found :%s", condResult)); - } - boolean cond = (boolean) condResult; - if (!cond) { - folder.computeResult = true; - return result.eval(folder, frame); + if (!frame.isAsync()) { + return condResult; + } + accumulatedUnknowns = AccumulatedUnknowns.maybeMerge(accumulatedUnknowns, condResult); + } else { + if (!(condResult instanceof Boolean)) { + throw new IllegalArgumentException( + String.format("Expected boolean value, found :%s", condResult)); + } + boolean cond = (boolean) condResult; + if (!cond) { + folder.computeResult = true; + return result.eval(folder, frame); + } } - folder.accuVal = loopStep.eval(folder, frame); + Object stepResult = loopStep.eval(folder, frame); + if (stepResult instanceof AccumulatedUnknowns) { + accumulatedUnknowns = AccumulatedUnknowns.maybeMerge(accumulatedUnknowns, stepResult); + } + folder.accuVal = stepResult; folder.initialized = true; } + if (frame.isAsync() && accumulatedUnknowns != null) { + return accumulatedUnknowns; + } folder.computeResult = true; return result.eval(folder, frame); } @@ -129,6 +141,7 @@ private Object evalMap(Map iterRange, Folder folder, ExecutionFrame frame) private Object evalList(Collection iterRange, Folder folder, ExecutionFrame frame) throws CelEvaluationException { int index = 0; + AccumulatedUnknowns accumulatedUnknowns = null; for (Object item : iterRange) { frame.incrementIterations(); @@ -141,22 +154,33 @@ private Object evalList(Collection iterRange, Folder folder, ExecutionFrame f Object condResult = condition.eval(folder, frame); if (condResult instanceof AccumulatedUnknowns) { - return condResult; - } - if (!(condResult instanceof Boolean)) { - throw new IllegalArgumentException( - String.format("Expected boolean value, found :%s", condResult)); - } - boolean cond = (boolean) condResult; - if (!cond) { - folder.computeResult = true; - return maybeUnwrapAccumulator(result.eval(folder, frame)); + if (!frame.isAsync()) { + return condResult; + } + accumulatedUnknowns = AccumulatedUnknowns.maybeMerge(accumulatedUnknowns, condResult); + } else { + if (!(condResult instanceof Boolean)) { + throw new IllegalArgumentException( + String.format("Expected boolean value, found :%s", condResult)); + } + boolean cond = (boolean) condResult; + if (!cond) { + folder.computeResult = true; + return maybeUnwrapAccumulator(result.eval(folder, frame)); + } } - folder.accuVal = loopStep.eval(folder, frame); + Object stepResult = loopStep.eval(folder, frame); + if (stepResult instanceof AccumulatedUnknowns) { + accumulatedUnknowns = AccumulatedUnknowns.maybeMerge(accumulatedUnknowns, stepResult); + } + folder.accuVal = stepResult; folder.initialized = true; index++; } + if (frame.isAsync() && accumulatedUnknowns != null) { + return accumulatedUnknowns; + } folder.computeResult = true; return maybeUnwrapAccumulator(result.eval(folder, frame)); } diff --git a/runtime/src/main/java/dev/cel/runtime/planner/EvalHelpers.java b/runtime/src/main/java/dev/cel/runtime/planner/EvalHelpers.java index 1b8d61234..700c03216 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/EvalHelpers.java +++ b/runtime/src/main/java/dev/cel/runtime/planner/EvalHelpers.java @@ -106,7 +106,7 @@ static Object dispatch( * adapts any public {@link CelUnknownSet} instances into internal {@link AccumulatedUnknowns} for * AST evaluation. */ - private static Object convertAndAdaptResult(CelValueConverter valueConverter, Object result) { + static Object convertAndAdaptResult(CelValueConverter valueConverter, Object result) { return InterpreterUtil.maybeAdaptToAccumulatedUnknowns( valueConverter.maybeUnwrap(valueConverter.toRuntimeValue(result))); } diff --git a/runtime/src/main/java/dev/cel/runtime/planner/EvalLateBoundCall.java b/runtime/src/main/java/dev/cel/runtime/planner/EvalLateBoundCall.java index 719b4af21..c58766ea4 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/EvalLateBoundCall.java +++ b/runtime/src/main/java/dev/cel/runtime/planner/EvalLateBoundCall.java @@ -17,20 +17,23 @@ import static dev.cel.runtime.planner.EvalHelpers.evalStrictly; import com.google.common.collect.ImmutableList; +import com.google.errorprone.annotations.Immutable; import dev.cel.common.ast.CelExpr; import dev.cel.common.exceptions.CelOverloadNotFoundException; import dev.cel.common.values.CelValueConverter; import dev.cel.runtime.AccumulatedUnknowns; +import dev.cel.runtime.CelAsyncFunctionOverload; import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelResolvedOverload; import dev.cel.runtime.GlobalResolver; +@Immutable final class EvalLateBoundCall extends PlannedInterpretable { private final String functionName; private final ImmutableList overloadIds; - @SuppressWarnings("Immutable") + @SuppressWarnings("Immutable") // Array not mutated private final PlannedInterpretable[] args; private final CelValueConverter celValueConverter; @@ -56,6 +59,29 @@ Object evalInternal(GlobalResolver resolver, ExecutionFrame frame) throws CelEva .findOverload(functionName, overloadIds, argVals) .orElseThrow(() -> new CelOverloadNotFoundException(functionName, overloadIds)); + if (resolvedOverload.getDefinition() instanceof CelAsyncFunctionOverload) { + if (!frame.isAsync()) { + throw new CelEvaluationException( + String.format( + "Async function '%s' evaluated in synchronous mode. Asynchronous functions are only" + + " supported via evalAsync.", + functionName)); + } + return frame + .asyncTracker() + .recordOrGet( + expr().id(), + functionName, + resolvedOverload.getOverloadId(), + argVals, + (CelAsyncFunctionOverload) resolvedOverload.getDefinition(), + celValueConverter, + frame.asyncExecutor(), + frame.asyncGate(), + frame.asyncCoordinator(), + frame.asyncObserver().orElse(null)); + } + return EvalHelpers.dispatch(functionName, resolvedOverload, celValueConverter, argVals); } 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 b67f5520c..7ec71df18 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/ExecutionFrame.java +++ b/runtime/src/main/java/dev/cel/runtime/planner/ExecutionFrame.java @@ -14,8 +14,12 @@ package dev.cel.runtime.planner; +import static com.google.common.base.Preconditions.checkState; + +import com.google.common.util.concurrent.ListeningExecutorService; import dev.cel.common.CelOptions; import dev.cel.common.exceptions.CelIterationLimitExceededException; +import dev.cel.runtime.CelAsyncObserver; import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelEvaluationListener; import dev.cel.runtime.CelFunctionResolver; @@ -30,10 +34,15 @@ final class ExecutionFrame { private final int comprehensionIterationLimit; private final CelFunctionResolver functionResolver; - private final PartialVars partialVars; + private final @Nullable PartialVars partialVars; private final @Nullable CelEvaluationListener listener; + private final @Nullable AsyncCallStateTracker asyncTracker; + private final @Nullable AsyncGate asyncGate; + private final @Nullable AsyncCompletionCoordinator asyncCoordinator; + private final @Nullable ListeningExecutorService asyncExecutor; + private final @Nullable CelAsyncObserver asyncObserver; private int iterationCount; - private BlockMemoizer blockMemoizer; + private @Nullable BlockMemoizer blockMemoizer; Optional findOverload( String functionName, Collection overloadIds, Object[] args) @@ -70,7 +79,65 @@ static ExecutionFrame create( @Nullable PartialVars partialVars, @Nullable CelEvaluationListener listener) { return new ExecutionFrame( - functionResolver, celOptions.comprehensionMaxIterations(), partialVars, listener); + functionResolver, + celOptions.comprehensionMaxIterations(), + partialVars, + listener, + /* asyncTracker= */ null, + /* asyncGate= */ null, + /* asyncCoordinator= */ null, + /* asyncExecutor= */ null, + /* asyncObserver= */ null); + } + + static ExecutionFrame createForAsync( + CelFunctionResolver functionResolver, + CelOptions celOptions, + @Nullable PartialVars partialVars, + @Nullable CelEvaluationListener listener, + AsyncCallStateTracker asyncTracker, + AsyncGate asyncGate, + AsyncCompletionCoordinator asyncCoordinator, + ListeningExecutorService asyncExecutor, + @Nullable CelAsyncObserver asyncObserver) { + return new ExecutionFrame( + functionResolver, + celOptions.comprehensionMaxIterations(), + partialVars, + listener, + asyncTracker, + asyncGate, + asyncCoordinator, + asyncExecutor, + asyncObserver); + } + + boolean isAsync() { + return asyncTracker != null; + } + + AsyncCallStateTracker asyncTracker() { + checkState(asyncTracker != null, "Not in async execution mode"); + return asyncTracker; + } + + AsyncGate asyncGate() { + checkState(asyncGate != null, "Not in async execution mode"); + return asyncGate; + } + + AsyncCompletionCoordinator asyncCoordinator() { + checkState(asyncCoordinator != null, "Not in async execution mode"); + return asyncCoordinator; + } + + ListeningExecutorService asyncExecutor() { + checkState(asyncExecutor != null, "Not in async execution mode"); + return asyncExecutor; + } + + Optional asyncObserver() { + return Optional.ofNullable(asyncObserver); } Optional partialVars() { @@ -85,10 +152,20 @@ private ExecutionFrame( CelFunctionResolver functionResolver, int limit, @Nullable PartialVars partialVars, - @Nullable CelEvaluationListener listener) { + @Nullable CelEvaluationListener listener, + @Nullable AsyncCallStateTracker asyncTracker, + @Nullable AsyncGate asyncGate, + @Nullable AsyncCompletionCoordinator asyncCoordinator, + @Nullable ListeningExecutorService asyncExecutor, + @Nullable CelAsyncObserver asyncObserver) { this.comprehensionIterationLimit = limit; this.functionResolver = functionResolver; this.partialVars = partialVars; this.listener = listener; + this.asyncTracker = asyncTracker; + this.asyncGate = asyncGate; + this.asyncCoordinator = asyncCoordinator; + this.asyncExecutor = asyncExecutor; + this.asyncObserver = asyncObserver; } } diff --git a/runtime/src/main/java/dev/cel/runtime/planner/PlannedProgram.java b/runtime/src/main/java/dev/cel/runtime/planner/PlannedProgram.java index 1470e4909..420d842b4 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/PlannedProgram.java +++ b/runtime/src/main/java/dev/cel/runtime/planner/PlannedProgram.java @@ -14,13 +14,20 @@ package dev.cel.runtime.planner; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; + import com.google.auto.value.AutoValue; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.SettableFuture; import com.google.errorprone.annotations.Immutable; import dev.cel.common.CelOptions; import dev.cel.common.annotations.Internal; import dev.cel.common.exceptions.CelRuntimeException; import dev.cel.common.values.ErrorValue; +import dev.cel.runtime.AccumulatedUnknowns; import dev.cel.runtime.Activation; +import dev.cel.runtime.CelAsyncEvaluationOptions; import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelEvaluationExceptionBuilder; import dev.cel.runtime.CelEvaluationListener; @@ -31,6 +38,8 @@ import dev.cel.runtime.InterpreterUtil; import dev.cel.runtime.PartialVars; import dev.cel.runtime.Program; +import dev.cel.runtime.RuntimeEquality; +import dev.cel.runtime.RuntimeHelpers; import java.util.Collection; import java.util.Map; import java.util.Optional; @@ -46,6 +55,8 @@ @AutoValue public abstract class PlannedProgram implements Program { + PlannedProgram() {} + private static final CelFunctionResolver EMPTY_FUNCTION_RESOLVER = new CelFunctionResolver() { @Override @@ -160,6 +171,165 @@ public Object trace( return evalOrThrow(interpretable(), resolver, functionResolver, partialVars, listener); } + @Override + public ListenableFuture evalAsync( + GlobalResolver resolver, + CelFunctionResolver lateBoundResolver, + @Nullable PartialVars partialVars, + ListeningExecutorService executor, + CelAsyncEvaluationOptions asyncOptions) { + SettableFuture resultFuture = SettableFuture.create(); + RuntimeEquality runtimeEquality = RuntimeEquality.create(RuntimeHelpers.create(), options()); + AsyncCallStateTracker tracker = new AsyncCallStateTracker(runtimeEquality); + AsyncGate gate = new AsyncGate(asyncOptions.maxConcurrency()); + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(asyncOptions, gate, executor); + + resultFuture.addListener( + () -> { + if (resultFuture.isCancelled()) { + gate.cancel(); + coordinator.cancel(); + tracker.cancelInFlight(); + } + }, + directExecutor()); + + AsyncDriver driver = + new AsyncDriver( + interpretable(), + resolver, + lateBoundResolver, + partialVars, + asyncOptions, + tracker, + gate, + coordinator, + executor, + resultFuture); + driver.scheduleNextStep(); + return resultFuture; + } + + private final class AsyncDriver { + private final PlannedInterpretable interpretable; + private final GlobalResolver resolver; + private final CelFunctionResolver lateBoundResolver; + private final @Nullable PartialVars partialVars; + private final CelAsyncEvaluationOptions options; + private final AsyncCallStateTracker tracker; + private final AsyncGate gate; + private final AsyncCompletionCoordinator coordinator; + private final ListeningExecutorService executor; + private final SettableFuture resultFuture; + private int iterationCount = 0; + + AsyncDriver( + PlannedInterpretable interpretable, + GlobalResolver resolver, + CelFunctionResolver lateBoundResolver, + @Nullable PartialVars partialVars, + CelAsyncEvaluationOptions options, + AsyncCallStateTracker tracker, + AsyncGate gate, + AsyncCompletionCoordinator coordinator, + ListeningExecutorService executor, + SettableFuture resultFuture) { + this.interpretable = interpretable; + this.resolver = resolver; + this.lateBoundResolver = lateBoundResolver; + this.partialVars = partialVars; + this.options = options; + this.tracker = tracker; + this.gate = gate; + this.coordinator = coordinator; + this.executor = executor; + this.resultFuture = resultFuture; + } + + void scheduleNextStep() { + if (resultFuture.isDone()) { + return; + } + executor.execute(this::step); + } + + private void step() { + if (resultFuture.isDone()) { + return; + } + + if (options.maxIterations() >= 0 && ++iterationCount > options.maxIterations()) { + cancelAll(); + resultFuture.setException( + new CelEvaluationException( + "Exceeded maximum async evaluation iterations: " + options.maxIterations())); + return; + } + + Object evalResult; + try { + ExecutionFrame frame = + ExecutionFrame.createForAsync( + lateBoundResolver, + options(), + partialVars, + /* listener= */ null, + tracker, + gate, + coordinator, + executor, + options.observer().orElse(null)); + evalResult = interpretable.eval(resolver, frame); + } catch (Exception e) { + cancelAll(); + resultFuture.setException(newCelEvaluationException(interpretable.expr().id(), e)); + return; + } + + if (evalResult instanceof ErrorValue) { + cancelAll(); + ErrorValue errorValue = (ErrorValue) evalResult; + resultFuture.setException( + newCelEvaluationException(errorValue.exprId(), errorValue.value())); + return; + } + + if (evalResult instanceof AccumulatedUnknowns) { + AccumulatedUnknowns unknowns = (AccumulatedUnknowns) evalResult; + if (!unknowns.hasCallIds()) { + cancelAll(); + resultFuture.set(InterpreterUtil.maybeAdaptToCelUnknownSet(evalResult)); + return; + } + + // Defensive fail-safe invariant: fail evaluation if unresolved async calls remain but + // concurrency tracking indicates zero in-flight or queued work. + if (gate.activeCount() == 0 + && !tracker.hasInFlightCalls() + && !coordinator.hasPendingBatch()) { + cancelAll(); + resultFuture.setException( + new CelEvaluationException( + "Asynchronous evaluation stalled: unresolved async calls remain but no tasks are" + + " in-flight.")); + return; + } + + coordinator.waitForCompletions(this::scheduleNextStep); + return; + } + + cancelAll(); + resultFuture.set(InterpreterUtil.maybeAdaptToCelUnknownSet(evalResult)); + } + + private void cancelAll() { + gate.cancel(); + tracker.cancelInFlight(); + } + } + private CelEvaluationException newCelEvaluationException(long exprId, Exception e) { CelEvaluationExceptionBuilder builder; if (e instanceof LocalizedEvaluationException) { @@ -187,7 +357,7 @@ private CelEvaluationException newCelEvaluationException(long exprId, Exception return builder.setMetadata(metadata(), exprId).build(); } - static Program create( + static PlannedProgram create( PlannedInterpretable interpretable, ErrorMetadata metadata, CelOptions options) { return new AutoValue_PlannedProgram(interpretable, metadata, options); } diff --git a/runtime/src/main/java/dev/cel/runtime/planner/ProgramPlanner.java b/runtime/src/main/java/dev/cel/runtime/planner/ProgramPlanner.java index 23a6e5dec..98e858579 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/ProgramPlanner.java +++ b/runtime/src/main/java/dev/cel/runtime/planner/ProgramPlanner.java @@ -47,11 +47,13 @@ import dev.cel.common.types.TypeType; import dev.cel.common.values.CelValueConverter; import dev.cel.common.values.CelValueProvider; +import dev.cel.runtime.CelAsyncFunctionOverload; import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelEvaluationExceptionBuilder; import dev.cel.runtime.CelResolvedOverload; import dev.cel.runtime.DefaultDispatcher; import dev.cel.runtime.Program; +import java.util.Arrays; import java.util.HashMap; import java.util.NoSuchElementException; import java.util.Optional; @@ -77,7 +79,7 @@ public final class ProgramPlanner { * Plans a {@link Program} from the provided parsed-only or type-checked {@link * CelAbstractSyntaxTree}. */ - public Program plan(CelAbstractSyntaxTree ast) throws CelEvaluationException { + public PlannedProgram plan(CelAbstractSyntaxTree ast) throws CelEvaluationException { PlannedInterpretable plannedInterpretable; ErrorMetadata errorMetadata = ErrorMetadata.create(ast.getSource().getPositionsMap(), ast.getSource().getDescription()); @@ -117,9 +119,8 @@ private PlannedInterpretable plan(CelExpr celExpr, PlannerContext ctx) { return planComprehension(celExpr, ctx); case NOT_SET: throw new UnsupportedOperationException("Unsupported kind: " + celExpr.getKind()); - default: - throw new UnsupportedOperationException("Unexpected kind: " + celExpr.getKind()); } + throw new UnsupportedOperationException("Unexpected kind: " + celExpr.getKind()); } private PlannedInterpretable planSelect(CelExpr celExpr, PlannerContext ctx) { @@ -320,6 +321,16 @@ private PlannedInterpretable planCall(CelExpr expr, PlannerContext ctx) { expr, functionName, overloadIds, evaluatedArgs, celValueConverter); } + if (resolvedOverload.getDefinition() instanceof CelAsyncFunctionOverload) { + return EvalAsyncCall.create( + expr, + functionName, + resolvedOverload.getOverloadId(), + (CelAsyncFunctionOverload) resolvedOverload.getDefinition(), + evaluatedArgs, + celValueConverter); + } + switch (argCount) { case 0: return EvalZeroArity.create(expr, functionName, resolvedOverload, celValueConverter); @@ -353,9 +364,7 @@ private PlannedInterpretable planBlock(CelBlock celBlock, PlannerContext ctx) { ImmutableList indices = celBlock.indices(); PlannedInterpretable[] slotExprs = new PlannedInterpretable[indices.size()]; - for (int i = 0; i < slotExprs.length; i++) { - slotExprs[i] = plan(indices.get(i), ctx); - } + Arrays.setAll(slotExprs, i -> plan(indices.get(i), ctx)); PlannedInterpretable resultExpr = plan(celBlock.result(), ctx); return EvalBlock.create(celBlock.expr(), slotExprs, resultExpr); } diff --git a/runtime/src/test/java/dev/cel/runtime/AccumulatedUnknownsTest.java b/runtime/src/test/java/dev/cel/runtime/AccumulatedUnknownsTest.java new file mode 100644 index 000000000..1cf5e7c90 --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/AccumulatedUnknownsTest.java @@ -0,0 +1,103 @@ +// 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; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import java.util.Set; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class AccumulatedUnknownsTest { + + @Test + public void createForAsyncCall_success() { + AccumulatedUnknowns unknowns = AccumulatedUnknowns.createForAsyncCall(42L); + + assertThat(unknowns.hasCallIds()).isTrue(); + assertThat(unknowns.callIds()).containsExactly(42L); + assertThat(unknowns.exprIds()).isEmpty(); + assertThat(unknowns.attributes()).isEmpty(); + } + + @Test + public void callIds_returnsUnmodifiableSet() { + AccumulatedUnknowns unknowns = AccumulatedUnknowns.createForAsyncCall(42L); + Set callIds = unknowns.callIds(); + + assertThrows(UnsupportedOperationException.class, () -> callIds.add(99L)); + } + + @Test + public void merge_mergesCallIdsAndExprIdsAndAttributes() { + AccumulatedUnknowns u1 = + AccumulatedUnknowns.create(ImmutableList.of(1L), ImmutableList.of(CelAttribute.EMPTY)); + u1.merge(AccumulatedUnknowns.createForAsyncCall(100L)); + + AccumulatedUnknowns u2 = AccumulatedUnknowns.create(ImmutableList.of(2L), ImmutableList.of()); + u2.merge(AccumulatedUnknowns.createForAsyncCall(200L)); + + AccumulatedUnknowns merged = u1.merge(u2); + + assertThat(merged).isSameInstanceAs(u1); + assertThat(merged.exprIds()).containsExactly(1L, 2L); + assertThat(merged.attributes()).containsExactly(CelAttribute.EMPTY); + assertThat(merged.callIds()).containsExactly(100L, 200L); + assertThat(merged.hasCallIds()).isTrue(); + } + + @Test + public void maybeMerge_withNullAccumulator_returnsNewUnknowns() { + AccumulatedUnknowns u = AccumulatedUnknowns.createForAsyncCall(1L); + + AccumulatedUnknowns result = AccumulatedUnknowns.maybeMerge(null, u); + + assertThat(result).isSameInstanceAs(u); + } + + @Test + public void maybeMerge_withExistingAccumulator_mergesBoth() { + AccumulatedUnknowns u1 = AccumulatedUnknowns.createForAsyncCall(1L); + AccumulatedUnknowns u2 = AccumulatedUnknowns.createForAsyncCall(2L); + + AccumulatedUnknowns result = AccumulatedUnknowns.maybeMerge(u1, u2); + + assertThat(result).isSameInstanceAs(u1); + assertThat(result.callIds()).containsExactly(1L, 2L); + } + + @Test + public void maybeMerge_withNonUnknownObject_returnsOriginalAccumulator() { + AccumulatedUnknowns u = AccumulatedUnknowns.createForAsyncCall(1L); + + AccumulatedUnknowns result = AccumulatedUnknowns.maybeMerge(u, "not an unknown"); + + assertThat(result).isSameInstanceAs(u); + assertThat(result.callIds()).containsExactly(1L); + } + + @Test + public void create_varargsAndCollections() { + AccumulatedUnknowns u = AccumulatedUnknowns.create(10L, 20L); + + assertThat(u.exprIds()).containsExactly(10L, 20L); + assertThat(u.attributes()).isEmpty(); + assertThat(u.hasCallIds()).isFalse(); + } +} diff --git a/runtime/src/test/java/dev/cel/runtime/BUILD.bazel b/runtime/src/test/java/dev/cel/runtime/BUILD.bazel index a2e44223a..0f05dc5a0 100644 --- a/runtime/src/test/java/dev/cel/runtime/BUILD.bazel +++ b/runtime/src/test/java/dev/cel/runtime/BUILD.bazel @@ -43,6 +43,7 @@ java_library( "//common/exceptions:bad_format", "//common/exceptions:divide_by_zero", "//common/exceptions:numeric_overflow", + "//common/exceptions:overload_not_found", "//common/exceptions:runtime_exception", "//common/internal:cel_descriptor_pools", "//common/internal:converter", @@ -64,6 +65,7 @@ java_library( "//parser:macro", "//parser:unparser", "//runtime", + "//runtime:accumulated_unknowns", "//runtime:activation", "//runtime:dispatcher", "//runtime:evaluation_exception_builder", @@ -71,7 +73,6 @@ java_library( "//runtime:function_binding", "//runtime:interpretable", "//runtime:interpreter", - "//runtime:interpreter_util", "//runtime:late_function_binding", "//runtime:lite_runtime", "//runtime:lite_runtime_factory", diff --git a/runtime/src/test/java/dev/cel/runtime/CelAsyncDrainStrategyTest.java b/runtime/src/test/java/dev/cel/runtime/CelAsyncDrainStrategyTest.java new file mode 100644 index 000000000..b78ec1beb --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/CelAsyncDrainStrategyTest.java @@ -0,0 +1,196 @@ +// 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; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import java.time.Duration; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CelAsyncDrainStrategyTest { + + private static final CelAsyncCall DUMMY_CALL = + new CelAsyncCall() { + @Override + public long callId() { + return 1L; + } + + @Override + public long exprId() { + return 10L; + } + + @Override + public String functionName() { + return "fn"; + } + + @Override + public String overloadId() { + return "fn_overload"; + } + + @Override + public ImmutableList arguments() { + return ImmutableList.of(); + } + + @Override + public Duration elapsedDuration() { + return Duration.ZERO; + } + }; + + @Test + public void drainReady_defaultDebounce_noActiveCalls_reevaluates() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainReady(); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(), 0); + + assertThat(action.shouldReevaluate()).isTrue(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainReady_defaultDebounce_emptyBatchWithActiveCalls_waitsForMore() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainReady(); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(), 3); + + assertThat(action.shouldReevaluate()).isFalse(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainReady_defaultDebounce_hasBatchWithActiveCalls_waitsDefaultDuration() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainReady(); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(DUMMY_CALL), 2); + + assertThat(action.shouldReevaluate()).isFalse(); + assertThat(action.waitDuration()).isEqualTo(Duration.ofNanos(100_000)); + } + + @Test + public void drainReady_zeroDebounce_alwaysReevaluates() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainReady(Duration.ZERO); + + CelAsyncDrainAction actionWithActive = strategy.nextAction(ImmutableList.of(DUMMY_CALL), 5); + + assertThat(actionWithActive.shouldReevaluate()).isTrue(); + assertThat(actionWithActive.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainReady_withDebounce_activeZero_reevaluates() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainReady(Duration.ofMillis(50)); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(), 0); + + assertThat(action.shouldReevaluate()).isTrue(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainReady_withDebounce_emptyBatchAndActiveCalls_waitsForMore() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainReady(Duration.ofMillis(50)); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(), 3); + + assertThat(action.shouldReevaluate()).isFalse(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainReady_withDebounce_hasCompletedBatchAndActiveCalls_waitsDuration() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainReady(Duration.ofMillis(50)); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(DUMMY_CALL), 2); + + assertThat(action.shouldReevaluate()).isFalse(); + assertThat(action.waitDuration()).isEqualTo(Duration.ofMillis(50)); + } + + @Test + public void drainNone_behavior() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainNone(); + + assertThat(strategy.nextAction(ImmutableList.of(), 0).shouldReevaluate()).isTrue(); + assertThat(strategy.nextAction(ImmutableList.of(DUMMY_CALL), 2).shouldReevaluate()).isTrue(); + assertThat(strategy.nextAction(ImmutableList.of(), 2).shouldReevaluate()).isFalse(); + } + + @Test + public void drainAll_behavior() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainAll(); + + assertThat(strategy.nextAction(ImmutableList.of(), 0).shouldReevaluate()).isTrue(); + assertThat(strategy.nextAction(ImmutableList.of(DUMMY_CALL), 0).shouldReevaluate()).isTrue(); + assertThat(strategy.nextAction(ImmutableList.of(DUMMY_CALL), 1).shouldReevaluate()).isFalse(); + assertThat(strategy.nextAction(ImmutableList.of(), 1).shouldReevaluate()).isFalse(); + } + + @Test + public void drainReady_negativeDebounce_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> CelAsyncDrainStrategy.drainReady(Duration.ofMillis(-1))); + } + + @Test + public void drainAction_reevaluate() { + CelAsyncDrainAction action = CelAsyncDrainAction.reevaluate(); + + assertThat(action.shouldReevaluate()).isTrue(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainAction_waitForMore() { + CelAsyncDrainAction action = CelAsyncDrainAction.waitForMore(); + + assertThat(action.shouldReevaluate()).isFalse(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainAction_waitDuration_success() { + CelAsyncDrainAction action = CelAsyncDrainAction.waitDuration(Duration.ofSeconds(2)); + + assertThat(action.shouldReevaluate()).isFalse(); + assertThat(action.waitDuration()).isEqualTo(Duration.ofSeconds(2)); + } + + @Test + public void drainAction_waitZero_reevaluates() { + CelAsyncDrainAction action = CelAsyncDrainAction.waitDuration(Duration.ZERO); + + assertThat(action.shouldReevaluate()).isTrue(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainAction_waitDuration_negative_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> CelAsyncDrainAction.waitDuration(Duration.ofMillis(-5))); + } +} diff --git a/runtime/src/test/java/dev/cel/runtime/CelAsyncEvaluationOptionsTest.java b/runtime/src/test/java/dev/cel/runtime/CelAsyncEvaluationOptionsTest.java new file mode 100644 index 000000000..891fe4b48 --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/CelAsyncEvaluationOptionsTest.java @@ -0,0 +1,93 @@ +// 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; + +import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.Assert.assertThrows; + +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import java.time.Duration; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import org.jspecify.annotations.Nullable; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class CelAsyncEvaluationOptionsTest { + + @Test + public void defaultOptions_valuesAndDaemonScheduler() throws Exception { + CelAsyncEvaluationOptions options = CelAsyncEvaluationOptions.defaultOptions(); + + assertThat(options.maxConcurrency()).isEqualTo(100); + assertThat(options.maxIterations()).isEqualTo(1_000); + assertThat(options.drainStrategy()).isNotNull(); + assertThat(options.observer()).isEmpty(); + assertThat(options.resolveScheduledExecutorService()).isNotNull(); + + Future isDaemonFuture = + options.resolveScheduledExecutorService().submit(() -> Thread.currentThread().isDaemon()); + assertThat(isDaemonFuture.get(5, SECONDS)).isTrue(); + } + + @Test + public void builder_validations() { + CelAsyncEvaluationOptions.Builder builder = CelAsyncEvaluationOptions.builder(); + + assertThrows(NullPointerException.class, () -> builder.setDrainStrategy(null)); + assertThrows(NullPointerException.class, () -> builder.setObserver(null)); + assertThrows(NullPointerException.class, () -> builder.setScheduledExecutorService(null)); + } + + @Test + public void builder_customValuesAndRoundTrip() { + CelAsyncDrainStrategy drainStrategy = CelAsyncDrainStrategy.drainReady(Duration.ofMillis(25)); + CelAsyncObserver observer = + new CelAsyncObserver() { + @Override + public void onCallStarted(CelAsyncCall call) {} + + @Override + public void onCallFinished( + CelAsyncCall call, @Nullable Object result, @Nullable Throwable error) {} + }; + ScheduledExecutorService customScheduler = Executors.newSingleThreadScheduledExecutor(); + + try { + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder() + .setMaxConcurrency(8) + .setMaxIterations(50) + .setDrainStrategy(drainStrategy) + .setObserver(observer) + .setScheduledExecutorService(customScheduler) + .build(); + + assertThat(options.maxConcurrency()).isEqualTo(8); + assertThat(options.maxIterations()).isEqualTo(50); + assertThat(options.drainStrategy()).isSameInstanceAs(drainStrategy); + assertThat(options.observer()).hasValue(observer); + assertThat(options.resolveScheduledExecutorService()).isSameInstanceAs(customScheduler); + + CelAsyncEvaluationOptions copy = options.toBuilder().build(); + assertThat(copy).isEqualTo(options); + } finally { + customScheduler.shutdown(); + } + } +} diff --git a/runtime/src/test/java/dev/cel/runtime/CelRuntimeLegacyImplTest.java b/runtime/src/test/java/dev/cel/runtime/CelRuntimeLegacyImplTest.java index fec5fab41..5957f3c77 100644 --- a/runtime/src/test/java/dev/cel/runtime/CelRuntimeLegacyImplTest.java +++ b/runtime/src/test/java/dev/cel/runtime/CelRuntimeLegacyImplTest.java @@ -15,7 +15,10 @@ package dev.cel.runtime; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService; +import static org.junit.Assert.assertThrows; +import com.google.common.util.concurrent.ListeningExecutorService; import com.google.protobuf.Message; import dev.cel.common.CelException; import dev.cel.common.exceptions.CelDivideByZeroException; @@ -24,7 +27,6 @@ import dev.cel.expr.conformance.proto3.TestAllTypes; import dev.cel.runtime.CelStandardFunctions.StandardFunction; import java.util.function.Function; -import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -37,7 +39,7 @@ public void evalException() throws CelException { CelCompiler compiler = CelCompilerFactory.standardCelCompilerBuilder().build(); CelRuntime runtime = CelRuntimeFactory.standardCelRuntimeBuilder().build(); CelRuntime.Program program = runtime.createProgram(compiler.compile("1/0").getAst()); - CelEvaluationException e = Assert.assertThrows(CelEvaluationException.class, program::eval); + CelEvaluationException e = assertThrows(CelEvaluationException.class, program::eval); assertThat(e).hasCauseThat().isInstanceOf(CelDivideByZeroException.class); } @@ -120,4 +122,15 @@ public void toRuntimeBuilder_optionalProperties() { assertThat(newRuntimeBuilder.overriddenStandardFunctions) .isEqualTo(overriddenStandardFunctions); } + + @Test + public void evalAsync_legacyInterpreter_throwsUnsupportedOperationException() throws Exception { + CelCompiler compiler = CelCompilerFactory.standardCelCompilerBuilder().build(); + CelRuntime runtime = CelRuntimeFactory.standardCelRuntimeBuilder().build(); + CelRuntime.Program program = runtime.createProgram(compiler.compile("1 + 1").getAst()); + + ListeningExecutorService executor = newDirectExecutorService(); + + assertThrows(UnsupportedOperationException.class, () -> program.evalAsync(executor)); + } } diff --git a/runtime/src/test/java/dev/cel/runtime/FunctionBindingImplTest.java b/runtime/src/test/java/dev/cel/runtime/FunctionBindingImplTest.java new file mode 100644 index 000000000..4072e59d3 --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/FunctionBindingImplTest.java @@ -0,0 +1,442 @@ +// 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; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.util.concurrent.Futures.immediateFuture; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import dev.cel.common.exceptions.CelOverloadNotFoundException; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class FunctionBindingImplTest { + + @Test + public void toListenableFuture_success_setsResult() throws Exception { + CompletableFuture cf = new CompletableFuture<>(); + ListenableFuture future = FunctionBindingImpl.toListenableFuture(cf); + + cf.complete("success"); + + assertThat(future.get()).isEqualTo("success"); + } + + @Test + public void toListenableFuture_completionExceptionWithCause_unwrapsCause() { + CompletableFuture cf = new CompletableFuture<>(); + ListenableFuture future = FunctionBindingImpl.toListenableFuture(cf); + IllegalArgumentException cause = new IllegalArgumentException("unwrapped cause"); + + cf.completeExceptionally(new CompletionException(cause)); + + ExecutionException e = assertThrows(ExecutionException.class, future::get); + assertThat(e).hasCauseThat().isSameInstanceAs(cause); + } + + @Test + public void toListenableFuture_throwableWithoutCause_setsExceptionDirectly() { + CompletableFuture cf = new CompletableFuture<>(); + ListenableFuture future = FunctionBindingImpl.toListenableFuture(cf); + IllegalArgumentException cause = new IllegalArgumentException("direct throwable"); + + cf.completeExceptionally(cause); + + ExecutionException e = assertThrows(ExecutionException.class, future::get); + assertThat(e).hasCauseThat().isSameInstanceAs(cause); + } + + @Test + public void toListenableFuture_cancellation_propagatesToCompletableFuture() { + CompletableFuture cf = new CompletableFuture<>(); + ListenableFuture future = FunctionBindingImpl.toListenableFuture(cf); + + future.cancel(false); + + assertThat(cf.isCancelled()).isTrue(); + } + + @Test + public void toListenableFuture_completableFutureCancelled_cancelsListenableFuture() { + CompletableFuture cf = new CompletableFuture<>(); + ListenableFuture future = FunctionBindingImpl.toListenableFuture(cf); + + cf.cancel(false); + + assertThat(future.isCancelled()).isTrue(); + } + + @Test + public void toListenableFuture_chainedCompletableFutureCancelled_cancelsListenableFuture() { + CompletableFuture upstream = new CompletableFuture<>(); + CompletableFuture chained = upstream.thenApply(s -> s + "!"); + ListenableFuture future = FunctionBindingImpl.toListenableFuture(chained); + + upstream.cancel(false); + + assertThat(future.isCancelled()).isTrue(); + } + + @Test + public void toListenableFuture_completionExceptionWrappingCancellation_cancelsListenableFuture() { + CompletableFuture cf = new CompletableFuture<>(); + ListenableFuture future = FunctionBindingImpl.toListenableFuture(cf); + + cf.completeExceptionally(new CompletionException(new CancellationException())); + + assertThat(future.isCancelled()).isTrue(); + } + + @Test + public void toListenableFuture_multiLayerCompletionException_unwrapsCause() { + CompletableFuture cf = new CompletableFuture<>(); + ListenableFuture future = FunctionBindingImpl.toListenableFuture(cf); + IllegalArgumentException cause = new IllegalArgumentException("deeply nested cause"); + + cf.completeExceptionally(new CompletionException(new CompletionException(cause))); + + ExecutionException e = assertThrows(ExecutionException.class, future::get); + assertThat(e).hasCauseThat().isSameInstanceAs(cause); + } + + @Test + public void + toListenableFuture_multiLayerCompletionExceptionWrappingCancellation_cancelsListenableFuture() { + CompletableFuture cf = new CompletableFuture<>(); + ListenableFuture future = FunctionBindingImpl.toListenableFuture(cf); + + cf.completeExceptionally( + new CompletionException(new CompletionException(new CancellationException()))); + + assertThat(future.isCancelled()).isTrue(); + } + + @Test + public void toListenableFuture_null_returnsNull() { + assertThat(FunctionBindingImpl.toListenableFuture(null)).isNull(); + } + + @Test + public void dynamicDispatch_unaryNonOptimizedOverload_invokesArrayApply() throws Exception { + CelFunctionBinding b1 = + CelFunctionBinding.from( + "custom_int", + ImmutableList.of(Long.class), + (CelFunctionOverload) (Object[] args) -> ((Long) args[0]) * 2L); + CelFunctionBinding b2 = + CelFunctionBinding.from( + "custom_string", + ImmutableList.of(String.class), + (CelFunctionOverload) (Object[] args) -> args[0] + "!"); + ImmutableSet bindings = + FunctionBindingImpl.groupOverloadsToFunction("custom", ImmutableSet.of(b1, b2)); + OptimizedFunctionOverload overload = + (OptimizedFunctionOverload) + Iterables.find(bindings, b -> b.getOverloadId().equals("custom")).getDefinition(); + + assertThat(overload.apply(21L)).isEqualTo(42L); + assertThat(overload.apply("hello")).isEqualTo("hello!"); + assertThrows(CelOverloadNotFoundException.class, () -> overload.apply(true)); + } + + @Test + public void dynamicDispatch_binaryNonOptimizedOverload_invokesArrayApply() throws Exception { + CelFunctionBinding b1 = + CelFunctionBinding.from( + "custom_add_int_int", + ImmutableList.of(Long.class, Long.class), + (CelFunctionOverload) (Object[] args) -> ((Long) args[0]) + ((Long) args[1])); + CelFunctionBinding b2 = + CelFunctionBinding.from( + "custom_add_string_string", + ImmutableList.of(String.class, String.class), + (CelFunctionOverload) (Object[] args) -> (String) args[0] + (String) args[1]); + ImmutableSet bindings = + FunctionBindingImpl.groupOverloadsToFunction("custom_add", ImmutableSet.of(b1, b2)); + OptimizedFunctionOverload overload = + (OptimizedFunctionOverload) + Iterables.find(bindings, b -> b.getOverloadId().equals("custom_add")).getDefinition(); + + assertThat(overload.apply(10L, 20L)).isEqualTo(30L); + assertThat(overload.apply("foo", "bar")).isEqualTo("foobar"); + assertThrows(CelOverloadNotFoundException.class, () -> overload.apply(10L, "bar")); + } + + @Test + public void dynamicDispatch_unaryOptimizedOverload_invokesOptimizedUnaryApply() throws Exception { + OptimizedFunctionOverload mockOverload = + new OptimizedFunctionOverload() { + @Override + public Object apply(Object arg) { + return (Long) arg * 10L; + } + + @Override + public Object apply(Object[] args) { + throw new AssertionError("Should not invoke array apply for unary optimized overload!"); + } + }; + CelFunctionBinding b1 = + CelFunctionBinding.from( + "custom_opt_unary_long", ImmutableList.of(Long.class), mockOverload); + CelFunctionBinding b2 = + CelFunctionBinding.from( + "custom_opt_unary_str", + ImmutableList.of(String.class), + (CelFunctionOverload) (Object[] args) -> args[0] + "!"); + ImmutableSet bindings = + FunctionBindingImpl.groupOverloadsToFunction("custom_opt_unary", ImmutableSet.of(b1, b2)); + OptimizedFunctionOverload overload = + (OptimizedFunctionOverload) + Iterables.find(bindings, b -> b.getOverloadId().equals("custom_opt_unary")) + .getDefinition(); + + assertThat(overload.apply(5L)).isEqualTo(50L); + assertThat(overload.apply("test")).isEqualTo("test!"); + } + + @Test + public void dynamicDispatch_binaryOptimizedOverload_invokesOptimizedBinaryApply() + throws Exception { + OptimizedFunctionOverload mockOverload = + new OptimizedFunctionOverload() { + @Override + public Object apply(Object arg1, Object arg2) { + return (Long) arg1 + (Long) arg2 + 100L; + } + + @Override + public Object apply(Object[] args) { + throw new AssertionError( + "Should not invoke array apply for binary optimized overload!"); + } + }; + CelFunctionBinding b1 = + CelFunctionBinding.from( + "custom_opt_binary_long", ImmutableList.of(Long.class, Long.class), mockOverload); + CelFunctionBinding b2 = + CelFunctionBinding.from( + "custom_opt_bin_str", + ImmutableList.of(String.class, String.class), + (CelFunctionOverload) (Object[] args) -> (String) args[0] + (String) args[1]); + ImmutableSet bindings = + FunctionBindingImpl.groupOverloadsToFunction("custom_opt_binary", ImmutableSet.of(b1, b2)); + OptimizedFunctionOverload overload = + (OptimizedFunctionOverload) + Iterables.find(bindings, b -> b.getOverloadId().equals("custom_opt_binary")) + .getDefinition(); + + assertThat(overload.apply(10L, 20L)).isEqualTo(130L); + assertThat(overload.apply("foo", "bar")).isEqualTo("foobar"); + } + + @Test + public void fromCompletableFuture_unary_invokesCompletableFutureAndAdapts() throws Exception { + CelFunctionBinding binding = + CelFunctionBinding.fromCompletableFuture( + "unary_cf", Long.class, (Long arg) -> CompletableFuture.completedFuture(arg * 2L)); + CelAsyncFunctionOverload overload = (CelAsyncFunctionOverload) binding.getDefinition(); + + assertThat(binding.getOverloadId()).isEqualTo("unary_cf"); + assertThat(binding.getArgTypes()).containsExactly(Long.class); + assertThat(binding.isStrict()).isTrue(); + assertThat(binding.getDefinition()).isInstanceOf(CelAsyncFunctionOverload.class); + assertThat(overload.applyAsync(21L).get(5, SECONDS)).isEqualTo(42L); + assertThat(overload.applyAsync(new Object[] {21L}).get(5, SECONDS)).isEqualTo(42L); + } + + @Test + public void fromCompletableFuture_binary_invokesCompletableFutureAndAdapts() throws Exception { + CelFunctionBinding binding = + CelFunctionBinding.fromCompletableFuture( + "binary_cf", + Long.class, + String.class, + (Long a, String b) -> CompletableFuture.completedFuture(b + a)); + CelAsyncFunctionOverload overload = (CelAsyncFunctionOverload) binding.getDefinition(); + + assertThat(binding.getOverloadId()).isEqualTo("binary_cf"); + assertThat(binding.getArgTypes()).containsExactly(Long.class, String.class).inOrder(); + assertThat(binding.isStrict()).isTrue(); + assertThat(binding.getDefinition()).isInstanceOf(CelAsyncFunctionOverload.class); + assertThat(overload.applyAsync(5L, "val:").get(5, SECONDS)).isEqualTo("val:5"); + assertThat(overload.applyAsync(new Object[] {5L, "val:"}).get(5, SECONDS)).isEqualTo("val:5"); + } + + @Test + public void fromCompletableFuture_nullArguments_throwsNullPointerException() { + assertThrows( + NullPointerException.class, + () -> + CelFunctionBinding.fromCompletableFuture( + null, Long.class, (Long x) -> CompletableFuture.completedFuture(x))); + assertThrows( + NullPointerException.class, + () -> + CelFunctionBinding.fromCompletableFuture( + "id", null, (Long x) -> CompletableFuture.completedFuture(x))); + assertThrows( + NullPointerException.class, + () -> CelFunctionBinding.fromCompletableFuture("id", Long.class, null)); + + assertThrows( + NullPointerException.class, + () -> + CelFunctionBinding.fromCompletableFuture( + null, Long.class, String.class, (a, b) -> CompletableFuture.completedFuture(a))); + assertThrows( + NullPointerException.class, + () -> + CelFunctionBinding.fromCompletableFuture( + "id", null, String.class, (a, b) -> CompletableFuture.completedFuture(a))); + assertThrows( + NullPointerException.class, + () -> + CelFunctionBinding.fromCompletableFuture( + "id", Long.class, null, (a, b) -> CompletableFuture.completedFuture(a))); + assertThrows( + NullPointerException.class, + () -> CelFunctionBinding.fromCompletableFuture("id", Long.class, String.class, null)); + } + + @Test + public void fromAsync_unary_invokesAsyncAndAdapts() throws Exception { + CelFunctionBinding binding = + CelFunctionBinding.fromAsync( + "unary_async", Long.class, (Long arg) -> immediateFuture(arg * 3L)); + CelAsyncFunctionOverload overload = (CelAsyncFunctionOverload) binding.getDefinition(); + + assertThat(binding.getOverloadId()).isEqualTo("unary_async"); + assertThat(binding.getArgTypes()).containsExactly(Long.class); + assertThat(binding.isStrict()).isTrue(); + assertThat(binding.getDefinition()).isInstanceOf(CelAsyncFunctionOverload.class); + assertThat(overload.applyAsync(10L).get(5, SECONDS)).isEqualTo(30L); + assertThat(overload.applyAsync(new Object[] {10L}).get(5, SECONDS)).isEqualTo(30L); + } + + @Test + public void fromAsync_binary_invokesAsyncAndAdapts() throws Exception { + CelFunctionBinding binding = + CelFunctionBinding.fromAsync( + "binary_async", Long.class, Long.class, (Long a, Long b) -> immediateFuture(a + b)); + CelAsyncFunctionOverload overload = (CelAsyncFunctionOverload) binding.getDefinition(); + + assertThat(binding.getOverloadId()).isEqualTo("binary_async"); + assertThat(binding.getArgTypes()).containsExactly(Long.class, Long.class).inOrder(); + assertThat(binding.isStrict()).isTrue(); + assertThat(binding.getDefinition()).isInstanceOf(CelAsyncFunctionOverload.class); + assertThat(overload.applyAsync(15L, 25L).get(5, SECONDS)).isEqualTo(40L); + assertThat(overload.applyAsync(new Object[] {15L, 25L}).get(5, SECONDS)).isEqualTo(40L); + } + + @Test + public void fromAsync_nullArguments_throwsNullPointerException() { + assertThrows( + NullPointerException.class, + () -> CelFunctionBinding.fromAsync(null, Long.class, (Long x) -> immediateFuture(x))); + assertThrows( + NullPointerException.class, + () -> CelFunctionBinding.fromAsync("id", null, (Long x) -> immediateFuture(x))); + assertThrows( + NullPointerException.class, + () -> + CelFunctionBinding.fromAsync( + "id", Long.class, (CelAsyncFunctionOverload.Unary) null)); + + assertThrows( + NullPointerException.class, + () -> + CelFunctionBinding.fromAsync( + null, Long.class, String.class, (a, b) -> immediateFuture(a))); + assertThrows( + NullPointerException.class, + () -> CelFunctionBinding.fromAsync("id", null, String.class, (a, b) -> immediateFuture(a))); + assertThrows( + NullPointerException.class, + () -> CelFunctionBinding.fromAsync("id", Long.class, null, (a, b) -> immediateFuture(a))); + assertThrows( + NullPointerException.class, + () -> + CelFunctionBinding.fromAsync( + "id", + Long.class, + String.class, + (CelAsyncFunctionOverload.Binary) null)); + + assertThrows( + NullPointerException.class, + () -> + CelFunctionBinding.fromAsync( + null, ImmutableList.of(Long.class), args -> immediateFuture(1L))); + assertThrows( + NullPointerException.class, + () -> + CelFunctionBinding.fromAsync( + "id", (Iterable>) null, args -> immediateFuture(1L))); + assertThrows( + NullPointerException.class, + () -> + CelFunctionBinding.fromAsync( + "id", ImmutableList.of(Long.class), (CelAsyncFunctionOverload) null)); + } + + @Test + public void fromCompletableFuture_unary_lambdaReturnsNull_returnsNullFuture() throws Exception { + CelFunctionBinding unaryBinding = + CelFunctionBinding.fromCompletableFuture("null_cf", Long.class, (Long arg) -> null); + CelAsyncFunctionOverload unaryOverload = + (CelAsyncFunctionOverload) unaryBinding.getDefinition(); + + assertThat(unaryOverload.applyAsync(1L)).isNull(); + assertThat(unaryOverload.applyAsync(new Object[] {1L})).isNull(); + } + + @Test + public void fromCompletableFuture_binary_lambdaReturnsNull_returnsNullFuture() throws Exception { + CelFunctionBinding binaryBinding = + CelFunctionBinding.fromCompletableFuture( + "null_cf_bin", Long.class, String.class, (Long a, String b) -> null); + CelAsyncFunctionOverload binaryOverload = + (CelAsyncFunctionOverload) binaryBinding.getDefinition(); + + assertThat(binaryOverload.applyAsync(1L, "a")).isNull(); + assertThat(binaryOverload.applyAsync(new Object[] {1L, "a"})).isNull(); + } + + @Test + public void fromOverloads_asyncBinding_throwsIllegalArgumentException() { + CelFunctionBinding asyncBinding = + CelFunctionBinding.fromAsync("async_fn", Long.class, (Long arg) -> immediateFuture(arg)); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> CelFunctionBinding.fromOverloads("async_fn", asyncBinding)); + assertThat(e) + .hasMessageThat() + .contains("Asynchronous function overloads cannot be grouped using fromOverloads."); + } +} diff --git a/runtime/src/test/java/dev/cel/runtime/planner/AsyncCallKeyTest.java b/runtime/src/test/java/dev/cel/runtime/planner/AsyncCallKeyTest.java new file mode 100644 index 000000000..e400f3b99 --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/planner/AsyncCallKeyTest.java @@ -0,0 +1,127 @@ +// 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.testing.EqualsTester; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import dev.cel.common.CelOptions; +import dev.cel.runtime.RuntimeEquality; +import dev.cel.runtime.RuntimeHelpers; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class AsyncCallKeyTest { + + private final RuntimeEquality runtimeEquality = + RuntimeEquality.create(RuntimeHelpers.create(), CelOptions.DEFAULT); + + @Test + public void equalsAndHashCode_identicalArgs_equal() { + AsyncCallKey k1 = AsyncCallKey.create(10L, new Object[] {"foo", 42L}, runtimeEquality); + AsyncCallKey k2 = AsyncCallKey.create(10L, new Object[] {"foo", 42L}, runtimeEquality); + + assertThat(k1).isEqualTo(k2); + assertThat(k1.hashCode()).isEqualTo(k2.hashCode()); + } + + @Test + public void equalsAndHashCode_differentExprId_notEqual() { + AsyncCallKey k1 = AsyncCallKey.create(10L, new Object[] {"foo"}, runtimeEquality); + AsyncCallKey k2 = AsyncCallKey.create(20L, new Object[] {"foo"}, runtimeEquality); + + assertThat(k1).isNotEqualTo(k2); + } + + @Test + public void equalsAndHashCode_differentArgLengths_notEqual() { + AsyncCallKey k1 = AsyncCallKey.create(10L, new Object[] {"foo"}, runtimeEquality); + AsyncCallKey k2 = AsyncCallKey.create(10L, new Object[] {"foo", "bar"}, runtimeEquality); + + assertThat(k1).isNotEqualTo(k2); + } + + @Test + public void equalsAndHashCode_differentArgs_differentHashCode() { + AsyncCallKey k1 = AsyncCallKey.create(10L, new Object[] {"foo"}, runtimeEquality); + AsyncCallKey k2 = AsyncCallKey.create(10L, new Object[] {"bar"}, runtimeEquality); + + assertThat(k1.hashCode()).isNotEqualTo(k2.hashCode()); + } + + @Test + public void equalsAndHashCode_nullArgs_equal() { + AsyncCallKey k1 = AsyncCallKey.create(10L, new Object[] {null}, runtimeEquality); + AsyncCallKey k2 = AsyncCallKey.create(10L, new Object[] {null}, runtimeEquality); + AsyncCallKey k3 = AsyncCallKey.create(10L, new Object[] {"notNull"}, runtimeEquality); + + assertThat(k1).isEqualTo(k2); + assertThat(k1.hashCode()).isEqualTo(k2.hashCode()); + assertThat(k1).isNotEqualTo(k3); + } + + @Test + public void equalsAndHashCode_numberNormalization_zeroAndNegativeZero_equalHashCode() { + AsyncCallKey kZero = AsyncCallKey.create(10L, new Object[] {0.0d}, runtimeEquality); + AsyncCallKey kNegZero = AsyncCallKey.create(10L, new Object[] {-0.0d}, runtimeEquality); + + assertThat(kZero.hashCode()).isEqualTo(kNegZero.hashCode()); + } + + @Test + public void equalsAndHashCode_numberNormalization_doubleNaN_equalAndEqualHashCode() { + AsyncCallKey kNan1 = AsyncCallKey.create(10L, new Object[] {Double.NaN}, runtimeEquality); + AsyncCallKey kNan2 = AsyncCallKey.create(10L, new Object[] {Double.NaN}, runtimeEquality); + + assertThat(kNan1).isEqualTo(kNan2); + assertThat(kNan1.hashCode()).isEqualTo(kNan2.hashCode()); + } + + @Test + public void equalsAndHashCode_numberNormalization_floatNaN_equal() { + AsyncCallKey kFloatNan1 = AsyncCallKey.create(10L, new Object[] {Float.NaN}, runtimeEquality); + AsyncCallKey kFloatNan2 = AsyncCallKey.create(10L, new Object[] {Float.NaN}, runtimeEquality); + + assertThat(kFloatNan1).isEqualTo(kFloatNan2); + } + + @Test + public void equalsAndHashCode_equalsTester() { + new EqualsTester() + .addEqualityGroup( + AsyncCallKey.create(10L, new Object[] {1}, runtimeEquality), + AsyncCallKey.create(10L, new Object[] {1}, runtimeEquality)) + .addEqualityGroup(AsyncCallKey.create(20L, new Object[] {1}, runtimeEquality)) + .addEqualityGroup(AsyncCallKey.create(10L, new Object[] {2}, runtimeEquality)) + .testEquals(); + } + + @Test + public void hashMapLookup_success() { + AsyncCallKey k1 = AsyncCallKey.create(10L, new Object[] {"key1"}, runtimeEquality); + AsyncCallKey k2 = AsyncCallKey.create(10L, new Object[] {"key2"}, runtimeEquality); + AsyncCallKey k1Lookup = AsyncCallKey.create(10L, new Object[] {"key1"}, runtimeEquality); + + Map map = new HashMap<>(); + map.put(k1, "val1"); + map.put(k2, "val2"); + + assertThat(map).containsEntry(k1Lookup, "val1"); + } +} diff --git a/runtime/src/test/java/dev/cel/runtime/planner/AsyncCallRecordTest.java b/runtime/src/test/java/dev/cel/runtime/planner/AsyncCallRecordTest.java new file mode 100644 index 000000000..ab8b022ee --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/planner/AsyncCallRecordTest.java @@ -0,0 +1,98 @@ +// 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.util.concurrent.SettableFuture; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import java.time.Duration; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class AsyncCallRecordTest { + + @Test + public void initialValues_matchConstructor() { + AsyncCallRecord record = + new AsyncCallRecord(1L, 10L, "myFunc", "myFunc_overload", new Object[] {"arg1", 2}); + + assertThat(record.callId()).isEqualTo(1L); + assertThat(record.exprId()).isEqualTo(10L); + assertThat(record.functionName()).isEqualTo("myFunc"); + assertThat(record.overloadId()).isEqualTo("myFunc_overload"); + assertThat(record.arguments()).containsExactly("arg1", 2).inOrder(); + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.RUNNING); + assertThat(record.isCancelled()).isFalse(); + assertThat(record.result()).isNull(); + assertThat(record.error()).isNull(); + assertThat(record.elapsedDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void complete_updatesStateAndResultAndElapsed() { + AsyncCallRecord record = + new AsyncCallRecord(1L, 10L, "myFunc", "myFunc_overload", new Object[] {}); + Duration elapsed = Duration.ofMillis(123); + + record.complete("successResult", elapsed); + + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.SUCCESS); + assertThat(record.result()).isEqualTo("successResult"); + assertThat(record.error()).isNull(); + assertThat(record.elapsedDuration()).isEqualTo(elapsed); + } + + @Test + public void fail_updatesStateAndErrorAndElapsed() { + AsyncCallRecord record = + new AsyncCallRecord(1L, 10L, "myFunc", "myFunc_overload", new Object[] {}); + Duration elapsed = Duration.ofMillis(456); + RuntimeException error = new RuntimeException("test error"); + + record.fail(error, elapsed); + + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.FAILURE); + assertThat(record.error()).isSameInstanceAs(error); + assertThat(record.result()).isNull(); + assertThat(record.elapsedDuration()).isEqualTo(elapsed); + } + + @Test + public void cancelInFlight_cancelsFutureAndSetsFlag() { + AsyncCallRecord record = + new AsyncCallRecord(1L, 10L, "myFunc", "myFunc_overload", new Object[] {}); + SettableFuture future = SettableFuture.create(); + record.setInFlightFuture(future); + + record.cancelInFlight(); + + assertThat(record.isCancelled()).isTrue(); + assertThat(future.isCancelled()).isTrue(); + } + + @Test + public void setInFlightFuture_afterCancelled_cancelsImmediately() { + AsyncCallRecord record = + new AsyncCallRecord(1L, 10L, "myFunc", "myFunc_overload", new Object[] {}); + record.cancelInFlight(); + + SettableFuture future = SettableFuture.create(); + record.setInFlightFuture(future); + + assertThat(future.isCancelled()).isTrue(); + } +} diff --git a/runtime/src/test/java/dev/cel/runtime/planner/AsyncCallStateTrackerTest.java b/runtime/src/test/java/dev/cel/runtime/planner/AsyncCallStateTrackerTest.java new file mode 100644 index 000000000..1d51b3e24 --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/planner/AsyncCallStateTrackerTest.java @@ -0,0 +1,439 @@ +// 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 static com.google.common.util.concurrent.Futures.immediateFuture; +import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import dev.cel.common.CelOptions; +import dev.cel.common.values.CelValueConverter; +import dev.cel.runtime.AccumulatedUnknowns; +import dev.cel.runtime.CelAsyncCall; +import dev.cel.runtime.CelAsyncDrainStrategy; +import dev.cel.runtime.CelAsyncEvaluationOptions; +import dev.cel.runtime.CelAsyncObserver; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.RuntimeEquality; +import dev.cel.runtime.RuntimeHelpers; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class AsyncCallStateTrackerTest { + + private final RuntimeEquality runtimeEquality = + RuntimeEquality.create(RuntimeHelpers.create(), CelOptions.DEFAULT); + private final ListeningExecutorService directExecutor = newDirectExecutorService(); + + @Test + @SuppressWarnings("Immutable") // Test only + public void recordOrGet_cancelledBeforeRun_releasesPermitAndReturns() throws Exception { + AsyncCallStateTracker tracker = new AsyncCallStateTracker(runtimeEquality); + AsyncGate gate = new AsyncGate(1); + CelAsyncEvaluationOptions options = CelAsyncEvaluationOptions.defaultOptions(); + List queuedTasks = new ArrayList<>(); + ListeningExecutorService mockExecutor = + MoreExecutors.listeningDecorator( + new AbstractExecutorService() { + @Override + public void shutdown() {} + + @Override + public ImmutableList shutdownNow() { + return ImmutableList.of(); + } + + @Override + public boolean isShutdown() { + return false; + } + + @Override + public boolean isTerminated() { + return false; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + + @Override + public void execute(Runnable command) { + queuedTasks.add(command); + } + }); + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, mockExecutor); + + // Acquire the only permit in gate so the next task is queued + gate.dispatch(mockExecutor, () -> {}); + assertThat(gate.activeCount()).isEqualTo(1); + + AtomicBoolean overloadCalled = new AtomicBoolean(false); + + Object result = + tracker.recordOrGet( + 1L, + "myFunc", + "myFunc_overload", + new Object[] {"arg"}, + args -> { + overloadCalled.set(true); + return immediateFuture("ok"); + }, + CelValueConverter.getDefaultInstance(), + mockExecutor, + gate, + coordinator, + /* observer= */ null); + + assertThat(result).isInstanceOf(AccumulatedUnknowns.class); + // Permit is still held by first dummy task, new task is in gate's pendingTasks + assertThat(gate.activeCount()).isEqualTo(1); + assertThat(queuedTasks).isEmpty(); + + // Release permit from the first task; gate drains pending task to mockExecutor + gate.releasePermit(mockExecutor); + assertThat(queuedTasks).hasSize(1); + // Gate has now acquired permit for the queued task + assertThat(gate.activeCount()).isEqualTo(1); + + // Cancel before task execution runs + tracker.cancelInFlight(); + + // Run the queued task; it should see record is cancelled and release permit + queuedTasks.get(0).run(); + + assertThat(overloadCalled.get()).isFalse(); + assertThat(gate.activeCount()).isEqualTo(0); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void recordOrGet_deduplicatesCallsWithSameKey() throws Exception { + AsyncCallStateTracker tracker = new AsyncCallStateTracker(runtimeEquality); + AsyncGate gate = new AsyncGate(0); + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder() + .setDrainStrategy(CelAsyncDrainStrategy.drainReady(Duration.ofMinutes(1))) + .build(); + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, directExecutor); + + SettableFuture pendingFuture = SettableFuture.create(); + + Object first = + tracker.recordOrGet( + 10L, + "myFunc", + "myFunc_overload", + new Object[] {"x"}, + args -> pendingFuture, + CelValueConverter.getDefaultInstance(), + directExecutor, + gate, + coordinator, + /* observer= */ null); + + Object second = + tracker.recordOrGet( + 10L, + "myFunc", + "myFunc_overload", + new Object[] {"x"}, + args -> pendingFuture, + CelValueConverter.getDefaultInstance(), + directExecutor, + gate, + coordinator, + /* observer= */ null); + + assertThat(first).isInstanceOf(AccumulatedUnknowns.class); + assertThat(second).isInstanceOf(AccumulatedUnknowns.class); + assertThat(((AccumulatedUnknowns) first).callIds()) + .isEqualTo(((AccumulatedUnknowns) second).callIds()); + assertThat(tracker.hasInFlightCalls()).isTrue(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void hasInFlightCalls_futureCompletes_returnsFalse() throws Exception { + AsyncCallStateTracker tracker = new AsyncCallStateTracker(runtimeEquality); + AsyncGate gate = new AsyncGate(0); + CelAsyncEvaluationOptions options = CelAsyncEvaluationOptions.defaultOptions(); + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, directExecutor); + SettableFuture pendingFuture = SettableFuture.create(); + + tracker.recordOrGet( + 10L, + "myFunc", + "myFunc_overload", + new Object[] {"x"}, + args -> pendingFuture, + CelValueConverter.getDefaultInstance(), + directExecutor, + gate, + coordinator, + /* observer= */ null); + pendingFuture.set("done"); + + assertThat(tracker.hasInFlightCalls()).isFalse(); + } + + @Test + public void recordOrGet_existingKey_doesNotAllocateNewCallId() throws Exception { + AsyncCallStateTracker tracker = new AsyncCallStateTracker(runtimeEquality); + AsyncGate gate = new AsyncGate(0); + CelAsyncEvaluationOptions options = CelAsyncEvaluationOptions.defaultOptions(); + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, directExecutor); + + Object first = + tracker.recordOrGet( + 10L, + "fn", + "fn_overload", + new Object[] {"x"}, + args -> SettableFuture.create(), + CelValueConverter.getDefaultInstance(), + directExecutor, + gate, + coordinator, + /* observer= */ null); + + Object second = + tracker.recordOrGet( + 10L, + "fn", + "fn_overload", + new Object[] {"x"}, + args -> SettableFuture.create(), + CelValueConverter.getDefaultInstance(), + directExecutor, + gate, + coordinator, + /* observer= */ null); + + Object third = + tracker.recordOrGet( + 20L, + "fn", + "fn_overload", + new Object[] {"y"}, + args -> SettableFuture.create(), + CelValueConverter.getDefaultInstance(), + directExecutor, + gate, + coordinator, + /* observer= */ null); + + assertThat(((AccumulatedUnknowns) first).callIds()).containsExactly(1L); + assertThat(((AccumulatedUnknowns) second).callIds()).containsExactly(1L); + assertThat(((AccumulatedUnknowns) third).callIds()).containsExactly(2L); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void recordOrGet_successfulExecution_recordsAccurateElapsedDuration() throws Exception { + AtomicLong nanoTime = new AtomicLong(100_000L); + AsyncCallStateTracker tracker = + new AsyncCallStateTracker(runtimeEquality, () -> nanoTime.getAndAdd(150_000L)); + AsyncGate gate = new AsyncGate(0); + CelAsyncEvaluationOptions options = CelAsyncEvaluationOptions.defaultOptions(); + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, directExecutor); + AtomicReference finishedCall = new AtomicReference<>(); + CelAsyncObserver observer = + new CelAsyncObserver() { + @Override + public void onCallStarted(CelAsyncCall call) {} + + @Override + public void onCallFinished(CelAsyncCall call, Object result, Throwable error) { + finishedCall.set(call); + } + }; + + SettableFuture pendingFuture = SettableFuture.create(); + tracker.recordOrGet( + 10L, + "fn", + "fn_overload", + new Object[] {"x"}, + args -> pendingFuture, + CelValueConverter.getDefaultInstance(), + directExecutor, + gate, + coordinator, + observer); + + pendingFuture.set("done"); + + assertThat(finishedCall.get()).isNotNull(); + assertThat(finishedCall.get().elapsedDuration()).isEqualTo(Duration.ofNanos(150_000L)); + } + + @Test + public void recordOrGet_synchronousException_recordsAccurateElapsedDuration() throws Exception { + AtomicLong nanoTime = new AtomicLong(100_000L); + AsyncCallStateTracker tracker = + new AsyncCallStateTracker(runtimeEquality, () -> nanoTime.getAndAdd(150_000L)); + AsyncGate gate = new AsyncGate(0); + CelAsyncEvaluationOptions options = CelAsyncEvaluationOptions.defaultOptions(); + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, directExecutor); + AtomicReference finishedCall = new AtomicReference<>(); + CelAsyncObserver observer = + new CelAsyncObserver() { + @Override + public void onCallStarted(CelAsyncCall call) {} + + @Override + public void onCallFinished(CelAsyncCall call, Object result, Throwable error) { + finishedCall.set(call); + } + }; + + assertThrows( + CelEvaluationException.class, + () -> + tracker.recordOrGet( + 10L, + "fn", + "fn_overload", + new Object[] {"x"}, + args -> { + throw new RuntimeException("fail"); + }, + CelValueConverter.getDefaultInstance(), + directExecutor, + gate, + coordinator, + observer)); + + assertThat(finishedCall.get()).isNotNull(); + assertThat(finishedCall.get().elapsedDuration()).isEqualTo(Duration.ofNanos(150_000L)); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void recordOrGet_asynchronousException_recordsAccurateElapsedDuration() throws Exception { + AtomicLong nanoTime = new AtomicLong(100_000L); + AsyncCallStateTracker tracker = + new AsyncCallStateTracker(runtimeEquality, () -> nanoTime.getAndAdd(150_000L)); + AsyncGate gate = new AsyncGate(0); + CelAsyncEvaluationOptions options = CelAsyncEvaluationOptions.defaultOptions(); + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, directExecutor); + AtomicReference finishedCall = new AtomicReference<>(); + CelAsyncObserver observer = + new CelAsyncObserver() { + @Override + public void onCallStarted(CelAsyncCall call) {} + + @Override + public void onCallFinished(CelAsyncCall call, Object result, Throwable error) { + finishedCall.set(call); + } + }; + + SettableFuture pendingFuture = SettableFuture.create(); + tracker.recordOrGet( + 10L, + "fn", + "fn_overload", + new Object[] {"x"}, + args -> pendingFuture, + CelValueConverter.getDefaultInstance(), + directExecutor, + gate, + coordinator, + observer); + + pendingFuture.setException(new RuntimeException("async fail")); + + assertThat(finishedCall.get()).isNotNull(); + assertThat(finishedCall.get().elapsedDuration()).isEqualTo(Duration.ofNanos(150_000L)); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void recordOrGet_asynchronousException_withoutObserver_doesNotThrow() throws Exception { + AsyncCallStateTracker tracker = new AsyncCallStateTracker(runtimeEquality); + AsyncGate gate = new AsyncGate(0); + CelAsyncEvaluationOptions options = CelAsyncEvaluationOptions.defaultOptions(); + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, directExecutor); + SettableFuture pendingFuture = SettableFuture.create(); + + tracker.recordOrGet( + 10L, + "fn", + "fn_overload", + new Object[] {"x"}, + args -> pendingFuture, + CelValueConverter.getDefaultInstance(), + directExecutor, + gate, + coordinator, + /* observer= */ null); + + pendingFuture.setException(new RuntimeException("async fail")); + assertThat(tracker.hasInFlightCalls()).isFalse(); + } + + @Test + public void recordOrGet_synchronousException_withoutObserver_throwsCelEvaluationException() { + AsyncCallStateTracker tracker = new AsyncCallStateTracker(runtimeEquality); + AsyncGate gate = new AsyncGate(0); + CelAsyncEvaluationOptions options = CelAsyncEvaluationOptions.defaultOptions(); + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, directExecutor); + + assertThrows( + CelEvaluationException.class, + () -> + tracker.recordOrGet( + 10L, + "fn", + "fn_overload", + new Object[] {"x"}, + args -> { + throw new RuntimeException("sync fail"); + }, + CelValueConverter.getDefaultInstance(), + directExecutor, + gate, + coordinator, + /* observer= */ null)); + } +} diff --git a/runtime/src/test/java/dev/cel/runtime/planner/AsyncCompletionCoordinatorTest.java b/runtime/src/test/java/dev/cel/runtime/planner/AsyncCompletionCoordinatorTest.java new file mode 100644 index 000000000..dad70d747 --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/planner/AsyncCompletionCoordinatorTest.java @@ -0,0 +1,269 @@ +// 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.ImmutableList; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import dev.cel.runtime.CelAsyncCall; +import dev.cel.runtime.CelAsyncDrainStrategy; +import dev.cel.runtime.CelAsyncEvaluationOptions; +import java.time.Duration; +import java.util.concurrent.Delayed; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class AsyncCompletionCoordinatorTest { + + private static final CelAsyncCall DUMMY_CALL = + new CelAsyncCall() { + @Override + public long callId() { + return 1L; + } + + @Override + public long exprId() { + return 10L; + } + + @Override + public String functionName() { + return "fn"; + } + + @Override + public String overloadId() { + return "fn_overload"; + } + + @Override + public ImmutableList arguments() { + return ImmutableList.of(); + } + + @Override + public Duration elapsedDuration() { + return Duration.ZERO; + } + }; + + @Test + public void initialStatus_emptyBatch() { + CelAsyncEvaluationOptions options = CelAsyncEvaluationOptions.defaultOptions(); + AsyncGate gate = new AsyncGate(0); + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, Runnable::run); + + assertThat(coordinator.hasPendingBatch()).isFalse(); + } + + @Test + public void notifyCallCompleted_addsToBatch() { + CelAsyncEvaluationOptions options = CelAsyncEvaluationOptions.defaultOptions(); + AsyncGate gate = new AsyncGate(0); + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, Runnable::run); + + coordinator.notifyCallCompleted(DUMMY_CALL); + + assertThat(coordinator.hasPendingBatch()).isTrue(); + } + + @Test + public void waitForCompletions_immediateReevaluation_triggersContinuation() { + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder() + .setDrainStrategy(CelAsyncDrainStrategy.drainReady(Duration.ZERO)) + .build(); + AsyncGate gate = new AsyncGate(0); + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, Runnable::run); + coordinator.notifyCallCompleted(DUMMY_CALL); + + AtomicBoolean continuationRan = new AtomicBoolean(false); + coordinator.waitForCompletions(() -> continuationRan.set(true)); + + assertThat(continuationRan.get()).isTrue(); + assertThat(coordinator.hasPendingBatch()).isFalse(); + assertThat(coordinator.isWaiting()).isFalse(); + assertThat(coordinator.hasContinuation()).isFalse(); + } + + @Test + public void cancel_cancelsDebounceTimerAndClearsBatch() { + AtomicBoolean mayInterruptArg = new AtomicBoolean(true); + ScheduledThreadPoolExecutor scheduler = + new ScheduledThreadPoolExecutor(1) { + @Override + public ScheduledFuture schedule(Runnable command, long delay, TimeUnit unit) { + ScheduledFuture task = super.schedule(command, delay, unit); + return new CapturingScheduledFuture<>(task, mayInterruptArg); + } + }; + try { + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder() + .setDrainStrategy(CelAsyncDrainStrategy.drainReady(Duration.ofMinutes(10))) + .setScheduledExecutorService(scheduler) + .build(); + AsyncGate gate = new AsyncGate(1); + // Simulate an active call in the gate so drainReady chooses waitDuration instead of + // reevaluate + gate.dispatch(Runnable::run, () -> {}); + + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, Runnable::run); + coordinator.notifyCallCompleted(DUMMY_CALL); + + AtomicBoolean continuationRan = new AtomicBoolean(false); + coordinator.waitForCompletions(() -> continuationRan.set(true)); + + // Continuation shouldn't have run because it scheduled a 10 min debounce timer + assertThat(continuationRan.get()).isFalse(); + assertThat(coordinator.isWaiting()).isTrue(); + assertThat(coordinator.hasContinuation()).isTrue(); + assertThat(coordinator.hasScheduledDebounceTimer()).isTrue(); + ScheduledFuture scheduledTask = (ScheduledFuture) scheduler.getQueue().peek(); + assertThat(scheduledTask).isNotNull(); + assertThat(scheduledTask.isCancelled()).isFalse(); + + coordinator.cancel(); + + assertThat(coordinator.hasPendingBatch()).isFalse(); + assertThat(coordinator.isWaiting()).isFalse(); + assertThat(coordinator.hasContinuation()).isFalse(); + assertThat(coordinator.hasScheduledDebounceTimer()).isFalse(); + // Verify debounce timer was cancelled with mayInterruptIfRunning = false + assertThat(scheduledTask.isCancelled()).isTrue(); + assertThat(mayInterruptArg.get()).isFalse(); + // Verify continuation was cancelled and does not run + assertThat(continuationRan.get()).isFalse(); + + // Subsequent completions after cancellation must not trigger the cancelled continuation + gate.releasePermit(Runnable::run); + coordinator.notifyCallCompleted(DUMMY_CALL); + assertThat(continuationRan.get()).isFalse(); + + // If scheduled task runnable executes after cancel, it must not run the continuation + ((Runnable) scheduledTask).run(); + assertThat(continuationRan.get()).isFalse(); + } finally { + scheduler.shutdownNow(); + } + } + + @Test + public void triggerContinuation_whenDebounceTimerPending_cancelsDebounceTimer() { + AtomicBoolean mayInterruptArg = new AtomicBoolean(true); + ScheduledThreadPoolExecutor scheduler = + new ScheduledThreadPoolExecutor(1) { + @Override + public ScheduledFuture schedule(Runnable command, long delay, TimeUnit unit) { + ScheduledFuture task = super.schedule(command, delay, unit); + return new CapturingScheduledFuture<>(task, mayInterruptArg); + } + }; + try { + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder() + .setDrainStrategy(CelAsyncDrainStrategy.drainReady(Duration.ofMinutes(10))) + .setScheduledExecutorService(scheduler) + .build(); + AsyncGate gate = new AsyncGate(1); + gate.dispatch(Runnable::run, () -> {}); + + AsyncCompletionCoordinator coordinator = + new AsyncCompletionCoordinator(options, gate, Runnable::run); + coordinator.notifyCallCompleted(DUMMY_CALL); + + AtomicBoolean continuationRan = new AtomicBoolean(false); + coordinator.waitForCompletions(() -> continuationRan.set(true)); + + ScheduledFuture scheduledTask = (ScheduledFuture) scheduler.getQueue().peek(); + assertThat(scheduledTask).isNotNull(); + assertThat(scheduledTask.isCancelled()).isFalse(); + assertThat(coordinator.hasScheduledDebounceTimer()).isTrue(); + + // Release gate permit so that subsequent notification causes drainReady to return + // shouldReevaluate() == true + gate.releasePermit(Runnable::run); + coordinator.notifyCallCompleted(DUMMY_CALL); + + // cancelDebounceTimer() in triggerContinuation must cancel the timer and clear reference + assertThat(continuationRan.get()).isTrue(); + assertThat(scheduledTask.isCancelled()).isTrue(); + assertThat(mayInterruptArg.get()).isFalse(); + assertThat(coordinator.hasScheduledDebounceTimer()).isFalse(); + } finally { + scheduler.shutdownNow(); + } + } + + private static final class CapturingScheduledFuture implements ScheduledFuture { + private final ScheduledFuture delegate; + private final AtomicBoolean capturedMayInterrupt; + + CapturingScheduledFuture(ScheduledFuture delegate, AtomicBoolean capturedMayInterrupt) { + this.delegate = delegate; + this.capturedMayInterrupt = capturedMayInterrupt; + } + + @Override + public long getDelay(TimeUnit unit) { + return delegate.getDelay(unit); + } + + @Override + public int compareTo(Delayed o) { + return delegate.compareTo(o); + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + capturedMayInterrupt.set(mayInterruptIfRunning); + return delegate.cancel(mayInterruptIfRunning); + } + + @Override + public boolean isCancelled() { + return delegate.isCancelled(); + } + + @Override + public boolean isDone() { + return delegate.isDone(); + } + + @Override + public V get() throws InterruptedException, ExecutionException { + return delegate.get(); + } + + @Override + public V get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + return delegate.get(timeout, unit); + } + } +} diff --git a/runtime/src/test/java/dev/cel/runtime/planner/AsyncGateTest.java b/runtime/src/test/java/dev/cel/runtime/planner/AsyncGateTest.java new file mode 100644 index 000000000..8936c25cc --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/planner/AsyncGateTest.java @@ -0,0 +1,293 @@ +// 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 static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ForwardingQueue; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import java.util.AbstractQueue; +import java.util.Collections; +import java.util.Iterator; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class AsyncGateTest { + + @Test + public void unboundedConcurrency_runsImmediatelyAndTracksActive() { + AsyncGate gate = new AsyncGate(0); + AtomicBoolean ran = new AtomicBoolean(false); + + gate.dispatch(Runnable::run, () -> ran.set(true)); + + assertThat(ran.get()).isTrue(); + assertThat(gate.activeCount()).isEqualTo(1); + + gate.releasePermit(Runnable::run); + assertThat(gate.activeCount()).isEqualTo(0); + } + + @Test + public void boundedConcurrency_queuesAndDrainsOnRelease() { + AsyncGate gate = new AsyncGate(1); + AtomicInteger tasksExecuted = new AtomicInteger(); + + gate.dispatch(Runnable::run, tasksExecuted::incrementAndGet); + assertThat(tasksExecuted.get()).isEqualTo(1); + assertThat(gate.activeCount()).isEqualTo(1); + + // Second task should be queued in pendingTasks because concurrency limit is 1 + gate.dispatch(Runnable::run, tasksExecuted::incrementAndGet); + assertThat(tasksExecuted.get()).isEqualTo(1); + + // Releasing permit drains the pending task + gate.releasePermit(Runnable::run); + assertThat(tasksExecuted.get()).isEqualTo(2); + assertThat(gate.activeCount()).isEqualTo(1); + + gate.releasePermit(Runnable::run); + assertThat(gate.activeCount()).isEqualTo(0); + } + + @Test + public void cancel_clearsPendingTasksAndIgnoresNewTasks() { + AsyncGate gate = new AsyncGate(1); + AtomicInteger tasksExecuted = new AtomicInteger(); + + gate.dispatch(Runnable::run, tasksExecuted::incrementAndGet); + assertThat(tasksExecuted.get()).isEqualTo(1); + + gate.dispatch(Runnable::run, tasksExecuted::incrementAndGet); + assertThat(tasksExecuted.get()).isEqualTo(1); + + gate.cancel(); + + // Releasing permit should not run the queued task because it was cleared + gate.releasePermit(Runnable::run); + assertThat(tasksExecuted.get()).isEqualTo(1); + + // New dispatch after cancel is ignored + gate.dispatch(Runnable::run, tasksExecuted::incrementAndGet); + assertThat(tasksExecuted.get()).isEqualTo(1); + } + + @Test + public void cancel_withQueuedPendingTasks_clearsPendingTasksImmediately() { + ConcurrentLinkedQueue pendingTasks = new ConcurrentLinkedQueue<>(); + AsyncGate gate = new AsyncGate(1, pendingTasks); + + gate.dispatch(Runnable::run, () -> {}); + gate.dispatch(Runnable::run, () -> {}); + assertThat(pendingTasks).hasSize(1); + + gate.cancel(); + + assertThat(pendingTasks).isEmpty(); + } + + @Test + public void drainPending_whenCancelled_clearsPendingTasksAndDoesNotRun() { + ConcurrentLinkedQueue pendingTasks = new ConcurrentLinkedQueue<>(); + AsyncGate gate = new AsyncGate(1, pendingTasks); + AtomicBoolean taskRan = new AtomicBoolean(false); + + gate.dispatch(Runnable::run, () -> {}); + gate.cancel(); + + pendingTasks.add(() -> taskRan.set(true)); + + gate.releasePermit(Runnable::run); + + assertThat(taskRan.get()).isFalse(); + assertThat(pendingTasks).isEmpty(); + } + + @Test + public void dispatch_whenPermitAvailableAfterQueueing_drainPendingRunsTask() { + AtomicBoolean taskRan = new AtomicBoolean(false); + Semaphore semaphore = new Semaphore(1); + ConcurrentLinkedQueue delegate = new ConcurrentLinkedQueue<>(); + Queue queue = + new ForwardingQueue() { + @Override + protected Queue delegate() { + return delegate; + } + + @Override + public boolean add(Runnable r) { + boolean res = super.add(r); + // Release permit directly on semaphore without calling drainPending. + // This verifies that dispatch drains the queue if a permit became available after + // queueing. + semaphore.release(); + return res; + } + }; + AsyncGate gate = new AsyncGate(semaphore, queue); + + gate.dispatch(Runnable::run, () -> {}); + gate.dispatch(Runnable::run, () -> taskRan.set(true)); + + assertThat(taskRan.get()).isTrue(); + } + + @Test + public void concurrentDrainAndCancel_retainsPermitBalance() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(4); + try { + for (int i = 0; i < 50; i++) { + AsyncGate gate = new AsyncGate(1); + CountDownLatch startLatch = new CountDownLatch(1); + + CountDownLatch doneLatch = new CountDownLatch(2); + + gate.dispatch(executor, () -> {}); + gate.dispatch(executor, () -> {}); + + executor.execute( + () -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + gate.releasePermit(executor); + doneLatch.countDown(); + } + }); + + executor.execute( + () -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + gate.cancel(); + doneLatch.countDown(); + } + }); + + startLatch.countDown(); + assertThat(doneLatch.await(5, SECONDS)).isTrue(); + assertThat(gate.activeCount()).isAtLeast(0); + } + } finally { + executor.shutdown(); + executor.awaitTermination(5, SECONDS); + } + } + + @Test + public void drainPending_whenTaskPolledIsNull_releasesPermitAndBreaks() { + Queue queue = + new AbstractQueue() { + private boolean first = true; + + @Override + public boolean offer(Runnable e) { + return true; + } + + @Override + public Runnable peek() { + return null; + } + + @Override + public Iterator iterator() { + return Collections.emptyIterator(); + } + + @Override + public int size() { + return first ? 1 : 0; + } + + @Override + public boolean isEmpty() { + if (first) { + first = false; + return false; + } + return true; + } + + @Override + public Runnable poll() { + return null; + } + }; + AsyncGate gate = new AsyncGate(1, queue); + gate.releasePermit(Runnable::run); + + assertThat(gate.activeCount()).isEqualTo(-1); + } + + @Test + public void dispatch_taskThrowsRuntimeException_releasesPermitAndDecrementsActiveCount() { + AsyncGate gate = new AsyncGate(1); + assertThrows( + RuntimeException.class, + () -> + gate.dispatch( + Runnable::run, + () -> { + throw new RuntimeException("fail"); + })); + + assertThat(gate.activeCount()).isEqualTo(0); + // Next task should be able to acquire permit immediately + AtomicBoolean secondRan = new AtomicBoolean(false); + gate.dispatch(Runnable::run, () -> secondRan.set(true)); + assertThat(secondRan.get()).isTrue(); + assertThat(gate.activeCount()).isEqualTo(1); + } + + @Test + public void drainPending_executorThrows_releasesPermitDecrementsActiveCountAndRethrows() { + AsyncGate gate = new AsyncGate(1); + gate.dispatch(Runnable::run, () -> {}); + gate.dispatch(Runnable::run, () -> {}); + + Executor rejectingExecutor = + r -> { + throw new RejectedExecutionException("rejected"); + }; + + assertThrows(RejectedExecutionException.class, () -> gate.releasePermit(rejectingExecutor)); + + assertThat(gate.activeCount()).isEqualTo(0); + AtomicBoolean secondRan = new AtomicBoolean(false); + gate.dispatch(Runnable::run, () -> secondRan.set(true)); + assertThat(secondRan.get()).isTrue(); + assertThat(gate.activeCount()).isEqualTo(1); + } +} 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 9116818dc..f734d3641 100644 --- a/runtime/src/test/java/dev/cel/runtime/planner/BUILD.bazel +++ b/runtime/src/test/java/dev/cel/runtime/planner/BUILD.bazel @@ -40,21 +40,34 @@ java_library( "//extensions", "//parser:macro", "//runtime", + "//runtime:accumulated_unknowns", + "//runtime:async_call", + "//runtime:async_drain_strategy", + "//runtime:async_observer", + "//runtime:async_options", "//runtime:descriptor_type_resolver", "//runtime:dispatcher", + "//runtime:evaluation_exception", "//runtime:function_binding", + "//runtime:function_resolver", + "//runtime:late_function_binding", "//runtime:partial_vars", "//runtime:program", + "//runtime:resolved_overload", "//runtime:runtime_equality", + "//runtime:runtime_factory", "//runtime:runtime_helpers", "//runtime:standard_functions", "//runtime:unknown_attributes", + "//runtime/planner:async_call_state_tracker", "//runtime/planner:program_planner", "//runtime/standard:type", "@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_java_proto", "@maven//:com_google_guava_guava", + "@maven//:com_google_guava_guava_testlib", "@maven//:com_google_testparameterinjector_test_parameter_injector", "@maven//:junit_junit", + "@maven//:org_jspecify_jspecify", ], ) diff --git a/runtime/src/test/java/dev/cel/runtime/planner/ProgramPlannerAsyncTest.java b/runtime/src/test/java/dev/cel/runtime/planner/ProgramPlannerAsyncTest.java new file mode 100644 index 000000000..d2c381bb7 --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/planner/ProgramPlannerAsyncTest.java @@ -0,0 +1,2639 @@ +// 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.collect.ImmutableList.toImmutableList; +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.util.concurrent.Futures.immediateFailedFuture; +import static com.google.common.util.concurrent.Futures.immediateFuture; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static dev.cel.common.CelFunctionDecl.newFunctionDeclaration; +import static dev.cel.common.CelOverloadDecl.newGlobalOverload; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; +import com.google.common.util.concurrent.ForwardingListeningExecutorService; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelOptions; +import dev.cel.common.CelSource; +import dev.cel.common.ast.CelConstant; +import dev.cel.common.ast.CelExpr; +import dev.cel.common.types.ListType; +import dev.cel.common.types.MapType; +import dev.cel.common.types.SimpleType; +import dev.cel.compiler.CelCompiler; +import dev.cel.compiler.CelCompilerFactory; +import dev.cel.expr.conformance.proto3.TestAllTypes; +import dev.cel.parser.CelStandardMacro; +import dev.cel.runtime.CelAsyncCall; +import dev.cel.runtime.CelAsyncDrainAction; +import dev.cel.runtime.CelAsyncDrainStrategy; +import dev.cel.runtime.CelAsyncEvaluationOptions; +import dev.cel.runtime.CelAsyncObserver; +import dev.cel.runtime.CelAttribute; +import dev.cel.runtime.CelAttributePattern; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelFunctionBinding; +import dev.cel.runtime.CelFunctionResolver; +import dev.cel.runtime.CelResolvedOverload; +import dev.cel.runtime.CelRuntime; +import dev.cel.runtime.CelRuntimeFactory; +import dev.cel.runtime.CelUnknownSet; +import dev.cel.runtime.CelVariableResolver; +import dev.cel.runtime.PartialVars; +import dev.cel.runtime.Program; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.LongStream; +import org.jspecify.annotations.Nullable; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class ProgramPlannerAsyncTest { + + private final ListeningExecutorService executor = + MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(4)); + + @After + public void tearDown() { + executor.shutdownNow(); + } + + private static final CelCompiler CEL_COMPILER = + CelCompilerFactory.standardCelCompilerBuilder() + .setStandardMacros(CelStandardMacro.STANDARD_MACROS) + .setOptions(CelOptions.current().build()) + .addVar("x", SimpleType.INT) + .addVar("y", SimpleType.INT) + .addVar("dx", SimpleType.DOUBLE) + .addVar("list_var", ListType.create(SimpleType.INT)) + .addVar("map_var", MapType.create(SimpleType.STRING, SimpleType.INT)) + .addFunctionDeclarations( + newFunctionDeclaration( + "asyncSquare", + newGlobalOverload("asyncSquare_int", SimpleType.INT, SimpleType.INT), + newGlobalOverload("asyncSquare_double", SimpleType.DOUBLE, SimpleType.DOUBLE)), + newFunctionDeclaration( + "asyncAdd", + newGlobalOverload( + "asyncAdd_int_int", SimpleType.INT, SimpleType.INT, SimpleType.INT)), + newFunctionDeclaration( + "asyncSquareCf", + newGlobalOverload("asyncSquareCf_int", SimpleType.INT, SimpleType.INT)), + newFunctionDeclaration( + "asyncAddCf", + newGlobalOverload( + "asyncAddCf_int_int", SimpleType.INT, SimpleType.INT, SimpleType.INT)), + newFunctionDeclaration( + "asyncIsEven", + newGlobalOverload("asyncIsEven_int", SimpleType.BOOL, SimpleType.INT)), + newFunctionDeclaration( + "asyncFail", newGlobalOverload("asyncFail_int", SimpleType.INT, SimpleType.INT)), + newFunctionDeclaration( + "asyncNullReturn", + newGlobalOverload("asyncNullReturn_int", SimpleType.INT, SimpleType.INT)), + newFunctionDeclaration( + "asyncSyncThrow", + newGlobalOverload("asyncSyncThrow_int", SimpleType.INT, SimpleType.INT)), + newFunctionDeclaration( + "lateBoundAsync", + newGlobalOverload("lateBoundAsync_int", SimpleType.INT, SimpleType.INT))) + .build(); + + @Test + public void evalAsync_unaryFunction_evaluatesSuccessfully() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(5)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(25L); + } + + @Test + public void evalAsync_binaryFunction_evaluatesSuccessfully() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncAdd(10, 20)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncAdd_int_int", + Long.class, + Long.class, + (Long a, Long b) -> immediateFuture(a + b))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(30L); + } + + @Test + public void evalAsync_completableFutureBinding_evaluatesSuccessfully() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquareCf(6)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromCompletableFuture( + "asyncSquareCf_int", + Long.class, + (Long arg) -> CompletableFuture.completedFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(36L); + } + + @Test + public void evalAsync_binaryCompletableFuture_evaluatesSuccessfully() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncAddCf(10, 20)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromCompletableFuture( + "asyncAddCf_int_int", + Long.class, + Long.class, + (Long a, Long b) -> CompletableFuture.completedFuture(a + b))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(30L); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_binaryCompletableFuture_failureWithCompletionException_unwrapsCause() + throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncAddCf(10, 20)").getAst(); + CompletableFuture failedCf = new CompletableFuture<>(); + failedCf.completeExceptionally( + new CompletionException(new IllegalArgumentException("simulated cf error"))); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromCompletableFuture( + "asyncAddCf_int_int", Long.class, Long.class, (Long a, Long b) -> failedCf)) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + ExecutionException e = assertThrows(ExecutionException.class, future::get); + + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(e).hasCauseThat().hasCauseThat().isInstanceOf(IllegalArgumentException.class); + assertThat(e).hasCauseThat().hasMessageThat().contains("simulated cf error"); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_binaryCompletableFuture_failureDirectException_propagatesCause() + throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncAddCf(10, 20)").getAst(); + CompletableFuture failedCf = new CompletableFuture<>(); + failedCf.completeExceptionally(new IllegalStateException("direct error")); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromCompletableFuture( + "asyncAddCf_int_int", Long.class, Long.class, (Long a, Long b) -> failedCf)) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + ExecutionException e = assertThrows(ExecutionException.class, future::get); + + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(e).hasCauseThat().hasCauseThat().isInstanceOf(IllegalStateException.class); + assertThat(e).hasCauseThat().hasMessageThat().contains("direct error"); + } + + @Test + @SuppressWarnings({"Immutable", "FutureReturnValueIgnored"}) // Test only + public void evalAsync_binaryCompletableFuture_cancellationPropagatesToCompletableFuture() + throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncAddCf(10, 20)").getAst(); + CompletableFuture inFlightCf = new CompletableFuture<>(); + CountDownLatch invoked = new CountDownLatch(1); + CountDownLatch cancelledLatch = new CountDownLatch(1); + inFlightCf.whenComplete( + (res, ex) -> { + if (inFlightCf.isCancelled()) { + cancelledLatch.countDown(); + } + }); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromCompletableFuture( + "asyncAddCf_int_int", + Long.class, + Long.class, + (Long a, Long b) -> { + invoked.countDown(); + return inFlightCf; + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + assertThat(invoked.await(5, SECONDS)).isTrue(); + + future.cancel(/* mayInterruptIfRunning= */ true); + + assertThat(future.isCancelled()).isTrue(); + assertThat(cancelledLatch.await(5, SECONDS)).isTrue(); + assertThat(inFlightCf.isCancelled()).isTrue(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_diamondDependency_evaluatesConcurrently() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(x) + asyncSquare(y)").getAst(); + AtomicInteger invocationCount = new AtomicInteger(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + invocationCount.incrementAndGet(); + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = + program.evalAsync(ImmutableMap.of("x", 3L, "y", 4L), executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(25L); + assertThat(invocationCount.get()).isEqualTo(2); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_shortCircuitOr_doesNotExecuteSkippedBranch() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("true || asyncSquare(5) == 25").getAst(); + AtomicBoolean asyncCalled = new AtomicBoolean(false); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + asyncCalled.set(true); + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(true); + assertThat(asyncCalled.get()).isFalse(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_shortCircuitAnd_doesNotExecuteSkippedBranch() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("false && asyncSquare(5) == 25").getAst(); + AtomicBoolean asyncCalled = new AtomicBoolean(false); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + asyncCalled.set(true); + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(false); + assertThat(asyncCalled.get()).isFalse(); + } + + @Test + public void evalAsync_comprehensionFilter_filtersCorrectly() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("list_var.filter(x, asyncIsEven(x))").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncIsEven_int", Long.class, (Long arg) -> immediateFuture(arg % 2 == 0))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = + program.evalAsync( + ImmutableMap.of("list_var", ImmutableList.of(1L, 2L, 3L, 4L, 5L, 6L)), executor); + Object result = future.get(5, SECONDS); + + assertThat((Iterable) result).containsExactly(2L, 4L, 6L).inOrder(); + } + + @Test + public void evalAsync_comprehensionExists_evaluatesSpeculatively() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("list_var.exists(x, asyncIsEven(x))").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncIsEven_int", Long.class, (Long arg) -> immediateFuture(arg % 2 == 0))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = + program.evalAsync(ImmutableMap.of("list_var", ImmutableList.of(1L, 3L, 4L, 7L)), executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(true); + } + + @Test + public void evalAsync_comprehensionExistsOverMap_evaluatesSpeculatively() throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("map_var.exists(k, asyncIsEven(map_var[k]))").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncIsEven_int", Long.class, (Long arg) -> immediateFuture(arg % 2L == 0L))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = + program.evalAsync(ImmutableMap.of("map_var", ImmutableMap.of("a", 1L, "b", 4L)), executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(true); + } + + @Test + public void evalAsync_comprehensionAll_evaluatesSpeculatively() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("list_var.all(x, asyncIsEven(x))").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncIsEven_int", Long.class, (Long arg) -> immediateFuture(arg % 2 == 0))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = + program.evalAsync(ImmutableMap.of("list_var", ImmutableList.of(2L, 4L, 6L)), executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(true); + } + + @Test + public void evalAsync_lateBoundFunction_evaluatesSuccessfully() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("lateBoundAsync(7)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder().addLateBoundFunctions("lateBoundAsync").build(); + Program program = runtime.createProgram(ast); + + CelFunctionResolver lateBoundResolver = + new CelFunctionResolver() { + @Override + public Optional findOverloadMatchingArgs( + String functionName, Collection overloadIds, Object[] args) { + if (functionName.equals("lateBoundAsync")) { + return Optional.of( + CelResolvedOverload.of( + functionName, + "lateBoundAsync_int", + CelFunctionBinding.fromAsync( + "lateBoundAsync_int", + Long.class, + (Long arg) -> immediateFuture(arg * 10)) + .getDefinition(), + /* isStrict= */ true, + Long.class)); + } + return Optional.empty(); + } + + @Override + public Optional findOverloadMatchingArgs( + String functionName, Object[] args) { + return findOverloadMatchingArgs(functionName, ImmutableList.of(), args); + } + }; + + ListenableFuture future = + program.evalAsync(ImmutableMap.of(), lateBoundResolver, executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(70L); + } + + @Test + public void syncEval_onAsyncFunction_throwsCelEvaluationException() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(5)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + CelEvaluationException e = assertThrows(CelEvaluationException.class, program::eval); + assertThat(e) + .hasMessageThat() + .contains("Asynchronous functions are only supported via evalAsync"); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_respectsMaxConcurrencyLimit() throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER + .compile( + "asyncSquare(1) + asyncSquare(2) + asyncSquare(3) + asyncSquare(4) +" + + " asyncSquare(5)") + .getAst(); + + AtomicInteger activeConcurrent = new AtomicInteger(); + AtomicInteger maxObservedConcurrent = new AtomicInteger(); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + int cur = activeConcurrent.incrementAndGet(); + maxObservedConcurrent.accumulateAndGet(cur, Math::max); + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + activeConcurrent.decrementAndGet(); + } + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder() + .setMaxConcurrency(2) + .setDrainStrategy(CelAsyncDrainStrategy.drainAll()) + .build(); + + ListenableFuture future = program.evalAsync(executor, options); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(1L + 4L + 9L + 16L + 25L); + assertThat(maxObservedConcurrent.get()).isAtMost(2); + } + + @Test + public void evalAsync_drainStrategyDrainNone_reevaluatesImmediately() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(2) + asyncSquare(3)").getAst(); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder() + .setDrainStrategy(CelAsyncDrainStrategy.drainNone()) + .build(); + + ListenableFuture future = program.evalAsync(executor, options); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(13L); + } + + @Test + public void evalAsync_observerReceivesLifecycleEvents() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(4)").getAst(); + + List events = Collections.synchronizedList(new ArrayList<>()); + CelAsyncObserver observer = + new CelAsyncObserver() { + @Override + public void onCallStarted(CelAsyncCall call) { + events.add("started:" + call.functionName()); + } + + @Override + public void onCallFinished( + CelAsyncCall call, @Nullable Object result, @Nullable Throwable error) { + events.add("finished:" + call.functionName() + ":" + result); + } + }; + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setObserver(observer).build(); + + ListenableFuture future = program.evalAsync(executor, options); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(16L); + assertThat(events).containsExactly("started:asyncSquare", "finished:asyncSquare:16").inOrder(); + } + + @Test + public void evalAsync_functionFailure_propagatesCelEvaluationException() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncFail(1)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncFail_int", + Long.class, + (Long arg) -> + immediateFailedFuture( + new IllegalArgumentException("simulated async error")))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + ExecutionException e = assertThrows(ExecutionException.class, future::get); + + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(e).hasCauseThat().hasMessageThat().contains("simulated async error"); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_futureCancellation_cancelsInFlightTasks() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(10)").getAst(); + SettableFuture inFlight = SettableFuture.create(); + CountDownLatch invoked = new CountDownLatch(1); + CountDownLatch cancelledLatch = new CountDownLatch(1); + AtomicBoolean cancelled = new AtomicBoolean(false); + inFlight.addListener( + () -> { + if (inFlight.isCancelled()) { + cancelled.set(true); + cancelledLatch.countDown(); + } + }, + directExecutor()); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + invoked.countDown(); + return inFlight; + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + assertThat(invoked.await(5, SECONDS)).isTrue(); + + future.cancel(/* mayInterruptIfRunning= */ true); + + assertThat(future.isCancelled()).isTrue(); + assertThat(cancelledLatch.await(5, SECONDS)).isTrue(); + assertThat(cancelled.get()).isTrue(); + } + + @Test + public void evalAsync_maxIterationsExceeded_throwsCelEvaluationException() throws Exception { + // When maxIterations is 0, evaluation immediately fails iteration safety check + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(5)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setMaxIterations(0).build(); + + ListenableFuture future = program.evalAsync(executor, options); + ExecutionException e = assertThrows(ExecutionException.class, future::get); + + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(e) + .hasCauseThat() + .hasMessageThat() + .contains("Exceeded maximum async evaluation iterations"); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_nanArgument_memoizesCallWithoutInfiniteLoop() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(dx) + 1.0").getAst(); + AtomicInteger callCount = new AtomicInteger(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_double", + Double.class, + (Double arg) -> { + callCount.incrementAndGet(); + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = + program.evalAsync(ImmutableMap.of("dx", Double.NaN), executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isInstanceOf(Double.class); + assertThat((Double) result).isNaN(); + // Must be memoized across passes without infinite loop or double-execution + assertThat(callCount.get()).isEqualTo(1); + } + + @Test + public void evalAsync_drainReadyZeroDebounce_reevaluatesImmediately() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(3) + asyncSquare(4)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder() + .setDrainStrategy(CelAsyncDrainStrategy.drainReady(Duration.ZERO)) + .build(); + + ListenableFuture future = program.evalAsync(executor, options); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(25L); + } + + @Test + public void evalAsync_applyAsyncReturnsNull_failsWithDescriptiveException() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncNullReturn(1)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync("asyncNullReturn_int", Long.class, (Long arg) -> null)) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + ExecutionException e = assertThrows(ExecutionException.class, future::get); + + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(e).hasCauseThat().hasMessageThat().contains("returned a null ListenableFuture"); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_synchronousException_propagatesAndCancelsSiblings() throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("asyncSquare(10) + asyncSyncThrow(5)").getAst(); + SettableFuture inFlight = SettableFuture.create(); + AtomicBoolean siblingCancelled = new AtomicBoolean(false); + CountDownLatch siblingCancelledLatch = new CountDownLatch(1); + inFlight.addListener( + () -> { + if (inFlight.isCancelled()) { + siblingCancelled.set(true); + siblingCancelledLatch.countDown(); + } + }, + directExecutor()); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync("asyncSquare_int", Long.class, (Long arg) -> inFlight), + CelFunctionBinding.fromAsync( + "asyncSyncThrow_int", + Long.class, + (Long arg) -> { + throw new IllegalStateException("synchronous crash"); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + ExecutionException e = assertThrows(ExecutionException.class, future::get); + + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(e).hasCauseThat().hasMessageThat().contains("synchronous crash"); + assertThat(siblingCancelledLatch.await(5, SECONDS)).isTrue(); + assertThat(siblingCancelled.get()).isTrue(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_evaluationError_cancelsInFlightSiblings() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(10) + (1 / 0)").getAst(); + SettableFuture inFlight = SettableFuture.create(); + AtomicBoolean siblingCancelled = new AtomicBoolean(false); + CountDownLatch siblingCancelledLatch = new CountDownLatch(1); + inFlight.addListener( + () -> { + if (inFlight.isCancelled()) { + siblingCancelled.set(true); + siblingCancelledLatch.countDown(); + } + }, + directExecutor()); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync("asyncSquare_int", Long.class, (Long arg) -> inFlight)) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + ExecutionException e = assertThrows(ExecutionException.class, future::get); + + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(siblingCancelledLatch.await(5, SECONDS)).isTrue(); + assertThat(siblingCancelled.get()).isTrue(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_cancellation_clearsPendingGateTasks() throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("asyncSquare(1) + asyncSquare(2) + asyncSquare(3)").getAst(); + SettableFuture task1Future = SettableFuture.create(); + CountDownLatch task1Started = new CountDownLatch(1); + AtomicInteger queuedExecuted = new AtomicInteger(); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + if (arg == 1L) { + task1Started.countDown(); + return task1Future; + } + queuedExecuted.incrementAndGet(); + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setMaxConcurrency(1).build(); + + ListenableFuture future = program.evalAsync(executor, options); + assertThat(task1Started.await(5, SECONDS)).isTrue(); + + // Cancel while task 1 is running and tasks 2 and 3 are queued in AsyncGate + future.cancel(/* mayInterruptIfRunning= */ true); + + assertThat(future.isCancelled()).isTrue(); + // Queued tasks should never execute + assertThat(queuedExecuted.get()).isEqualTo(0); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_observerThrowsError_doesNotLeakPermits() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(2) + asyncSquare(3)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncObserver faultyObserver = + new CelAsyncObserver() { + @Override + public void onCallStarted(CelAsyncCall call) {} + + @Override + public void onCallFinished( + CelAsyncCall call, @Nullable Object result, @Nullable Throwable error) { + throw new AssertionError("Simulated test harness assertion error in observer"); + } + }; + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder() + .setMaxConcurrency(1) + .setObserver(faultyObserver) + .build(); + + ListenableFuture future = program.evalAsync(executor, options); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(13L); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_negativeZeroDouble_normalizesInCacheKey() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(dx)").getAst(); + AtomicInteger callCount = new AtomicInteger(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_double", + Double.class, + (Double arg) -> { + callCount.incrementAndGet(); + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(ImmutableMap.of("dx", -0.0d), executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(0.0d); + assertThat(callCount.get()).isEqualTo(1); + } + + @Test + @SuppressWarnings({"Immutable", "FutureReturnValueIgnored"}) // Test only + public void celFunctionBinding_fromCompletableFuture_cancellationPropagates() throws Exception { + CompletableFuture cf = new CompletableFuture<>(); + CountDownLatch invoked = new CountDownLatch(1); + CountDownLatch cancelledLatch = new CountDownLatch(1); + cf.whenComplete((res, ex) -> cancelledLatch.countDown()); + CelFunctionBinding binding = + CelFunctionBinding.fromCompletableFuture( + "asyncSquareCf_int", + Long.class, + (Long arg) -> { + invoked.countDown(); + return cf; + }); + + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquareCf(5)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder().addFunctionBindings(binding).build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + assertThat(invoked.await(5, SECONDS)).isTrue(); + future.cancel(/* mayInterruptIfRunning= */ false); + + assertThat(future.isCancelled()).isTrue(); + assertThat(cancelledLatch.await(5, SECONDS)).isTrue(); + assertThat(cf.isCancelled()).isTrue(); + } + + @Test + @SuppressWarnings({"Immutable", "FutureReturnValueIgnored"}) // Test only + public void celFunctionBinding_fromCompletableFuture_failedWithCompletionException_unwrapsCause() + throws Exception { + CompletableFuture cf = new CompletableFuture<>(); + cf.completeExceptionally( + new CompletionException(new IllegalArgumentException("cf wrapped fail"))); + CelFunctionBinding binding = + CelFunctionBinding.fromCompletableFuture("asyncSquareCf_int", Long.class, (Long arg) -> cf); + + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquareCf(5)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder().addFunctionBindings(binding).build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + ExecutionException e = assertThrows(ExecutionException.class, future::get); + + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(e).hasCauseThat().hasCauseThat().isInstanceOf(IllegalArgumentException.class); + assertThat(e).hasCauseThat().hasMessageThat().contains("cf wrapped fail"); + } + + @Test + @SuppressWarnings({"Immutable", "FutureReturnValueIgnored"}) // Test only + public void celFunctionBinding_fromCompletableFuture_failedDirectly_propagatesCause() + throws Exception { + CompletableFuture cf = new CompletableFuture<>(); + cf.completeExceptionally(new IllegalArgumentException("cf direct fail")); + CelFunctionBinding binding = + CelFunctionBinding.fromCompletableFuture("asyncSquareCf_int", Long.class, (Long arg) -> cf); + + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquareCf(5)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder().addFunctionBindings(binding).build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + ExecutionException e = assertThrows(ExecutionException.class, future::get); + + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(e).hasCauseThat().hasCauseThat().isInstanceOf(IllegalArgumentException.class); + assertThat(e).hasCauseThat().hasMessageThat().contains("cf direct fail"); + } + + @Test + public void celFunctionBinding_fromAsyncWithIterableArgTypes_evaluates() throws Exception { + CelFunctionBinding binding = + CelFunctionBinding.fromAsync( + "asyncSquare_int", + ImmutableList.of(Long.class), + (args) -> immediateFuture(((Long) args[0]) * ((Long) args[0]))); + + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(4)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder().addFunctionBindings(binding).build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + assertThat(future.get(5, SECONDS)).isEqualTo(16L); + } + + @Test + public void evalAsync_nestedAsyncCalls_evaluatesCorrectly() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(asyncSquare(3))").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(81L); + } + + @Test + public void evalAsync_comprehensionMap_evaluatesCorrectly() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("list_var.map(x, asyncSquare(x))").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = + program.evalAsync(ImmutableMap.of("list_var", ImmutableList.of(1L, 2L, 3L)), executor); + Object result = future.get(5, SECONDS); + + assertThat((Iterable) result).containsExactly(1L, 4L, 9L).inOrder(); + } + + @Test + public void syncEval_onLateBoundAsyncFunction_throwsCelEvaluationException() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("lateBoundAsync(10)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder().addLateBoundFunctions("lateBoundAsync").build(); + Program program = runtime.createProgram(ast); + + CelFunctionResolver lateBoundResolver = + new CelFunctionResolver() { + @Override + public Optional findOverloadMatchingArgs( + String functionName, Collection overloadIds, Object[] args) { + if (functionName.equals("lateBoundAsync")) { + return Optional.of( + CelResolvedOverload.of( + functionName, + "lateBoundAsync_int", + CelFunctionBinding.fromAsync( + "lateBoundAsync_int", Long.class, (Long arg) -> immediateFuture(100L)) + .getDefinition(), + /* isStrict= */ true, + Long.class)); + } + return Optional.empty(); + } + + @Override + public Optional findOverloadMatchingArgs( + String functionName, Object[] args) { + return findOverloadMatchingArgs(functionName, ImmutableList.of(), args); + } + }; + + CelEvaluationException e = + assertThrows( + CelEvaluationException.class, () -> program.eval(ImmutableMap.of(), lateBoundResolver)); + assertThat(e).hasMessageThat().contains("evaluated in synchronous mode"); + } + + @Test + public void evalAsync_partialVarsUnknown_returnsUnknownSet() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("x").getAst(); + CelRuntime runtime = CelRuntimeFactory.plannerRuntimeBuilder().build(); + Program program = runtime.createProgram(ast); + PartialVars partialVars = PartialVars.of(CelAttributePattern.create("x")); + + ListenableFuture future = program.evalAsync(partialVars, executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isInstanceOf(CelUnknownSet.class); + } + + @Test + public void evalAsync_comprehensionMap_evaluatesSpeculatively() throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("{\"a\": 2, \"b\": 4}.all(k, asyncIsEven(2))").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncIsEven_int", Long.class, (Long arg) -> immediateFuture(arg % 2 == 0))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(true); + } + + @Test + public void evalAsync_comprehensionListMap_evaluatesSpeculatively() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("list_var.map(x, asyncSquare(x))").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = + program.evalAsync(ImmutableMap.of("list_var", ImmutableList.of(2L, 4L)), executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(ImmutableList.of(4L, 16L)); + } + + @Test + public void evalAsync_maxIterationsExact_succeeds() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(5)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setMaxIterations(2).build(); + + ListenableFuture future = program.evalAsync(executor, options); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(25L); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_maxIterationsExceeded_cancelsInFlightCalls() throws Exception { + SettableFuture callFuture = SettableFuture.create(); + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(5) + asyncSquare(10)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> callFuture)) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setMaxIterations(0).build(); + + ListenableFuture future = program.evalAsync(executor, options); + ExecutionException e = assertThrows(ExecutionException.class, future::get); + + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(e) + .hasCauseThat() + .hasMessageThat() + .contains("Exceeded maximum async evaluation iterations"); + } + + @Test + public void evalAsync_withObserver_recordsElapsedDurationForSuccessAndFailure() throws Exception { + List elapsedDurations = Collections.synchronizedList(new ArrayList<>()); + CelAsyncObserver observer = + new CelAsyncObserver() { + @Override + public void onCallStarted(CelAsyncCall call) {} + + @Override + public void onCallFinished( + CelAsyncCall call, @Nullable Object result, @Nullable Throwable error) { + elapsedDurations.add(call.elapsedDuration()); + } + }; + + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(3)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setObserver(observer).build(); + + ListenableFuture future = program.evalAsync(executor, options); + assertThat(future.get(5, SECONDS)).isEqualTo(9L); + + assertThat(Iterables.getOnlyElement(elapsedDurations)).isAtLeast(Duration.ZERO); + } + + @Test + public void evalAsync_withObserver_recordsElapsedDurationOnFailure() throws Exception { + List elapsedDurations = Collections.synchronizedList(new ArrayList<>()); + CelAsyncObserver observer = + new CelAsyncObserver() { + @Override + public void onCallStarted(CelAsyncCall call) {} + + @Override + public void onCallFinished( + CelAsyncCall call, @Nullable Object result, @Nullable Throwable error) { + elapsedDurations.add(call.elapsedDuration()); + } + }; + + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncFail(1)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncFail_int", + Long.class, + (Long arg) -> + immediateFailedFuture( + new IllegalArgumentException("simulated async error")))) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setObserver(observer).build(); + + ListenableFuture future = program.evalAsync(executor, options); + assertThrows(ExecutionException.class, future::get); + + assertThat(Iterables.getOnlyElement(elapsedDurations)).isAtLeast(Duration.ZERO); + } + + @Test + public void evalAsync_withObserver_recordsElapsedDurationOnSyncThrow() throws Exception { + List elapsedDurations = Collections.synchronizedList(new ArrayList<>()); + CelAsyncObserver observer = + new CelAsyncObserver() { + @Override + public void onCallStarted(CelAsyncCall call) {} + + @Override + public void onCallFinished( + CelAsyncCall call, @Nullable Object result, @Nullable Throwable error) { + elapsedDurations.add(call.elapsedDuration()); + } + }; + + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSyncThrow(1)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSyncThrow_int", + Long.class, + (Long arg) -> { + throw new RuntimeException("sync boom"); + })) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setObserver(observer).build(); + + ListenableFuture future = program.evalAsync(executor, options); + assertThrows(ExecutionException.class, future::get); + + assertThat(Iterables.getOnlyElement(elapsedDurations)).isAtLeast(Duration.ZERO); + } + + @Test + public void evalAsync_programThreadSafety_evaluatesConcurrentlyOnMultipleThreads() + throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(x) + 1").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + try { + Thread.sleep(5); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + + int numThreads = 10; + ListeningExecutorService clientExecutor = + MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(numThreads)); + try { + CountDownLatch startGate = new CountDownLatch(1); + List> futures = new ArrayList<>(); + for (int i = 0; i < numThreads; i++) { + long xVal = i * 10L; + futures.add( + clientExecutor.submit( + () -> { + startGate.await(); + return (Long) + program.evalAsync(ImmutableMap.of("x", xVal), executor).get(5, SECONDS); + })); + } + startGate.countDown(); + + List results = Futures.allAsList(futures).get(5, SECONDS); + ImmutableList expected = + LongStream.range(0, numThreads) + .map(i -> (i * 10L) * (i * 10L) + 1L) + .boxed() + .collect(toImmutableList()); + assertThat(results).containsExactlyElementsIn(expected).inOrder(); + } finally { + clientExecutor.shutdownNow(); + } + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_shortCircuitTernary_falseCondition_doesNotExecuteTrueBranch() + throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("false ? asyncSquare(5) : 42").getAst(); + AtomicBoolean asyncCalled = new AtomicBoolean(false); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + asyncCalled.set(true); + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(42L); + assertThat(asyncCalled.get()).isFalse(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_shortCircuitTernary_trueCondition_doesNotExecuteFalseBranch() + throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("true ? 42 : asyncSquare(5)").getAst(); + AtomicBoolean asyncCalled = new AtomicBoolean(false); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + asyncCalled.set(true); + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(42L); + assertThat(asyncCalled.get()).isFalse(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_shortCircuitTernary_asyncConditionEven_evaluatesOnlyTrueBranch() + throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("asyncIsEven(x) ? asyncSquare(5) : asyncSquare(10)").getAst(); + List squaresCalled = Collections.synchronizedList(new ArrayList<>()); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncIsEven_int", Long.class, (Long arg) -> immediateFuture(arg % 2L == 0L)), + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + squaresCalled.add(arg); + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(ImmutableMap.of("x", 2L), executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(25L); + assertThat(squaresCalled).containsExactly(5L); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_shortCircuitTernary_asyncConditionOdd_evaluatesOnlyFalseBranch() + throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("asyncIsEven(x) ? asyncSquare(5) : asyncSquare(10)").getAst(); + List squaresCalled = Collections.synchronizedList(new ArrayList<>()); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncIsEven_int", Long.class, (Long arg) -> immediateFuture(arg % 2L == 0L)), + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + squaresCalled.add(arg); + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(ImmutableMap.of("x", 3L), executor); + Object result = future.get(5, SECONDS); + + assertThat(result).isEqualTo(100L); + assertThat(squaresCalled).containsExactly(10L); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_threeValuedLogicOr_resolvesWithoutWaitingForAsync() throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("(asyncSquare(10) == 100) || (x == 1)").getAst(); + SettableFuture slowFuture = SettableFuture.create(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> slowFuture)) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(ImmutableMap.of("x", 1L), executor); + Object result = future.get(5, SECONDS); + + assertThat((Boolean) result).isTrue(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_threeValuedLogicOr_falseBranch_waitsForAsync() throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("(asyncSquare(10) == 100) || (x == 1)").getAst(); + SettableFuture asyncFuture = SettableFuture.create(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> asyncFuture)) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(ImmutableMap.of("x", 0L), executor); + assertThat(future.isDone()).isFalse(); + + asyncFuture.set(100L); + Object result = future.get(5, SECONDS); + assertThat((Boolean) result).isTrue(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_threeValuedLogicAnd_resolvesWithoutWaitingForAsync() throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("(asyncSquare(10) == 100) && (x == 1)").getAst(); + SettableFuture slowFuture = SettableFuture.create(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> slowFuture)) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(ImmutableMap.of("x", 0L), executor); + Object result = future.get(5, SECONDS); + + assertThat((Boolean) result).isFalse(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_threeValuedLogicAnd_trueBranch_waitsForAsync() throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("(asyncSquare(10) == 100) && (x == 1)").getAst(); + SettableFuture asyncFuture = SettableFuture.create(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> asyncFuture)) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = program.evalAsync(ImmutableMap.of("x", 1L), executor); + assertThat(future.isDone()).isFalse(); + + asyncFuture.set(100L); + Object result = future.get(5, SECONDS); + assertThat((Boolean) result).isTrue(); + } + + @Test + public void evalAsync_comprehensionAsyncError_propagatesFailure() throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("list_var.map(i, i == 2 ? asyncFail(i) : asyncSquare(i))").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg)), + CelFunctionBinding.fromAsync( + "asyncFail_int", + Long.class, + (Long arg) -> + immediateFailedFuture(new IllegalArgumentException("comprehension fail")))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture future = + program.evalAsync(ImmutableMap.of("list_var", ImmutableList.of(1L, 2L, 3L)), executor); + ExecutionException e = assertThrows(ExecutionException.class, future::get); + + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(e).hasCauseThat().hasMessageThat().contains("comprehension fail"); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_mixedPartialUnknownAndAsync_resolvesAsyncAndReturnsUnknownSet() + throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(5) + x").getAst(); + AtomicBoolean asyncCalled = new AtomicBoolean(false); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + asyncCalled.set(true); + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + PartialVars partialVars = PartialVars.of(CelAttributePattern.create("x")); + + ListenableFuture future = program.evalAsync(partialVars, executor); + Object result = future.get(5, SECONDS); + + assertThat(asyncCalled.get()).isTrue(); + assertThat(result).isInstanceOf(CelUnknownSet.class); + CelUnknownSet unknownSet = (CelUnknownSet) result; + assertThat(unknownSet.attributes()).containsExactly(CelAttribute.fromQualifiedIdentifier("x")); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_highVolumeFanout_respectsConcurrencyAndCompletes() throws Exception { + ImmutableList.Builder itemsBuilder = ImmutableList.builder(); + for (long i = 1; i <= 50; i++) { + itemsBuilder.add(i); + } + ImmutableList items = itemsBuilder.build(); + ImmutableList expected = + LongStream.rangeClosed(1, 50).map(x -> x * x).boxed().collect(toImmutableList()); + + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("list_var.map(x, asyncSquare(x))").getAst(); + AtomicInteger activeConcurrent = new AtomicInteger(); + AtomicInteger maxConcurrent = new AtomicInteger(); + ListeningExecutorService workerPool = + MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(4)); + try { + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> + workerPool.submit( + () -> { + int cur = activeConcurrent.incrementAndGet(); + maxConcurrent.accumulateAndGet(cur, Math::max); + try { + Thread.sleep(5); + return arg * arg; + } finally { + activeConcurrent.decrementAndGet(); + } + }))) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setMaxConcurrency(3).build(); + + ListenableFuture future = + program.evalAsync(ImmutableMap.of("list_var", items), executor, options); + Object result = future.get(10, SECONDS); + + assertThat((Iterable) result).containsExactlyElementsIn(expected).inOrder(); + assertThat(maxConcurrent.get()).isAtLeast(2); + assertThat(maxConcurrent.get()).isAtMost(3); + } finally { + workerPool.shutdownNow(); + } + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_mapComprehensionLoopStepAsync_mergesAccumulatedUnknownsAcrossIterations() + throws Exception { + CelExpr comprehensionExpr = + CelExpr.ofComprehension( + 1L, + "k", + "", + CelExpr.ofMap( + 2L, + ImmutableList.of( + CelExpr.ofMapEntry( + 3L, + CelExpr.ofConstant(4L, CelConstant.ofValue("a")), + CelExpr.ofConstant(5L, CelConstant.ofValue(1L)), + false), + CelExpr.ofMapEntry( + 6L, + CelExpr.ofConstant(7L, CelConstant.ofValue("b")), + CelExpr.ofConstant(8L, CelConstant.ofValue(2L)), + false))), + "acc", + CelExpr.ofConstant(9L, CelConstant.ofValue(0L)), + CelExpr.ofConstant(10L, CelConstant.ofValue(true)), + CelExpr.ofCall( + 11L, Optional.empty(), "asyncEcho", ImmutableList.of(CelExpr.ofIdent(12L, "k"))), + CelExpr.ofIdent(13L, "acc")); + CelAbstractSyntaxTree ast = + CelAbstractSyntaxTree.newParsedAst(comprehensionExpr, CelSource.newBuilder().build()); + + Map> futures = new HashMap<>(); + futures.put("a", SettableFuture.create()); + futures.put("b", SettableFuture.create()); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncEcho", String.class, (String arg) -> futures.get(arg))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture evalFuture = program.evalAsync(executor); + + // Both calls for "a" and "b" should have been speculatively dispatched in the comprehension + assertThat(futures.get("a").isCancelled()).isFalse(); + assertThat(futures.get("b").isCancelled()).isFalse(); + assertThat(evalFuture.isDone()).isFalse(); + + // Complete "a" + futures.get("a").set(10L); + // evalFuture must not be done yet because "b" is still pending + assertThat(evalFuture.isDone()).isFalse(); + + // Complete "b" + futures.get("b").set(20L); + Object result = evalFuture.get(5, SECONDS); + assertThat(result).isEqualTo(20L); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_evalException_clearsPendingGateTasks() throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("asyncSquare(1) + asyncSquare(2) + (1 / 0)").getAst(); + SettableFuture task1Future = SettableFuture.create(); + CountDownLatch task1Started = new CountDownLatch(1); + AtomicInteger task2Executed = new AtomicInteger(); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + if (arg == 1L) { + task1Started.countDown(); + return task1Future; + } + task2Executed.incrementAndGet(); + return immediateFuture(arg * arg); + })) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setMaxConcurrency(1).build(); + + ListenableFuture future = program.evalAsync(executor, options); + assertThat(task1Started.await(5, SECONDS)).isTrue(); + + // 1 / 0 throws CelEvaluationException synchronously during eval, calling cancelAll() + ExecutionException e = assertThrows(ExecutionException.class, future::get); + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(e).hasCauseThat().hasMessageThat().contains("/ by zero"); + + // Task 1 was in-flight and must be cancelled by cancelAll() + assertThat(task1Future.isCancelled()).isTrue(); + + // Task 1 now finishes. Gate must be cancelled so queued task 2 never executes + task1Future.set(1L); + + assertThat(task2Executed.get()).isEqualTo(0); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_mapComprehensionConditionAsync_mergesAccumulatedUnknownsAcrossIterations() + throws Exception { + CelExpr comprehensionExpr = + CelExpr.ofComprehension( + 1L, + "k", + "", + CelExpr.ofMap( + 2L, + ImmutableList.of( + CelExpr.ofMapEntry( + 3L, + CelExpr.ofConstant(4L, CelConstant.ofValue("a")), + CelExpr.ofConstant(5L, CelConstant.ofValue(1L)), + false), + CelExpr.ofMapEntry( + 6L, + CelExpr.ofConstant(7L, CelConstant.ofValue("b")), + CelExpr.ofConstant(8L, CelConstant.ofValue(2L)), + false))), + "acc", + CelExpr.ofConstant(9L, CelConstant.ofValue(0L)), + CelExpr.ofCall( + 10L, Optional.empty(), "asyncCond", ImmutableList.of(CelExpr.ofIdent(11L, "k"))), + CelExpr.ofCall( + 12L, Optional.empty(), "asyncEcho", ImmutableList.of(CelExpr.ofIdent(13L, "k"))), + CelExpr.ofIdent(14L, "acc")); + CelAbstractSyntaxTree ast = + CelAbstractSyntaxTree.newParsedAst(comprehensionExpr, CelSource.newBuilder().build()); + + Map> condFutures = new HashMap<>(); + condFutures.put("a", SettableFuture.create()); + condFutures.put("b", SettableFuture.create()); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncCond", String.class, (String arg) -> condFutures.get(arg)), + CelFunctionBinding.fromAsync( + "asyncEcho", String.class, (String arg) -> immediateFuture(100L))) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture evalFuture = program.evalAsync(executor); + + // Both condition evaluations for "a" and "b" should have been speculatively dispatched + assertThat(condFutures.get("a").isCancelled()).isFalse(); + assertThat(condFutures.get("b").isCancelled()).isFalse(); + assertThat(evalFuture.isDone()).isFalse(); + + // Complete "a" condition with true + condFutures.get("a").set(true); + assertThat(evalFuture.isDone()).isFalse(); + + // Complete "b" condition with true + condFutures.get("b").set(true); + + Object result = evalFuture.get(5, SECONDS); + assertThat(result).isNotNull(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_shortCircuitOr_cancelsOrphanedInFlightFuture() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncIsEven(y) || asyncIsEven(x)").getAst(); + SettableFuture yFuture = SettableFuture.create(); + SettableFuture xFuture = SettableFuture.create(); + CountDownLatch xStarted = new CountDownLatch(1); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncIsEven_int", + Long.class, + (Long arg) -> { + if (arg == 2L) { + return yFuture; + } + xStarted.countDown(); + return xFuture; + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture evalFuture = + program.evalAsync(ImmutableMap.of("y", 2L, "x", 3L), executor); + + // Wait until x has been dispatched and is actively in-flight + assertThat(xStarted.await(5, SECONDS)).isTrue(); + assertThat(evalFuture.isDone()).isFalse(); + + // Complete y with true, which resolves the OR expression + yFuture.set(true); + + Object result = evalFuture.get(5, SECONDS); + + assertThat(result).isEqualTo(true); + // The in-flight speculative task xFuture must be cancelled + assertThat(xFuture.isCancelled()).isTrue(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_nestedAsyncCalls_withInFlightInnerCall_evaluatesCorrectly() + throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(asyncSquare(3))").getAst(); + SettableFuture innerFuture = SettableFuture.create(); + SettableFuture outerFuture = SettableFuture.create(); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + if (arg == 3L) { + return innerFuture; + } + return outerFuture; + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture evalFuture = program.evalAsync(executor); + + assertThat(evalFuture.isDone()).isFalse(); + + innerFuture.set(9L); + outerFuture.set(81L); + + Object result = evalFuture.get(5, SECONDS); + assertThat(result).isEqualTo(81L); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_comprehensionExistsOverMap_withInFlightAsync_evaluatesCorrectly() + throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("map_var.exists(k, asyncIsEven(map_var[k]))").getAst(); + SettableFuture futureA = SettableFuture.create(); + SettableFuture futureB = SettableFuture.create(); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncIsEven_int", + Long.class, + (Long arg) -> { + if (arg == 1L) { + return futureA; + } + return futureB; + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture evalFuture = + program.evalAsync(ImmutableMap.of("map_var", ImmutableMap.of("a", 1L, "b", 4L)), executor); + + assertThat(evalFuture.isDone()).isFalse(); + + futureA.set(false); + futureB.set(true); + + Object result = evalFuture.get(5, SECONDS); + assertThat(result).isEqualTo(true); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_comprehensionMap_withInFlightAsync_evaluatesCorrectly() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("list_var.map(x, asyncSquare(x))").getAst(); + SettableFuture future1 = SettableFuture.create(); + SettableFuture future2 = SettableFuture.create(); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + if (arg == 2L) { + return future1; + } + return future2; + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture evalFuture = + program.evalAsync(ImmutableMap.of("list_var", ImmutableList.of(2L, 3L)), executor); + + assertThat(evalFuture.isDone()).isFalse(); + + future1.set(4L); + future2.set(9L); + + Object result = evalFuture.get(5, SECONDS); + assertThat((Iterable) result).containsExactly(4L, 9L).inOrder(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_maxIterationsExceeded_cancelsInFlightTasksAndEnforcesLimit() + throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(1) + asyncSquare(2)").getAst(); + SettableFuture firstFuture = SettableFuture.create(); + SettableFuture secondFuture = SettableFuture.create(); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + if (arg == 1L) { + return firstFuture; + } + return secondFuture; + })) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setMaxIterations(1).build(); + ListenableFuture evalFuture = program.evalAsync(ImmutableMap.of(), executor, options); + + firstFuture.set(1L); + + ExecutionException e = assertThrows(ExecutionException.class, () -> evalFuture.get(5, SECONDS)); + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(e) + .hasCauseThat() + .hasMessageThat() + .contains("Exceeded maximum async evaluation iterations: 1"); + assertThat(secondFuture.isCancelled()).isTrue(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_zeroMaxIterations_failsImmediatelyWithoutEvaluating() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(1)").getAst(); + AtomicBoolean functionCalled = new AtomicBoolean(false); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + functionCalled.set(true); + return SettableFuture.create(); + })) + .build(); + Program program = runtime.createProgram(ast); + + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setMaxIterations(0).build(); + ListenableFuture evalFuture = program.evalAsync(ImmutableMap.of(), executor, options); + + ExecutionException e = assertThrows(ExecutionException.class, () -> evalFuture.get(5, SECONDS)); + assertThat(e).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(e) + .hasCauseThat() + .hasMessageThat() + .contains("Exceeded maximum async evaluation iterations: 0"); + assertThat(functionCalled.get()).isFalse(); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_binaryAsyncCall_withMultipleInFlightArguments_evaluatesCorrectly() + throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("asyncAdd(asyncSquare(2), asyncSquare(3))").getAst(); + SettableFuture square2Future = SettableFuture.create(); + SettableFuture square3Future = SettableFuture.create(); + CountDownLatch square2Invoked = new CountDownLatch(1); + CountDownLatch square3Invoked = new CountDownLatch(1); + AtomicInteger outerCallCount = new AtomicInteger(); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + if (arg == 2L) { + square2Invoked.countDown(); + return square2Future; + } + square3Invoked.countDown(); + return square3Future; + }), + CelFunctionBinding.fromAsync( + "asyncAdd_int_int", + Long.class, + Long.class, + (Long a, Long b) -> { + outerCallCount.incrementAndGet(); + return immediateFuture(a + b); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture evalFuture = program.evalAsync(executor); + + assertThat(square2Invoked.await(5, SECONDS)).isTrue(); + assertThat(square3Invoked.await(5, SECONDS)).isTrue(); + assertThat(evalFuture.isDone()).isFalse(); + assertThat(outerCallCount.get()).isEqualTo(0); + + square2Future.set(4L); + square3Future.set(9L); + + Object result = evalFuture.get(5, SECONDS); + + assertThat(result).isEqualTo(13L); + assertThat(outerCallCount.get()).isEqualTo(1); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_evaluationCancelled_clearsPendingGateTasks() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("[1, 2].map(n, asyncSquare(n))").getAst(); + SettableFuture firstFuture = SettableFuture.create(); + SettableFuture secondFuture = SettableFuture.create(); + CountDownLatch task1Started = new CountDownLatch(1); + AtomicInteger tasksSubmitted = new AtomicInteger(); + ListeningExecutorService trackingExecutor = + new ForwardingListeningExecutorService() { + @Override + protected ListeningExecutorService delegate() { + return executor; + } + + @Override + public void execute(Runnable r) { + tasksSubmitted.incrementAndGet(); + super.execute(r); + } + }; + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + if (arg == 1L) { + task1Started.countDown(); + return firstFuture; + } + return secondFuture; + })) + .build(); + Program program = runtime.createProgram(ast); + + // Concurrency limit of 1: task 1 acquires permit, task 2 is queued in gate via comprehension + // fanout + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setMaxConcurrency(1).build(); + ListenableFuture evalFuture = program.evalAsync(trackingExecutor, options); + + // Wait until task 1 is running and holding the permit + assertThat(task1Started.await(5, SECONDS)).isTrue(); + + // Verify task 1 was submitted and task 2 is queued in gate + assertThat(tasksSubmitted.get()).isEqualTo(1); + + // Cancel evaluation before task 1 finishes. cancelAll() should cancel gate and clear pending + // tasks. + evalFuture.cancel(/* mayInterruptIfRunning= */ true); + + // Complete task 1 so its callback runs gate.releasePermit(). + firstFuture.set(1L); + + // If gate.cancel() was called, pending tasks were cleared and no further tasks are submitted. + assertThat(tasksSubmitted.get()).isEqualTo(1); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_evaluationError_clearsPendingGateTasks() throws Exception { + CelAbstractSyntaxTree ast = + CEL_COMPILER.compile("[1, 2].map(n, asyncSquare(n)) + [1 / 0]").getAst(); + SettableFuture firstFuture = SettableFuture.create(); + SettableFuture secondFuture = SettableFuture.create(); + CountDownLatch task1Started = new CountDownLatch(1); + AtomicInteger tasksSubmitted = new AtomicInteger(); + ListeningExecutorService trackingExecutor = + new ForwardingListeningExecutorService() { + @Override + protected ListeningExecutorService delegate() { + return executor; + } + + @Override + public void execute(Runnable r) { + tasksSubmitted.incrementAndGet(); + super.execute(r); + } + }; + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + if (arg == 1L) { + task1Started.countDown(); + return firstFuture; + } + return secondFuture; + })) + .build(); + Program program = runtime.createProgram(ast); + + // Concurrency limit of 1: task 1 acquires permit, task 2 is queued in gate + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder().setMaxConcurrency(1).build(); + ListenableFuture evalFuture = program.evalAsync(trackingExecutor, options); + + // Wait until task 1 is running and holding the permit (task 2 is queued in gate) + assertThat(task1Started.await(5, SECONDS)).isTrue(); + assertThat(tasksSubmitted.get()).isEqualTo(1); + + // Evaluation failed due to division by zero: cancelAll() should cancel gate and clear pending + // tasks. + ExecutionException exception = + assertThrows(ExecutionException.class, () -> evalFuture.get(5, SECONDS)); + assertThat(exception).hasCauseThat().isInstanceOf(CelEvaluationException.class); + assertThat(exception).hasCauseThat().hasMessageThat().contains("/ by zero"); + + // Complete task 1 so its callback runs gate.releasePermit(). + firstFuture.set(1L); + + // If gate.cancel() was called in cancelAll(), pending tasks were cleared and task 2 is never + // submitted. + assertThat(tasksSubmitted.get()).isEqualTo(1); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_evaluationCancelled_cancelsScheduledDebounceTimer() throws Exception { + ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor(1); + try { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(1) + asyncSquare(2)").getAst(); + SettableFuture firstFuture = SettableFuture.create(); + SettableFuture secondFuture = SettableFuture.create(); + CountDownLatch callsDispatched = new CountDownLatch(2); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", + Long.class, + (Long arg) -> { + callsDispatched.countDown(); + if (arg == 1L) { + return firstFuture; + } + return secondFuture; + })) + .build(); + Program program = runtime.createProgram(ast); + + // Use a drain strategy that does not reevaluate on zero active count (so cancelling + // secondFuture + // does not trigger reevaluation / cancelDebounceTimer as a side effect). + CelAsyncDrainStrategy neverReevaluateDrainStrategy = + (completedBatch, activeCount) -> CelAsyncDrainAction.waitDuration(Duration.ofMinutes(10)); + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.builder() + .setDrainStrategy(neverReevaluateDrainStrategy) + .setScheduledExecutorService(scheduler) + .setMaxConcurrency(2) + .build(); + ListenableFuture evalFuture = program.evalAsync(executor, options); + + // Wait for both calls to be dispatched + assertThat(callsDispatched.await(5, SECONDS)).isTrue(); + + // Complete first future; with second future in flight, drain strategy schedules debounce + // timer + firstFuture.set(1L); + + // Verify timer was scheduled in scheduler queue + ScheduledFuture scheduledTask = (ScheduledFuture) scheduler.getQueue().peek(); + assertThat(scheduledTask).isNotNull(); + assertThat(scheduledTask.isCancelled()).isFalse(); + + // Cancel evaluation. cancelAll() should call coordinator.cancel(), cancelling debounce timer. + evalFuture.cancel(/* mayInterruptIfRunning= */ true); + + assertThat(scheduledTask.isCancelled()).isTrue(); + } finally { + scheduler.shutdownNow(); + } + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void + evalAsync_mapComprehensionConditionAsync_withInFlightIteration1AndConcreteIteration2_waitsForCompletion() + throws Exception { + CelExpr comprehensionExpr = + CelExpr.ofComprehension( + 1L, + "k", + "", + CelExpr.ofMap( + 2L, + ImmutableList.of( + CelExpr.ofMapEntry( + 3L, + CelExpr.ofConstant(4L, CelConstant.ofValue("a")), + CelExpr.ofConstant(5L, CelConstant.ofValue(1L)), + false), + CelExpr.ofMapEntry( + 6L, + CelExpr.ofConstant(7L, CelConstant.ofValue("b")), + CelExpr.ofConstant(8L, CelConstant.ofValue(2L)), + false))), + "acc", + CelExpr.ofConstant(9L, CelConstant.ofValue(0L)), + CelExpr.ofCall( + 10L, Optional.empty(), "asyncCond", ImmutableList.of(CelExpr.ofIdent(11L, "k"))), + CelExpr.ofConstant(12L, CelConstant.ofValue(100L)), + CelExpr.ofIdent(13L, "acc")); + CelAbstractSyntaxTree ast = + CelAbstractSyntaxTree.newParsedAst(comprehensionExpr, CelSource.newBuilder().build()); + + CountDownLatch callsDispatched = new CountDownLatch(2); + SettableFuture condFutureA = SettableFuture.create(); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncCond", + String.class, + (String arg) -> { + callsDispatched.countDown(); + if (arg.equals("a")) { + return condFutureA; + } + return immediateFuture(true); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture evalFuture = program.evalAsync(executor); + + // Wait until both iterations have been dispatched + assertThat(callsDispatched.await(5, SECONDS)).isTrue(); + + // When condition evaluation in iteration "a" is in-flight, it accumulates as an unknown. + // Even if iteration "b" condition evaluates immediately to true and step evaluates to concrete + // 100L, + // the overall comprehension result must wait for iteration "a" to resolve before proceeding. + assertThat(evalFuture.isDone()).isFalse(); + + condFutureA.set(true); + Object result = evalFuture.get(5, SECONDS); + + assertThat(result).isEqualTo(100L); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void + evalAsync_mapComprehensionLoopStepAsync_withInFlightIteration1AndConcreteIteration2_waitsForCompletion() + throws Exception { + CelExpr comprehensionExpr = + CelExpr.ofComprehension( + 1L, + "k", + "", + CelExpr.ofMap( + 2L, + ImmutableList.of( + CelExpr.ofMapEntry( + 3L, + CelExpr.ofConstant(4L, CelConstant.ofValue("a")), + CelExpr.ofConstant(5L, CelConstant.ofValue(1L)), + false), + CelExpr.ofMapEntry( + 6L, + CelExpr.ofConstant(7L, CelConstant.ofValue("b")), + CelExpr.ofConstant(8L, CelConstant.ofValue(2L)), + false))), + "acc", + CelExpr.ofConstant(9L, CelConstant.ofValue(0L)), + CelExpr.ofConstant(10L, CelConstant.ofValue(true)), + CelExpr.ofCall( + 11L, Optional.empty(), "asyncEcho", ImmutableList.of(CelExpr.ofIdent(12L, "k"))), + CelExpr.ofIdent(13L, "acc")); + CelAbstractSyntaxTree ast = + CelAbstractSyntaxTree.newParsedAst(comprehensionExpr, CelSource.newBuilder().build()); + + CountDownLatch callsDispatched = new CountDownLatch(2); + SettableFuture futureA = SettableFuture.create(); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncEcho", + String.class, + (String arg) -> { + callsDispatched.countDown(); + if (arg.equals("a")) { + return futureA; + } + return immediateFuture(20L); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture evalFuture = program.evalAsync(executor); + + // Wait until both iterations have been dispatched + assertThat(callsDispatched.await(5, SECONDS)).isTrue(); + + // With futureA in flight from iteration "a", even if iteration "b" evaluates to concrete 20L, + // comprehension evaluation must wait for the pending async loop step in iteration "a" to + // complete. + assertThat(evalFuture.isDone()).isFalse(); + + futureA.set(10L); + Object result = evalFuture.get(5, SECONDS); + + assertThat(result).isEqualTo(20L); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void + evalAsync_listComprehensionLoopStepAsync_withInFlightIteration1AndConcreteIteration2_waitsForCompletion() + throws Exception { + CelExpr comprehensionExpr = + CelExpr.ofComprehension( + 1L, + "x", + "", + CelExpr.ofList( + 2L, + ImmutableList.of( + CelExpr.ofConstant(3L, CelConstant.ofValue(1L)), + CelExpr.ofConstant(4L, CelConstant.ofValue(2L))), + ImmutableList.of()), + "acc", + CelExpr.ofConstant(5L, CelConstant.ofValue(0L)), + CelExpr.ofConstant(6L, CelConstant.ofValue(true)), + CelExpr.ofCall( + 7L, Optional.empty(), "asyncSquare", ImmutableList.of(CelExpr.ofIdent(8L, "x"))), + CelExpr.ofIdent(9L, "acc")); + CelAbstractSyntaxTree ast = + CelAbstractSyntaxTree.newParsedAst(comprehensionExpr, CelSource.newBuilder().build()); + + CountDownLatch callsDispatched = new CountDownLatch(2); + SettableFuture future1 = SettableFuture.create(); + + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare", + Long.class, + (Long arg) -> { + callsDispatched.countDown(); + if (arg == 1L) { + return future1; + } + return immediateFuture(20L); + })) + .build(); + Program program = runtime.createProgram(ast); + + ListenableFuture evalFuture = program.evalAsync(executor); + + // Wait until both iterations have been dispatched + assertThat(callsDispatched.await(5, SECONDS)).isTrue(); + + // With future1 in flight from iteration 1, even if iteration 2 evaluates to concrete 20L, + // list comprehension evaluation must wait for the pending async loop step in iteration 1 to + // complete. + assertThat(evalFuture.isDone()).isFalse(); + + future1.set(10L); + Object result = evalFuture.get(5, SECONDS); + + assertThat(result).isEqualTo(20L); + } + + @Test + public void eval_asyncFunctionInSynchronousMode_throwsCelEvaluationException() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("asyncSquare(2)").getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.fromAsync( + "asyncSquare_int", Long.class, (Long arg) -> immediateFuture(arg * arg))) + .build(); + Program program = runtime.createProgram(ast); + + CelEvaluationException e = assertThrows(CelEvaluationException.class, program::eval); + assertThat(e) + .hasMessageThat() + .contains("Async function 'asyncSquare' evaluated in synchronous mode"); + } + + @Test + @SuppressWarnings("Immutable") // Test only + public void evalAsync_programOverloads_evaluateSuccessfully() throws Exception { + CelAbstractSyntaxTree ast = CEL_COMPILER.compile("x + 1").getAst(); + CelRuntime runtime = CelRuntimeFactory.plannerRuntimeBuilder().build(); + Program program = runtime.createProgram(ast); + CelAsyncEvaluationOptions options = CelAsyncEvaluationOptions.defaultOptions(); + + // evalAsync(Map, executor, options) + assertThat(program.evalAsync(ImmutableMap.of("x", 2L), executor, options).get(5, SECONDS)) + .isEqualTo(3L); + + // evalAsync(CelVariableResolver, executor) + CelVariableResolver resolver = name -> name.equals("x") ? Optional.of(5L) : Optional.empty(); + assertThat(program.evalAsync(resolver, executor).get(5, SECONDS)).isEqualTo(6L); + + // evalAsync(CelVariableResolver, executor, options) + assertThat(program.evalAsync(resolver, executor, options).get(5, SECONDS)).isEqualTo(6L); + + // evalAsync(PartialVars, executor, options) + PartialVars partialVars = + PartialVars.of( + name -> name.equals("x") ? Optional.of(10L) : Optional.empty(), ImmutableList.of()); + assertThat(program.evalAsync(partialVars, executor, options).get(5, SECONDS)).isEqualTo(11L); + + // Late-bound function resolver overloads + CelAbstractSyntaxTree lateAst = CEL_COMPILER.compile("lateBoundAsync(x)").getAst(); + CelRuntime lateRuntime = + CelRuntimeFactory.plannerRuntimeBuilder().addLateBoundFunctions("lateBoundAsync").build(); + Program lateProgram = lateRuntime.createProgram(lateAst); + CelFunctionResolver lateResolver = + new CelFunctionResolver() { + @Override + public Optional findOverloadMatchingArgs( + String functionName, Collection overloadIds, Object[] args) { + if (functionName.equals("lateBoundAsync")) { + return Optional.of( + CelResolvedOverload.of( + functionName, + "lateBoundAsync_int", + CelFunctionBinding.fromAsync( + "lateBoundAsync_int", + Long.class, + (Long arg) -> immediateFuture(arg * 10L)) + .getDefinition(), + /* isStrict= */ true, + Long.class)); + } + return Optional.empty(); + } + + @Override + public Optional findOverloadMatchingArgs( + String functionName, Object[] args) { + return findOverloadMatchingArgs(functionName, ImmutableList.of(), args); + } + }; + + // evalAsync(Map, lateBoundResolver, executor) + assertThat( + lateProgram.evalAsync(ImmutableMap.of("x", 2L), lateResolver, executor).get(5, SECONDS)) + .isEqualTo(20L); + + // evalAsync(Map, lateBoundResolver, executor, options) + assertThat( + lateProgram + .evalAsync(ImmutableMap.of("x", 3L), lateResolver, executor, options) + .get(5, SECONDS)) + .isEqualTo(30L); + + // evalAsync(CelVariableResolver, lateBoundResolver, executor) + assertThat(lateProgram.evalAsync(resolver, lateResolver, executor).get(5, SECONDS)) + .isEqualTo(50L); + + // evalAsync(CelVariableResolver, lateBoundResolver, executor, options) + assertThat(lateProgram.evalAsync(resolver, lateResolver, executor, options).get(5, SECONDS)) + .isEqualTo(50L); + + // Protobuf Message overloads + CelCompiler protoCompiler = + CelCompilerFactory.standardCelCompilerBuilder() + .addVar("single_int64", SimpleType.INT) + .build(); + CelAbstractSyntaxTree protoAst = protoCompiler.compile("single_int64").getAst(); + CelRuntime protoRuntime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addMessageTypes(TestAllTypes.getDescriptor()) + .build(); + CelRuntime.Program protoProgram = protoRuntime.createProgram(protoAst); + TestAllTypes message = TestAllTypes.newBuilder().setSingleInt64(42L).build(); + + assertThat(protoProgram.evalAsync(message, executor).get(5, SECONDS)).isEqualTo(42L); + assertThat(protoProgram.evalAsync(message, executor, options).get(5, SECONDS)).isEqualTo(42L); + } + + @Test + public void evalAsync_defaultImplementation_throwsUnsupportedOperationException() { + Program program = + new Program() { + @Override + public Object eval() { + return null; + } + + @Override + public Object eval(Map mapValue) { + return null; + } + + @Override + public Object eval( + Map mapValue, CelFunctionResolver lateBoundFunctionResolver) { + return null; + } + + @Override + public Object eval(CelVariableResolver resolver) { + return null; + } + + @Override + public Object eval( + CelVariableResolver resolver, CelFunctionResolver lateBoundFunctionResolver) { + return null; + } + + @Override + public Object eval(PartialVars partialVars) { + return null; + } + }; + + UnsupportedOperationException e = + assertThrows(UnsupportedOperationException.class, () -> program.evalAsync(executor)); + assertThat(e) + .hasMessageThat() + .contains("evalAsync is not supported by this Program implementation."); + } +} diff --git a/runtime/src/test/java/dev/cel/runtime/planner/ProgramPlannerTest.java b/runtime/src/test/java/dev/cel/runtime/planner/ProgramPlannerTest.java index a3b1e3596..fbf701d57 100644 --- a/runtime/src/test/java/dev/cel/runtime/planner/ProgramPlannerTest.java +++ b/runtime/src/test/java/dev/cel/runtime/planner/ProgramPlannerTest.java @@ -14,6 +14,7 @@ package dev.cel.runtime.planner; +import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.truth.Truth.assertThat; import static dev.cel.common.CelFunctionDecl.newFunctionDeclaration; import static dev.cel.common.CelOverloadDecl.newGlobalOverload; @@ -92,7 +93,9 @@ public final class ProgramPlannerTest { private static final CelTypeProvider TYPE_PROVIDER = new CombinedCelTypeProvider( DefaultTypeProvider.getInstance(), - new ProtoMessageTypeProvider(ImmutableSet.of(TestAllTypes.getDescriptor()))); + ProtoMessageTypeProvider.newBuilder() + .addDescriptors(ImmutableSet.of(TestAllTypes.getDescriptor())) + .build()); private static final RuntimeEquality RUNTIME_EQUALITY = RuntimeEquality.create(RuntimeHelpers.create(), CEL_OPTIONS); private static final CelDescriptorPool DESCRIPTOR_POOL = @@ -255,9 +258,7 @@ private static DefaultDispatcher newDispatcher() { private static void addBindingsToDispatcher( DefaultDispatcher.Builder builder, ImmutableCollection overloadBindings) { - if (overloadBindings.isEmpty()) { - throw new IllegalArgumentException("Invalid bindings"); - } + checkArgument(!overloadBindings.isEmpty(), "Invalid bindings"); overloadBindings.forEach( overload -> @@ -519,7 +520,7 @@ public void plan_call_throws() throws Exception { .hasMessageThat() .contains("evaluation error at :5: Function 'error' failed with arg(s) ''"); assertThat(e).hasCauseThat().isInstanceOf(IllegalArgumentException.class); - assertThat(e.getCause()).hasMessageThat().contains("Intentional error"); + assertThat(e).hasCauseThat().hasMessageThat().contains("Intentional error"); } @Test @@ -1202,6 +1203,64 @@ public void plan_foldMap_withUnknownLoopCondition_earlyReturn() throws Exception CelUnknownSet.create(ImmutableSet.of(CelAttribute.create("unk")), ImmutableSet.of(7L))); } + @Test + public void plan_foldMap_shortCircuitCondition_resultResolvesOuterScopeVariable() + throws Exception { + CelExpr comprehensionExpr = + CelExpr.ofComprehension( + 1L, + "k", + "", + CelExpr.ofMap( + 2L, + ImmutableList.of( + CelExpr.ofMapEntry( + 3L, + CelExpr.ofConstant(4L, CelConstant.ofValue("inner_k")), + CelExpr.ofConstant(5L, CelConstant.ofValue(1L)), + false))), + "acc", + CelExpr.ofConstant(6L, CelConstant.ofValue(true)), + CelExpr.ofConstant(7L, CelConstant.ofValue(false)), + CelExpr.ofIdent(8L, "acc"), + CelExpr.ofIdent(9L, "k")); + CelAbstractSyntaxTree ast = + CelAbstractSyntaxTree.newParsedAst(comprehensionExpr, CelSource.newBuilder().build()); + + Program program = PLANNER.plan(ast); + + Object result = program.eval(ImmutableMap.of("k", "outer_k")); + + assertThat(result).isEqualTo("outer_k"); + } + + @Test + public void plan_foldList_shortCircuitCondition_resultResolvesOuterScopeVariable() + throws Exception { + CelExpr comprehensionExpr = + CelExpr.ofComprehension( + 1L, + "x", + "", + CelExpr.ofList( + 2L, + ImmutableList.of(CelExpr.ofConstant(3L, CelConstant.ofValue("inner_x"))), + ImmutableList.of()), + "acc", + CelExpr.ofConstant(4L, CelConstant.ofValue(true)), + CelExpr.ofConstant(5L, CelConstant.ofValue(false)), + CelExpr.ofIdent(6L, "acc"), + CelExpr.ofIdent(7L, "x")); + CelAbstractSyntaxTree ast = + CelAbstractSyntaxTree.newParsedAst(comprehensionExpr, CelSource.newBuilder().build()); + + Program program = PLANNER.plan(ast); + + Object result = program.eval(ImmutableMap.of("x", "outer_x")); + + assertThat(result).isEqualTo("outer_x"); + } + @Test public void plan_binaryFunction_withUnknownArg() throws Exception { CelCompiler compiler =