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 } -