Skip to content

Commit 788ee68

Browse files
l46kokcopybara-github
authored andcommitted
Add AsyncCallRecord
PiperOrigin-RevId: 982639852
1 parent ef2f7aa commit 788ee68

7 files changed

Lines changed: 973 additions & 5 deletions

File tree

runtime/planner/BUILD.bazel

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,9 @@ java_library(
3535
visibility = ["//:internal"],
3636
exports = ["//runtime/src/main/java/dev/cel/runtime/planner:async_completion_coordinator"],
3737
)
38+
39+
java_library(
40+
name = "async_call_state_tracker",
41+
visibility = ["//:internal"],
42+
exports = ["//runtime/src/main/java/dev/cel/runtime/planner:async_call_state_tracker"],
43+
)

runtime/src/main/java/dev/cel/runtime/RuntimeEquality.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,9 @@ public int hashCode(Object object) {
237237

238238
object = runtimeHelpers.adaptValue(object);
239239
if (object instanceof Number) {
240-
return Double.hashCode(((Number) object).doubleValue());
240+
double value = ((Number) object).doubleValue();
241+
// Normalize -0.0 to 0.0. objectEquals reports the two as equal, so they must hash alike.
242+
return Double.hashCode(value == 0.0d ? 0.0d : value);
241243
}
242244
if (object instanceof Iterable) {
243245
int h = 1;
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package dev.cel.runtime.planner;
16+
17+
import static java.util.Objects.requireNonNull;
18+
19+
import com.google.common.util.concurrent.ListenableFuture;
20+
import javax.annotation.concurrent.ThreadSafe;
21+
import dev.cel.runtime.CelAsyncCall;
22+
import dev.cel.runtime.CelAsyncFunctionOverload;
23+
import dev.cel.runtime.RuntimeEquality;
24+
import java.util.Optional;
25+
import java.util.concurrent.atomic.AtomicBoolean;
26+
import org.jspecify.annotations.Nullable;
27+
28+
/** Tracks the execution state and result of a single asynchronous function call. */
29+
@ThreadSafe
30+
// CEL-Internal-4
31+
final class AsyncCallRecord implements CelAsyncCall {
32+
33+
enum State {
34+
NOT_STARTED,
35+
RUNNING,
36+
SUCCESS,
37+
FAILURE,
38+
CANCELLED
39+
}
40+
41+
// Type markers keep values of different kinds from colliding in the bucket hash, e.g. the
42+
// string "NaN" and the double NaN. Collisions remain harmless because matches() disambiguates the
43+
// bucket.
44+
private static final int STRING_HASH_MARKER = 's';
45+
private static final int BOOL_HASH_MARKER = 'b';
46+
private static final int NUMBER_HASH_MARKER = 'n';
47+
private static final int COMPLEX_HASH_MARKER = 'x';
48+
49+
private final long callId;
50+
private final long exprId;
51+
private final String functionName;
52+
private final String overloadId;
53+
54+
@SuppressWarnings("Immutable") // Array not mutated after construction
55+
private final Object[] args;
56+
57+
private final CelAsyncFunctionOverload overload;
58+
59+
private final Object lock = new Object();
60+
private final AtomicBoolean completionReported = new AtomicBoolean(false);
61+
private volatile State state = State.NOT_STARTED;
62+
private volatile @Nullable Object result;
63+
private volatile @Nullable Throwable error;
64+
private volatile @Nullable ListenableFuture<?> inFlightFuture;
65+
66+
static AsyncCallRecord create(
67+
long callId,
68+
long exprId,
69+
String functionName,
70+
String overloadId,
71+
Object[] args,
72+
CelAsyncFunctionOverload overload) {
73+
return new AsyncCallRecord(callId, exprId, functionName, overloadId, args, overload);
74+
}
75+
76+
/**
77+
* Computes the bucket hash under which a call is tracked.
78+
*
79+
* <p>This is a bucketing hint rather than a full identity: the only requirement is that calls
80+
* which {@link #matches} considers identical hash alike. Callers must resolve the exact call
81+
* within a bucket via {@link #matches}.
82+
*
83+
* <p>Only string, bool, and numeric arguments contribute. More complex values (lists, maps,
84+
* protos, bytes, null) rely on a richer notion of equivalence than a value hash can capture
85+
* safely, so they are intentionally excluded from the hash and share a single bucket.
86+
*
87+
* <p>The function name is intentionally omitted from the hash; {@code exprId} already identifies
88+
* the call site, and a coarser hash is always safe. Arguments are expected to be CEL-evaluated or
89+
* adapted values.
90+
*/
91+
static int hashCall(long exprId, String overloadId, Object[] args) {
92+
int result = 31 * Long.hashCode(exprId) + overloadId.hashCode();
93+
for (Object arg : args) {
94+
result = result * 31 + hashArg(arg);
95+
}
96+
return result;
97+
}
98+
99+
/**
100+
* Returns whether this record tracks a call to the same expression node, function, overload, and
101+
* arguments.
102+
*
103+
* <p>Arguments are compared under CEL heterogeneous equality, with one deliberate exception: NaN
104+
* compares equal to itself. CEL defines NaN != NaN, which would prevent a node re-evaluated with
105+
* a NaN argument from ever finding its existing record and cause it to dispatch a fresh call on
106+
* every pass.
107+
*/
108+
boolean matches(
109+
long exprId,
110+
String functionName,
111+
String overloadId,
112+
Object[] args,
113+
RuntimeEquality runtimeEquality) {
114+
if (this.exprId != exprId
115+
|| !this.functionName.equals(functionName)
116+
|| !this.overloadId.equals(overloadId)
117+
|| this.args.length != args.length) {
118+
return false;
119+
}
120+
for (int i = 0; i < this.args.length; i++) {
121+
Object arg = this.args[i];
122+
Object otherArg = args[i];
123+
if (!celEquals(runtimeEquality, arg, otherArg) && !(isNan(arg) && isNan(otherArg))) {
124+
return false;
125+
}
126+
}
127+
return true;
128+
}
129+
130+
private static boolean celEquals(
131+
RuntimeEquality runtimeEquality, @Nullable Object a, @Nullable Object b) {
132+
try {
133+
return runtimeEquality.objectEquals(a, b);
134+
} catch (RuntimeException e) {
135+
// Incomparable values (e.g. unsupported proto equality) are simply not the same call.
136+
// Mirrors cel-go, where types.Equal returns an error value rather than throwing.
137+
return false;
138+
}
139+
}
140+
141+
@Override
142+
public long callId() {
143+
return callId;
144+
}
145+
146+
@Override
147+
public long exprId() {
148+
return exprId;
149+
}
150+
151+
@Override
152+
public String functionName() {
153+
return functionName;
154+
}
155+
156+
@Override
157+
public String overloadId() {
158+
return overloadId;
159+
}
160+
161+
/**
162+
* Transitions the call state from {@link State#NOT_STARTED} to {@link State#RUNNING}.
163+
*
164+
* @return true if the transition succeeded, false if the call was already running, completed, or
165+
* cancelled.
166+
*/
167+
boolean markRunning() {
168+
synchronized (lock) {
169+
if (state != State.NOT_STARTED) {
170+
return false;
171+
}
172+
state = State.RUNNING;
173+
return true;
174+
}
175+
}
176+
177+
void setInFlightFuture(ListenableFuture<?> future) {
178+
requireNonNull(future);
179+
boolean shouldCancel;
180+
synchronized (lock) {
181+
inFlightFuture = future;
182+
shouldCancel = (state == State.CANCELLED && !future.isDone());
183+
}
184+
if (shouldCancel) {
185+
future.cancel(/* mayInterruptIfRunning= */ false);
186+
}
187+
}
188+
189+
boolean cancelInFlight() {
190+
ListenableFuture<?> futureToCancel = null;
191+
synchronized (lock) {
192+
if (!isPending()) {
193+
return false;
194+
}
195+
state = State.CANCELLED;
196+
ListenableFuture<?> future = inFlightFuture;
197+
if (future != null && !future.isDone()) {
198+
futureToCancel = future;
199+
}
200+
}
201+
if (futureToCancel != null) {
202+
futureToCancel.cancel(/* mayInterruptIfRunning= */ false);
203+
}
204+
return true;
205+
}
206+
207+
/**
208+
* Claims the right to report this call's completion, returning true for the first caller only.
209+
*
210+
* <p>Tracked separately from {@link State} because a call cancelled after dispatch still holds a
211+
* concurrency permit and must release it exactly once.
212+
*/
213+
boolean markCompletionReported() {
214+
return completionReported.compareAndSet(false, true);
215+
}
216+
217+
boolean isCancelled() {
218+
return state == State.CANCELLED;
219+
}
220+
221+
boolean complete(@Nullable Object result) {
222+
synchronized (lock) {
223+
if (!isPending()) {
224+
return false;
225+
}
226+
this.result = result;
227+
state = State.SUCCESS;
228+
return true;
229+
}
230+
}
231+
232+
boolean fail(Throwable error) {
233+
requireNonNull(error);
234+
synchronized (lock) {
235+
if (!isPending()) {
236+
return false;
237+
}
238+
this.error = error;
239+
state = State.FAILURE;
240+
return true;
241+
}
242+
}
243+
244+
Object[] args() {
245+
return args.clone();
246+
}
247+
248+
CelAsyncFunctionOverload overload() {
249+
return overload;
250+
}
251+
252+
State state() {
253+
return state;
254+
}
255+
256+
/**
257+
* Returns the completed result, if present.
258+
*
259+
* <p>Note: If a call completed successfully with a {@code null} value, this method returns {@code
260+
* Optional.empty()}. Callers should check {@link #state()} to distinguish between a call that has
261+
* not completed and one that succeeded with {@code null}.
262+
*/
263+
Optional<Object> result() {
264+
return Optional.ofNullable(result);
265+
}
266+
267+
Optional<Throwable> error() {
268+
return Optional.ofNullable(error);
269+
}
270+
271+
private static int hashArg(@Nullable Object arg) {
272+
if (arg instanceof String) {
273+
return STRING_HASH_MARKER * 31 + arg.hashCode();
274+
}
275+
if (arg instanceof Boolean) {
276+
return BOOL_HASH_MARKER * 31 + arg.hashCode();
277+
}
278+
if (arg instanceof Number) {
279+
// Hash int, uint, and double through a common double representation so that values CEL
280+
// considers equal (1 == 1u == 1.0) share a bucket. NaN needs no special case because
281+
// Double.hashCode(NaN) is a constant across all double and float NaN representations.
282+
double value = ((Number) arg).doubleValue();
283+
// Normalize -0.0 to 0.0, which CEL considers equal to 0.0.
284+
return NUMBER_HASH_MARKER * 31 + Double.hashCode(value == 0.0d ? 0.0d : value);
285+
}
286+
return COMPLEX_HASH_MARKER;
287+
}
288+
289+
private static boolean isNan(@Nullable Object value) {
290+
return value instanceof Number && Double.isNaN(((Number) value).doubleValue());
291+
}
292+
293+
private boolean isPending() {
294+
return state == State.NOT_STARTED || state == State.RUNNING;
295+
}
296+
297+
private AsyncCallRecord(
298+
long callId,
299+
long exprId,
300+
String functionName,
301+
String overloadId,
302+
Object[] args,
303+
CelAsyncFunctionOverload overload) {
304+
this.callId = callId;
305+
this.exprId = exprId;
306+
this.functionName = requireNonNull(functionName);
307+
this.overloadId = requireNonNull(overloadId);
308+
this.args = requireNonNull(args).clone();
309+
this.overload = requireNonNull(overload);
310+
}
311+
}

runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,22 @@ java_library(
217217
],
218218
)
219219

220+
java_library(
221+
name = "async_call_state_tracker",
222+
srcs = ["AsyncCallRecord.java"],
223+
tags = [
224+
],
225+
deps = [
226+
"//runtime:async_call",
227+
"//runtime:function_overload",
228+
"//runtime:runtime_equality",
229+
"@maven//:com_google_code_findbugs_annotations",
230+
"@maven//:com_google_errorprone_error_prone_annotations",
231+
"@maven//:com_google_guava_guava",
232+
"@maven//:org_jspecify_jspecify",
233+
],
234+
)
235+
220236
java_library(
221237
name = "activation_wrapper",
222238
srcs = ["ActivationWrapper.java"],

runtime/src/test/java/dev/cel/runtime/RuntimeEqualityTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public void objectEquals_and_hashCode() {
3737
assertEqualityAndHashCode(runtimeEquality, 2, 2L);
3838
assertEqualityAndHashCode(runtimeEquality, 3, 3.0);
3939
assertEqualityAndHashCode(runtimeEquality, 4, UnsignedLong.valueOf(4));
40+
assertEqualityAndHashCode(runtimeEquality, 0.0d, -0.0d);
4041
assertEqualityAndHashCode(
4142
runtimeEquality,
4243
ImmutableList.of(1, 2, 3),

0 commit comments

Comments
 (0)