diff --git a/runtime/planner/BUILD.bazel b/runtime/planner/BUILD.bazel index 0a4ef8a84..4c482337d 100644 --- a/runtime/planner/BUILD.bazel +++ b/runtime/planner/BUILD.bazel @@ -35,3 +35,9 @@ java_library( visibility = ["//:internal"], exports = ["//runtime/src/main/java/dev/cel/runtime/planner:async_completion_coordinator"], ) + +java_library( + name = "async_call_state_tracker", + visibility = ["//:internal"], + exports = ["//runtime/src/main/java/dev/cel/runtime/planner:async_call_state_tracker"], +) diff --git a/runtime/src/main/java/dev/cel/runtime/RuntimeEquality.java b/runtime/src/main/java/dev/cel/runtime/RuntimeEquality.java index 56a8761cd..4814dc269 100644 --- a/runtime/src/main/java/dev/cel/runtime/RuntimeEquality.java +++ b/runtime/src/main/java/dev/cel/runtime/RuntimeEquality.java @@ -237,7 +237,9 @@ public int hashCode(Object object) { object = runtimeHelpers.adaptValue(object); if (object instanceof Number) { - return Double.hashCode(((Number) object).doubleValue()); + double value = ((Number) object).doubleValue(); + // Normalize -0.0 to 0.0. objectEquals reports the two as equal, so they must hash alike. + return Double.hashCode(value == 0.0d ? 0.0d : value); } if (object instanceof Iterable) { int h = 1; 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..d45a6a95c --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/planner/AsyncCallRecord.java @@ -0,0 +1,289 @@ +// 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.base.Preconditions.checkNotNull; + +import com.google.common.util.concurrent.ListenableFuture; +import javax.annotation.concurrent.ThreadSafe; +import dev.cel.runtime.CelAsyncCall; +import dev.cel.runtime.CelAsyncFunctionOverload; +import dev.cel.runtime.RuntimeEquality; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jspecify.annotations.Nullable; + +/** Tracks the execution state and result of a single asynchronous function call. */ +@ThreadSafe +// CEL-Internal-4 +final class AsyncCallRecord implements CelAsyncCall { + + enum State { + NOT_STARTED, + RUNNING, + SUCCESS, + FAILURE, + CANCELLED + } + + // Type markers keep values of different kinds from colliding in the bucket hash, e.g. the + // string "NaN" and the double NaN. Collisions remain harmless because matches() disambiguates the + // bucket. + private static final int STRING_HASH_MARKER = 's'; + private static final int BOOL_HASH_MARKER = 'b'; + private static final int NUMBER_HASH_MARKER = 'n'; + private static final int COMPLEX_HASH_MARKER = 'x'; + + private final long callId; + private final long exprId; + private final String functionName; + private final String overloadId; + + @SuppressWarnings("Immutable") // Array not mutated after construction + private final Object[] args; + + private final CelAsyncFunctionOverload overload; + + private final Object lock = new Object(); + private final AtomicBoolean completionReported = new AtomicBoolean(false); + private volatile State state = State.NOT_STARTED; + private volatile @Nullable Object result; + private volatile @Nullable Throwable error; + private volatile @Nullable ListenableFuture inFlightFuture; + + static AsyncCallRecord create( + long callId, + long exprId, + String functionName, + String overloadId, + Object[] args, + CelAsyncFunctionOverload overload) { + return new AsyncCallRecord(callId, exprId, functionName, overloadId, args, overload); + } + + /** + * Computes the bucket hash under which a call is tracked. + * + *

This is a bucketing hint, not an identity: calls that {@link #matches} considers identical + * hash alike, but distinct calls may share a bucket. Resolve the exact call via {@link #matches}. + */ + static int hashCall(long exprId, String overloadId, Object[] args) { + int result = 31 * Long.hashCode(exprId) + overloadId.hashCode(); + for (Object arg : args) { + result = result * 31 + hashArg(arg); + } + return result; + } + + /** + * Returns whether this record tracks a call to the same expression node, function, overload, and + * arguments. + * + *

Arguments are compared under CEL equality, except that NaN compares equal to itself so that + * a node re-evaluated with a NaN argument can find its existing record. + */ + boolean matches( + long exprId, + String functionName, + String overloadId, + Object[] args, + RuntimeEquality runtimeEquality) { + if (this.exprId != exprId + || !this.functionName.equals(functionName) + || !this.overloadId.equals(overloadId) + || this.args.length != args.length) { + return false; + } + for (int i = 0; i < this.args.length; i++) { + Object arg = this.args[i]; + Object otherArg = args[i]; + if (!runtimeEquality.objectEquals(arg, otherArg) && !(isNan(arg) && isNan(otherArg))) { + return false; + } + } + return true; + } + + @Override + public long callId() { + return callId; + } + + @Override + public long exprId() { + return exprId; + } + + @Override + public String functionName() { + return functionName; + } + + @Override + public String overloadId() { + return overloadId; + } + + /** + * Transitions the call state from {@link State#NOT_STARTED} to {@link State#RUNNING}. + * + * @return true if the transition succeeded, false if the call was already running, completed, or + * cancelled. + */ + boolean markRunning() { + synchronized (lock) { + if (state != State.NOT_STARTED) { + return false; + } + state = State.RUNNING; + return true; + } + } + + void setInFlightFuture(ListenableFuture future) { + checkNotNull(future); + boolean shouldCancel; + synchronized (lock) { + inFlightFuture = future; + shouldCancel = (state == State.CANCELLED && !future.isDone()); + } + if (shouldCancel) { + future.cancel(/* mayInterruptIfRunning= */ false); + } + } + + boolean cancelInFlight() { + ListenableFuture futureToCancel = null; + synchronized (lock) { + if (!isPending()) { + return false; + } + state = State.CANCELLED; + ListenableFuture future = inFlightFuture; + if (future != null && !future.isDone()) { + futureToCancel = future; + } + } + if (futureToCancel != null) { + futureToCancel.cancel(/* mayInterruptIfRunning= */ false); + } + return true; + } + + /** + * Claims the right to report this call's completion, returning true for the first caller only. + * + *

Tracked separately from {@link State} because a call cancelled after dispatch still holds a + * concurrency permit and must release it exactly once. + */ + boolean markCompletionReported() { + return completionReported.compareAndSet(false, true); + } + + boolean isCancelled() { + return state == State.CANCELLED; + } + + boolean complete(@Nullable Object result) { + synchronized (lock) { + if (!isPending()) { + return false; + } + this.result = result; + state = State.SUCCESS; + return true; + } + } + + boolean fail(Throwable error) { + checkNotNull(error); + synchronized (lock) { + if (!isPending()) { + return false; + } + this.error = error; + state = State.FAILURE; + return true; + } + } + + Object[] args() { + return args.clone(); + } + + CelAsyncFunctionOverload overload() { + return overload; + } + + State state() { + return state; + } + + /** + * Returns the completed result, if present. + * + *

Note: If a call completed successfully with a {@code null} value, this method returns {@code + * Optional.empty()}. Callers should check {@link #state()} to distinguish between a call that has + * not completed and one that succeeded with {@code null}. + */ + Optional result() { + return Optional.ofNullable(result); + } + + Optional error() { + return Optional.ofNullable(error); + } + + private static int hashArg(@Nullable Object arg) { + if (arg instanceof String) { + return STRING_HASH_MARKER * 31 + arg.hashCode(); + } + if (arg instanceof Boolean) { + return BOOL_HASH_MARKER * 31 + arg.hashCode(); + } + if (arg instanceof Number) { + // Hash int, uint, and double through a common double representation so that values CEL + // considers equal (1 == 1u == 1.0) share a bucket. NaN needs no special case because + // Double.hashCode(NaN) is a constant across all double and float NaN representations. + double value = ((Number) arg).doubleValue(); + // Normalize -0.0 to 0.0, which CEL considers equal to 0.0. + return NUMBER_HASH_MARKER * 31 + Double.hashCode(value == 0.0d ? 0.0d : value); + } + return COMPLEX_HASH_MARKER; + } + + private static boolean isNan(@Nullable Object value) { + return value instanceof Number && Double.isNaN(((Number) value).doubleValue()); + } + + private boolean isPending() { + return state == State.NOT_STARTED || state == State.RUNNING; + } + + private AsyncCallRecord( + long callId, + long exprId, + String functionName, + String overloadId, + Object[] args, + CelAsyncFunctionOverload overload) { + this.callId = callId; + this.exprId = exprId; + this.functionName = checkNotNull(functionName); + this.overloadId = checkNotNull(overloadId); + this.args = checkNotNull(args).clone(); + this.overload = checkNotNull(overload); + } +} 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 d838e8d53..009672e32 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel +++ b/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel @@ -217,6 +217,22 @@ java_library( ], ) +java_library( + name = "async_call_state_tracker", + srcs = ["AsyncCallRecord.java"], + tags = [ + ], + deps = [ + "//runtime:async_call", + "//runtime:function_overload", + "//runtime:runtime_equality", + "@maven//:com_google_code_findbugs_annotations", + "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:com_google_guava_guava", + "@maven//:org_jspecify_jspecify", + ], +) + java_library( name = "activation_wrapper", srcs = ["ActivationWrapper.java"], diff --git a/runtime/src/test/java/dev/cel/runtime/RuntimeEqualityTest.java b/runtime/src/test/java/dev/cel/runtime/RuntimeEqualityTest.java index 00e55873c..a5617beee 100644 --- a/runtime/src/test/java/dev/cel/runtime/RuntimeEqualityTest.java +++ b/runtime/src/test/java/dev/cel/runtime/RuntimeEqualityTest.java @@ -37,6 +37,7 @@ public void objectEquals_and_hashCode() { assertEqualityAndHashCode(runtimeEquality, 2, 2L); assertEqualityAndHashCode(runtimeEquality, 3, 3.0); assertEqualityAndHashCode(runtimeEquality, 4, UnsignedLong.valueOf(4)); + assertEqualityAndHashCode(runtimeEquality, 0.0d, -0.0d); assertEqualityAndHashCode( runtimeEquality, ImmutableList.of(1, 2, 3), 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..0dacb48ca --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/planner/AsyncCallRecordTest.java @@ -0,0 +1,615 @@ +// 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 org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.primitives.UnsignedLong; +import com.google.common.util.concurrent.SettableFuture; +import com.google.testing.junit.testparameterinjector.TestParameter; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import dev.cel.common.CelOptions; +import dev.cel.common.values.NullValue; +import dev.cel.runtime.CelAsyncFunctionOverload; +import dev.cel.runtime.RuntimeEquality; +import dev.cel.runtime.RuntimeHelpers; +import java.util.concurrent.CountDownLatch; +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 AsyncCallRecordTest { + + private static final CelAsyncFunctionOverload DUMMY_OVERLOAD = args -> immediateFuture("ok"); + + private static final RuntimeEquality RUNTIME_EQUALITY = + RuntimeEquality.create(RuntimeHelpers.create(), CelOptions.DEFAULT); + + /** Each constant differs from the base call in exactly one component. */ + @SuppressWarnings("ImmutableEnumChecker") + private enum CallMismatch { + EXPR_ID(20L, "myFunc", "myFunc_overload", ImmutableList.of(1L, "a")), + FUNCTION_NAME(10L, "otherFunc", "myFunc_overload", ImmutableList.of(1L, "a")), + OVERLOAD_ID(10L, "myFunc", "other_overload", ImmutableList.of(1L, "a")), + ARITY_FEWER(10L, "myFunc", "myFunc_overload", ImmutableList.of(1L)), + ARITY_MORE(10L, "myFunc", "myFunc_overload", ImmutableList.of(1L, "a", "extra")), + ARG_VALUE(10L, "myFunc", "myFunc_overload", ImmutableList.of(2L, "a")); + + private final long exprId; + private final String functionName; + private final String overloadId; + private final ImmutableList args; + + boolean matchesAgainst(AsyncCallRecord record) { + return record.matches(exprId, functionName, overloadId, args.toArray(), RUNTIME_EQUALITY); + } + + CallMismatch(long exprId, String functionName, String overloadId, ImmutableList args) { + this.exprId = exprId; + this.functionName = functionName; + this.overloadId = overloadId; + this.args = args; + } + } + + @Test + public void initialValues_matchConstructor() { + AsyncCallRecord record = + AsyncCallRecord.create( + 1L, 10L, "myFunc", "myFunc_overload", new Object[] {"arg1"}, DUMMY_OVERLOAD); + + assertThat(record.callId()).isEqualTo(1L); + assertThat(record.exprId()).isEqualTo(10L); + assertThat(record.functionName()).isEqualTo("myFunc"); + assertThat(record.overloadId()).isEqualTo("myFunc_overload"); + assertThat(record.args()).asList().containsExactly("arg1"); + assertThat(record.overload()).isEqualTo(DUMMY_OVERLOAD); + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.NOT_STARTED); + assertThat(record.isCancelled()).isFalse(); + assertThat(record.result()).isEmpty(); + assertThat(record.error()).isEmpty(); + } + + @Test + public void create_nullArguments_throwsNullPointerException() { + assertThrows( + NullPointerException.class, + () -> AsyncCallRecord.create(1L, 10L, null, "overload", new Object[0], DUMMY_OVERLOAD)); + assertThrows( + NullPointerException.class, + () -> AsyncCallRecord.create(1L, 10L, "func", null, new Object[0], DUMMY_OVERLOAD)); + assertThrows( + NullPointerException.class, + () -> AsyncCallRecord.create(1L, 10L, "func", "overload", null, DUMMY_OVERLOAD)); + assertThrows( + NullPointerException.class, + () -> AsyncCallRecord.create(1L, 10L, "func", "overload", new Object[0], null)); + } + + @Test + public void hashCall_celEqualNumericArgs_shareBucket() { + // 1 == 1u == 1.0 in CEL, so all three must land in the same bucket. + int intHash = AsyncCallRecord.hashCall(10L, "ov", new Object[] {1L}); + + assertThat(AsyncCallRecord.hashCall(10L, "ov", new Object[] {UnsignedLong.ONE})) + .isEqualTo(intHash); + assertThat(AsyncCallRecord.hashCall(10L, "ov", new Object[] {1.0d})).isEqualTo(intHash); + } + + @Test + public void hashCall_signedZeroArgs_shareBucket() { + int zeroHash = AsyncCallRecord.hashCall(10L, "ov", new Object[] {0.0d}); + + assertThat(AsyncCallRecord.hashCall(10L, "ov", new Object[] {-0.0d})).isEqualTo(zeroHash); + assertThat(AsyncCallRecord.hashCall(10L, "ov", new Object[] {0L})).isEqualTo(zeroHash); + assertThat(AsyncCallRecord.hashCall(10L, "ov", new Object[] {UnsignedLong.ZERO})) + .isEqualTo(zeroHash); + } + + @Test + public void hashCall_distinctNanRepresentations_shareBucket() { + int nanHash = AsyncCallRecord.hashCall(10L, "ov", new Object[] {Double.NaN}); + + assertThat( + AsyncCallRecord.hashCall( + 10L, "ov", new Object[] {Double.longBitsToDouble(0x7ff8000000000001L)})) + .isEqualTo(nanHash); + assertThat(AsyncCallRecord.hashCall(10L, "ov", new Object[] {Float.NaN})).isEqualTo(nanHash); + } + + @Test + public void hashCall_distinctCalls_produceDistinctBuckets() { + // Distinctness is best-effort (hashCall is a bucketing hint), but verifies that call site + // components and argument boundaries are salted. + int base = AsyncCallRecord.hashCall(10L, "ov", new Object[] {"a", "bc"}); + + assertThat(AsyncCallRecord.hashCall(11L, "ov", new Object[] {"a", "bc"})).isNotEqualTo(base); + assertThat(AsyncCallRecord.hashCall(10L, "other", new Object[] {"a", "bc"})).isNotEqualTo(base); + // Arguments are mixed in separately, so ("a", "bc") does not collide with ("ab", "c"). + assertThat(AsyncCallRecord.hashCall(10L, "ov", new Object[] {"ab", "c"})).isNotEqualTo(base); + // Type markers keep the string "NaN" distinct from the double NaN. + assertThat(AsyncCallRecord.hashCall(10L, "ov", new Object[] {Double.NaN})) + .isNotEqualTo(AsyncCallRecord.hashCall(10L, "ov", new Object[] {"NaN"})); + } + + @Test + public void hashCall_complexArgs_shareSingleBucket() { + // Complex values are deliberately excluded from the hash because they need a richer + // equivalence than a value hash can express. They share one bucket and are separated by + // matches() instead. + int listHash = AsyncCallRecord.hashCall(10L, "ov", new Object[] {ImmutableList.of(1L)}); + + assertThat(AsyncCallRecord.hashCall(10L, "ov", new Object[] {ImmutableList.of(2L)})) + .isEqualTo(listHash); + assertThat(AsyncCallRecord.hashCall(10L, "ov", new Object[] {ImmutableMap.of("k", "v")})) + .isEqualTo(listHash); + assertThat(AsyncCallRecord.hashCall(10L, "ov", new Object[] {NullValue.NULL_VALUE})) + .isEqualTo(listHash); + } + + @Test + public void matches_identicalCall_returnsTrue() { + AsyncCallRecord record = recordWithArgs(1L, "a"); + + assertThat(matches(record, 10L, "myFunc", "myFunc_overload", 1L, "a")).isTrue(); + } + + @Test + public void matches_mismatchedComponent_returnsFalse(@TestParameter CallMismatch mismatch) { + AsyncCallRecord record = recordWithArgs(1L, "a"); + + assertThat(mismatch.matchesAgainst(record)).isFalse(); + } + + @Test + public void matches_crossTypeNumericArgs_returnsTrue() { + AsyncCallRecord record = recordWithArgs(1L); + + assertThat(matches(record, 10L, "myFunc", "myFunc_overload", 1.0d)).isTrue(); + assertThat(matches(record, 10L, "myFunc", "myFunc_overload", UnsignedLong.ONE)).isTrue(); + } + + @Test + public void matches_signedZeroArgs_returnsTrue() { + AsyncCallRecord record = recordWithArgs(0.0d); + + assertThat(matches(record, 10L, "myFunc", "myFunc_overload", -0.0d)).isTrue(); + } + + @Test + public void matches_nanArgs_returnsTrue() { + // CEL defines NaN != NaN. Without the override a node re-evaluated with a NaN argument would + // never find its record and would dispatch a fresh call on every pass. + AsyncCallRecord record = recordWithArgs(Double.NaN); + + assertThat(matches(record, 10L, "myFunc", "myFunc_overload", Double.NaN)).isTrue(); + } + + @Test + public void matches_floatNanArgs_returnsTrue() { + AsyncCallRecord record = recordWithArgs(Float.NaN); + + assertThat(matches(record, 10L, "myFunc", "myFunc_overload", Float.NaN)).isTrue(); + assertThat(matches(record, 10L, "myFunc", "myFunc_overload", Double.NaN)).isTrue(); + } + + @Test + public void matches_nanVsNonNan_returnsFalse() { + AsyncCallRecord record = recordWithArgs(Double.NaN); + + assertThat(matches(record, 10L, "myFunc", "myFunc_overload", 0.0d)).isFalse(); + assertThat(matches(record, 10L, "myFunc", "myFunc_overload", 1.0d)).isFalse(); + } + + @Test + public void matches_celEqualCollectionArgs_returnsTrue() { + AsyncCallRecord record = recordWithArgs(ImmutableList.of(1L, 2L), ImmutableMap.of(1L, "v")); + + assertThat( + matches( + record, + 10L, + "myFunc", + "myFunc_overload", + ImmutableList.of(1.0d, 2.0d), + ImmutableMap.of(UnsignedLong.ONE, "v"))) + .isTrue(); + } + + @Test + public void matches_differingCollectionArgs_returnsFalse() { + AsyncCallRecord record = recordWithArgs(ImmutableList.of(1L, 2L), ImmutableMap.of(1L, "v")); + + assertThat( + matches( + record, + 10L, + "myFunc", + "myFunc_overload", + ImmutableList.of(1L, 3L), + ImmutableMap.of(1L, "v"))) + .isFalse(); + } + + @Test + public void matches_differentArgTypes_returnsFalse() { + AsyncCallRecord record = recordWithArgs("notANumber"); + + assertThat(matches(record, 10L, "myFunc", "myFunc_overload", 1L)).isFalse(); + } + + @Test + public void markRunning_successFromNotStarted() { + AsyncCallRecord record = createRecord(AsyncCallRecord.State.NOT_STARTED); + + boolean marked = record.markRunning(); + + assertThat(marked).isTrue(); + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.RUNNING); + } + + @Test + public void markRunning_whenAlreadyTerminalOrRunning_returnsFalse( + @TestParameter({"RUNNING", "SUCCESS", "FAILURE", "CANCELLED"}) AsyncCallRecord.State state) { + AsyncCallRecord record = createRecord(state); + + boolean marked = record.markRunning(); + + assertThat(marked).isFalse(); + assertThat(record.state()).isEqualTo(state); + } + + @Test + public void concurrentMarkRunning_exactlyOneSucceeds() throws Exception { + int numThreads = 4; + for (int i = 0; i < 100; i++) { + AsyncCallRecord record = createRecord(AsyncCallRecord.State.NOT_STARTED); + CountDownLatch startLatch = new CountDownLatch(1); + AtomicInteger successCount = new AtomicInteger(); + Thread[] threads = new Thread[numThreads]; + + for (int t = 0; t < numThreads; t++) { + threads[t] = + new Thread( + () -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + if (record.markRunning()) { + successCount.incrementAndGet(); + } + }); + threads[t].start(); + } + + startLatch.countDown(); + for (Thread thread : threads) { + thread.join(); + } + + assertThat(successCount.get()).isEqualTo(1); + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.RUNNING); + } + } + + @Test + public void concurrentMarkRunningAndCancelInFlight_alwaysCancelsSuccessfully() throws Exception { + for (int i = 0; i < 100; i++) { + AsyncCallRecord record = createRecord(AsyncCallRecord.State.NOT_STARTED); + CountDownLatch startLatch = new CountDownLatch(1); + AtomicBoolean markedRunning = new AtomicBoolean(); + AtomicBoolean cancelled = new AtomicBoolean(); + + Thread t1 = + new Thread( + () -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + markedRunning.set(record.markRunning()); + }); + Thread t2 = + new Thread( + () -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + cancelled.set(record.cancelInFlight()); + }); + + t1.start(); + t2.start(); + startLatch.countDown(); + t1.join(); + t2.join(); + + assertThat(cancelled.get()).isTrue(); + assertThat(record.isCancelled()).isTrue(); + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.CANCELLED); + } + } + + @Test + public void complete_successFromActiveState( + @TestParameter({"NOT_STARTED", "RUNNING"}) AsyncCallRecord.State state) { + AsyncCallRecord record = createRecord(state); + + boolean completed = record.complete("successResult"); + + assertThat(completed).isTrue(); + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.SUCCESS); + assertThat(record.result()).hasValue("successResult"); + assertThat(record.error()).isEmpty(); + } + + @Test + public void complete_whenAlreadyCompleted_returnsFalseAndDoesNotOverwrite() { + AsyncCallRecord record = createRecord(AsyncCallRecord.State.RUNNING); + record.complete("firstResult"); + + boolean secondCompleted = record.complete("secondResult"); + + assertThat(secondCompleted).isFalse(); + assertThat(record.result()).hasValue("firstResult"); + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.SUCCESS); + } + + @Test + public void fail_successFromActiveState( + @TestParameter({"NOT_STARTED", "RUNNING"}) AsyncCallRecord.State state) { + AsyncCallRecord record = createRecord(state); + RuntimeException error = new RuntimeException("test error"); + + boolean failed = record.fail(error); + + assertThat(failed).isTrue(); + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.FAILURE); + assertThat(record.error()).hasValue(error); + assertThat(record.result()).isEmpty(); + } + + @Test + public void fail_whenAlreadyCompleted_returnsFalseAndDoesNotOverwrite() { + AsyncCallRecord record = createRecord(AsyncCallRecord.State.RUNNING); + record.complete("firstResult"); + + boolean failed = record.fail(new RuntimeException("subsequent failure")); + + assertThat(failed).isFalse(); + assertThat(record.result()).hasValue("firstResult"); + assertThat(record.error()).isEmpty(); + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.SUCCESS); + } + + @Test + public void cancelInFlight_activeState_cancelsFutureAndTransitionsToCancelled( + @TestParameter({"NOT_STARTED", "RUNNING"}) AsyncCallRecord.State state) { + AsyncCallRecord record = createRecord(state); + SettableFuture future = SettableFuture.create(); + record.setInFlightFuture(future); + + boolean cancelled = record.cancelInFlight(); + + assertThat(cancelled).isTrue(); + assertThat(record.isCancelled()).isTrue(); + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.CANCELLED); + assertThat(future.isCancelled()).isTrue(); + } + + @Test + public void cancelInFlight_completedOrFailed_returnsFalseAndPreservesState( + @TestParameter({"SUCCESS", "FAILURE"}) AsyncCallRecord.State state) { + AsyncCallRecord record = createRecord(state); + SettableFuture future = SettableFuture.create(); + record.setInFlightFuture(future); + + boolean cancelled = record.cancelInFlight(); + + assertThat(cancelled).isFalse(); + assertThat(record.isCancelled()).isFalse(); + assertThat(record.state()).isEqualTo(state); + assertThat(future.isCancelled()).isFalse(); + } + + @Test + public void cancelInFlight_alreadyCancelled_returnsFalse() { + AsyncCallRecord record = createRecord(AsyncCallRecord.State.CANCELLED); + + boolean cancelled = record.cancelInFlight(); + + assertThat(cancelled).isFalse(); + assertThat(record.isCancelled()).isTrue(); + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.CANCELLED); + } + + @Test + public void setInFlightFuture_afterCancelled_cancelsImmediately() { + AsyncCallRecord record = createRecord(AsyncCallRecord.State.CANCELLED); + SettableFuture future = SettableFuture.create(); + + record.setInFlightFuture(future); + + assertThat(future.isCancelled()).isTrue(); + } + + @Test + public void markCompletionReported_onlyFirstCallerSucceeds() { + AsyncCallRecord record = createRecord(AsyncCallRecord.State.RUNNING); + + boolean firstReport = record.markCompletionReported(); + boolean secondReport = record.markCompletionReported(); + + assertThat(firstReport).isTrue(); + assertThat(secondReport).isFalse(); + } + + @Test + public void markCompletionReported_afterCancellation_stillSucceedsOnce() { + AsyncCallRecord record = createRecord(AsyncCallRecord.State.RUNNING); + record.cancelInFlight(); + + boolean firstReport = record.markCompletionReported(); + boolean secondReport = record.markCompletionReported(); + + assertThat(firstReport).isTrue(); + assertThat(secondReport).isFalse(); + } + + @Test + public void concurrentCancellationAndSetInFlightFuture_futureIsAlwaysCancelled() + throws Exception { + for (int i = 0; i < 100; i++) { + AsyncCallRecord record = createRecord(AsyncCallRecord.State.RUNNING); + SettableFuture future = SettableFuture.create(); + CountDownLatch startLatch = new CountDownLatch(1); + AtomicBoolean cancelled = new AtomicBoolean(); + + Thread cancelThread = + new Thread( + () -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + cancelled.set(record.cancelInFlight()); + }); + Thread setFutureThread = + new Thread( + () -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + record.setInFlightFuture(future); + }); + + cancelThread.start(); + setFutureThread.start(); + startLatch.countDown(); + cancelThread.join(); + setFutureThread.join(); + + assertThat(record.isCancelled()).isTrue(); + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.CANCELLED); + assertThat(future.isCancelled()).isTrue(); + } + } + + @Test + public void concurrentCompleteAndCancelInFlight_exactlyOneWinner() throws Exception { + for (int i = 0; i < 100; i++) { + AsyncCallRecord record = createRecord(AsyncCallRecord.State.RUNNING); + CountDownLatch startLatch = new CountDownLatch(1); + AtomicBoolean completed = new AtomicBoolean(); + AtomicBoolean cancelled = new AtomicBoolean(); + + Thread completeThread = + new Thread( + () -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + completed.set(record.complete("success")); + }); + Thread cancelThread = + new Thread( + () -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + cancelled.set(record.cancelInFlight()); + }); + + completeThread.start(); + cancelThread.start(); + startLatch.countDown(); + completeThread.join(); + cancelThread.join(); + + assertThat(completed.get() ^ cancelled.get()).isTrue(); + if (completed.get()) { + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.SUCCESS); + assertThat(record.result()).hasValue("success"); + } else { + assertThat(record.state()).isEqualTo(AsyncCallRecord.State.CANCELLED); + assertThat(record.result()).isEmpty(); + } + } + } + + @Test + public void args_returnsDefensiveCopy() { + AsyncCallRecord record = + AsyncCallRecord.create( + 1L, 10L, "myFunc", "myFunc_overload", new Object[] {"original"}, DUMMY_OVERLOAD); + + Object[] returnedArgs = record.args(); + returnedArgs[0] = "mutated"; + + assertThat(record.args()).asList().containsExactly("original"); + } + + private static AsyncCallRecord recordWithArgs(Object... args) { + return AsyncCallRecord.create(1L, 10L, "myFunc", "myFunc_overload", args, DUMMY_OVERLOAD); + } + + private static boolean matches( + AsyncCallRecord record, long exprId, String functionName, String overloadId, Object... args) { + return record.matches(exprId, functionName, overloadId, args, RUNTIME_EQUALITY); + } + + private static AsyncCallRecord createRecord(AsyncCallRecord.State state) { + AsyncCallRecord record = + AsyncCallRecord.create(1L, 10L, "myFunc", "myFunc_overload", new Object[0], DUMMY_OVERLOAD); + switch (state) { + case NOT_STARTED: + break; + case RUNNING: + record.markRunning(); + break; + case SUCCESS: + record.complete("success"); + break; + case FAILURE: + record.fail(new RuntimeException("failed")); + break; + case CANCELLED: + record.cancelInFlight(); + break; + } + return record; + } +} 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 38d1d0d70..304fbe21d 100644 --- a/runtime/src/test/java/dev/cel/runtime/planner/BUILD.bazel +++ b/runtime/src/test/java/dev/cel/runtime/planner/BUILD.bazel @@ -16,8 +16,9 @@ java_library( "//:java_truth", "//common:cel_ast", "//common:cel_descriptor_util", + "//common:cel_function_decl", + "//common:cel_overload_decl", "//common:cel_source", - "//common:compiler_common", "//common:container", "//common:error_codes", "//common:options", @@ -40,9 +41,6 @@ java_library( "//extensions", "//parser:macro", "//runtime", - "//runtime:async_call", - "//runtime:async_drain_strategy", - "//runtime:async_options", "//runtime:descriptor_type_resolver", "//runtime:dispatcher", "//runtime:function_binding", @@ -52,11 +50,13 @@ java_library( "//runtime:runtime_helpers", "//runtime:standard_functions", "//runtime:unknown_attributes", + "//runtime/planner:async_call_state_tracker", "//runtime/planner:async_completion_coordinator", "//runtime/planner:async_gate", "//runtime/planner:program_planner", "//runtime/standard:type", "@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_java_proto", + "@maven//:com_google_code_findbugs_annotations", "@maven//:com_google_guava_guava", "@maven//:com_google_testparameterinjector_test_parameter_injector", "@maven//:junit_junit",