From c1718f64762f36ae701eb5ac065641fcfd598f22 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 24 Jun 2026 09:31:45 +0545 Subject: [PATCH 1/2] perf(cel): build static CEL env once and Extend per call GetCelEnv rebuilt the entire CEL environment -- including kubernetes.Library() and the validation of all its declarations -- on every expression compile. That makes the compile (cache-miss) path the single largest source of CEL allocation, and it runs on every evaluation of an expression that attaches a CelEnv (e.g. catalog.query), which are non-cacheable by design. Build the environment-independent options once into a cached base *cel.Env (sync.OnceValues + EagerlyValidateDeclarations) and layer the per-call variables, registered types and caller functions on top with Env.Extend, which deep-copies and is safe to share across goroutines. Compile-path benchmark (BenchmarkRunExpressionContextCompile/smallEnv): before: 425747 B/op, 5442 allocs/op, 324us/op after: 199132 B/op, 2254 allocs/op, 179us/op (~53% bytes, ~59% allocs) Cache-hit path is unchanged (base env is untouched on a hit). GetCelEnv is retained for external callers and now shares the static option set. --- cel.go | 49 +++++++++++++++++++++++++++++++++--- run_expression_bench_test.go | 46 +++++++++++++++++++++++++++++++++ template.go | 17 +++++++++++-- 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/cel.go b/cel.go index c6adba445..017f028fc 100644 --- a/cel.go +++ b/cel.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" "regexp" + "sync" "github.com/flanksource/commons/context" "github.com/flanksource/commons/logger" @@ -26,19 +27,59 @@ func RegisterType(i any) { typeAdapters = append(typeAdapters, ext.NativeTypes(reflect.TypeOf(i))) } -func GetCelEnv(environment map[string]any) []cel.EnvOption { - // Generated functions - var opts = funcs.CelEnvOption +// staticCelEnvOptions returns the environment-independent CEL options: the +// generated functions, the kubernetes library, the cel-go extensions, the +// standard library and gomplate's own custom functions. They are identical on +// every call, so they are built once into the cached base environment (see +// baseCelEnv) instead of being reconstructed for every expression compile. +// +// Built once into the cached base env (see baseCelEnv), so this is not sized for +// speed. Starting from a nil slice also guarantees the first append copies +// funcs.CelEnvOption rather than aliasing its backing array. +func staticCelEnvOptions() []cel.EnvOption { + var opts []cel.EnvOption //nolint:prealloc + opts = append(opts, funcs.CelEnvOption...) opts = append(opts, kubernetes.Library()...) opts = append(opts, ext.Strings(), ext.Encoders(), ext.Lists(), ext.Math(), ext.Sets()) opts = append(opts, cel.StdLib()) opts = append(opts, cel.OptionalTypes(cel.OptionalTypesVersion(1))) opts = append(opts, nilsafe.Library(nilsafe.WithZeroValues())) opts = append(opts, strings.Library...) - opts = append(opts, typeAdapters...) opts = append(opts, getGoTemplateCelFunction()) opts = append(opts, getDebugCelFunction()) opts = append(opts, getFoldCelLibrary()) + return opts +} + +// baseCelEnv builds, exactly once, a CEL environment that holds only the static, +// environment-independent options. RunExpressionContext layers the per-call +// variables, registered native types and caller-provided functions on top with +// Env.Extend. +// +// This is the dominant CEL allocation/CPU saving: kubernetes.Library() and the +// validation of its declarations are paid once for the lifetime of the process +// instead of on every compile. EagerlyValidateDeclarations forces the base env +// 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. +var baseCelEnv = sync.OnceValues(func() (*cel.Env, error) { + opts := staticCelEnvOptions() + opts = append(opts, cel.EagerlyValidateDeclarations(true)) + return cel.NewEnv(opts...) +}) + +// GetCelEnv returns the full set of CEL env options: the static options, any +// types registered via RegisterType, and one variable per environment key. +// +// RunExpressionContext no longer uses this on the hot path; it compiles against +// the cached base environment (baseCelEnv) and layers the per-call options with +// Env.Extend. GetCelEnv is retained for external callers and shares the static +// option set via staticCelEnvOptions. +func GetCelEnv(environment map[string]any) []cel.EnvOption { + opts := staticCelEnvOptions() + opts = append(opts, typeAdapters...) // Load input as variables for k := range environment { diff --git a/run_expression_bench_test.go b/run_expression_bench_test.go index ac0faca93..8f3c1e00a 100644 --- a/run_expression_bench_test.go +++ b/run_expression_bench_test.go @@ -26,6 +26,9 @@ package gomplate import ( "fmt" "testing" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types/ref" ) // benchExprEnv returns an env map shaped like a Kubernetes Pod config item as the @@ -128,3 +131,46 @@ func BenchmarkRunExpressionContext(b *testing.B) { }) } } + +// 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" + if withConfig { + name = "compile/largeEnv" + } + b.Run(name, func(b *testing.B) { + env := benchExprEnv(withConfig) + + 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 { + b.Fatal(err) + } + exprBenchSink = out + } + }) + } +} diff --git a/template.go b/template.go index 7ea84e606..d07bba15a 100644 --- a/template.go +++ b/template.go @@ -203,7 +203,20 @@ func RunExpressionContext(ctx commonsContext.Context, _environment map[string]an } if prg == nil { - envOptions := GetCelEnv(data) + 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 @@ -225,7 +238,7 @@ func RunExpressionContext(ctx commonsContext.Context, _environment map[string]an envOptions = append(envOptions, template.CelEnvs...) - env, err := cel.NewEnv(envOptions...) + env, err := base.Extend(envOptions...) if err != nil { return "", err } From 0266b1b50317c17c48a7b1f981bef99d0c89a2f4 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 24 Jun 2026 12:49:03 +0545 Subject: [PATCH 2/2] fix: DOcker image --- Dockerfile | 2 +- Dockerfile.integration | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index effca1afd..5708d422d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1.3.1-labs -FROM --platform=linux/amd64 golang:1.19-alpine@sha256:0ec0646e208ea58e5d29e558e39f2e59fccf39b7bda306cb53bbaff91919eca5 AS build +FROM --platform=linux/amd64 golang:1.26-alpine AS build ARG TARGETOS ARG TARGETARCH diff --git a/Dockerfile.integration b/Dockerfile.integration index d9fd17d00..c3927b1ad 100644 --- a/Dockerfile.integration +++ b/Dockerfile.integration @@ -1,4 +1,4 @@ -FROM golang:1.19-alpine +FROM golang:1.26-alpine RUN apk add --no-cache make tzdata