shithub: hugo

Download patch

ref: cd575023af846aa18ffa709f37bc70277e98cad3
parent: 6315098104ff80f8be6d5ae812835b4b4079582e
author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
date: Tue Aug 13 08:35:04 EDT 2019

Improve the server assets cache invalidation logic

Fixes #6199

--- a/hugofs/glob/glob.go
+++ b/hugofs/glob/glob.go
@@ -51,7 +51,7 @@
 }
 
 func NormalizePath(p string) string {
-	return strings.Trim(filepath.ToSlash(strings.ToLower(p)), "/.")
+	return strings.Trim(path.Clean(filepath.ToSlash(strings.ToLower(p))), "/.")
 }
 
 // ResolveRootDir takes a normalized path on the form "assets/**.json" and
@@ -60,14 +60,7 @@
 	parts := strings.Split(path.Dir(p), "/")
 	var roots []string
 	for _, part := range parts {
-		isSpecial := false
-		for i := 0; i < len(part); i++ {
-			if syntax.Special(part[i]) {
-				isSpecial = true
-				break
-			}
-		}
-		if isSpecial {
+		if HasGlobChar(part) {
 			break
 		}
 		roots = append(roots, part)
@@ -78,4 +71,26 @@
 	}
 
 	return strings.Join(roots, "/")
+}
+
+// FilterGlobParts removes any string with glob wildcard.
+func FilterGlobParts(a []string) []string {
+	b := a[:0]
+	for _, x := range a {
+		if !HasGlobChar(x) {
+			b = append(b, x)
+		}
+	}
+	return b
+}
+
+// HasGlobChar returns whether s contains any glob wildcards.
+func HasGlobChar(s string) bool {
+	for i := 0; i < len(s); i++ {
+		if syntax.Special(s[i]) {
+			return true
+		}
+	}
+	return false
+
 }
--- a/hugofs/glob/glob_test.go
+++ b/hugofs/glob/glob_test.go
@@ -24,8 +24,8 @@
 	c := qt.New(t)
 
 	for _, test := range []struct {
-		in     string
-		expect string
+		input    string
+		expected string
 	}{
 		{"data/foo.json", "data"},
 		{"a/b/**/foo.json", "a/b"},
@@ -33,16 +33,30 @@
 		{"a/b[a-c]/foo.json", "a"},
 	} {
 
-		c.Assert(ResolveRootDir(test.in), qt.Equals, test.expect)
+		c.Assert(ResolveRootDir(test.input), qt.Equals, test.expected)
 	}
 }
 
+func TestFilterGlobParts(t *testing.T) {
+	c := qt.New(t)
+
+	for _, test := range []struct {
+		input    []string
+		expected []string
+	}{
+		{[]string{"a", "*", "c"}, []string{"a", "c"}},
+	} {
+
+		c.Assert(FilterGlobParts(test.input), qt.DeepEquals, test.expected)
+	}
+}
+
 func TestNormalizePath(t *testing.T) {
 	c := qt.New(t)
 
 	for _, test := range []struct {
-		in     string
-		expect string
+		input    string
+		expected string
 	}{
 		{filepath.FromSlash("data/FOO.json"), "data/foo.json"},
 		{filepath.FromSlash("/data/FOO.json"), "data/foo.json"},
@@ -50,7 +64,7 @@
 		{"//", ""},
 	} {
 
-		c.Assert(NormalizePath(test.in), qt.Equals, test.expect)
+		c.Assert(NormalizePath(test.input), qt.Equals, test.expected)
 	}
 }
 
--- a/hugolib/site.go
+++ b/hugolib/site.go
@@ -917,10 +917,12 @@
 		logger = helpers.NewDistinctFeedbackLogger()
 	)
 
-	cachePartitions := make([]string, len(events))
+	var cachePartitions []string
 
-	for i, ev := range events {
-		cachePartitions[i] = resources.ResourceKeyPartition(ev.Name)
+	for _, ev := range events {
+		if assetsFilename := s.BaseFs.Assets.MakePathRelative(ev.Name); assetsFilename != "" {
+			cachePartitions = append(cachePartitions, resources.ResourceKeyPartitions(assetsFilename)...)
+		}
 
 		if s.isContentDirEvent(ev) {
 			logger.Println("Source changed", ev)
--- a/resources/resource_cache.go
+++ b/resources/resource_cache.go
@@ -21,6 +21,10 @@
 	"strings"
 	"sync"
 
+	"github.com/gohugoio/hugo/helpers"
+
+	"github.com/gohugoio/hugo/hugofs/glob"
+
 	"github.com/gohugoio/hugo/resources/resource"
 
 	"github.com/gohugoio/hugo/cache/filecache"
@@ -47,11 +51,14 @@
 	nlocker *locker.Locker
 }
 
-// ResourceKeyPartition returns a partition name
-// to  allow for more fine grained cache flushes.
-// It will return the file extension without the leading ".". If no
-// extension, it will return "other".
-func ResourceKeyPartition(filename string) string {
+// ResourceCacheKey converts the filename into the format used in the resource
+// cache.
+func ResourceCacheKey(filename string) string {
+	filename = filepath.ToSlash(filename)
+	return path.Join(resourceKeyPartition(filename), filename)
+}
+
+func resourceKeyPartition(filename string) string {
 	ext := strings.TrimPrefix(path.Ext(filepath.ToSlash(filename)), ".")
 	if ext == "" {
 		ext = CACHE_OTHER
@@ -59,6 +66,63 @@
 	return ext
 }
 
+// Commonly used aliases and directory names used for some types.
+var extAliasKeywords = map[string][]string{
+	"sass": []string{"scss"},
+	"scss": []string{"sass"},
+}
+
+// ResourceKeyPartitions resolves a ordered slice of partitions that is
+// used to do resource cache invalidations.
+//
+// We use the first directory path element and the extension, so:
+//     a/b.json => "a", "json"
+//     b.json => "json"
+//
+// For some of the extensions we will also map to closely related types,
+// e.g. "scss" will also return "sass".
+//
+func ResourceKeyPartitions(filename string) []string {
+	var partitions []string
+	filename = glob.NormalizePath(filename)
+	dir, name := path.Split(filename)
+	ext := strings.TrimPrefix(path.Ext(filepath.ToSlash(name)), ".")
+
+	if dir != "" {
+		partitions = append(partitions, strings.Split(dir, "/")[0])
+	}
+
+	if ext != "" {
+		partitions = append(partitions, ext)
+	}
+
+	if aliases, found := extAliasKeywords[ext]; found {
+		partitions = append(partitions, aliases...)
+	}
+
+	if len(partitions) == 0 {
+		partitions = []string{CACHE_OTHER}
+	}
+
+	return helpers.UniqueStringsSorted(partitions)
+}
+
+// ResourceKeyContainsAny returns whether the key is a member of any of the
+// given partitions.
+//
+// This is used for resource cache invalidation.
+func ResourceKeyContainsAny(key string, partitions []string) bool {
+	parts := strings.Split(key, "/")
+	for _, p1 := range partitions {
+		for _, p2 := range parts {
+			if p1 == p2 {
+				return true
+			}
+		}
+	}
+	return false
+}
+
 func newResourceCache(rs *Spec) *ResourceCache {
 	return &ResourceCache{
 		rs:        rs,
@@ -83,7 +147,7 @@
 }
 
 func (c *ResourceCache) cleanKey(key string) string {
-	return strings.TrimPrefix(path.Clean(key), "/")
+	return strings.TrimPrefix(path.Clean(strings.ToLower(key)), "/")
 }
 
 func (c *ResourceCache) get(key string) (interface{}, bool) {
@@ -93,8 +157,8 @@
 	return r, found
 }
 
-func (c *ResourceCache) GetOrCreate(partition, key string, f func() (resource.Resource, error)) (resource.Resource, error) {
-	r, err := c.getOrCreate(partition, key, func() (interface{}, error) { return f() })
+func (c *ResourceCache) GetOrCreate(key string, f func() (resource.Resource, error)) (resource.Resource, error) {
+	r, err := c.getOrCreate(key, func() (interface{}, error) { return f() })
 	if r == nil || err != nil {
 		return nil, err
 	}
@@ -101,8 +165,8 @@
 	return r.(resource.Resource), nil
 }
 
-func (c *ResourceCache) GetOrCreateResources(partition, key string, f func() (resource.Resources, error)) (resource.Resources, error) {
-	r, err := c.getOrCreate(partition, key, func() (interface{}, error) { return f() })
+func (c *ResourceCache) GetOrCreateResources(key string, f func() (resource.Resources, error)) (resource.Resources, error) {
+	r, err := c.getOrCreate(key, func() (interface{}, error) { return f() })
 	if r == nil || err != nil {
 		return nil, err
 	}
@@ -109,8 +173,8 @@
 	return r.(resource.Resources), nil
 }
 
-func (c *ResourceCache) getOrCreate(partition, key string, f func() (interface{}, error)) (interface{}, error) {
-	key = c.cleanKey(path.Join(partition, key))
+func (c *ResourceCache) getOrCreate(key string, f func() (interface{}, error)) (interface{}, error) {
+	key = c.cleanKey(key)
 	// First check in-memory cache.
 	r, found := c.get(key)
 	if found {
@@ -200,7 +264,7 @@
 
 func (c *ResourceCache) DeletePartitions(partitions ...string) {
 	partitionsSet := map[string]bool{
-		// Always clear out the resources not matching the partition.
+		// Always clear out the resources not matching any partition.
 		"other": true,
 	}
 	for _, p := range partitions {
@@ -217,13 +281,11 @@
 
 	for k := range c.cache {
 		clear := false
-		partIdx := strings.Index(k, "/")
-		if partIdx == -1 {
-			clear = true
-		} else {
-			partition := k[:partIdx]
-			if partitionsSet[partition] {
+		for p, _ := range partitionsSet {
+			if strings.Contains(k, p) {
+				// There will be some false positive, but that's fine.
 				clear = true
+				break
 			}
 		}
 
--- /dev/null
+++ b/resources/resource_cache_test.go
@@ -1,0 +1,58 @@
+// Copyright 2019 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 resources
+
+import (
+	"path/filepath"
+	"testing"
+
+	qt "github.com/frankban/quicktest"
+)
+
+func TestResourceKeyPartitions(t *testing.T) {
+	c := qt.New(t)
+
+	for _, test := range []struct {
+		input    string
+		expected []string
+	}{
+		{"a.js", []string{"js"}},
+		{"a.scss", []string{"sass", "scss"}},
+		{"a.sass", []string{"sass", "scss"}},
+		{"d/a.js", []string{"d", "js"}},
+		{"js/a.js", []string{"js"}},
+		{"D/a.JS", []string{"d", "js"}},
+		{"d/a", []string{"d"}},
+		{filepath.FromSlash("/d/a.js"), []string{"d", "js"}},
+		{filepath.FromSlash("/d/e/a.js"), []string{"d", "js"}},
+	} {
+		c.Assert(ResourceKeyPartitions(test.input), qt.DeepEquals, test.expected, qt.Commentf(test.input))
+	}
+}
+
+func TestResourceKeyContainsAny(t *testing.T) {
+	c := qt.New(t)
+
+	for _, test := range []struct {
+		key      string
+		filename string
+		expected bool
+	}{
+		{"styles/css", "asdf.css", true},
+		{"styles/css", "styles/asdf.scss", true},
+		{"js/foo.bar", "asdf.css", false},
+	} {
+		c.Assert(ResourceKeyContainsAny(test.key, ResourceKeyPartitions(test.filename)), qt.Equals, test.expected)
+	}
+}
--- a/resources/resource_factories/bundler/bundler.go
+++ b/resources/resource_factories/bundler/bundler.go
@@ -18,6 +18,7 @@
 	"bytes"
 	"fmt"
 	"io"
+	"path"
 	"path/filepath"
 
 	"github.com/gohugoio/hugo/common/hugio"
@@ -66,7 +67,7 @@
 // Concat concatenates the list of Resource objects.
 func (c *Client) Concat(targetPath string, r resource.Resources) (resource.Resource, error) {
 	// The CACHE_OTHER will make sure this will be re-created and published on rebuilds.
-	return c.rs.ResourceCache.GetOrCreate(resources.CACHE_OTHER, targetPath, func() (resource.Resource, error) {
+	return c.rs.ResourceCache.GetOrCreate(path.Join(resources.CACHE_OTHER, targetPath), func() (resource.Resource, error) {
 		var resolvedm media.Type
 
 		// The given set of resources must be of the same Media Type.
--- a/resources/resource_factories/create/create.go
+++ b/resources/resource_factories/create/create.go
@@ -18,6 +18,7 @@
 import (
 	"path"
 	"path/filepath"
+	"strings"
 
 	"github.com/gohugoio/hugo/hugofs/glob"
 
@@ -42,7 +43,7 @@
 // Get creates a new Resource by opening the given filename in the assets filesystem.
 func (c *Client) Get(filename string) (resource.Resource, error) {
 	filename = filepath.Clean(filename)
-	return c.rs.ResourceCache.GetOrCreate(resources.ResourceKeyPartition(filename), filename, func() (resource.Resource, error) {
+	return c.rs.ResourceCache.GetOrCreate(resources.ResourceCacheKey(filename), func() (resource.Resource, error) {
 		return c.rs.New(resources.ResourceSourceDescriptor{
 			Fs:             c.rs.BaseFs.Assets.Fs,
 			LazyPublish:    true,
@@ -66,18 +67,22 @@
 }
 
 func (c *Client) match(pattern string, firstOnly bool) (resource.Resources, error) {
-	var partition string
+	var name string
 	if firstOnly {
-		partition = "__get-match"
+		name = "__get-match"
 	} else {
-		partition = "__match"
+		name = "__match"
 	}
 
-	// TODO(bep) match will be improved as part of https://github.com/gohugoio/hugo/issues/6199
-	partition = path.Join(resources.CACHE_OTHER, partition)
-	key := glob.NormalizePath(pattern)
+	pattern = glob.NormalizePath(pattern)
+	partitions := glob.FilterGlobParts(strings.Split(pattern, "/"))
+	if len(partitions) == 0 {
+		partitions = []string{resources.CACHE_OTHER}
+	}
+	key := path.Join(name, path.Join(partitions...))
+	key = path.Join(key, pattern)
 
-	return c.rs.ResourceCache.GetOrCreateResources(partition, key, func() (resource.Resources, error) {
+	return c.rs.ResourceCache.GetOrCreateResources(key, func() (resource.Resources, error) {
 		var res resource.Resources
 
 		handle := func(info hugofs.FileMetaInfo) (bool, error) {
@@ -110,7 +115,7 @@
 
 // FromString creates a new Resource from a string with the given relative target path.
 func (c *Client) FromString(targetPath, content string) (resource.Resource, error) {
-	return c.rs.ResourceCache.GetOrCreate(resources.CACHE_OTHER, targetPath, func() (resource.Resource, error) {
+	return c.rs.ResourceCache.GetOrCreate(path.Join(resources.CACHE_OTHER, targetPath), func() (resource.Resource, error) {
 		return c.rs.New(
 			resources.ResourceSourceDescriptor{
 				Fs:          c.rs.FileCaches.AssetsCache().Fs,
--- a/resources/transform.go
+++ b/resources/transform.go
@@ -330,14 +330,13 @@
 			if p == "" {
 				panic("target path needed for key creation")
 			}
-			partition := ResourceKeyPartition(p)
-			base = partition + "/" + p
+			base = ResourceCacheKey(p)
 		default:
 			return fmt.Errorf("transformation not supported for type %T", element)
 		}
 	}
 
-	key = r.cache.cleanKey(base + "_" + helpers.MD5String(key))
+	key = r.cache.cleanKey(base) + "_" + helpers.MD5String(key)
 
 	cached, found := r.cache.get(key)
 	if found {