Skip to content

Commit 45c2cac

Browse files
Enable peak memory tracking in runtime interpreter and cel options
1 parent ebcee03 commit 45c2cac

9 files changed

Lines changed: 1315 additions & 22 deletions

File tree

cel/memory_test.go

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cel
16+
17+
import (
18+
"context"
19+
"math"
20+
"strings"
21+
"testing"
22+
23+
"cel.dev/cel-go/common/types"
24+
)
25+
26+
func TestMemoryTracking(t *testing.T) {
27+
tests := []struct {
28+
name string
29+
expr string
30+
decls []EnvOption
31+
memOpts []types.MemoryTrackerOption
32+
in any
33+
wantPeak uint32
34+
}{
35+
{
36+
name: "attribute_resolution",
37+
expr: `a`,
38+
decls: []EnvOption{Variable("a", ListType(IntType))},
39+
in: map[string]any{"a": []int64{1, 2, 3}},
40+
// 1 (list container) + 3 elements, then the call-free program peaks at the attribute.
41+
wantPeak: 4,
42+
},
43+
{
44+
name: "call_output",
45+
expr: `a + a`,
46+
decls: []EnvOption{Variable("a", ListType(IntType))},
47+
in: map[string]any{"a": []int64{1, 2}},
48+
// The peak is the call output: a lazy concat list backed by both inputs, sizing
49+
// as their sum (3 + 3 = 6), which exceeds either input observed on its own.
50+
wantPeak: 6,
51+
},
52+
{
53+
name: "attribute_field_selection",
54+
expr: `m.vals`,
55+
decls: []EnvOption{Variable("m", MapType(StringType, ListType(IntType)))},
56+
in: map[string]any{"m": map[string][]int64{"vals": {1, 2, 3}}},
57+
// The resolved attribute value is the inner list: 1 (container) + 3 elements.
58+
wantPeak: 4,
59+
},
60+
}
61+
62+
for _, tst := range tests {
63+
tc := tst
64+
t.Run(tc.name, func(t *testing.T) {
65+
t.Parallel()
66+
env := testEnv(t, tc.decls...)
67+
ast, iss := env.Compile(tc.expr)
68+
if iss.Err() != nil {
69+
t.Fatalf("env.Compile(%v) failed: %v", tc.expr, iss.Err())
70+
}
71+
program, err := env.Program(ast, MemoryTracking(tc.memOpts...))
72+
if err != nil {
73+
t.Fatalf("env.Program() failed: %v", err)
74+
}
75+
_, details, err := program.Eval(tc.in)
76+
if err != nil {
77+
t.Fatalf("program.Eval() failed: %v", err)
78+
}
79+
peak := details.PeakMemory()
80+
if peak == nil {
81+
t.Fatalf("EvalDetails.PeakMemory() got nil, wanted %d", tc.wantPeak)
82+
}
83+
if *peak != tc.wantPeak {
84+
t.Errorf("EvalDetails.PeakMemory() got %d, wanted %d", *peak, tc.wantPeak)
85+
}
86+
if details.MemoryTracker() == nil {
87+
t.Error("EvalDetails.MemoryTracker() got nil, wanted tracker")
88+
}
89+
})
90+
}
91+
}
92+
93+
func TestMemoryTrackingComprehension(t *testing.T) {
94+
env := testEnv(t, Variable("a", ListType(IntType)))
95+
ast, iss := env.Compile(`a.map(x, x * 2)`)
96+
if iss.Err() != nil {
97+
t.Fatalf("env.Compile() failed: %v", iss.Err())
98+
}
99+
program, err := env.Program(ast, MemoryTracking())
100+
if err != nil {
101+
t.Fatalf("env.Program() failed: %v", err)
102+
}
103+
in := map[string]any{"a": []int64{1, 2, 3, 4, 5}}
104+
_, details, err := program.Eval(in)
105+
if err != nil {
106+
t.Fatalf("program.Eval() failed: %v", err)
107+
}
108+
peak := details.PeakMemory()
109+
if peak == nil {
110+
t.Fatal("EvalDetails.PeakMemory() got nil, wanted non-nil peak")
111+
}
112+
// The comprehension result is 1 (container) + 5 elements; the peak must be at least as
113+
// large since the final accumulation observes the input alongside the built list.
114+
if *peak < 6 {
115+
t.Errorf("EvalDetails.PeakMemory() got %d, wanted at least 6", *peak)
116+
}
117+
}
118+
119+
func TestMemoryTrackingConcurrentEval(t *testing.T) {
120+
env := testEnv(t, Variable("a", ListType(IntType)))
121+
ast, iss := env.Compile(`a + a`)
122+
if iss.Err() != nil {
123+
t.Fatalf("env.Compile() failed: %v", iss.Err())
124+
}
125+
program, err := env.Program(ast, MemoryTracking())
126+
if err != nil {
127+
t.Fatalf("env.Program() failed: %v", err)
128+
}
129+
ctx, cancel := context.WithCancel(context.Background())
130+
defer cancel()
131+
res := <-program.ConcurrentEval(ctx, map[string]any{"a": []int64{1, 2}})
132+
if res.Err != nil {
133+
t.Fatalf("program.ConcurrentEval() failed: %v", res.Err)
134+
}
135+
peak := res.EvalDetails.PeakMemory()
136+
if peak == nil {
137+
t.Fatal("EvalDetails.PeakMemory() got nil, wanted non-nil peak")
138+
}
139+
if *peak != 6 {
140+
t.Errorf("EvalDetails.PeakMemory() got %d, wanted 6", *peak)
141+
}
142+
}
143+
144+
func TestMemoryTrackingDisabled(t *testing.T) {
145+
env := testEnv(t, Variable("a", ListType(IntType)))
146+
ast, iss := env.Compile(`a + a`)
147+
if iss.Err() != nil {
148+
t.Fatalf("env.Compile() failed: %v", iss.Err())
149+
}
150+
program, err := env.Program(ast, EvalOptions(OptTrackState))
151+
if err != nil {
152+
t.Fatalf("env.Program() failed: %v", err)
153+
}
154+
_, details, err := program.Eval(map[string]any{"a": []int64{1, 2}})
155+
if err != nil {
156+
t.Fatalf("program.Eval() failed: %v", err)
157+
}
158+
if peak := details.PeakMemory(); peak != nil {
159+
t.Errorf("EvalDetails.PeakMemory() got %d, wanted nil when tracking disabled", *peak)
160+
}
161+
if tracker := details.MemoryTracker(); tracker != nil {
162+
t.Errorf("EvalDetails.MemoryTracker() got %v, wanted nil when tracking disabled", tracker)
163+
}
164+
}
165+
166+
func TestMemoryLimit(t *testing.T) {
167+
tests := []struct {
168+
name string
169+
memLimit uint32
170+
wantErr string
171+
}{
172+
{
173+
name: "under_limit",
174+
memLimit: 1000,
175+
},
176+
{
177+
name: "over_limit",
178+
memLimit: 5,
179+
wantErr: "memory limit exceeded",
180+
},
181+
}
182+
for _, tst := range tests {
183+
tc := tst
184+
t.Run(tc.name, func(t *testing.T) {
185+
t.Parallel()
186+
env := testEnv(t, Variable("a", ListType(IntType)))
187+
ast, iss := env.Compile(`a + a`)
188+
if iss.Err() != nil {
189+
t.Fatalf("env.Compile() failed: %v", iss.Err())
190+
}
191+
program, err := env.Program(ast, MemoryLimit(tc.memLimit))
192+
if err != nil {
193+
t.Fatalf("env.Program() failed: %v", err)
194+
}
195+
_, _, err = program.Eval(map[string]any{"a": []int64{1, 2, 3}})
196+
if tc.wantErr == "" {
197+
if err != nil {
198+
t.Fatalf("program.Eval() failed: %v", err)
199+
}
200+
return
201+
}
202+
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
203+
t.Fatalf("program.Eval() got error %v, wanted error containing %q", err, tc.wantErr)
204+
}
205+
})
206+
}
207+
}
208+
209+
func TestMemoryTrackingCalculationLimitExceeded(t *testing.T) {
210+
env := testEnv(t, Variable("a", ListType(StringType)))
211+
ast, iss := env.Compile(`a`)
212+
if iss.Err() != nil {
213+
t.Fatalf("env.Compile() failed: %v", iss.Err())
214+
}
215+
program, err := env.Program(ast,
216+
MemoryTracking(
217+
types.MemoryTrackerSizeCalculator(
218+
types.NewSizeCalculator(types.SizeCalculatorMaxTraversal(2)))))
219+
if err != nil {
220+
t.Fatalf("env.Program() failed: %v", err)
221+
}
222+
_, details, err := program.Eval(map[string]any{"a": []string{"a", "b", "c", "d", "e"}})
223+
if err != nil {
224+
t.Fatalf("program.Eval() failed: %v", err)
225+
}
226+
tracker := details.MemoryTracker()
227+
if tracker == nil {
228+
t.Fatal("EvalDetails.MemoryTracker() got nil, wanted tracker")
229+
}
230+
if !tracker.CalculationLimitExceeded() {
231+
t.Error("MemoryTracker.CalculationLimitExceeded() got false, wanted true")
232+
}
233+
if peak := details.PeakMemory(); peak == nil || *peak != math.MaxUint32 {
234+
t.Errorf("EvalDetails.PeakMemory() got %v, wanted MaxUint32", peak)
235+
}
236+
}

cel/options.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,10 @@ const (
729729
//
730730
// Deprecated: use ext.StringsValidateFormatCalls() as this option is now a no-op.
731731
OptCheckStringFormat EvalOption = 1 << iota
732+
733+
// OptTrackMemory enables the runtime peak memory tracking and returns the peak watermark within
734+
// evalDetails via func PeakMemory()
735+
OptTrackMemory EvalOption = 1 << iota
732736
)
733737

734738
// EvalOptions sets one or more evaluation options which may affect the evaluation or Result.
@@ -818,6 +822,36 @@ func CostTracking(costEstimator interpreter.ActualCostEstimator) ProgramOption {
818822
}
819823
}
820824

825+
// MemoryTracking enables peak memory tracking during evaluation with an optional set of
826+
// types.MemoryTrackerOption values to configure the tracker's size calculator, sample
827+
// interval, and limit behaviors.
828+
//
829+
// Peak memory is measured in aggregate element counts as computed by a types.SizeCalculator
830+
// and is observed at the points where values materialize during evaluation: resolved
831+
// attributes, call results, constructed aggregates, and comprehension results. The peak
832+
// watermark is available via the EvalDetails.PeakMemory() method.
833+
func MemoryTracking(memOpts ...types.MemoryTrackerOption) ProgramOption {
834+
return func(p *prog) (*prog, error) {
835+
p.memoryOptions = append(p.memoryOptions, memOpts...)
836+
p.evalOpts |= OptTrackMemory
837+
return p, nil
838+
}
839+
}
840+
841+
// MemoryLimit enables memory tracking and configures program evaluation to exit early with a
842+
// "memory limit exceeded" error if the peak tracked memory exceeds the limit.
843+
//
844+
// The MemoryLimit is a metric that corresponds to the aggregate element counts of the values
845+
// observed during evaluation. It is indicative of memory usage, not CPU usage; see CostLimit
846+
// for bounding compute.
847+
func MemoryLimit(memLimit uint32) ProgramOption {
848+
return func(p *prog) (*prog, error) {
849+
p.memoryLimit = &memLimit
850+
p.evalOpts |= OptTrackMemory
851+
return p, nil
852+
}
853+
}
854+
821855
// CostLimit enables cost tracking and sets configures program evaluation to exit early with a
822856
// "runtime cost limit exceeded" error if the runtime cost exceeds the costLimit.
823857
// The CostLimit is a metric that corresponds to the number and estimated expense of operations

0 commit comments

Comments
 (0)