Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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)
Expand Down
126 changes: 122 additions & 4 deletions template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package gomplate

import (
"testing"
"time"

_ "github.com/flanksource/gomplate/v3/js"
_ "github.com/robertkrimen/otto/underscore"
Expand All @@ -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)
}
Expand All @@ -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)
}
}
Loading