|
| 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 | +} |
0 commit comments