Skip to content

Commit 8ebfae5

Browse files
l46kokcopybara-github
authored andcommitted
Add AsyncCallStateTracker for call deduplication and state management
PiperOrigin-RevId: 982605763
1 parent c182a1d commit 8ebfae5

9 files changed

Lines changed: 2088 additions & 6 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: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,12 @@ public Optional<Object> findInMap(Map<?, ?> map, Object index) {
135135
* comparable even if they are not of the same type, where type differences are usually trivially
136136
* false.
137137
*/
138-
@SuppressWarnings({"rawtypes", "unchecked"})
138+
@SuppressWarnings({"rawtypes", "unchecked", "ReferenceEquality"})
139139
public boolean objectEquals(Object x, Object y) {
140140
if (celOptions.disableCelStandardEquality()) {
141141
return Objects.equals(x, y);
142142
}
143-
if (x == y) {
143+
if (x == y && !isNan(x)) {
144144
return true;
145145
}
146146
x = runtimeHelpers.adaptValue(x);
@@ -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;
@@ -276,6 +278,10 @@ private static Optional<Long> unsignedToLongLossless(UnsignedLong v) {
276278
return Optional.empty();
277279
}
278280

281+
private static boolean isNan(Object value) {
282+
return value instanceof Number && Double.isNaN(((Number) value).doubleValue());
283+
}
284+
279285
RuntimeEquality(RuntimeHelpers runtimeHelpers, CelOptions celOptions) {
280286
this.runtimeHelpers = runtimeHelpers;
281287
this.celOptions = celOptions;
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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 com.google.common.base.Preconditions.checkNotNull;
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, not an identity: calls that {@link #matches} considers identical
80+
* hash alike, but distinct calls may share a bucket. Resolve the exact call via {@link #matches}.
81+
*/
82+
static int hashCall(long exprId, String overloadId, Object[] args) {
83+
int result = 31 * Long.hashCode(exprId) + overloadId.hashCode();
84+
for (Object arg : args) {
85+
result = result * 31 + hashArg(arg);
86+
}
87+
return result;
88+
}
89+
90+
/**
91+
* Returns whether this record tracks a call to the same expression node, function, overload, and
92+
* arguments.
93+
*
94+
* <p>Arguments are compared under CEL equality, except that NaN compares equal to itself so that
95+
* a node re-evaluated with a NaN argument can find its existing record.
96+
*/
97+
boolean matches(
98+
long exprId,
99+
String functionName,
100+
String overloadId,
101+
Object[] args,
102+
RuntimeEquality runtimeEquality) {
103+
if (this.exprId != exprId
104+
|| !this.functionName.equals(functionName)
105+
|| !this.overloadId.equals(overloadId)
106+
|| this.args.length != args.length) {
107+
return false;
108+
}
109+
for (int i = 0; i < this.args.length; i++) {
110+
Object arg = this.args[i];
111+
Object otherArg = args[i];
112+
if (!runtimeEquality.objectEquals(arg, otherArg) && !(isNan(arg) && isNan(otherArg))) {
113+
return false;
114+
}
115+
}
116+
return true;
117+
}
118+
119+
@Override
120+
public long callId() {
121+
return callId;
122+
}
123+
124+
@Override
125+
public long exprId() {
126+
return exprId;
127+
}
128+
129+
@Override
130+
public String functionName() {
131+
return functionName;
132+
}
133+
134+
@Override
135+
public String overloadId() {
136+
return overloadId;
137+
}
138+
139+
/**
140+
* Transitions the call state from {@link State#NOT_STARTED} to {@link State#RUNNING}.
141+
*
142+
* @return true if the transition succeeded, false if the call was already running, completed, or
143+
* cancelled.
144+
*/
145+
boolean markRunning() {
146+
synchronized (lock) {
147+
if (state != State.NOT_STARTED) {
148+
return false;
149+
}
150+
state = State.RUNNING;
151+
return true;
152+
}
153+
}
154+
155+
void setInFlightFuture(ListenableFuture<?> future) {
156+
checkNotNull(future);
157+
boolean shouldCancel;
158+
synchronized (lock) {
159+
inFlightFuture = future;
160+
shouldCancel = (state == State.CANCELLED && !future.isDone());
161+
}
162+
if (shouldCancel) {
163+
future.cancel(/* mayInterruptIfRunning= */ false);
164+
}
165+
}
166+
167+
boolean cancelInFlight() {
168+
ListenableFuture<?> futureToCancel = null;
169+
synchronized (lock) {
170+
if (!isPending()) {
171+
return false;
172+
}
173+
state = State.CANCELLED;
174+
ListenableFuture<?> future = inFlightFuture;
175+
if (future != null && !future.isDone()) {
176+
futureToCancel = future;
177+
}
178+
}
179+
if (futureToCancel != null) {
180+
futureToCancel.cancel(/* mayInterruptIfRunning= */ false);
181+
}
182+
return true;
183+
}
184+
185+
/**
186+
* Claims the right to report this call's completion, returning true for the first caller only.
187+
*
188+
* <p>Tracked separately from {@link State} because a call cancelled after dispatch still holds a
189+
* concurrency permit and must release it exactly once.
190+
*/
191+
boolean markCompletionReported() {
192+
return completionReported.compareAndSet(false, true);
193+
}
194+
195+
boolean isCancelled() {
196+
return state == State.CANCELLED;
197+
}
198+
199+
boolean complete(@Nullable Object result) {
200+
synchronized (lock) {
201+
if (!isPending()) {
202+
return false;
203+
}
204+
this.result = result;
205+
state = State.SUCCESS;
206+
return true;
207+
}
208+
}
209+
210+
boolean fail(Throwable error) {
211+
checkNotNull(error);
212+
synchronized (lock) {
213+
if (!isPending()) {
214+
return false;
215+
}
216+
this.error = error;
217+
state = State.FAILURE;
218+
return true;
219+
}
220+
}
221+
222+
Object[] args() {
223+
return args.clone();
224+
}
225+
226+
CelAsyncFunctionOverload overload() {
227+
return overload;
228+
}
229+
230+
State state() {
231+
return state;
232+
}
233+
234+
/**
235+
* Returns the completed result, if present.
236+
*
237+
* <p>Note: If a call completed successfully with a {@code null} value, this method returns {@code
238+
* Optional.empty()}. Callers should check {@link #state()} to distinguish between a call that has
239+
* not completed and one that succeeded with {@code null}.
240+
*/
241+
Optional<Object> result() {
242+
return Optional.ofNullable(result);
243+
}
244+
245+
Optional<Throwable> error() {
246+
return Optional.ofNullable(error);
247+
}
248+
249+
private static int hashArg(@Nullable Object arg) {
250+
if (arg instanceof String) {
251+
return STRING_HASH_MARKER * 31 + arg.hashCode();
252+
}
253+
if (arg instanceof Boolean) {
254+
return BOOL_HASH_MARKER * 31 + arg.hashCode();
255+
}
256+
if (arg instanceof Number) {
257+
// Hash int, uint, and double through a common double representation so that values CEL
258+
// considers equal (1 == 1u == 1.0) share a bucket. NaN needs no special case because
259+
// Double.hashCode(NaN) is a constant across all double and float NaN representations.
260+
double value = ((Number) arg).doubleValue();
261+
// Normalize -0.0 to 0.0, which CEL considers equal to 0.0.
262+
return NUMBER_HASH_MARKER * 31 + Double.hashCode(value == 0.0d ? 0.0d : value);
263+
}
264+
return COMPLEX_HASH_MARKER;
265+
}
266+
267+
private static boolean isNan(@Nullable Object value) {
268+
return value instanceof Number && Double.isNaN(((Number) value).doubleValue());
269+
}
270+
271+
private boolean isPending() {
272+
return state == State.NOT_STARTED || state == State.RUNNING;
273+
}
274+
275+
private AsyncCallRecord(
276+
long callId,
277+
long exprId,
278+
String functionName,
279+
String overloadId,
280+
Object[] args,
281+
CelAsyncFunctionOverload overload) {
282+
this.callId = callId;
283+
this.exprId = exprId;
284+
this.functionName = checkNotNull(functionName);
285+
this.overloadId = checkNotNull(overloadId);
286+
this.args = checkNotNull(args).clone();
287+
this.overload = checkNotNull(overload);
288+
}
289+
}

0 commit comments

Comments
 (0)