From 59d05936f3d66af144252edcc9bd2a9da479c31e Mon Sep 17 00:00:00 2001 From: Sean Huh Date: Wed, 2 Sep 2026 10:54:09 -0700 Subject: [PATCH] Add plumbing for executor, async evaluation option. Define program API contracts PiperOrigin-RevId: 975218617 --- .bazelrc | 2 +- publish/BUILD.bazel | 4 + runtime/BUILD.bazel | 49 +++ .../src/main/java/dev/cel/runtime/BUILD.bazel | 122 ++++++ .../java/dev/cel/runtime/CelAsyncCall.java | 34 ++ .../dev/cel/runtime/CelAsyncDrainAction.java | 57 +++ .../cel/runtime/CelAsyncDrainStrategy.java | 101 +++++ .../runtime/CelAsyncEvaluationOptions.java | 141 +++++++ .../cel/runtime/CelAsyncFunctionOverload.java | 57 +++ .../dev/cel/runtime/CelAsyncObserver.java | 46 +++ .../dev/cel/runtime/CelFunctionBinding.java | 81 ++++ .../dev/cel/runtime/CelFunctionResolver.java | 18 +- .../main/java/dev/cel/runtime/CelRuntime.java | 7 + .../dev/cel/runtime/CelRuntimeBuilder.java | 17 + .../java/dev/cel/runtime/CelRuntimeImpl.java | 60 ++- .../dev/cel/runtime/CelRuntimeLegacyImpl.java | 36 +- .../main/java/dev/cel/runtime/Program.java | 30 ++ .../java/dev/cel/runtime/ProgramImpl.java | 45 +++ .../java/dev/cel/runtime/planner/BUILD.bazel | 2 + .../cel/runtime/planner/PlannedProgram.java | 33 ++ .../src/test/java/dev/cel/runtime/BUILD.bazel | 3 +- .../runtime/CelAsyncDrainStrategyTest.java | 299 ++++++++++++++ .../CelAsyncEvaluationOptionsTest.java | 120 ++++++ .../cel/runtime/CelRuntimeLegacyImplTest.java | 48 ++- .../cel/runtime/FunctionBindingImplTest.java | 365 ++++++++++++++++++ 25 files changed, 1768 insertions(+), 9 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/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 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/src/main/java/dev/cel/runtime/BUILD.bazel b/runtime/src/main/java/dev/cel/runtime/BUILD.bazel index 489bb64d8..9518e1601 100644 --- a/runtime/src/main/java/dev/cel/runtime/BUILD.bazel +++ b/runtime/src/main/java/dev/cel/runtime/BUILD.bazel @@ -784,6 +784,7 @@ cel_android_library( java_library( name = "function_overload", srcs = [ + "CelAsyncFunctionOverload.java", "CelFunctionOverload.java", "OptimizedFunctionOverload.java", ], @@ -800,9 +801,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 +821,7 @@ java_library( tags = [ ], deps = [ + ":async_options", ":descriptor_type_resolver", ":dispatcher", ":evaluation_exception", @@ -867,6 +872,7 @@ java_library( tags = [ ], deps = [ + ":async_options", ":descriptor_message_provider", ":descriptor_type_resolver", ":dispatcher", @@ -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,6 +1285,118 @@ cel_android_library( ], ) +java_library( + name = "async_call", + srcs = ["CelAsyncCall.java"], + tags = [ + ], + deps = [ + "@maven//:com_google_code_findbugs_annotations", + "@maven//:com_google_errorprone_error_prone_annotations", + ], +) + +cel_android_library( + name = "async_call_android", + srcs = ["CelAsyncCall.java"], + tags = [ + ], + deps = [ + "@maven//:com_google_code_findbugs_annotations", + "@maven//:com_google_errorprone_error_prone_annotations", + ], +) + +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//:com_google_guava_guava", + "@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", + "@maven_android//:com_google_guava_guava", + ], +) + +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"], @@ -1288,6 +1408,7 @@ java_library( ":partial_vars", ":variable_resolver", "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:com_google_guava_guava", ], ) @@ -1302,6 +1423,7 @@ cel_android_library( ":partial_vars_android", ":variable_resolver", "@maven//:com_google_errorprone_error_prone_annotations", + "@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..1582f12a1 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/CelAsyncCall.java @@ -0,0 +1,34 @@ +// 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; + +/** Describes a pending or completed asynchronous function call. */ +@ThreadSafe +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(); +} 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..6d8349f8e --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/CelAsyncDrainStrategy.java @@ -0,0 +1,101 @@ +// 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. + */ + 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) -> { + checkNotNull(completed, "completedBatch must not be null"); + checkArgument(active >= 0, "activeCallsCount must be non-negative: %s", active); + return 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) -> { + checkNotNull(completed, "completedBatch must not be null"); + checkArgument(active >= 0, "activeCallsCount must be non-negative: %s", active); + return 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) { + checkNotNull(completedBatch, "completedBatch must not be null"); + checkArgument( + activeCallsCount >= 0, "activeCallsCount must be non-negative: %s", activeCallsCount); + if (activeCallsCount == 0) { + return CelAsyncDrainAction.reevaluate(); + } + if (completedBatch.isEmpty()) { + return CelAsyncDrainAction.waitForMore(); + } + if (debounce.isZero()) { + return CelAsyncDrainAction.reevaluate(); + } + 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..19609e923 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/CelAsyncEvaluationOptions.java @@ -0,0 +1,141 @@ +// 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.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicLong; + +/** Options for configuring asynchronous CEL evaluation. */ +@AutoValue +@ThreadSafe +public abstract class CelAsyncEvaluationOptions { + + private static final int DEFAULT_MAX_CONCURRENCY = 100; + private 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(); + + /** + * Returns the custom configured {@link ScheduledExecutorService}, if present. + * + *

If absent, {@link #resolveScheduledExecutorService()} falls back to an internal, shared + * single-threaded daemon scheduler. + */ + public abstract Optional scheduledExecutorService(); + + /** Returns the configured lifecycle observer, if present. */ + public abstract Optional observer(); + + /** + * Resolves the {@link ScheduledExecutorService} used for debounce timers, falling back to a + * shared, lazily initialized single-threaded daemon scheduler (named {@code + * cel-async-debounce-*}) if not custom-configured. + * + *

The scheduler is used exclusively as an alarm clock to trigger continuation wakeups; it does + * not execute CEL evaluation tasks. + */ + public ScheduledExecutorService resolveScheduledExecutorService() { + return scheduledExecutorService().orElse(DefaultDebounceSchedulerHolder.INSTANCE); + } + + public abstract Builder toBuilder(); + + /** + * Returns a new {@link Builder} initialized with standard default options: + * + *

+ */ + public static Builder newBuilder() { + return new AutoValue_CelAsyncEvaluationOptions.Builder() + .setMaxConcurrency(DEFAULT_MAX_CONCURRENCY) + .setDrainStrategy(CelAsyncDrainStrategy.drainReady()) + .setMaxIterations(DEFAULT_MAX_ITERATIONS); + } + + /** + * Returns a new {@link Builder} initialized with standard default options. + * + *

Equivalent to calling {@link #newBuilder()}. + */ + public static Builder builder() { + return newBuilder(); + } + + /** + * Returns a {@link CelAsyncEvaluationOptions} instance with the {@link #newBuilder() default + * configuration}. + */ + 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); + + /** + * Sets a custom {@link ScheduledExecutorService} for debounce timers. + * + *

If not set, defaults to an internal, shared single-threaded daemon scheduler. + */ + public abstract Builder setScheduledExecutorService( + ScheduledExecutorService scheduledExecutorService); + + public abstract Builder setObserver(CelAsyncObserver observer); + + public abstract CelAsyncEvaluationOptions build(); + } + + // Package-private constructor prevents extension outside package while allowing AutoValue. + CelAsyncEvaluationOptions() {} +} 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..685c81573 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/CelAsyncFunctionOverload.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 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. */ + 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. */ + @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..31b001115 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/CelAsyncObserver.java @@ -0,0 +1,46 @@ +// 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 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. + * + * @param call The call description. + * @param args The evaluated arguments passed to the function call. + */ + void onCallStarted(CelAsyncCall call, ImmutableList args); + + /** + * 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..3b0084394 100644 --- a/runtime/src/main/java/dev/cel/runtime/CelFunctionBinding.java +++ b/runtime/src/main/java/dev/cel/runtime/CelFunctionBinding.java @@ -15,10 +15,12 @@ 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; @@ -100,6 +102,74 @@ 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); + } /** See {@link #fromOverloads(String, Collection)}. */ static ImmutableSet fromOverloads( @@ -110,11 +180,22 @@ static ImmutableSet fromOverloads( /** * Creates a set of bindings for a function, enabling dynamic dispatch logic to select the correct * overload at runtime based on argument types. + * + *

Note: Overloaded functions with {@link CelAsyncFunctionOverload} are not currently + * supported. */ 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."); + // TODO: Dynamic dispatch grouping does not currently support asynchronous + // function overloads. In parsed-only mode, overloaded async functions must be resolved + // at runtime via CelFunctionResolver. + 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..836c48d8b 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. * @@ -33,7 +49,7 @@ public interface CelFunctionResolver { * from this list with matching arguments. * @param args The arguments to pass to the function. * @return an optional value of the resolved overload. - * @throws CelEvaluationException if the overload resolution is ambiguous, + * @throws CelEvaluationException if the overload resolution is ambiguous. */ Optional findOverloadMatchingArgs( String functionName, Collection overloadIds, Object[] args) diff --git a/runtime/src/main/java/dev/cel/runtime/CelRuntime.java b/runtime/src/main/java/dev/cel/runtime/CelRuntime.java index 1e7fdcac8..e9c6ca20a 100644 --- a/runtime/src/main/java/dev/cel/runtime/CelRuntime.java +++ b/runtime/src/main/java/dev/cel/runtime/CelRuntime.java @@ -14,6 +14,7 @@ package dev.cel.runtime; +import com.google.common.util.concurrent.ListenableFuture; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.Immutable; import javax.annotation.concurrent.ThreadSafe; @@ -42,6 +43,12 @@ 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. + */ + ListenableFuture evalAsync(Message message); + /** * 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/CelRuntimeBuilder.java b/runtime/src/main/java/dev/cel/runtime/CelRuntimeBuilder.java index 00f6e3bf7..feacf5e37 100644 --- a/runtime/src/main/java/dev/cel/runtime/CelRuntimeBuilder.java +++ b/runtime/src/main/java/dev/cel/runtime/CelRuntimeBuilder.java @@ -14,6 +14,7 @@ package dev.cel.runtime; +import com.google.common.util.concurrent.ListeningExecutorService; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.CheckReturnValue; import com.google.protobuf.DescriptorProtos.FileDescriptorSet; @@ -214,6 +215,22 @@ public interface CelRuntimeBuilder { @CanIgnoreReturnValue CelRuntimeBuilder setContainer(CelContainer container); + /** + * Sets options to use for asynchronous evaluation. + * + *

If not configured, defaults to {@link CelAsyncEvaluationOptions#defaultOptions()}. + */ + @CanIgnoreReturnValue + CelRuntimeBuilder setAsyncEvaluationOptions(CelAsyncEvaluationOptions asyncEvaluationOptions); + + /** + * Sets the executor to use for asynchronous evaluation. + * + *

This executor is required when evaluating expressions asynchronously via {@link + * Program#evalAsync}. + */ + @CanIgnoreReturnValue + CelRuntimeBuilder setAsyncExecutor(ListeningExecutorService asyncExecutor); /** Build a new instance of the {@code CelRuntime}. */ @CheckReturnValue diff --git a/runtime/src/main/java/dev/cel/runtime/CelRuntimeImpl.java b/runtime/src/main/java/dev/cel/runtime/CelRuntimeImpl.java index 5cda25800..857434ba2 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; @@ -95,6 +97,16 @@ public abstract class CelRuntimeImpl implements CelRuntime { @AutoValue.CopyAnnotations abstract @Nullable ExtensionRegistry extensionRegistry(); + // CelAsyncEvaluationOptions is an immutable value object configuring asynchronous evaluation. + @SuppressWarnings("Immutable") + @AutoValue.CopyAnnotations + abstract CelAsyncEvaluationOptions asyncEvaluationOptions(); + + // The executor service is an externally managed, thread-safe asynchronous execution pool. + @SuppressWarnings("Immutable") + @AutoValue.CopyAnnotations + abstract Optional asyncExecutor(); + @Override public Program createProgram(CelAbstractSyntaxTree ast) throws CelEvaluationException { return toRuntimeProgram(planner().plan(ast)); @@ -162,6 +174,44 @@ public Object eval(PartialVars partialVars) throws CelEvaluationException { return program.eval(partialVars); } + @Override + public ListenableFuture evalAsync() { + return program.evalAsync(); + } + + @Override + public ListenableFuture evalAsync(Map mapValue) { + return program.evalAsync(mapValue); + } + + @Override + public ListenableFuture evalAsync( + Map mapValue, CelFunctionResolver lateBoundFunctionResolver) { + return program.evalAsync(mapValue, lateBoundFunctionResolver); + } + + @Override + public ListenableFuture evalAsync(Message message) { + throw new UnsupportedOperationException( + "evalAsync is not supported by this Program implementation."); + } + + @Override + public ListenableFuture evalAsync(CelVariableResolver resolver) { + return program.evalAsync(resolver); + } + + @Override + public ListenableFuture evalAsync( + CelVariableResolver resolver, CelFunctionResolver lateBoundFunctionResolver) { + return program.evalAsync(resolver, lateBoundFunctionResolver); + } + + @Override + public ListenableFuture evalAsync(PartialVars partialVars) { + return program.evalAsync(partialVars); + } + @Override public Object trace(CelEvaluationListener listener) throws CelEvaluationException { return ((PlannedProgram) program) @@ -253,7 +303,8 @@ public static Builder newBuilder() { .setFunctionBindings(ImmutableMap.of()) .setStandardFunctions(CelStandardFunctions.newBuilder().build()) .setContainer(CelContainer.newBuilder().build()) - .setExtensionRegistry(ExtensionRegistry.getEmptyRegistry()); + .setExtensionRegistry(ExtensionRegistry.getEmptyRegistry()) + .setAsyncEvaluationOptions(CelAsyncEvaluationOptions.defaultOptions()); } /** Builder for {@link CelRuntimeImpl}. */ @@ -280,6 +331,13 @@ public abstract static class Builder implements CelRuntimeBuilder { @Override public abstract Builder setContainer(CelContainer container); + @Override + public abstract Builder setAsyncEvaluationOptions( + CelAsyncEvaluationOptions asyncEvaluationOptions); + + @Override + public abstract Builder setAsyncExecutor(ListeningExecutorService asyncExecutor); + abstract CelOptions options(); abstract CelContainer container(); diff --git a/runtime/src/main/java/dev/cel/runtime/CelRuntimeLegacyImpl.java b/runtime/src/main/java/dev/cel/runtime/CelRuntimeLegacyImpl.java index 428c6dba5..144de7e9d 100644 --- a/runtime/src/main/java/dev/cel/runtime/CelRuntimeLegacyImpl.java +++ b/runtime/src/main/java/dev/cel/runtime/CelRuntimeLegacyImpl.java @@ -20,6 +20,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; +import com.google.common.util.concurrent.ListeningExecutorService; import com.google.errorprone.annotations.CanIgnoreReturnValue; import javax.annotation.concurrent.ThreadSafe; import com.google.protobuf.DescriptorProtos.FileDescriptorSet; @@ -84,6 +85,8 @@ public final class CelRuntimeLegacyImpl implements CelRuntime { private final ImmutableSet celRuntimeLibraries; private final ImmutableList celFunctionBindings; + private final CelAsyncEvaluationOptions asyncEvaluationOptions; + private final @Nullable ListeningExecutorService asyncExecutor; @Override public CelRuntime.Program createProgram(CelAbstractSyntaxTree ast) { @@ -101,7 +104,8 @@ public CelRuntimeBuilder toRuntimeBuilder() { .setExtensionRegistry(extensionRegistry) .addFileTypes(fileDescriptors) .addLibraries(celRuntimeLibraries) - .addFunctionBindings(celFunctionBindings); + .addFunctionBindings(celFunctionBindings) + .setAsyncEvaluationOptions(asyncEvaluationOptions); if (customTypeFactory != null) { builder.setTypeFactory(customTypeFactory); @@ -111,6 +115,9 @@ public CelRuntimeBuilder toRuntimeBuilder() { builder.setStandardFunctions(overriddenStandardFunctions); } + if (asyncExecutor != null) { + builder.setAsyncExecutor(asyncExecutor); + } return builder; } @@ -132,6 +139,8 @@ public static final class Builder implements CelRuntimeBuilder { @VisibleForTesting Function customTypeFactory; @VisibleForTesting CelStandardFunctions overriddenStandardFunctions; + @VisibleForTesting CelAsyncEvaluationOptions asyncEvaluationOptions; + @VisibleForTesting @Nullable ListeningExecutorService asyncExecutor; private CelOptions options; @@ -257,6 +266,19 @@ public CelRuntimeBuilder setContainer(CelContainer container) { "This method is not supported for the legacy runtime"); } + @Override + public CelRuntimeBuilder setAsyncEvaluationOptions( + CelAsyncEvaluationOptions asyncEvaluationOptions) { + this.asyncEvaluationOptions = checkNotNull(asyncEvaluationOptions); + return this; + } + + @Override + public CelRuntimeBuilder setAsyncExecutor(ListeningExecutorService asyncExecutor) { + this.asyncExecutor = checkNotNull(asyncExecutor); + return this; + } + /** Build a new {@code CelRuntimeLegacyImpl} instance from the builder config. */ @Override public CelRuntimeLegacyImpl build() { @@ -357,7 +379,9 @@ public CelRuntimeLegacyImpl build() { overriddenStandardFunctions, fileDescriptors, runtimeLibraries, - ImmutableList.copyOf(customFunctionBindings.values())); + ImmutableList.copyOf(customFunctionBindings.values()), + asyncEvaluationOptions, + asyncExecutor); } private ImmutableSet newStandardFunctionBindings( @@ -432,6 +456,8 @@ private Builder() { this.celRuntimeLibraries = ImmutableSet.builder(); this.extensionRegistry = ExtensionRegistry.getEmptyRegistry(); this.customTypeFactory = null; + this.asyncEvaluationOptions = CelAsyncEvaluationOptions.defaultOptions(); + this.asyncExecutor = null; } } @@ -444,7 +470,9 @@ private CelRuntimeLegacyImpl( @Nullable CelStandardFunctions overriddenStandardFunctions, ImmutableSet fileDescriptors, ImmutableSet celRuntimeLibraries, - ImmutableList celFunctionBindings) { + ImmutableList celFunctionBindings, + CelAsyncEvaluationOptions asyncEvaluationOptions, + @Nullable ListeningExecutorService asyncExecutor) { this.interpreter = interpreter; this.options = options; this.standardEnvironmentEnabled = standardEnvironmentEnabled; @@ -454,5 +482,7 @@ private CelRuntimeLegacyImpl( this.fileDescriptors = fileDescriptors; this.celRuntimeLibraries = celRuntimeLibraries; this.celFunctionBindings = celFunctionBindings; + this.asyncEvaluationOptions = asyncEvaluationOptions; + this.asyncExecutor = asyncExecutor; } } diff --git a/runtime/src/main/java/dev/cel/runtime/Program.java b/runtime/src/main/java/dev/cel/runtime/Program.java index e808a373c..c9df239eb 100644 --- a/runtime/src/main/java/dev/cel/runtime/Program.java +++ b/runtime/src/main/java/dev/cel/runtime/Program.java @@ -14,6 +14,7 @@ package dev.cel.runtime; +import com.google.common.util.concurrent.ListenableFuture; import com.google.errorprone.annotations.Immutable; import java.util.Map; @@ -46,4 +47,33 @@ 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. */ + ListenableFuture evalAsync(); + + /** + * Evaluate the expression asynchronously using a {@code mapValue} as the source of input + * variables. + */ + ListenableFuture evalAsync(Map mapValue); + + /** + * Evaluate the expression asynchronously using a {@code mapValue} as the source of input + * variables and late-bound functions {@code lateBoundFunctionResolver}. + */ + ListenableFuture evalAsync( + Map mapValue, CelFunctionResolver lateBoundFunctionResolver); + + /** Evaluate the expression asynchronously with a custom variable {@code resolver}. */ + ListenableFuture evalAsync(CelVariableResolver resolver); + + /** + * Evaluate the expression asynchronously with a custom variable {@code resolver} and late-bound + * functions {@code lateBoundFunctionResolver}. + */ + ListenableFuture evalAsync( + CelVariableResolver resolver, CelFunctionResolver lateBoundFunctionResolver); + + /** Evaluate the expression asynchronously with unknown attribute patterns {@code partialVars}. */ + ListenableFuture evalAsync(PartialVars partialVars); } diff --git a/runtime/src/main/java/dev/cel/runtime/ProgramImpl.java b/runtime/src/main/java/dev/cel/runtime/ProgramImpl.java index 2543a9525..cc6795561 100644 --- a/runtime/src/main/java/dev/cel/runtime/ProgramImpl.java +++ b/runtime/src/main/java/dev/cel/runtime/ProgramImpl.java @@ -16,6 +16,7 @@ import com.google.auto.value.AutoValue; import com.google.common.base.Preconditions; +import com.google.common.util.concurrent.ListenableFuture; import com.google.errorprone.annotations.Immutable; import com.google.protobuf.Message; import dev.cel.common.CelOptions; @@ -68,6 +69,50 @@ public Object eval(PartialVars partialVars) throws CelEvaluationException { /* listener= */ Optional.empty()); } + @Override + public ListenableFuture evalAsync() { + throw new UnsupportedOperationException( + "evalAsync is not supported by the legacy interpreter."); + } + + @Override + public ListenableFuture evalAsync(Map mapValue) { + throw new UnsupportedOperationException( + "evalAsync is not supported by the legacy interpreter."); + } + + @Override + public ListenableFuture evalAsync( + Map mapValue, CelFunctionResolver lateBoundFunctionResolver) { + throw new UnsupportedOperationException( + "evalAsync is not supported by the legacy interpreter."); + } + + @Override + public ListenableFuture evalAsync(Message message) { + throw new UnsupportedOperationException( + "evalAsync is not supported by the legacy interpreter."); + } + + @Override + public ListenableFuture evalAsync(CelVariableResolver resolver) { + throw new UnsupportedOperationException( + "evalAsync is not supported by the legacy interpreter."); + } + + @Override + public ListenableFuture evalAsync( + CelVariableResolver resolver, CelFunctionResolver lateBoundFunctionResolver) { + throw new UnsupportedOperationException( + "evalAsync is not supported by the legacy interpreter."); + } + + @Override + public ListenableFuture evalAsync(PartialVars partialVars) { + throw new UnsupportedOperationException( + "evalAsync is not supported by the legacy interpreter."); + } + @Override public Object trace(CelEvaluationListener listener) throws CelEvaluationException { return evalInternal(Activation.EMPTY, listener); 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..d4dbb1659 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel +++ b/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel @@ -92,6 +92,7 @@ java_library( "//runtime:resolved_overload", "//runtime:variable_resolver", "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:com_google_guava_guava", "@maven//:org_jspecify_jspecify", ], ) @@ -622,6 +623,7 @@ cel_android_library( "//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", ], ) 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..f7f3d7f01 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/PlannedProgram.java +++ b/runtime/src/main/java/dev/cel/runtime/planner/PlannedProgram.java @@ -15,6 +15,7 @@ package dev.cel.runtime.planner; import com.google.auto.value.AutoValue; +import com.google.common.util.concurrent.ListenableFuture; import com.google.errorprone.annotations.Immutable; import dev.cel.common.CelOptions; import dev.cel.common.annotations.Internal; @@ -129,6 +130,38 @@ public Object eval(PartialVars partialVars) throws CelEvaluationException { /* listener= */ null); } + @Override + public ListenableFuture evalAsync() { + throw new UnsupportedOperationException("evalAsync is not supported by PlannedProgram."); + } + + @Override + public ListenableFuture evalAsync(Map mapValue) { + throw new UnsupportedOperationException("evalAsync is not supported by PlannedProgram."); + } + + @Override + public ListenableFuture evalAsync( + Map mapValue, CelFunctionResolver lateBoundFunctionResolver) { + throw new UnsupportedOperationException("evalAsync is not supported by PlannedProgram."); + } + + @Override + public ListenableFuture evalAsync(CelVariableResolver resolver) { + throw new UnsupportedOperationException("evalAsync is not supported by PlannedProgram."); + } + + @Override + public ListenableFuture evalAsync( + CelVariableResolver resolver, CelFunctionResolver lateBoundFunctionResolver) { + throw new UnsupportedOperationException("evalAsync is not supported by PlannedProgram."); + } + + @Override + public ListenableFuture evalAsync(PartialVars partialVars) { + throw new UnsupportedOperationException("evalAsync is not supported by PlannedProgram."); + } + public Object evalOrThrow( PlannedInterpretable interpretable, GlobalResolver resolver, diff --git a/runtime/src/test/java/dev/cel/runtime/BUILD.bazel b/runtime/src/test/java/dev/cel/runtime/BUILD.bazel index a2e44223a..f898b66fe 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", @@ -69,9 +70,9 @@ java_library( "//runtime:evaluation_exception_builder", "//runtime:evaluation_listener", "//runtime:function_binding", + "//runtime:function_resolver", "//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..e93b8688c --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/CelAsyncDrainStrategyTest.java @@ -0,0 +1,299 @@ +// 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"; + } + }; + + @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_hasCompletedBatch_reevaluates() { + 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_zeroDebounce_emptyBatchWithActiveCalls_waitsForMore() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainReady(Duration.ZERO); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(), 5); + + assertThat(action.shouldReevaluate()).isFalse(); + assertThat(action.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_noActiveCalls_reevaluates() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainNone(); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(), 0); + + assertThat(action.shouldReevaluate()).isTrue(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainNone_withCompletedBatchAndActiveCalls_reevaluates() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainNone(); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(DUMMY_CALL), 2); + + assertThat(action.shouldReevaluate()).isTrue(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainNone_emptyBatchWithActiveCalls_waitsForMore() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainNone(); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(), 2); + + assertThat(action.shouldReevaluate()).isFalse(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainAll_noActiveCalls_reevaluates() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainAll(); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(), 0); + + assertThat(action.shouldReevaluate()).isTrue(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainAll_withCompletedBatchAndNoActiveCalls_reevaluates() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainAll(); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(DUMMY_CALL), 0); + + assertThat(action.shouldReevaluate()).isTrue(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainAll_withCompletedBatchAndActiveCalls_waitsForMore() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainAll(); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(DUMMY_CALL), 1); + + assertThat(action.shouldReevaluate()).isFalse(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainAll_emptyBatchWithActiveCalls_waitsForMore() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainAll(); + + CelAsyncDrainAction action = strategy.nextAction(ImmutableList.of(), 1); + + assertThat(action.shouldReevaluate()).isFalse(); + assertThat(action.waitDuration()).isEqualTo(Duration.ZERO); + } + + @Test + public void drainReady_nullDebounce_throwsException() { + assertThrows(NullPointerException.class, () -> CelAsyncDrainStrategy.drainReady(null)); + } + + @Test + public void drainReady_nullCompletedBatch_throwsException() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainReady(); + + assertThrows(NullPointerException.class, () -> strategy.nextAction(null, 1)); + } + + @Test + public void drainReady_negativeActiveCalls_throwsException() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainReady(); + + assertThrows(IllegalArgumentException.class, () -> strategy.nextAction(ImmutableList.of(), -1)); + } + + @Test + public void drainNone_nullCompletedBatch_throwsException() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainNone(); + + assertThrows(NullPointerException.class, () -> strategy.nextAction(null, 1)); + } + + @Test + public void drainNone_negativeActiveCalls_throwsException() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainNone(); + + assertThrows(IllegalArgumentException.class, () -> strategy.nextAction(ImmutableList.of(), -1)); + } + + @Test + public void drainAll_nullCompletedBatch_throwsException() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainAll(); + + assertThrows(NullPointerException.class, () -> strategy.nextAction(null, 1)); + } + + @Test + public void drainAll_negativeActiveCalls_throwsException() { + CelAsyncDrainStrategy strategy = CelAsyncDrainStrategy.drainAll(); + + assertThrows(IllegalArgumentException.class, () -> strategy.nextAction(ImmutableList.of(), -1)); + } + + @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))); + } + + @Test + public void drainAction_waitDuration_null_throwsException() { + assertThrows(NullPointerException.class, () -> CelAsyncDrainAction.waitDuration(null)); + } +} 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..fc26513e7 --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/CelAsyncEvaluationOptionsTest.java @@ -0,0 +1,120 @@ +// 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.common.collect.ImmutableList; +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.After; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class CelAsyncEvaluationOptionsTest { + + private ScheduledExecutorService customScheduler; + + @After + public void tearDown() { + if (customScheduler != null) { + customScheduler.shutdown(); + } + } + + @Test + public void defaultOptions_returnsDefaultValues() { + 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.scheduledExecutorService()).isEmpty(); + assertThat(options.resolveScheduledExecutorService()).isNotNull(); + } + + @Test + public void resolveScheduledExecutorService_defaultScheduler_runsAsDaemonThread() + throws Exception { + CelAsyncEvaluationOptions options = CelAsyncEvaluationOptions.defaultOptions(); + + 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_nonPositiveMaxConcurrency_roundTripsCleanly() { + CelAsyncEvaluationOptions unboundedZero = + CelAsyncEvaluationOptions.builder().setMaxConcurrency(0).build(); + CelAsyncEvaluationOptions unboundedNegative = + CelAsyncEvaluationOptions.builder().setMaxConcurrency(-1).build(); + + assertThat(unboundedZero.maxConcurrency()).isEqualTo(0); + assertThat(unboundedNegative.maxConcurrency()).isEqualTo(-1); + } + + @Test + public void builder_customValuesAndRoundTrip() { + CelAsyncDrainStrategy drainStrategy = CelAsyncDrainStrategy.drainReady(Duration.ofMillis(25)); + CelAsyncObserver observer = + new CelAsyncObserver() { + @Override + public void onCallStarted(CelAsyncCall call, ImmutableList args) {} + + @Override + public void onCallFinished( + CelAsyncCall call, @Nullable Object result, @Nullable Throwable error) {} + }; + customScheduler = Executors.newSingleThreadScheduledExecutor(); + + 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.scheduledExecutorService()).hasValue(customScheduler); + assertThat(options.resolveScheduledExecutorService()).isSameInstanceAs(customScheduler); + + CelAsyncEvaluationOptions copy = options.toBuilder().build(); + assertThat(copy).isEqualTo(options); + } +} diff --git a/runtime/src/test/java/dev/cel/runtime/CelRuntimeLegacyImplTest.java b/runtime/src/test/java/dev/cel/runtime/CelRuntimeLegacyImplTest.java index fec5fab41..5bef0c61e 100644 --- a/runtime/src/test/java/dev/cel/runtime/CelRuntimeLegacyImplTest.java +++ b/runtime/src/test/java/dev/cel/runtime/CelRuntimeLegacyImplTest.java @@ -15,7 +15,11 @@ 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.collect.ImmutableMap; +import com.google.common.util.concurrent.ListeningExecutorService; import com.google.protobuf.Message; import dev.cel.common.CelException; import dev.cel.common.exceptions.CelDivideByZeroException; @@ -23,8 +27,8 @@ import dev.cel.compiler.CelCompilerFactory; import dev.cel.expr.conformance.proto3.TestAllTypes; import dev.cel.runtime.CelStandardFunctions.StandardFunction; +import java.util.Optional; 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 +41,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 +124,44 @@ public void toRuntimeBuilder_optionalProperties() { assertThat(newRuntimeBuilder.overriddenStandardFunctions) .isEqualTo(overriddenStandardFunctions); } + + @Test + public void toRuntimeBuilder_asyncProperties_copied() { + ListeningExecutorService executor = newDirectExecutorService(); + CelAsyncEvaluationOptions options = + CelAsyncEvaluationOptions.newBuilder().setMaxConcurrency(5).build(); + CelRuntimeBuilder celRuntimeBuilder = + CelRuntimeFactory.standardCelRuntimeBuilder() + .setAsyncEvaluationOptions(options) + .setAsyncExecutor(executor); + CelRuntime celRuntime = celRuntimeBuilder.build(); + + CelRuntimeLegacyImpl.Builder newRuntimeBuilder = + (CelRuntimeLegacyImpl.Builder) celRuntime.toRuntimeBuilder(); + + assertThat(newRuntimeBuilder.asyncEvaluationOptions).isEqualTo(options); + assertThat(newRuntimeBuilder.asyncExecutor).isEqualTo(executor); + } + + @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()); + CelVariableResolver resolver = name -> Optional.of(1L); + + assertThrows(UnsupportedOperationException.class, program::evalAsync); + assertThrows(UnsupportedOperationException.class, () -> program.evalAsync(ImmutableMap.of())); + assertThrows( + UnsupportedOperationException.class, + () -> program.evalAsync(ImmutableMap.of(), CelFunctionResolver.EMPTY)); + assertThrows( + UnsupportedOperationException.class, + () -> program.evalAsync(TestAllTypes.getDefaultInstance())); + assertThrows(UnsupportedOperationException.class, () -> program.evalAsync(resolver)); + assertThrows( + UnsupportedOperationException.class, + () -> program.evalAsync(resolver, CelFunctionResolver.EMPTY)); + assertThrows(UnsupportedOperationException.class, () -> program.evalAsync((PartialVars) null)); + } } 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..0395cf560 --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/FunctionBindingImplTest.java @@ -0,0 +1,365 @@ +// 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.testing.junit.testparameterinjector.TestParameterInjector; +import dev.cel.common.exceptions.CelOverloadNotFoundException; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class FunctionBindingImplTest { + + @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", String.class, (String arg) -> arg + "!"); + 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", String.class, String.class, (String a, String b) -> a + b); + 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 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 fromAsync_unary_lambdaReturnsNull_returnsNullFuture() throws Exception { + CelFunctionBinding unaryBinding = + CelFunctionBinding.fromAsync( + "null_async", Long.class, (CelAsyncFunctionOverload.Unary) (Long arg) -> null); + CelAsyncFunctionOverload unaryOverload = + (CelAsyncFunctionOverload) unaryBinding.getDefinition(); + + assertThat(unaryOverload.applyAsync(1L)).isNull(); + assertThat(unaryOverload.applyAsync(new Object[] {1L})).isNull(); + } + + @Test + public void fromAsync_binary_lambdaReturnsNull_returnsNullFuture() throws Exception { + CelFunctionBinding binaryBinding = + CelFunctionBinding.fromAsync( + "null_async_bin", + Long.class, + String.class, + (CelAsyncFunctionOverload.Binary) (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 fromAsync_unary_synchronousApplyThrowsUnsupportedOperationException() { + CelFunctionBinding binding = + CelFunctionBinding.fromAsync("unary_async", Long.class, (Long arg) -> immediateFuture(arg)); + CelFunctionOverload overload = binding.getDefinition(); + + UnsupportedOperationException e = + assertThrows(UnsupportedOperationException.class, () -> overload.apply(new Object[] {10L})); + + assertThat(e) + .hasMessageThat() + .contains("Async overload cannot be evaluated synchronously. Use evalAsync instead."); + } + + @Test + public void fromAsync_varargs_invokesAsyncAndAdapts() throws Exception { + CelFunctionBinding binding = + CelFunctionBinding.fromAsync( + "custom_async_varargs", + ImmutableList.of(Long.class, String.class), + (Object[] args) -> immediateFuture((Long) args[0] + (String) args[1])); + CelAsyncFunctionOverload overload = (CelAsyncFunctionOverload) binding.getDefinition(); + + assertThat(binding.getOverloadId()).isEqualTo("custom_async_varargs"); + assertThat(binding.getArgTypes()).containsExactly(Long.class, String.class).inOrder(); + assertThat(binding.isStrict()).isTrue(); + assertThat(binding.getDefinition()).isInstanceOf(CelAsyncFunctionOverload.class); + assertThat(overload.applyAsync(new Object[] {10L, "test"}).get(5, SECONDS)).isEqualTo("10test"); + } + + @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."); + } + + @Test + public void fromAsync_withIterableArgTypes_success() throws Exception { + CelAsyncFunctionOverload overload = + args -> immediateFuture((Long) args[0] + (Long) args[1] + (Long) args[2]); + + CelFunctionBinding binding = + CelFunctionBinding.fromAsync( + "ternary_async", ImmutableList.of(Long.class, Long.class, Long.class), overload); + + assertThat(binding.getOverloadId()).isEqualTo("ternary_async"); + assertThat(binding.getArgTypes()).containsExactly(Long.class, Long.class, Long.class).inOrder(); + assertThat(binding.isStrict()).isTrue(); + assertThat(binding.getDefinition()).isSameInstanceAs(overload); + CelAsyncFunctionOverload bindingDef = (CelAsyncFunctionOverload) binding.getDefinition(); + assertThat(bindingDef.applyAsync(new Object[] {10L, 20L, 30L}).get(5, SECONDS)).isEqualTo(60L); + } + + @Test + public void asyncFunctionOverload_defaultMethods_delegatesToVarargsAndThrowsOnSync() + throws Exception { + CelAsyncFunctionOverload overload = + args -> { + long sum = 0L; + for (Object arg : args) { + sum += (Long) arg; + } + return immediateFuture(sum); + }; + + assertThat(overload.applyAsync(42L).get(5, SECONDS)).isEqualTo(42L); + assertThat(overload.applyAsync(10L, 20L).get(5, SECONDS)).isEqualTo(30L); + + UnsupportedOperationException thrown = + assertThrows(UnsupportedOperationException.class, () -> overload.apply(new Object[] {1L})); + assertThat(thrown) + .hasMessageThat() + .contains("Async overload cannot be evaluated synchronously. Use evalAsync instead."); + } + + @Test + public void celFunctionResolver_empty_alwaysReturnsEmpty() throws Exception { + CelFunctionResolver resolver = CelFunctionResolver.EMPTY; + + assertThat(resolver.findOverloadMatchingArgs("fn", new Object[] {1L})).isEmpty(); + assertThat( + resolver.findOverloadMatchingArgs( + "fn", ImmutableList.of("fn_overload"), new Object[] {1L})) + .isEmpty(); + } + + @Test + public void dynamicDispatch_applyVarargs_matchesCorrectOverload() throws Exception { + CelFunctionBinding binding1 = + CelFunctionBinding.from( + "sum_three_longs", + ImmutableList.of(Long.class, Long.class, Long.class), + args -> (Long) args[0] + (Long) args[1] + (Long) args[2]); + CelFunctionBinding binding2 = + CelFunctionBinding.from( + "concat_three_strings", + ImmutableList.of(String.class, String.class, String.class), + args -> (String) args[0] + (String) args[1] + (String) args[2]); + + ImmutableSet overloads = + CelFunctionBinding.fromOverloads("add3", binding1, binding2); + OptimizedFunctionOverload dispatchOverload = + (OptimizedFunctionOverload) + Iterables.find(overloads, b -> b.getOverloadId().equals("add3")).getDefinition(); + + assertThat(dispatchOverload.apply(new Object[] {1L, 2L, 3L})).isEqualTo(6L); + assertThat(dispatchOverload.apply(new Object[] {"a", "b", "c"})).isEqualTo("abc"); + + CelOverloadNotFoundException thrown = + assertThrows( + CelOverloadNotFoundException.class, + () -> dispatchOverload.apply(new Object[] {1L, "b", 3L})); + assertThat(thrown) + .hasMessageThat() + .contains( + "No matching overload for function 'add3'. Overload candidates: sum_three_longs," + + " concat_three_strings"); + } + + @Test + public void fromOverloads_nullOrEmptyFunctionName_throwsIllegalArgumentException() { + CelFunctionBinding binding = CelFunctionBinding.from("fn_1", Long.class, (Long arg) -> 1L); + + assertThrows( + IllegalArgumentException.class, () -> CelFunctionBinding.fromOverloads(null, binding)); + assertThrows( + IllegalArgumentException.class, () -> CelFunctionBinding.fromOverloads("", binding)); + } + + @Test + public void fromOverloads_emptyOverloadBindings_throwsIllegalArgumentException() { + assertThrows( + IllegalArgumentException.class, + () -> CelFunctionBinding.fromOverloads("fn", ImmutableList.of())); + assertThrows(IllegalArgumentException.class, () -> CelFunctionBinding.fromOverloads("fn")); + } + + @Test + public void fromOverloads_varargsAndCollection_success() { + CelFunctionBinding binding1 = + CelFunctionBinding.from("unary_fn", Long.class, (Long arg) -> arg + 1L); + CelFunctionBinding binding2 = + CelFunctionBinding.from("binary_fn", Long.class, Long.class, (Long a, Long b) -> a + b); + + ImmutableSet varargsBindings = + CelFunctionBinding.fromOverloads("poly_fn", binding1, binding2); + ImmutableSet collectionBindings = + CelFunctionBinding.fromOverloads("poly_fn", ImmutableList.of(binding1, binding2)); + + assertThat(varargsBindings).isNotEmpty(); + assertThat(collectionBindings).isNotEmpty(); + } +}