diff --git a/expr.go b/expr.go index 950cd14..0abd19e 100644 --- a/expr.go +++ b/expr.go @@ -194,6 +194,7 @@ func NewAggregateEvaluator[T Evaluable]( }, lock: &sync.RWMutex{}, constants: map[uuid.UUID]struct{}{}, + alwaysTrue: map[uuid.UUID]struct{}{}, mixed: map[uuid.UUID]struct{}{}, stopGC: make(chan struct{}), concurrency: opts.Concurrency, @@ -239,6 +240,10 @@ type aggregator[T Evaluable] struct { // the expression containing non-aggregateable clauses. constants map[uuid.UUID]struct{} + // alwaysTrue tracks evaluable IDs whose expression is a constant true literal, + // meaning they always match without requiring CEL evaluation. + alwaysTrue map[uuid.UUID]struct{} + // deleted tracks evaluable IDs that have been soft-deleted. // Remove operations mark items here instead of actually removing them, // avoiding lock contention during evaluation. @@ -261,7 +266,7 @@ type aggregator[T Evaluable] struct { func (a *aggregator[T]) Len() int { a.lock.RLock() defer a.lock.RUnlock() - return int(atomic.LoadInt32(&a.fastLen)) + len(a.mixed) + len(a.constants) + return int(atomic.LoadInt32(&a.fastLen)) + len(a.mixed) + len(a.constants) + len(a.alwaysTrue) } // FastLen returns the number of expressions being matched by aggregated trees. @@ -308,6 +313,22 @@ func (a *aggregator[T]) Evaluate(ctx context.Context, data map[string]any) ([]T, napool := newErrPool(errPoolOpts{concurrency: a.concurrency}) a.lock.RLock() + + // Always-true expressions match without CEL evaluation. + for uuid := range a.alwaysTrue { + if _, deleted := a.deleted.Load(uuid); deleted { + continue + } + item, err := a.kv.Get(uuid) + if err != nil { + continue + } + atomic.AddInt32(&matched, 1) + s.Lock() + result = append(result, item) + s.Unlock() + } + for uuid := range a.constants { // Skip deleted items if _, deleted := a.deleted.Load(uuid); deleted { @@ -492,6 +513,20 @@ func (a *aggregator[T]) Add(ctx context.Context, eval T) (float64, error) { return -1, err } + if parsed.LiteralBool != nil { + if !*parsed.LiteralBool { + // This is a constant false expression which never matches. + // Skip adding it entirely to avoid unnecessary evaluation. + return -1, nil + } + // This is a constant true expression which always matches. + // Add it to the always-true list for fast evaluation without CEL. + a.lock.Lock() + a.alwaysTrue[parsed.EvaluableID] = struct{}{} + a.lock.Unlock() + return -1, nil + } + if eval.GetExpression() == "" || parsed.HasMacros { // This is an empty expression which always matches. a.lock.Lock() @@ -739,6 +774,7 @@ func (a *aggregator[T]) GC(ctx context.Context) bool { a.lock.Lock() for _, id := range constantsToDelete { delete(a.constants, id) + delete(a.alwaysTrue, id) } for _, id := range mixedToDelete { delete(a.mixed, id) diff --git a/expr_test.go b/expr_test.go index ce876a2..826be4d 100644 --- a/expr_test.go +++ b/expr_test.go @@ -58,6 +58,69 @@ func TestAdd(t *testing.T) { require.Equal(t, 1, e.SlowLen()) } +func TestAdd_LiteralFalse(t *testing.T) { + ctx := context.Background() + parser := NewTreeParser(NewCachingCompiler(newEnv(), nil)) + + e := NewAggregateEvaluator(AggregateEvaluatorOpts[testEvaluable]{ + Parser: parser, + Eval: testBoolEvaluator, + Concurrency: 0, + }) + defer e.Close() + + // Add a literal false expression + expr := tex(`false`) + _, err := e.Add(ctx, expr) + require.NoError(t, err) + + // Literal false should not be added to any evaluation path + require.Equal(t, 0, e.Len()) + require.Equal(t, 0, e.FastLen()) + require.Equal(t, 0, e.MixedLen()) + require.Equal(t, 0, e.SlowLen()) + + // Evaluating should return no matches + results, matched, err := e.Evaluate(ctx, map[string]any{ + "event": map[string]any{"data": map[string]any{"foo": "bar"}}, + }) + require.NoError(t, err) + require.Equal(t, int32(0), matched) + require.Len(t, results, 0) +} + +func TestAdd_LiteralTrue(t *testing.T) { + ctx := context.Background() + parser := NewTreeParser(NewCachingCompiler(newEnv(), nil)) + + e := NewAggregateEvaluator(AggregateEvaluatorOpts[testEvaluable]{ + Parser: parser, + Eval: testBoolEvaluator, + Concurrency: 0, + }) + defer e.Close() + + // Add a literal true expression + expr := tex(`true`) + _, err := e.Add(ctx, expr) + require.NoError(t, err) + + // Literal true should be tracked but not in the slow path + require.Equal(t, 1, e.Len()) + require.Equal(t, 0, e.FastLen()) + require.Equal(t, 0, e.MixedLen()) + require.Equal(t, 0, e.SlowLen()) + + // Evaluating should always match the literal true expression + results, matched, err := e.Evaluate(ctx, map[string]any{ + "event": map[string]any{"data": map[string]any{"foo": "bar"}}, + }) + require.NoError(t, err) + require.Equal(t, int32(1), matched) + require.Len(t, results, 1) + require.Equal(t, expr.GetExpression(), results[0].GetExpression()) +} + func TestEvaluate_Strings(t *testing.T) { ctx := context.Background() parser := NewTreeParser(NewCachingCompiler(newEnv(), nil)) diff --git a/parser.go b/parser.go index 5bb9b25..eb6f37d 100644 --- a/parser.go +++ b/parser.go @@ -126,11 +126,27 @@ func (p *parser) Parse(ctx context.Context, eval Evaluable) (*ParsedExpression, } node.normalize() + + // Check if the expression is a constant boolean literal (true or false). + // A bare literal has no predicates, no ands, no ors after normalization. + var literalBool *bool + if !node.HasPredicate() && len(node.Ands) == 0 && len(node.Ors) == 0 && !hasMacros { + nativeExpr := ast.NativeRep().Expr() + if nativeExpr.Kind() == celast.LiteralKind { + if val := nativeExpr.AsLiteral(); val != nil { + if boolVal, ok := val.Value().(bool); ok { + literalBool = &boolVal + } + } + } + } + return &ParsedExpression{ Root: *node, Vars: vars, EvaluableID: eval.GetID(), HasMacros: hasMacros, + LiteralBool: literalBool, }, nil } @@ -154,6 +170,11 @@ type ParsedExpression struct { EvaluableID uuid.UUID HasMacros bool + + // LiteralBool is non-nil when the expression is a constant boolean literal. + // When false, the expression never matches any input. + // When true, the expression always matches any input. + LiteralBool *bool } // RootGroups returns the top-level matching groups within an expression. This is a small