mirror of
https://github.com/gohugoio/hugo.git
synced 2025-08-28 22:19:59 +02:00
Only invoke a given cached partial once
Note that this is backed by a LRU cache (which we soon shall see more usage of), so if you're a heavy user of cached partials it may be evicted and refreshed if needed. But in most cases every partial is only invoked once. This commit also adds a timeout (the global `timeout` config option) to make infinite recursion in partials easier to reason about. ``` name old time/op new time/op delta IncludeCached-10 8.92ms ± 0% 8.48ms ± 1% -4.87% (p=0.016 n=4+5) name old alloc/op new alloc/op delta IncludeCached-10 6.65MB ± 0% 5.17MB ± 0% -22.32% (p=0.002 n=6+6) name old allocs/op new allocs/op delta IncludeCached-10 117k ± 0% 71k ± 0% -39.44% (p=0.002 n=6+6) ``` Closes #4086 Updates #9588
This commit is contained in:
@@ -17,19 +17,17 @@ package partials
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
texttemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate"
|
||||
"github.com/bep/lazycache"
|
||||
|
||||
"github.com/gohugoio/hugo/helpers"
|
||||
"github.com/gohugoio/hugo/identity"
|
||||
texttemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate"
|
||||
|
||||
"github.com/gohugoio/hugo/tpl"
|
||||
|
||||
@@ -42,32 +40,48 @@ import (
|
||||
var TestTemplateProvider deps.ResourceProvider
|
||||
|
||||
type partialCacheKey struct {
|
||||
name string
|
||||
variant any
|
||||
Name string
|
||||
Variants []any
|
||||
}
|
||||
type includeResult struct {
|
||||
name string
|
||||
result any
|
||||
err error
|
||||
}
|
||||
|
||||
func (k partialCacheKey) Key() string {
|
||||
if k.Variants == nil {
|
||||
return k.Name
|
||||
}
|
||||
return identity.HashString(append([]any{k.Name}, k.Variants...)...)
|
||||
}
|
||||
|
||||
func (k partialCacheKey) templateName() string {
|
||||
if !strings.HasPrefix(k.name, "partials/") {
|
||||
return "partials/" + k.name
|
||||
if !strings.HasPrefix(k.Name, "partials/") {
|
||||
return "partials/" + k.Name
|
||||
}
|
||||
return k.name
|
||||
return k.Name
|
||||
}
|
||||
|
||||
// partialCache represents a cache of partials protected by a mutex.
|
||||
// partialCache represents a LRU cache of partials.
|
||||
type partialCache struct {
|
||||
sync.RWMutex
|
||||
p map[partialCacheKey]any
|
||||
cache *lazycache.Cache[string, includeResult]
|
||||
}
|
||||
|
||||
func (p *partialCache) clear() {
|
||||
p.Lock()
|
||||
defer p.Unlock()
|
||||
p.p = make(map[partialCacheKey]any)
|
||||
p.cache.DeleteFunc(func(string, includeResult) bool {
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// New returns a new instance of the templates-namespaced template functions.
|
||||
func New(deps *deps.Deps) *Namespace {
|
||||
cache := &partialCache{p: make(map[partialCacheKey]any)}
|
||||
// This lazycache was introduced in Hugo 0.111.0.
|
||||
// We're going to expand and consolidate all memory caches in Hugo using this,
|
||||
// so just set a high limit for now.
|
||||
lru := lazycache.New[string, includeResult](lazycache.Options{MaxEntries: 1000})
|
||||
|
||||
cache := &partialCache{cache: lru}
|
||||
deps.BuildStartListeners.Add(
|
||||
func() {
|
||||
cache.clear()
|
||||
@@ -103,21 +117,44 @@ func (c *contextWrapper) Set(in any) string {
|
||||
// A string if the partial is a text/template, or template.HTML when html/template.
|
||||
// Note that ctx is provided by Hugo, not the end user.
|
||||
func (ns *Namespace) Include(ctx context.Context, name string, contextList ...any) (any, error) {
|
||||
name, result, err := ns.include(ctx, name, contextList...)
|
||||
if err != nil {
|
||||
return result, err
|
||||
res := ns.includWithTimeout(ctx, name, contextList...)
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
|
||||
if ns.deps.Metrics != nil {
|
||||
ns.deps.Metrics.TrackValue(name, result, false)
|
||||
ns.deps.Metrics.TrackValue(res.name, res.result, false)
|
||||
}
|
||||
|
||||
return res.result, nil
|
||||
}
|
||||
|
||||
func (ns *Namespace) includWithTimeout(ctx context.Context, name string, dataList ...any) includeResult {
|
||||
ctx, cancel := context.WithTimeout(ctx, ns.deps.Timeout)
|
||||
defer cancel()
|
||||
|
||||
res := make(chan includeResult, 1)
|
||||
|
||||
go func() {
|
||||
res <- ns.include(ctx, name, dataList...)
|
||||
}()
|
||||
|
||||
select {
|
||||
case r := <-res:
|
||||
return r
|
||||
case <-ctx.Done():
|
||||
err := ctx.Err()
|
||||
if err == context.DeadlineExceeded {
|
||||
err = fmt.Errorf("partial %q timed out after %s. This is most likely due to infinite recursion. If this is just a slow template, you can try to increase the 'timeout' config setting.", name, ns.deps.Timeout)
|
||||
}
|
||||
return includeResult{err: err}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// include is a helper function that lookups and executes the named partial.
|
||||
// Returns the final template name and the rendered output.
|
||||
func (ns *Namespace) include(ctx context.Context, name string, dataList ...any) (string, any, error) {
|
||||
func (ns *Namespace) include(ctx context.Context, name string, dataList ...any) includeResult {
|
||||
var data any
|
||||
if len(dataList) > 0 {
|
||||
data = dataList[0]
|
||||
@@ -137,7 +174,7 @@ func (ns *Namespace) include(ctx context.Context, name string, dataList ...any)
|
||||
}
|
||||
|
||||
if !found {
|
||||
return "", "", fmt.Errorf("partial %q not found", name)
|
||||
return includeResult{err: fmt.Errorf("partial %q not found", name)}
|
||||
}
|
||||
|
||||
var info tpl.ParseInfo
|
||||
@@ -164,7 +201,7 @@ func (ns *Namespace) include(ctx context.Context, name string, dataList ...any)
|
||||
}
|
||||
|
||||
if err := ns.deps.Tmpl().ExecuteWithContext(ctx, templ, w, data); err != nil {
|
||||
return "", nil, err
|
||||
return includeResult{err: err}
|
||||
}
|
||||
|
||||
var result any
|
||||
@@ -177,101 +214,41 @@ func (ns *Namespace) include(ctx context.Context, name string, dataList ...any)
|
||||
result = template.HTML(w.(fmt.Stringer).String())
|
||||
}
|
||||
|
||||
return templ.Name(), result, nil
|
||||
return includeResult{
|
||||
name: templ.Name(),
|
||||
result: result,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// IncludeCached executes and caches partial templates. The cache is created with name+variants as the key.
|
||||
// Note that ctx is provided by Hugo, not the end user.
|
||||
func (ns *Namespace) IncludeCached(ctx context.Context, name string, context any, variants ...any) (any, error) {
|
||||
key, err := createKey(name, variants...)
|
||||
start := time.Now()
|
||||
key := partialCacheKey{
|
||||
Name: name,
|
||||
Variants: variants,
|
||||
}
|
||||
|
||||
r, found, err := ns.cachedPartials.cache.GetOrCreate(key.Key(), func(string) (includeResult, error) {
|
||||
r := ns.includWithTimeout(ctx, key.Name, context)
|
||||
return r, r.err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := ns.getOrCreate(ctx, key, context)
|
||||
if err == errUnHashable {
|
||||
// Try one more
|
||||
key.variant = helpers.HashString(key.variant)
|
||||
result, err = ns.getOrCreate(ctx, key, context)
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func createKey(name string, variants ...any) (partialCacheKey, error) {
|
||||
var variant any
|
||||
|
||||
if len(variants) > 1 {
|
||||
variant = helpers.HashString(variants...)
|
||||
} else if len(variants) == 1 {
|
||||
variant = variants[0]
|
||||
t := reflect.TypeOf(variant)
|
||||
switch t.Kind() {
|
||||
// This isn't an exhaustive list of unhashable types.
|
||||
// There may be structs with slices,
|
||||
// but that should be very rare. We do recover from that situation
|
||||
// below.
|
||||
case reflect.Slice, reflect.Array, reflect.Map:
|
||||
variant = helpers.HashString(variant)
|
||||
}
|
||||
}
|
||||
|
||||
return partialCacheKey{name: name, variant: variant}, nil
|
||||
}
|
||||
|
||||
var errUnHashable = errors.New("unhashable")
|
||||
|
||||
func (ns *Namespace) getOrCreate(ctx context.Context, key partialCacheKey, context any) (result any, err error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = r.(error)
|
||||
if strings.Contains(err.Error(), "unhashable type") {
|
||||
ns.cachedPartials.RUnlock()
|
||||
err = errUnHashable
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
ns.cachedPartials.RLock()
|
||||
p, ok := ns.cachedPartials.p[key]
|
||||
ns.cachedPartials.RUnlock()
|
||||
|
||||
if ok {
|
||||
if ns.deps.Metrics != nil {
|
||||
ns.deps.Metrics.TrackValue(key.templateName(), p, true)
|
||||
if ns.deps.Metrics != nil {
|
||||
if found {
|
||||
// The templates that gets executed is measured in Execute.
|
||||
// We need to track the time spent in the cache to
|
||||
// get the totals correct.
|
||||
ns.deps.Metrics.MeasureSince(key.templateName(), start)
|
||||
|
||||
}
|
||||
return p, nil
|
||||
ns.deps.Metrics.TrackValue(key.templateName(), r.result, found)
|
||||
}
|
||||
|
||||
// This needs to be done outside the lock.
|
||||
// See #9588
|
||||
_, p, err = ns.include(ctx, key.name, context)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ns.cachedPartials.Lock()
|
||||
defer ns.cachedPartials.Unlock()
|
||||
// Double-check.
|
||||
if p2, ok := ns.cachedPartials.p[key]; ok {
|
||||
if ns.deps.Metrics != nil {
|
||||
ns.deps.Metrics.TrackValue(key.templateName(), p, true)
|
||||
ns.deps.Metrics.MeasureSince(key.templateName(), start)
|
||||
}
|
||||
return p2, nil
|
||||
|
||||
}
|
||||
if ns.deps.Metrics != nil {
|
||||
ns.deps.Metrics.TrackValue(key.templateName(), p, false)
|
||||
}
|
||||
|
||||
ns.cachedPartials.p[key] = p
|
||||
|
||||
return p, nil
|
||||
return r.result, nil
|
||||
}
|
||||
|
Reference in New Issue
Block a user