From 613b482aeeb3412d5cabca6bd3c194394bf21af7 Mon Sep 17 00:00:00 2001 From: Yash Mehrotra Date: Tue, 19 May 2026 14:59:17 +0530 Subject: [PATCH] chore(cel): add support for custom caching key/time --- template.go | 44 ++++++++++++++--- template_test.go | 126 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 160 insertions(+), 10 deletions(-) diff --git a/template.go b/template.go index 1e5bbe38d..41300c7b0 100644 --- a/template.go +++ b/template.go @@ -57,6 +57,17 @@ type Template struct { // If any other function type is used, an error will be returned. // Opt to CelEnvs for those cases. Functions map[string]any `yaml:"-" json:"-"` + + // CacheKey, when non-empty, is used as the program-cache key for this + // template, bypassing the IsCacheable() heuristic. The caller asserts that + // any two templates sharing this key may share the compiled cel.Program + // (same expression semantics, same env shape, same function semantics). + CacheKey string `yaml:"-" json:"-"` + + // CacheTime controls how long the compiled program/template is retained + // in the cache. Zero means use the cache's default TTL; a positive value + // is an explicit TTL; a negative value means no expiration. + CacheTime time.Duration `yaml:"-" json:"-"` } func (t Template) String() string { @@ -103,7 +114,9 @@ func short(v string) string { return fmt.Sprintf("%s .. %d more lines", lines[0], len(lines)-1) } -func (t Template) CacheKey(env map[string]any) string { +// autoCacheKey derives a cache key from the template fields and the env shape. +// Used when the caller has not supplied an explicit CacheKey. +func (t Template) autoCacheKey(env map[string]any) string { envVars := make([]string, 0, len(env)+1) for k := range env { envVars = append(envVars, k) @@ -119,7 +132,22 @@ func (t Template) CacheKey(env map[string]any) string { t.Template } +// cacheKey returns the key to use for the program/template cache. If the caller +// supplied CacheKey, it is used verbatim; otherwise the auto-derived key is used. +func (t Template) cacheKey(env map[string]any) string { + if t.CacheKey != "" { + return t.CacheKey + } + return t.autoCacheKey(env) +} + func (t Template) IsCacheable() bool { + // An explicit CacheKey is the caller asserting the program is reusable + // regardless of Functions/CelEnvs identity. + if t.CacheKey != "" { + return true + } + // Note: If custom functions are provided then we don't cache the template // because it's not possible to uniquely identify a function to be used as a cache key. // Pointers don't work well because different functions, that are behaviourly different, @@ -170,7 +198,7 @@ func RunExpressionContext(ctx commonsContext.Context, _environment map[string]an var prg cel.Program if template.IsCacheable() { - cached, ok := celExpressionCache.Get(template.CacheKey(_environment)) + cached, ok := celExpressionCache.Get(template.cacheKey(_environment)) if ok { if cachedPrg, ok := cached.(*cel.Program); ok { prg = *cachedPrg @@ -189,12 +217,14 @@ func RunExpressionContext(ctx commonsContext.Context, _environment map[string]an return "", oops.With("template", template.Expression).Errorf("issues: %s", issues.String()) } - prg, err = env.Program(ast, cel.Globals(data)) + prg, err = env.Program(ast) if err != nil { return "", err } - celExpressionCache.SetDefault(template.CacheKey(_environment), &prg) + if template.IsCacheable() { + celExpressionCache.Set(template.cacheKey(_environment), &prg, template.CacheTime) + } } out, _, err := prg.Eval(data) @@ -277,7 +307,7 @@ func goTemplate(ctx commonsContext.Context, template Template, environment map[s var tpl *gotemplate.Template if template.IsCacheable() { - cached, ok := goTemplateCache.Get(template.CacheKey(nil)) + cached, ok := goTemplateCache.Get(template.cacheKey(nil)) if ok { if cachedTpl, ok := cached.(*gotemplate.Template); ok { if ctx.Logger != nil && properties.On(false, "gomplate.log") { @@ -312,7 +342,9 @@ func goTemplate(ctx commonsContext.Context, template Template, environment map[s return "", oops.With("template", template.Template).Wrap(err) } - goTemplateCache.SetDefault(template.CacheKey(nil), tpl) + if template.IsCacheable() { + goTemplateCache.Set(template.cacheKey(nil), tpl, template.CacheTime) + } } data, err := Serialize(environment) diff --git a/template_test.go b/template_test.go index 34d0c4677..4b775899d 100644 --- a/template_test.go +++ b/template_test.go @@ -2,6 +2,7 @@ package gomplate import ( "testing" + "time" _ "github.com/flanksource/gomplate/v3/js" _ "github.com/robertkrimen/otto/underscore" @@ -27,9 +28,9 @@ func TestCacheKeyConsistency(t *testing.T) { }, } - expectedCacheKey := tt.CacheKey(map[string]any{"age": 19, "name": "james"}) + expectedCacheKey := tt.cacheKey(map[string]any{"age": 19, "name": "james"}) for i := 0; i < 10; i++ { - key := tt.CacheKey(map[string]any{"age": 19, "name": "james"}) + key := tt.cacheKey(map[string]any{"age": 19, "name": "james"}) if key != expectedCacheKey { t.Errorf("cache key mismatch: %s != %s", key, expectedCacheKey) } @@ -49,12 +50,129 @@ func TestCacheKeyConsistency(t *testing.T) { }, } - expectCacheKey := tt.CacheKey(map[string]any{"age": 19, "name": "james"}) + expectCacheKey := tt.cacheKey(map[string]any{"age": 19, "name": "james"}) for i := 0; i < 10; i++ { - key := tt.CacheKey(map[string]any{"age": 19, "name": "james"}) + key := tt.cacheKey(map[string]any{"age": 19, "name": "james"}) if key != expectCacheKey { t.Errorf("cache key mismatch: %s != %s", key, expectCacheKey) } } } } + +func TestExplicitCacheKey(t *testing.T) { + tt := Template{ + Expression: "name + age", + CacheKey: "user-defined-key", + } + + if got := tt.cacheKey(map[string]any{"foo": 1}); got != "user-defined-key" { + t.Errorf("expected explicit CacheKey to be used, got %q", got) + } + + if got := tt.cacheKey(map[string]any{"bar": 2}); got != "user-defined-key" { + t.Errorf("explicit CacheKey must be stable across env shapes, got %q", got) + } + + if !tt.IsCacheable() { + t.Errorf("template with explicit CacheKey must be cacheable") + } + + withFuncs := Template{ + Expression: "name", + Functions: map[string]any{"hello": func() any { return "world" }}, + } + if withFuncs.IsCacheable() { + t.Errorf("template with Functions and no CacheKey must not be cacheable") + } + withFuncs.CacheKey = "stable" + if !withFuncs.IsCacheable() { + t.Errorf("template with Functions but explicit CacheKey must be cacheable") + } +} + +func TestCacheTime(t *testing.T) { + // No expiration: cache entry should have zero expiration time. + { + tpl := Template{ + Expression: "1 + 1", + CacheKey: "cachetime-noexp", + CacheTime: -1, + } + if _, err := RunExpression(nil, tpl); err != nil { + t.Fatalf("eval: %v", err) + } + _, exp, ok := celExpressionCache.GetWithExpiration(tpl.CacheKey) + if !ok { + t.Fatalf("entry not cached") + } + if !exp.IsZero() { + t.Errorf("expected no-expiration entry, got expiry %v", exp) + } + } + + // Explicit short TTL: entry expiration should be close to now+CacheTime. + { + tpl := Template{ + Expression: "1 + 1", + CacheKey: "cachetime-short", + CacheTime: 50 * time.Millisecond, + } + before := time.Now() + if _, err := RunExpression(nil, tpl); err != nil { + t.Fatalf("eval: %v", err) + } + _, exp, ok := celExpressionCache.GetWithExpiration(tpl.CacheKey) + if !ok { + t.Fatalf("entry not cached") + } + diff := exp.Sub(before) + if diff < 40*time.Millisecond || diff > 200*time.Millisecond { + t.Errorf("expected expiry ~50ms after set, got %v", diff) + } + } + + // Zero CacheTime: should fall back to the cache's default TTL (~1h). + { + tpl := Template{ + Expression: "1 + 1", + CacheKey: "cachetime-default", + } + before := time.Now() + if _, err := RunExpression(nil, tpl); err != nil { + t.Fatalf("eval: %v", err) + } + _, exp, ok := celExpressionCache.GetWithExpiration(tpl.CacheKey) + if !ok { + t.Fatalf("entry not cached") + } + diff := exp.Sub(before) + if diff < 30*time.Minute || diff > 90*time.Minute { + t.Errorf("expected ~1h default TTL, got %v", diff) + } + } + +} + +func TestRunExpressionReusesProgramAcrossDifferentData(t *testing.T) { + tpl := Template{ + Expression: "name + age", + CacheKey: "reuse-test", + } + + out1, err := RunExpression(map[string]any{"name": "alice-", "age": "30"}, tpl) + if err != nil { + t.Fatalf("first eval: %v", err) + } + if out1 != "alice-30" { + t.Errorf("first eval result: got %v", out1) + } + + out2, err := RunExpression(map[string]any{"name": "bob-", "age": "42"}, tpl) + if err != nil { + t.Fatalf("second eval: %v", err) + } + if out2 != "bob-42" { + t.Errorf("second eval result: got %v (cached program should not leak first-call data)", out2) + } +}