blob: a011114bdc8e5e6a26e6f8143b7984f8f1632a62 [file] [edit]
// 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
//
// http://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 async provides helpers for configuring and executing asynchronous CEL functions,
// including drain strategies, retry, timeout, concurrency limiting, and caching wrappers.
package async
import (
"context"
"errors"
"time"
"github.com/google/cel-go/common/decls"
"github.com/google/cel-go/common/functions"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/interpreter"
)
// Call describes a pending or completed asynchronous function call.
// This interface exposes a safe, read-only view of the internal interpreter state.
type Call = interpreter.AsyncCall
// Observer provides callbacks for monitoring the lifecycle of asynchronous function calls.
//
// Implementations must be safe for concurrent use: the start and finish callbacks run on different
// goroutines, and finish callbacks for distinct calls may run concurrently. See
// interpreter.AsyncObserver for details.
type Observer = interpreter.AsyncObserver
// BlockingOp is a blocking asynchronous function operation.
type BlockingOp = functions.BlockingAsyncOp
// DrainAction dictates what ConcurrentEval should do after inspecting completions.
type DrainAction struct {
// Reevaluate indicates that the AST should be re-evaluated immediately.
// If true, WaitDuration is ignored.
Reevaluate bool
// WaitDuration indicates how long the evaluator should wait for additional
// completions before deciding to re-evaluate. A duration of 0 means wait
// indefinitely (block on the next completion).
WaitDuration time.Duration
}
// DrainStrategy controls when ConcurrentEval re-evaluates after async completions.
//
// The evaluator consults the strategy each time a completion is received.
type DrainStrategy interface {
// NextAction evaluates the current state of asynchronous evaluation and
// determines the next step.
//
// - completed: The set of completions accumulated in the current batch.
// - active: The number of async calls currently launched but unresolved.
NextAction(completed []Call, active int) DrainAction
}
// DrainNone returns a strategy that re-evaluates after every single completion.
// This is the default strategy.
func DrainNone() DrainStrategy {
return drainNone{}
}
type drainNone struct{}
func (drainNone) NextAction(completed []Call, active int) DrainAction {
return DrainAction{Reevaluate: active == 0 || len(completed) > 0}
}
// DrainReady returns a strategy that waits for a short duration after the first
// completion to batch any other functions that complete at roughly the same time.
func DrainReady(debounce time.Duration) DrainStrategy {
return drainReady{debounce: debounce}
}
type drainReady struct {
debounce time.Duration
}
func (d drainReady) NextAction(completed []Call, active int) DrainAction {
if active == 0 {
return DrainAction{Reevaluate: true} // Nothing left to wait for
}
if len(completed) == 0 {
return DrainAction{Reevaluate: false, WaitDuration: 0} // Wait indefinitely for first
}
return DrainAction{Reevaluate: false, WaitDuration: d.debounce} // Wait for debounce period
}
// DrainAll returns a strategy that waits for all currently pending calls to
// complete before re-evaluating.
//
// Note: This strategy is optimal for independent async calls, but will over-wait
// if some calls depend on the results of others.
func DrainAll() DrainStrategy {
return drainAll{}
}
type drainAll struct{}
func (drainAll) NextAction(completed []Call, active int) DrainAction {
return DrainAction{Reevaluate: active == 0}
}
// Timeout wraps a BlockingAsyncOp with a per-call timeout.
//
// The timeout is enforced even when the wrapped function ignores its context: the function runs on
// its own goroutine and Timeout selects on the deadline, returning a timeout error when it
// fires. A function that ignores cancellation cannot be forcibly stopped (Go cannot kill a
// goroutine), so its goroutine continues running in the background until it returns on its own;
// only its result is abandoned. This is the recommended way to bound functions that may hang or
// are not under the caller's control. The extra goroutine is incurred only by Timeout-wrapped
// calls, not by async evaluation in general.
func Timeout(fn functions.BlockingAsyncOp, timeout time.Duration) functions.BlockingAsyncOp {
return func(ctx context.Context, args ...ref.Val) ref.Val {
tCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
resCh := make(chan ref.Val, 1)
go func() { resCh <- fn(tCtx, args...) }()
select {
case res := <-resCh:
return res
case <-tCtx.Done():
return types.NewErr("operation timed out after %v: %v", timeout, tCtx.Err())
}
}
}
// TimeoutBinding wraps a BlockingAsyncOp with a per-call timeout and returns an OverloadOpt.
func TimeoutBinding(fn functions.BlockingAsyncOp, timeout time.Duration) decls.OverloadOpt {
return decls.AsyncBinding(Timeout(fn, timeout))
}
// RetryOption configures the behavior of RetryBinding.
type RetryOption func(*retryConfig)
type retryConfig struct {
maxAttempts int
backoff time.Duration
}
// RetryAttempts sets the maximum number of attempts (including the first one).
func RetryAttempts(attempts int) RetryOption {
return func(c *retryConfig) {
c.maxAttempts = attempts
}
}
// RetryBackoff sets the fixed backoff duration between attempts.
func RetryBackoff(backoff time.Duration) RetryOption {
return func(c *retryConfig) {
c.backoff = backoff
}
}
// RetryableError is an interface that errors can implement to signal whether they are retryable.
type RetryableError interface {
error
IsRetryable() bool
}
// Retry wraps a BlockingAsyncOp with a retry policy.
// It will retry the operation if it returns a types.Err that wraps a RetryableError returning true for IsRetryable.
func Retry(fn functions.BlockingAsyncOp, opts ...RetryOption) functions.BlockingAsyncOp {
config := &retryConfig{
maxAttempts: 3,
backoff: 100 * time.Millisecond,
}
for _, opt := range opts {
opt(config)
}
return func(ctx context.Context, args ...ref.Val) ref.Val {
var lastErr ref.Val
var backoff *time.Timer
defer func() {
if backoff != nil {
backoff.Stop()
}
}()
for i := 0; i < config.maxAttempts; i++ {
if i > 0 {
// Reuse a single timer across attempts and stop it on cancellation so the
// pending timer is not left to fire after the call returns.
if backoff == nil {
backoff = time.NewTimer(config.backoff)
} else {
backoff.Reset(config.backoff)
}
select {
case <-backoff.C:
case <-ctx.Done():
backoff.Stop()
return types.NewErr("operation cancelled during retry: %v", ctx.Err())
}
}
res := fn(ctx, args...)
if !types.IsError(res) {
return res
}
err := res.(*types.Err)
lastErr = res
if !isRetryable(err) {
return res
}
}
return lastErr
}
}
// RetryBinding wraps a BlockingAsyncOp with a retry policy and returns an OverloadOpt.
func RetryBinding(fn functions.BlockingAsyncOp, opts ...RetryOption) decls.OverloadOpt {
return decls.AsyncBinding(Retry(fn, opts...))
}
func isRetryable(err *types.Err) bool {
var re RetryableError
if errors.As(err, &re) {
return re.IsRetryable()
}
return false
}