Add stale-while-revalidate support to the cache.

This commit is contained in:
miyuko
2025-10-15 23:53:12 +01:00
parent 8bb6d0ff28
commit eda3e8a791
12 changed files with 60 additions and 19 deletions
+5 -1
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) {
+1 -1
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)
+13 -3
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
+6 -5
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()
}
+4 -3
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 {
+1 -1
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)
}
+9 -2
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()
+16 -2
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
+1 -1
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 {