Add stale-while-revalidate support to the cache.

This commit is contained in:
miyuko
2025-10-15 22:47:24 +01:00
parent 8bb6d0ff28
commit eda3e8a791
12 changed files with 60 additions and 19 deletions

View File

@@ -34,6 +34,7 @@ max-size = "256MB"
[storage.s3.site-cache]
max-size = "16MB"
max-age = "60s"
max-stale = "1h"
[limits]
max-site-size = "128M"

1
go.mod
View File

@@ -14,6 +14,7 @@ require (
github.com/maypok86/otter/v2 v2.2.1
github.com/minio/minio-go/v7 v7.0.95
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pquerna/cachecontrol v0.2.0
github.com/prometheus/client_golang v1.23.2
github.com/samber/slog-multi v1.5.0
github.com/tj/go-redirects v0.0.0-20200911105812-fd1ba1020b37

2
go.sum
View File

@@ -97,6 +97,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEnkRx9k=
github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=

View File

@@ -34,7 +34,7 @@ type Backend interface {
DeleteBlob(ctx context.Context, name string) error
// Retrieve a manifest.
GetManifest(ctx context.Context, name string) (*Manifest, error)
GetManifest(ctx context.Context, name string, opts GetManifestOptions) (*Manifest, error)
// Stage a manifest. This operation stores a new version of a manifest, locking any blobs
// referenced from it in place (for garbage collection purposes) but without any other side
@@ -52,6 +52,10 @@ type Backend interface {
CheckDomain(ctx context.Context, domain string) (found bool, err error)
}
type GetManifestOptions struct {
BypassCache bool
}
var backend Backend
func ConfigureBackend(config *StorageConfig) (err error) {

View File

@@ -133,7 +133,7 @@ func (fs *FSBackend) DeleteBlob(ctx context.Context, name string) error {
return fs.blobRoot.Remove(blobPath)
}
func (fs *FSBackend) GetManifest(ctx context.Context, name string) (*Manifest, error) {
func (fs *FSBackend) GetManifest(ctx context.Context, name string, opts GetManifestOptions) (*Manifest, error) {
data, err := fs.siteRoot.ReadFile(name)
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("%w: %s", errNotFound, err.(*os.PathError).Path)

View File

@@ -121,8 +121,11 @@ func makeCacheOptions[K comparable, V any](
options.MaximumWeight = config.MaxSize.Bytes()
options.Weigher = weigher
}
if config.MaxAge != 0 {
options.ExpiryCalculator = otter.ExpiryWriting[K, V](time.Duration(config.MaxAge))
if config.MaxStale != 0 {
options.RefreshCalculator = otter.RefreshWriting[K, V](time.Duration(config.MaxAge))
}
if config.MaxAge != 0 || config.MaxStale != 0 {
options.ExpiryCalculator = otter.ExpiryWriting[K, V](time.Duration(config.MaxAge + config.MaxStale))
}
return options
}
@@ -284,7 +287,7 @@ func stagedManifestObjectName(manifestData []byte) string {
return fmt.Sprintf("dirty/%x", sha256.Sum256(manifestData))
}
func (s3 *S3Backend) GetManifest(ctx context.Context, name string) (*Manifest, error) {
func (s3 *S3Backend) GetManifest(ctx context.Context, name string, opts GetManifestOptions) (*Manifest, error) {
loader := func(ctx context.Context, name string) (*CachedManifest, error) {
manifest, size, err := func() (*Manifest, uint32, error) {
log.Printf("s3: get manifest %s\n", name)
@@ -322,6 +325,13 @@ func (s3 *S3Backend) GetManifest(ctx context.Context, name string) (*Manifest, e
}
}
if opts.BypassCache {
entry, found := s3.siteCache.Cache.GetEntry(name)
if found && entry.RefreshableAt().Before(time.Now()) {
s3.siteCache.Cache.Invalidate(name)
}
}
cached, err := s3.siteCache.Get(ctx, name, otter.LoaderFunc[string, *CachedManifest](loader))
if err != nil {
return nil, err

View File

@@ -13,19 +13,20 @@ type weightedCacheEntry interface {
}
type trackedLoader[K comparable, V any] struct {
loader otter.Loader[K, V]
invoked bool
loader otter.Loader[K, V]
loaded bool
reloaded bool
}
func (l *trackedLoader[K, V]) Load(ctx context.Context, key K) (V, error) {
val, err := l.loader.Load(ctx, key)
l.invoked = true
l.loaded = true
return val, err
}
func (l *trackedLoader[K, V]) Reload(ctx context.Context, key K, oldValue V) (V, error) {
val, err := l.loader.Reload(ctx, key, oldValue)
l.invoked = true
l.reloaded = true
return val, err
}
@@ -67,7 +68,7 @@ func (c *observedCache[K, V]) Get(ctx context.Context, key K, loader otter.Loade
observedLoader := trackedLoader[K, V]{loader: loader}
val, err := c.Cache.Get(ctx, key, &observedLoader)
if err == nil {
if observedLoader.invoked {
if observedLoader.loaded {
if c.metrics.MissNumberCounter != nil {
c.metrics.MissNumberCounter.Inc()
}

View File

@@ -59,8 +59,9 @@ type WildcardConfig struct {
}
type CacheConfig struct {
MaxSize datasize.ByteSize `toml:"max-size"`
MaxAge Duration `toml:"max-age"`
MaxSize datasize.ByteSize `toml:"max-size"`
MaxAge Duration `toml:"max-age"`
MaxStale Duration `toml:"max-stale"`
}
type StorageConfig struct {
@@ -81,7 +82,7 @@ type S3Config struct {
Region string `toml:"region"`
Bucket string `toml:"bucket"`
BlobCache CacheConfig `toml:"blob-cache" default:"{\"MaxSize\":\"256MB\"}"`
SiteCache CacheConfig `toml:"site-cache" default:"{\"MaxAge\":\"60s\",\"MaxSize\":\"16MB\"}"`
SiteCache CacheConfig `toml:"site-cache" default:"{\"MaxAge\":\"60s\",\"MaxStale\":\"1h\",\"MaxSize\":\"16MB\"}"`
}
type LimitsConfig struct {

View File

@@ -149,7 +149,7 @@ func main() {
webRoot += "/.index"
}
manifest, err := backend.GetManifest(context.Background(), webRoot)
manifest, err := backend.GetManifest(context.Background(), webRoot, GetManifestOptions{})
if err != nil {
log.Fatalln(err)
}

View File

@@ -232,9 +232,16 @@ func (backend *observedBackend) DeleteBlob(ctx context.Context, name string) (er
return
}
func (backend *observedBackend) GetManifest(ctx context.Context, name string) (manifest *Manifest, err error) {
func (backend *observedBackend) GetManifest(
ctx context.Context,
name string,
opts GetManifestOptions,
) (
manifest *Manifest,
err error,
) {
span, ctx := ObserveFunction(ctx, "GetManifest", "manifest.name", name)
if manifest, err = backend.inner.GetManifest(ctx, name); err == nil {
if manifest, err = backend.inner.GetManifest(ctx, name, opts); err == nil {
manifestsRetrievedCount.Inc()
}
span.Finish()

View File

@@ -17,6 +17,7 @@ import (
"time"
"github.com/klauspost/compress/zstd"
"github.com/pquerna/cachecontrol/cacheobject"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
@@ -70,6 +71,17 @@ func getPage(w http.ResponseWriter, r *http.Request) error {
var sitePath string
var manifest *Manifest
cacheControl, err := cacheobject.ParseRequestCacheControl(r.Header.Get("Cache-Control"))
if err != nil {
cacheControl = &cacheobject.RequestCacheDirectives{
MaxAge: -1,
MaxStale: -1,
MinFresh: -1,
}
}
bypassCache := cacheControl.NoCache || cacheControl.MaxAge == 0
host, err := GetHost(r)
if err != nil {
return err
@@ -77,13 +89,15 @@ func getPage(w http.ResponseWriter, r *http.Request) error {
sitePath = strings.TrimPrefix(r.URL.Path, "/")
if projectName, projectPath, found := strings.Cut(sitePath, "/"); found {
projectManifest, err := backend.GetManifest(r.Context(), makeWebRoot(host, projectName))
projectManifest, err := backend.GetManifest(r.Context(), makeWebRoot(host, projectName),
GetManifestOptions{BypassCache: bypassCache})
if err == nil {
sitePath, manifest = projectPath, projectManifest
}
}
if manifest == nil {
manifest, err = backend.GetManifest(r.Context(), makeWebRoot(host, ".index"))
manifest, err = backend.GetManifest(r.Context(), makeWebRoot(host, ".index"),
GetManifestOptions{BypassCache: bypassCache})
if manifest == nil {
if found, fallbackErr := HandleWildcardFallback(w, r); found {
return fallbackErr

View File

@@ -30,7 +30,7 @@ func Update(ctx context.Context, webRoot string, manifest *Manifest) UpdateResul
var err error
outcome := UpdateError
oldManifest, _ = backend.GetManifest(ctx, webRoot)
oldManifest, _ = backend.GetManifest(ctx, webRoot, GetManifestOptions{})
if IsManifestEmpty(manifest) {
newManifest, err = manifest, backend.DeleteManifest(ctx, webRoot)
if err == nil {