hugo/cache/dynacache/dynacache.go
Bjørn Erik Pedersen 7203a95a60 Fix rebuilds when running hugo -w
This was partly broken in Hugo 0.123.0.

We have two internal config options that gets set from the CLI:

* Running; a web server is running
* Watching; either set via `hugo -w`  or `hugo server --watch=false`

Part of the change detection code wrongly used the `Running` as a flag when `Watching` would be the correct.

Fixes #12296
2024-04-25 14:35:49 +02:00

594 lines
14 KiB
Go

// Copyright 2024 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 dynacache
import (
"context"
"fmt"
"math"
"path"
"regexp"
"runtime"
"sync"
"time"
"github.com/bep/lazycache"
"github.com/bep/logg"
"github.com/gohugoio/hugo/common/collections"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/common/rungroup"
"github.com/gohugoio/hugo/config"
"github.com/gohugoio/hugo/helpers"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/resources/resource"
)
const minMaxSize = 10
// New creates a new cache.
func New(opts Options) *Cache {
if opts.CheckInterval == 0 {
opts.CheckInterval = time.Second * 2
}
if opts.MaxSize == 0 {
opts.MaxSize = 100000
}
if opts.Log == nil {
panic("nil Log")
}
if opts.MinMaxSize == 0 {
opts.MinMaxSize = 30
}
stats := &stats{
opts: opts,
adjustmentFactor: 1.0,
currentMaxSize: opts.MaxSize,
availableMemory: config.GetMemoryLimit(),
}
infol := opts.Log.InfoCommand("dynacache")
evictedIdentities := collections.NewStack[identity.Identity]()
onEvict := func(k, v any) {
if !opts.Watching {
return
}
identity.WalkIdentitiesShallow(v, func(level int, id identity.Identity) bool {
evictedIdentities.Push(id)
return false
})
resource.MarkStale(v)
}
c := &Cache{
partitions: make(map[string]PartitionManager),
onEvict: onEvict,
evictedIdentities: evictedIdentities,
opts: opts,
stats: stats,
infol: infol,
}
c.stop = c.start()
return c
}
// Options for the cache.
type Options struct {
Log loggers.Logger
CheckInterval time.Duration
MaxSize int
MinMaxSize int
Watching bool
}
// Options for a partition.
type OptionsPartition struct {
// When to clear the this partition.
ClearWhen ClearWhen
// Weight is a number between 1 and 100 that indicates how, in general, how big this partition may get.
Weight int
}
func (o OptionsPartition) WeightFraction() float64 {
return float64(o.Weight) / 100
}
func (o OptionsPartition) CalculateMaxSize(maxSizePerPartition int) int {
return int(math.Floor(float64(maxSizePerPartition) * o.WeightFraction()))
}
// A dynamic partitioned cache.
type Cache struct {
mu sync.RWMutex
partitions map[string]PartitionManager
onEvict func(k, v any)
evictedIdentities *collections.Stack[identity.Identity]
opts Options
infol logg.LevelLogger
stats *stats
stopOnce sync.Once
stop func()
}
// DrainEvictedIdentities drains the evicted identities from the cache.
func (c *Cache) DrainEvictedIdentities() []identity.Identity {
return c.evictedIdentities.Drain()
}
// ClearMatching clears all partition for which the predicate returns true.
func (c *Cache) ClearMatching(predicatePartition func(k string, p PartitionManager) bool, predicateValue func(k, v any) bool) {
if predicatePartition == nil {
predicatePartition = func(k string, p PartitionManager) bool { return true }
}
if predicateValue == nil {
panic("nil predicateValue")
}
g := rungroup.Run[PartitionManager](context.Background(), rungroup.Config[PartitionManager]{
NumWorkers: len(c.partitions),
Handle: func(ctx context.Context, partition PartitionManager) error {
partition.clearMatching(predicateValue)
return nil
},
})
for k, p := range c.partitions {
if !predicatePartition(k, p) {
continue
}
g.Enqueue(p)
}
g.Wait()
}
// ClearOnRebuild prepares the cache for a new rebuild taking the given changeset into account.
func (c *Cache) ClearOnRebuild(changeset ...identity.Identity) {
g := rungroup.Run[PartitionManager](context.Background(), rungroup.Config[PartitionManager]{
NumWorkers: len(c.partitions),
Handle: func(ctx context.Context, partition PartitionManager) error {
partition.clearOnRebuild(changeset...)
return nil
},
})
for _, p := range c.partitions {
g.Enqueue(p)
}
g.Wait()
// Clear any entries marked as stale above.
g = rungroup.Run[PartitionManager](context.Background(), rungroup.Config[PartitionManager]{
NumWorkers: len(c.partitions),
Handle: func(ctx context.Context, partition PartitionManager) error {
partition.clearStale()
return nil
},
})
for _, p := range c.partitions {
g.Enqueue(p)
}
g.Wait()
}
type keysProvider interface {
Keys() []string
}
// Keys returns a list of keys in all partitions.
func (c *Cache) Keys(predicate func(s string) bool) []string {
if predicate == nil {
predicate = func(s string) bool { return true }
}
var keys []string
for pn, g := range c.partitions {
pkeys := g.(keysProvider).Keys()
for _, k := range pkeys {
p := path.Join(pn, k)
if predicate(p) {
keys = append(keys, p)
}
}
}
return keys
}
func calculateMaxSizePerPartition(maxItemsTotal, totalWeightQuantity, numPartitions int) int {
if numPartitions == 0 {
panic("numPartitions must be > 0")
}
if totalWeightQuantity == 0 {
panic("totalWeightQuantity must be > 0")
}
avgWeight := float64(totalWeightQuantity) / float64(numPartitions)
return int(math.Floor(float64(maxItemsTotal) / float64(numPartitions) * (100.0 / avgWeight)))
}
// Stop stops the cache.
func (c *Cache) Stop() {
c.stopOnce.Do(func() {
c.stop()
})
}
func (c *Cache) adjustCurrentMaxSize() {
c.mu.RLock()
defer c.mu.RUnlock()
if len(c.partitions) == 0 {
return
}
var m runtime.MemStats
runtime.ReadMemStats(&m)
s := c.stats
s.memstatsCurrent = m
// fmt.Printf("\n\nAvailable = %v\nAlloc = %v\nTotalAlloc = %v\nSys = %v\nNumGC = %v\nMaxSize = %d\nAdjustmentFactor=%f\n\n", helpers.FormatByteCount(s.availableMemory), helpers.FormatByteCount(m.Alloc), helpers.FormatByteCount(m.TotalAlloc), helpers.FormatByteCount(m.Sys), m.NumGC, c.stats.currentMaxSize, s.adjustmentFactor)
if s.availableMemory >= s.memstatsCurrent.Alloc {
if s.adjustmentFactor <= 1.0 {
s.adjustmentFactor += 0.2
}
} else {
// We're low on memory.
s.adjustmentFactor -= 0.4
}
if s.adjustmentFactor <= 0 {
s.adjustmentFactor = 0.05
}
if !s.adjustCurrentMaxSize() {
return
}
totalWeight := 0
for _, pm := range c.partitions {
totalWeight += pm.getOptions().Weight
}
maxSizePerPartition := calculateMaxSizePerPartition(c.stats.currentMaxSize, totalWeight, len(c.partitions))
evicted := 0
for _, p := range c.partitions {
evicted += p.adjustMaxSize(p.getOptions().CalculateMaxSize(maxSizePerPartition))
}
if evicted > 0 {
c.infol.
WithFields(
logg.Fields{
{Name: "evicted", Value: evicted},
{Name: "numGC", Value: m.NumGC},
{Name: "limit", Value: helpers.FormatByteCount(c.stats.availableMemory)},
{Name: "alloc", Value: helpers.FormatByteCount(m.Alloc)},
{Name: "totalAlloc", Value: helpers.FormatByteCount(m.TotalAlloc)},
},
).Logf("adjusted partitions' max size")
}
}
func (c *Cache) start() func() {
ticker := time.NewTicker(c.opts.CheckInterval)
quit := make(chan struct{})
go func() {
for {
select {
case <-ticker.C:
c.adjustCurrentMaxSize()
// Reset the ticker to avoid drift.
ticker.Reset(c.opts.CheckInterval)
case <-quit:
ticker.Stop()
return
}
}
}()
return func() {
close(quit)
}
}
var partitionNameRe = regexp.MustCompile(`^\/[a-zA-Z0-9]{4}(\/[a-zA-Z0-9]+)?(\/[a-zA-Z0-9]+)?`)
// GetOrCreatePartition gets or creates a partition with the given name.
func GetOrCreatePartition[K comparable, V any](c *Cache, name string, opts OptionsPartition) *Partition[K, V] {
if c == nil {
panic("nil Cache")
}
if opts.Weight < 1 || opts.Weight > 100 {
panic("invalid Weight, must be between 1 and 100")
}
if partitionNameRe.FindString(name) != name {
panic(fmt.Sprintf("invalid partition name %q", name))
}
c.mu.RLock()
p, found := c.partitions[name]
c.mu.RUnlock()
if found {
return p.(*Partition[K, V])
}
c.mu.Lock()
defer c.mu.Unlock()
// Double check.
p, found = c.partitions[name]
if found {
return p.(*Partition[K, V])
}
// At this point, we don't know the number of partitions or their configuration, but
// this will be re-adjusted later.
const numberOfPartitionsEstimate = 10
maxSize := opts.CalculateMaxSize(c.opts.MaxSize / numberOfPartitionsEstimate)
onEvict := func(k K, v V) {
c.onEvict(k, v)
}
// Create a new partition and cache it.
partition := &Partition[K, V]{
c: lazycache.New(lazycache.Options[K, V]{MaxEntries: maxSize, OnEvict: onEvict}),
maxSize: maxSize,
trace: c.opts.Log.Logger().WithLevel(logg.LevelTrace).WithField("partition", name),
opts: opts,
}
c.partitions[name] = partition
return partition
}
// Partition is a partition in the cache.
type Partition[K comparable, V any] struct {
c *lazycache.Cache[K, V]
zero V
trace logg.LevelLogger
opts OptionsPartition
maxSize int
}
// GetOrCreate gets or creates a value for the given key.
func (p *Partition[K, V]) GetOrCreate(key K, create func(key K) (V, error)) (V, error) {
v, _, err := p.c.GetOrCreate(key, create)
return v, err
}
// GetOrCreateWitTimeout gets or creates a value for the given key and times out if the create function
// takes too long.
func (p *Partition[K, V]) GetOrCreateWitTimeout(key K, duration time.Duration, create func(key K) (V, error)) (V, error) {
resultch := make(chan V, 1)
errch := make(chan error, 1)
go func() {
v, _, err := p.c.GetOrCreate(key, create)
if err != nil {
errch <- err
return
}
resultch <- v
}()
select {
case v := <-resultch:
return v, nil
case err := <-errch:
return p.zero, err
case <-time.After(duration):
return p.zero, &herrors.TimeoutError{
Duration: duration,
}
}
}
func (p *Partition[K, V]) clearMatching(predicate func(k, v any) bool) {
p.c.DeleteFunc(func(key K, v V) bool {
if predicate(key, v) {
p.trace.Log(
logg.StringFunc(
func() string {
return fmt.Sprintf("clearing cache key %v", key)
},
),
)
return true
}
return false
})
}
func (p *Partition[K, V]) clearOnRebuild(changeset ...identity.Identity) {
opts := p.getOptions()
if opts.ClearWhen == ClearNever {
return
}
if opts.ClearWhen == ClearOnRebuild {
// Clear all.
p.Clear()
return
}
depsFinder := identity.NewFinder(identity.FinderConfig{})
shouldDelete := func(key K, v V) bool {
// We always clear elements marked as stale.
if resource.IsStaleAny(v) {
return true
}
// Now check if this entry has changed based on the changeset
// based on filesystem events.
if len(changeset) == 0 {
// Nothing changed.
return false
}
var probablyDependent bool
identity.WalkIdentitiesShallow(v, func(level int, id2 identity.Identity) bool {
for _, id := range changeset {
if r := depsFinder.Contains(id, id2, -1); r > 0 {
// It's probably dependent, evict from cache.
probablyDependent = true
return true
}
}
return false
})
return probablyDependent
}
// First pass.
// Second pass needs to be done in a separate loop to catch any
// elements marked as stale in the other partitions.
p.c.DeleteFunc(func(key K, v V) bool {
if shouldDelete(key, v) {
p.trace.Log(
logg.StringFunc(
func() string {
return fmt.Sprintf("first pass: clearing cache key %v", key)
},
),
)
return true
}
return false
})
}
func (p *Partition[K, V]) Keys() []K {
var keys []K
p.c.DeleteFunc(func(key K, v V) bool {
keys = append(keys, key)
return false
})
return keys
}
func (p *Partition[K, V]) clearStale() {
p.c.DeleteFunc(func(key K, v V) bool {
isStale := resource.IsStaleAny(v)
if isStale {
p.trace.Log(
logg.StringFunc(
func() string {
return fmt.Sprintf("second pass: clearing cache key %v", key)
},
),
)
}
return isStale
})
}
// adjustMaxSize adjusts the max size of the and returns the number of items evicted.
func (p *Partition[K, V]) adjustMaxSize(newMaxSize int) int {
if newMaxSize < minMaxSize {
newMaxSize = minMaxSize
}
oldMaxSize := p.maxSize
if newMaxSize == oldMaxSize {
return 0
}
p.maxSize = newMaxSize
// fmt.Println("Adjusting max size of partition from", oldMaxSize, "to", newMaxSize)
return p.c.Resize(newMaxSize)
}
func (p *Partition[K, V]) getMaxSize() int {
return p.maxSize
}
func (p *Partition[K, V]) getOptions() OptionsPartition {
return p.opts
}
func (p *Partition[K, V]) Clear() {
p.c.DeleteFunc(func(key K, v V) bool {
return true
})
}
func (p *Partition[K, V]) Get(ctx context.Context, key K) (V, bool) {
return p.c.Get(key)
}
type PartitionManager interface {
adjustMaxSize(addend int) int
getMaxSize() int
getOptions() OptionsPartition
clearOnRebuild(changeset ...identity.Identity)
clearMatching(predicate func(k, v any) bool)
clearStale()
}
const (
ClearOnRebuild ClearWhen = iota + 1
ClearOnChange
ClearNever
)
type ClearWhen int
type stats struct {
opts Options
memstatsCurrent runtime.MemStats
currentMaxSize int
availableMemory uint64
adjustmentFactor float64
}
func (s *stats) adjustCurrentMaxSize() bool {
newCurrentMaxSize := int(math.Floor(float64(s.opts.MaxSize) * s.adjustmentFactor))
if newCurrentMaxSize < s.opts.MinMaxSize {
newCurrentMaxSize = int(s.opts.MinMaxSize)
}
changed := newCurrentMaxSize != s.currentMaxSize
s.currentMaxSize = newCurrentMaxSize
return changed
}
// CleanKey turns s into a format suitable for a cache key for this package.
// The key will be a Unix-styled path with a leading slash but no trailing slash.
func CleanKey(s string) string {
return path.Clean(paths.ToSlashPreserveLeading(s))
}