diff --git a/.github/scripts/benchstat-summary.py b/.github/scripts/benchstat-summary.py index c1acb882d..6eb888c2c 100644 --- a/.github/scripts/benchstat-summary.py +++ b/.github/scripts/benchstat-summary.py @@ -1,136 +1,162 @@ #!/usr/bin/env python3 -""" -benchstat-summary.py — parse benchstat output and produce a markdown summary. - -Usage: benchstat-summary.py [--threshold PCT] benchstat.txt - -Extracts significant regressions/improvements from benchstat output. -Exit code 1 if any regression exceeds the threshold (default: 5%). -""" +"""Render significant benchstat CSV changes and enforce regression thresholds.""" import argparse +import csv import re import sys from dataclasses import dataclass +from typing import Iterable + +CHANGE_RE = re.compile(r"^([+-]\d+(?:\.\d+)?)%$") -@dataclass + +@dataclass(frozen=True) class Change: name: str + metric: str base: str head: str pct: float pval: str -# Matches lines like: -# BenchmarkName-4 1.030m ± 3% 1.304m ± 5% +26.57% (p=0.000 n=10) -# BenchmarkName-4 3.997 ± 1% 3.910 ± 0% -2.16% (p=0.000 n=10) -LINE_RE = re.compile( - r"^(\S+)" # benchmark name - r"\s+" - r"(\S+)" # base value - r"\s+±\s+\d+%" # base variance - r"\s+" - r"(\S+)" # head value - r"\s+±\s+\d+%" # head variance - r"\s+" - r"([+-]\d+\.\d+)%" # percentage change - r"\s+" - r"\(p=(\d+\.\d+)" # p-value -) +def parse_benchstat_rows(rows: Iterable[list[str]]) -> list[Change]: + changes: list[Change] = [] + columns: tuple[str, int, int, int, int] | None = None + found_table = False + for row in rows: + if not row: + continue + if row[0] == "" and "vs base" in row: + metric_indexes = [ + index + for index, value in enumerate(row) + if value and value not in {"CI", "vs base", "P"} + ] + if len(metric_indexes) < 2: + raise ValueError(f"invalid benchstat metric header: {row}") + columns = ( + row[metric_indexes[0]], + metric_indexes[0], + metric_indexes[1], + row.index("vs base"), + row.index("P"), + ) + found_table = True + continue + if columns is None or row[0] == "geomean": + continue + + metric, base_index, head_index, change_index, pval_index = columns + required_length = max(base_index, head_index, change_index, pval_index) + 1 + row.extend([""] * (required_length - len(row))) + if not row[base_index] or not row[head_index]: + continue + match = CHANGE_RE.match(row[change_index]) + if match is None: + continue + changes.append( + Change( + name=row[0], + metric=metric, + base=format_metric(row[base_index], metric), + head=format_metric(row[head_index], metric), + pct=float(match.group(1)), + pval=row[pval_index].removeprefix("p=").split()[0], + ) + ) + if not found_table: + raise ValueError("input contains no benchstat CSV metric tables") + return changes def parse_benchstat(path: str) -> list[Change]: - changes = [] - with open(path) as f: - for line in f: - stripped = line.strip() - if stripped.startswith("geomean"): - continue - m = LINE_RE.match(stripped) - if not m: - continue - changes.append( - Change( - name=m.group(1), - base=m.group(2), - head=m.group(3), - pct=float(m.group(4)), - pval=m.group(5), - ) - ) - return changes + with open(path, newline="", encoding="utf-8") as source: + return parse_benchstat_rows(csv.reader(source)) + + +def format_metric(raw: str, metric: str) -> str: + value = float(raw) + if metric == "sec/op": + for scale, suffix in ((1, "s/op"), (1e3, "ms/op"), (1e6, "us/op"), (1e9, "ns/op")): + converted = value * scale + if converted >= 1: + return f"{converted:.3g} {suffix}" + if metric in {"B/op", "allocs/op"}: + return f"{value:.3g} {metric}" + return f"{value:.3g} {metric}" + + +def regressions_above_threshold(changes: list[Change], threshold: float) -> list[Change]: + return sorted( + [change for change in changes if change.pct > threshold], + key=lambda change: -change.pct, + ) def render_markdown(changes: list[Change], threshold: float) -> str: - regressions = sorted([c for c in changes if c.pct > 0], key=lambda c: -c.pct) - improvements = sorted([c for c in changes if c.pct < 0], key=lambda c: c.pct) - + regressions = sorted([change for change in changes if change.pct > 0], key=lambda change: -change.pct) + improvements = sorted([change for change in changes if change.pct < 0], key=lambda change: change.pct) if not regressions and not improvements: return "### No significant performance changes detected\n" lines: list[str] = [] - if regressions: - above = [r for r in regressions if r.pct > threshold] + above = regressions_above_threshold(changes, threshold) + heading = f"### {len(regressions)} minor regression(s) (all within {threshold:g}% threshold)\n" if above: - lines.append( - f"### {len(regressions)} regression(s) detected (threshold: >{threshold:g}%)\n" - ) - else: - lines.append( - f"### {len(regressions)} minor regression(s) (all within {threshold:g}% threshold)\n" - ) - - lines.append("| Benchmark | Base | Head | Change | p-value |") - lines.append("|-----------|------|------|--------|---------|") - for r in regressions: - change = f"+{r.pct:.2f}%" - if r.pct > threshold: - change = f"**{change}**" - lines.append(f"| `{r.name}` | {r.base} | {r.head} | {change} | {r.pval} |") - lines.append("") + heading = f"### {len(regressions)} regression(s) detected (threshold: >{threshold:g}%)\n" + lines.append(heading) + lines.extend(render_table(regressions, threshold, emphasize_regressions=True)) if improvements: - lines.append(f"
") + lines.append("
") lines.append(f"{len(improvements)} improvement(s)\n") - lines.append("| Benchmark | Base | Head | Change | p-value |") - lines.append("|-----------|------|------|--------|---------|") - for imp in improvements: - lines.append( - f"| `{imp.name}` | {imp.base} | {imp.head} | {imp.pct:.2f}% | {imp.pval} |" - ) - lines.append("") + lines.extend(render_table(improvements, threshold, emphasize_regressions=False)) lines.append("
") lines.append("") - return "\n".join(lines) -def main(): +def render_table(changes: list[Change], threshold: float, emphasize_regressions: bool) -> list[str]: + lines = [ + "| Benchmark | Metric | Base | Head | Change | p-value |", + "|-----------|--------|------|------|--------|---------|", + ] + for change in changes: + percentage = f"{change.pct:+.2f}%" + if emphasize_regressions and change.pct > threshold: + percentage = f"**{percentage}**" + lines.append( + f"| `{change.name}` | {change.metric} | {change.base} | {change.head} | {percentage} | {change.pval} |" + ) + lines.append("") + return lines + + +def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("input", help="Path to benchstat output file") - parser.add_argument( - "--threshold", - type=float, - default=5, - help="Regression percentage threshold to flag as failure (default: 5)", - ) + parser.add_argument("input", help="Path to benchstat CSV output") + parser.add_argument("--threshold", type=float, default=5, help="Regression threshold percentage") + parser.add_argument("--no-fail", action="store_true", help="Render regressions without returning a failure") args = parser.parse_args() - changes = parse_benchstat(args.input) - summary = render_markdown(changes, args.threshold) - print(summary) + try: + changes = parse_benchstat(args.input) + except (OSError, ValueError) as error: + parser.error(str(error)) + print(render_markdown(changes, args.threshold)) - # Exit 1 if any regression exceeds the threshold - regressions_above_threshold = [c for c in changes if c.pct > args.threshold] - if regressions_above_threshold: - print(f"\nFailed: {len(regressions_above_threshold)} benchmark(s) regressed by more than {args.threshold:g}%:") - for c in sorted(regressions_above_threshold, key=lambda c: -c.pct): - print(f" {c.name}: {c.base} -> {c.head} (+{c.pct:.2f}%)") - sys.exit(1) + regressions = regressions_above_threshold(changes, args.threshold) + if regressions and not args.no_fail: + print(f"\nFailed: {len(regressions)} metric(s) regressed by more than {args.threshold:g}%:") + for change in regressions: + print(f" {change.name} {change.metric}: {change.base} -> {change.head} ({change.pct:+.2f}%)") + return 1 + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/.github/scripts/benchstat-summary_test.py b/.github/scripts/benchstat-summary_test.py new file mode 100644 index 000000000..3feaed282 --- /dev/null +++ b/.github/scripts/benchstat-summary_test.py @@ -0,0 +1,64 @@ +import csv +import io +import runpy +from pathlib import Path + +import pytest + + +SCRIPT = runpy.run_path(Path(__file__).with_name("benchstat-summary.py")) +parse_benchstat_rows = SCRIPT["parse_benchstat_rows"] +regressions_above_threshold = SCRIPT["regressions_above_threshold"] +render_markdown = SCRIPT["render_markdown"] + +BENCHSTAT_CSV = """goos: linux +goarch: amd64 +,.tmp/base.txt,,.tmp/head.txt,,, +,sec/op,CI,sec/op,CI,vs base,P +Thing-8,1e-06,± 1%,1.2e-06,± 1%,+20.00%,p=0.008 n=10 +Stable-8,2e-06,± 1%,2.01e-06,± 1%,~,p=0.310 n=10 +HeadOnly-8,,,3e-06,± 1% +geomean,1e-06,,1.2e-06,,+20.00%, + +,.tmp/base.txt,,.tmp/head.txt,,, +,B/op,CI,B/op,CI,vs base,P +Thing-8,200,± 0%,250,± 0%,+25.00%,p=0.008 n=10 + +,.tmp/base.txt,,.tmp/head.txt,,, +,allocs/op,CI,allocs/op,CI,vs base,P +Thing-8,4,± 0%,3,± 0%,-25.00%,p=0.008 n=10 +""" + + +def test_parse_benchstat_rows_preserves_metric_identity_and_formats_values(): + changes = parse_benchstat_rows(csv.reader(io.StringIO(BENCHSTAT_CSV))) + + assert [(change.name, change.metric, change.base, change.head, change.pct) for change in changes] == [ + ("Thing-8", "sec/op", "1 us/op", "1.2 us/op", 20.0), + ("Thing-8", "B/op", "200 B/op", "250 B/op", 25.0), + ("Thing-8", "allocs/op", "4 allocs/op", "3 allocs/op", -25.0), + ] + + +def test_render_markdown_emphasizes_only_regressions_above_threshold(): + changes = parse_benchstat_rows(csv.reader(io.StringIO(BENCHSTAT_CSV))) + + rendered = render_markdown(changes, threshold=20) + + assert "| Benchmark | Metric | Base | Head | Change | p-value |" in rendered + assert "| `Thing-8` | sec/op | 1 us/op | 1.2 us/op | +20.00% | 0.008 |" in rendered + assert "| `Thing-8` | B/op | 200 B/op | 250 B/op | **+25.00%** | 0.008 |" in rendered + assert "1 improvement(s)" in rendered + + +def test_regression_gate_checks_each_metric(): + changes = parse_benchstat_rows(csv.reader(io.StringIO(BENCHSTAT_CSV))) + + regressions = regressions_above_threshold(changes, threshold=20) + + assert [(change.metric, change.pct) for change in regressions] == [("B/op", 25.0)] + + +def test_parse_benchstat_rows_rejects_non_csv_output(): + with pytest.raises(ValueError, match="no benchstat CSV metric tables"): + parse_benchstat_rows(csv.reader(io.StringIO("BenchmarkThing 1 ns/op\n"))) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 2de8d0a1c..7702005c8 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -17,7 +17,7 @@ jobs: - name: Install Go uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2 with: - go-version: 1.25.x + go-version: 1.26.x cache: false - name: Checkout code uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -66,29 +66,41 @@ jobs: run: | GOBIN="$PWD/.bin" go install golang.org/x/perf/cmd/benchstat@82a0b07e230d76fa1b3036c383d7a98172f87334 echo "$PWD/.bin" >> "$GITHUB_PATH" + - name: Test benchmark summary + run: | + python3 -m pip install pytest==9.0.3 + python3 -m pytest .github/scripts/benchstat-summary_test.py - name: Prepare base worktree run: | - mkdir -p .bench - git worktree add .bench/base "${{ github.event.pull_request.base.sha }}" + mkdir -p .tmp/bench + git worktree add .tmp/bench/base "${{ github.event.pull_request.base.sha }}" # Override the base's bench files with HEAD's so both runs execute # the same benchmark suite — only the implementation under test differs. - cp serialize_bench_test.go .bench/base/serialize_bench_test.go - cp run_expression_bench_test.go .bench/base/run_expression_bench_test.go - - name: Benchmark base + cp serialize_bench_test.go .tmp/bench/base/serialize_bench_test.go + cp run_expression_bench_test.go .tmp/bench/base/run_expression_bench_test.go + cp cel_v031_bench_test.go .tmp/bench/base/cel_v031_bench_test.go + - name: Compile benchmark binaries run: | - cd .bench/base - go test -run=^$ -bench='BenchmarkSerialize|BenchmarkRunExpressionContext' -count=6 -timeout 20m \ - github.com/flanksource/gomplate/v3 | tee "$GITHUB_WORKSPACE/bench-base.txt" - - name: Benchmark head + cd .tmp/bench/base + go test -c -o "$GITHUB_WORKSPACE/.tmp/bench/base.test" github.com/flanksource/gomplate/v3 + cd "$GITHUB_WORKSPACE" + go test -c -o .tmp/bench/head.test github.com/flanksource/gomplate/v3 + - name: Benchmark interleaved base and head run: | - go test -run=^$ -bench='BenchmarkSerialize|BenchmarkRunExpressionContext' -count=6 -timeout 20m \ - github.com/flanksource/gomplate/v3 | tee bench-head.txt + : > bench-base.txt + : > bench-head.txt + for sample in {1..10} + do + echo "Running base sample ${sample}/10" + .tmp/bench/base.test -test.run=^$ -test.bench='Benchmark(Serialize|RunExpressionContext|CELEnvExtend|CELProgramEvaluation|RunExpressionNativeInput)' -test.benchmem=true -test.benchtime=1s -test.count=1 -test.timeout=20m >> bench-base.txt + echo "Running head sample ${sample}/10" + .tmp/bench/head.test -test.run=^$ -test.bench='Benchmark(Serialize|RunExpressionContext|CELEnvExtend|CELProgramEvaluation|RunExpressionNativeInput)' -test.benchmem=true -test.benchtime=1s -test.count=1 -test.timeout=20m >> bench-head.txt + done - name: Compare run: | benchstat bench-base.txt bench-head.txt > benchstat.txt - - # Generate the summary (ignore exit code here; we check it in the next step) - python3 .github/scripts/benchstat-summary.py benchstat.txt > bench-summary.md || true + benchstat -format csv bench-base.txt bench-head.txt > benchstat.csv + python3 .github/scripts/benchstat-summary.py --no-fail benchstat.csv > bench-summary.md { echo "" @@ -97,13 +109,19 @@ jobs: echo "Base: \`${{ github.event.pull_request.base.sha }}\`" echo "Head: \`${{ github.event.pull_request.head.sha }}\`" echo "" - cat bench-summary.md + while IFS= read -r line + do + echo "$line" + done < bench-summary.md echo "" echo '
' echo 'Full benchstat output' echo "" echo '```text' - cat benchstat.txt + while IFS= read -r line + do + echo "$line" + done < benchstat.txt echo '```' echo "" echo '
' @@ -116,6 +134,8 @@ jobs: bench-base.txt bench-head.txt benchstat.txt + benchstat.csv + bench-summary.md bench-report.md retention-days: 14 - name: Post report to PR @@ -151,4 +171,4 @@ jobs: }); } - name: Check for regressions - run: python3 .github/scripts/benchstat-summary.py --threshold 5 benchstat.txt > /dev/null + run: python3 .github/scripts/benchstat-summary.py --threshold 5 benchstat.csv > /dev/null diff --git a/.gitignore b/.gitignore index 975a8c365..1f81b148e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .git .bin +.tmp +__pycache__/ +.pytest_cache/ bin report.xml ./gomplate @@ -8,4 +11,4 @@ report.xml *.out *.test -.vscode \ No newline at end of file +.vscode diff --git a/CEL.md b/CEL.md index 8ebf56669..8d7e9aa77 100644 --- a/CEL.md +++ b/CEL.md @@ -19,6 +19,27 @@ CEL expressions use the [Common Expression Language (CEL)](https://cel.dev/). | `null_type` | The value `null` | | `type` | Values representing the types above | +### Native Go struct types + +Register a Go struct type to expose it directly to CEL instead of converting top-level values of that type to maps: + +```go +type Person struct { + DisplayName string `json:"display_name"` +} + +if err := gomplate.RegisterType(Person{}); err != nil { + return err +} + +result, err := gomplate.RunExpression( + map[string]any{"person": Person{DisplayName: "Ada"}}, + gomplate.Template{Expression: "person.display_name"}, +) +``` + +Registration is process-wide, concurrency-safe, and applies to subsequent CEL compilations. JSON field names are honored; a JSON tag containing only options, such as `json:",omitempty"`, retains the Go field name. When an expression returns a registered top-level value directly, the result is the original Go value. Unregistered values and Go-template evaluation retain the existing serialization behavior. + --- ## Standard Operators @@ -1300,6 +1321,8 @@ Determines if a string matches a regular expression pattern. "12345".matches("^\\d+$") // true ``` +Built-in CEL regex operations, including `.matches()`, reject regex programs larger than 10,000 instructions. This limit applies to both literal and dynamically supplied patterns. It does not apply to gomplate's separate `regexp.*` functions. + ### .quote Makes a string safe to print by escaping special characters. diff --git a/Makefile b/Makefile index b3fb1abf9..2afcab46f 100644 --- a/Makefile +++ b/Makefile @@ -144,7 +144,8 @@ $(PREFIX)/bin/$(PKG_NAME)_%$(TARGETVARIANT)$(call extension,$(GOOS)): $(shell fi $(PREFIX)/bin/$(PKG_NAME)$(call extension,$(GOOS)): $(PREFIX)/bin/$(PKG_NAME)_$(GOOS)-$(GOARCH)$(TARGETVARIANT)$(call extension,$(GOOS)) cp $< $@ -build: $(PREFIX)/bin/$(PKG_NAME)_$(GOOS)-$(GOARCH)$(TARGETVARIANT)$(call extension,$(GOOS)) $(PREFIX)/bin/$(PKG_NAME)$(call extension,$(GOOS)) +build: + $(GO) build ./... ifeq ($(OS),Windows_NT) test: diff --git a/README.md b/README.md index b07fd246e..a66b1766e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Flanksource Gomplate is a fork of [hairyhenderson/gomplate](https://github.com/h - **Go Text/Template** – Full [Go `text/template`](https://pkg.go.dev/text/template) support with an extended function library from gomplate (base64, collections, crypto, data formats, filepath, math, random, regexp, strings, time, and more) - **CEL (Common Expression Language)** – [CEL](https://cel.dev/) support with: - Standard CEL operators and built-ins - - [celext](https://github.com/google/cel-go/tree/master/ext) extensions (strings, encoders, lists, math, sets) + - [cel-go extensions](https://github.com/cel-expr/cel-go/tree/v0.31.0/ext) (strings, encoders, lists, math, sets) - Kubernetes-specific helpers (`k8s.*`) - AWS helpers (`aws.*`) - Many gomplate functions remapped into CEL (`base64`, `math`, `random`, `regexp`, `filepath`, `crypto`, `sets`, etc.) diff --git a/cel.go b/cel.go index 017f028fc..4d331268e 100644 --- a/cel.go +++ b/cel.go @@ -3,7 +3,6 @@ package gomplate import ( gocontext "context" "fmt" - "reflect" "regexp" "sync" @@ -21,11 +20,7 @@ import ( "github.com/flanksource/gomplate/v3/strings" ) -var typeAdapters = []cel.EnvOption{} - -func RegisterType(i any) { - typeAdapters = append(typeAdapters, ext.NativeTypes(reflect.TypeOf(i))) -} +const celRegexProgramSizeLimit = 10_000 // staticCelEnvOptions returns the environment-independent CEL options: the // generated functions, the kubernetes library, the cel-go extensions, the @@ -48,6 +43,7 @@ func staticCelEnvOptions() []cel.EnvOption { opts = append(opts, getGoTemplateCelFunction()) opts = append(opts, getDebugCelFunction()) opts = append(opts, getFoldCelLibrary()) + opts = append(opts, cel.RegexProgramSizeLimit(celRegexProgramSizeLimit)) return opts } @@ -62,8 +58,8 @@ func staticCelEnvOptions() []cel.EnvOption { // to validate its declarations up front so Extend reuses them and only validates // the small per-call delta. // -// Env.Extend deep-copies the environment and never mutates the receiver, so the -// cached base env is safe to share across goroutines. +// Env.Extend uses copy-on-write and never mutates the receiver, so the cached +// base env is safe to share across goroutines. var baseCelEnv = sync.OnceValues(func() (*cel.Env, error) { opts := staticCelEnvOptions() opts = append(opts, cel.EagerlyValidateDeclarations(true)) @@ -79,7 +75,9 @@ var baseCelEnv = sync.OnceValues(func() (*cel.Env, error) { // option set via staticCelEnvOptions. func GetCelEnv(environment map[string]any) []cel.EnvOption { opts := staticCelEnvOptions() - opts = append(opts, typeAdapters...) + if nativeTypes := currentNativeTypes(); nativeTypes.envOption != nil { + opts = append(opts, nativeTypes.envOption) + } // Load input as variables for k := range environment { diff --git a/cel_expression.go b/cel_expression.go new file mode 100644 index 000000000..2a7bfe468 --- /dev/null +++ b/cel_expression.go @@ -0,0 +1,142 @@ +package gomplate + +import ( + "fmt" + "strconv" + "strings" + "time" + + commonsContext "github.com/flanksource/commons/context" + "github.com/flanksource/commons/properties" + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/patrickmn/go-cache" + "github.com/samber/oops" +) + +var celExpressionCache = cache.New(time.Hour, time.Hour) + +func RunExpression(environment map[string]any, template Template) (any, error) { + return RunExpressionContext(newContext(), environment, template) +} + +func RunExpressionContext(ctx commonsContext.Context, environment map[string]any, template Template) (any, error) { + tracker := celTrackerFromContext(ctx) + if tracker != nil { + if err := tracker.begin(); err != nil { + return nil, err + } + defer tracker.abort() + } + + nativeTypes := currentNativeTypes() + data, err := serializeForCEL(environment, nativeTypes) + if err != nil { + return "", err + } + cacheKey := template.celCacheKey(environment, nativeTypes.generation) + + var program cel.Program + var ast *cel.Ast + if tracker == nil && template.IsCacheable() { + cached, found := celExpressionCache.Get(cacheKey) + if found { + if cachedProgram, ok := cached.(*cel.Program); ok { + program = *cachedProgram + } + } + } + + if program == nil { + program, ast, err = compileCELProgram(data, template, nativeTypes, tracker != nil) + if err != nil { + return "", err + } + if tracker == nil && template.IsCacheable() { + celExpressionCache.Set(cacheKey, &program, template.CacheTime) + } + } + + out, details, err := program.Eval(data) + if tracker != nil { + tracker.complete(ast, details, out) + } + if err != nil { + return nil, oops.With("template", template.Expression).Wrap(err) + } + if ctx.Logger != nil && out.Value() != template.Expression && properties.On(false, "gomplate.log") { + ctx.Logger.V(4).Infof("templated %s => %v", template.ShortString(), out) + } + return out.Value(), nil +} + +func compileCELProgram(data map[string]any, template Template, nativeTypes *nativeTypeSnapshot, trackState bool) (cel.Program, *cel.Ast, error) { + base, err := baseCelEnv() + if err != nil { + return nil, nil, err + } + + var typeAdapter ref.TypeAdapter = types.DefaultTypeAdapter + envOptions := celEnvOptions(data, template, nativeTypes, func(value any) ref.Val { + return typeAdapter.NativeToValue(value) + }) + env, err := base.Extend(envOptions...) + if err != nil { + return nil, nil, err + } + typeAdapter = env.TypeAdapter() + expression := strings.ReplaceAll(template.Expression, "\n", " ") + if trackState { + expression = template.Expression + } + ast, issues := env.Compile(expression) + if issues != nil && issues.Err() != nil { + return nil, nil, oops.With("template", template.Expression).Errorf("issues: %s", issues.String()) + } + + evalOptions := []cel.EvalOption{cel.OptOptimize} + if trackState { + evalOptions = append(evalOptions, cel.OptTrackState) + } + program, err := env.Program(ast, cel.EvalOptions(evalOptions...)) + if err != nil { + return nil, nil, err + } + return program, ast, nil +} + +func celEnvOptions(data map[string]any, template Template, nativeTypes *nativeTypeSnapshot, adaptValue func(any) ref.Val) []cel.EnvOption { + envOptions := make([]cel.EnvOption, 0, len(data)+len(template.Functions)+len(template.CelEnvs)+1) + if nativeTypes.envOption != nil { + envOptions = append(envOptions, nativeTypes.envOption) + } + for key := range data { + envOptions = append(envOptions, cel.Variable(key, cel.AnyType)) + } + for name, function := range template.Functions { + functionName := name + registeredFunction := function + envOptions = append(envOptions, cel.Function(functionName, cel.Overload( + functionName, + nil, + cel.AnyType, + cel.FunctionBinding(func(_ ...ref.Val) ref.Val { + function, ok := registeredFunction.(func() any) + if !ok { + return types.WrapErr(fmt.Errorf("%s is expected to be of type func() any", functionName)) + } + return adaptValue(function()) + }), + ))) + } + envOptions = append(envOptions, template.CelEnvs...) + return envOptions +} + +func (t Template) celCacheKey(environment map[string]any, nativeTypeGeneration uint64) string { + if nativeTypeGeneration == 0 { + return t.cacheKey(environment) + } + return strconv.FormatUint(nativeTypeGeneration, 10) + ":" + t.cacheKey(environment) +} diff --git a/cel_native.go b/cel_native.go new file mode 100644 index 000000000..a248644b0 --- /dev/null +++ b/cel_native.go @@ -0,0 +1,154 @@ +package gomplate + +import ( + "fmt" + "maps" + "reflect" + "strings" + "sync" + "sync/atomic" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/ext" + "google.golang.org/protobuf/proto" +) + +type nativeTypeSnapshot struct { + items []any + reflectTypes map[reflect.Type]struct{} + keys map[string]struct{} + generation uint64 + envOption cel.EnvOption +} + +var emptyNativeTypeSnapshot = &nativeTypeSnapshot{ + reflectTypes: map[reflect.Type]struct{}{}, + keys: map[string]struct{}{}, +} + +var nativeTypeRegistry struct { + sync.Mutex + snapshot atomic.Pointer[nativeTypeSnapshot] +} + +func currentNativeTypes() *nativeTypeSnapshot { + if snapshot := nativeTypeRegistry.snapshot.Load(); snapshot != nil { + return snapshot + } + return emptyNativeTypeSnapshot +} + +// RegisterType makes a Go or CEL type available to subsequent CEL evaluations. +func RegisterType(value any) error { + item, reflectType, key, err := nativeTypeRegistration(value) + if err != nil { + return err + } + + nativeTypeRegistry.Lock() + defer nativeTypeRegistry.Unlock() + + current := currentNativeTypes() + if _, found := current.keys[key]; found { + return nil + } + + items := append(append([]any(nil), current.items...), item) + args := make([]any, 0, len(items)+1) + args = append(args, ext.ParseStructField(jsonCELFieldName)) + args = append(args, items...) + envOption := ext.NativeTypes(args...) + if _, err := cel.NewEnv(envOption); err != nil { + return fmt.Errorf("register CEL type %s: %w", key, err) + } + + reflectTypes := maps.Clone(current.reflectTypes) + addRegisteredReflectType(reflectTypes, reflectType) + keys := maps.Clone(current.keys) + keys[key] = struct{}{} + nativeTypeRegistry.snapshot.Store(&nativeTypeSnapshot{ + items: items, + reflectTypes: reflectTypes, + keys: keys, + generation: current.generation + 1, + envOption: envOption, + }) + return nil +} + +func nativeTypeRegistration(value any) (item any, reflectType reflect.Type, key string, err error) { + if value == nil { + return nil, nil, "", fmt.Errorf("register CEL type: value is nil") + } + + switch typed := value.(type) { + case reflect.Type: + if typed == nil { + return nil, nil, "", fmt.Errorf("register CEL type: reflect.Type is nil") + } + return typed, typed, registeredReflectTypeKey(typed), nil + case reflect.Value: + if !typed.IsValid() { + return nil, nil, "", fmt.Errorf("register CEL type: reflect.Value is invalid") + } + return typed, typed.Type(), registeredReflectTypeKey(typed.Type()), nil + case proto.Message: + reflectType = reflect.TypeOf(typed) + if reflectType.Kind() == reflect.Pointer && reflect.ValueOf(typed).IsNil() { + return nil, nil, "", fmt.Errorf("register CEL type: protobuf message is nil") + } + return typed, reflectType, "proto:" + string(typed.ProtoReflect().Descriptor().FullName()), nil + case types.StructTypeDescriptor: + refType, ok := typed.(ref.Type) + if !ok { + return nil, nil, "", fmt.Errorf("register CEL type: descriptor %T must also implement ref.Type", typed) + } + return refType, typed.ReflectType(), "cel:" + refType.TypeName(), nil + case ref.Type: + return typed, nil, "cel:" + typed.TypeName(), nil + default: + reflectType = reflect.TypeOf(value) + return reflectType, reflectType, registeredReflectTypeKey(reflectType), nil + } +} + +func registeredReflectTypeKey(reflectType reflect.Type) string { + for reflectType.Kind() == reflect.Pointer { + reflectType = reflectType.Elem() + } + return "reflect:" + reflectType.PkgPath() + ":" + reflectType.String() +} + +func addRegisteredReflectType(registered map[reflect.Type]struct{}, reflectType reflect.Type) { + if reflectType == nil { + return + } + registered[reflectType] = struct{}{} + if reflectType.Kind() == reflect.Pointer { + registered[reflectType.Elem()] = struct{}{} + } else { + registered[reflect.PointerTo(reflectType)] = struct{}{} + } +} + +func (snapshot *nativeTypeSnapshot) preserves(value any) bool { + if snapshot == nil || value == nil { + return false + } + _, found := snapshot.reflectTypes[reflect.TypeOf(value)] + return found +} + +func jsonCELFieldName(field reflect.StructField) string { + tag, found := field.Tag.Lookup("json") + if !found { + return field.Name + } + name := strings.Split(tag, ",")[0] + if name == "" { + return field.Name + } + return name +} diff --git a/cel_native_test.go b/cel_native_test.go new file mode 100644 index 000000000..37b34d0c1 --- /dev/null +++ b/cel_native_test.go @@ -0,0 +1,196 @@ +package gomplate + +import ( + "fmt" + "reflect" + "strings" + "sync" + + "github.com/google/cel-go/common/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type registeredCELPerson struct { + DisplayName string `json:"display_name"` + Nickname string `json:",omitempty"` + Ignored string `json:"-"` +} + +type cachedCELPerson struct { + Name string `json:"name"` +} + +type describedCELPerson struct { + Name string `json:"name"` +} + +type concurrentCELPerson struct { + DisplayName string `json:"display_name"` +} + +type reflectedTypeCELPerson struct { + Name string `json:"name"` +} + +type reflectedValueCELPerson struct { + Name string `json:"name"` +} + +type functionCELPerson struct { + DisplayName string `json:"display_name"` +} + +var _ = Describe("CEL native types", Ordered, func() { + It("passes registered top-level values to CEL without serializing them", func() { + person := registeredCELPerson{ + DisplayName: "Ada Lovelace", + Nickname: "Ada", + Ignored: "private", + } + Expect(RegisterType(person)).To(Succeed()) + + result, err := RunExpression(map[string]any{"person": person}, Template{ + Expression: "person", + CacheKey: "cel-native-person", + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(person)) + + field, err := RunExpression(map[string]any{"person": &person}, Template{ + Expression: `person.display_name + ":" + person.Nickname`, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(field).To(Equal("Ada Lovelace:Ada")) + + _, err = RunExpression(map[string]any{"person": person}, Template{Expression: "person.Ignored"}) + Expect(err).To(MatchError(ContainSubstring("no such field: Ignored"))) + }) + + It("invalidates cached programs when a native type is registered", func() { + person := cachedCELPerson{Name: "Grace Hopper"} + template := Template{Expression: "person", CacheKey: "cel-native-generation"} + + before, err := RunExpression(map[string]any{"person": person}, template) + Expect(err).NotTo(HaveOccurred()) + Expect(before).To(Equal(map[string]any{"name": "Grace Hopper"})) + + Expect(RegisterType(person)).To(Succeed()) + + after, err := RunExpression(map[string]any{"person": person}, template) + Expect(err).NotTo(HaveOccurred()) + Expect(after).To(Equal(person)) + }) + + It("accepts self-describing native CEL types", func() { + descriptor, err := types.NewNativeType( + reflect.TypeOf(describedCELPerson{}), + types.ParseStructField(jsonCELFieldName), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(RegisterType(descriptor)).To(Succeed()) + + person := describedCELPerson{Name: "Katherine Johnson"} + result, err := RunExpression(map[string]any{"person": person}, Template{Expression: "person"}) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(person)) + }) + + It("accepts reflect.Type and reflect.Value registrations", func() { + Expect(RegisterType(reflect.TypeOf(reflectedTypeCELPerson{}))).To(Succeed()) + Expect(RegisterType(reflect.ValueOf(reflectedValueCELPerson{}))).To(Succeed()) + + result, err := RunExpression(map[string]any{ + "typed": reflectedTypeCELPerson{Name: "Mary Jackson"}, + "valued": reflectedValueCELPerson{Name: "Christine Darden"}, + }, Template{Expression: `typed.name + ":" + valued.name`}) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal("Mary Jackson:Christine Darden")) + }) + + It("adapts native values returned by template functions", func() { + person := functionCELPerson{DisplayName: "Margaret Hamilton"} + Expect(RegisterType(person)).To(Succeed()) + + result, err := RunExpression(nil, Template{ + Expression: "person().display_name", + Functions: map[string]any{ + "person": func() any { return person }, + }, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal("Margaret Hamilton")) + }) + + It("rejects invalid types without publishing a new generation", func() { + generation := currentNativeTypes().generation + + err := RegisterType(42) + + Expect(err).To(MatchError(ContainSubstring("unsupported reflect.Type"))) + Expect(currentNativeTypes().generation).To(Equal(generation)) + }) + + It("treats repeated registrations as idempotent", func() { + person := registeredCELPerson{} + Expect(RegisterType(person)).To(Succeed()) + generation := currentNativeTypes().generation + + Expect(RegisterType(&person)).To(Succeed()) + + Expect(currentNativeTypes().generation).To(Equal(generation)) + }) + + It("supports concurrent registration snapshots and evaluation", func() { + person := concurrentCELPerson{DisplayName: "Dorothy Vaughan"} + errors := make(chan error, 16) + var waitGroup sync.WaitGroup + for range 8 { + waitGroup.Add(2) + go func() { + defer waitGroup.Done() + errors <- RegisterType(person) + }() + go func() { + defer waitGroup.Done() + _, err := RunExpression(map[string]any{"person": person}, Template{Expression: "person.display_name"}) + errors <- err + }() + } + waitGroup.Wait() + close(errors) + + for err := range errors { + Expect(err).NotTo(HaveOccurred()) + } + }) +}) + +var _ = Describe("CEL regex program limits", func() { + const limit = 10_000 + + It("rejects oversized literal regex programs during compilation", func() { + pattern := strings.Repeat("a?", limit+1) + expression := fmt.Sprintf(`"a".matches(%q)`, pattern) + + _, err := RunExpression(nil, Template{Expression: expression}) + + Expect(err).To(MatchError(ContainSubstring("regex program size"))) + Expect(err).To(MatchError(ContainSubstring("exceeds limit of 10000"))) + }) + + It("rejects oversized dynamic regex programs during evaluation", func() { + pattern := strings.Repeat("a?", limit+1) + + _, err := RunExpression(map[string]any{"pattern": pattern}, Template{ + Expression: `"a".matches(pattern)`, + }) + + Expect(err).To(MatchError(ContainSubstring("regex program size"))) + Expect(err).To(MatchError(ContainSubstring("exceeds limit of 10000"))) + }) +}) diff --git a/cel_tracker_test.go b/cel_tracker_test.go index 156bb42d0..ea86b19b2 100644 --- a/cel_tracker_test.go +++ b/cel_tracker_test.go @@ -78,6 +78,37 @@ var _ = Describe("CELTracker", func() { Expect(valueLines).To(ContainElement(2)) }) + It("tracks optimized list, optional, and regex evaluation", func() { + tracker := NewCELTracker() + expression := `([1, 2] + [3, 4]).size() == 4 && optional.of(name).orValue("") == "Ada" && name.matches("^A.*")` + + result, err := RunExpressionContext(newTrackedContext(tracker), map[string]any{"name": "Ada"}, Template{Expression: expression}) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(true)) + Expect(tracker.Snapshot()).To(SatisfyAll( + WithTransform(func(snapshot CELTraceSnapshot) *cel.Ast { return snapshot.AST }, Not(BeNil())), + WithTransform(func(snapshot CELTraceSnapshot) *cel.EvalDetails { return snapshot.Details }, Not(BeNil())), + WithTransform(func(snapshot CELTraceSnapshot) any { return snapshot.Output.Value() }, Equal(true)), + )) + }) + + It("tracks an empty optional fallback", func() { + tracker := NewCELTracker() + + result, err := RunExpressionContext(newTrackedContext(tracker), nil, Template{ + Expression: `optional.none().orValue("") == ""`, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(true)) + Expect(tracker.Snapshot()).To(SatisfyAll( + WithTransform(func(snapshot CELTraceSnapshot) *cel.Ast { return snapshot.AST }, Not(BeNil())), + WithTransform(func(snapshot CELTraceSnapshot) *cel.EvalDetails { return snapshot.Details }, Not(BeNil())), + WithTransform(func(snapshot CELTraceSnapshot) any { return snapshot.Output.Value() }, Equal(true)), + )) + }) + It("rejects concurrent reuse and can be reused after evaluation", func() { tracker := NewCELTracker() started := make(chan struct{}) diff --git a/cel_v031_bench_test.go b/cel_v031_bench_test.go new file mode 100644 index 000000000..0f571cbd0 --- /dev/null +++ b/cel_v031_bench_test.go @@ -0,0 +1,121 @@ +package gomplate + +import ( + "fmt" + "testing" + + "github.com/google/cel-go/cel" +) + +type benchmarkNativeInput struct { + DisplayName string `json:"display_name"` + Scores []int `json:"scores"` +} + +func BenchmarkCELEnvExtendCustomFunction(b *testing.B) { + base, err := baseCelEnv() + if err != nil { + b.Fatal(err) + } + options := benchmarkEnvOptions(10, 1) + b.ReportAllocs() + for b.Loop() { + if _, err := base.Extend(options...); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkCELProgramEvaluation(b *testing.B) { + cases := []struct { + name string + expression string + data map[string]any + }{ + {"scalar", `config_type == "Kubernetes::Pod"`, map[string]any{"config_type": "Kubernetes::Pod"}}, + {"list_optional_regex", `([1, 2] + [3, 4]).size() == 4 && optional.of(name).orValue("") == "Ada" && name.matches("^A.*")`, map[string]any{"name": "Ada"}}, + {"comprehension", `[1, 2, 3, 4, 5].filter(n, n % 2 == 0).map(n, n * n).exists(n, n == 16)`, nil}, + } + for _, benchmark := range cases { + for _, optimize := range []bool{false, true} { + name := fmt.Sprintf("expression=%s/optimized=%t", benchmark.name, optimize) + b.Run(name, func(b *testing.B) { + data, err := Serialize(benchmark.data) + if err != nil { + b.Fatal(err) + } + program, err := compileBenchmarkCELProgram(data, benchmark.expression, optimize) + if err != nil { + b.Fatal(err) + } + if output, _, err := program.Eval(data); err != nil || output.Value() != true { + b.Fatalf("unexpected warm-up result %v: %v", output, err) + } + b.ReportAllocs() + for b.Loop() { + if _, _, err := program.Eval(data); err != nil { + b.Fatal(err) + } + } + }) + } + } +} + +func BenchmarkRunExpressionNativeInput(b *testing.B) { + registerBenchmarkNativeType(b) + cases := []struct { + name string + value any + }{ + {"map", map[string]any{"display_name": "Ada", "scores": []int{1, 2, 3}}}, + {"native_struct", benchmarkNativeInput{DisplayName: "Ada", Scores: []int{1, 2, 3}}}, + } + for _, benchmark := range cases { + b.Run("input="+benchmark.name, func(b *testing.B) { + env := map[string]any{"person": benchmark.value} + template := Template{ + Expression: `person.display_name == "Ada" && person.scores.size() == 3`, + CacheKey: "benchmark-native-input-" + benchmark.name, + } + assertBenchmarkExpression(b, env, template) + b.ReportAllocs() + for b.Loop() { + if _, err := RunExpression(env, template); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func registerBenchmarkNativeType(b *testing.B) { + b.Helper() + // The benchmark workflow compiles this file against both the void base API + // and the error-returning head API. + switch register := any(RegisterType).(type) { + case func(any): + register(benchmarkNativeInput{}) + case func(any) error: + if err := register(benchmarkNativeInput{}); err != nil { + b.Fatal(err) + } + default: + b.Fatalf("unexpected RegisterType signature %T", RegisterType) + } +} + +func compileBenchmarkCELProgram(data map[string]any, expression string, optimize bool) (cel.Program, error) { + env, err := cel.NewEnv(GetCelEnv(data)...) + if err != nil { + return nil, err + } + ast, issues := env.Compile(expression) + if issues != nil && issues.Err() != nil { + return nil, issues.Err() + } + if optimize { + return env.Program(ast, cel.EvalOptions(cel.OptOptimize)) + } + return env.Program(ast) +} diff --git a/go.mod b/go.mod index ae7651d21..31bd61add 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/flanksource/commons v1.53.1 github.com/flanksource/is-healthy v1.0.90 github.com/flanksource/kubectl-neat v1.0.4 - github.com/google/cel-go v0.27.0 + github.com/google/cel-go v0.31.0 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/gosimple/slug v1.15.0 diff --git a/go.sum b/go.sum index 98d0f51bd..f0c21eecc 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= -github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/cel-go v0.31.0 h1:H0bhpFTqOvmHrBGrWKp7ZlhBm5Hh8PYUEXnwxT1LL7A= +github.com/google/cel-go v0.31.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/nilsafe/nilsafe.go b/nilsafe/nilsafe.go index 51b55006c..e5fc59c84 100644 --- a/nilsafe/nilsafe.go +++ b/nilsafe/nilsafe.go @@ -38,11 +38,11 @@ func (*library) LibraryName() string { return "cel.lib.ext.nilsafe" func (*library) CompileOptions() []cel.EnvOption { return nil } func (l *library) ProgramOptions() []cel.ProgramOption { - return []cel.ProgramOption{cel.CustomDecorator(l.makeDecorator())} + return []cel.ProgramOption{cel.CustomDecoratorV2(l.makeDecorator())} } -func (l *library) makeDecorator() interpreter.InterpretableDecorator { - return func(i interpreter.Interpretable) (interpreter.Interpretable, error) { +func (l *library) makeDecorator() interpreter.InterpretableDecoratorV2 { + return func(i interpreter.InterpretableV2) (interpreter.InterpretableV2, error) { if attr, ok := i.(interpreter.InterpretableAttribute); ok { if attr.ID() != attr.Attr().ID() { return i, nil @@ -77,7 +77,14 @@ type nilSafeAttr struct { } func (a *nilSafeAttr) Eval(ctx interpreter.Activation) ref.Val { - val := a.InterpretableAttribute.Eval(ctx) + return nilSafeResolution(a.InterpretableAttribute.Eval(ctx)) +} + +func (a *nilSafeAttr) Exec(frame *interpreter.ExecutionFrame) ref.Val { + return nilSafeResolution(a.InterpretableAttribute.Exec(frame)) +} + +func nilSafeResolution(val ref.Val) ref.Val { if types.IsError(val) && isResolutionError(val) { return types.NullValue } @@ -100,10 +107,24 @@ type nilSafeCall struct { } func (c *nilSafeCall) Eval(ctx interpreter.Activation) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Eval(ctx) }, + func() ref.Val { return c.InterpretableCall.Eval(ctx) }, + ) +} + +func (c *nilSafeCall) Exec(frame *interpreter.ExecutionFrame) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Exec(frame) }, + func() ref.Val { return c.InterpretableCall.Exec(frame) }, + ) +} + +func (c *nilSafeCall) eval(evalArg func(interpreter.InterpretableV2) ref.Val, evalCall func() ref.Val) ref.Val { for _, arg := range c.Args() { - if arg.Eval(ctx) == types.NullValue { + if evalArg(arg) == types.NullValue { return types.NullValue } } - return c.InterpretableCall.Eval(ctx) + return evalCall() } diff --git a/nilsafe/zeroval.go b/nilsafe/zeroval.go index 0b9ced621..5954e5f2d 100644 --- a/nilsafe/zeroval.go +++ b/nilsafe/zeroval.go @@ -47,17 +47,31 @@ type zeroValueCall struct { } func (c *zeroValueCall) Eval(ctx interpreter.Activation) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Eval(ctx) }, + func() ref.Val { return c.InterpretableCall.Eval(ctx) }, + ) +} + +func (c *zeroValueCall) Exec(frame *interpreter.ExecutionFrame) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Exec(frame) }, + func() ref.Val { return c.InterpretableCall.Exec(frame) }, + ) +} + +func (c *zeroValueCall) eval(evalArg func(interpreter.InterpretableV2) ref.Val, evalCall func() ref.Val) ref.Val { args := c.Args() vals := make([]ref.Val, len(args)) hasNull := false for i, arg := range args { - vals[i] = arg.Eval(ctx) + vals[i] = evalArg(arg) if vals[i] == types.NullValue { hasNull = true } } if !hasNull { - return c.InterpretableCall.Eval(ctx) + return evalCall() } fn := c.Function() @@ -78,13 +92,27 @@ type zeroValueEq struct { } func (c *zeroValueEq) Eval(ctx interpreter.Activation) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Eval(ctx) }, + func() ref.Val { return c.InterpretableCall.Eval(ctx) }, + ) +} + +func (c *zeroValueEq) Exec(frame *interpreter.ExecutionFrame) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Exec(frame) }, + func() ref.Val { return c.InterpretableCall.Exec(frame) }, + ) +} + +func (c *zeroValueEq) eval(evalArg func(interpreter.InterpretableV2) ref.Val, evalCall func() ref.Val) ref.Val { args := c.Args() - lhs, rhs := args[0].Eval(ctx), args[1].Eval(ctx) + lhs, rhs := evalArg(args[0]), evalArg(args[1]) lNull := lhs == types.NullValue rNull := rhs == types.NullValue if !lNull && !rNull { - return c.InterpretableCall.Eval(ctx) + return evalCall() } if lNull && rNull { return types.True @@ -98,24 +126,32 @@ func (c *zeroValueEq) Eval(ctx interpreter.Activation) ref.Val { return lhs.Equal(rhs) } -func (c *zeroValueEq) Function() string { return operators.Equals } -func (c *zeroValueEq) OverloadID() string { return "" } -func (c *zeroValueEq) Args() []interpreter.Interpretable { - return c.InterpretableCall.Args() -} - type zeroValueNe struct { interpreter.InterpretableCall } func (c *zeroValueNe) Eval(ctx interpreter.Activation) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Eval(ctx) }, + func() ref.Val { return c.InterpretableCall.Eval(ctx) }, + ) +} + +func (c *zeroValueNe) Exec(frame *interpreter.ExecutionFrame) ref.Val { + return c.eval( + func(arg interpreter.InterpretableV2) ref.Val { return arg.Exec(frame) }, + func() ref.Val { return c.InterpretableCall.Exec(frame) }, + ) +} + +func (c *zeroValueNe) eval(evalArg func(interpreter.InterpretableV2) ref.Val, evalCall func() ref.Val) ref.Val { args := c.Args() - lhs, rhs := args[0].Eval(ctx), args[1].Eval(ctx) + lhs, rhs := evalArg(args[0]), evalArg(args[1]) lNull := lhs == types.NullValue rNull := rhs == types.NullValue if !lNull && !rNull { - return c.InterpretableCall.Eval(ctx) + return evalCall() } if lNull && rNull { return types.False @@ -134,12 +170,6 @@ func (c *zeroValueNe) Eval(ctx interpreter.Activation) ref.Val { return types.True } -func (c *zeroValueNe) Function() string { return operators.NotEquals } -func (c *zeroValueNe) OverloadID() string { return "" } -func (c *zeroValueNe) Args() []interpreter.Interpretable { - return c.InterpretableCall.Args() -} - func isOperator(fn string) bool { switch fn { case operators.Add, operators.Subtract, operators.Multiply, diff --git a/run_expression_bench_test.go b/run_expression_bench_test.go index 8f3c1e00a..2c4fd85bb 100644 --- a/run_expression_bench_test.go +++ b/run_expression_bench_test.go @@ -1,27 +1,12 @@ package gomplate -// This benchmark exercises the CEL expression evaluation path (RunExpressionContext) -// the way callers use it at runtime: with a CacheKey so the compiled cel.Program is -// served from celExpressionCache on every iteration after the first. -// -// Motivation: a production heap profile showed RunExpressionContext -> GetCelEnv -// (notably kubernetes.Library()) + Serialize accounting for the single largest slice -// of lifetime allocation, because GetCelEnv was rebuilt on EVERY evaluation even -// though the compiled program is cached. After iteration 1 (the cache miss), all -// remaining iterations are cache hits; any allocation that remains is the per-call -// overhead that runs regardless of the program cache. -// -// Only the cache-hit steady state is benchmarked: that is the prod hot path and the -// regression guard for the "GetCelEnv must not run on cache hits" invariant. -// // Run: -// go test -run=^$ -bench=BenchmarkRunExpressionContext -benchmem +// go test -run=^$ -bench='BenchmarkRunExpressionContext|BenchmarkCELEnvExtend|BenchmarkCELProgramEvaluation' -benchmem // -// Capture a heap profile and inspect it the same way we inspect prod ones: -// go test -run=^$ -bench=BenchmarkRunExpressionContext/cacheHit -benchmem \ -// -memprofile /tmp/cel.mem.pprof -memprofilerate=1 -// go tool pprof -alloc_space -top -nodecount=25 /tmp/cel.mem.pprof -// go tool pprof -alloc_space -peek 'GetCelEnv$' /tmp/cel.mem.pprof +// Capture a heap profile in the project scratch directory: +// go test -run=^$ -bench=BenchmarkRunExpressionContext/cache=hit -benchmem \ +// -memprofile .tmp/cel.mem.pprof -memprofilerate=1 +// go tool pprof -alloc_space -top -nodecount=25 .tmp/cel.mem.pprof import ( "fmt" @@ -31,10 +16,6 @@ import ( "github.com/google/cel-go/common/types/ref" ) -// benchExprEnv returns an env map shaped like a Kubernetes Pod config item as the -// scraper passes it to template evaluation. GetCelEnv registers one cel.Variable per -// top-level key and Serialize walks the entire structure, so env size directly drives -// the per-call allocation under test. func benchExprEnv(withNestedConfig bool) map[string]any { env := map[string]any{ "id": "0192f0a4-1234-7000-8000-aaaaaaaaaaaa", @@ -47,130 +28,140 @@ func benchExprEnv(withNestedConfig bool) map[string]any { "namespace": "default", }, } + if !withNestedConfig { + return env + } - if withNestedConfig { - containers := make([]any, 0, 3) - for i := 0; i < 3; i++ { - containers = append(containers, map[string]any{ - "name": fmt.Sprintf("container-%d", i), - "image": fmt.Sprintf("registry.example.com/app:%d.2.3", i), - "ports": []any{map[string]any{"containerPort": 8080 + i, "protocol": "TCP"}}, - "env": []any{ - map[string]any{"name": "LOG_LEVEL", "value": "info"}, - map[string]any{"name": "REGION", "value": "us-east-1"}, - }, - "resources": map[string]any{ - "limits": map[string]any{"cpu": "500m", "memory": "512Mi"}, - "requests": map[string]any{"cpu": "100m", "memory": "128Mi"}, - }, - }) - } - - env["config"] = map[string]any{ - "apiVersion": "v1", - "kind": "Pod", - "metadata": map[string]any{ - "name": "nginx-7c5ddbdf54-abcde", - "namespace": "default", - "labels": map[string]any{ - "app": "nginx", "team": "platform", "env": "production", "version": "v1.2.3", - }, - "annotations": map[string]any{ - "prometheus.io/scrape": "true", - "prometheus.io/port": "8080", - }, - "ownerReferences": []any{ - map[string]any{"apiVersion": "apps/v1", "kind": "ReplicaSet", "name": "nginx-7c5ddbdf54"}, - }, + containers := make([]any, 0, 3) + for i := range 3 { + containers = append(containers, map[string]any{ + "name": fmt.Sprintf("container-%d", i), + "image": fmt.Sprintf("registry.example.com/app:%d.2.3", i), + "ports": []any{map[string]any{"containerPort": 8080 + i, "protocol": "TCP"}}, + "env": []any{ + map[string]any{"name": "LOG_LEVEL", "value": "info"}, + map[string]any{"name": "REGION", "value": "us-east-1"}, }, - "spec": map[string]any{"containers": containers, "nodeName": "ip-10-0-1-23"}, - "status": map[string]any{"phase": "Running", "podIP": "10.0.5.12", "hostIP": "10.0.1.23"}, - } + "resources": map[string]any{ + "limits": map[string]any{"cpu": "500m", "memory": "512Mi"}, + "requests": map[string]any{"cpu": "100m", "memory": "128Mi"}, + }, + }) + } + env["config"] = map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": "nginx-7c5ddbdf54-abcde", + "namespace": "default", + "labels": map[string]any{ + "app": "nginx", "team": "platform", "env": "production", "version": "v1.2.3", + }, + "annotations": map[string]any{ + "prometheus.io/scrape": "true", + "prometheus.io/port": "8080", + }, + "ownerReferences": []any{ + map[string]any{"apiVersion": "apps/v1", "kind": "ReplicaSet", "name": "nginx-7c5ddbdf54"}, + }, + }, + "spec": map[string]any{"containers": containers, "nodeName": "ip-10-0-1-23"}, + "status": map[string]any{"phase": "Running", "podIP": "10.0.5.12", "hostIP": "10.0.1.23"}, } - return env } -// exprBenchSink prevents the compiler from optimizing away results. -var exprBenchSink any - -// BenchmarkRunExpressionContext measures the CEL evaluation path on the cache-hit -// steady state: a CacheKey is set so the compiled cel.Program is reused from -// celExpressionCache. After the warm-up run, every iteration is a cache hit, and the -// reported B/op / allocs/op is the per-call overhead that runs regardless of the program -// cache (Serialize + Eval, plus GetCelEnv if a regression reintroduces it before the -// cache lookup). func BenchmarkRunExpressionContext(b *testing.B) { const expression = `config_type == "Kubernetes::Pod"` - for _, withConfig := range []bool{false, true} { - name := "cacheHit/smallEnv" + name := "small" if withConfig { - name = "cacheHit/largeEnv" + name = "large" } - b.Run(name, func(b *testing.B) { + b.Run("cache=hit/environment="+name, func(b *testing.B) { + celExpressionCache.Flush() env := benchExprEnv(withConfig) - tmpl := Template{ - Expression: expression, - CacheKey: "bench.RunExpressionContext:config_type==Kubernetes::Pod", - } - // Warm the cache once so we measure steady state, not the one-time compile. - if _, err := RunExpression(env, tmpl); err != nil { - b.Fatal(err) - } - + template := Template{Expression: expression, CacheKey: "benchmark-cache-hit-" + name} + assertBenchmarkExpression(b, env, template) b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - out, err := RunExpression(env, tmpl) - if err != nil { + for b.Loop() { + if _, err := RunExpression(env, template); err != nil { b.Fatal(err) } - exprBenchSink = out } }) } } -// BenchmarkRunExpressionContextCompile measures the CEL compile path -// (RunExpressionContext cache MISS). Production expressions that reference a -// context-capturing function such as catalog.query attach a CelEnv, which makes -// the compiled program non-cacheable (IsCacheable() is false when len(CelEnvs) -// != 0), so they pay the full env-build + compile cost on EVERY call. This is the -// path that previously rebuilt kubernetes.Library() and revalidated all of its -// declarations every time; it is now served by extending the cached base env. func BenchmarkRunExpressionContextCompile(b *testing.B) { const expression = `config_type == "Kubernetes::Pod"` - - // A trivial CelEnv: its only purpose is to make the template non-cacheable so - // every iteration goes through the compile path (mirrors catalog.query & co.). - noopFn := cel.Function("bench_noop", - cel.Overload("bench_noop_string", - []*cel.Type{cel.StringType}, cel.StringType, - cel.UnaryBinding(func(v ref.Val) ref.Val { return v }), - ), - ) - for _, withConfig := range []bool{false, true} { - name := "compile/smallEnv" + name := "small" if withConfig { - name = "compile/largeEnv" + name = "large" } - b.Run(name, func(b *testing.B) { + b.Run("cache=miss/environment="+name, func(b *testing.B) { env := benchExprEnv(withConfig) + template := Template{Expression: expression, CelEnvs: []cel.EnvOption{benchmarkNoopFunction()}} + assertBenchmarkExpression(b, env, template) + b.ReportAllocs() + for b.Loop() { + if _, err := RunExpression(env, template); err != nil { + b.Fatal(err) + } + } + }) + } +} +func BenchmarkCELEnvExtend(b *testing.B) { + base, err := baseCelEnv() + if err != nil { + b.Fatal(err) + } + cases := []struct { + variables int + functions int + }{{1, 0}, {10, 0}, {100, 0}} + for _, benchmark := range cases { + name := fmt.Sprintf("variables=%d/functions=%d", benchmark.variables, benchmark.functions) + b.Run(name, func(b *testing.B) { + options := benchmarkEnvOptions(benchmark.variables, benchmark.functions) b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - out, err := RunExpression(env, Template{ - Expression: expression, - CelEnvs: []cel.EnvOption{noopFn}, - }) - if err != nil { + for b.Loop() { + if _, err := base.Extend(options...); err != nil { b.Fatal(err) } - exprBenchSink = out } }) } } + +func benchmarkNoopFunction() cel.EnvOption { + return cel.Function("bench_noop", cel.Overload( + "bench_noop_string", []*cel.Type{cel.StringType}, cel.StringType, + cel.UnaryBinding(func(value ref.Val) ref.Val { return value }), + )) +} + +func benchmarkEnvOptions(variables, functions int) []cel.EnvOption { + options := make([]cel.EnvOption, 0, variables+functions) + for i := range variables { + options = append(options, cel.Variable(fmt.Sprintf("value_%d", i), cel.AnyType)) + } + if functions == 1 { + options = append(options, benchmarkNoopFunction()) + } + return options +} + +func assertBenchmarkExpression(b *testing.B, environment map[string]any, template Template) { + b.Helper() + output, err := RunExpression(environment, template) + if err != nil { + b.Fatal(err) + } + if output != true { + b.Fatalf("unexpected warm-up result: %v", output) + } +} diff --git a/serialize.go b/serialize.go index ab15438ec..3541a0a6e 100644 --- a/serialize.go +++ b/serialize.go @@ -81,3 +81,39 @@ func Serialize(in map[string]any) (out map[string]any, err error) { } return out, nil } + +func serializeForCEL(in map[string]any, nativeTypes *nativeTypeSnapshot) (map[string]any, error) { + if in == nil || nativeTypes == nil || len(nativeTypes.reflectTypes) == 0 { + return Serialize(in) + } + + preserved := 0 + for _, value := range in { + if nativeTypes.preserves(value) { + preserved++ + } + } + if preserved == 0 { + return Serialize(in) + } + if preserved == len(in) { + return in, nil + } + + serializedInput := make(map[string]any, len(in)-preserved) + for key, value := range in { + if !nativeTypes.preserves(value) { + serializedInput[key] = value + } + } + out, err := Serialize(serializedInput) + if err != nil { + return nil, err + } + for key, value := range in { + if nativeTypes.preserves(value) { + out[key] = value + } + } + return out, nil +} diff --git a/serialize_bench_test.go b/serialize_bench_test.go index 9a907f051..1c3ef1561 100644 --- a/serialize_bench_test.go +++ b/serialize_bench_test.go @@ -25,16 +25,11 @@ type benchPerson struct { } func BenchmarkSerialize(b *testing.B) { - sizes := []int{10, 100, 1000, 10000} - - for _, size := range sizes { - b.Run(fmt.Sprintf("Size-%d", size), func(b *testing.B) { + for _, size := range []int{10, 100, 1000, 10000} { + b.Run(fmt.Sprintf("items=%d/native_values=true", size), func(b *testing.B) { input := newSerializeBenchmarkInput(size) - b.ReportAllocs() - b.ResetTimer() - - for i := 0; i < b.N; i++ { + for b.Loop() { if _, err := Serialize(input); err != nil { b.Fatal(err) } @@ -43,20 +38,12 @@ func BenchmarkSerialize(b *testing.B) { } } -// BenchmarkSerialize_NoNativeTypes measures the path where Walk finds no -// uuid/duration/AsMapper values — isolates the Alter cost from the SetOne -// fixup loop optimized in the last commit. func BenchmarkSerialize_NoNativeTypes(b *testing.B) { - sizes := []int{100, 1000, 10000} - - for _, size := range sizes { - b.Run(fmt.Sprintf("Size-%d", size), func(b *testing.B) { + for _, size := range []int{100, 1000, 10000} { + b.Run(fmt.Sprintf("items=%d/native_values=false", size), func(b *testing.B) { input := newPlainBenchmarkInput(size) - b.ReportAllocs() - b.ResetTimer() - - for i := 0; i < b.N; i++ { + for b.Loop() { if _, err := Serialize(input); err != nil { b.Fatal(err) } @@ -67,20 +54,16 @@ func BenchmarkSerialize_NoNativeTypes(b *testing.B) { func newSerializeBenchmarkInput(size int) map[string]any { items := make([]any, size) + identifier := uuid.MustParse("0192f0a4-1234-7000-8000-aaaaaaaaaaaa") for i := range items { items[i] = benchPerson{ Name: fmt.Sprintf("person-%d", i), Age: i % 100, - ID: uuid.New(), + ID: identifier, Duration: time.Duration(i) * time.Millisecond, - Address: &benchAddress{ - City: "Kathmandu", - Country: "Nepal", - }, + Address: &benchAddress{City: "Kathmandu", Country: "Nepal"}, MetaData: map[string]any{ - "index": i, - "enabled": i%2 == 0, - "uuid": uuid.New(), + "index": i, "enabled": i%2 == 0, "uuid": identifier, "duration": time.Duration(i) * time.Second, }, Codes: []string{"GO", "JS", "CEL"}, @@ -91,10 +74,9 @@ func newSerializeBenchmarkInput(size int) map[string]any { }, } } - return map[string]any{ - "id": uuid.New(), - "started": time.Now(), + "id": identifier, + "started": time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC), "duration": 5 * time.Minute, "items": items, "nested": map[string]any{ @@ -113,13 +95,9 @@ func newPlainBenchmarkInput(size int) map[string]any { "age": i % 100, "enabled": i%2 == 0, "codes": []string{"GO", "JS", "CEL"}, - "address": map[string]any{ - "city": "Kathmandu", - "country": "Nepal", - }, + "address": map[string]any{"city": "Kathmandu", "country": "Nepal"}, } } - return map[string]any{ "started": "2026-01-01T00:00:00Z", "items": items, diff --git a/template.go b/template.go index ae3a2db38..c6edeeae4 100644 --- a/template.go +++ b/template.go @@ -17,8 +17,6 @@ import ( "github.com/flanksource/commons/properties" _ "github.com/flanksource/gomplate/v3/js" "github.com/google/cel-go/cel" - "github.com/google/cel-go/common/types" - "github.com/google/cel-go/common/types/ref" "github.com/patrickmn/go-cache" "github.com/robertkrimen/otto" "github.com/robertkrimen/otto/registry" @@ -31,8 +29,7 @@ var funcMap gotemplate.FuncMap var ( // keep the cache period low as lots of anonymous functions can pile up the cache. - goTemplateCache = cache.New(time.Hour, time.Hour) - celExpressionCache = cache.New(time.Hour, time.Hour) + goTemplateCache = cache.New(time.Hour, time.Hour) ) func init() { @@ -177,119 +174,6 @@ func (t Template) IsEmpty() bool { return t.Template == "" && t.JSONPath == "" && t.Expression == "" && t.Javascript == "" } -func RunExpression(_environment map[string]any, template Template) (any, error) { - return RunExpressionContext(newContext(), _environment, template) -} - -func RunExpressionContext(ctx commonsContext.Context, _environment map[string]any, template Template) (any, error) { - tracker := celTrackerFromContext(ctx) - if tracker != nil { - if err := tracker.begin(); err != nil { - return nil, err - } - defer tracker.abort() - } - - data, err := Serialize(_environment) - if err != nil { - return "", err - } - - // Look up the compiled-program cache BEFORE constructing the CEL env options. - // GetCelEnv (notably kubernetes.Library()) is the dominant allocation on the CEL - // path. On the overwhelmingly common cache hit it would be built and then - // immediately discarded, since cel.NewEnv is only needed to compile a new - // program. Build env options only when we actually need to compile. - var prg cel.Program - var ast *cel.Ast - if tracker == nil && template.IsCacheable() { - cached, ok := celExpressionCache.Get(template.cacheKey(_environment)) - if ok { - if cachedPrg, ok := cached.(*cel.Program); ok { - prg = *cachedPrg - } - } - } - - if prg == nil { - base, err := baseCelEnv() - if err != nil { - return "", err - } - - // Only the per-call options are layered on top of the cached base env: the - // heavy, environment-independent libraries already live in base. This keeps - // the dominant CEL setup cost (kubernetes.Library and declaration - // validation) off the compile path. - envOptions := make([]cel.EnvOption, 0, len(typeAdapters)+len(data)+len(template.Functions)+len(template.CelEnvs)) - envOptions = append(envOptions, typeAdapters...) - for k := range data { - envOptions = append(envOptions, cel.Variable(k, cel.AnyType)) - } - for name, fn := range template.Functions { - _name := name - _fn := fn - envOptions = append(envOptions, cel.Function(_name, cel.Overload( - _name, - nil, - cel.AnyType, - cel.FunctionBinding(func(values ...ref.Val) ref.Val { - ogFunc, ok := _fn.(func() any) - if !ok { - return types.WrapErr(fmt.Errorf("%s is expected to be of type func() any", _name)) - } - - out := ogFunc() - return types.DefaultTypeAdapter.NativeToValue(out) - }), - ))) - } - - envOptions = append(envOptions, template.CelEnvs...) - - env, err := base.Extend(envOptions...) - if err != nil { - return "", err - } - - expression := strings.ReplaceAll(template.Expression, "\n", " ") - if tracker != nil { - expression = template.Expression - } - var issues *cel.Issues - ast, issues = env.Compile(expression) - if issues != nil && issues.Err() != nil { - return "", oops.With("template", template.Expression).Errorf("issues: %s", issues.String()) - } - - var programOptions []cel.ProgramOption - if tracker != nil { - programOptions = append(programOptions, cel.EvalOptions(cel.OptTrackState)) - } - prg, err = env.Program(ast, programOptions...) - if err != nil { - return "", err - } - - if tracker == nil && template.IsCacheable() { - celExpressionCache.Set(template.cacheKey(_environment), &prg, template.CacheTime) - } - } - - out, details, err := prg.Eval(data) - if tracker != nil { - tracker.complete(ast, details, out) - } - if err != nil { - return nil, oops.With("template", template.Expression).Wrap(err) - } - if ctx.Logger != nil && out.Value() != template.Expression && properties.On(false, "gomplate.log") { - ctx.Logger.V(4).Infof("templated %s => %v", template.ShortString(), out) - } - return out.Value(), nil - -} - func newContext() commonsContext.Context { return commonsContext.NewContext(context.TODO(), commonsContext.WithLogger(logger.GetLogger("gomplate"))) diff --git a/template_test.go b/template_test.go index 04d4382eb..93838b5ee 100644 --- a/template_test.go +++ b/template_test.go @@ -102,7 +102,7 @@ func TestCacheTime(t *testing.T) { if _, err := RunExpression(nil, tpl); err != nil { t.Fatalf("eval: %v", err) } - _, exp, ok := celExpressionCache.GetWithExpiration(tpl.CacheKey) + _, exp, ok := celExpressionCache.GetWithExpiration(tpl.celCacheKey(nil, currentNativeTypes().generation)) if !ok { t.Fatalf("entry not cached") } @@ -122,7 +122,7 @@ func TestCacheTime(t *testing.T) { if _, err := RunExpression(nil, tpl); err != nil { t.Fatalf("eval: %v", err) } - _, exp, ok := celExpressionCache.GetWithExpiration(tpl.CacheKey) + _, exp, ok := celExpressionCache.GetWithExpiration(tpl.celCacheKey(nil, currentNativeTypes().generation)) if !ok { t.Fatalf("entry not cached") } @@ -142,7 +142,7 @@ func TestCacheTime(t *testing.T) { if _, err := RunExpression(nil, tpl); err != nil { t.Fatalf("eval: %v", err) } - _, exp, ok := celExpressionCache.GetWithExpiration(tpl.CacheKey) + _, exp, ok := celExpressionCache.GetWithExpiration(tpl.celCacheKey(nil, currentNativeTypes().generation)) if !ok { t.Fatalf("entry not cached") }