hugo/tpl/partials/partials.go
Bjørn Erik Pedersen 0927cf739f tpl/partials: Make sure a cached partial is invoked only once
This commit revises the locking strategy for `partialCached`. We have added a benchmark that may be a little artificial, but it should at least show that we're not losing any performance over this:

```bash
name              old time/op    new time/op    delta
IncludeCached-10    12.2ms ± 2%    11.3ms ± 1%   -7.36%  (p=0.029 n=4+4)

name              old alloc/op   new alloc/op   delta
IncludeCached-10    7.17MB ± 0%    5.09MB ± 0%  -29.00%  (p=0.029 n=4+4)

name              old allocs/op  new allocs/op  delta
IncludeCached-10      128k ± 1%       70k ± 0%  -45.42%  (p=0.029 n=4+4)
```

This commit also revises the template metrics hints logic a little, and add a test for it, which output is currently this:

```bash
 cumulative       average       maximum      cache  percent  cached  total
       duration      duration      duration  potential   cached   count  count  template
     ----------      --------      --------  ---------  -------  ------  -----  --------
      163.334µs     163.334µs     163.334µs          0        0       0      1  index.html
       23.749µs       5.937µs      19.916µs         25       50       2      4  partials/dynamic1.html
        9.625µs       4.812µs        6.75µs        100       50       1      2  partials/static1.html
        7.625µs       7.625µs       7.625µs        100        0       0      1  partials/static2.html
```

Some notes:

* The duration now includes the cached invocations (which should be very short)
* A cached template gets executed once before it gets cached, so the "percent cached" will never be 100.

Fixes #4086
Fixes #9506
2022-02-17 11:53:24 +01:00

265 lines
6.5 KiB
Go

// Copyright 2017 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package partials provides template functions for working with reusable
// templates.
package partials
import (
"errors"
"fmt"
"html/template"
"io"
"io/ioutil"
"reflect"
"strings"
"sync"
"time"
texttemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate"
"github.com/gohugoio/hugo/helpers"
"github.com/gohugoio/hugo/tpl"
bp "github.com/gohugoio/hugo/bufferpool"
"github.com/gohugoio/hugo/deps"
)
// TestTemplateProvider is global deps.ResourceProvider.
// NOTE: It's currently unused.
var TestTemplateProvider deps.ResourceProvider
type partialCacheKey struct {
name string
variant interface{}
}
func (k partialCacheKey) templateName() string {
if !strings.HasPrefix(k.name, "partials/") {
return "partials/" + k.name
}
return k.name
}
// partialCache represents a cache of partials protected by a mutex.
type partialCache struct {
sync.RWMutex
p map[partialCacheKey]interface{}
}
func (p *partialCache) clear() {
p.Lock()
defer p.Unlock()
p.p = make(map[partialCacheKey]interface{})
}
// New returns a new instance of the templates-namespaced template functions.
func New(deps *deps.Deps) *Namespace {
cache := &partialCache{p: make(map[partialCacheKey]interface{})}
deps.BuildStartListeners.Add(
func() {
cache.clear()
})
return &Namespace{
deps: deps,
cachedPartials: cache,
}
}
// Namespace provides template functions for the "templates" namespace.
type Namespace struct {
deps *deps.Deps
cachedPartials *partialCache
}
// contextWrapper makes room for a return value in a partial invocation.
type contextWrapper struct {
Arg interface{}
Result interface{}
}
// Set sets the return value and returns an empty string.
func (c *contextWrapper) Set(in interface{}) string {
c.Result = in
return ""
}
// Include executes the named partial.
// If the partial contains a return statement, that value will be returned.
// Else, the rendered output will be returned:
// A string if the partial is a text/template, or template.HTML when html/template.
func (ns *Namespace) Include(name string, contextList ...interface{}) (interface{}, error) {
name, result, err := ns.include(name, contextList...)
if err != nil {
return result, err
}
if ns.deps.Metrics != nil {
ns.deps.Metrics.TrackValue(name, result, false)
}
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(name string, contextList ...interface{}) (string, interface{}, error) {
var context interface{}
if len(contextList) > 0 {
context = contextList[0]
}
var n string
if strings.HasPrefix(name, "partials/") {
n = name
} else {
n = "partials/" + name
}
templ, found := ns.deps.Tmpl().Lookup(n)
if !found {
// For legacy reasons.
templ, found = ns.deps.Tmpl().Lookup(n + ".html")
}
if !found {
return "", "", fmt.Errorf("partial %q not found", name)
}
var info tpl.ParseInfo
if ip, ok := templ.(tpl.Info); ok {
info = ip.ParseInfo()
}
var w io.Writer
if info.HasReturn {
// Wrap the context sent to the template to capture the return value.
// Note that the template is rewritten to make sure that the dot (".")
// and the $ variable points to Arg.
context = &contextWrapper{
Arg: context,
}
// We don't care about any template output.
w = ioutil.Discard
} else {
b := bp.GetBuffer()
defer bp.PutBuffer(b)
w = b
}
if err := ns.deps.Tmpl().Execute(templ, w, context); err != nil {
return "", "", err
}
var result interface{}
if ctx, ok := context.(*contextWrapper); ok {
result = ctx.Result
} else if _, ok := templ.(*texttemplate.Template); ok {
result = w.(fmt.Stringer).String()
} else {
result = template.HTML(w.(fmt.Stringer).String())
}
return templ.Name(), result, nil
}
// IncludeCached executes and caches partial templates. The cache is created with name+variants as the key.
func (ns *Namespace) IncludeCached(name string, context interface{}, variants ...interface{}) (interface{}, error) {
key, err := createKey(name, variants...)
if err != nil {
return nil, err
}
result, err := ns.getOrCreate(key, context)
if err == errUnHashable {
// Try one more
key.variant = helpers.HashString(key.variant)
result, err = ns.getOrCreate(key, context)
}
return result, err
}
func createKey(name string, variants ...interface{}) (partialCacheKey, error) {
var variant interface{}
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(key partialCacheKey, context interface{}) (result interface{}, 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)
// The templates that gets executed is measued 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.cachedPartials.Lock()
defer ns.cachedPartials.Unlock()
var name string
name, p, err = ns.include(key.name, context)
if err != nil {
return nil, err
}
if ns.deps.Metrics != nil {
ns.deps.Metrics.TrackValue(name, p, false)
}
ns.cachedPartials.p[key] = p
return p, nil
}