diff --git a/cel/cel_test.go b/cel/cel_test.go
index f78a43ba5..b2698bd0e 100644
--- a/cel/cel_test.go
+++ b/cel/cel_test.go
@@ -178,7 +178,6 @@ func TestExtendCheckerParity(t *testing.T) {
}
}
-
func TestCompile(t *testing.T) {
prg, err := Compile(`"hello " + name`, Variable("name", StringType))
if err != nil {
diff --git a/cel/env_test.go b/cel/env_test.go
index 95124c15b..052b12e21 100644
--- a/cel/env_test.go
+++ b/cel/env_test.go
@@ -194,7 +194,6 @@ func TestEnvExtendDisableDeclaration(t *testing.T) {
}
}
-
func TestEnvCheckExtendRace(t *testing.T) {
t.Parallel()
for i := 0; i < 500; i++ {
@@ -328,8 +327,6 @@ func TestEnvConcurrentExtendWithMutation(t *testing.T) {
wg.Wait()
}
-
-
func TestEnvPartialVarsError(t *testing.T) {
env := testEnv(t)
_, err := env.PartialVars(10)
diff --git a/cel/io_test.go b/cel/io_test.go
index 41da494aa..3438679ec 100644
--- a/cel/io_test.go
+++ b/cel/io_test.go
@@ -448,4 +448,4 @@ func TestRefValToExprValue_Wrappers(t *testing.T) {
if res.GetValue() == nil {
t.Error("RefValToExprValue() returned nil value")
}
-}
\ No newline at end of file
+}
diff --git a/cel/validator_test.go b/cel/validator_test.go
index fb31c94c0..17f236319 100644
--- a/cel/validator_test.go
+++ b/cel/validator_test.go
@@ -596,4 +596,3 @@ func TestOverrideValidatorPreservesOrder(t *testing.T) {
t.Fatalf("expected overridden nesting limit validator with limit 5, got %v", validators[1])
}
}
-
diff --git a/checker/cost.go b/checker/cost.go
index 33b498051..00a115113 100644
--- a/checker/cost.go
+++ b/checker/cost.go
@@ -15,1009 +15,101 @@
package checker
import (
- "math"
-
- "cel.dev/cel-go/common"
"cel.dev/cel-go/common/ast"
"cel.dev/cel-go/common/cost"
- "cel.dev/cel-go/common/overloads"
"cel.dev/cel-go/common/types"
- "cel.dev/cel-go/parser"
)
-// WARNING: Any changes to cost calculations in this file require a corresponding change in interpreter/runtimecost.go
-
-// CostEstimator estimates the sizes of variable length input data and the costs of functions.
-type CostEstimator interface {
- // EstimateSize returns a SizeEstimate for the given AstNode, or nil if the estimator has no
- // estimate to provide.
- //
- // The size is equivalent to the result of the CEL `size()` function:
- // * Number of unicode characters in a string
- // * Number of bytes in a sequence
- // * Number of map entries or number of list items.
+type (
+ // CostEstimator estimates the sizes of variable length input data and the costs of functions.
//
- // EstimateSize is only called for AstNodes where CEL does not know the size; EstimateSize is not
- // called for values defined inline in CEL where the size is already obvious to CEL.
- EstimateSize(element AstNode) *SizeEstimate
-
- // EstimateCallCost returns the estimated cost of an invocation, or nil if the estimator has no
- // estimate to provide.
- EstimateCallCost(function, overloadID string, target *AstNode, args []AstNode) *CallEstimate
-}
-
-// CallEstimate includes a CostEstimate for the call, and an optional estimate of the result object size.
-// The ResultSize should only be provided if the call results in a map, list, string or bytes.
-type CallEstimate struct {
- CostEstimate
-
- ResultSize *SizeEstimate
-}
-
-// AstNode represents an AST node for the purpose of cost estimations.
-type AstNode interface {
- // Path returns a field path through the provided type declarations to the type of the AstNode, or nil if the AstNode does not
- // represent type directly reachable from the provided type declarations.
- // The first path element is a variable. All subsequent path elements are one of: field name, '@items', '@keys', '@values'.
- Path() []string
-
- // Type returns the deduced type of the AstNode.
- Type() *types.Type
+ // Deprecated: use cost.CostEstimator
+ CostEstimator = cost.Estimator
- // Expr returns the expression of the AstNode.
- Expr() ast.Expr
-
- // ComputedSize returns a size estimate of the AstNode derived from information available in the CEL expression.
- // For constants and inline list and map declarations, the exact size is returned. For concatenated list, strings
- // and bytes, the size is derived from the size estimates of the operands. nil is returned if there is no
- // computed size available.
- ComputedSize() *SizeEstimate
-}
-
-type astNode struct {
- path []string
- t *types.Type
- expr ast.Expr
- derivedSize *SizeEstimate
-}
+ // CallEstimate includes a CostEstimate for the call, and an optional estimate of the result object size.
+ //
+ // Deprecated: use cost.CallEstimate
+ CallEstimate = cost.CallEstimate
-func (e astNode) Path() []string {
- return e.path
-}
+ // AstNode represents an AST node for the purpose of cost estimations.
+ //
+ // Deprecated: use cost.AstNode
+ AstNode = cost.AstNode
-func (e astNode) Type() *types.Type {
- return e.t
-}
+ // SizeEstimate represents an estimated size of a variable length string, bytes, map or list.
+ //
+ // Deprecated: use cost.SizeEstimate
+ SizeEstimate = cost.SizeEstimate
-func (e astNode) Expr() ast.Expr {
- return e.expr
-}
+ // CostEstimate represents an estimated cost range and provides add and multiply operations
+ // that do not overflow.
+ //
+ // Deprecated: use cost.CostEstimate
+ CostEstimate = cost.CostEstimate
-func (e astNode) ComputedSize() *SizeEstimate {
- return e.derivedSize
-}
+ // CostOption configures flags which affect cost computations.
+ //
+ // Deprecated: use cost.CostOption
+ CostOption = cost.CostOption
-// SizeEstimate represents an estimated size of a variable length string, bytes, map or list.
-type SizeEstimate struct {
- Min, Max uint64
-}
+ // FunctionEstimator provides a CallEstimate given the target and arguments for a specific function, overload pair.
+ //
+ // Deprecated: use cost.FunctionEstimator
+ FunctionEstimator = cost.FunctionEstimator
+)
-// UnknownSizeEstimate returns a size between 0 and max uint
+// UnknownSizeEstimate returns a size between 0 and max uint.
+//
+// Deprecated: use cost.UnknownSizeEstimate
func UnknownSizeEstimate() SizeEstimate {
- return unknownSizeEstimate
+ return cost.UnknownSizeEstimate()
}
// FixedSizeEstimate returns a size estimate with a fixed min and max range.
+//
+// Deprecated: use cost.FixedSizeEstimate
func FixedSizeEstimate(size uint64) SizeEstimate {
- return SizeEstimate{Min: size, Max: size}
-}
-
-// Add adds to another SizeEstimate and returns the sum.
-// If add would result in an uint64 overflow, the result is math.MaxUint64.
-func (se SizeEstimate) Add(sizeEstimate SizeEstimate) SizeEstimate {
- return SizeEstimate{
- cost.SafeAdd(se.Min, sizeEstimate.Min),
- cost.SafeAdd(se.Max, sizeEstimate.Max),
- }
-}
-
-// Multiply multiplies by another SizeEstimate and returns the product.
-// If multiply would result in an uint64 overflow, the result is math.MaxUint64.
-func (se SizeEstimate) Multiply(sizeEstimate SizeEstimate) SizeEstimate {
- return SizeEstimate{
- cost.SafeMultiply(se.Min, sizeEstimate.Min),
- cost.SafeMultiply(se.Max, sizeEstimate.Max),
- }
-}
-
-// MultiplyByCostFactor multiplies a SizeEstimate by a cost factor and returns the CostEstimate with the
-// nearest integer of the result, rounded up.
-func (se SizeEstimate) MultiplyByCostFactor(costPerUnit float64) CostEstimate {
- return CostEstimate{
- cost.SafeMultiplyByFactor(se.Min, costPerUnit),
- cost.SafeMultiplyByFactor(se.Max, costPerUnit),
- }
-}
-
-// MultiplyByCost multiplies by the cost and returns the product.
-// If multiply would result in an uint64 overflow, the result is math.MaxUint64.
-func (se SizeEstimate) MultiplyByCost(estimate CostEstimate) CostEstimate {
- return CostEstimate{
- cost.SafeMultiply(se.Min, estimate.Min),
- cost.SafeMultiply(se.Max, estimate.Max),
- }
-}
-
-// Union returns a SizeEstimate that encompasses both input the SizeEstimate.
-func (se SizeEstimate) Union(size SizeEstimate) SizeEstimate {
- result := se
- if size.Min < result.Min {
- result.Min = size.Min
- }
- if size.Max > result.Max {
- result.Max = size.Max
- }
- return result
-}
-
-// AsCost converts a size estimates to an equivalent cost estimate.
-func (se SizeEstimate) AsCost() CostEstimate {
- return se.MultiplyByCostFactor(1)
-}
-
-// CostEstimate represents an estimated cost range and provides add and multiply operations
-// that do not overflow.
-type CostEstimate struct {
- Min, Max uint64
+ return cost.FixedSizeEstimate(size)
}
// UnknownCostEstimate returns a cost with an unknown impact.
+//
+// Deprecated: use cost.UnknownCostEstimate
func UnknownCostEstimate() CostEstimate {
- return unknownCostEstimate
+ return cost.UnknownCostEstimate()
}
// FixedCostEstimate returns a cost with a fixed min and max range.
+//
+// Deprecated: use cost.FixedCostEstimate
func FixedCostEstimate(fixedCost uint64) CostEstimate {
- return CostEstimate{Min: fixedCost, Max: fixedCost}
-}
-
-// Add adds the costs and returns the sum.
-// If add would result in an uint64 overflow for the min or max, the value is set to math.MaxUint64.
-func (ce CostEstimate) Add(estimate CostEstimate) CostEstimate {
- return CostEstimate{
- Min: cost.SafeAdd(ce.Min, estimate.Min),
- Max: cost.SafeAdd(ce.Max, estimate.Max),
- }
-}
-
-// Multiply multiplies by the cost and returns the product.
-// If multiply would result in an uint64 overflow, the result is math.MaxUint64.
-func (ce CostEstimate) Multiply(estimate CostEstimate) CostEstimate {
- return CostEstimate{
- Min: cost.SafeMultiply(ce.Min, estimate.Min),
- Max: cost.SafeMultiply(ce.Max, estimate.Max),
- }
-}
-
-// MultiplyByCostFactor multiplies a CostEstimate by a cost factor and returns the CostEstimate with the
-// nearest integer of the result, rounded up.
-func (ce CostEstimate) MultiplyByCostFactor(costPerUnit float64) CostEstimate {
- return CostEstimate{
- Min: cost.SafeMultiplyByFactor(ce.Min, costPerUnit),
- Max: cost.SafeMultiplyByFactor(ce.Max, costPerUnit),
- }
+ return cost.FixedCostEstimate(fixedCost)
}
-// Union returns a CostEstimate that encompasses both input the CostEstimates.
-func (ce CostEstimate) Union(size CostEstimate) CostEstimate {
- result := ce
- if size.Min < result.Min {
- result.Min = size.Min
- }
- if size.Max > result.Max {
- result.Max = size.Max
- }
- return result
-}
-
-// CostOption configures flags which affect cost computations.
-type CostOption func(*coster) error
-
// PresenceTestHasCost determines whether presence testing has a cost of one or zero.
//
-// Defaults to presence test has a cost of one.
+// Deprecated: use cost.PresenceTestHasCost
func PresenceTestHasCost(hasCost bool) CostOption {
- return func(c *coster) error {
- if hasCost {
- c.presenceTestCost = selectAndIdentCost
- return nil
- }
- c.presenceTestCost = FixedCostEstimate(0)
- return nil
- }
+ return cost.PresenceTestHasCost(hasCost)
}
-// FunctionEstimator provides a CallEstimate given the target and arguments for a specific function, overload pair.
-type FunctionEstimator func(estimator CostEstimator, target *AstNode, args []AstNode) *CallEstimate
-
-// OverloadCostEstimate binds a FunctionCoster to a specific function overload ID.
+// OverloadCostEstimate binds a FunctionEstimator to a specific function overload ID.
//
-// When a OverloadCostEstimate is provided, it will override the cost calculation of the CostEstimator provided to
-// the Cost() call.
+// Deprecated: use cost.OverloadCostEstimate
func OverloadCostEstimate(overloadID string, functionCoster FunctionEstimator) CostOption {
- return func(c *coster) error {
- c.overloadEstimators[overloadID] = functionCoster
- return nil
- }
-}
-
-// Cost estimates the cost of the parsed and type checked CEL expression.
-func Cost(checked *ast.AST, estimator CostEstimator, opts ...CostOption) (CostEstimate, error) {
- c := &coster{
- checkedAST: checked,
- estimator: estimator,
- overloadEstimators: map[string]FunctionEstimator{},
- exprPaths: map[int64][]string{},
- localVars: make(scopes),
- computedSizes: map[int64]SizeEstimate{},
- computedEntrySizes: map[int64]entrySizeEstimate{},
- presenceTestCost: FixedCostEstimate(1),
- }
- for _, opt := range opts {
- err := opt(c)
- if err != nil {
- return CostEstimate{}, err
- }
- }
- return c.cost(checked.Expr()), nil
-}
-
-type coster struct {
- // exprPaths maps from Expr Id to field path.
- exprPaths map[int64][]string
- // localVars tracks the local and iteration variables assigned during evaluation.
- localVars scopes
- // computedSizes tracks the computed sizes of call results.
- computedSizes map[int64]SizeEstimate
- // computedEntrySizes tracks the size of list and map entries
- computedEntrySizes map[int64]entrySizeEstimate
-
- checkedAST *ast.AST
- estimator CostEstimator
- overloadEstimators map[string]FunctionEstimator
- // presenceTestCost will either be a zero or one based on whether has() macros count against cost computations.
- presenceTestCost CostEstimate
+ return cost.OverloadCostEstimate(overloadID, functionCoster)
}
-// entrySizeEstimate captures the container kind and associated key/index and value SizeEstimate values.
+// NewAstNode creates a new AstNode for cost estimation.
//
-// An entrySizeEstimate only exists if both the key/index and the value have SizeEstimate values, otherwise
-// a nil entrySizeEstimate should be used.
-type entrySizeEstimate struct {
- containerKind types.Kind
- key SizeEstimate
- val SizeEstimate
-}
-
-// container returns the container kind (list or map) of the entry.
-func (s *entrySizeEstimate) container() types.Kind {
- if s == nil {
- return types.UnknownKind
- }
- return s.containerKind
-}
-
-// keySize returns the SizeEstimate for the key if one exists.
-func (s *entrySizeEstimate) keySize() *SizeEstimate {
- if s == nil {
- return nil
- }
- return &s.key
-}
-
-// valSize returns the SizeEstimate for the value if one exists.
-func (s *entrySizeEstimate) valSize() *SizeEstimate {
- if s == nil {
- return nil
- }
- return &s.val
-}
-
-func (s *entrySizeEstimate) union(other *entrySizeEstimate) *entrySizeEstimate {
- if s == nil || other == nil {
- return nil
- }
- sk := s.key.Union(other.key)
- sv := s.val.Union(other.val)
- return &entrySizeEstimate{
- containerKind: s.containerKind,
- key: sk,
- val: sv,
- }
-}
-
-// localVar captures the local variable size and entrySize estimates if they exist for variables
-type localVar struct {
- exprID int64
- path []string
- size *SizeEstimate
- entrySize *entrySizeEstimate
-}
-
-// scopes is a stack of variable name to integer id stack to handle scopes created by cel.bind() like macros
-type scopes map[string][]*localVar
-
-func (s scopes) push(varName string, expr ast.Expr, path []string, size *SizeEstimate, entrySize *entrySizeEstimate) {
- s[varName] = append(s[varName], &localVar{
- exprID: expr.ID(),
- path: path,
- size: size,
- entrySize: entrySize,
- })
-}
-
-func (s scopes) pop(varName string) {
- varStack := s[varName]
- s[varName] = varStack[:len(varStack)-1]
-}
-
-func (s scopes) peek(varName string) (*localVar, bool) {
- varStack := s[varName]
- if len(varStack) > 0 {
- return varStack[len(varStack)-1], true
- }
- return nil, false
-}
-
-func (c *coster) pushIterKey(varName string, rangeExpr ast.Expr) {
- entrySize := c.computeEntrySize(rangeExpr)
- size := entrySize.keySize()
- path := c.getPath(rangeExpr)
- container := entrySize.container()
- if container == types.UnknownKind {
- container = c.getType(rangeExpr).Kind()
- }
- subpath := "@keys"
- if container == types.ListKind {
- subpath = "@indices"
- }
- c.localVars.push(varName, rangeExpr, append(path, subpath), size, nil)
+// Deprecated: use cost.NewAstNode
+func NewAstNode(expr ast.Expr, path []string, t *types.Type, derivedSize *SizeEstimate) AstNode {
+ return cost.NewAstNode(expr, path, t, derivedSize)
}
-func (c *coster) pushIterValue(varName string, rangeExpr ast.Expr) {
- entrySize := c.computeEntrySize(rangeExpr)
- size := entrySize.valSize()
- path := c.getPath(rangeExpr)
- container := entrySize.container()
- if container == types.UnknownKind {
- container = c.getType(rangeExpr).Kind()
- }
- subpath := "@values"
- if container == types.ListKind {
- subpath = "@items"
- }
- c.localVars.push(varName, rangeExpr, append(path, subpath), size, nil)
-}
-
-func (c *coster) pushIterSingle(varName string, rangeExpr ast.Expr) {
- entrySize := c.computeEntrySize(rangeExpr)
- size := entrySize.keySize()
- subpath := "@keys"
- container := entrySize.container()
- if container == types.UnknownKind {
- container = c.getType(rangeExpr).Kind()
- }
- if container == types.ListKind {
- size = entrySize.valSize()
- subpath = "@items"
- }
- path := c.getPath(rangeExpr)
- c.localVars.push(varName, rangeExpr, append(path, subpath), size, nil)
-}
-
-func (c *coster) pushLocalVar(varName string, e ast.Expr) {
- path := c.getPath(e)
- // note: retrieve the entry size for the local variable based on the size of the binding expression
- // since the binding expression could be a list or map, the entry size should also be propagated
- entrySize := c.computeEntrySize(e)
- c.localVars.push(varName, e, path, c.computeSize(e), entrySize)
-}
-
-func (c *coster) peekLocalVar(varName string) (*localVar, bool) {
- return c.localVars.peek(varName)
-}
-
-func (c *coster) popLocalVar(varName string) {
- c.localVars.pop(varName)
-}
-
-func (c *coster) cost(e ast.Expr) CostEstimate {
- if e == nil {
- return CostEstimate{}
- }
- var estimate CostEstimate
- switch e.Kind() {
- case ast.LiteralKind:
- estimate = constCost
- case ast.IdentKind:
- estimate = c.costIdent(e)
- case ast.SelectKind:
- estimate = c.costSelect(e)
- case ast.CallKind:
- estimate = c.costCall(e)
- case ast.ListKind:
- estimate = c.costCreateList(e)
- case ast.MapKind:
- estimate = c.costCreateMap(e)
- case ast.StructKind:
- estimate = c.costCreateStruct(e)
- case ast.ComprehensionKind:
- if c.isBind(e) {
- estimate = c.costBind(e)
- } else {
- estimate = c.costComprehension(e)
- }
- default:
- return CostEstimate{}
- }
- return estimate
-}
-
-func (c *coster) costIdent(e ast.Expr) CostEstimate {
- identName := e.AsIdent()
- // build and track the field path
- if v, ok := c.peekLocalVar(identName); ok {
- c.addPath(e, v.path)
- } else {
- c.addPath(e, []string{identName})
- }
- return selectAndIdentCost
-}
-
-func (c *coster) costSelect(e ast.Expr) CostEstimate {
- sel := e.AsSelect()
- var sum CostEstimate
- if sel.IsTestOnly() {
- // recurse, but do not add any cost
- // this is equivalent to how evalTestOnly increments the runtime cost counter
- // but does not add any additional cost for the qualifier, except here we do
- // the reverse (ident adds cost)
- sum = sum.Add(c.presenceTestCost)
- sum = sum.Add(c.cost(sel.Operand()))
- return sum
- }
- sum = sum.Add(c.cost(sel.Operand()))
- targetType := c.getType(sel.Operand())
- switch targetType.Kind() {
- case types.MapKind, types.StructKind, types.TypeParamKind:
- sum = sum.Add(selectAndIdentCost)
- }
-
- // build and track the field path
- c.addPath(e, append(c.getPath(sel.Operand()), sel.FieldName()))
- return sum
-}
-
-func (c *coster) costCall(e ast.Expr) CostEstimate {
- // Dyn is just a way to disable type-checking, so return the cost of 1 with the cost of the argument
- if dynEstimate := c.maybeUnwrapDynCall(e); dynEstimate != nil {
- return *dynEstimate
- }
-
- // Continue estimating the cost of all other calls.
- call := e.AsCall()
- args := call.Args()
- var sum CostEstimate
-
- argTypes := make([]AstNode, len(args))
- argCosts := make([]CostEstimate, len(args))
- for i, arg := range args {
- argCosts[i] = c.cost(arg)
- argTypes[i] = c.newAstNode(arg)
- }
-
- overloadIDs := c.checkedAST.GetOverloadIDs(e.ID())
- if len(overloadIDs) == 0 {
- return CostEstimate{}
- }
- var targetType *AstNode
- if call.IsMemberFunction() {
- sum = sum.Add(c.cost(call.Target()))
- var t AstNode = c.newAstNode(call.Target())
- targetType = &t
- }
- // Pick a cost estimate range that covers all the overload cost estimation ranges
- fnCost := CostEstimate{Min: uint64(math.MaxUint64), Max: 0}
- var resultSize *SizeEstimate
- for _, overload := range overloadIDs {
- overloadCost := c.functionCost(e, call.FunctionName(), overload, targetType, argTypes, argCosts)
- fnCost = fnCost.Union(overloadCost.CostEstimate)
- if overloadCost.ResultSize != nil {
- if resultSize == nil {
- resultSize = overloadCost.ResultSize
- } else {
- size := resultSize.Union(*overloadCost.ResultSize)
- resultSize = &size
- }
- }
- // build and track the field path for index operations
- switch overload {
- case overloads.IndexList:
- if len(args) > 0 {
- // note: assigning resultSize here could be redundant with the path-based lookup later
- resultSize = c.computeEntrySize(args[0]).valSize()
- c.addPath(e, append(c.getPath(args[0]), "@items"))
- }
- case overloads.IndexMap:
- if len(args) > 0 {
- resultSize = c.computeEntrySize(args[0]).valSize()
- c.addPath(e, append(c.getPath(args[0]), "@values"))
- }
- }
- if resultSize == nil {
- resultSize = c.computeSize(e)
- }
- }
- c.setSize(e, resultSize)
- return sum.Add(fnCost)
-}
-
-func (c *coster) maybeUnwrapDynCall(e ast.Expr) *CostEstimate {
- call := e.AsCall()
- if call.FunctionName() != "dyn" {
- return nil
- }
- arg := call.Args()[0]
- argCost := c.cost(arg)
- c.copySizeEstimates(e, arg)
- callCost := FixedCostEstimate(1).Add(argCost)
- return &callCost
-}
-
-func (c *coster) costCreateList(e ast.Expr) CostEstimate {
- create := e.AsList()
- var sum CostEstimate
- itemSize := SizeEstimate{Min: math.MaxUint64, Max: 0}
- if create.Size() == 0 {
- itemSize.Min = 0
- }
- for _, e := range create.Elements() {
- sum = sum.Add(c.cost(e))
- is := c.sizeOrUnknown(e)
- itemSize = itemSize.Union(is)
- }
- c.setEntrySize(e, &entrySizeEstimate{containerKind: types.ListKind, key: FixedSizeEstimate(1), val: itemSize})
- return sum.Add(createListBaseCost)
-}
-
-func (c *coster) costCreateMap(e ast.Expr) CostEstimate {
- mapVal := e.AsMap()
- var sum CostEstimate
- keySize := SizeEstimate{Min: math.MaxUint64, Max: 0}
- valSize := SizeEstimate{Min: math.MaxUint64, Max: 0}
- if mapVal.Size() == 0 {
- valSize.Min = 0
- keySize.Min = 0
- }
- for _, ent := range mapVal.Entries() {
- entry := ent.AsMapEntry()
- sum = sum.Add(c.cost(entry.Key()))
- sum = sum.Add(c.cost(entry.Value()))
- // Compute the key size range
- ks := c.sizeOrUnknown(entry.Key())
- keySize = keySize.Union(ks)
- // Compute the value size range
- vs := c.sizeOrUnknown(entry.Value())
- valSize = valSize.Union(vs)
- }
- c.setEntrySize(e, &entrySizeEstimate{containerKind: types.MapKind, key: keySize, val: valSize})
- return sum.Add(createMapBaseCost)
-}
-
-func (c *coster) costCreateStruct(e ast.Expr) CostEstimate {
- msgVal := e.AsStruct()
- var sum CostEstimate
- for _, ent := range msgVal.Fields() {
- field := ent.AsStructField()
- sum = sum.Add(c.cost(field.Value()))
- }
- return sum.Add(createMessageBaseCost)
-}
-
-func (c *coster) costComprehension(e ast.Expr) CostEstimate {
- comp := e.AsComprehension()
- var sum CostEstimate
- sum = sum.Add(c.cost(comp.IterRange()))
- sum = sum.Add(c.cost(comp.AccuInit()))
- c.pushLocalVar(comp.AccuVar(), comp.AccuInit())
-
- // Track the iterRange of each IterVar and AccuVar for field path construction
- if comp.HasIterVar2() {
- c.pushIterKey(comp.IterVar(), comp.IterRange())
- c.pushIterValue(comp.IterVar2(), comp.IterRange())
- } else {
- c.pushIterSingle(comp.IterVar(), comp.IterRange())
- }
-
- // Determine the cost for each element in the loop
- loopCost := c.cost(comp.LoopCondition())
- stepCost := c.cost(comp.LoopStep())
-
- // Clear the intermediate variable tracking.
- c.popLocalVar(comp.IterVar())
- if comp.HasIterVar2() {
- c.popLocalVar(comp.IterVar2())
- }
-
- // Determine the result cost.
- sum = sum.Add(c.cost(comp.Result()))
- c.localVars.pop(comp.AccuVar())
-
- // Estimate the cost of the loop.
- rangeCnt := c.sizeOrUnknown(comp.IterRange())
- rangeCost := rangeCnt.MultiplyByCost(stepCost.Add(loopCost))
- sum = sum.Add(rangeCost)
-
- switch k := comp.AccuInit().Kind(); k {
- case ast.LiteralKind:
- c.setSize(e, c.computeSize(comp.AccuInit()))
- case ast.ListKind, ast.MapKind:
- c.setSize(e, &rangeCnt)
- // For a step which produces a container value, it will have an entry size associated
- // with its expression id.
- if stepEntrySize := c.computeEntrySize(comp.LoopStep()); stepEntrySize != nil {
- c.setEntrySize(e, stepEntrySize)
- break
- }
- }
- return sum
-}
-
-func (c *coster) isBind(e ast.Expr) bool {
- comp := e.AsComprehension()
- iterRange := comp.IterRange()
- loopCond := comp.LoopCondition()
- return iterRange.Kind() == ast.ListKind && iterRange.AsList().Size() == 0 &&
- loopCond.Kind() == ast.LiteralKind && loopCond.AsLiteral() == types.False &&
- comp.AccuVar() != parser.AccumulatorName
-}
-
-func (c *coster) costBind(e ast.Expr) CostEstimate {
- comp := e.AsComprehension()
- var sum CostEstimate
- // Binds are lazily initialized, so we retain the cost of an empty iteration range.
- sum = sum.Add(c.cost(comp.IterRange()))
- sum = sum.Add(c.cost(comp.AccuInit()))
-
- c.pushLocalVar(comp.AccuVar(), comp.AccuInit())
- sum = sum.Add(c.cost(comp.Result()))
- c.popLocalVar(comp.AccuVar())
-
- // Associate the bind output size with the result size.
- c.copySizeEstimates(e, comp.Result())
- return sum
-}
-
-func (c *coster) functionCost(e ast.Expr, function, overloadID string, target *AstNode, args []AstNode, argCosts []CostEstimate) CallEstimate {
- argCostSum := func() CostEstimate {
- var sum CostEstimate
- for _, a := range argCosts {
- sum = sum.Add(a)
- }
- return sum
- }
- if len(c.overloadEstimators) != 0 {
- if estimator, found := c.overloadEstimators[overloadID]; found {
- if est := estimator(c.estimator, target, args); est != nil {
- callEst := *est
- return CallEstimate{CostEstimate: callEst.Add(argCostSum()), ResultSize: est.ResultSize}
- }
- }
- }
- if est := c.estimator.EstimateCallCost(function, overloadID, target, args); est != nil {
- callEst := *est
- return CallEstimate{CostEstimate: callEst.Add(argCostSum()), ResultSize: est.ResultSize}
- }
- switch overloadID {
- // O(n) functions
- case overloads.ExtFormatString:
- if target != nil {
- // ResultSize not calculated because we can't bound the max size.
- return CallEstimate{
- CostEstimate: c.sizeOrUnknown(*target).MultiplyByCostFactor(common.StringTraversalCostFactor).Add(argCostSum())}
- }
- case overloads.StringToBytes:
- if len(args) == 1 {
- sz := c.sizeOrUnknown(args[0])
- // ResultSize max is when each char converts to 4 bytes.
- return CallEstimate{
- CostEstimate: sz.MultiplyByCostFactor(common.StringTraversalCostFactor).Add(argCostSum()),
- ResultSize: &SizeEstimate{Min: sz.Min, Max: sz.Max * 4}}
- }
- case overloads.BytesToString:
- if len(args) == 1 {
- sz := c.sizeOrUnknown(args[0])
- // ResultSize min is when 4 bytes convert to 1 char.
- return CallEstimate{
- CostEstimate: sz.MultiplyByCostFactor(common.StringTraversalCostFactor).Add(argCostSum()),
- ResultSize: &SizeEstimate{Min: sz.Min / 4, Max: sz.Max}}
- }
- case overloads.ExtQuoteString:
- if len(args) == 1 {
- sz := c.sizeOrUnknown(args[0])
- // ResultSize max is when each char is escaped. 2 quote chars always added.
- return CallEstimate{
- CostEstimate: sz.MultiplyByCostFactor(common.StringTraversalCostFactor).Add(argCostSum()),
- ResultSize: &SizeEstimate{Min: sz.Min + 2, Max: sz.Max*2 + 2}}
- }
- case overloads.StartsWithString, overloads.EndsWithString:
- if len(args) == 1 {
- return CallEstimate{CostEstimate: c.sizeOrUnknown(args[0]).MultiplyByCostFactor(common.StringTraversalCostFactor).Add(argCostSum())}
- }
- case overloads.InList:
- // If a list is composed entirely of constant values this is O(1), but we don't account for that here.
- // We just assume all list containment checks are O(n).
- if len(args) == 2 {
- return CallEstimate{CostEstimate: c.sizeOrUnknown(args[1]).MultiplyByCostFactor(1).Add(argCostSum())}
- }
- // O(nm) functions
- case overloads.Matches, overloads.MatchesString:
- // https://swtch.com/~rsc/regexp/regexp1.html applies to RE2 implementation supported by CEL
- var strNode, regexNode AstNode
- if overloadID == overloads.MatchesString && target != nil && len(args) == 1 {
- strNode = *target
- regexNode = args[0]
- } else if overloadID == overloads.Matches && target == nil && len(args) == 2 {
- strNode = args[0]
- regexNode = args[1]
- }
- if strNode != nil && regexNode != nil {
- // Add one to string length for purposes of cost calculation to prevent product of string and regex to be 0
- // in case where string is empty but regex is still expensive.
- strCost := c.sizeOrUnknown(strNode).Add(SizeEstimate{Min: 1, Max: 1}).MultiplyByCostFactor(common.StringTraversalCostFactor)
- // We don't know how many expressions are in the regex, just the string length (a huge
- // improvement here would be to somehow get a count the number of expressions in the regex or
- // how many states are in the regex state machine and use that to measure regex cost).
- // For now, we're making a guess that each expression in a regex is typically at least 4 chars
- // in length.
- regexCost := c.sizeOrUnknown(regexNode).MultiplyByCostFactor(common.RegexStringLengthCostFactor)
- return CallEstimate{CostEstimate: strCost.Multiply(regexCost).Add(argCostSum())}
- }
- case overloads.ContainsString:
- if target != nil && len(args) == 1 {
- strCost := c.sizeOrUnknown(*target).MultiplyByCostFactor(common.StringTraversalCostFactor)
- substrCost := c.sizeOrUnknown(args[0]).MultiplyByCostFactor(common.StringTraversalCostFactor)
- return CallEstimate{CostEstimate: strCost.Multiply(substrCost).Add(argCostSum())}
- }
- case overloads.LogicalOr, overloads.LogicalAnd:
- lhs := argCosts[0]
- rhs := argCosts[1]
- // min cost is min of LHS for short circuited && or ||
- argCost := CostEstimate{Min: lhs.Min, Max: lhs.Add(rhs).Max}
- return CallEstimate{CostEstimate: argCost}
- case overloads.Conditional:
- size := c.sizeOrUnknown(args[1]).Union(c.sizeOrUnknown(args[2]))
- resultEntrySize := c.computeEntrySize(args[1].Expr()).union(c.computeEntrySize(args[2].Expr()))
- c.setEntrySize(e, resultEntrySize)
- conditionalCost := argCosts[0]
- ifTrueCost := argCosts[1]
- ifFalseCost := argCosts[2]
- argCost := conditionalCost.Add(ifTrueCost.Union(ifFalseCost))
- return CallEstimate{CostEstimate: argCost, ResultSize: &size}
- case overloads.AddString, overloads.AddBytes, overloads.AddList:
- if len(args) == 2 {
- lhsSize := c.sizeOrUnknown(args[0])
- rhsSize := c.sizeOrUnknown(args[1])
- resultSize := lhsSize.Add(rhsSize)
- rhsEntrySize := c.computeEntrySize(args[0].Expr())
- lhsEntrySize := c.computeEntrySize(args[1].Expr())
- resultEntrySize := rhsEntrySize.union(lhsEntrySize)
- if resultEntrySize != nil {
- c.setEntrySize(e, resultEntrySize)
- }
- switch overloadID {
- case overloads.AddList:
- // list concatenation is O(1), but we handle it here to track size
- return CallEstimate{CostEstimate: FixedCostEstimate(1).Add(argCostSum()), ResultSize: &resultSize}
- default:
- return CallEstimate{CostEstimate: resultSize.MultiplyByCostFactor(common.StringTraversalCostFactor).Add(argCostSum()), ResultSize: &resultSize}
- }
- }
- case overloads.LessString, overloads.GreaterString, overloads.LessEqualsString, overloads.GreaterEqualsString,
- overloads.LessBytes, overloads.GreaterBytes, overloads.LessEqualsBytes, overloads.GreaterEqualsBytes,
- overloads.Equals, overloads.NotEquals:
- lhsCost := c.sizeOrUnknown(args[0])
- rhsCost := c.sizeOrUnknown(args[1])
- min := uint64(0)
- smallestMax := lhsCost.Max
- if rhsCost.Max < smallestMax {
- smallestMax = rhsCost.Max
- }
- if smallestMax > 0 {
- min = 1
- }
- // equality of 2 scalar values results in a cost of 1
- return CallEstimate{
- CostEstimate: CostEstimate{Min: min, Max: smallestMax}.MultiplyByCostFactor(common.StringTraversalCostFactor).Add(argCostSum()),
- }
- }
- // O(1) functions
- // See CostTracker.costCall for more details about O(1) cost calculations
-
- // Benchmarks suggest that most of the other operations take +/- 50% of a base cost unit
- // which on an Intel xeon 2.20GHz CPU is 50ns.
- return CallEstimate{CostEstimate: FixedCostEstimate(1).Add(argCostSum())}
-}
-
-func (c *coster) getType(e ast.Expr) *types.Type {
- return c.checkedAST.GetType(e.ID())
-}
-
-func (c *coster) getPath(e ast.Expr) []string {
- if e.Kind() == ast.IdentKind {
- if v, found := c.peekLocalVar(e.AsIdent()); found {
- return v.path[:]
- }
- }
- return c.exprPaths[e.ID()][:]
-}
-
-func (c *coster) addPath(e ast.Expr, path []string) {
- c.exprPaths[e.ID()] = path
-}
-
-func isAccumulatorVar(name string) bool {
- return name == parser.AccumulatorName || name == parser.HiddenAccumulatorName
-}
-
-func (c *coster) newAstNode(e ast.Expr) *astNode {
- path := c.getPath(e)
- if len(path) > 0 && isAccumulatorVar(path[0]) {
- // only provide paths to root vars; omit accumulator vars
- path = nil
- }
- return &astNode{
- path: path,
- t: c.getType(e),
- expr: e,
- derivedSize: c.computeSize(e)}
-}
-
-func (c *coster) setSize(e ast.Expr, size *SizeEstimate) {
- if size == nil {
- return
- }
- // Store the computed size with the expression
- c.computedSizes[e.ID()] = *size
-}
-
-func (c *coster) sizeOrUnknown(node any) SizeEstimate {
- switch v := node.(type) {
- case ast.Expr:
- if sz := c.computeSize(v); sz != nil {
- return *sz
- }
- case AstNode:
- if sz := v.ComputedSize(); sz != nil {
- return *sz
- }
- }
- return UnknownSizeEstimate()
-}
-
-func (c *coster) copySizeEstimates(dst, src ast.Expr) {
- c.setSize(dst, c.computeSize(src))
- c.setEntrySize(dst, c.computeEntrySize(src))
-}
-
-func (c *coster) computeSize(e ast.Expr) *SizeEstimate {
- if size, ok := c.computedSizes[e.ID()]; ok {
- return &size
- }
- if size := computeExprSize(e); size != nil {
- return size
- }
- // Ensure size estimates are computed first as users may choose to override the costs that
- // CEL would otherwise ascribe to the type.
- node := astNode{expr: e, path: c.getPath(e), t: c.getType(e)}
- if size := c.estimator.EstimateSize(node); size != nil {
- // storing the computed size should reduce calls to EstimateSize()
- c.computedSizes[e.ID()] = *size
- return size
- }
- if size := computeTypeSize(c.getType(e)); size != nil {
- return size
- }
- if e.Kind() == ast.IdentKind {
- varName := e.AsIdent()
- if v, ok := c.peekLocalVar(varName); ok && v.size != nil {
- return v.size
- }
- }
- return nil
-}
-
-func (c *coster) setEntrySize(e ast.Expr, size *entrySizeEstimate) {
- if size == nil {
- return
- }
- c.computedEntrySizes[e.ID()] = *size
-}
-
-func (c *coster) computeEntrySize(e ast.Expr) *entrySizeEstimate {
- if sz, found := c.computedEntrySizes[e.ID()]; found {
- return &sz
- }
- if e.Kind() == ast.IdentKind {
- varName := e.AsIdent()
- if v, ok := c.peekLocalVar(varName); ok && v.entrySize != nil {
- return v.entrySize
- }
- }
- return nil
-}
-
-func computeExprSize(expr ast.Expr) *SizeEstimate {
- var v uint64
- switch expr.Kind() {
- case ast.LiteralKind:
- switch ck := expr.AsLiteral().(type) {
- case types.String:
- // converting to runes here is an O(n) operation, but
- // this is consistent with how size is computed at runtime,
- // and how the language definition defines string size
- v = uint64(len([]rune(ck)))
- case types.Bytes:
- v = uint64(len(ck))
- case types.Bool, types.Double, types.Duration,
- types.Int, types.Timestamp, types.Uint,
- types.Null:
- v = uint64(1)
- default:
- return nil
- }
- case ast.ListKind:
- v = uint64(expr.AsList().Size())
- case ast.MapKind:
- v = uint64(expr.AsMap().Size())
- default:
- return nil
- }
- size := FixedSizeEstimate(v)
- return &size
-}
-
-func computeTypeSize(t *types.Type) *SizeEstimate {
- if isScalar(t) {
- size := FixedSizeEstimate(1)
- return &size
- }
- return nil
-}
-
-// isScalar returns true if the given type is known to be of a constant size at
-// compile time. isScalar will return false for strings (they are variable-width)
-// in addition to protobuf.Any and protobuf.Value (their size is not knowable at compile time).
-func isScalar(t *types.Type) bool {
- switch t.Kind() {
- case types.BoolKind, types.DoubleKind, types.DurationKind, types.IntKind, types.TimestampKind, types.UintKind:
- return true
- case types.OpaqueKind:
- if t.TypeName() == "optional_type" {
- return isScalar(t.Parameters()[0])
- }
- }
- return false
+// Cost estimates the cost of the parsed and type checked CEL expression.
+//
+// Deprecated: use cost.Cost
+func Cost(checked *ast.AST, estimator CostEstimator, opts ...CostOption) (CostEstimate, error) {
+ return cost.Cost(checked, estimator, opts...)
}
-
-var (
- unknownSizeEstimate = SizeEstimate{Min: 0, Max: math.MaxUint64}
- unknownCostEstimate = unknownSizeEstimate.MultiplyByCostFactor(1)
-
- selectAndIdentCost = FixedCostEstimate(common.SelectAndIdentCost)
- constCost = FixedCostEstimate(common.ConstCost)
-
- createListBaseCost = FixedCostEstimate(common.ListCreateBaseCost)
- createMapBaseCost = FixedCostEstimate(common.MapCreateBaseCost)
- createMessageBaseCost = FixedCostEstimate(common.StructCreateBaseCost)
-)
diff --git a/checker/cost_test.go b/checker/cost_test.go
index 3a7ef835b..830e271c8 100644
--- a/checker/cost_test.go
+++ b/checker/cost_test.go
@@ -15,855 +15,98 @@
package checker
import (
- "math"
- "strings"
"testing"
"cel.dev/cel-go/common"
"cel.dev/cel-go/common/containers"
+ "cel.dev/cel-go/common/cost"
"cel.dev/cel-go/common/decls"
- "cel.dev/cel-go/common/overloads"
"cel.dev/cel-go/common/stdlib"
"cel.dev/cel-go/common/types"
"cel.dev/cel-go/parser"
-
- proto3pb "cel.dev/cel-go/test/proto3pb"
)
-func TestCost(t *testing.T) {
- allTypes := types.NewObjectType("google.expr.proto3.test.TestAllTypes")
- allList := types.NewListType(allTypes)
- intList := types.NewListType(types.IntType)
- nestedList := types.NewListType(allList)
-
- allMap := types.NewMapType(types.StringType, allTypes)
- nestedMap := types.NewMapType(types.StringType, allMap)
-
- zeroCost := CostEstimate{}
- oneCost := FixedCostEstimate(1)
- cases := []struct {
- name string
- expr string
- vars []*decls.VariableDecl
- hints map[string]uint64
- options []CostOption
- wanted CostEstimate
- }{
- {
- name: "const",
- expr: `"Hello World!"`,
- wanted: zeroCost,
- },
- {
- name: "identity",
- expr: `input`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", intList)},
- wanted: CostEstimate{Min: 1, Max: 1},
- },
- {
- name: "select: map",
- expr: `input['key']`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.NewMapType(types.StringType, types.StringType))},
- wanted: CostEstimate{Min: 2, Max: 2},
- },
- {
- name: "select: field",
- expr: `input.single_int32`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", allTypes)},
- wanted: CostEstimate{Min: 2, Max: 2},
- },
- {
- name: "select: field test only no has() cost",
- expr: `has(input.single_int32)`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewObjectType("google.expr.proto3.test.TestAllTypes"))},
- wanted: CostEstimate{Min: 1, Max: 1},
- options: []CostOption{PresenceTestHasCost(false)},
- },
- {
- name: "select: field test only",
- expr: `has(input.single_int32)`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewObjectType("google.expr.proto3.test.TestAllTypes"))},
- wanted: CostEstimate{Min: 2, Max: 2},
- },
- {
- name: "select: non-proto field test has() cost",
- expr: `has(input.testAttr.nestedAttr)`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", nestedMap)},
- wanted: CostEstimate{Min: 3, Max: 3},
- options: []CostOption{PresenceTestHasCost(true)},
- },
- {
- name: "select: non-proto field test no has() cost",
- expr: `has(input.testAttr.nestedAttr)`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", nestedMap)},
- wanted: CostEstimate{Min: 2, Max: 2},
- options: []CostOption{PresenceTestHasCost(false)},
- },
- {
- name: "select: non-proto field test",
- expr: `has(input.testAttr.nestedAttr)`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", nestedMap)},
- wanted: CostEstimate{Min: 3, Max: 3},
- },
- {
- name: "estimated function call",
- expr: `input.getFullYear()`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.TimestampType)},
- wanted: CostEstimate{Min: 8, Max: 8},
- },
- {
- name: "create list",
- expr: `[1, 2, 3]`,
- wanted: CostEstimate{Min: 10, Max: 10},
- },
- {
- name: "create struct",
- expr: `google.expr.proto3.test.TestAllTypes{single_int32: 1, single_float: 3.14, single_string: 'str'}`,
- wanted: CostEstimate{Min: 40, Max: 40},
- },
- {
- name: "create map",
- expr: `{"a": 1, "b": 2, "c": 3}`,
- wanted: CostEstimate{Min: 30, Max: 30},
- },
- {
- name: "all comprehension",
- vars: []*decls.VariableDecl{decls.NewVariable("input", allList)},
- hints: map[string]uint64{"input": 100},
- expr: `input.all(x, true)`,
- wanted: CostEstimate{Min: 2, Max: 302},
- },
- {
- name: "nested all comprehension",
- vars: []*decls.VariableDecl{decls.NewVariable("input", nestedList)},
- hints: map[string]uint64{"input": 50, "input.@items": 10},
- expr: `input.all(x, x.all(y, true))`,
- wanted: CostEstimate{Min: 2, Max: 1752},
- },
- {
- name: "all comprehension on literal",
- expr: `[1, 2, 3].all(x, true)`,
- wanted: CostEstimate{Min: 20, Max: 20},
- },
- {
- name: "variable cost function",
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.StringType)},
- hints: map[string]uint64{"input": 500},
- expr: `input.matches('[0-9]')`,
- wanted: CostEstimate{Min: 3, Max: 103},
- },
- {
- name: "variable cost function with constant",
- expr: `'123'.matches('[0-9]')`,
- wanted: CostEstimate{Min: 2, Max: 2},
- },
- {
- name: "or",
- expr: `true || false`,
- wanted: zeroCost,
- },
- {
- name: "or accumulated branch cost",
- expr: `a || b || c || d`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("a", types.BoolType),
- decls.NewVariable("b", types.BoolType),
- decls.NewVariable("c", types.BoolType),
- decls.NewVariable("d", types.BoolType),
- },
- wanted: CostEstimate{Min: 1, Max: 4},
- },
- {
- name: "and",
- expr: `true && false`,
- wanted: zeroCost,
- },
- {
- name: "and accumulated branch cost",
- expr: `a && b && c && d`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("a", types.BoolType),
- decls.NewVariable("b", types.BoolType),
- decls.NewVariable("c", types.BoolType),
- decls.NewVariable("d", types.BoolType),
- },
- wanted: CostEstimate{Min: 1, Max: 4},
- },
- {
- name: "lt",
- expr: `1 < 2`,
- wanted: oneCost,
- },
- {
- name: "lte",
- expr: `1 <= 2`,
- wanted: oneCost,
- },
- {
- name: "eq",
- expr: `1 == 2`,
- wanted: oneCost,
- },
- {
- name: "gt",
- expr: `2 > 1`,
- wanted: oneCost,
- },
- {
- name: "gte",
- expr: `2 >= 1`,
- wanted: oneCost,
- },
- {
- name: "in",
- expr: `2 in [1, 2, 3]`,
- wanted: CostEstimate{Min: 13, Max: 13},
- },
- {
- name: "plus",
- expr: `1 + 1`,
- wanted: oneCost,
- },
- {
- name: "minus",
- expr: `1 - 1`,
- wanted: oneCost,
- },
- {
- name: "/",
- expr: `1 / 1`,
- wanted: oneCost,
- },
- {
- name: "/",
- expr: `1 * 1`,
- wanted: oneCost,
- },
- {
- name: "%",
- expr: `1 % 1`,
- wanted: oneCost,
- },
- {
- name: "ternary",
- expr: `true ? 1 : 2`,
- wanted: zeroCost,
- },
- {
- name: "string size",
- expr: `size("123")`,
- wanted: oneCost,
- },
- {
- name: "bytes size",
- expr: `size(b"123")`,
- wanted: oneCost,
- },
- {
- name: "bytes to string conversion",
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.BytesType)},
- hints: map[string]uint64{"input": 500},
- expr: `string(input)`,
- wanted: CostEstimate{Min: 1, Max: 51},
- },
- {
- name: "bytes to string conversion equality",
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.BytesType)},
- hints: map[string]uint64{"input": 500},
- // equality check ensures that the resultSize calculation is included in cost
- expr: `string(input) == string(input)`,
- wanted: CostEstimate{Min: 3, Max: 152},
- },
- {
- name: "string to bytes conversion",
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.StringType)},
- hints: map[string]uint64{"input": 500},
- expr: `bytes(input)`,
- wanted: CostEstimate{Min: 1, Max: 51},
- },
- {
- name: "string to bytes conversion equality",
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.StringType)},
- hints: map[string]uint64{"input": 500},
- // equality check ensures that the resultSize calculation is included in cost
- expr: `bytes(input) == bytes(input)`,
- wanted: CostEstimate{Min: 3, Max: 302},
- },
- {
- name: "int to string conversion",
- expr: `string(1)`,
- wanted: CostEstimate{Min: 1, Max: 1},
- },
- {
- name: "contains",
- expr: `input.contains(arg1)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- decls.NewVariable("arg1", types.StringType),
- },
- hints: map[string]uint64{"input": 500, "arg1": 500},
- wanted: CostEstimate{Min: 2, Max: 2502},
- },
- {
- name: "matches",
- expr: `input.matches('\\d+a\\d+b')`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- },
- hints: map[string]uint64{"input": 500},
- wanted: CostEstimate{Min: 3, Max: 103},
- },
- {
- name: "matches global",
- expr: `matches(input, '\\d+a\\d+b')`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- },
- hints: map[string]uint64{"input": 500},
- wanted: CostEstimate{Min: 3, Max: 103},
- },
- {
- name: "startsWith",
- expr: `input.startsWith(arg1)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- decls.NewVariable("arg1", types.StringType),
- },
- hints: map[string]uint64{"arg1": 500},
- wanted: CostEstimate{Min: 2, Max: 52},
- },
- {
- name: "endsWith",
- expr: `input.endsWith(arg1)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- decls.NewVariable("arg1", types.StringType),
- },
- hints: map[string]uint64{"arg1": 500},
- wanted: CostEstimate{Min: 2, Max: 52},
- },
- {
- name: "size receiver",
- expr: `input.size()`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- },
- wanted: CostEstimate{Min: 2, Max: 2},
- },
- {
- name: "size",
- expr: `size(input)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- },
- wanted: CostEstimate{Min: 2, Max: 2},
- },
- {
- name: "ternary eval",
- expr: `(x > 2 ? input1 : input2).all(y, true)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("x", types.IntType),
- decls.NewVariable("input1", allList),
- decls.NewVariable("input2", allList),
- },
- hints: map[string]uint64{"input1": 1, "input2": 1},
- wanted: CostEstimate{Min: 4, Max: 7},
- },
- {
- name: "comprehension over map",
- expr: `input.all(k, input[k].single_int32 > 3)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", allMap),
- },
- hints: map[string]uint64{"input": 10},
- wanted: CostEstimate{Min: 2, Max: 82},
- },
- {
- name: "comprehension over nested map of maps",
- expr: `input.all(k, input[k].all(x, true))`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", nestedMap),
- },
- hints: map[string]uint64{"input": 5, "input.@values": 10},
- wanted: CostEstimate{Min: 2, Max: 187},
- },
- {
- name: "string size of map keys",
- expr: `input.all(k, k.contains(k))`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", nestedMap),
- },
- hints: map[string]uint64{"input": 5, "input.@keys": 10},
- wanted: CostEstimate{Min: 2, Max: 32},
- },
- {
- name: "comprehension variable shadowing",
- expr: `input.all(k, input[k].all(k, true) && k.contains(k))`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", nestedMap),
- },
- hints: map[string]uint64{"input": 2, "input.@values": 2, "input.@keys": 5},
- wanted: CostEstimate{Min: 2, Max: 34},
- },
- {
- name: "comprehension variable shadowing",
- expr: `input.all(k, input[k].all(k, true) && k.contains(k))`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", nestedMap),
- },
- hints: map[string]uint64{"input": 2, "input.@values": 2, "input.@keys": 5},
- wanted: CostEstimate{Min: 2, Max: 34},
- },
- {
- name: "list concat",
- expr: `(list1 + list2).all(x, true)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("list1", types.NewListType(types.IntType)),
- decls.NewVariable("list2", types.NewListType(types.IntType)),
- },
- hints: map[string]uint64{"list1": 10, "list2": 10},
- wanted: CostEstimate{Min: 4, Max: 64},
- },
- {
- name: "str concat",
- expr: `"abcdefg".contains(str1 + str2)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("str1", types.StringType),
- decls.NewVariable("str2", types.StringType),
- },
- hints: map[string]uint64{"str1": 10, "str2": 10},
- wanted: CostEstimate{Min: 2, Max: 6},
- },
- {
- name: "str concat custom cost estimate",
- expr: `"abcdefg".contains(str1 + str2)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("str1", types.StringType),
- decls.NewVariable("str2", types.StringType),
- },
- hints: map[string]uint64{"str1": 10, "str2": 10},
- options: []CostOption{
- OverloadCostEstimate(overloads.ContainsString,
- func(estimator CostEstimator, target *AstNode, args []AstNode) *CallEstimate {
- if target != nil && len(args) == 1 {
- strSize := estimateSize(estimator, *target).MultiplyByCostFactor(0.2)
- subSize := estimateSize(estimator, args[0]).MultiplyByCostFactor(0.2)
- return &CallEstimate{CostEstimate: strSize.Multiply(subSize)}
- }
- return nil
- }),
- },
- wanted: CostEstimate{Min: 2, Max: 12},
- },
- {
- name: "list size comparison",
- expr: `list1.size() == list2.size()`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("list1", types.NewListType(types.IntType)),
- decls.NewVariable("list2", types.NewListType(types.IntType)),
- },
- wanted: CostEstimate{Min: 5, Max: 5},
- },
- {
- name: "list size from ternary",
- expr: `x > y ? list1.size() : list2.size()`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("x", types.IntType),
- decls.NewVariable("y", types.IntType),
- decls.NewVariable("list1", types.NewListType(types.IntType)),
- decls.NewVariable("list2", types.NewListType(types.IntType)),
- },
- wanted: CostEstimate{Min: 5, Max: 5},
- },
- {
- name: "list size from concat",
- expr: `([x, y] + list1 + list2).size()`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("x", types.IntType),
- decls.NewVariable("y", types.IntType),
- decls.NewVariable("list1", types.NewListType(types.IntType)),
- decls.NewVariable("list2", types.NewListType(types.IntType)),
- },
- hints: map[string]uint64{
- "list1": 10,
- "list2": 20,
- },
- wanted: CostEstimate{Min: 17, Max: 17},
- },
- {
- name: "list cost tracking through comprehension",
- expr: `[list1, list2].exists(l, l.exists(v, v.startsWith('hi')))`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("list1", types.NewListType(types.StringType)),
- decls.NewVariable("list2", types.NewListType(types.StringType)),
- },
- hints: map[string]uint64{
- "list1": 10,
- "list1.@items": 64,
- "list2": 20,
- "list2.@items": 128,
- },
- wanted: CostEstimate{Min: 21, Max: 265},
- },
- {
- name: "str endsWith equality",
- expr: `str1.endsWith("abcdefghijklmnopqrstuvwxyz") == str2.endsWith("abcdefghijklmnopqrstuvwxyz")`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("str1", types.StringType),
- decls.NewVariable("str2", types.StringType),
- },
- wanted: CostEstimate{Min: 9, Max: 9},
- },
- {
- name: "nested subexpression operators",
- expr: `((5 != 6) == (1 == 2)) == ((3 <= 4) == (9 != 9))`,
- wanted: CostEstimate{Min: 7, Max: 7},
- },
- {
- name: "str size estimate",
- expr: `string(timestamp1) == string(timestamp2)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("timestamp1", types.TimestampType),
- decls.NewVariable("timestamp2", types.TimestampType),
- },
- wanted: CostEstimate{Min: 5, Max: 1844674407370955268},
- },
- {
- name: "timestamp equality check",
- expr: `timestamp1 == timestamp2`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("timestamp1", types.TimestampType),
- decls.NewVariable("timestamp2", types.TimestampType),
- },
- wanted: CostEstimate{Min: 3, Max: 3},
- },
- {
- name: "duration inequality check",
- expr: `duration1 != duration2`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("duration1", types.DurationType),
- decls.NewVariable("duration2", types.DurationType),
- },
- wanted: CostEstimate{Min: 3, Max: 3},
- },
- {
- name: ".filter list literal",
- expr: `[1,2,3,4,5].filter(x, x % 2 == 0)`,
- wanted: CostEstimate{Min: 41, Max: 101},
- },
- {
- name: ".map list literal",
- expr: `[1,2,3,4,5].map(x, x)`,
- wanted: CostEstimate{Min: 86, Max: 86},
- },
- {
- name: ".map.filter list literal",
- expr: `[1,2,3,4,5].map(x, x).filter(x, x % 2 == 0)`,
- wanted: CostEstimate{Min: 117, Max: 177},
- },
- {
- name: ".map.exists list literal",
- expr: `[1,2,3,4,5].map(x, x).exists(x, x == 5) == true`,
- wanted: CostEstimate{Min: 108, Max: 118},
- },
- {
- name: ".map.map list literal",
- expr: `[1,2,3,4,5].map(x, x).map(x, x)`,
- wanted: CostEstimate{Min: 162, Max: 162},
- },
- {
- name: ".map list literal selection",
- expr: `[1,2,3,4,5].map(x, x)[4]`,
- wanted: CostEstimate{Min: 87, Max: 87},
- },
- {
- name: "nested array selection",
- expr: `[[1,2],[1,2],[1,2],[1,2],[1,2]][4]`,
- wanted: CostEstimate{Min: 61, Max: 61},
- },
- {
- name: "nested map selection",
- expr: `{'a': [1,2], 'b': [1,2], 'c': [1,2], 'd': [1,2], 'e': [1,2]}.b`,
- wanted: CostEstimate{Min: 81, Max: 81},
- },
- {
- name: "comprehension on nested list",
- expr: `[[1, 1], [2, 2], [3, 3], [4, 4], [5, 5]].all(y, y.all(y, y == 1))`,
- wanted: CostEstimate{Min: 76, Max: 136},
- },
- {
- name: "comprehension on transformed nested list",
- expr: `[1,2,3,4,5].map(x, [x, x]).all(y, y.all(y, y == 1))`,
- wanted: CostEstimate{Min: 157, Max: 217},
- },
- {
- name: "comprehension on nested literal list",
- expr: `["a", "ab", "abc", "abcd", "abcde"].map(x, [x, x]).all(y, y.all(y, y.startsWith('a')))`,
- wanted: CostEstimate{Min: 157, Max: 217},
- },
- {
- name: "comprehension on nested variable list",
- expr: `input.map(x, [x, x]).all(y, y.all(y, y.startsWith('a')))`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewListType(types.StringType))},
- hints: map[string]uint64{
- "input": 5,
- "input.@items": 10,
- },
- wanted: CostEstimate{Min: 13, Max: 208},
- },
- {
- name: "comprehension chaining with concat",
- expr: `[1,2,3,4,5].map(x, x).map(x, x) + [1]`,
- wanted: CostEstimate{Min: 173, Max: 173},
- },
- {
- name: "nested comprehension",
- expr: `[1,2,3].all(i, i in [1,2,3].map(j, j + j))`,
- wanted: CostEstimate{Min: 20, Max: 230},
- },
- {
- name: "nested dyn comprehension",
- expr: `dyn([1,2,3]).all(i, i in dyn([1,2,3]).map(j, j + j))`,
- wanted: CostEstimate{Min: 21, Max: 234},
- },
- {
- name: "literal map access",
- expr: `{'hello': 'hi'}['hello'] != {'hello': 'bye'}['hello']`,
- wanted: CostEstimate{Min: 63, Max: 63},
- },
- {
- name: "literal list access",
- expr: `['hello', 'hi'][0] != ['hello', 'bye'][1]`,
- wanted: CostEstimate{Min: 23, Max: 23},
- },
- {
- name: "type call",
- expr: `type(1)`,
- wanted: CostEstimate{Min: 1, Max: 1},
- },
- {
- name: "type call variable",
- expr: `type(self.val1)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("self", types.NewMapType(types.StringType, types.IntType)),
- },
- wanted: CostEstimate{Min: 3, Max: 3},
- },
- {
- name: "type call variable equality",
- expr: `type(self.val1) == int`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("self", types.NewMapType(types.StringType, types.IntType)),
- },
- wanted: CostEstimate{Min: 5, Max: 1844674407370955268},
- },
- {
- name: "type literal equality cost",
- expr: `type(1) == int`,
- wanted: CostEstimate{Min: 3, Max: 1844674407370955266},
- },
- {
- name: "type variable equality cost",
- expr: `type(1) == int`,
- wanted: CostEstimate{Min: 3, Max: 1844674407370955266},
- },
- {
- name: "namespace variable equality",
- expr: `self.val1 == 1.0`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("self.val1", types.DoubleType),
- },
- wanted: CostEstimate{Min: 2, Max: 2},
- },
- {
- name: "simple map variable equality",
- expr: `self.val1 == 1.0`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("self", types.NewMapType(types.StringType, types.DoubleType)),
- },
- wanted: CostEstimate{Min: 3, Max: 3},
- },
- {
- name: "date-time math",
- vars: []*decls.VariableDecl{
- decls.NewVariable("self", types.NewMapType(types.StringType, types.TimestampType)),
- },
- expr: `self.val1 == timestamp('2011-08-18T00:00:00.000+01:00') + duration('19h3m37s10ms')`,
- wanted: FixedCostEstimate(6),
- },
- {
- name: "date-time math self-conversion",
- vars: []*decls.VariableDecl{
- decls.NewVariable("self", types.NewMapType(types.StringType, types.TimestampType)),
- },
- expr: `timestamp(self.val1) == timestamp('2011-08-18T00:00:00.000+01:00') + duration('19h3m37s10ms')`,
- wanted: FixedCostEstimate(7),
- },
- {
- name: "boolean vars equal",
- vars: []*decls.VariableDecl{
- decls.NewVariable("self", types.NewMapType(types.StringType, types.BoolType)),
- },
- expr: `self.val1 != self.val2`,
- wanted: FixedCostEstimate(5),
- },
- {
- name: "boolean var equals literal",
- vars: []*decls.VariableDecl{
- decls.NewVariable("self", types.NewMapType(types.StringType, types.BoolType)),
- },
- expr: `self.val1 != true`,
- wanted: FixedCostEstimate(3),
- },
- {
- name: "double var equals literal",
- vars: []*decls.VariableDecl{
- decls.NewVariable("self", types.NewMapType(types.StringType, types.DoubleType)),
- },
- expr: `self.val1 == 1.0`,
- wanted: FixedCostEstimate(3),
- },
- {
- name: "bytes list max",
- expr: "[bytes('012345678901'), bytes('012345678901'), bytes('012345678901'), bytes('012345678901'), bytes('012345678901')].max()",
- options: []CostOption{
- OverloadCostEstimate("list_bytes_max",
- func(estimator CostEstimator, target *AstNode, args []AstNode) *CallEstimate {
- if target != nil {
- // Charge 1 cost for comparing each element in the list
- elCost := CostEstimate{Min: 1, Max: 1}
- // If the list contains strings or bytes, add the cost of traversing all the strings/bytes as a way
- // of estimating the additional comparison cost.
- if elNode := listElementNode(*target); elNode != nil {
- k := elNode.Type().Kind()
- if k == types.StringKind || k == types.BytesKind {
- sz := sizeEstimate(estimator, elNode)
- elCost = elCost.Add(sz.MultiplyByCostFactor(common.StringTraversalCostFactor))
- }
- return &CallEstimate{CostEstimate: sizeEstimate(estimator, *target).MultiplyByCost(elCost)}
- }
- }
- return nil
- }),
- },
- wanted: CostEstimate{Min: 25, Max: 35},
- },
+func TestCheckerCostForwarding(t *testing.T) {
+ p, err := parser.NewParser(parser.Macros(parser.AllMacros...))
+ if err != nil {
+ t.Fatalf("parser.NewParser() failed: %v", err)
+ }
+ src := common.NewStringSource("a + b", "")
+ pe, errs := p.Parse(src)
+ if len(errs.GetErrors()) != 0 {
+ t.Fatalf("parser.Parse() failed: %v", errs.ToDisplayString())
+ }
+ reg, err := types.NewRegistry()
+ if err != nil {
+ t.Fatalf("types.NewRegistry() failed: %v", err)
+ }
+ e, err := NewEnv(containers.DefaultContainer, reg)
+ if err != nil {
+ t.Fatalf("NewEnv() failed: %v", err)
+ }
+ err = e.AddFunctions(stdlib.Functions()...)
+ if err != nil {
+ t.Fatalf("AddFunctions() failed: %v", err)
+ }
+ err = e.AddIdents(
+ decls.NewVariable("a", types.IntType),
+ decls.NewVariable("b", types.IntType),
+ )
+ if err != nil {
+ t.Fatalf("AddIdents() failed: %v", err)
+ }
+ checked, errs := Check(pe, src, e)
+ if len(errs.GetErrors()) != 0 {
+ t.Fatalf("Check() failed: %v", errs.ToDisplayString())
}
- for _, tst := range cases {
- tc := tst
- t.Run(tc.name, func(t *testing.T) {
- if tc.hints == nil {
- tc.hints = map[string]uint64{}
- }
- p, err := parser.NewParser(parser.Macros(parser.AllMacros...))
- if err != nil {
- t.Fatalf("parser.NewParser() failed: %v", err)
- }
- src := common.NewStringSource(tc.expr, "")
- pe, errs := p.Parse(src)
- if len(errs.GetErrors()) != 0 {
- t.Fatalf("parser.Parse(%v) failed: %v", tc.expr, errs.ToDisplayString())
- }
- reg, err := types.NewRegistry(types.ProtoTypeDefs(&proto3pb.TestAllTypes{}))
- if err != nil {
- t.Fatalf("types.NewRegistry(...) failed: %v", err)
- }
-
- e, err := NewEnv(containers.DefaultContainer, reg)
- if err != nil {
- t.Fatalf("NewEnv() failed: %v", err)
- }
- err = e.AddFunctions(stdlib.Functions()...)
- if err != nil {
- t.Fatalf("environment creation error: %v", err)
- }
- maxFunc, _ := decls.NewFunction("max",
- decls.MemberOverload("list_bytes_max",
- []*types.Type{types.NewListType(types.BytesType)},
- types.BytesType))
- err = e.AddFunctions(maxFunc)
- if err != nil {
- t.Fatalf("environment creation error: %v", err)
- }
- err = e.AddIdents(tc.vars...)
- if err != nil {
- t.Fatalf("environment creation error: %s\n", err)
- }
- checked, errs := Check(pe, src, e)
- if len(errs.GetErrors()) != 0 {
- t.Fatalf("Check(%s) failed: %v", tc.expr, errs.ToDisplayString())
- }
- est, err := Cost(checked, testCostEstimator{hints: tc.hints}, tc.options...)
- if err != nil {
- t.Fatalf("Cost() failed: %v", err)
- }
- if est.Min != tc.wanted.Min || est.Max != tc.wanted.Max {
- t.Fatalf("Got cost interval [%v, %v], wanted [%v, %v]",
- est.Min, est.Max, tc.wanted.Min, tc.wanted.Max)
- }
- })
+ est, err := Cost(checked, dummyCostEstimator{}, PresenceTestHasCost(true))
+ if err != nil {
+ t.Fatalf("Cost() failed: %v", err)
+ }
+ if est.Min != 3 || est.Max != 3 {
+ t.Errorf("Cost() = [%d, %d], wanted [3, 3]", est.Min, est.Max)
}
-}
-type testCostEstimator struct {
- hints map[string]uint64
-}
+ fixedCost := FixedCostEstimate(5)
+ if fixedCost.Min != 5 || fixedCost.Max != 5 {
+ t.Errorf("FixedCostEstimate(5) = %v", fixedCost)
+ }
-func (tc testCostEstimator) EstimateSize(element AstNode) *SizeEstimate {
- if l, ok := tc.hints[strings.Join(element.Path(), ".")]; ok {
- return &SizeEstimate{Min: 0, Max: l}
+ unknownCost := UnknownCostEstimate()
+ if unknownCost.Min != 0 {
+ t.Errorf("UnknownCostEstimate() = %v", unknownCost)
}
- if element.Type() == types.BytesType {
- return &SizeEstimate{Min: 0, Max: 12}
+
+ fixedSize := FixedSizeEstimate(10)
+ if fixedSize.Min != 10 || fixedSize.Max != 10 {
+ t.Errorf("FixedSizeEstimate(10) = %v", fixedSize)
}
- return nil
-}
-func (tc testCostEstimator) EstimateCallCost(function, overloadID string, target *AstNode, args []AstNode) *CallEstimate {
- switch overloadID {
- case overloads.TimestampToYear:
- return &CallEstimate{CostEstimate: CostEstimate{Min: 7, Max: 7}}
+ unknownSize := UnknownSizeEstimate()
+ if unknownSize.Min != 0 {
+ t.Errorf("UnknownSizeEstimate() = %v", unknownSize)
}
- return nil
-}
-func estimateSize(estimator CostEstimator, node AstNode) SizeEstimate {
- if l := node.ComputedSize(); l != nil {
- return *l
+ node := NewAstNode(nil, []string{"foo"}, types.IntType, nil)
+ if len(node.Path()) != 1 || node.Path()[0] != "foo" {
+ t.Errorf("NewAstNode().Path() = %v", node.Path())
}
- if l := estimator.EstimateSize(node); l != nil {
- return *l
+
+ opt := OverloadCostEstimate("op", func(estimator cost.Estimator, target *cost.AstNode, args []cost.AstNode) *cost.CallEstimate {
+ return nil
+ })
+ if opt == nil {
+ t.Errorf("OverloadCostEstimate() = nil")
}
- return SizeEstimate{Min: 0, Max: math.MaxUint64}
}
-func listElementNode(list AstNode) AstNode {
- if params := list.Type().Parameters(); len(params) > 0 {
- lt := params[0]
- nodePath := list.Path()
- if nodePath != nil {
- // Provide path if we have it so that a OpenAPIv3 maxLength validation can be looked up, if it exists
- // for this node.
- path := make([]string, len(nodePath)+1)
- copy(path, nodePath)
- path[len(nodePath)] = "@items"
- return &astNode{path: path, t: lt, expr: nil}
- } else {
- // Provide just the type if no path is available so that worst case size can be looked up based on type.
- return &astNode{t: lt, expr: nil}
- }
- }
+type dummyCostEstimator struct{}
+
+func (d dummyCostEstimator) EstimateSize(element AstNode) *SizeEstimate {
return nil
}
-func sizeEstimate(estimator CostEstimator, t AstNode) SizeEstimate {
- if sz := t.ComputedSize(); sz != nil {
- return *sz
- }
- if sz := estimator.EstimateSize(t); sz != nil {
- return *sz
- }
- return SizeEstimate{Min: 0, Max: math.MaxUint64}
+func (d dummyCostEstimator) EstimateCallCost(function, overloadID string, target *AstNode, args []AstNode) *CallEstimate {
+ return nil
}
diff --git a/checker/options.go b/checker/options.go
index 10d3bcbc0..52f884ae1 100644
--- a/checker/options.go
+++ b/checker/options.go
@@ -49,4 +49,3 @@ func JSONFieldNames(enabled bool) Option {
return nil
}
}
-
diff --git a/common/cost.go b/common/cost.go
index 5e24bd0f4..125b60e06 100644
--- a/common/cost.go
+++ b/common/cost.go
@@ -16,25 +16,39 @@ package common
const (
// SelectAndIdentCost is the cost of an operation that accesses an identifier or performs a select.
+ //
+ // Deprecated: use cost.SelectAndIdentCost
SelectAndIdentCost = 1
// ConstCost is the cost of an operation that accesses a constant.
+ //
+ // Deprecated: use cost.ConstCost
ConstCost = 0
// ListCreateBaseCost is the base cost of any operation that creates a new list.
+ //
+ // Deprecated: use cost.ListCreateBaseCost
ListCreateBaseCost = 10
// MapCreateBaseCost is the base cost of any operation that creates a new map.
+ //
+ // Deprecated: use cost.MapCreateBaseCost
MapCreateBaseCost = 30
// StructCreateBaseCost is the base cost of any operation that creates a new struct.
+ //
+ // Deprecated: use cost.StructCreateBaseCost
StructCreateBaseCost = 40
// StringTraversalCostFactor is multiplied to a length of a string when computing the cost of traversing the entire
// string once.
+ //
+ // Deprecated: use cost.StringTraversalCostFactor
StringTraversalCostFactor = 0.1
- // RegexStringLengthCostFactor is multiplied ot the length of a regex string pattern when computing the cost of
+ // RegexStringLengthCostFactor is multiplied to the length of a regex string pattern when computing the cost of
// applying the regex to a string of unit cost.
+ //
+ // Deprecated: use cost.RegexStringLengthCostFactor
RegexStringLengthCostFactor = 0.25
)
diff --git a/common/cost/BUILD.bazel b/common/cost/BUILD.bazel
index b7d416b5f..5bb6fb913 100644
--- a/common/cost/BUILD.bazel
+++ b/common/cost/BUILD.bazel
@@ -9,8 +9,17 @@ go_library(
name = "go_default_library",
srcs = [
"cost.go",
+ "estimator.go",
+ "tracker.go",
],
importpath = "cel.dev/cel-go/common/cost",
+ deps = [
+ "//common/ast:go_default_library",
+ "//common/overloads:go_default_library",
+ "//common/types:go_default_library",
+ "//common/types/ref:go_default_library",
+ "//common/types/traits:go_default_library",
+ ],
)
go_test(
@@ -18,8 +27,23 @@ go_test(
size = "small",
srcs = [
"cost_test.go",
+ "estimator_test.go",
+ "tracker_test.go",
],
embed = [
":go_default_library",
],
+ deps = [
+ "//checker:go_default_library",
+ "//common:go_default_library",
+ "//common/containers:go_default_library",
+ "//common/decls:go_default_library",
+ "//common/overloads:go_default_library",
+ "//common/stdlib:go_default_library",
+ "//common/types:go_default_library",
+ "//common/types/ref:go_default_library",
+ "//interpreter:go_default_library",
+ "//parser:go_default_library",
+ "//test/proto3pb:go_default_library",
+ ],
)
diff --git a/common/cost/cost.go b/common/cost/cost.go
index 1a81a88e5..d2155f42b 100644
--- a/common/cost/cost.go
+++ b/common/cost/cost.go
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-// Package cost provides the saturating arithmetic shared by cost estimation and cost tracking.
+// Package cost provides cost estimation, cost tracking, and saturating arithmetic.
//
// Costs and sizes are unsigned 64-bit values where math.MaxUint64 doubles as the representation
// of an unbounded, or unknown, quantity. Every operation in this package saturates at
@@ -20,7 +20,53 @@
// sequence of operations.
package cost
-import "math"
+import (
+ "math"
+
+ "cel.dev/cel-go/common/ast"
+ "cel.dev/cel-go/common/types"
+ "cel.dev/cel-go/common/types/ref"
+ "cel.dev/cel-go/common/types/traits"
+)
+
+const (
+ // SelectAndIdentCost is the cost of an operation that accesses an identifier or performs a select.
+ SelectAndIdentCost = 1
+
+ // ConstCost is the cost of an operation that accesses a constant.
+ ConstCost = 0
+
+ // ListCreateBaseCost is the base cost of any operation that creates a new list.
+ ListCreateBaseCost = 10
+
+ // MapCreateBaseCost is the base cost of any operation that creates a new map.
+ MapCreateBaseCost = 30
+
+ // StructCreateBaseCost is the base cost of any operation that creates a new struct.
+ StructCreateBaseCost = 40
+
+ // StringTraversalCostFactor is multiplied to a length of a string when computing the cost of traversing the entire
+ // string once.
+ StringTraversalCostFactor = 0.1
+
+ // RegexStringLengthCostFactor is multiplied to the length of a regex string pattern when computing the cost of
+ // applying the regex to a string of unit cost.
+ RegexStringLengthCostFactor = 0.25
+)
+
+var (
+ // CallCostEstimate is the base cost estimate for an O(1) function call.
+ CallCostEstimate = FixedCostEstimate(1)
+
+ // CallCost is the base cost for an O(1) function call.
+ CallCost = uint64(1)
+
+ // ListAllocCost is the base cost estimate for allocating a list.
+ ListAllocCost = FixedCostEstimate(ListCreateBaseCost)
+
+ // StringCostFactor is the cost factor for traversing a string once.
+ StringCostFactor = StringTraversalCostFactor
+)
// maxUint64AsFloat is the smallest float64 value greater than math.MaxUint64.
//
@@ -76,3 +122,211 @@ func SafeCeil(x float64) uint64 {
}
return uint64(ceil)
}
+
+// SizeEstimate represents an estimated size of a variable length string, bytes, map or list.
+type SizeEstimate struct {
+ Min, Max uint64
+}
+
+// UnknownSizeEstimate returns a size between 0 and max uint.
+func UnknownSizeEstimate() SizeEstimate {
+ return unknownSizeEstimate
+}
+
+// FixedSizeEstimate returns a size estimate with a fixed min and max range.
+func FixedSizeEstimate(size uint64) SizeEstimate {
+ return SizeEstimate{Min: size, Max: size}
+}
+
+// RangedSizeEstimate returns a size estimate bounded by min and max.
+func RangedSizeEstimate(min, max uint64) SizeEstimate {
+ return SizeEstimate{Min: min, Max: max}
+}
+
+// AtLeastOne returns a size estimate with min and max guaranteed to be at least 1.
+func AtLeastOne(size SizeEstimate) SizeEstimate {
+ if size.Min == 0 {
+ size.Min = 1
+ }
+ if size.Max == 0 {
+ size.Max = 1
+ }
+ return size
+}
+
+// Add adds to another SizeEstimate and returns the sum.
+// If add would result in an uint64 overflow, the result is math.MaxUint64.
+func (se SizeEstimate) Add(sizeEstimate SizeEstimate) SizeEstimate {
+ return SizeEstimate{
+ SafeAdd(se.Min, sizeEstimate.Min),
+ SafeAdd(se.Max, sizeEstimate.Max),
+ }
+}
+
+// Multiply multiplies by another SizeEstimate and returns the product.
+// If multiply would result in an uint64 overflow, the result is math.MaxUint64.
+func (se SizeEstimate) Multiply(sizeEstimate SizeEstimate) SizeEstimate {
+ return SizeEstimate{
+ SafeMultiply(se.Min, sizeEstimate.Min),
+ SafeMultiply(se.Max, sizeEstimate.Max),
+ }
+}
+
+// MultiplyByCostFactor multiplies a SizeEstimate by a cost factor and returns the CostEstimate with the
+// nearest integer of the result, rounded up.
+func (se SizeEstimate) MultiplyByCostFactor(costPerUnit float64) CostEstimate {
+ return CostEstimate{
+ SafeMultiplyByFactor(se.Min, costPerUnit),
+ SafeMultiplyByFactor(se.Max, costPerUnit),
+ }
+}
+
+// MultiplyByCost multiplies by the cost and returns the product.
+// If multiply would result in an uint64 overflow, the result is math.MaxUint64.
+func (se SizeEstimate) MultiplyByCost(estimate CostEstimate) CostEstimate {
+ return CostEstimate{
+ SafeMultiply(se.Min, estimate.Min),
+ SafeMultiply(se.Max, estimate.Max),
+ }
+}
+
+// Union returns a SizeEstimate that encompasses both input SizeEstimate values.
+func (se SizeEstimate) Union(size SizeEstimate) SizeEstimate {
+ result := se
+ if size.Min < result.Min {
+ result.Min = size.Min
+ }
+ if size.Max > result.Max {
+ result.Max = size.Max
+ }
+ return result
+}
+
+// AsCost converts a size estimate to an equivalent cost estimate.
+func (se SizeEstimate) AsCost() CostEstimate {
+ return se.MultiplyByCostFactor(1)
+}
+
+// CostEstimate represents an estimated cost range and provides add and multiply operations
+// that do not overflow.
+type CostEstimate struct {
+ Min, Max uint64
+}
+
+// UnknownCostEstimate returns a cost with an unknown impact.
+func UnknownCostEstimate() CostEstimate {
+ return unknownCostEstimate
+}
+
+// FixedCostEstimate returns a cost with a fixed min and max range.
+func FixedCostEstimate(fixedCost uint64) CostEstimate {
+ return CostEstimate{Min: fixedCost, Max: fixedCost}
+}
+
+// Add adds the costs and returns the sum.
+// If add would result in an uint64 overflow for the min or max, the value is set to math.MaxUint64.
+func (ce CostEstimate) Add(estimate CostEstimate) CostEstimate {
+ return CostEstimate{
+ Min: SafeAdd(ce.Min, estimate.Min),
+ Max: SafeAdd(ce.Max, estimate.Max),
+ }
+}
+
+// Multiply multiplies by the cost and returns the product.
+// If multiply would result in an uint64 overflow, the result is math.MaxUint64.
+func (ce CostEstimate) Multiply(estimate CostEstimate) CostEstimate {
+ return CostEstimate{
+ Min: SafeMultiply(ce.Min, estimate.Min),
+ Max: SafeMultiply(ce.Max, estimate.Max),
+ }
+}
+
+// MultiplyByCostFactor multiplies a CostEstimate by a cost factor and returns the CostEstimate with the
+// nearest integer of the result, rounded up.
+func (ce CostEstimate) MultiplyByCostFactor(costPerUnit float64) CostEstimate {
+ return CostEstimate{
+ Min: SafeMultiplyByFactor(ce.Min, costPerUnit),
+ Max: SafeMultiplyByFactor(ce.Max, costPerUnit),
+ }
+}
+
+// Union returns a CostEstimate that encompasses both input CostEstimates.
+func (ce CostEstimate) Union(size CostEstimate) CostEstimate {
+ result := ce
+ if size.Min < result.Min {
+ result.Min = size.Min
+ }
+ if size.Max > result.Max {
+ result.Max = size.Max
+ }
+ return result
+}
+
+// CallEstimate includes a CostEstimate for the call, and an optional estimate of the result object size.
+// The ResultSize should only be provided if the call results in a map, list, string or bytes.
+type CallEstimate struct {
+ CostEstimate
+
+ ResultSize *SizeEstimate
+}
+
+// NewCallEstimate creates a new CallEstimate with the given cost and optional result size.
+func NewCallEstimate(cost CostEstimate, sz *SizeEstimate) *CallEstimate {
+ return &CallEstimate{CostEstimate: cost, ResultSize: sz}
+}
+
+// ActualSize returns the size of the value for all traits.Sizer values,
+// and returns a size of 1 for all other value types.
+func ActualSize(value ref.Val) uint64 {
+ if sz, ok := value.(traits.Sizer); ok {
+ return uint64(sz.Size().(types.Int))
+ }
+ return 1
+}
+
+// EstimateSize returns a SizeEstimate for the given node from its computed size, estimator, or unknown.
+func EstimateSize(estimator Estimator, node AstNode) SizeEstimate {
+ if l := node.ComputedSize(); l != nil {
+ return *l
+ }
+ if l := estimator.EstimateSize(node); l != nil {
+ return *l
+ }
+ return SizeEstimate{Min: 0, Max: math.MaxUint64}
+}
+
+// EstimateTraversal computes cost as a function of the size of the target object and whether the call allocates memory.
+func EstimateTraversal(nodeSize SizeEstimate, costFactor float64, allocationCost *CostEstimate) (CostEstimate, *SizeEstimate) {
+ cost := nodeSize.MultiplyByCostFactor(costFactor)
+ if allocationCost != nil {
+ cost = cost.Add(*allocationCost)
+ }
+ return cost, &nodeSize
+}
+
+// EstimateStringScan estimates cost for scanning a string.
+func EstimateStringScan(sz SizeEstimate) (CostEstimate, *SizeEstimate) {
+ return EstimateTraversal(sz, StringCostFactor, nil)
+}
+
+// EstimateListAlloc estimates cost for allocating a list.
+func EstimateListAlloc(sz SizeEstimate, costFactor float64) (CostEstimate, *SizeEstimate) {
+ return EstimateTraversal(sz, costFactor, &ListAllocCost)
+}
+
+// NodeAsUintValue returns the value of a literal int node as a uint64, or the default value if the
+// node is not a non-negative int literal.
+func NodeAsUintValue(node AstNode, defaultVal uint64) uint64 {
+ if node.Expr().Kind() != ast.LiteralKind {
+ return defaultVal
+ }
+ lit := node.Expr().AsLiteral()
+ if lit.Type() != types.IntType {
+ return defaultVal
+ }
+ val := lit.(types.Int)
+ if val < types.IntZero {
+ return 0
+ }
+ return uint64(lit.(types.Int))
+}
diff --git a/common/cost/cost_test.go b/common/cost/cost_test.go
index 83a3fd5f8..5e18dad67 100644
--- a/common/cost/cost_test.go
+++ b/common/cost/cost_test.go
@@ -116,3 +116,79 @@ func TestSafeCeil(t *testing.T) {
})
}
}
+
+func TestSizeEstimate(t *testing.T) {
+ s1 := FixedSizeEstimate(5)
+ s2 := FixedSizeEstimate(10)
+ if got := s1.Add(s2); got.Min != 15 || got.Max != 15 {
+ t.Errorf("s1.Add(s2) = %v, want {15, 15}", got)
+ }
+ if got := s1.Multiply(s2); got.Min != 50 || got.Max != 50 {
+ t.Errorf("s1.Multiply(s2) = %v, want {50, 50}", got)
+ }
+ if got := s1.Union(s2); got.Min != 5 || got.Max != 10 {
+ t.Errorf("s1.Union(s2) = %v, want {5, 10}", got)
+ }
+ if got := s1.MultiplyByCostFactor(0.5); got.Min != 3 || got.Max != 3 {
+ t.Errorf("s1.MultiplyByCostFactor(0.5) = %v, want {3, 3}", got)
+ }
+ if got := s1.MultiplyByCost(FixedCostEstimate(4)); got.Min != 20 || got.Max != 20 {
+ t.Errorf("s1.MultiplyByCost(4) = %v, want {20, 20}", got)
+ }
+ if got := s1.AsCost(); got.Min != 5 || got.Max != 5 {
+ t.Errorf("s1.AsCost() = %v, want {5, 5}", got)
+ }
+ if got := UnknownSizeEstimate(); got.Min != 0 || got.Max != math.MaxUint64 {
+ t.Errorf("UnknownSizeEstimate() = %v, want {0, MaxUint64}", got)
+ }
+ if got := RangedSizeEstimate(3, 8); got.Min != 3 || got.Max != 8 {
+ t.Errorf("RangedSizeEstimate(3, 8) = %v, want {3, 8}", got)
+ }
+ if got := AtLeastOne(FixedSizeEstimate(0)); got.Min != 1 || got.Max != 1 {
+ t.Errorf("AtLeastOne(0) = %v, want {1, 1}", got)
+ }
+}
+
+func TestCostEstimate(t *testing.T) {
+ c1 := FixedCostEstimate(5)
+ c2 := FixedCostEstimate(10)
+ if got := c1.Add(c2); got.Min != 15 || got.Max != 15 {
+ t.Errorf("c1.Add(c2) = %v, want {15, 15}", got)
+ }
+ if got := c1.Multiply(c2); got.Min != 50 || got.Max != 50 {
+ t.Errorf("c1.Multiply(c2) = %v, want {50, 50}", got)
+ }
+ if got := c1.Union(c2); got.Min != 5 || got.Max != 10 {
+ t.Errorf("c1.Union(c2) = %v, want {5, 10}", got)
+ }
+ if got := c1.MultiplyByCostFactor(0.5); got.Min != 3 || got.Max != 3 {
+ t.Errorf("c1.MultiplyByCostFactor(0.5) = %v, want {3, 3}", got)
+ }
+ if got := UnknownCostEstimate(); got.Min != 0 || got.Max != math.MaxUint64 {
+ t.Errorf("UnknownCostEstimate() = %v, want {0, MaxUint64}", got)
+ }
+}
+
+func TestExtCostHelpers(t *testing.T) {
+ sz := FixedSizeEstimate(10)
+ costEst, resSz := EstimateStringScan(sz)
+ if costEst.Min != 1 || costEst.Max != 1 {
+ t.Errorf("EstimateStringScan cost = %v, want {1, 1}", costEst)
+ }
+ if resSz == nil || resSz.Min != 10 || resSz.Max != 10 {
+ t.Errorf("EstimateStringScan resSz = %v, want {10, 10}", resSz)
+ }
+
+ allocCost, allocSz := EstimateListAlloc(sz, 0.5)
+ if allocCost.Min != 15 || allocCost.Max != 15 {
+ t.Errorf("EstimateListAlloc cost = %v, want {15, 15}", allocCost)
+ }
+ if allocSz == nil || allocSz.Min != 10 || allocSz.Max != 10 {
+ t.Errorf("EstimateListAlloc allocSz = %v, want {10, 10}", allocSz)
+ }
+
+ callEst := NewCallEstimate(costEst, resSz)
+ if callEst.CostEstimate != costEst || callEst.ResultSize != resSz {
+ t.Errorf("NewCallEstimate = %v, want CostEstimate=%v ResultSize=%v", callEst, costEst, resSz)
+ }
+}
diff --git a/common/cost/estimator.go b/common/cost/estimator.go
new file mode 100644
index 000000000..ce6336387
--- /dev/null
+++ b/common/cost/estimator.go
@@ -0,0 +1,902 @@
+// Copyright 2022 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 cost
+
+import (
+ "math"
+
+ "cel.dev/cel-go/common/ast"
+ "cel.dev/cel-go/common/overloads"
+ "cel.dev/cel-go/common/types"
+)
+
+// WARNING: Any changes to cost calculations in this file require a corresponding change in interpreter/runtimecost.go
+
+// Estimator estimates the sizes of variable length input data and the costs of functions.
+type Estimator interface {
+ // EstimateSize returns a SizeEstimate for the given AstNode, or nil if the estimator has no
+ // estimate to provide.
+ //
+ // The size is equivalent to the result of the CEL `size()` function:
+ // * Number of unicode characters in a string
+ // * Number of bytes in a sequence
+ // * Number of map entries or number of list items.
+ //
+ // EstimateSize is only called for AstNodes where CEL does not know the size; EstimateSize is not
+ // called for values defined inline in CEL where the size is already obvious to CEL.
+ EstimateSize(element AstNode) *SizeEstimate
+
+ // EstimateCallCost returns the estimated cost of an invocation, or nil if the estimator has no
+ // estimate to provide.
+ EstimateCallCost(function, overloadID string, target *AstNode, args []AstNode) *CallEstimate
+}
+
+// AstNode represents an AST node for the purpose of cost estimations.
+type AstNode interface {
+ // Path returns a field path through the provided type declarations to the type of the AstNode, or nil if the AstNode does not
+ // represent type directly reachable from the provided type declarations.
+ // The first path element is a variable. All subsequent path elements are one of: field name, '@items', '@keys', '@values'.
+ Path() []string
+
+ // Type returns the deduced type of the AstNode.
+ Type() *types.Type
+
+ // Expr returns the expression of the AstNode.
+ Expr() ast.Expr
+
+ // ComputedSize returns a size estimate of the AstNode derived from information available in the CEL expression.
+ // For constants and inline list and map declarations, the exact size is returned. For concatenated list, strings
+ // and bytes, the size is derived from the size estimates of the operands. nil is returned if there is no
+ // computed size available.
+ ComputedSize() *SizeEstimate
+}
+
+type astNode struct {
+ path []string
+ t *types.Type
+ expr ast.Expr
+ derivedSize *SizeEstimate
+}
+
+func (e astNode) Path() []string {
+ return e.path
+}
+
+func (e astNode) Type() *types.Type {
+ return e.t
+}
+
+func (e astNode) Expr() ast.Expr {
+ return e.expr
+}
+
+func (e astNode) ComputedSize() *SizeEstimate {
+ return e.derivedSize
+}
+
+// NewAstNode creates a new AstNode for cost estimation.
+func NewAstNode(expr ast.Expr, path []string, t *types.Type, derivedSize *SizeEstimate) AstNode {
+ return &astNode{
+ path: path,
+ t: t,
+ expr: expr,
+ derivedSize: derivedSize,
+ }
+}
+
+// CostOption configures flags which affect cost computations.
+type CostOption func(*coster) error
+
+// PresenceTestHasCost determines whether presence testing has a cost of one or zero.
+//
+// Defaults to presence test has a cost of one.
+func PresenceTestHasCost(hasCost bool) CostOption {
+ return func(c *coster) error {
+ if hasCost {
+ c.presenceTestCost = selectAndIdentCost
+ return nil
+ }
+ c.presenceTestCost = FixedCostEstimate(0)
+ return nil
+ }
+}
+
+// FunctionEstimator provides a CallEstimate given the target and arguments for a specific function, overload pair.
+type FunctionEstimator func(estimator Estimator, target *AstNode, args []AstNode) *CallEstimate
+
+// OverloadCostEstimate binds a FunctionEstimator to a specific function overload ID.
+//
+// When a OverloadCostEstimate is provided, it will override the cost calculation of the CostEstimator provided to
+// the Cost() call.
+func OverloadCostEstimate(overloadID string, functionCoster FunctionEstimator) CostOption {
+ return func(c *coster) error {
+ c.overloadEstimators[overloadID] = functionCoster
+ return nil
+ }
+}
+
+// Cost estimates the cost of the parsed and type checked CEL expression.
+func Cost(checked *ast.AST, estimator Estimator, opts ...CostOption) (CostEstimate, error) {
+ c := &coster{
+ checkedAST: checked,
+ estimator: estimator,
+ overloadEstimators: map[string]FunctionEstimator{},
+ exprPaths: map[int64][]string{},
+ localVars: make(scopes),
+ computedSizes: map[int64]SizeEstimate{},
+ computedEntrySizes: map[int64]entrySizeEstimate{},
+ presenceTestCost: FixedCostEstimate(1),
+ }
+ for _, opt := range opts {
+ err := opt(c)
+ if err != nil {
+ return CostEstimate{}, err
+ }
+ }
+ return c.cost(checked.Expr()), nil
+}
+
+type coster struct {
+ // exprPaths maps from Expr Id to field path.
+ exprPaths map[int64][]string
+ // localVars tracks the local and iteration variables assigned during evaluation.
+ localVars scopes
+ // computedSizes tracks the computed sizes of call results.
+ computedSizes map[int64]SizeEstimate
+ // computedEntrySizes tracks the size of list and map entries
+ computedEntrySizes map[int64]entrySizeEstimate
+
+ checkedAST *ast.AST
+ estimator Estimator
+ overloadEstimators map[string]FunctionEstimator
+ // presenceTestCost will either be a zero or one based on whether has() macros count against cost computations.
+ presenceTestCost CostEstimate
+}
+
+// entrySizeEstimate captures the container kind and associated key/index and value SizeEstimate values.
+//
+// An entrySizeEstimate only exists if both the key/index and the value have SizeEstimate values, otherwise
+// a nil entrySizeEstimate should be used.
+type entrySizeEstimate struct {
+ containerKind types.Kind
+ key SizeEstimate
+ val SizeEstimate
+}
+
+// container returns the container kind (list or map) of the entry.
+func (s *entrySizeEstimate) container() types.Kind {
+ if s == nil {
+ return types.UnknownKind
+ }
+ return s.containerKind
+}
+
+// keySize returns the SizeEstimate for the key if one exists.
+func (s *entrySizeEstimate) keySize() *SizeEstimate {
+ if s == nil {
+ return nil
+ }
+ return &s.key
+}
+
+// valSize returns the SizeEstimate for the value if one exists.
+func (s *entrySizeEstimate) valSize() *SizeEstimate {
+ if s == nil {
+ return nil
+ }
+ return &s.val
+}
+
+func (s *entrySizeEstimate) union(other *entrySizeEstimate) *entrySizeEstimate {
+ if s == nil || other == nil {
+ return nil
+ }
+ sk := s.key.Union(other.key)
+ sv := s.val.Union(other.val)
+ return &entrySizeEstimate{
+ containerKind: s.containerKind,
+ key: sk,
+ val: sv,
+ }
+}
+
+// localVar captures the local variable size and entrySize estimates if they exist for variables
+type localVar struct {
+ exprID int64
+ path []string
+ size *SizeEstimate
+ entrySize *entrySizeEstimate
+}
+
+// scopes is a stack of variable name to integer id stack to handle scopes created by cel.bind() like macros
+type scopes map[string][]*localVar
+
+func (s scopes) push(varName string, expr ast.Expr, path []string, size *SizeEstimate, entrySize *entrySizeEstimate) {
+ s[varName] = append(s[varName], &localVar{
+ exprID: expr.ID(),
+ path: path,
+ size: size,
+ entrySize: entrySize,
+ })
+}
+
+func (s scopes) pop(varName string) {
+ varStack := s[varName]
+ s[varName] = varStack[:len(varStack)-1]
+}
+
+func (s scopes) peek(varName string) (*localVar, bool) {
+ varStack := s[varName]
+ if len(varStack) > 0 {
+ return varStack[len(varStack)-1], true
+ }
+ return nil, false
+}
+
+func (c *coster) pushIterKey(varName string, rangeExpr ast.Expr) {
+ entrySize := c.computeEntrySize(rangeExpr)
+ size := entrySize.keySize()
+ path := c.getPath(rangeExpr)
+ container := entrySize.container()
+ if container == types.UnknownKind {
+ container = c.getType(rangeExpr).Kind()
+ }
+ subpath := "@keys"
+ if container == types.ListKind {
+ subpath = "@indices"
+ }
+ c.localVars.push(varName, rangeExpr, append(path, subpath), size, nil)
+}
+
+func (c *coster) pushIterValue(varName string, rangeExpr ast.Expr) {
+ entrySize := c.computeEntrySize(rangeExpr)
+ size := entrySize.valSize()
+ path := c.getPath(rangeExpr)
+ container := entrySize.container()
+ if container == types.UnknownKind {
+ container = c.getType(rangeExpr).Kind()
+ }
+ subpath := "@values"
+ if container == types.ListKind {
+ subpath = "@items"
+ }
+ c.localVars.push(varName, rangeExpr, append(path, subpath), size, nil)
+}
+
+func (c *coster) pushIterSingle(varName string, rangeExpr ast.Expr) {
+ entrySize := c.computeEntrySize(rangeExpr)
+ size := entrySize.keySize()
+ subpath := "@keys"
+ container := entrySize.container()
+ if container == types.UnknownKind {
+ container = c.getType(rangeExpr).Kind()
+ }
+ if container == types.ListKind {
+ size = entrySize.valSize()
+ subpath = "@items"
+ }
+ path := c.getPath(rangeExpr)
+ c.localVars.push(varName, rangeExpr, append(path, subpath), size, nil)
+}
+
+func (c *coster) pushLocalVar(varName string, e ast.Expr) {
+ path := c.getPath(e)
+ // note: retrieve the entry size for the local variable based on the size of the binding expression
+ // since the binding expression could be a list or map, the entry size should also be propagated
+ entrySize := c.computeEntrySize(e)
+ c.localVars.push(varName, e, path, c.computeSize(e), entrySize)
+}
+
+func (c *coster) peekLocalVar(varName string) (*localVar, bool) {
+ return c.localVars.peek(varName)
+}
+
+func (c *coster) popLocalVar(varName string) {
+ c.localVars.pop(varName)
+}
+
+func (c *coster) cost(e ast.Expr) CostEstimate {
+ if e == nil {
+ return CostEstimate{}
+ }
+ var estimate CostEstimate
+ switch e.Kind() {
+ case ast.LiteralKind:
+ estimate = constCost
+ case ast.IdentKind:
+ estimate = c.costIdent(e)
+ case ast.SelectKind:
+ estimate = c.costSelect(e)
+ case ast.CallKind:
+ estimate = c.costCall(e)
+ case ast.ListKind:
+ estimate = c.costCreateList(e)
+ case ast.MapKind:
+ estimate = c.costCreateMap(e)
+ case ast.StructKind:
+ estimate = c.costCreateStruct(e)
+ case ast.ComprehensionKind:
+ if c.isBind(e) {
+ estimate = c.costBind(e)
+ } else {
+ estimate = c.costComprehension(e)
+ }
+ default:
+ return CostEstimate{}
+ }
+ return estimate
+}
+
+func (c *coster) costIdent(e ast.Expr) CostEstimate {
+ identName := e.AsIdent()
+ // build and track the field path
+ if v, ok := c.peekLocalVar(identName); ok {
+ c.addPath(e, v.path)
+ } else {
+ c.addPath(e, []string{identName})
+ }
+ return selectAndIdentCost
+}
+
+func (c *coster) costSelect(e ast.Expr) CostEstimate {
+ sel := e.AsSelect()
+ var sum CostEstimate
+ if sel.IsTestOnly() {
+ // recurse, but do not add any cost
+ // this is equivalent to how evalTestOnly increments the runtime cost counter
+ // but does not add any additional cost for the qualifier, except here we do
+ // the reverse (ident adds cost)
+ sum = sum.Add(c.presenceTestCost)
+ sum = sum.Add(c.cost(sel.Operand()))
+ return sum
+ }
+ sum = sum.Add(c.cost(sel.Operand()))
+ targetType := c.getType(sel.Operand())
+ switch targetType.Kind() {
+ case types.MapKind, types.StructKind, types.TypeParamKind:
+ sum = sum.Add(selectAndIdentCost)
+ }
+
+ // build and track the field path
+ c.addPath(e, append(c.getPath(sel.Operand()), sel.FieldName()))
+ return sum
+}
+
+func (c *coster) costCall(e ast.Expr) CostEstimate {
+ // Dyn is just a way to disable type-checking, so return the cost of 1 with the cost of the argument
+ if dynEstimate := c.maybeUnwrapDynCall(e); dynEstimate != nil {
+ return *dynEstimate
+ }
+
+ // Continue estimating the cost of all other calls.
+ call := e.AsCall()
+ args := call.Args()
+ var sum CostEstimate
+
+ argTypes := make([]AstNode, len(args))
+ argCosts := make([]CostEstimate, len(args))
+ for i, arg := range args {
+ argCosts[i] = c.cost(arg)
+ argTypes[i] = c.newAstNode(arg)
+ }
+
+ overloadIDs := c.checkedAST.GetOverloadIDs(e.ID())
+ if len(overloadIDs) == 0 {
+ return CostEstimate{}
+ }
+ var targetType *AstNode
+ if call.IsMemberFunction() {
+ sum = sum.Add(c.cost(call.Target()))
+ var t AstNode = c.newAstNode(call.Target())
+ targetType = &t
+ }
+ // Pick a cost estimate range that covers all the overload cost estimation ranges
+ fnCost := CostEstimate{Min: uint64(math.MaxUint64), Max: 0}
+ var resultSize *SizeEstimate
+ for _, overload := range overloadIDs {
+ overloadCost := c.functionCost(e, call.FunctionName(), overload, targetType, argTypes, argCosts)
+ fnCost = fnCost.Union(overloadCost.CostEstimate)
+ if overloadCost.ResultSize != nil {
+ if resultSize == nil {
+ resultSize = overloadCost.ResultSize
+ } else {
+ size := resultSize.Union(*overloadCost.ResultSize)
+ resultSize = &size
+ }
+ }
+ // build and track the field path for index operations
+ switch overload {
+ case overloads.IndexList:
+ if len(args) > 0 {
+ // note: assigning resultSize here could be redundant with the path-based lookup later
+ resultSize = c.computeEntrySize(args[0]).valSize()
+ c.addPath(e, append(c.getPath(args[0]), "@items"))
+ }
+ case overloads.IndexMap:
+ if len(args) > 0 {
+ resultSize = c.computeEntrySize(args[0]).valSize()
+ c.addPath(e, append(c.getPath(args[0]), "@values"))
+ }
+ }
+ if resultSize == nil {
+ resultSize = c.computeSize(e)
+ }
+ }
+ c.setSize(e, resultSize)
+ return sum.Add(fnCost)
+}
+
+func (c *coster) maybeUnwrapDynCall(e ast.Expr) *CostEstimate {
+ call := e.AsCall()
+ if call.FunctionName() != "dyn" {
+ return nil
+ }
+ arg := call.Args()[0]
+ argCost := c.cost(arg)
+ c.copySizeEstimates(e, arg)
+ callCost := FixedCostEstimate(1).Add(argCost)
+ return &callCost
+}
+
+func (c *coster) costCreateList(e ast.Expr) CostEstimate {
+ create := e.AsList()
+ var sum CostEstimate
+ itemSize := SizeEstimate{Min: math.MaxUint64, Max: 0}
+ if create.Size() == 0 {
+ itemSize.Min = 0
+ }
+ for _, e := range create.Elements() {
+ sum = sum.Add(c.cost(e))
+ is := c.sizeOrUnknown(e)
+ itemSize = itemSize.Union(is)
+ }
+ c.setEntrySize(e, &entrySizeEstimate{containerKind: types.ListKind, key: FixedSizeEstimate(1), val: itemSize})
+ return sum.Add(createListBaseCost)
+}
+
+func (c *coster) costCreateMap(e ast.Expr) CostEstimate {
+ mapVal := e.AsMap()
+ var sum CostEstimate
+ keySize := SizeEstimate{Min: math.MaxUint64, Max: 0}
+ valSize := SizeEstimate{Min: math.MaxUint64, Max: 0}
+ if mapVal.Size() == 0 {
+ valSize.Min = 0
+ keySize.Min = 0
+ }
+ for _, ent := range mapVal.Entries() {
+ entry := ent.AsMapEntry()
+ sum = sum.Add(c.cost(entry.Key()))
+ sum = sum.Add(c.cost(entry.Value()))
+ // Compute the key size range
+ ks := c.sizeOrUnknown(entry.Key())
+ keySize = keySize.Union(ks)
+ // Compute the value size range
+ vs := c.sizeOrUnknown(entry.Value())
+ valSize = valSize.Union(vs)
+ }
+ c.setEntrySize(e, &entrySizeEstimate{containerKind: types.MapKind, key: keySize, val: valSize})
+ return sum.Add(createMapBaseCost)
+}
+
+func (c *coster) costCreateStruct(e ast.Expr) CostEstimate {
+ msgVal := e.AsStruct()
+ var sum CostEstimate
+ for _, ent := range msgVal.Fields() {
+ field := ent.AsStructField()
+ sum = sum.Add(c.cost(field.Value()))
+ }
+ return sum.Add(createMessageBaseCost)
+}
+
+func (c *coster) costComprehension(e ast.Expr) CostEstimate {
+ comp := e.AsComprehension()
+ var sum CostEstimate
+ sum = sum.Add(c.cost(comp.IterRange()))
+ sum = sum.Add(c.cost(comp.AccuInit()))
+ c.pushLocalVar(comp.AccuVar(), comp.AccuInit())
+
+ // Track the iterRange of each IterVar and AccuVar for field path construction
+ if comp.HasIterVar2() {
+ c.pushIterKey(comp.IterVar(), comp.IterRange())
+ c.pushIterValue(comp.IterVar2(), comp.IterRange())
+ } else {
+ c.pushIterSingle(comp.IterVar(), comp.IterRange())
+ }
+
+ // Determine the cost for each element in the loop
+ loopCost := c.cost(comp.LoopCondition())
+ stepCost := c.cost(comp.LoopStep())
+
+ // Clear the intermediate variable tracking.
+ c.popLocalVar(comp.IterVar())
+ if comp.HasIterVar2() {
+ c.popLocalVar(comp.IterVar2())
+ }
+
+ // Determine the result cost.
+ sum = sum.Add(c.cost(comp.Result()))
+ c.localVars.pop(comp.AccuVar())
+
+ // Estimate the cost of the loop.
+ rangeCnt := c.sizeOrUnknown(comp.IterRange())
+ rangeCost := rangeCnt.MultiplyByCost(stepCost.Add(loopCost))
+ sum = sum.Add(rangeCost)
+
+ switch k := comp.AccuInit().Kind(); k {
+ case ast.LiteralKind:
+ c.setSize(e, c.computeSize(comp.AccuInit()))
+ case ast.ListKind, ast.MapKind:
+ c.setSize(e, &rangeCnt)
+ // For a step which produces a container value, it will have an entry size associated
+ // with its expression id.
+ if stepEntrySize := c.computeEntrySize(comp.LoopStep()); stepEntrySize != nil {
+ c.setEntrySize(e, stepEntrySize)
+ break
+ }
+ }
+ return sum
+}
+
+func (c *coster) isBind(e ast.Expr) bool {
+ comp := e.AsComprehension()
+ iterRange := comp.IterRange()
+ loopCond := comp.LoopCondition()
+ return iterRange.Kind() == ast.ListKind && iterRange.AsList().Size() == 0 &&
+ loopCond.Kind() == ast.LiteralKind && loopCond.AsLiteral() == types.False &&
+ !isAccumulatorVar(comp.AccuVar())
+}
+
+func (c *coster) costBind(e ast.Expr) CostEstimate {
+ comp := e.AsComprehension()
+ var sum CostEstimate
+ // Binds are lazily initialized, so we retain the cost of an empty iteration range.
+ sum = sum.Add(c.cost(comp.IterRange()))
+ sum = sum.Add(c.cost(comp.AccuInit()))
+
+ c.pushLocalVar(comp.AccuVar(), comp.AccuInit())
+ sum = sum.Add(c.cost(comp.Result()))
+ c.popLocalVar(comp.AccuVar())
+
+ // Associate the bind output size with the result size.
+ c.copySizeEstimates(e, comp.Result())
+ return sum
+}
+
+func (c *coster) functionCost(e ast.Expr, function, overloadID string, target *AstNode, args []AstNode, argCosts []CostEstimate) CallEstimate {
+ argCostSum := func() CostEstimate {
+ var sum CostEstimate
+ for _, a := range argCosts {
+ sum = sum.Add(a)
+ }
+ return sum
+ }
+ if len(c.overloadEstimators) != 0 {
+ if estimator, found := c.overloadEstimators[overloadID]; found {
+ if est := estimator(c.estimator, target, args); est != nil {
+ callEst := *est
+ return CallEstimate{CostEstimate: callEst.Add(argCostSum()), ResultSize: est.ResultSize}
+ }
+ }
+ }
+ if est := c.estimator.EstimateCallCost(function, overloadID, target, args); est != nil {
+ callEst := *est
+ return CallEstimate{CostEstimate: callEst.Add(argCostSum()), ResultSize: est.ResultSize}
+ }
+ switch overloadID {
+ // O(n) functions
+ case overloads.ExtFormatString:
+ if target != nil {
+ // ResultSize not calculated because we can't bound the max size.
+ return CallEstimate{
+ CostEstimate: c.sizeOrUnknown(*target).MultiplyByCostFactor(StringTraversalCostFactor).Add(argCostSum())}
+ }
+ case overloads.StringToBytes:
+ if len(args) == 1 {
+ sz := c.sizeOrUnknown(args[0])
+ // ResultSize max is when each char converts to 4 bytes.
+ return CallEstimate{
+ CostEstimate: sz.MultiplyByCostFactor(StringTraversalCostFactor).Add(argCostSum()),
+ ResultSize: &SizeEstimate{Min: sz.Min, Max: sz.Max * 4}}
+ }
+ case overloads.BytesToString:
+ if len(args) == 1 {
+ sz := c.sizeOrUnknown(args[0])
+ // ResultSize min is when 4 bytes convert to 1 char.
+ return CallEstimate{
+ CostEstimate: sz.MultiplyByCostFactor(StringTraversalCostFactor).Add(argCostSum()),
+ ResultSize: &SizeEstimate{Min: sz.Min / 4, Max: sz.Max}}
+ }
+ case overloads.ExtQuoteString:
+ if len(args) == 1 {
+ sz := c.sizeOrUnknown(args[0])
+ // ResultSize max is when each char is escaped. 2 quote chars always added.
+ return CallEstimate{
+ CostEstimate: sz.MultiplyByCostFactor(StringTraversalCostFactor).Add(argCostSum()),
+ ResultSize: &SizeEstimate{Min: sz.Min + 2, Max: sz.Max*2 + 2}}
+ }
+ case overloads.StartsWithString, overloads.EndsWithString:
+ if len(args) == 1 {
+ return CallEstimate{CostEstimate: c.sizeOrUnknown(args[0]).MultiplyByCostFactor(StringTraversalCostFactor).Add(argCostSum())}
+ }
+ case overloads.InList:
+ // If a list is composed entirely of constant values this is O(1), but we don't account for that here.
+ // We just assume all list containment checks are O(n).
+ if len(args) == 2 {
+ return CallEstimate{CostEstimate: c.sizeOrUnknown(args[1]).MultiplyByCostFactor(1).Add(argCostSum())}
+ }
+ // O(nm) functions
+ case overloads.Matches, overloads.MatchesString:
+ // https://swtch.com/~rsc/regexp/regexp1.html applies to RE2 implementation supported by CEL
+ var strNode, regexNode AstNode
+ if overloadID == overloads.MatchesString && target != nil && len(args) == 1 {
+ strNode = *target
+ regexNode = args[0]
+ } else if overloadID == overloads.Matches && target == nil && len(args) == 2 {
+ strNode = args[0]
+ regexNode = args[1]
+ }
+ if strNode != nil && regexNode != nil {
+ // Add one to string length for purposes of cost calculation to prevent product of string and regex to be 0
+ // in case where string is empty but regex is still expensive.
+ strCost := c.sizeOrUnknown(strNode).Add(SizeEstimate{Min: 1, Max: 1}).MultiplyByCostFactor(StringTraversalCostFactor)
+ // We don't know how many expressions are in the regex, just the string length (a huge
+ // improvement here would be to somehow get a count the number of expressions in the regex or
+ // how many states are in the regex state machine and use that to measure regex cost).
+ // For now, we're making a guess that each expression in a regex is typically at least 4 chars
+ // in length.
+ regexCost := c.sizeOrUnknown(regexNode).MultiplyByCostFactor(RegexStringLengthCostFactor)
+ return CallEstimate{CostEstimate: strCost.Multiply(regexCost).Add(argCostSum())}
+ }
+ case overloads.ContainsString:
+ if target != nil && len(args) == 1 {
+ strCost := c.sizeOrUnknown(*target).MultiplyByCostFactor(StringTraversalCostFactor)
+ substrCost := c.sizeOrUnknown(args[0]).MultiplyByCostFactor(StringTraversalCostFactor)
+ return CallEstimate{CostEstimate: strCost.Multiply(substrCost).Add(argCostSum())}
+ }
+ case overloads.LogicalOr, overloads.LogicalAnd:
+ lhs := argCosts[0]
+ rhs := argCosts[1]
+ // min cost is min of LHS for short circuited && or ||
+ argCost := CostEstimate{Min: lhs.Min, Max: lhs.Add(rhs).Max}
+ return CallEstimate{CostEstimate: argCost}
+ case overloads.Conditional:
+ size := c.sizeOrUnknown(args[1]).Union(c.sizeOrUnknown(args[2]))
+ resultEntrySize := c.computeEntrySize(args[1].Expr()).union(c.computeEntrySize(args[2].Expr()))
+ c.setEntrySize(e, resultEntrySize)
+ conditionalCost := argCosts[0]
+ ifTrueCost := argCosts[1]
+ ifFalseCost := argCosts[2]
+ argCost := conditionalCost.Add(ifTrueCost.Union(ifFalseCost))
+ return CallEstimate{CostEstimate: argCost, ResultSize: &size}
+ case overloads.AddString, overloads.AddBytes, overloads.AddList:
+ if len(args) == 2 {
+ lhsSize := c.sizeOrUnknown(args[0])
+ rhsSize := c.sizeOrUnknown(args[1])
+ resultSize := lhsSize.Add(rhsSize)
+ rhsEntrySize := c.computeEntrySize(args[0].Expr())
+ lhsEntrySize := c.computeEntrySize(args[1].Expr())
+ resultEntrySize := rhsEntrySize.union(lhsEntrySize)
+ if resultEntrySize != nil {
+ c.setEntrySize(e, resultEntrySize)
+ }
+ switch overloadID {
+ case overloads.AddList:
+ // list concatenation is O(1), but we handle it here to track size
+ return CallEstimate{CostEstimate: FixedCostEstimate(1).Add(argCostSum()), ResultSize: &resultSize}
+ default:
+ return CallEstimate{CostEstimate: resultSize.MultiplyByCostFactor(StringTraversalCostFactor).Add(argCostSum()), ResultSize: &resultSize}
+ }
+ }
+ case overloads.LessString, overloads.GreaterString, overloads.LessEqualsString, overloads.GreaterEqualsString,
+ overloads.LessBytes, overloads.GreaterBytes, overloads.LessEqualsBytes, overloads.GreaterEqualsBytes,
+ overloads.Equals, overloads.NotEquals:
+ lhsCost := c.sizeOrUnknown(args[0])
+ rhsCost := c.sizeOrUnknown(args[1])
+ min := uint64(0)
+ smallestMax := lhsCost.Max
+ if rhsCost.Max < smallestMax {
+ smallestMax = rhsCost.Max
+ }
+ if smallestMax > 0 {
+ min = 1
+ }
+ // equality of 2 scalar values results in a cost of 1
+ return CallEstimate{
+ CostEstimate: CostEstimate{Min: min, Max: smallestMax}.MultiplyByCostFactor(StringTraversalCostFactor).Add(argCostSum()),
+ }
+ }
+ // O(1) functions
+ // See CostTracker.costCall for more details about O(1) cost calculations
+
+ // Benchmarks suggest that most of the other operations take +/- 50% of a base cost unit
+ // which on an Intel xeon 2.20GHz CPU is 50ns.
+ return CallEstimate{CostEstimate: FixedCostEstimate(1).Add(argCostSum())}
+}
+
+func (c *coster) getType(e ast.Expr) *types.Type {
+ return c.checkedAST.GetType(e.ID())
+}
+
+func (c *coster) getPath(e ast.Expr) []string {
+ if e.Kind() == ast.IdentKind {
+ if v, found := c.peekLocalVar(e.AsIdent()); found {
+ return v.path[:]
+ }
+ }
+ return c.exprPaths[e.ID()][:]
+}
+
+func (c *coster) addPath(e ast.Expr, path []string) {
+ c.exprPaths[e.ID()] = path
+}
+
+func isAccumulatorVar(name string) bool {
+ return name == accumulatorName || name == hiddenAccumulatorName
+}
+
+func (c *coster) newAstNode(e ast.Expr) *astNode {
+ path := c.getPath(e)
+ if len(path) > 0 && isAccumulatorVar(path[0]) {
+ // only provide paths to root vars; omit accumulator vars
+ path = nil
+ }
+ return &astNode{
+ path: path,
+ t: c.getType(e),
+ expr: e,
+ derivedSize: c.computeSize(e)}
+}
+
+func (c *coster) setSize(e ast.Expr, size *SizeEstimate) {
+ if size == nil {
+ return
+ }
+ // Store the computed size with the expression
+ c.computedSizes[e.ID()] = *size
+}
+
+func (c *coster) sizeOrUnknown(node any) SizeEstimate {
+ switch v := node.(type) {
+ case ast.Expr:
+ if sz := c.computeSize(v); sz != nil {
+ return *sz
+ }
+ case AstNode:
+ if sz := v.ComputedSize(); sz != nil {
+ return *sz
+ }
+ }
+ return UnknownSizeEstimate()
+}
+
+func (c *coster) copySizeEstimates(dst, src ast.Expr) {
+ c.setSize(dst, c.computeSize(src))
+ c.setEntrySize(dst, c.computeEntrySize(src))
+}
+
+func (c *coster) computeSize(e ast.Expr) *SizeEstimate {
+ if size, ok := c.computedSizes[e.ID()]; ok {
+ return &size
+ }
+ if size := computeExprSize(e); size != nil {
+ return size
+ }
+ // Ensure size estimates are computed first as users may choose to override the costs that
+ // CEL would otherwise ascribe to the type.
+ node := astNode{expr: e, path: c.getPath(e), t: c.getType(e)}
+ if size := c.estimator.EstimateSize(node); size != nil {
+ // storing the computed size should reduce calls to EstimateSize()
+ c.computedSizes[e.ID()] = *size
+ return size
+ }
+ if size := computeTypeSize(c.getType(e)); size != nil {
+ return size
+ }
+ if e.Kind() == ast.IdentKind {
+ varName := e.AsIdent()
+ if v, ok := c.peekLocalVar(varName); ok && v.size != nil {
+ return v.size
+ }
+ }
+ return nil
+}
+
+func (c *coster) setEntrySize(e ast.Expr, size *entrySizeEstimate) {
+ if size == nil {
+ return
+ }
+ c.computedEntrySizes[e.ID()] = *size
+}
+
+func (c *coster) computeEntrySize(e ast.Expr) *entrySizeEstimate {
+ if sz, found := c.computedEntrySizes[e.ID()]; found {
+ return &sz
+ }
+ if e.Kind() == ast.IdentKind {
+ varName := e.AsIdent()
+ if v, ok := c.peekLocalVar(varName); ok && v.entrySize != nil {
+ return v.entrySize
+ }
+ }
+ return nil
+}
+
+func computeExprSize(expr ast.Expr) *SizeEstimate {
+ var v uint64
+ switch expr.Kind() {
+ case ast.LiteralKind:
+ switch ck := expr.AsLiteral().(type) {
+ case types.String:
+ // converting to runes here is an O(n) operation, but
+ // this is consistent with how size is computed at runtime,
+ // and how the language definition defines string size
+ v = uint64(len([]rune(ck)))
+ case types.Bytes:
+ v = uint64(len(ck))
+ case types.Bool, types.Double, types.Duration,
+ types.Int, types.Timestamp, types.Uint,
+ types.Null:
+ v = uint64(1)
+ default:
+ return nil
+ }
+ case ast.ListKind:
+ v = uint64(expr.AsList().Size())
+ case ast.MapKind:
+ v = uint64(expr.AsMap().Size())
+ default:
+ return nil
+ }
+ size := FixedSizeEstimate(v)
+ return &size
+}
+
+func computeTypeSize(t *types.Type) *SizeEstimate {
+ if isScalar(t) {
+ size := FixedSizeEstimate(1)
+ return &size
+ }
+ return nil
+}
+
+// isScalar returns true if the given type is known to be of a constant size at
+// compile time. isScalar will return false for strings (they are variable-width)
+// in addition to protobuf.Any and protobuf.Value (their size is not knowable at compile time).
+func isScalar(t *types.Type) bool {
+ switch t.Kind() {
+ case types.BoolKind, types.DoubleKind, types.DurationKind, types.IntKind, types.TimestampKind, types.UintKind:
+ return true
+ case types.OpaqueKind:
+ if t.TypeName() == "optional_type" {
+ return isScalar(t.Parameters()[0])
+ }
+ }
+ return false
+}
+
+var (
+ unknownSizeEstimate = SizeEstimate{Min: 0, Max: math.MaxUint64}
+ unknownCostEstimate = unknownSizeEstimate.MultiplyByCostFactor(1)
+
+ selectAndIdentCost = FixedCostEstimate(SelectAndIdentCost)
+ constCost = FixedCostEstimate(ConstCost)
+
+ createListBaseCost = FixedCostEstimate(ListCreateBaseCost)
+ createMapBaseCost = FixedCostEstimate(MapCreateBaseCost)
+ createMessageBaseCost = FixedCostEstimate(StructCreateBaseCost)
+
+ accumulatorName = "__result__"
+ hiddenAccumulatorName = "@result"
+)
diff --git a/common/cost/estimator_test.go b/common/cost/estimator_test.go
new file mode 100644
index 000000000..5bde4c15f
--- /dev/null
+++ b/common/cost/estimator_test.go
@@ -0,0 +1,871 @@
+// Copyright 2022 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 cost_test
+
+import (
+ "math"
+ "strings"
+ "testing"
+
+ "cel.dev/cel-go/checker"
+ "cel.dev/cel-go/common"
+ "cel.dev/cel-go/common/containers"
+ "cel.dev/cel-go/common/cost"
+ "cel.dev/cel-go/common/decls"
+ "cel.dev/cel-go/common/overloads"
+ "cel.dev/cel-go/common/stdlib"
+ "cel.dev/cel-go/common/types"
+ "cel.dev/cel-go/parser"
+
+ proto3pb "cel.dev/cel-go/test/proto3pb"
+)
+
+func TestCost(t *testing.T) {
+ allTypes := types.NewObjectType("google.expr.proto3.test.TestAllTypes")
+ allList := types.NewListType(allTypes)
+ intList := types.NewListType(types.IntType)
+ nestedList := types.NewListType(allList)
+
+ allMap := types.NewMapType(types.StringType, allTypes)
+ nestedMap := types.NewMapType(types.StringType, allMap)
+
+ zeroCost := cost.CostEstimate{}
+ oneCost := cost.FixedCostEstimate(1)
+ cases := []struct {
+ name string
+ expr string
+ vars []*decls.VariableDecl
+ hints map[string]uint64
+ options []cost.CostOption
+ wanted cost.CostEstimate
+ }{
+ {
+ name: "const",
+ expr: `"Hello World!"`,
+ wanted: zeroCost,
+ },
+ {
+ name: "identity",
+ expr: `input`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", intList)},
+ wanted: cost.CostEstimate{Min: 1, Max: 1},
+ },
+ {
+ name: "select: map",
+ expr: `input['key']`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.NewMapType(types.StringType, types.StringType))},
+ wanted: cost.CostEstimate{Min: 2, Max: 2},
+ },
+ {
+ name: "select: field",
+ expr: `input.single_int32`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", allTypes)},
+ wanted: cost.CostEstimate{Min: 2, Max: 2},
+ },
+ {
+ name: "select: field test only no has() cost",
+ expr: `has(input.single_int32)`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewObjectType("google.expr.proto3.test.TestAllTypes"))},
+ wanted: cost.CostEstimate{Min: 1, Max: 1},
+ options: []cost.CostOption{cost.PresenceTestHasCost(false)},
+ },
+ {
+ name: "select: field test only",
+ expr: `has(input.single_int32)`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewObjectType("google.expr.proto3.test.TestAllTypes"))},
+ wanted: cost.CostEstimate{Min: 2, Max: 2},
+ },
+ {
+ name: "select: non-proto field test has() cost",
+ expr: `has(input.testAttr.nestedAttr)`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", nestedMap)},
+ wanted: cost.CostEstimate{Min: 3, Max: 3},
+ options: []cost.CostOption{cost.PresenceTestHasCost(true)},
+ },
+ {
+ name: "select: non-proto field test no has() cost",
+ expr: `has(input.testAttr.nestedAttr)`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", nestedMap)},
+ wanted: cost.CostEstimate{Min: 2, Max: 2},
+ options: []cost.CostOption{cost.PresenceTestHasCost(false)},
+ },
+ {
+ name: "select: non-proto field test",
+ expr: `has(input.testAttr.nestedAttr)`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", nestedMap)},
+ wanted: cost.CostEstimate{Min: 3, Max: 3},
+ },
+ {
+ name: "estimated function call",
+ expr: `input.getFullYear()`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.TimestampType)},
+ wanted: cost.CostEstimate{Min: 8, Max: 8},
+ },
+ {
+ name: "create list",
+ expr: `[1, 2, 3]`,
+ wanted: cost.CostEstimate{Min: 10, Max: 10},
+ },
+ {
+ name: "create struct",
+ expr: `google.expr.proto3.test.TestAllTypes{single_int32: 1, single_float: 3.14, single_string: 'str'}`,
+ wanted: cost.CostEstimate{Min: 40, Max: 40},
+ },
+ {
+ name: "create map",
+ expr: `{"a": 1, "b": 2, "c": 3}`,
+ wanted: cost.CostEstimate{Min: 30, Max: 30},
+ },
+ {
+ name: "all comprehension",
+ vars: []*decls.VariableDecl{decls.NewVariable("input", allList)},
+ hints: map[string]uint64{"input": 100},
+ expr: `input.all(x, true)`,
+ wanted: cost.CostEstimate{Min: 2, Max: 302},
+ },
+ {
+ name: "nested all comprehension",
+ vars: []*decls.VariableDecl{decls.NewVariable("input", nestedList)},
+ hints: map[string]uint64{"input": 50, "input.@items": 10},
+ expr: `input.all(x, x.all(y, true))`,
+ wanted: cost.CostEstimate{Min: 2, Max: 1752},
+ },
+ {
+ name: "all comprehension on literal",
+ expr: `[1, 2, 3].all(x, true)`,
+ wanted: cost.CostEstimate{Min: 20, Max: 20},
+ },
+ {
+ name: "variable cost function",
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.StringType)},
+ hints: map[string]uint64{"input": 500},
+ expr: `input.matches('[0-9]')`,
+ wanted: cost.CostEstimate{Min: 3, Max: 103},
+ },
+ {
+ name: "variable cost function with constant",
+ expr: `'123'.matches('[0-9]')`,
+ wanted: cost.CostEstimate{Min: 2, Max: 2},
+ },
+ {
+ name: "or",
+ expr: `true || false`,
+ wanted: zeroCost,
+ },
+ {
+ name: "or accumulated branch cost",
+ expr: `a || b || c || d`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("a", types.BoolType),
+ decls.NewVariable("b", types.BoolType),
+ decls.NewVariable("c", types.BoolType),
+ decls.NewVariable("d", types.BoolType),
+ },
+ wanted: cost.CostEstimate{Min: 1, Max: 4},
+ },
+ {
+ name: "and",
+ expr: `true && false`,
+ wanted: zeroCost,
+ },
+ {
+ name: "and accumulated branch cost",
+ expr: `a && b && c && d`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("a", types.BoolType),
+ decls.NewVariable("b", types.BoolType),
+ decls.NewVariable("c", types.BoolType),
+ decls.NewVariable("d", types.BoolType),
+ },
+ wanted: cost.CostEstimate{Min: 1, Max: 4},
+ },
+ {
+ name: "lt",
+ expr: `1 < 2`,
+ wanted: oneCost,
+ },
+ {
+ name: "lte",
+ expr: `1 <= 2`,
+ wanted: oneCost,
+ },
+ {
+ name: "eq",
+ expr: `1 == 2`,
+ wanted: oneCost,
+ },
+ {
+ name: "gt",
+ expr: `2 > 1`,
+ wanted: oneCost,
+ },
+ {
+ name: "gte",
+ expr: `2 >= 1`,
+ wanted: oneCost,
+ },
+ {
+ name: "in",
+ expr: `2 in [1, 2, 3]`,
+ wanted: cost.CostEstimate{Min: 13, Max: 13},
+ },
+ {
+ name: "plus",
+ expr: `1 + 1`,
+ wanted: oneCost,
+ },
+ {
+ name: "minus",
+ expr: `1 - 1`,
+ wanted: oneCost,
+ },
+ {
+ name: "/",
+ expr: `1 / 1`,
+ wanted: oneCost,
+ },
+ {
+ name: "/",
+ expr: `1 * 1`,
+ wanted: oneCost,
+ },
+ {
+ name: "%",
+ expr: `1 % 1`,
+ wanted: oneCost,
+ },
+ {
+ name: "ternary",
+ expr: `true ? 1 : 2`,
+ wanted: zeroCost,
+ },
+ {
+ name: "string size",
+ expr: `size("123")`,
+ wanted: oneCost,
+ },
+ {
+ name: "bytes size",
+ expr: `size(b"123")`,
+ wanted: oneCost,
+ },
+ {
+ name: "bytes to string conversion",
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.BytesType)},
+ hints: map[string]uint64{"input": 500},
+ expr: `string(input)`,
+ wanted: cost.CostEstimate{Min: 1, Max: 51},
+ },
+ {
+ name: "bytes to string conversion equality",
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.BytesType)},
+ hints: map[string]uint64{"input": 500},
+ // equality check ensures that the resultSize calculation is included in cost
+ expr: `string(input) == string(input)`,
+ wanted: cost.CostEstimate{Min: 3, Max: 152},
+ },
+ {
+ name: "string to bytes conversion",
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.StringType)},
+ hints: map[string]uint64{"input": 500},
+ expr: `bytes(input)`,
+ wanted: cost.CostEstimate{Min: 1, Max: 51},
+ },
+ {
+ name: "string to bytes conversion equality",
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.StringType)},
+ hints: map[string]uint64{"input": 500},
+ // equality check ensures that the resultSize calculation is included in cost
+ expr: `bytes(input) == bytes(input)`,
+ wanted: cost.CostEstimate{Min: 3, Max: 302},
+ },
+ {
+ name: "int to string conversion",
+ expr: `string(1)`,
+ wanted: cost.CostEstimate{Min: 1, Max: 1},
+ },
+ {
+ name: "contains",
+ expr: `input.contains(arg1)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ decls.NewVariable("arg1", types.StringType),
+ },
+ hints: map[string]uint64{"input": 500, "arg1": 500},
+ wanted: cost.CostEstimate{Min: 2, Max: 2502},
+ },
+ {
+ name: "matches",
+ expr: `input.matches('\\d+a\\d+b')`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ },
+ hints: map[string]uint64{"input": 500},
+ wanted: cost.CostEstimate{Min: 3, Max: 103},
+ },
+ {
+ name: "matches global",
+ expr: `matches(input, '\\d+a\\d+b')`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ },
+ hints: map[string]uint64{"input": 500},
+ wanted: cost.CostEstimate{Min: 3, Max: 103},
+ },
+ {
+ name: "startsWith",
+ expr: `input.startsWith(arg1)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ decls.NewVariable("arg1", types.StringType),
+ },
+ hints: map[string]uint64{"arg1": 500},
+ wanted: cost.CostEstimate{Min: 2, Max: 52},
+ },
+ {
+ name: "endsWith",
+ expr: `input.endsWith(arg1)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ decls.NewVariable("arg1", types.StringType),
+ },
+ hints: map[string]uint64{"arg1": 500},
+ wanted: cost.CostEstimate{Min: 2, Max: 52},
+ },
+ {
+ name: "size receiver",
+ expr: `input.size()`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ },
+ wanted: cost.CostEstimate{Min: 2, Max: 2},
+ },
+ {
+ name: "size",
+ expr: `size(input)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ },
+ wanted: cost.CostEstimate{Min: 2, Max: 2},
+ },
+ {
+ name: "ternary eval",
+ expr: `(x > 2 ? input1 : input2).all(y, true)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("x", types.IntType),
+ decls.NewVariable("input1", allList),
+ decls.NewVariable("input2", allList),
+ },
+ hints: map[string]uint64{"input1": 1, "input2": 1},
+ wanted: cost.CostEstimate{Min: 4, Max: 7},
+ },
+ {
+ name: "comprehension over map",
+ expr: `input.all(k, input[k].single_int32 > 3)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", allMap),
+ },
+ hints: map[string]uint64{"input": 10},
+ wanted: cost.CostEstimate{Min: 2, Max: 82},
+ },
+ {
+ name: "comprehension over nested map of maps",
+ expr: `input.all(k, input[k].all(x, true))`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", nestedMap),
+ },
+ hints: map[string]uint64{"input": 5, "input.@values": 10},
+ wanted: cost.CostEstimate{Min: 2, Max: 187},
+ },
+ {
+ name: "string size of map keys",
+ expr: `input.all(k, k.contains(k))`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", nestedMap),
+ },
+ hints: map[string]uint64{"input": 5, "input.@keys": 10},
+ wanted: cost.CostEstimate{Min: 2, Max: 32},
+ },
+ {
+ name: "comprehension variable shadowing",
+ expr: `input.all(k, input[k].all(k, true) && k.contains(k))`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", nestedMap),
+ },
+ hints: map[string]uint64{"input": 2, "input.@values": 2, "input.@keys": 5},
+ wanted: cost.CostEstimate{Min: 2, Max: 34},
+ },
+ {
+ name: "comprehension variable shadowing",
+ expr: `input.all(k, input[k].all(k, true) && k.contains(k))`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", nestedMap),
+ },
+ hints: map[string]uint64{"input": 2, "input.@values": 2, "input.@keys": 5},
+ wanted: cost.CostEstimate{Min: 2, Max: 34},
+ },
+ {
+ name: "list concat",
+ expr: `(list1 + list2).all(x, true)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("list1", types.NewListType(types.IntType)),
+ decls.NewVariable("list2", types.NewListType(types.IntType)),
+ },
+ hints: map[string]uint64{"list1": 10, "list2": 10},
+ wanted: cost.CostEstimate{Min: 4, Max: 64},
+ },
+ {
+ name: "str concat",
+ expr: `"abcdefg".contains(str1 + str2)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("str1", types.StringType),
+ decls.NewVariable("str2", types.StringType),
+ },
+ hints: map[string]uint64{"str1": 10, "str2": 10},
+ wanted: cost.CostEstimate{Min: 2, Max: 6},
+ },
+ {
+ name: "str concat custom cost estimate",
+ expr: `"abcdefg".contains(str1 + str2)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("str1", types.StringType),
+ decls.NewVariable("str2", types.StringType),
+ },
+ hints: map[string]uint64{"str1": 10, "str2": 10},
+ options: []cost.CostOption{
+ cost.OverloadCostEstimate(overloads.ContainsString,
+ func(estimator cost.Estimator, target *cost.AstNode, args []cost.AstNode) *cost.CallEstimate {
+ if target != nil && len(args) == 1 {
+ strSize := estimateSize(estimator, *target).MultiplyByCostFactor(0.2)
+ subSize := estimateSize(estimator, args[0]).MultiplyByCostFactor(0.2)
+ return &cost.CallEstimate{CostEstimate: strSize.Multiply(subSize)}
+ }
+ return nil
+ }),
+ },
+ wanted: cost.CostEstimate{Min: 2, Max: 12},
+ },
+ {
+ name: "list size comparison",
+ expr: `list1.size() == list2.size()`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("list1", types.NewListType(types.IntType)),
+ decls.NewVariable("list2", types.NewListType(types.IntType)),
+ },
+ wanted: cost.CostEstimate{Min: 5, Max: 5},
+ },
+ {
+ name: "list size from ternary",
+ expr: `x > y ? list1.size() : list2.size()`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("x", types.IntType),
+ decls.NewVariable("y", types.IntType),
+ decls.NewVariable("list1", types.NewListType(types.IntType)),
+ decls.NewVariable("list2", types.NewListType(types.IntType)),
+ },
+ wanted: cost.CostEstimate{Min: 5, Max: 5},
+ },
+ {
+ name: "list size from concat",
+ expr: `([x, y] + list1 + list2).size()`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("x", types.IntType),
+ decls.NewVariable("y", types.IntType),
+ decls.NewVariable("list1", types.NewListType(types.IntType)),
+ decls.NewVariable("list2", types.NewListType(types.IntType)),
+ },
+ hints: map[string]uint64{
+ "list1": 10,
+ "list2": 20,
+ },
+ wanted: cost.CostEstimate{Min: 17, Max: 17},
+ },
+ {
+ name: "list cost tracking through comprehension",
+ expr: `[list1, list2].exists(l, l.exists(v, v.startsWith('hi')))`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("list1", types.NewListType(types.StringType)),
+ decls.NewVariable("list2", types.NewListType(types.StringType)),
+ },
+ hints: map[string]uint64{
+ "list1": 10,
+ "list1.@items": 64,
+ "list2": 20,
+ "list2.@items": 128,
+ },
+ wanted: cost.CostEstimate{Min: 21, Max: 265},
+ },
+ {
+ name: "str endsWith equality",
+ expr: `str1.endsWith("abcdefghijklmnopqrstuvwxyz") == str2.endsWith("abcdefghijklmnopqrstuvwxyz")`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("str1", types.StringType),
+ decls.NewVariable("str2", types.StringType),
+ },
+ wanted: cost.CostEstimate{Min: 9, Max: 9},
+ },
+ {
+ name: "nested subexpression operators",
+ expr: `((5 != 6) == (1 == 2)) == ((3 <= 4) == (9 != 9))`,
+ wanted: cost.CostEstimate{Min: 7, Max: 7},
+ },
+ {
+ name: "str size estimate",
+ expr: `string(timestamp1) == string(timestamp2)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("timestamp1", types.TimestampType),
+ decls.NewVariable("timestamp2", types.TimestampType),
+ },
+ wanted: cost.CostEstimate{Min: 5, Max: 1844674407370955268},
+ },
+ {
+ name: "timestamp equality check",
+ expr: `timestamp1 == timestamp2`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("timestamp1", types.TimestampType),
+ decls.NewVariable("timestamp2", types.TimestampType),
+ },
+ wanted: cost.CostEstimate{Min: 3, Max: 3},
+ },
+ {
+ name: "duration inequality check",
+ expr: `duration1 != duration2`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("duration1", types.DurationType),
+ decls.NewVariable("duration2", types.DurationType),
+ },
+ wanted: cost.CostEstimate{Min: 3, Max: 3},
+ },
+ {
+ name: ".filter list literal",
+ expr: `[1,2,3,4,5].filter(x, x % 2 == 0)`,
+ wanted: cost.CostEstimate{Min: 41, Max: 101},
+ },
+ {
+ name: ".map list literal",
+ expr: `[1,2,3,4,5].map(x, x)`,
+ wanted: cost.CostEstimate{Min: 86, Max: 86},
+ },
+ {
+ name: ".map.filter list literal",
+ expr: `[1,2,3,4,5].map(x, x).filter(x, x % 2 == 0)`,
+ wanted: cost.CostEstimate{Min: 117, Max: 177},
+ },
+ {
+ name: ".map.exists list literal",
+ expr: `[1,2,3,4,5].map(x, x).exists(x, x == 5) == true`,
+ wanted: cost.CostEstimate{Min: 108, Max: 118},
+ },
+ {
+ name: ".map.map list literal",
+ expr: `[1,2,3,4,5].map(x, x).map(x, x)`,
+ wanted: cost.CostEstimate{Min: 162, Max: 162},
+ },
+ {
+ name: ".map list literal selection",
+ expr: `[1,2,3,4,5].map(x, x)[4]`,
+ wanted: cost.CostEstimate{Min: 87, Max: 87},
+ },
+ {
+ name: "nested array selection",
+ expr: `[[1,2],[1,2],[1,2],[1,2],[1,2]][4]`,
+ wanted: cost.CostEstimate{Min: 61, Max: 61},
+ },
+ {
+ name: "nested map selection",
+ expr: `{'a': [1,2], 'b': [1,2], 'c': [1,2], 'd': [1,2], 'e': [1,2]}.b`,
+ wanted: cost.CostEstimate{Min: 81, Max: 81},
+ },
+ {
+ name: "comprehension on nested list",
+ expr: `[[1, 1], [2, 2], [3, 3], [4, 4], [5, 5]].all(y, y.all(y, y == 1))`,
+ wanted: cost.CostEstimate{Min: 76, Max: 136},
+ },
+ {
+ name: "comprehension on transformed nested list",
+ expr: `[1,2,3,4,5].map(x, [x, x]).all(y, y.all(y, y == 1))`,
+ wanted: cost.CostEstimate{Min: 157, Max: 217},
+ },
+ {
+ name: "comprehension on nested literal list",
+ expr: `["a", "ab", "abc", "abcd", "abcde"].map(x, [x, x]).all(y, y.all(y, y.startsWith('a')))`,
+ wanted: cost.CostEstimate{Min: 157, Max: 217},
+ },
+ {
+ name: "comprehension on nested variable list",
+ expr: `input.map(x, [x, x]).all(y, y.all(y, y.startsWith('a')))`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewListType(types.StringType))},
+ hints: map[string]uint64{
+ "input": 5,
+ "input.@items": 10,
+ },
+ wanted: cost.CostEstimate{Min: 13, Max: 208},
+ },
+ {
+ name: "comprehension chaining with concat",
+ expr: `[1,2,3,4,5].map(x, x).map(x, x) + [1]`,
+ wanted: cost.CostEstimate{Min: 173, Max: 173},
+ },
+ {
+ name: "nested comprehension",
+ expr: `[1,2,3].all(i, i in [1,2,3].map(j, j + j))`,
+ wanted: cost.CostEstimate{Min: 20, Max: 230},
+ },
+ {
+ name: "nested dyn comprehension",
+ expr: `dyn([1,2,3]).all(i, i in dyn([1,2,3]).map(j, j + j))`,
+ wanted: cost.CostEstimate{Min: 21, Max: 234},
+ },
+ {
+ name: "literal map access",
+ expr: `{'hello': 'hi'}['hello'] != {'hello': 'bye'}['hello']`,
+ wanted: cost.CostEstimate{Min: 63, Max: 63},
+ },
+ {
+ name: "literal list access",
+ expr: `['hello', 'hi'][0] != ['hello', 'bye'][1]`,
+ wanted: cost.CostEstimate{Min: 23, Max: 23},
+ },
+ {
+ name: "type call",
+ expr: `type(1)`,
+ wanted: cost.CostEstimate{Min: 1, Max: 1},
+ },
+ {
+ name: "type call variable",
+ expr: `type(self.val1)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("self", types.NewMapType(types.StringType, types.IntType)),
+ },
+ wanted: cost.CostEstimate{Min: 3, Max: 3},
+ },
+ {
+ name: "type call variable equality",
+ expr: `type(self.val1) == int`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("self", types.NewMapType(types.StringType, types.IntType)),
+ },
+ wanted: cost.CostEstimate{Min: 5, Max: 1844674407370955268},
+ },
+ {
+ name: "type literal equality cost",
+ expr: `type(1) == int`,
+ wanted: cost.CostEstimate{Min: 3, Max: 1844674407370955266},
+ },
+ {
+ name: "type variable equality cost",
+ expr: `type(1) == int`,
+ wanted: cost.CostEstimate{Min: 3, Max: 1844674407370955266},
+ },
+ {
+ name: "namespace variable equality",
+ expr: `self.val1 == 1.0`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("self.val1", types.DoubleType),
+ },
+ wanted: cost.CostEstimate{Min: 2, Max: 2},
+ },
+ {
+ name: "simple map variable equality",
+ expr: `self.val1 == 1.0`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("self", types.NewMapType(types.StringType, types.DoubleType)),
+ },
+ wanted: cost.CostEstimate{Min: 3, Max: 3},
+ },
+ {
+ name: "date-time math",
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("self", types.NewMapType(types.StringType, types.TimestampType)),
+ },
+ expr: `self.val1 == timestamp('2011-08-18T00:00:00.000+01:00') + duration('19h3m37s10ms')`,
+ wanted: cost.FixedCostEstimate(6),
+ },
+ {
+ name: "date-time math self-conversion",
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("self", types.NewMapType(types.StringType, types.TimestampType)),
+ },
+ expr: `timestamp(self.val1) == timestamp('2011-08-18T00:00:00.000+01:00') + duration('19h3m37s10ms')`,
+ wanted: cost.FixedCostEstimate(7),
+ },
+ {
+ name: "boolean vars equal",
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("self", types.NewMapType(types.StringType, types.BoolType)),
+ },
+ expr: `self.val1 != self.val2`,
+ wanted: cost.FixedCostEstimate(5),
+ },
+ {
+ name: "boolean var equals literal",
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("self", types.NewMapType(types.StringType, types.BoolType)),
+ },
+ expr: `self.val1 != true`,
+ wanted: cost.FixedCostEstimate(3),
+ },
+ {
+ name: "double var equals literal",
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("self", types.NewMapType(types.StringType, types.DoubleType)),
+ },
+ expr: `self.val1 == 1.0`,
+ wanted: cost.FixedCostEstimate(3),
+ },
+ {
+ name: "bytes list max",
+ expr: "[bytes('012345678901'), bytes('012345678901'), bytes('012345678901'), bytes('012345678901'), bytes('012345678901')].max()",
+ options: []cost.CostOption{
+ cost.OverloadCostEstimate("list_bytes_max",
+ func(estimator cost.Estimator, target *cost.AstNode, args []cost.AstNode) *cost.CallEstimate {
+ if target != nil {
+ // Charge 1 cost for comparing each element in the list
+ elCost := cost.CostEstimate{Min: 1, Max: 1}
+ // If the list contains strings or bytes, add the cost of traversing all the strings/bytes as a way
+ // of estimating the additional comparison cost.
+ if elNode := listElementNode(*target); elNode != nil {
+ k := elNode.Type().Kind()
+ if k == types.StringKind || k == types.BytesKind {
+ sz := sizeEstimate(estimator, elNode)
+ elCost = elCost.Add(sz.MultiplyByCostFactor(cost.StringTraversalCostFactor))
+ }
+ return &cost.CallEstimate{CostEstimate: sizeEstimate(estimator, *target).MultiplyByCost(elCost)}
+ }
+ }
+ return nil
+ }),
+ },
+ wanted: cost.CostEstimate{Min: 25, Max: 35},
+ },
+ }
+
+ for _, tst := range cases {
+ tc := tst
+ t.Run(tc.name, func(t *testing.T) {
+ if tc.hints == nil {
+ tc.hints = map[string]uint64{}
+ }
+ p, err := parser.NewParser(parser.Macros(parser.AllMacros...))
+ if err != nil {
+ t.Fatalf("parser.NewParser() failed: %v", err)
+ }
+ src := common.NewStringSource(tc.expr, "")
+ pe, errs := p.Parse(src)
+ if len(errs.GetErrors()) != 0 {
+ t.Fatalf("parser.Parse(%v) failed: %v", tc.expr, errs.ToDisplayString())
+ }
+ reg, err := types.NewRegistry(types.ProtoTypeDefs(&proto3pb.TestAllTypes{}))
+ if err != nil {
+ t.Fatalf("types.NewRegistry(...) failed: %v", err)
+ }
+
+ e, err := checker.NewEnv(containers.DefaultContainer, reg)
+ if err != nil {
+ t.Fatalf("checker.NewEnv() failed: %v", err)
+ }
+ err = e.AddFunctions(stdlib.Functions()...)
+ if err != nil {
+ t.Fatalf("environment creation error: %v", err)
+ }
+ maxFunc, _ := decls.NewFunction("max",
+ decls.MemberOverload("list_bytes_max",
+ []*types.Type{types.NewListType(types.BytesType)},
+ types.BytesType))
+ err = e.AddFunctions(maxFunc)
+ if err != nil {
+ t.Fatalf("environment creation error: %v", err)
+ }
+ err = e.AddIdents(tc.vars...)
+ if err != nil {
+ t.Fatalf("environment creation error: %s\n", err)
+ }
+ checked, errs := checker.Check(pe, src, e)
+ if len(errs.GetErrors()) != 0 {
+ t.Fatalf("Check(%s) failed: %v", tc.expr, errs.ToDisplayString())
+ }
+ est, err := cost.Cost(checked, testCostEstimator{hints: tc.hints}, tc.options...)
+ if err != nil {
+ t.Fatalf("Cost() failed: %v", err)
+ }
+ if est.Min != tc.wanted.Min || est.Max != tc.wanted.Max {
+ t.Fatalf("Got cost interval [%v, %v], wanted [%v, %v]",
+ est.Min, est.Max, tc.wanted.Min, tc.wanted.Max)
+ }
+ })
+ }
+}
+
+type testCostEstimator struct {
+ hints map[string]uint64
+}
+
+func (tc testCostEstimator) EstimateSize(element cost.AstNode) *cost.SizeEstimate {
+ if l, ok := tc.hints[strings.Join(element.Path(), ".")]; ok {
+ return &cost.SizeEstimate{Min: 0, Max: l}
+ }
+ if element.Type() == types.BytesType {
+ return &cost.SizeEstimate{Min: 0, Max: 12}
+ }
+ return nil
+}
+
+func (tc testCostEstimator) EstimateCallCost(function, overloadID string, target *cost.AstNode, args []cost.AstNode) *cost.CallEstimate {
+ switch overloadID {
+ case overloads.TimestampToYear:
+ return &cost.CallEstimate{CostEstimate: cost.CostEstimate{Min: 7, Max: 7}}
+ }
+ return nil
+}
+
+func estimateSize(estimator cost.Estimator, node cost.AstNode) cost.SizeEstimate {
+ if l := node.ComputedSize(); l != nil {
+ return *l
+ }
+ if l := estimator.EstimateSize(node); l != nil {
+ return *l
+ }
+ return cost.SizeEstimate{Min: 0, Max: math.MaxUint64}
+}
+
+func listElementNode(list cost.AstNode) cost.AstNode {
+ if params := list.Type().Parameters(); len(params) > 0 {
+ lt := params[0]
+ nodePath := list.Path()
+ if nodePath != nil {
+ // Provide path if we have it so that a OpenAPIv3 maxLength validation can be looked up, if it exists
+ // for this node.
+ path := make([]string, len(nodePath)+1)
+ copy(path, nodePath)
+ path[len(nodePath)] = "@items"
+ return cost.NewAstNode(nil, path, lt, nil)
+ } else {
+ // Provide just the type if no path is available so that worst case size can be looked up based on type.
+ return cost.NewAstNode(nil, nil, lt, nil)
+ }
+ }
+ return nil
+}
+
+func sizeEstimate(estimator cost.Estimator, t cost.AstNode) cost.SizeEstimate {
+ if sz := t.ComputedSize(); sz != nil {
+ return *sz
+ }
+ if sz := estimator.EstimateSize(t); sz != nil {
+ return *sz
+ }
+ return cost.SizeEstimate{Min: 0, Max: math.MaxUint64}
+}
diff --git a/common/cost/tracker.go b/common/cost/tracker.go
new file mode 100644
index 000000000..bd64178f7
--- /dev/null
+++ b/common/cost/tracker.go
@@ -0,0 +1,288 @@
+// 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 cost
+
+import (
+ "cel.dev/cel-go/common/overloads"
+ "cel.dev/cel-go/common/types/ref"
+)
+
+// WARNING: Any changes to cost calculations in this file require a corresponding change in estimator.go
+
+// ActualCostEstimator provides function call cost estimations at runtime.
+//
+// CallCost returns an estimated cost for the function overload invocation with the given args, or nil if it has no
+// estimate to provide. CEL attempts to provide reasonable estimates for its standard function library, so CallCost
+// should typically not need to provide an estimate for CELs standard function.
+type ActualCostEstimator interface {
+ CallCost(function, overloadID string, args []ref.Val, result ref.Val) *uint64
+}
+
+// FunctionTracker computes the actual cost of evaluating the functions with the given arguments and result.
+type FunctionTracker func(args []ref.Val, result ref.Val) *uint64
+
+// Call represents an invocable function with a name and overload ID.
+type Call interface {
+ Function() string
+ OverloadID() string
+}
+
+// TrackerOption configures the behavior of CostTracker objects.
+type TrackerOption func(*Tracker) error
+
+// TrackerLimit sets the runtime limit on the evaluation cost during execution and will terminate the expression
+// evaluation if the limit is exceeded.
+func TrackerLimit(limit uint64) TrackerOption {
+ return func(tracker *Tracker) error {
+ tracker.Limit = &limit
+ return nil
+ }
+}
+
+// TrackerPresenceTestHasCost determines whether presence testing has a cost of one or zero.
+// Defaults to presence test has a cost of one.
+func TrackerPresenceTestHasCost(hasCost bool) TrackerOption {
+ return func(tracker *Tracker) error {
+ tracker.presenceTestHasCost = hasCost
+ return nil
+ }
+}
+
+// TrackerLimitExceededHandler sets a custom handler invoked when the cost limit is exceeded.
+func TrackerLimitExceededHandler(handler func()) TrackerOption {
+ return func(tracker *Tracker) error {
+ tracker.limitExceededHandler = handler
+ return nil
+ }
+}
+
+// OverloadTracker binds an overload ID to a runtime FunctionTracker implementation.
+//
+// OverloadTracker instances augment or override ActualCostEstimator decisions, allowing for versioned and/or
+// optional cost tracking changes.
+func OverloadTracker(overloadID string, fnTracker FunctionTracker) TrackerOption {
+ return func(tracker *Tracker) error {
+ tracker.overloadTrackers[overloadID] = fnTracker
+ return nil
+ }
+}
+
+// LimitExceededError indicates that the actual cost limit was exceeded during evaluation.
+type LimitExceededError struct {
+ Message string
+}
+
+func (e LimitExceededError) Error() string {
+ return e.Message
+}
+
+// Tracker represents the information needed for tracking runtime cost.
+type Tracker struct {
+ Estimator ActualCostEstimator
+ overloadTrackers map[string]FunctionTracker
+ Limit *uint64
+ presenceTestHasCost bool
+ limitExceededHandler func()
+
+ cost uint64
+}
+
+// NewTracker creates a new Tracker with a given estimator and a set of functional TrackerOption values.
+func NewTracker(estimator ActualCostEstimator, opts ...TrackerOption) (*Tracker, error) {
+ tracker := &Tracker{
+ Estimator: estimator,
+ overloadTrackers: map[string]FunctionTracker{},
+ presenceTestHasCost: true,
+ }
+ for _, opt := range opts {
+ err := opt(tracker)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return tracker, nil
+}
+
+// Clone makes a shallow copy of the tracker.
+// The different clones can be used independently from each other.
+func (c *Tracker) Clone() (*Tracker, error) {
+ tracker := &Tracker{
+ Estimator: c.Estimator,
+ overloadTrackers: c.overloadTrackers,
+ Limit: c.Limit,
+ presenceTestHasCost: c.presenceTestHasCost,
+ limitExceededHandler: c.limitExceededHandler,
+ }
+ return tracker, nil
+}
+
+// ActualCost returns the runtime cost.
+func (c *Tracker) ActualCost() uint64 {
+ return c.cost
+}
+
+// PresenceTestHasCost returns whether presence testing has a cost.
+func (c *Tracker) PresenceTestHasCost() bool {
+ return c.presenceTestHasCost
+}
+
+// CreateList records list literal construction cost.
+func (c *Tracker) CreateList(id int64, res ref.Val) {
+ c.cost = SafeAdd(c.cost, ListCreateBaseCost)
+ c.checkLimit()
+}
+
+// CreateMap records map literal construction cost.
+func (c *Tracker) CreateMap(id int64, res ref.Val) {
+ c.cost = SafeAdd(c.cost, MapCreateBaseCost)
+ c.checkLimit()
+}
+
+// CreateStruct records struct/object construction cost.
+func (c *Tracker) CreateStruct(id int64, res ref.Val) {
+ c.cost = SafeAdd(c.cost, StructCreateBaseCost)
+ c.checkLimit()
+}
+
+// EvalAttribute records attribute resolution cost (ident / select).
+func (c *Tracker) EvalAttribute(id int64, isTestOnly bool, res ref.Val) {
+ if !isTestOnly || c.presenceTestHasCost {
+ c.cost = SafeAdd(c.cost, SelectAndIdentCost)
+ c.checkLimit()
+ }
+}
+
+// Qualify records qualifier cost.
+func (c *Tracker) Qualify(id int64) {
+ c.cost = SafeAdd(c.cost, 1)
+ c.checkLimit()
+}
+
+// EvalZeroArity records the cost for a 0-arity call expression.
+func (c *Tracker) EvalZeroArity(vars any, id int64, call Call, result ref.Val) {
+ c.cost = SafeAdd(c.cost, c.CostCall(call, nil, result))
+ c.checkLimit()
+}
+
+// EvalUnary records the cost for a unary call expression.
+func (c *Tracker) EvalUnary(vars any, id int64, call Call, arg ref.Val, result ref.Val) {
+ var buf [1]ref.Val
+ buf[0] = arg
+ c.cost = SafeAdd(c.cost, c.CostCall(call, buf[:], result))
+ c.checkLimit()
+}
+
+// EvalBinary records the cost for a binary call expression.
+func (c *Tracker) EvalBinary(vars any, id int64, call Call, lhs, rhs ref.Val, result ref.Val) {
+ var buf [2]ref.Val
+ buf[0] = lhs
+ buf[1] = rhs
+ c.cost = SafeAdd(c.cost, c.CostCall(call, buf[:], result))
+ c.checkLimit()
+}
+
+// EvalVarArgs records the cost for a variadic call expression.
+func (c *Tracker) EvalVarArgs(vars any, id int64, call Call, args []ref.Val, result ref.Val) {
+ c.cost = SafeAdd(c.cost, c.CostCall(call, args, result))
+ c.checkLimit()
+}
+
+func (c *Tracker) checkLimit() {
+ if c.Limit != nil && c.cost > *c.Limit {
+ if c.limitExceededHandler != nil {
+ c.limitExceededHandler()
+ }
+ panic(LimitExceededError{Message: "operation cancelled: actual cost limit exceeded"})
+ }
+}
+
+// CostCall calculates the runtime cost for a function call.
+func (c *Tracker) CostCall(call Call, args []ref.Val, result ref.Val) uint64 {
+ var total uint64
+ if len(c.overloadTrackers) != 0 {
+ if tracker, found := c.overloadTrackers[call.OverloadID()]; found {
+ callCost := tracker(args, result)
+ if callCost != nil {
+ total = SafeAdd(total, *callCost)
+ return total
+ }
+ }
+ }
+ if c.Estimator != nil {
+ callCost := c.Estimator.CallCost(call.Function(), call.OverloadID(), args, result)
+ if callCost != nil {
+ total = SafeAdd(total, *callCost)
+ return total
+ }
+ }
+ // if user didn't specify, the default way of calculating runtime cost would be used.
+ // if user has their own implementation of ActualCostEstimator, make sure to cover the mapping between overloadId and cost calculation
+ switch call.OverloadID() {
+ // O(n) functions
+ case overloads.StartsWithString, overloads.EndsWithString:
+ total = SafeAdd(total, SafeMultiplyByFactor(ActualSize(args[1]), StringTraversalCostFactor))
+ case overloads.StringToBytes, overloads.BytesToString, overloads.ExtQuoteString, overloads.ExtFormatString:
+ total = SafeAdd(total, SafeMultiplyByFactor(ActualSize(args[0]), StringTraversalCostFactor))
+ case overloads.InList:
+ // If a list is composed entirely of constant values this is O(1), but we don't account for that here.
+ // We just assume all list containment checks are O(n).
+ total = SafeAdd(total, ActualSize(args[1]))
+ // O(min(m, n)) functions
+ case overloads.LessString, overloads.GreaterString, overloads.LessEqualsString, overloads.GreaterEqualsString,
+ overloads.LessBytes, overloads.GreaterBytes, overloads.LessEqualsBytes, overloads.GreaterEqualsBytes,
+ overloads.Equals, overloads.NotEquals:
+ // When we check the equality of 2 scalar values (e.g. 2 integers, 2 floating-point numbers, 2 booleans etc.),
+ // the CostTracker.ActualSize() function by definition returns 1 for each operand, resulting in an overall cost
+ // of 1.
+ lhsSize := ActualSize(args[0])
+ rhsSize := ActualSize(args[1])
+ minSize := min(rhsSize, lhsSize)
+ total = SafeAdd(total, SafeMultiplyByFactor(minSize, StringTraversalCostFactor))
+ // O(m+n) functions
+ case overloads.AddString, overloads.AddBytes:
+ // In the worst case scenario, we would need to reallocate a new backing store and copy both operands over.
+ argSize := SafeAdd(ActualSize(args[0]), ActualSize(args[1]))
+ total = SafeAdd(total, SafeMultiplyByFactor(argSize, StringTraversalCostFactor))
+ // O(nm) functions
+ case overloads.Matches, overloads.MatchesString:
+ // https://swtch.com/~rsc/regexp/regexp1.html applies to RE2 implementation supported by CEL
+ // Add one to string length for purposes of cost calculation to prevent product of string and regex to be 0
+ // in case where string is empty but regex is still expensive.
+ strCost := SafeMultiplyByFactor(SafeAdd(1, ActualSize(args[0])), StringTraversalCostFactor)
+ // We don't know how many expressions are in the regex, just the string length (a huge
+ // improvement here would be to somehow get a count the number of expressions in the regex or
+ // how many states are in the regex state machine and use that to measure regex cost).
+ // For now, we're making a guess that each expression in a regex is typically at least 4 chars
+ // in length.
+ regexCost := SafeMultiplyByFactor(ActualSize(args[1]), RegexStringLengthCostFactor)
+ total = SafeAdd(total, SafeMultiply(strCost, regexCost))
+ case overloads.ContainsString:
+ strCost := SafeMultiplyByFactor(ActualSize(args[0]), StringTraversalCostFactor)
+ substrCost := SafeMultiplyByFactor(ActualSize(args[1]), StringTraversalCostFactor)
+ total = SafeAdd(total, SafeMultiply(strCost, substrCost))
+
+ default:
+ // The following operations are assumed to have O(1) complexity.
+ // - AddList due to the implementation. Index lookup can be O(c) the
+ // number of concatenated lists, but we don't track that is cost calculations.
+ // - Conversions, since none perform a traversal of a type of unbound length.
+ // - Computing the size of strings, byte sequences, lists and maps.
+ // - Logical operations and all operators on fixed width scalars (comparisons, equality)
+ // - Any functions that don't have a declared cost either here or in provided ActualCostEstimator.
+ total = SafeAdd(total, 1)
+
+ }
+ return total
+}
diff --git a/common/cost/tracker_test.go b/common/cost/tracker_test.go
new file mode 100644
index 000000000..2b453f1bc
--- /dev/null
+++ b/common/cost/tracker_test.go
@@ -0,0 +1,1182 @@
+// Copyright 2022 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 cost_test
+
+import (
+ "fmt"
+ "math"
+ "math/rand"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "cel.dev/cel-go/checker"
+ "cel.dev/cel-go/common"
+ "cel.dev/cel-go/common/containers"
+ "cel.dev/cel-go/common/cost"
+ "cel.dev/cel-go/common/decls"
+ "cel.dev/cel-go/common/overloads"
+ "cel.dev/cel-go/common/stdlib"
+ "cel.dev/cel-go/common/types"
+ "cel.dev/cel-go/common/types/ref"
+ "cel.dev/cel-go/interpreter"
+ "cel.dev/cel-go/parser"
+
+ proto3pb "cel.dev/cel-go/test/proto3pb"
+)
+
+type testCall struct {
+ function string
+ overloadID string
+}
+
+func (c testCall) Function() string {
+ return c.function
+}
+
+func (c testCall) OverloadID() string {
+ return c.overloadID
+}
+
+func TestCostTrackerBasic(t *testing.T) {
+ tracker, err := cost.NewTracker(nil,
+ cost.TrackerPresenceTestHasCost(true),
+ )
+ if err != nil {
+ t.Fatalf("NewCostTracker() failed: %v", err)
+ }
+
+ tracker.CreateList(1, nil)
+ if tracker.ActualCost() != cost.ListCreateBaseCost {
+ t.Errorf("ActualCost() after CreateList = %d, want %d", tracker.ActualCost(), cost.ListCreateBaseCost)
+ }
+
+ tracker.CreateMap(2, nil)
+ if tracker.ActualCost() != cost.ListCreateBaseCost+cost.MapCreateBaseCost {
+ t.Errorf("ActualCost() after CreateMap = %d, want %d", tracker.ActualCost(), cost.ListCreateBaseCost+cost.MapCreateBaseCost)
+ }
+
+ tracker.CreateStruct(3, nil)
+ if tracker.ActualCost() != cost.ListCreateBaseCost+cost.MapCreateBaseCost+cost.StructCreateBaseCost {
+ t.Errorf("ActualCost() after CreateStruct = %d, want %d", tracker.ActualCost(), cost.ListCreateBaseCost+cost.MapCreateBaseCost+cost.StructCreateBaseCost)
+ }
+
+ tracker.EvalAttribute(4, false, nil)
+ if tracker.ActualCost() != cost.ListCreateBaseCost+cost.MapCreateBaseCost+cost.StructCreateBaseCost+cost.SelectAndIdentCost {
+ t.Errorf("ActualCost() after EvalAttribute = %d, want %d", tracker.ActualCost(), cost.ListCreateBaseCost+cost.MapCreateBaseCost+cost.StructCreateBaseCost+cost.SelectAndIdentCost)
+ }
+
+ tracker.Qualify(5)
+ if tracker.ActualCost() != cost.ListCreateBaseCost+cost.MapCreateBaseCost+cost.StructCreateBaseCost+cost.SelectAndIdentCost+1 {
+ t.Errorf("ActualCost() after Qualify = %d, want %d", tracker.ActualCost(), cost.ListCreateBaseCost+cost.MapCreateBaseCost+cost.StructCreateBaseCost+cost.SelectAndIdentCost+1)
+ }
+
+ if !tracker.PresenceTestHasCost() {
+ t.Errorf("PresenceTestHasCost() = false, want true")
+ }
+}
+
+func TestCostTrackerLimit(t *testing.T) {
+ var exceeded bool
+ tracker, err := cost.NewTracker(nil,
+ cost.TrackerLimit(15),
+ cost.TrackerLimitExceededHandler(func() {
+ exceeded = true
+ }),
+ )
+ if err != nil {
+ t.Fatalf("NewCostTracker() failed: %v", err)
+ }
+
+ tracker.CreateList(1, nil) // cost = 10 <= 15
+ if exceeded {
+ t.Errorf("exceeded = true, want false")
+ }
+
+ defer func() {
+ r := recover()
+ if r == nil {
+ t.Fatalf("expected panic on cost limit exceeded")
+ }
+ if !exceeded {
+ t.Errorf("exceeded handler was not called")
+ }
+ }()
+
+ tracker.CreateList(2, nil) // cost = 20 > 15 -> panic
+}
+
+func TestCostTrackerOverloadTracker(t *testing.T) {
+ tracker, err := cost.NewTracker(nil,
+ cost.OverloadTracker("custom_op", func(args []ref.Val, result ref.Val) *uint64 {
+ c := uint64(42)
+ return &c
+ }),
+ )
+ if err != nil {
+ t.Fatalf("NewCostTracker() failed: %v", err)
+ }
+
+ call := testCall{function: "custom", overloadID: "custom_op"}
+ tracker.EvalZeroArity(nil, 1, call, types.IntZero)
+ if tracker.ActualCost() != 42 {
+ t.Errorf("ActualCost() = %d, want 42", tracker.ActualCost())
+ }
+}
+
+func TestCostTrackerClone(t *testing.T) {
+ tracker, err := cost.NewTracker(nil)
+ if err != nil {
+ t.Fatalf("NewCostTracker() failed: %v", err)
+ }
+ tracker.Qualify(1)
+
+ clone, err := tracker.Clone()
+ if err != nil {
+ t.Fatalf("Clone() failed: %v", err)
+ }
+ if clone.ActualCost() != 0 {
+ t.Errorf("clone.ActualCost() = %d, want 0", clone.ActualCost())
+ }
+
+ clone.Qualify(2)
+ if clone.ActualCost() != 1 {
+ t.Errorf("clone.ActualCost() = %d, want 1", clone.ActualCost())
+ }
+ if tracker.ActualCost() != 1 {
+ t.Errorf("tracker.ActualCost() = %d, want 1", tracker.ActualCost())
+ }
+}
+
+func TestCostTrackerStandardFunctions(t *testing.T) {
+ tracker, err := cost.NewTracker(nil)
+ if err != nil {
+ t.Fatalf("NewCostTracker() failed: %v", err)
+ }
+
+ // StartsWith
+ tracker.EvalBinary(nil, 1, testCall{function: "startsWith", overloadID: overloads.StartsWithString}, types.String("hello world"), types.String("hello"), types.True)
+ // cost.ActualSize("hello") = 5. cost = ceil(5 * 0.1) = 1.
+ if tracker.ActualCost() != 1 {
+ t.Errorf("ActualCost() after startsWith = %d, want 1", tracker.ActualCost())
+ }
+}
+
+func TestTrackCostAdvanced(t *testing.T) {
+ var equalCases = []struct {
+ in any
+ lhsExpr string
+ rhsExpr string
+ }{
+ {
+ lhsExpr: `1`,
+ rhsExpr: `2`,
+ },
+ {
+ lhsExpr: `"abc".contains("d")`,
+ rhsExpr: `"def".contains("d")`,
+ },
+ {
+ lhsExpr: `1 in [4, 5, 6]`,
+ rhsExpr: `2 in [15, 17, 16]`,
+ },
+ }
+ for _, tc := range equalCases {
+ t.Run(tc.lhsExpr+" vs "+tc.rhsExpr, func(t *testing.T) {
+ ctx := constructActivation(t, tc.in)
+ lhsCost, _, err := computeCost(t, tc.lhsExpr, nil, ctx, nil)
+ if err != nil {
+ t.Fatalf("Interpreter.Eval(activation interpreter.Activation) failed to eval expression due: %v", err)
+ }
+ rhsCost, _, err := computeCost(t, tc.rhsExpr, nil, ctx, nil)
+ if err != nil {
+ t.Fatalf("Interpreter.Eval(activation interpreter.Activation) failed to eval expression due: %v", err)
+ }
+ if lhsCost != rhsCost {
+ t.Errorf(`Interpreter.Eval(activation interpreter.Activation) failed return a cost for %s of %d equal to a cost for %s of %d`,
+ tc.lhsExpr, lhsCost, tc.rhsExpr, rhsCost)
+ }
+ })
+
+ }
+ var smallerCases = []struct {
+ in any
+ lhsExpr string
+ rhsExpr string
+ }{
+ {
+ lhsExpr: `1`,
+ rhsExpr: `1 + 2`,
+ },
+ {
+ lhsExpr: `"abc".contains("d")`,
+ rhsExpr: `"abcdhdflsfiehfieubdkwjbdwgxvuyagwsdwdnw qdbgquyidvbwqi".contains("e")`,
+ },
+ {
+ lhsExpr: `1 in [4, 5, 6]`,
+ rhsExpr: `1 in [4, 5, 6, 7, 8, 9]`,
+ },
+ }
+ for _, tc := range smallerCases {
+ t.Run(tc.lhsExpr+" vs "+tc.rhsExpr, func(t *testing.T) {
+ ctx := constructActivation(t, tc.in)
+ lhsCost, _, err := computeCost(t, tc.lhsExpr, nil, ctx, nil)
+ if err != nil {
+ t.Fatalf("Interpreter.Eval(activation interpreter.Activation) failed to eval expression due: %v", err)
+ }
+ rhsCost, _, err := computeCost(t, tc.rhsExpr, nil, ctx, nil)
+ if err != nil {
+ t.Fatalf("Interpreter.Eval(activation interpreter.Activation) failed to eval expression due: %v", err)
+ }
+ if lhsCost >= rhsCost {
+ t.Errorf(`Interpreter.Eval(activation interpreter.Activation) failed return a cost for %s of %d less than the cost for %s of %d`,
+ tc.lhsExpr, lhsCost, tc.rhsExpr, rhsCost)
+ }
+ })
+ }
+}
+
+func computeCost(t *testing.T, expr string, vars []*decls.VariableDecl, ctx interpreter.Activation, options []cost.TrackerOption) (actualCost uint64, est cost.CostEstimate, err error) {
+ t.Helper()
+
+ s := common.NewTextSource(expr)
+ p, err := parser.NewParser(parser.Macros(parser.AllMacros...))
+ if err != nil {
+ t.Fatalf("Failed to initialize parser: %v", err)
+ }
+ parsed, errs := p.Parse(s)
+ if len(errs.GetErrors()) != 0 {
+ t.Fatalf(`Failed to Parse expression "%s", error: %v`, expr, errs.GetErrors())
+ }
+
+ cont := containers.DefaultContainer
+ reg := newTestRegistry(t, types.ProtoTypeDefs(&proto3pb.TestAllTypes{}))
+ attrs := interpreter.NewAttributeFactory(cont, reg, reg)
+ env := newTestEnv(t, cont, reg)
+ err = env.AddIdents(vars...)
+ if err != nil {
+ t.Fatalf("Failed to initialize env: %v", err)
+ }
+ costTracker, err := cost.NewTracker(&testRuntimeCostEstimator{}, options...)
+ if err != nil {
+ t.Fatalf("cost.NewCostTracker() failed: %v", err)
+ }
+ costTracker, err = costTracker.Clone()
+ if err != nil {
+ t.Fatalf("checker.Clone() failed: %v", err)
+ }
+ checked, errs := checker.Check(parsed, s, env)
+ if len(errs.GetErrors()) != 0 {
+ t.Fatalf(`Failed to check expression "%s", error: %v`, expr, errs.GetErrors())
+ }
+ est, err = cost.Cost(checked, testTrackerCostEstimator{}, cost.PresenceTestHasCost(costTracker.PresenceTestHasCost()))
+ if err != nil {
+ t.Fatalf("cost.Cost() failed: %v", err)
+ }
+ interp := newStandardInterpreter(t, cont, reg, reg, attrs)
+ prg, err := interp.NewInterpretable(checked,
+ interpreter.CostObserver(interpreter.CostTrackerFactory(func() (*cost.Tracker, error) {
+ return costTracker, nil
+ })))
+ if err != nil {
+ t.Fatalf(`Failed to check expression "%s", error: %v`, expr, errs.GetErrors())
+ }
+
+ defer func() {
+ if r := recover(); r != nil {
+ switch t := r.(type) {
+ case interpreter.EvalCancelledError:
+ err = t
+ default:
+ err = fmt.Errorf("internal error: %v", r)
+ }
+ }
+ }()
+ frame := interpreter.AsFrame(ctx)
+ prg.Exec(frame)
+ // TODO: enable this once all attributes are properly pushed and popped from stack.
+ //if len(costTracker.stack) != 1 {
+ // t.Fatalf(`Expected resulting stack size to be 1 but got %d: %#+v`, len(costTracker.stack), costTracker.stack)
+ //}
+ return costTracker.ActualCost(), est, err
+}
+
+func constructActivation(t testing.TB, in any) interpreter.Activation {
+ t.Helper()
+ if in == nil {
+ return interpreter.EmptyActivation()
+ }
+ a, err := interpreter.NewActivation(in)
+ if err != nil {
+ t.Fatalf("interpreter.NewActivation(%v) failed: %v", in, err)
+ }
+ return a
+}
+
+const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+
+func randSeq(n int) []byte {
+ b := make([]byte, n)
+ for i := range b {
+ b[i] = letterBytes[rand.Intn(len(letterBytes))]
+ }
+ return b
+}
+
+type testRuntimeCostEstimator struct {
+}
+
+var timeToYearCost uint64 = 7
+
+func (e testRuntimeCostEstimator) CallCost(function, overloadID string, args []ref.Val, result ref.Val) *uint64 {
+ argsSize := make([]uint64, len(args))
+ for i, arg := range args {
+ reflectV := reflect.ValueOf(arg.Value())
+ switch reflectV.Kind() {
+ // Note that the CEL bytes type is implemented with Go byte slices, therefore also supported by the following
+ // code.
+ case reflect.String, reflect.Array, reflect.Slice, reflect.Map:
+ argsSize[i] = uint64(reflectV.Len())
+ default:
+ argsSize[i] = 1
+ }
+ }
+
+ switch overloadID {
+ case overloads.TimestampToYear:
+ return &timeToYearCost
+ default:
+ return nil
+ }
+}
+
+type testTrackerCostEstimator struct {
+ hints map[string]int64
+}
+
+func (tc testTrackerCostEstimator) EstimateSize(element cost.AstNode) *cost.SizeEstimate {
+ if l, ok := tc.hints[strings.Join(element.Path(), ".")]; ok {
+ return &cost.SizeEstimate{Min: 0, Max: uint64(l)}
+ }
+ return nil
+}
+
+func (tc testTrackerCostEstimator) EstimateCallCost(function, overloadID string, target *cost.AstNode, args []cost.AstNode) *cost.CallEstimate {
+ switch overloadID {
+ case overloads.TimestampToYear:
+ return &cost.CallEstimate{CostEstimate: cost.FixedCostEstimate(7)}
+ }
+ return nil
+}
+
+func TestRuntimeCost(t *testing.T) {
+ allTypes := types.NewObjectType("google.expr.proto3.test.TestAllTypes")
+ allList := types.NewListType(allTypes)
+ intList := types.NewListType(types.IntType)
+ nestedList := types.NewListType(allList)
+
+ allMap := types.NewMapType(types.StringType, allTypes)
+ nestedMap := types.NewMapType(types.StringType, allMap)
+ cases := []struct {
+ name string
+ expr string
+ vars []*decls.VariableDecl
+ want uint64
+ in any
+ testFuncCost bool
+ limit uint64
+ options []cost.TrackerOption
+
+ expectExceedsLimit bool
+ }{
+ {
+ name: "const",
+ expr: `"Hello World!"`,
+ want: 0,
+ },
+ {
+ name: "identity",
+ expr: `input`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", intList)},
+ want: 1,
+ in: map[string]any{"input": []int{1, 2}},
+ },
+ {
+ name: "select: map",
+ expr: `input['key']`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewMapType(types.StringType, types.StringType))},
+ want: 2,
+ in: map[string]any{"input": map[string]string{"key": "v"}},
+ },
+ {
+ name: "select: array index",
+ expr: `input[0]`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewListType(types.StringType))},
+ want: 2,
+ in: map[string]any{"input": []string{"v"}},
+ },
+ {
+ name: "select: field",
+ expr: `input.single_int32`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", allTypes)},
+ want: 2,
+ in: map[string]any{
+ "input": &proto3pb.TestAllTypes{
+ RepeatedBool: []bool{false},
+ MapInt64NestedType: map[int64]*proto3pb.NestedTestAllTypes{
+ 1: {},
+ },
+ MapStringString: map[string]string{},
+ },
+ },
+ },
+ {
+ name: "expr select: map",
+ expr: `input['ke' + 'y']`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewMapType(types.StringType, types.StringType))},
+ want: 3,
+ in: map[string]any{"input": map[string]string{"key": "v"}},
+ },
+ {
+ name: "expr select: array index",
+ expr: `input[3-3]`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewListType(types.StringType))},
+ want: 3,
+ in: map[string]any{"input": []string{"v"}},
+ },
+ {
+ name: "select: field test only no has() cost",
+ expr: `has(input.single_int32)`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewObjectType("google.expr.proto3.test.TestAllTypes"))},
+ want: 1,
+ options: []cost.TrackerOption{cost.TrackerPresenceTestHasCost(false)},
+ in: map[string]any{
+ "input": &proto3pb.TestAllTypes{
+ RepeatedBool: []bool{false},
+ MapInt64NestedType: map[int64]*proto3pb.NestedTestAllTypes{
+ 1: {},
+ },
+ MapStringString: map[string]string{},
+ },
+ },
+ },
+ {
+ name: "select: field test only",
+ expr: `has(input.single_int32)`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewObjectType("google.expr.proto3.test.TestAllTypes"))},
+ want: 2,
+ in: map[string]any{
+ "input": &proto3pb.TestAllTypes{
+ RepeatedBool: []bool{false},
+ MapInt64NestedType: map[int64]*proto3pb.NestedTestAllTypes{
+ 1: {},
+ },
+ MapStringString: map[string]string{},
+ },
+ },
+ },
+ {
+ name: "select: non-proto field test has() cost",
+ expr: `has(input.testAttr.nestedAttr)`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", nestedMap)},
+ want: 3,
+ options: []cost.TrackerOption{cost.TrackerPresenceTestHasCost(true)},
+ in: map[string]any{
+ "input": map[string]any{
+ "testAttr": map[string]any{
+ "nestedAttr": "0",
+ },
+ },
+ },
+ },
+ {
+ name: "select: non-proto field test no has() cost",
+ expr: `has(input.testAttr.nestedAttr)`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", nestedMap)},
+ want: 2,
+ options: []cost.TrackerOption{cost.TrackerPresenceTestHasCost(false)},
+ in: map[string]any{
+ "input": map[string]any{
+ "testAttr": map[string]any{
+ "nestedAttr": "0",
+ },
+ },
+ },
+ },
+ {
+ name: "select: non-proto field test",
+ expr: `has(input.testAttr.nestedAttr)`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", nestedMap)},
+ want: 3,
+ in: map[string]any{
+ "input": map[string]any{
+ "testAttr": map[string]any{
+ "nestedAttr": "0",
+ },
+ },
+ },
+ },
+ {
+ name: "estimated function call",
+ expr: `input.getFullYear()`,
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.TimestampType)},
+ want: 8,
+ in: map[string]any{"input": time.Now()},
+ testFuncCost: true,
+ },
+ {
+ name: "create list",
+ expr: `[1, 2, 3]`,
+ want: 10,
+ },
+ {
+ name: "create struct",
+ expr: `google.expr.proto3.test.TestAllTypes{single_int32: 1, single_float: 3.14, single_string: 'str'}`,
+ want: 40,
+ },
+ {
+ name: "create map",
+ expr: `{"a": 1, "b": 2, "c": 3}`,
+ want: 30,
+ },
+ {
+ name: "all comprehension",
+ vars: []*decls.VariableDecl{decls.NewVariable("input", allList)},
+ expr: `input.all(x, true)`,
+ want: 2,
+ in: map[string]any{
+ "input": []*proto3pb.TestAllTypes{},
+ },
+ },
+ {
+ name: "nested all comprehension",
+ vars: []*decls.VariableDecl{decls.NewVariable("input", nestedList)},
+ expr: `input.all(x, x.all(y, true))`,
+ want: 2,
+ in: map[string]any{
+ "input": []*proto3pb.TestAllTypes{},
+ },
+ },
+ {
+ name: "all comprehension on literal",
+ expr: `[1, 2, 3].all(x, true)`,
+ want: 20,
+ },
+ {
+ name: "variable cost function",
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.StringType)},
+ expr: `input.matches('[0-9]')`,
+ want: 103,
+ in: map[string]any{"input": string(randSeq(500))},
+ },
+ {
+ name: "variable cost function with constant",
+ expr: `'123'.matches('[0-9]')`,
+ want: 2,
+ },
+ {
+ name: "or",
+ expr: `false || false`,
+ want: 0,
+ },
+ {
+ name: "or short-circuit",
+ expr: `true || false`,
+ want: 0,
+ },
+
+ {
+ name: "or accumulated branch cost",
+ expr: `a || b || c || d`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("a", types.BoolType),
+ decls.NewVariable("b", types.BoolType),
+ decls.NewVariable("c", types.BoolType),
+ decls.NewVariable("d", types.BoolType),
+ },
+ in: map[string]any{
+ "a": false,
+ "b": false,
+ "c": false,
+ "d": false,
+ },
+ want: 4,
+ },
+ {
+ name: "and",
+ expr: `true && false`,
+ want: 0,
+ },
+ {
+ name: "and short-circuit",
+ expr: `false && true`,
+ want: 0,
+ },
+ {
+ name: "and accumulated branch cost",
+ expr: `a && b && c && d`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("a", types.BoolType),
+ decls.NewVariable("b", types.BoolType),
+ decls.NewVariable("c", types.BoolType),
+ decls.NewVariable("d", types.BoolType),
+ },
+ in: map[string]any{
+ "a": true,
+ "b": true,
+ "c": true,
+ "d": true,
+ },
+ want: 4,
+ },
+ {
+ name: "lt",
+ expr: `1 < 2`,
+ want: 1,
+ },
+ {
+ name: "lte",
+ expr: `1 <= 2`,
+ want: 1,
+ },
+ {
+ name: "eq",
+ expr: `1 == 2`,
+ want: 1,
+ },
+ {
+ name: "gt",
+ expr: `2 > 1`,
+ want: 1,
+ },
+ {
+ name: "gte",
+ expr: `2 >= 1`,
+ want: 1,
+ },
+ {
+ name: "in",
+ expr: `2 in [1, 2, 3]`,
+ want: 13,
+ },
+ {
+ name: "plus",
+ expr: `1 + 1`,
+ want: 1,
+ },
+ {
+ name: "minus",
+ expr: `1 - 1`,
+ want: 1,
+ },
+ {
+ name: "/",
+ expr: `1 / 1`,
+ want: 1,
+ },
+ {
+ name: "/",
+ expr: `1 * 1`,
+ want: 1,
+ },
+ {
+ name: "%",
+ expr: `1 % 1`,
+ want: 1,
+ },
+ {
+ name: "ternary",
+ expr: `true ? 1 : 2`,
+ want: 0,
+ },
+ {
+ name: "string size",
+ expr: `size("123")`,
+ want: 1,
+ },
+ {
+ name: "str eq str",
+ expr: `'12345678901234567890' == '123456789012345678901234567890'`,
+ want: 2,
+ },
+ {
+ name: "bytes to string conversion",
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.BytesType)},
+ expr: `string(input)`,
+ want: 51,
+ in: map[string]any{"input": randSeq(500)},
+ },
+ {
+ name: "string to bytes conversion",
+ vars: []*decls.VariableDecl{decls.NewVariable("input", types.StringType)},
+ expr: `bytes(input)`,
+ want: 51,
+ in: map[string]any{"input": string(randSeq(500))},
+ },
+ {
+ name: "int to string conversion",
+ expr: `string(1)`,
+ want: 1,
+ },
+ {
+ name: "contains",
+ expr: `input.contains(arg1)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ decls.NewVariable("arg1", types.StringType),
+ },
+ want: 2502,
+ in: map[string]any{"input": string(randSeq(500)), "arg1": string(randSeq(500))},
+ },
+ {
+ name: "matches",
+ expr: `input.matches('\\d+a\\d+b')`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ },
+ want: 103,
+ in: map[string]any{"input": string(randSeq(500)), "arg1": string(randSeq(500))},
+ },
+ {
+ name: "matches global",
+ expr: `matches(input, '\\d+a\\d+b')`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ },
+ want: 103,
+ in: map[string]any{"input": string(randSeq(500))},
+ },
+ {
+ name: "startsWith",
+ expr: `input.startsWith(arg1)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ decls.NewVariable("arg1", types.StringType),
+ },
+ want: 52,
+ in: map[string]any{"input": "idc", "arg1": string(randSeq(500))},
+ },
+ {
+ name: "endsWith",
+ expr: `input.endsWith(arg1)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ decls.NewVariable("arg1", types.StringType),
+ },
+ want: 52,
+ in: map[string]any{"input": "idc", "arg1": string(randSeq(500))},
+ },
+ {
+ name: "size receiver",
+ expr: `input.size()`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ },
+ want: 2,
+ in: map[string]any{"input": "500", "arg1": "500"},
+ },
+ {
+ name: "size",
+ expr: `size(input)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", types.StringType),
+ },
+ want: 2,
+ in: map[string]any{"input": "500", "arg1": "500"},
+ },
+ {
+ name: "ternary eval",
+ expr: `(x > 2 ? input1 : input2).all(y, true)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("x", types.IntType),
+ decls.NewVariable("input1", allList),
+ decls.NewVariable("input2", allList),
+ },
+ want: 6,
+ in: map[string]any{"input1": []*proto3pb.TestAllTypes{{}}, "input2": []*proto3pb.TestAllTypes{{}}, "x": 1},
+ },
+ {
+ name: "ternary eval trivial, true",
+ expr: `true ? false : 1 > 3`,
+ want: 0,
+ in: map[string]any{},
+ },
+ {
+ name: "ternary eval trivial, false",
+ expr: `false ? false : 1 > 3`,
+ want: 1,
+ in: map[string]any{},
+ },
+ {
+ name: "comprehension over map",
+ expr: `input.all(k, input[k].single_int32 > 3)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", allMap),
+ },
+ want: 9,
+ in: map[string]any{"input": map[string]any{"val": &proto3pb.TestAllTypes{}}},
+ },
+ {
+ name: "comprehension over nested map of maps",
+ expr: `input.all(k, input[k].all(x, true))`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", nestedMap),
+ },
+ want: 2,
+ in: map[string]any{"input": map[string]any{}},
+ },
+ {
+ name: "string size of map keys",
+ expr: `input.all(k, k.contains(k))`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", nestedMap),
+ },
+ want: 2,
+ in: map[string]any{"input": map[string]any{}},
+ },
+ {
+ name: "comprehension variable shadowing",
+ expr: `input.all(k, input[k].all(k, true) && k.contains(k))`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", nestedMap),
+ },
+ want: 2,
+ in: map[string]any{"input": map[string]any{}},
+ },
+ {
+ name: "comprehension variable shadowing",
+ expr: `input.all(k, input[k].all(k, true) && k.contains(k))`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("input", nestedMap),
+ },
+ want: 2,
+ in: map[string]any{"input": map[string]any{}},
+ },
+ {
+ name: "list concat",
+ expr: `(list1 + list2).all(x, true)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("list1", types.NewListType(types.IntType)),
+ decls.NewVariable("list2", types.NewListType(types.IntType)),
+ },
+ want: 4,
+ in: map[string]any{"list1": []int{}, "list2": []int{}},
+ },
+ {
+ name: "str concat",
+ expr: `"abcdefg".contains(str1 + str2)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("str1", types.StringType),
+ decls.NewVariable("str2", types.StringType),
+ },
+ want: 6,
+ in: map[string]any{"str1": "val1", "str2": "val2222222"},
+ },
+ {
+ name: "str concat custom cost tracker",
+ expr: `"abcdefg".contains(str1 + str2)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("str1", types.StringType),
+ decls.NewVariable("str2", types.StringType),
+ },
+ options: []cost.TrackerOption{
+ cost.OverloadTracker(overloads.ContainsString,
+ func(args []ref.Val, result ref.Val) *uint64 {
+ strCost := uint64(math.Ceil(float64(cost.ActualSize(args[0])) * 0.2))
+ substrCost := uint64(math.Ceil(float64(cost.ActualSize(args[1])) * 0.2))
+ cost := strCost * substrCost
+ return &cost
+ }),
+ },
+ want: 10,
+ in: map[string]any{"str1": "val1", "str2": "val2222222"},
+ },
+ {
+ name: "at limit",
+ expr: `"abcdefg".contains(str1 + str2)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("str1", types.StringType),
+ decls.NewVariable("str2", types.StringType),
+ },
+ in: map[string]any{"str1": "val1", "str2": "val2222222"},
+ limit: 6,
+ want: 6,
+ },
+ {
+ name: "above limit",
+ expr: `"abcdefg".contains(str1 + str2)`,
+ vars: []*decls.VariableDecl{
+ decls.NewVariable("str1", types.StringType),
+ decls.NewVariable("str2", types.StringType),
+ },
+ in: map[string]any{"str1": "val1", "str2": "val2222222"},
+ limit: 5,
+ expectExceedsLimit: true,
+ },
+ {
+ name: "ternary as operand",
+ expr: `(1 > 2 ? 5 : 3) > 1`,
+ vars: []*decls.VariableDecl{},
+ in: map[string]any{},
+ want: 2,
+ },
+ {
+ name: "ternary as operand",
+ expr: `(1 > 2 || 2 > 1) == true`,
+ vars: []*decls.VariableDecl{},
+ in: map[string]any{},
+ want: 3,
+ },
+ {
+ name: "list map literal",
+ expr: `[{'k1': 1}, {'k2': 2}].all(x, true)`,
+ vars: []*decls.VariableDecl{},
+ in: map[string]any{},
+ want: 77,
+ },
+ {
+ name: "list map literal",
+ expr: `[{'k1': 1}, {'k2': 2}].all(x, true)`,
+ vars: []*decls.VariableDecl{},
+ in: map[string]any{},
+ want: 77,
+ },
+ {
+ name: ".filter list literal",
+ expr: `[1,2,3,4,5].filter(x, x % 2 == 0)`,
+ vars: []*decls.VariableDecl{},
+ in: map[string]any{},
+ want: 62,
+ },
+ {
+ name: ".map list literal",
+ expr: `[1,2,3,4,5].map(x, x)`,
+ vars: []*decls.VariableDecl{},
+ in: map[string]any{},
+ want: 86,
+ },
+ {
+ name: ".map.filter list literal",
+ expr: `[1,2,3,4,5].map(x, x).filter(x, x % 2 == 0)`,
+ vars: []*decls.VariableDecl{},
+ in: map[string]any{},
+ want: 138,
+ },
+ {
+ name: ".map.exists list literal",
+ expr: `[1,2,3,4,5].map(x, x).exists(x, x == 5) == true`,
+ vars: []*decls.VariableDecl{},
+ in: map[string]any{},
+ want: 118,
+ },
+ {
+ name: ".map.map list literal",
+ expr: `[1,2,3,4,5].map(x, x).map(x, x)`,
+ vars: []*decls.VariableDecl{},
+ in: map[string]any{},
+ want: 162,
+ },
+ {
+ name: ".map.map list literal",
+ expr: `[1,2,3,4,5].map(x, [x, x]).filter(z, z.size() == 2)`,
+ vars: []*decls.VariableDecl{},
+ in: map[string]any{},
+ want: 232,
+ },
+ {
+ name: "comprehension on nested list",
+ expr: `[1,2,3,4,5].map(x, [x, x]).all(y, y.all(y, y == 1))`,
+ want: 171,
+ },
+ {
+ name: "comprehension size",
+ expr: `[1,2,3,4,5].map(x, x).map(x, x) + [1]`,
+ want: 173,
+ },
+ {
+ name: "nested comprehension",
+ expr: `[1,2,3].all(i, i in [1,2,3].map(j, j + j))`,
+ want: 86,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := constructActivation(t, tc.in)
+ var costLimit *uint64
+ if tc.limit > 0 {
+ costLimit = &tc.limit
+ }
+ options := tc.options
+ if costLimit != nil {
+ options = append(options, cost.TrackerLimit(*costLimit))
+ }
+ actualCost, est, err := computeCost(t, tc.expr, tc.vars, ctx, options)
+ if err != nil {
+ if tc.expectExceedsLimit {
+ return
+ }
+ t.Fatalf("Interpreter.Eval(activation interpreter.Activation) failed due to: %v", err)
+ }
+ if tc.expectExceedsLimit {
+ t.Fatalf("Interpreter.Eval(activation interpreter.Activation) failed to return a cost exceeded error for limit %d, got cost %d", tc.limit, actualCost)
+ }
+ if actualCost != tc.want {
+ t.Fatalf("Interpreter.Eval(activation interpreter.Activation) failed to return expected runtime cost %d, got %d", tc.want, actualCost)
+ }
+ if est.Min > actualCost || est.Max < actualCost {
+ t.Fatalf("Interpreter.Eval(activation interpreter.Activation) failed to return cost in range of estimate cost [%d, %d], got %d",
+ est.Min, est.Max, actualCost)
+ }
+ })
+ }
+}
+
+func BenchmarkCostTracking(b *testing.B) {
+ benchmarks := []struct {
+ name string
+ expr string
+ vars []*decls.VariableDecl
+ in map[string]any
+ }{
+ {
+ name: "simple_comparison",
+ expr: "x > 10",
+ vars: []*decls.VariableDecl{decls.NewVariable("x", types.IntType)},
+ in: map[string]any{"x": 15},
+ },
+ {
+ name: "function_calls",
+ expr: "str.startsWith('hello') && str.endsWith('world')",
+ vars: []*decls.VariableDecl{decls.NewVariable("str", types.StringType)},
+ in: map[string]any{"str": "hello beautiful world"},
+ },
+ {
+ name: "comprehension",
+ expr: "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(x, x * 2).filter(x, x > 10)",
+ },
+ {
+ name: "nested_comprehensions",
+ expr: "[1, 2, 3, 4, 5].all(i, [1, 2, 3, 4, 5].exists(j, i + j == 6))",
+ },
+ }
+
+ for _, bm := range benchmarks {
+ b.Run(bm.name, func(b *testing.B) {
+ s := common.NewTextSource(bm.expr)
+ p, err := parser.NewParser(parser.Macros(parser.AllMacros...))
+ if err != nil {
+ b.Fatalf("Failed to initialize parser: %v", err)
+ }
+ parsed, errs := p.Parse(s)
+ if len(errs.GetErrors()) != 0 {
+ b.Fatalf("Parse(%s) failed: %v", bm.expr, errs.GetErrors())
+ }
+
+ cont := containers.DefaultContainer
+ reg := newTestRegistry(b, types.ProtoTypeDefs(&proto3pb.TestAllTypes{}))
+ attrs := interpreter.NewAttributeFactory(cont, reg, reg)
+ env := newTestEnv(b, cont, reg)
+ if len(bm.vars) > 0 {
+ err = env.AddIdents(bm.vars...)
+ if err != nil {
+ b.Fatalf("Failed to add idents: %v", err)
+ }
+ }
+ checked, errs := checker.Check(parsed, s, env)
+ if len(errs.GetErrors()) != 0 {
+ b.Fatalf("Check(%s) failed: %v", bm.expr, errs.GetErrors())
+ }
+
+ evalCostTracker, err := cost.NewTracker(nil)
+ if err != nil {
+ b.Fatalf("cost.NewCostTracker() failed: %v", err)
+ }
+ trackerFactory := func() (*cost.Tracker, error) {
+ return evalCostTracker.Clone()
+ }
+ interp := newStandardInterpreter(b, cont, reg, reg, attrs)
+ prg, err := interp.NewInterpretable(checked, interpreter.CostObserver(interpreter.CostTrackerFactory(trackerFactory)))
+ if err != nil {
+ b.Fatalf("NewInterpretable(%s) failed: %v", bm.expr, err)
+ }
+
+ ctx := constructActivation(b, bm.in)
+ b.ResetTimer()
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ prg.Eval(ctx)
+ }
+ })
+ }
+}
+
+func newTestEnv(t testing.TB, cont *containers.Container, reg *types.Registry) *checker.Env {
+ t.Helper()
+ env, err := checker.NewEnv(cont, reg, checker.CrossTypeNumericComparisons(true))
+ if err != nil {
+ t.Fatalf("checker.NewEnv(%v, %v) failed: %v", cont, reg, err)
+ }
+ err = env.AddFunctions(stdlib.Functions()...)
+ if err != nil {
+ t.Fatalf("env.Add(stdlib.Functions()...) failed: %v", err)
+ }
+ return env
+}
+
+func newTestRegistry(t testing.TB, opts ...types.RegistryOption) *types.Registry {
+ t.Helper()
+ var o []any
+ for _, opt := range opts {
+ o = append(o, opt)
+ }
+ reg, err := types.NewRegistry(o...)
+ if err != nil {
+ t.Fatalf("types.NewRegistry() failed: %v", err)
+ }
+ return reg
+}
+
+func newStandardInterpreter(t testing.TB,
+ container *containers.Container,
+ provider types.Provider,
+ adapter types.Adapter,
+ resolver interpreter.AttributeFactory,
+ optFuncs ...*decls.FunctionDecl) interpreter.Interpreter {
+ t.Helper()
+ disp := interpreter.NewDispatcher()
+ for _, fn := range stdlib.Functions() {
+ bindings, err := fn.Bindings()
+ if err != nil {
+ t.Fatalf("fn.Bindings() failed for function %v. error: %v", fn.Name(), err)
+ }
+ err = disp.Add(bindings...)
+ if err != nil {
+ t.Fatalf("dispatcher.Add() failed: %v", err)
+ }
+ }
+ for _, fn := range optFuncs {
+ bindings, err := fn.Bindings()
+ if err != nil {
+ t.Fatalf("fn.Bindings() failed for function %v. error: %v", fn.Name(), err)
+ }
+ err = disp.Add(bindings...)
+ if err != nil {
+ t.Fatalf("dispatcher.Add() failed: %v", err)
+ }
+ }
+ return interpreter.NewInterpreter(disp, container, provider, adapter, resolver)
+}
diff --git a/common/types/pb/equal.go b/common/types/pb/equal.go
index 76893d85e..275a2a61c 100644
--- a/common/types/pb/equal.go
+++ b/common/types/pb/equal.go
@@ -29,10 +29,10 @@ import (
//
// - Messages must share the same instance of the type descriptor
// - Known set fields are compared using semantics equality
-// - Bytes are compared using bytes.Equal
-// - Scalar values are compared with operator ==
-// - List and map types are equal if they have the same length and all elements are equal
-// - Messages are equal if they share the same descriptor and all set fields are equal
+// - Bytes are compared using bytes.Equal
+// - Scalar values are compared with operator ==
+// - List and map types are equal if they have the same length and all elements are equal
+// - Messages are equal if they share the same descriptor and all set fields are equal
// - Unknown fields are compared using byte equality
// - NaN values are not equal to each other
// - google.protobuf.Any values are unpacked before comparison
diff --git a/ext/costs.go b/ext/costs.go
index 3a4209ef4..add7ede2f 100644
--- a/ext/costs.go
+++ b/ext/costs.go
@@ -15,92 +15,56 @@
package ext
import (
- "math"
-
- "cel.dev/cel-go/checker"
- "cel.dev/cel-go/common"
- "cel.dev/cel-go/common/ast"
- "cel.dev/cel-go/common/types"
+ "cel.dev/cel-go/common/cost"
"cel.dev/cel-go/common/types/ref"
- "cel.dev/cel-go/common/types/traits"
)
var (
- callCostEstimate = checker.FixedCostEstimate(1)
- callCost = uint64(1)
- listAllocCost = checker.FixedCostEstimate(common.ListCreateBaseCost)
- stringCostFactor = common.StringTraversalCostFactor
+ callCostEstimate = cost.CallCostEstimate
+ callCost = cost.CallCost
+ listAllocCost = cost.ListAllocCost
+ stringCostFactor = cost.StringCostFactor
)
-func estimateStringScan(sz checker.SizeEstimate) (checker.CostEstimate, *checker.SizeEstimate) {
- return estimateTraversal(sz, stringCostFactor, nil)
+func estimateStringScan(sz cost.SizeEstimate) (cost.CostEstimate, *cost.SizeEstimate) {
+ return cost.EstimateStringScan(sz)
}
-func estimateListAlloc(sz checker.SizeEstimate, costFactor float64) (checker.CostEstimate, *checker.SizeEstimate) {
- return estimateTraversal(sz, costFactor, &listAllocCost)
+func estimateListAlloc(sz cost.SizeEstimate, costFactor float64) (cost.CostEstimate, *cost.SizeEstimate) {
+ return cost.EstimateListAlloc(sz, costFactor)
}
// estimateTraversal computes cost as a function of the size of the target object and whether the call allocates memory.
-func estimateTraversal(nodeSize checker.SizeEstimate, costFactor float64, allocationCost *checker.CostEstimate) (checker.CostEstimate, *checker.SizeEstimate) {
- cost := nodeSize.MultiplyByCostFactor(costFactor)
- if allocationCost != nil {
- cost = cost.Add(*allocationCost)
- }
- return cost, &nodeSize
+func estimateTraversal(nodeSize cost.SizeEstimate, costFactor float64, allocationCost *cost.CostEstimate) (cost.CostEstimate, *cost.SizeEstimate) {
+ return cost.EstimateTraversal(nodeSize, costFactor, allocationCost)
}
-func estimateSize(estimator checker.CostEstimator, node checker.AstNode) checker.SizeEstimate {
- if l := node.ComputedSize(); l != nil {
- return *l
- }
- if l := estimator.EstimateSize(node); l != nil {
- return *l
- }
- return checker.SizeEstimate{Min: 0, Max: math.MaxUint64}
+func estimateSize(estimator cost.Estimator, node cost.AstNode) cost.SizeEstimate {
+ return cost.EstimateSize(estimator, node)
}
func actualSize(value ref.Val) uint64 {
- if sz, ok := value.(traits.Sizer); ok {
- return uint64(sz.Size().(types.Int))
- }
- return 1
+ return cost.ActualSize(value)
}
// nodeAsUintValue returns the value of a literal int node as a uint64, or the default value if the
// node is not a non-negative int literal.
-func nodeAsUintValue(node checker.AstNode, defaultVal uint64) uint64 {
- if node.Expr().Kind() != ast.LiteralKind {
- return defaultVal
- }
- lit := node.Expr().AsLiteral()
- if lit.Type() != types.IntType {
- return defaultVal
- }
- val := lit.(types.Int)
- if val < types.IntZero {
- return 0
- }
- return uint64(lit.(types.Int))
+func nodeAsUintValue(node cost.AstNode, defaultVal uint64) uint64 {
+ return cost.NodeAsUintValue(node, defaultVal)
}
-func callEstimate(cost checker.CostEstimate, sz *checker.SizeEstimate) *checker.CallEstimate {
- return &checker.CallEstimate{CostEstimate: cost, ResultSize: sz}
+func callEstimate(c cost.CostEstimate, sz *cost.SizeEstimate) *cost.CallEstimate {
+ return cost.NewCallEstimate(c, sz)
}
-func rangedSizeEstimate(min, max uint64) checker.SizeEstimate {
- return checker.SizeEstimate{Min: min, Max: max}
+func rangedSizeEstimate(min, max uint64) cost.SizeEstimate {
+ return cost.RangedSizeEstimate(min, max)
}
-func fixedSizeEstimate(val uint64) checker.SizeEstimate {
- return checker.FixedSizeEstimate(val)
+func fixedSizeEstimate(val uint64) cost.SizeEstimate {
+ return cost.FixedSizeEstimate(val)
}
-func atLeastOne(size checker.SizeEstimate) checker.SizeEstimate {
- if size.Min == 0 {
- size.Min = 1
- }
- if size.Max == 0 {
- size.Max = 1
- }
- return size
+func atLeastOne(size cost.SizeEstimate) cost.SizeEstimate {
+ return cost.AtLeastOne(size)
}
diff --git a/ext/encoders_test.go b/ext/encoders_test.go
index 05da80126..f078c5b1b 100644
--- a/ext/encoders_test.go
+++ b/ext/encoders_test.go
@@ -336,4 +336,3 @@ func TestJSONEncodeCostUnbounded(t *testing.T) {
t.Errorf("det.ActualCost() got %d, wanted %d", *det.ActualCost(), uint64(math.MaxUint64))
}
}
-
diff --git a/ext/lists.go b/ext/lists.go
index 936625cc5..ce9b862ca 100644
--- a/ext/lists.go
+++ b/ext/lists.go
@@ -21,7 +21,6 @@ import (
"cel.dev/cel-go/cel"
"cel.dev/cel-go/checker"
- "cel.dev/cel-go/common"
"cel.dev/cel-go/common/ast"
"cel.dev/cel-go/common/cost"
"cel.dev/cel-go/common/decls"
@@ -840,12 +839,12 @@ func estimateListCallWithResultSize(costFactor float64, costSize checker.SizeEst
}
// estimateListCallWithDirectCost computes cost using a pre-calculated CostEstimate and a separate result size estimate.
-func estimateListCallWithDirectCost(cost checker.CostEstimate, resultSize checker.SizeEstimate, allocates bool) *checker.CallEstimate {
+func estimateListCallWithDirectCost(costVal checker.CostEstimate, resultSize checker.SizeEstimate, allocates bool) *checker.CallEstimate {
if allocates {
- cost = cost.Add(checker.FixedCostEstimate(common.ListCreateBaseCost))
+ costVal = costVal.Add(checker.FixedCostEstimate(cost.ListCreateBaseCost))
}
- cost = cost.Add(callCostEstimate)
- return &checker.CallEstimate{CostEstimate: cost, ResultSize: &resultSize}
+ costVal = costVal.Add(callCostEstimate)
+ return &checker.CallEstimate{CostEstimate: costVal, ResultSize: &resultSize}
}
// trackListOutputSize computes cost as a function of the size of the result list.
@@ -892,7 +891,7 @@ func trackListSelfCompare(l traits.Lister) *uint64 {
}
elem := l.Get(types.IntZero)
if elem.Type() == types.StringType || elem.Type() == types.BytesType {
- costFactor += common.StringTraversalCostFactor
+ costFactor += cost.StringTraversalCostFactor
}
return trackAllocatingListCall(costFactor, cost.SafeMultiply(sz, sz))
}
@@ -903,7 +902,7 @@ func trackAllocatingListCall(costFactor float64, size uint64) *uint64 {
if costFactor < 0.0 {
costFactor = 1.0
}
- total := cost.SafeAdd(uint64(float64(size)*costFactor), callCost, common.ListCreateBaseCost)
+ total := cost.SafeAdd(uint64(float64(size)*costFactor), callCost, cost.ListCreateBaseCost)
return &total
}
@@ -917,7 +916,7 @@ func estimateListDistinctLegacy(estimator checker.CostEstimator, target *checker
if tType.Kind() == types.ListKind && len(tType.Parameters()) > 0 {
elemType := tType.Parameters()[0]
if elemType.Kind() == types.StringKind || elemType.Kind() == types.BytesKind {
- costFactor += common.StringTraversalCostFactor
+ costFactor += cost.StringTraversalCostFactor
}
}
return estimateAllocatingListCall(costFactor, sz.Multiply(sz))
@@ -946,7 +945,7 @@ func estimateListSortCostLegacy(estimator checker.CostEstimator, node checker.As
costFactor := 2.0
switch elemType {
case types.StringType, types.BytesType:
- costFactor += common.StringTraversalCostFactor
+ costFactor += cost.StringTraversalCostFactor
}
return estimateAllocatingListCall(costFactor, sz.Multiply(sz))
}
@@ -995,7 +994,7 @@ func estimateItemSize(estimator checker.CostEstimator, node checker.AstNode) che
func estimateElementEqualityCost(estimator checker.CostEstimator, elemType *types.Type, itemSize checker.SizeEstimate) checker.CostEstimate {
switch elemType.Kind() {
case types.StringKind, types.BytesKind:
- return itemSize.MultiplyByCostFactor(common.StringTraversalCostFactor)
+ return itemSize.MultiplyByCostFactor(cost.StringTraversalCostFactor)
case types.ListKind, types.MapKind, types.StructKind:
return checker.UnknownCostEstimate()
default:
diff --git a/ext/math_test.go b/ext/math_test.go
index 815d113fc..8b5311a72 100644
--- a/ext/math_test.go
+++ b/ext/math_test.go
@@ -616,7 +616,7 @@ func TestMathVersions(t *testing.T) {
{
version: 2,
supportedFunctions: map[string]string{
- "sqrt": `math.sqrt(25) == 5.0`,
+ "sqrt": `math.sqrt(25) == 5.0`,
},
},
}
diff --git a/ext/regex.go b/ext/regex.go
index cbffa6470..c5ba90b5f 100644
--- a/ext/regex.go
+++ b/ext/regex.go
@@ -24,7 +24,6 @@ import (
"cel.dev/cel-go/cel"
"cel.dev/cel-go/checker"
- "cel.dev/cel-go/common"
"cel.dev/cel-go/common/cost"
"cel.dev/cel-go/common/types"
"cel.dev/cel-go/common/types/ref"
@@ -342,9 +341,9 @@ func estimateExtractCost() checker.FunctionEstimator {
targetSize := estimateSize(c, args[0])
// Fixed size estimate of +1 is added for safety from zero size args.
// The target cost is the size of the target string, scaled by a traversal factor.
- targetCost := targetSize.Add(fixedSizeEstimate(1)).MultiplyByCostFactor(common.StringTraversalCostFactor)
+ targetCost := targetSize.Add(fixedSizeEstimate(1)).MultiplyByCostFactor(cost.StringTraversalCostFactor)
// The regex cost is the size of the regex pattern, scaled by a complexity factor.
- regexCost := estimateSize(c, args[1]).Add(fixedSizeEstimate(1)).MultiplyByCostFactor(common.RegexStringLengthCostFactor)
+ regexCost := estimateSize(c, args[1]).Add(fixedSizeEstimate(1)).MultiplyByCostFactor(cost.RegexStringLengthCostFactor)
// The result is a single string. Worst Case: it's the size of the entire target.
resultSize := rangedSizeEstimate(0, targetSize.Max)
// The total cost is the search cost (target + regex) plus the allocation cost for the result string.
@@ -363,13 +362,13 @@ func estimateExtractAllCost() checker.FunctionEstimator {
targetSize := estimateSize(c, args[0])
// Fixed size estimate of +1 is added for safety from zero size args.
// The target cost is the size of the target string, scaled by a traversal factor.
- targetCost := targetSize.Add(fixedSizeEstimate(1)).MultiplyByCostFactor(common.StringTraversalCostFactor)
+ targetCost := targetSize.Add(fixedSizeEstimate(1)).MultiplyByCostFactor(cost.StringTraversalCostFactor)
// The regex cost is the size of the regex pattern, scaled by a complexity factor.
- regexCost := estimateSize(c, args[1]).Add(fixedSizeEstimate(1)).MultiplyByCostFactor(common.RegexStringLengthCostFactor)
+ regexCost := estimateSize(c, args[1]).Add(fixedSizeEstimate(1)).MultiplyByCostFactor(cost.RegexStringLengthCostFactor)
// The result is a list of strings. Worst Case: it's contents are the size of the entire target.
resultSize := rangedSizeEstimate(0, targetSize.Max)
// The cost to allocate the result list is its base cost plus the size of its contents.
- allocationSize := resultSize.Add(fixedSizeEstimate(common.ListCreateBaseCost))
+ allocationSize := resultSize.Add(fixedSizeEstimate(cost.ListCreateBaseCost))
// The total cost is the search cost (target + regex) plus the allocation cost for the result list.
return callEstimate(
targetCost.Multiply(regexCost).Add(checker.CostEstimate(allocationSize)),
@@ -388,9 +387,9 @@ func estimateReplaceCost() checker.FunctionEstimator {
replacementSize := estimateSize(c, args[2])
// Fixed size estimate of +1 is added for safety from zero size args.
// The target cost is the size of the target string, scaled by a traversal factor.
- targetCost := targetSize.Add(fixedSizeEstimate(1)).MultiplyByCostFactor(common.StringTraversalCostFactor)
+ targetCost := targetSize.Add(fixedSizeEstimate(1)).MultiplyByCostFactor(cost.StringTraversalCostFactor)
// The regex cost is the size of the regex pattern, scaled by a complexity factor.
- regexCost := estimateSize(c, args[1]).Add(fixedSizeEstimate(1)).MultiplyByCostFactor(common.RegexStringLengthCostFactor)
+ regexCost := estimateSize(c, args[1]).Add(fixedSizeEstimate(1)).MultiplyByCostFactor(cost.RegexStringLengthCostFactor)
// Estimate the potential size range of the output string. The final size could be smaller
// (if the replacement size is 0) or larger than the original.
allReplacedSize := targetSize.Max * replacementSize.Max
@@ -412,8 +411,8 @@ func estimateReplaceCost() checker.FunctionEstimator {
func extractCostTracker() interpreter.FunctionTracker {
return func(args []ref.Val, result ref.Val) *uint64 {
- targetCost := float64(cost.SafeAdd(actualSize(args[0]), 1)) * common.StringTraversalCostFactor
- regexCost := float64(cost.SafeAdd(actualSize(args[1]), 1)) * common.RegexStringLengthCostFactor
+ targetCost := float64(cost.SafeAdd(actualSize(args[0]), 1)) * cost.StringTraversalCostFactor
+ regexCost := float64(cost.SafeAdd(actualSize(args[1]), 1)) * cost.RegexStringLengthCostFactor
// Actual search cost calculation = targetCost + regexCost
searchCost := targetCost * regexCost
// The total cost is the base call cost + search cost + result string allocation.
@@ -426,12 +425,12 @@ func extractCostTracker() interpreter.FunctionTracker {
func extractAllCostTracker() interpreter.FunctionTracker {
return func(args []ref.Val, result ref.Val) *uint64 {
- targetCost := float64(actualSize(args[0])+1) * common.StringTraversalCostFactor
- regexCost := float64(actualSize(args[1])+1) * common.RegexStringLengthCostFactor
+ targetCost := float64(actualSize(args[0])+1) * cost.StringTraversalCostFactor
+ regexCost := float64(actualSize(args[1])+1) * cost.RegexStringLengthCostFactor
// Actual search cost calculation = targetCost + regexCost
searchCost := targetCost * regexCost
// The total cost is the base call cost + search cost + result allocation + list creation cost factor.
- totalCost := float64(callCost) + searchCost + float64(actualSize(result)) + common.ListCreateBaseCost
+ totalCost := float64(callCost) + searchCost + float64(actualSize(result)) + cost.ListCreateBaseCost
// Round up and convert to uint64 for the final cost.
finalCost := uint64(math.Ceil(totalCost))
return &finalCost
@@ -440,8 +439,8 @@ func extractAllCostTracker() interpreter.FunctionTracker {
func replaceCostTracker() interpreter.FunctionTracker {
return func(args []ref.Val, result ref.Val) *uint64 {
- targetCost := float64(actualSize(args[0])+1) * common.StringTraversalCostFactor
- regexCost := float64(actualSize(args[1])+1) * common.RegexStringLengthCostFactor
+ targetCost := float64(actualSize(args[0])+1) * cost.StringTraversalCostFactor
+ regexCost := float64(actualSize(args[1])+1) * cost.RegexStringLengthCostFactor
// Actual search cost calculation = targetCost + regexCost
searchCost := targetCost * regexCost
// The total cost is the base call cost + search cost + result string allocation.
diff --git a/ext/strings.go b/ext/strings.go
index 2e3bc8ac3..0ffeefd23 100644
--- a/ext/strings.go
+++ b/ext/strings.go
@@ -29,7 +29,6 @@ import (
"cel.dev/cel-go/cel"
"cel.dev/cel-go/checker"
- "cel.dev/cel-go/common"
"cel.dev/cel-go/common/cost"
"cel.dev/cel-go/common/types"
"cel.dev/cel-go/common/types/ref"
@@ -997,7 +996,7 @@ func estimateStringSplitCost(estimator checker.CostEstimator, target *checker.As
// Worst case: split("") produces N elements for a string of size N.
resultSize := rangedSizeEstimate(0, targetSize.Max)
// Include list creation base cost plus allocation for each element.
- allocationCost := resultSize.MultiplyByCostFactor(1).Add(checker.FixedCostEstimate(common.ListCreateBaseCost))
+ allocationCost := resultSize.MultiplyByCostFactor(1).Add(checker.FixedCostEstimate(cost.ListCreateBaseCost))
cost := traversalCost.Add(allocationCost).Add(callCostEstimate)
return callEstimate(cost, &resultSize)
}
@@ -1071,7 +1070,7 @@ func trackStringReplaceCost(args []ref.Val, result ref.Val) *uint64 {
func trackStringSplitCost(args []ref.Val, result ref.Val) *uint64 {
traversalCost := cost.SafeMultiplyByFactor(cost.SafeAdd(actualSize(args[0]), 1), stringCostFactor)
resultSize := actualSize(result)
- total := cost.SafeAdd(callCost, traversalCost, resultSize, common.ListCreateBaseCost)
+ total := cost.SafeAdd(callCost, traversalCost, resultSize, cost.ListCreateBaseCost)
return &total
}
diff --git a/interpreter/interpreter_test.go b/interpreter/interpreter_test.go
index bc8df4f50..659816de6 100644
--- a/interpreter/interpreter_test.go
+++ b/interpreter/interpreter_test.go
@@ -2807,9 +2807,13 @@ func TestCustomDecorator(t *testing.T) {
}
func TestCostTrackerActualCost(t *testing.T) {
- ct := &CostTracker{cost: 42}
- if ct.ActualCost() != 42 {
- t.Errorf("ct.ActualCost() = %d, wanted 42", ct.ActualCost())
+ ct, err := NewCostTracker(nil)
+ if err != nil {
+ t.Fatalf("NewCostTracker(nil) failed: %v", err)
+ }
+ ct.Qualify(1)
+ if ct.ActualCost() != 1 {
+ t.Errorf("ct.ActualCost() = %d, wanted 1", ct.ActualCost())
}
}
diff --git a/interpreter/runtimecost.go b/interpreter/runtimecost.go
index e92acf3ab..f79a3aa6d 100644
--- a/interpreter/runtimecost.go
+++ b/interpreter/runtimecost.go
@@ -17,22 +17,69 @@ package interpreter
import (
"errors"
- "cel.dev/cel-go/common"
"cel.dev/cel-go/common/cost"
- "cel.dev/cel-go/common/overloads"
"cel.dev/cel-go/common/types"
"cel.dev/cel-go/common/types/ref"
- "cel.dev/cel-go/common/types/traits"
)
// WARNING: Any changes to cost calculations in this file require a corresponding change in checker/cost.go
-// ActualCostEstimator provides function call cost estimations at runtime
-// CallCost returns an estimated cost for the function overload invocation with the given args, or nil if it has no
-// estimate to provide. CEL attempts to provide reasonable estimates for its standard function library, so CallCost
-// should typically not need to provide an estimate for CELs standard function.
-type ActualCostEstimator interface {
- CallCost(function, overloadID string, args []ref.Val, result ref.Val) *uint64
+type (
+ // ActualCostEstimator provides function call cost estimations at runtime
+ //
+ // Deprecated: use cost.ActualCostEstimator
+ ActualCostEstimator = cost.ActualCostEstimator
+
+ // FunctionTracker computes the actual cost of evaluating the functions with the given arguments and result.
+ //
+ // Deprecated: use cost.FunctionTracker
+ FunctionTracker = cost.FunctionTracker
+
+ // CostTrackerOption configures the behavior of CostTracker objects.
+ //
+ // Deprecated: use cost.CostTrackerOption
+ CostTrackerOption = cost.TrackerOption
+
+ // CostTracker represents the information needed for tracking runtime cost.
+ //
+ // Deprecated: use cost.CostTracker
+ CostTracker = cost.Tracker
+)
+
+// CostTrackerLimit sets the runtime limit on the evaluation cost during execution and will terminate the expression
+// evaluation if the limit is exceeded.
+//
+// Deprecated: use cost.CostTrackerLimit
+func CostTrackerLimit(limit uint64) CostTrackerOption {
+ return cost.TrackerLimit(limit)
+}
+
+// PresenceTestHasCost determines whether presence testing has a cost of one or zero.
+// Defaults to presence test has a cost of one.
+//
+// Deprecated: use cost.CostTrackerPresenceTestHasCost
+func PresenceTestHasCost(hasCost bool) CostTrackerOption {
+ return cost.TrackerPresenceTestHasCost(hasCost)
+}
+
+// OverloadCostTracker binds an overload ID to a runtime FunctionTracker implementation.
+//
+// Deprecated: use cost.OverloadCostTracker
+func OverloadCostTracker(overloadID string, fnTracker FunctionTracker) CostTrackerOption {
+ return cost.OverloadTracker(overloadID, fnTracker)
+}
+
+// NewCostTracker creates a new CostTracker with a given estimator and a set of functional CostTrackerOption values.
+//
+// Deprecated: use cost.NewCostTracker
+func NewCostTracker(estimator ActualCostEstimator, opts ...CostTrackerOption) (*CostTracker, error) {
+ evalCancelHandler := cost.TrackerLimitExceededHandler(func() {
+ panic(EvalCancelledError{Cause: CostLimitExceeded, Message: "operation cancelled: actual cost limit exceeded"})
+ })
+ allOpts := make([]cost.TrackerOption, 0, len(opts)+1)
+ allOpts = append(allOpts, evalCancelHandler)
+ allOpts = append(allOpts, opts...)
+ return cost.NewTracker(estimator, allOpts...)
}
// costTrackPlanOption modifies the cost tracking factory associatied with the CostObserver
@@ -95,117 +142,6 @@ func (ct *costTrackerFactory) GetState(frame *ExecutionFrame) any {
func (ct *costTrackerFactory) Observe(vars Activation, id int64, programStep any, val ref.Val) {
}
-// CostTrackerOption configures the behavior of CostTracker objects.
-type CostTrackerOption func(*CostTracker) error
-
-// CostTrackerLimit sets the runtime limit on the evaluation cost during execution and will terminate the expression
-// evaluation if the limit is exceeded.
-func CostTrackerLimit(limit uint64) CostTrackerOption {
- return func(tracker *CostTracker) error {
- tracker.Limit = &limit
- return nil
- }
-}
-
-// PresenceTestHasCost determines whether presence testing has a cost of one or zero.
-// Defaults to presence test has a cost of one.
-func PresenceTestHasCost(hasCost bool) CostTrackerOption {
- return func(tracker *CostTracker) error {
- tracker.presenceTestHasCost = hasCost
- return nil
- }
-}
-
-// NewCostTracker creates a new CostTracker with a given estimator and a set of functional CostTrackerOption values.
-func NewCostTracker(estimator ActualCostEstimator, opts ...CostTrackerOption) (*CostTracker, error) {
- tracker := &CostTracker{
- Estimator: estimator,
- overloadTrackers: map[string]FunctionTracker{},
- presenceTestHasCost: true,
- }
- for _, opt := range opts {
- err := opt(tracker)
- if err != nil {
- return nil, err
- }
- }
- return tracker, nil
-}
-
-// OverloadCostTracker binds an overload ID to a runtime FunctionTracker implementation.
-//
-// OverloadCostTracker instances augment or override ActualCostEstimator decisions, allowing for versioned and/or
-// optional cost tracking changes.
-func OverloadCostTracker(overloadID string, fnTracker FunctionTracker) CostTrackerOption {
- return func(tracker *CostTracker) error {
- tracker.overloadTrackers[overloadID] = fnTracker
- return nil
- }
-}
-
-// FunctionTracker computes the actual cost of evaluating the functions with the given arguments and result.
-type FunctionTracker func(args []ref.Val, result ref.Val) *uint64
-
-// CostTracker represents the information needed for tracking runtime cost.
-type CostTracker struct {
- Estimator ActualCostEstimator
- overloadTrackers map[string]FunctionTracker
- Limit *uint64
- presenceTestHasCost bool
-
- cost uint64
-}
-
-// Clone makes a shallow copy of the tracker.
-// The different clones can be used independently from
-// each other.
-func (c *CostTracker) Clone() (*CostTracker, error) {
- tracker := &CostTracker{
- Estimator: c.Estimator,
- overloadTrackers: c.overloadTrackers,
- Limit: c.Limit,
- presenceTestHasCost: c.presenceTestHasCost,
- }
- return tracker, nil
-}
-
-// ActualCost returns the runtime cost
-func (c *CostTracker) ActualCost() uint64 {
- return c.cost
-}
-
-// CreateList records list literal construction cost.
-func (c *CostTracker) CreateList(id int64, res ref.Val) {
- c.cost = cost.SafeAdd(c.cost, common.ListCreateBaseCost)
- c.checkLimit()
-}
-
-// CreateMap records map literal construction cost.
-func (c *CostTracker) CreateMap(id int64, res ref.Val) {
- c.cost = cost.SafeAdd(c.cost, common.MapCreateBaseCost)
- c.checkLimit()
-}
-
-// CreateStruct records struct/object construction cost.
-func (c *CostTracker) CreateStruct(id int64, res ref.Val) {
- c.cost = cost.SafeAdd(c.cost, common.StructCreateBaseCost)
- c.checkLimit()
-}
-
-// EvalAttribute records attribute resolution cost (ident / select).
-func (c *CostTracker) EvalAttribute(id int64, isTestOnly bool, res ref.Val) {
- if !isTestOnly || c.presenceTestHasCost {
- c.cost = cost.SafeAdd(c.cost, common.SelectAndIdentCost)
- c.checkLimit()
- }
-}
-
-// Qualify records qualifier cost.
-func (c *CostTracker) Qualify(id int64) {
- c.cost = cost.SafeAdd(c.cost, 1)
- c.checkLimit()
-}
-
type costTrackingInterpretable struct {
InterpretableV2
factory func() (*CostTracker, error)
@@ -226,127 +162,8 @@ func (c *costTrackingInterpretable) Eval(ctx Activation) ref.Val {
return c.Exec(AsFrame(ctx))
}
-// EvalZeroArity records the cost for a 0-arity call expression.
-func (c *CostTracker) EvalZeroArity(vars Activation, id int64, call InterpretableCall, result ref.Val) {
- c.cost = cost.SafeAdd(c.cost, c.costCall(call, nil, result))
- c.checkLimit()
-}
-
-// EvalUnary records the cost for a unary call expression.
-func (c *CostTracker) EvalUnary(vars Activation, id int64, call InterpretableCall, arg ref.Val, result ref.Val) {
- var buf [1]ref.Val
- buf[0] = arg
- c.cost = cost.SafeAdd(c.cost, c.costCall(call, buf[:], result))
- c.checkLimit()
-}
-
-// EvalBinary records the cost for a binary call expression.
-func (c *CostTracker) EvalBinary(vars Activation, id int64, call InterpretableCall, lhs, rhs ref.Val, result ref.Val) {
- var buf [2]ref.Val
- buf[0] = lhs
- buf[1] = rhs
- c.cost = cost.SafeAdd(c.cost, c.costCall(call, buf[:], result))
- c.checkLimit()
-}
-
-// EvalVarArgs records the cost for a variadic call expression.
-func (c *CostTracker) EvalVarArgs(vars Activation, id int64, call InterpretableCall, args []ref.Val, result ref.Val) {
- c.cost = cost.SafeAdd(c.cost, c.costCall(call, args, result))
- c.checkLimit()
-}
-
-func (c *CostTracker) checkLimit() {
- if c.Limit != nil && c.cost > *c.Limit {
- panic(EvalCancelledError{Cause: CostLimitExceeded, Message: "operation cancelled: actual cost limit exceeded"})
- }
-}
-
-func (c *CostTracker) costCall(call InterpretableCall, args []ref.Val, result ref.Val) uint64 {
- var total uint64
- if len(c.overloadTrackers) != 0 {
- if tracker, found := c.overloadTrackers[call.OverloadID()]; found {
- callCost := tracker(args, result)
- if callCost != nil {
- total = cost.SafeAdd(total, *callCost)
- return total
- }
- }
- }
- if c.Estimator != nil {
- callCost := c.Estimator.CallCost(call.Function(), call.OverloadID(), args, result)
- if callCost != nil {
- total = cost.SafeAdd(total, *callCost)
- return total
- }
- }
- // if user didn't specify, the default way of calculating runtime cost would be used.
- // if user has their own implementation of ActualCostEstimator, make sure to cover the mapping between overloadId and cost calculation
- switch call.OverloadID() {
- // O(n) functions
- case overloads.StartsWithString, overloads.EndsWithString:
- total = cost.SafeAdd(total, cost.SafeMultiplyByFactor(actualSize(args[1]), common.StringTraversalCostFactor))
- case overloads.StringToBytes, overloads.BytesToString, overloads.ExtQuoteString, overloads.ExtFormatString:
- total = cost.SafeAdd(total, cost.SafeMultiplyByFactor(actualSize(args[0]), common.StringTraversalCostFactor))
- case overloads.InList:
- // If a list is composed entirely of constant values this is O(1), but we don't account for that here.
- // We just assume all list containment checks are O(n).
- total = cost.SafeAdd(total, actualSize(args[1]))
- // O(min(m, n)) functions
- case overloads.LessString, overloads.GreaterString, overloads.LessEqualsString, overloads.GreaterEqualsString,
- overloads.LessBytes, overloads.GreaterBytes, overloads.LessEqualsBytes, overloads.GreaterEqualsBytes,
- overloads.Equals, overloads.NotEquals:
- // When we check the equality of 2 scalar values (e.g. 2 integers, 2 floating-point numbers, 2 booleans etc.),
- // the CostTracker.ActualSize() function by definition returns 1 for each operand, resulting in an overall cost
- // of 1.
- lhsSize := actualSize(args[0])
- rhsSize := actualSize(args[1])
- minSize := min(rhsSize, lhsSize)
- total = cost.SafeAdd(total, cost.SafeMultiplyByFactor(minSize, common.StringTraversalCostFactor))
- // O(m+n) functions
- case overloads.AddString, overloads.AddBytes:
- // In the worst case scenario, we would need to reallocate a new backing store and copy both operands over.
- argSize := cost.SafeAdd(actualSize(args[0]), actualSize(args[1]))
- total = cost.SafeAdd(total, cost.SafeMultiplyByFactor(argSize, common.StringTraversalCostFactor))
- // O(nm) functions
- case overloads.Matches, overloads.MatchesString:
- // https://swtch.com/~rsc/regexp/regexp1.html applies to RE2 implementation supported by CEL
- // Add one to string length for purposes of cost calculation to prevent product of string and regex to be 0
- // in case where string is empty but regex is still expensive.
- strCost := cost.SafeMultiplyByFactor(cost.SafeAdd(1, actualSize(args[0])), common.StringTraversalCostFactor)
- // We don't know how many expressions are in the regex, just the string length (a huge
- // improvement here would be to somehow get a count the number of expressions in the regex or
- // how many states are in the regex state machine and use that to measure regex cost).
- // For now, we're making a guess that each expression in a regex is typically at least 4 chars
- // in length.
- regexCost := cost.SafeMultiplyByFactor(actualSize(args[1]), common.RegexStringLengthCostFactor)
- total = cost.SafeAdd(total, cost.SafeMultiply(strCost, regexCost))
- case overloads.ContainsString:
- strCost := cost.SafeMultiplyByFactor(actualSize(args[0]), common.StringTraversalCostFactor)
- substrCost := cost.SafeMultiplyByFactor(actualSize(args[1]), common.StringTraversalCostFactor)
- total = cost.SafeAdd(total, cost.SafeMultiply(strCost, substrCost))
-
- default:
- // The following operations are assumed to have O(1) complexity.
- // - AddList due to the implementation. Index lookup can be O(c) the
- // number of concatenated lists, but we don't track that is cost calculations.
- // - Conversions, since none perform a traversal of a type of unbound length.
- // - Computing the size of strings, byte sequences, lists and maps.
- // - Logical operations and all operators on fixed width scalars (comparisons, equality)
- // - Any functions that don't have a declared cost either here or in provided ActualCostEstimator.
- total = cost.SafeAdd(total, 1)
-
- }
- return total
-}
-
// actualSize returns the size of the value for all traits.Sizer values, a fixed size for all proto-based
// objects, and a size of 1 for all other value types.
func actualSize(value ref.Val) uint64 {
- if sz, ok := value.(traits.Sizer); ok {
- return uint64(sz.Size().(types.Int))
- }
- if opt, ok := value.(*types.Optional); ok && opt.HasValue() {
- return actualSize(opt.GetValue())
- }
- return 1
+ return cost.ActualSize(value)
}
diff --git a/interpreter/runtimecost_test.go b/interpreter/runtimecost_test.go
index 347d3ba6b..b402707a1 100644
--- a/interpreter/runtimecost_test.go
+++ b/interpreter/runtimecost_test.go
@@ -15,13 +15,7 @@
package interpreter
import (
- "fmt"
- "math"
- "math/rand"
- "reflect"
- "strings"
"testing"
- "time"
"cel.dev/cel-go/checker"
"cel.dev/cel-go/common"
@@ -31,956 +25,104 @@ import (
"cel.dev/cel-go/common/types"
"cel.dev/cel-go/common/types/ref"
"cel.dev/cel-go/parser"
-
- proto3pb "cel.dev/cel-go/test/proto3pb"
)
-func TestTrackCostAdvanced(t *testing.T) {
- var equalCases = []struct {
- in any
- lhsExpr string
- rhsExpr string
- }{
- {
- lhsExpr: `1`,
- rhsExpr: `2`,
- },
- {
- lhsExpr: `"abc".contains("d")`,
- rhsExpr: `"def".contains("d")`,
- },
- {
- lhsExpr: `1 in [4, 5, 6]`,
- rhsExpr: `2 in [15, 17, 16]`,
- },
+func TestCostTrackerForwarding(t *testing.T) {
+ tracker, err := NewCostTracker(nil,
+ CostTrackerLimit(100),
+ PresenceTestHasCost(true),
+ OverloadCostTracker(overloads.ContainsString, func(args []ref.Val, result ref.Val) *uint64 {
+ c := uint64(5)
+ return &c
+ }),
+ )
+ if err != nil {
+ t.Fatalf("NewCostTracker() failed: %v", err)
}
- for _, tc := range equalCases {
- t.Run(tc.lhsExpr+" vs "+tc.rhsExpr, func(t *testing.T) {
- ctx := constructActivation(t, tc.in)
- lhsCost, _, err := computeCost(t, tc.lhsExpr, nil, ctx, nil)
- if err != nil {
- t.Fatalf("Interpreter.Eval(activation Activation) failed to eval expression due: %v", err)
- }
- rhsCost, _, err := computeCost(t, tc.rhsExpr, nil, ctx, nil)
- if err != nil {
- t.Fatalf("Interpreter.Eval(activation Activation) failed to eval expression due: %v", err)
- }
- if lhsCost != rhsCost {
- t.Errorf(`Interpreter.Eval(activation Activation) failed return a cost for %s of %d equal to a cost for %s of %d`,
- tc.lhsExpr, lhsCost, tc.rhsExpr, rhsCost)
- }
- })
+ clone, err := tracker.Clone()
+ if err != nil {
+ t.Fatalf("tracker.Clone() failed: %v", err)
}
- var smallerCases = []struct {
- in any
- lhsExpr string
- rhsExpr string
- }{
- {
- lhsExpr: `1`,
- rhsExpr: `1 + 2`,
- },
- {
- lhsExpr: `"abc".contains("d")`,
- rhsExpr: `"abcdhdflsfiehfieubdkwjbdwgxvuyagwsdwdnw qdbgquyidvbwqi".contains("e")`,
- },
- {
- lhsExpr: `1 in [4, 5, 6]`,
- rhsExpr: `1 in [4, 5, 6, 7, 8, 9]`,
- },
+
+ clone.CreateList(1, nil)
+ if clone.ActualCost() != 10 {
+ t.Errorf("clone.ActualCost() = %d, wanted 10", clone.ActualCost())
}
- for _, tc := range smallerCases {
- t.Run(tc.lhsExpr+" vs "+tc.rhsExpr, func(t *testing.T) {
- ctx := constructActivation(t, tc.in)
- lhsCost, _, err := computeCost(t, tc.lhsExpr, nil, ctx, nil)
- if err != nil {
- t.Fatalf("Interpreter.Eval(activation Activation) failed to eval expression due: %v", err)
- }
- rhsCost, _, err := computeCost(t, tc.rhsExpr, nil, ctx, nil)
- if err != nil {
- t.Fatalf("Interpreter.Eval(activation Activation) failed to eval expression due: %v", err)
- }
- if lhsCost >= rhsCost {
- t.Errorf(`Interpreter.Eval(activation Activation) failed return a cost for %s of %d less than the cost for %s of %d`,
- tc.lhsExpr, lhsCost, tc.rhsExpr, rhsCost)
- }
- })
+ if !clone.PresenceTestHasCost() {
+ t.Errorf("clone.PresenceTestHasCost() = false, wanted true")
}
}
-func computeCost(t *testing.T, expr string, vars []*decls.VariableDecl, ctx Activation, options []CostTrackerOption) (cost uint64, est checker.CostEstimate, err error) {
- t.Helper()
-
- s := common.NewTextSource(expr)
+func TestCostObserverIntegration(t *testing.T) {
p, err := parser.NewParser(parser.Macros(parser.AllMacros...))
if err != nil {
- t.Fatalf("Failed to initialize parser: %v", err)
+ t.Fatalf("NewParser() failed: %v", err)
}
- parsed, errs := p.Parse(s)
+ src := common.NewTextSource("a + b")
+ parsed, errs := p.Parse(src)
if len(errs.GetErrors()) != 0 {
- t.Fatalf(`Failed to Parse expression "%s", error: %v`, expr, errs.GetErrors())
+ t.Fatalf("Parse() failed: %v", errs.ToDisplayString())
}
cont := containers.DefaultContainer
- reg := newTestRegistry(t, types.ProtoTypeDefs(&proto3pb.TestAllTypes{}))
+ reg := newTestRegistry(t)
attrs := NewAttributeFactory(cont, reg, reg)
env := newTestEnv(t, cont, reg)
- err = env.AddIdents(vars...)
+ err = env.AddIdents(
+ decls.NewVariable("a", types.IntType),
+ decls.NewVariable("b", types.IntType),
+ )
if err != nil {
- t.Fatalf("Failed to initialize env: %v", err)
+ t.Fatalf("AddIdents() failed: %v", err)
}
- costTracker, err := NewCostTracker(&testRuntimeCostEstimator{}, options...)
- if err != nil {
- t.Fatalf("NewCostTracker() failed: %v", err)
- }
- costTracker, err = costTracker.Clone()
- if err != nil {
- t.Fatalf("checker.Clone() failed: %v", err)
- }
- checked, errs := checker.Check(parsed, s, env)
+
+ checked, errs := checker.Check(parsed, src, env)
if len(errs.GetErrors()) != 0 {
- t.Fatalf(`Failed to check expression "%s", error: %v`, expr, errs.GetErrors())
+ t.Fatalf("Check() failed: %v", errs.ToDisplayString())
}
- est, err = checker.Cost(checked, testCostEstimator{}, checker.PresenceTestHasCost(costTracker.presenceTestHasCost))
+
+ tracker, err := NewCostTracker(nil)
if err != nil {
- t.Fatalf("checker.Cost() failed: %v", err)
+ t.Fatalf("NewCostTracker() failed: %v", err)
}
+
interp := newStandardInterpreter(t, cont, reg, reg, attrs)
prg, err := interp.NewInterpretable(checked,
CostObserver(CostTrackerFactory(func() (*CostTracker, error) {
- return costTracker, nil
+ return tracker, nil
})))
if err != nil {
- t.Fatalf(`Failed to check expression "%s", error: %v`, expr, errs.GetErrors())
+ t.Fatalf("NewInterpretable() failed: %v", err)
}
- defer func() {
- if r := recover(); r != nil {
- switch t := r.(type) {
- case EvalCancelledError:
- err = t
- default:
- err = fmt.Errorf("internal error: %v", r)
- }
- }
- }()
- frame := AsFrame(ctx)
- prg.Exec(frame)
- // TODO: enable this once all attributes are properly pushed and popped from stack.
- //if len(costTracker.stack) != 1 {
- // t.Fatalf(`Expected resulting stack size to be 1 but got %d: %#+v`, len(costTracker.stack), costTracker.stack)
- //}
- return costTracker.cost, est, err
-}
-
-func constructActivation(t testing.TB, in any) Activation {
- t.Helper()
- if in == nil {
- return EmptyActivation()
- }
- a, err := NewActivation(in)
+ act, err := NewActivation(map[string]any{"a": 1, "b": 2})
if err != nil {
- t.Fatalf("NewActivation(%v) failed: %v", in, err)
- }
- return a
-}
-
-const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
-
-func randSeq(n int) []byte {
- b := make([]byte, n)
- for i := range b {
- b[i] = letterBytes[rand.Intn(len(letterBytes))]
- }
- return b
-}
-
-type testRuntimeCostEstimator struct {
-}
-
-var timeToYearCost uint64 = 7
-
-func (e testRuntimeCostEstimator) CallCost(function, overloadID string, args []ref.Val, result ref.Val) *uint64 {
- argsSize := make([]uint64, len(args))
- for i, arg := range args {
- reflectV := reflect.ValueOf(arg.Value())
- switch reflectV.Kind() {
- // Note that the CEL bytes type is implemented with Go byte slices, therefore also supported by the following
- // code.
- case reflect.String, reflect.Array, reflect.Slice, reflect.Map:
- argsSize[i] = uint64(reflectV.Len())
- default:
- argsSize[i] = 1
- }
- }
-
- switch overloadID {
- case overloads.TimestampToYear:
- return &timeToYearCost
- default:
- return nil
- }
-}
-
-type testCostEstimator struct {
- hints map[string]int64
-}
-
-func (tc testCostEstimator) EstimateSize(element checker.AstNode) *checker.SizeEstimate {
- if l, ok := tc.hints[strings.Join(element.Path(), ".")]; ok {
- return &checker.SizeEstimate{Min: 0, Max: uint64(l)}
- }
- return nil
-}
-
-func (tc testCostEstimator) EstimateCallCost(function, overloadID string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate {
- switch overloadID {
- case overloads.TimestampToYear:
- return &checker.CallEstimate{CostEstimate: checker.FixedCostEstimate(7)}
- }
- return nil
-}
-
-func TestRuntimeCost(t *testing.T) {
- allTypes := types.NewObjectType("google.expr.proto3.test.TestAllTypes")
- allList := types.NewListType(allTypes)
- intList := types.NewListType(types.IntType)
- nestedList := types.NewListType(allList)
-
- allMap := types.NewMapType(types.StringType, allTypes)
- nestedMap := types.NewMapType(types.StringType, allMap)
- cases := []struct {
- name string
- expr string
- vars []*decls.VariableDecl
- want uint64
- in any
- testFuncCost bool
- limit uint64
- options []CostTrackerOption
-
- expectExceedsLimit bool
- }{
- {
- name: "const",
- expr: `"Hello World!"`,
- want: 0,
- },
- {
- name: "identity",
- expr: `input`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", intList)},
- want: 1,
- in: map[string]any{"input": []int{1, 2}},
- },
- {
- name: "select: map",
- expr: `input['key']`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewMapType(types.StringType, types.StringType))},
- want: 2,
- in: map[string]any{"input": map[string]string{"key": "v"}},
- },
- {
- name: "select: array index",
- expr: `input[0]`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewListType(types.StringType))},
- want: 2,
- in: map[string]any{"input": []string{"v"}},
- },
- {
- name: "select: field",
- expr: `input.single_int32`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", allTypes)},
- want: 2,
- in: map[string]any{
- "input": &proto3pb.TestAllTypes{
- RepeatedBool: []bool{false},
- MapInt64NestedType: map[int64]*proto3pb.NestedTestAllTypes{
- 1: {},
- },
- MapStringString: map[string]string{},
- },
- },
- },
- {
- name: "expr select: map",
- expr: `input['ke' + 'y']`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewMapType(types.StringType, types.StringType))},
- want: 3,
- in: map[string]any{"input": map[string]string{"key": "v"}},
- },
- {
- name: "expr select: array index",
- expr: `input[3-3]`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewListType(types.StringType))},
- want: 3,
- in: map[string]any{"input": []string{"v"}},
- },
- {
- name: "select: field test only no has() cost",
- expr: `has(input.single_int32)`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewObjectType("google.expr.proto3.test.TestAllTypes"))},
- want: 1,
- options: []CostTrackerOption{PresenceTestHasCost(false)},
- in: map[string]any{
- "input": &proto3pb.TestAllTypes{
- RepeatedBool: []bool{false},
- MapInt64NestedType: map[int64]*proto3pb.NestedTestAllTypes{
- 1: {},
- },
- MapStringString: map[string]string{},
- },
- },
- },
- {
- name: "select: field test only",
- expr: `has(input.single_int32)`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.NewObjectType("google.expr.proto3.test.TestAllTypes"))},
- want: 2,
- in: map[string]any{
- "input": &proto3pb.TestAllTypes{
- RepeatedBool: []bool{false},
- MapInt64NestedType: map[int64]*proto3pb.NestedTestAllTypes{
- 1: {},
- },
- MapStringString: map[string]string{},
- },
- },
- },
- {
- name: "select: non-proto field test has() cost",
- expr: `has(input.testAttr.nestedAttr)`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", nestedMap)},
- want: 3,
- options: []CostTrackerOption{PresenceTestHasCost(true)},
- in: map[string]any{
- "input": map[string]any{
- "testAttr": map[string]any{
- "nestedAttr": "0",
- },
- },
- },
- },
- {
- name: "select: non-proto field test no has() cost",
- expr: `has(input.testAttr.nestedAttr)`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", nestedMap)},
- want: 2,
- options: []CostTrackerOption{PresenceTestHasCost(false)},
- in: map[string]any{
- "input": map[string]any{
- "testAttr": map[string]any{
- "nestedAttr": "0",
- },
- },
- },
- },
- {
- name: "select: non-proto field test",
- expr: `has(input.testAttr.nestedAttr)`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", nestedMap)},
- want: 3,
- in: map[string]any{
- "input": map[string]any{
- "testAttr": map[string]any{
- "nestedAttr": "0",
- },
- },
- },
- },
- {
- name: "estimated function call",
- expr: `input.getFullYear()`,
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.TimestampType)},
- want: 8,
- in: map[string]any{"input": time.Now()},
- testFuncCost: true,
- },
- {
- name: "create list",
- expr: `[1, 2, 3]`,
- want: 10,
- },
- {
- name: "create struct",
- expr: `google.expr.proto3.test.TestAllTypes{single_int32: 1, single_float: 3.14, single_string: 'str'}`,
- want: 40,
- },
- {
- name: "create map",
- expr: `{"a": 1, "b": 2, "c": 3}`,
- want: 30,
- },
- {
- name: "all comprehension",
- vars: []*decls.VariableDecl{decls.NewVariable("input", allList)},
- expr: `input.all(x, true)`,
- want: 2,
- in: map[string]any{
- "input": []*proto3pb.TestAllTypes{},
- },
- },
- {
- name: "nested all comprehension",
- vars: []*decls.VariableDecl{decls.NewVariable("input", nestedList)},
- expr: `input.all(x, x.all(y, true))`,
- want: 2,
- in: map[string]any{
- "input": []*proto3pb.TestAllTypes{},
- },
- },
- {
- name: "all comprehension on literal",
- expr: `[1, 2, 3].all(x, true)`,
- want: 20,
- },
- {
- name: "variable cost function",
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.StringType)},
- expr: `input.matches('[0-9]')`,
- want: 103,
- in: map[string]any{"input": string(randSeq(500))},
- },
- {
- name: "variable cost function with constant",
- expr: `'123'.matches('[0-9]')`,
- want: 2,
- },
- {
- name: "or",
- expr: `false || false`,
- want: 0,
- },
- {
- name: "or short-circuit",
- expr: `true || false`,
- want: 0,
- },
-
- {
- name: "or accumulated branch cost",
- expr: `a || b || c || d`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("a", types.BoolType),
- decls.NewVariable("b", types.BoolType),
- decls.NewVariable("c", types.BoolType),
- decls.NewVariable("d", types.BoolType),
- },
- in: map[string]any{
- "a": false,
- "b": false,
- "c": false,
- "d": false,
- },
- want: 4,
- },
- {
- name: "and",
- expr: `true && false`,
- want: 0,
- },
- {
- name: "and short-circuit",
- expr: `false && true`,
- want: 0,
- },
- {
- name: "and accumulated branch cost",
- expr: `a && b && c && d`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("a", types.BoolType),
- decls.NewVariable("b", types.BoolType),
- decls.NewVariable("c", types.BoolType),
- decls.NewVariable("d", types.BoolType),
- },
- in: map[string]any{
- "a": true,
- "b": true,
- "c": true,
- "d": true,
- },
- want: 4,
- },
- {
- name: "lt",
- expr: `1 < 2`,
- want: 1,
- },
- {
- name: "lte",
- expr: `1 <= 2`,
- want: 1,
- },
- {
- name: "eq",
- expr: `1 == 2`,
- want: 1,
- },
- {
- name: "gt",
- expr: `2 > 1`,
- want: 1,
- },
- {
- name: "gte",
- expr: `2 >= 1`,
- want: 1,
- },
- {
- name: "in",
- expr: `2 in [1, 2, 3]`,
- want: 13,
- },
- {
- name: "plus",
- expr: `1 + 1`,
- want: 1,
- },
- {
- name: "minus",
- expr: `1 - 1`,
- want: 1,
- },
- {
- name: "/",
- expr: `1 / 1`,
- want: 1,
- },
- {
- name: "/",
- expr: `1 * 1`,
- want: 1,
- },
- {
- name: "%",
- expr: `1 % 1`,
- want: 1,
- },
- {
- name: "ternary",
- expr: `true ? 1 : 2`,
- want: 0,
- },
- {
- name: "string size",
- expr: `size("123")`,
- want: 1,
- },
- {
- name: "str eq str",
- expr: `'12345678901234567890' == '123456789012345678901234567890'`,
- want: 2,
- },
- {
- name: "bytes to string conversion",
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.BytesType)},
- expr: `string(input)`,
- want: 51,
- in: map[string]any{"input": randSeq(500)},
- },
- {
- name: "string to bytes conversion",
- vars: []*decls.VariableDecl{decls.NewVariable("input", types.StringType)},
- expr: `bytes(input)`,
- want: 51,
- in: map[string]any{"input": string(randSeq(500))},
- },
- {
- name: "int to string conversion",
- expr: `string(1)`,
- want: 1,
- },
- {
- name: "contains",
- expr: `input.contains(arg1)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- decls.NewVariable("arg1", types.StringType),
- },
- want: 2502,
- in: map[string]any{"input": string(randSeq(500)), "arg1": string(randSeq(500))},
- },
- {
- name: "matches",
- expr: `input.matches('\\d+a\\d+b')`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- },
- want: 103,
- in: map[string]any{"input": string(randSeq(500)), "arg1": string(randSeq(500))},
- },
- {
- name: "matches global",
- expr: `matches(input, '\\d+a\\d+b')`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- },
- want: 103,
- in: map[string]any{"input": string(randSeq(500))},
- },
- {
- name: "startsWith",
- expr: `input.startsWith(arg1)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- decls.NewVariable("arg1", types.StringType),
- },
- want: 52,
- in: map[string]any{"input": "idc", "arg1": string(randSeq(500))},
- },
- {
- name: "endsWith",
- expr: `input.endsWith(arg1)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- decls.NewVariable("arg1", types.StringType),
- },
- want: 52,
- in: map[string]any{"input": "idc", "arg1": string(randSeq(500))},
- },
- {
- name: "size receiver",
- expr: `input.size()`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- },
- want: 2,
- in: map[string]any{"input": "500", "arg1": "500"},
- },
- {
- name: "size",
- expr: `size(input)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", types.StringType),
- },
- want: 2,
- in: map[string]any{"input": "500", "arg1": "500"},
- },
- {
- name: "ternary eval",
- expr: `(x > 2 ? input1 : input2).all(y, true)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("x", types.IntType),
- decls.NewVariable("input1", allList),
- decls.NewVariable("input2", allList),
- },
- want: 6,
- in: map[string]any{"input1": []*proto3pb.TestAllTypes{{}}, "input2": []*proto3pb.TestAllTypes{{}}, "x": 1},
- },
- {
- name: "ternary eval trivial, true",
- expr: `true ? false : 1 > 3`,
- want: 0,
- in: map[string]any{},
- },
- {
- name: "ternary eval trivial, false",
- expr: `false ? false : 1 > 3`,
- want: 1,
- in: map[string]any{},
- },
- {
- name: "comprehension over map",
- expr: `input.all(k, input[k].single_int32 > 3)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", allMap),
- },
- want: 9,
- in: map[string]any{"input": map[string]any{"val": &proto3pb.TestAllTypes{}}},
- },
- {
- name: "comprehension over nested map of maps",
- expr: `input.all(k, input[k].all(x, true))`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", nestedMap),
- },
- want: 2,
- in: map[string]any{"input": map[string]any{}},
- },
- {
- name: "string size of map keys",
- expr: `input.all(k, k.contains(k))`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", nestedMap),
- },
- want: 2,
- in: map[string]any{"input": map[string]any{}},
- },
- {
- name: "comprehension variable shadowing",
- expr: `input.all(k, input[k].all(k, true) && k.contains(k))`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", nestedMap),
- },
- want: 2,
- in: map[string]any{"input": map[string]any{}},
- },
- {
- name: "comprehension variable shadowing",
- expr: `input.all(k, input[k].all(k, true) && k.contains(k))`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("input", nestedMap),
- },
- want: 2,
- in: map[string]any{"input": map[string]any{}},
- },
- {
- name: "list concat",
- expr: `(list1 + list2).all(x, true)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("list1", types.NewListType(types.IntType)),
- decls.NewVariable("list2", types.NewListType(types.IntType)),
- },
- want: 4,
- in: map[string]any{"list1": []int{}, "list2": []int{}},
- },
- {
- name: "str concat",
- expr: `"abcdefg".contains(str1 + str2)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("str1", types.StringType),
- decls.NewVariable("str2", types.StringType),
- },
- want: 6,
- in: map[string]any{"str1": "val1", "str2": "val2222222"},
- },
- {
- name: "str concat custom cost tracker",
- expr: `"abcdefg".contains(str1 + str2)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("str1", types.StringType),
- decls.NewVariable("str2", types.StringType),
- },
- options: []CostTrackerOption{
- OverloadCostTracker(overloads.ContainsString,
- func(args []ref.Val, result ref.Val) *uint64 {
- strCost := uint64(math.Ceil(float64(actualSize(args[0])) * 0.2))
- substrCost := uint64(math.Ceil(float64(actualSize(args[1])) * 0.2))
- cost := strCost * substrCost
- return &cost
- }),
- },
- want: 10,
- in: map[string]any{"str1": "val1", "str2": "val2222222"},
- },
- {
- name: "at limit",
- expr: `"abcdefg".contains(str1 + str2)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("str1", types.StringType),
- decls.NewVariable("str2", types.StringType),
- },
- in: map[string]any{"str1": "val1", "str2": "val2222222"},
- limit: 6,
- want: 6,
- },
- {
- name: "above limit",
- expr: `"abcdefg".contains(str1 + str2)`,
- vars: []*decls.VariableDecl{
- decls.NewVariable("str1", types.StringType),
- decls.NewVariable("str2", types.StringType),
- },
- in: map[string]any{"str1": "val1", "str2": "val2222222"},
- limit: 5,
- expectExceedsLimit: true,
- },
- {
- name: "ternary as operand",
- expr: `(1 > 2 ? 5 : 3) > 1`,
- vars: []*decls.VariableDecl{},
- in: map[string]any{},
- want: 2,
- },
- {
- name: "ternary as operand",
- expr: `(1 > 2 || 2 > 1) == true`,
- vars: []*decls.VariableDecl{},
- in: map[string]any{},
- want: 3,
- },
- {
- name: "list map literal",
- expr: `[{'k1': 1}, {'k2': 2}].all(x, true)`,
- vars: []*decls.VariableDecl{},
- in: map[string]any{},
- want: 77,
- },
- {
- name: "list map literal",
- expr: `[{'k1': 1}, {'k2': 2}].all(x, true)`,
- vars: []*decls.VariableDecl{},
- in: map[string]any{},
- want: 77,
- },
- {
- name: ".filter list literal",
- expr: `[1,2,3,4,5].filter(x, x % 2 == 0)`,
- vars: []*decls.VariableDecl{},
- in: map[string]any{},
- want: 62,
- },
- {
- name: ".map list literal",
- expr: `[1,2,3,4,5].map(x, x)`,
- vars: []*decls.VariableDecl{},
- in: map[string]any{},
- want: 86,
- },
- {
- name: ".map.filter list literal",
- expr: `[1,2,3,4,5].map(x, x).filter(x, x % 2 == 0)`,
- vars: []*decls.VariableDecl{},
- in: map[string]any{},
- want: 138,
- },
- {
- name: ".map.exists list literal",
- expr: `[1,2,3,4,5].map(x, x).exists(x, x == 5) == true`,
- vars: []*decls.VariableDecl{},
- in: map[string]any{},
- want: 118,
- },
- {
- name: ".map.map list literal",
- expr: `[1,2,3,4,5].map(x, x).map(x, x)`,
- vars: []*decls.VariableDecl{},
- in: map[string]any{},
- want: 162,
- },
- {
- name: ".map.map list literal",
- expr: `[1,2,3,4,5].map(x, [x, x]).filter(z, z.size() == 2)`,
- vars: []*decls.VariableDecl{},
- in: map[string]any{},
- want: 232,
- },
- {
- name: "comprehension on nested list",
- expr: `[1,2,3,4,5].map(x, [x, x]).all(y, y.all(y, y == 1))`,
- want: 171,
- },
- {
- name: "comprehension size",
- expr: `[1,2,3,4,5].map(x, x).map(x, x) + [1]`,
- want: 173,
- },
- {
- name: "nested comprehension",
- expr: `[1,2,3].all(i, i in [1,2,3].map(j, j + j))`,
- want: 86,
- },
+ t.Fatalf("NewActivation() failed: %v", err)
}
+ frame := AsFrame(act)
+ prg.Exec(frame)
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- ctx := constructActivation(t, tc.in)
- var costLimit *uint64
- if tc.limit > 0 {
- costLimit = &tc.limit
- }
- options := tc.options
- if costLimit != nil {
- options = append(options, CostTrackerLimit(*costLimit))
- }
- actualCost, est, err := computeCost(t, tc.expr, tc.vars, ctx, options)
- if err != nil {
- if tc.expectExceedsLimit {
- return
- }
- t.Fatalf("Interpreter.Eval(activation Activation) failed due to: %v", err)
- }
- if tc.expectExceedsLimit {
- t.Fatalf("Interpreter.Eval(activation Activation) failed to return a cost exceeded error for limit %d, got cost %d", tc.limit, actualCost)
- }
- if actualCost != tc.want {
- t.Fatalf("Interpreter.Eval(activation Activation) failed to return expected runtime cost %d, got %d", tc.want, actualCost)
- }
- if est.Min > actualCost || est.Max < actualCost {
- t.Fatalf("Interpreter.Eval(activation Activation) failed to return cost in range of estimate cost [%d, %d], got %d",
- est.Min, est.Max, actualCost)
- }
- })
+ if tracker.ActualCost() != 3 {
+ t.Errorf("tracker.ActualCost() = %d, wanted 3", tracker.ActualCost())
}
}
-func BenchmarkCostTracking(b *testing.B) {
- benchmarks := []struct {
- name string
- expr string
- vars []*decls.VariableDecl
- in map[string]any
- }{
- {
- name: "simple_comparison",
- expr: "x > 10",
- vars: []*decls.VariableDecl{decls.NewVariable("x", types.IntType)},
- in: map[string]any{"x": 15},
- },
- {
- name: "function_calls",
- expr: "str.startsWith('hello') && str.endsWith('world')",
- vars: []*decls.VariableDecl{decls.NewVariable("str", types.StringType)},
- in: map[string]any{"str": "hello beautiful world"},
- },
- {
- name: "comprehension",
- expr: "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(x, x * 2).filter(x, x > 10)",
- },
- {
- name: "nested_comprehensions",
- expr: "[1, 2, 3, 4, 5].all(i, [1, 2, 3, 4, 5].exists(j, i + j == 6))",
- },
+func TestCostLimitExceededPanic(t *testing.T) {
+ tracker, err := NewCostTracker(nil, CostTrackerLimit(5))
+ if err != nil {
+ t.Fatalf("NewCostTracker() failed: %v", err)
}
- for _, bm := range benchmarks {
- b.Run(bm.name, func(b *testing.B) {
- s := common.NewTextSource(bm.expr)
- p, err := parser.NewParser(parser.Macros(parser.AllMacros...))
- if err != nil {
- b.Fatalf("Failed to initialize parser: %v", err)
- }
- parsed, errs := p.Parse(s)
- if len(errs.GetErrors()) != 0 {
- b.Fatalf("Parse(%s) failed: %v", bm.expr, errs.GetErrors())
- }
-
- cont := containers.DefaultContainer
- reg := newTestRegistry(b, types.ProtoTypeDefs(&proto3pb.TestAllTypes{}))
- attrs := NewAttributeFactory(cont, reg, reg)
- env := newTestEnv(b, cont, reg)
- if len(bm.vars) > 0 {
- err = env.AddIdents(bm.vars...)
- if err != nil {
- b.Fatalf("Failed to add idents: %v", err)
- }
- }
- checked, errs := checker.Check(parsed, s, env)
- if len(errs.GetErrors()) != 0 {
- b.Fatalf("Check(%s) failed: %v", bm.expr, errs.GetErrors())
- }
-
- evalCostTracker, err := NewCostTracker(nil)
- if err != nil {
- b.Fatalf("NewCostTracker() failed: %v", err)
- }
- trackerFactory := func() (*CostTracker, error) {
- return evalCostTracker.Clone()
- }
- interp := newStandardInterpreter(b, cont, reg, reg, attrs)
- prg, err := interp.NewInterpretable(checked, CostObserver(CostTrackerFactory(trackerFactory)))
- if err != nil {
- b.Fatalf("NewInterpretable(%s) failed: %v", bm.expr, err)
- }
+ defer func() {
+ r := recover()
+ if r == nil {
+ t.Fatalf("expected panic on cost limit exceeded")
+ }
+ if cancelledErr, ok := r.(EvalCancelledError); !ok || cancelledErr.Cause != CostLimitExceeded {
+ t.Errorf("got panic %v, wanted EvalCancelledError with CostLimitExceeded", r)
+ }
+ }()
- ctx := constructActivation(b, bm.in)
- b.ResetTimer()
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- prg.Eval(ctx)
- }
- })
- }
+ tracker.CreateList(1, nil) // base cost = 10 > 5 -> triggers limitExceededHandler -> panics EvalCancelledError
}
-