diff --git a/conf/config.example.toml b/conf/config.example.toml index e4adab5..b7f4685 100644 --- a/conf/config.example.toml +++ b/conf/config.example.toml @@ -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" diff --git a/go.mod b/go.mod index ba0df49..e9021a9 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 82e7317..7482d1a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/src/backend.go b/src/backend.go index 09bfdad..cf2be4e 100644 --- a/src/backend.go +++ b/src/backend.go @@ -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) { diff --git a/src/backend_fs.go b/src/backend_fs.go index cc58a17..ac19d47 100644 --- a/src/backend_fs.go +++ b/src/backend_fs.go @@ -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) diff --git a/src/backend_s3.go b/src/backend_s3.go index a81f5be..f388495 100644 --- a/src/backend_s3.go +++ b/src/backend_s3.go @@ -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 diff --git a/src/cache.go b/src/cache.go index c8d0109..3e7360b 100644 --- a/src/cache.go +++ b/src/cache.go @@ -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() } diff --git a/src/config.go b/src/config.go index 86b6430..7bd5ef4 100644 --- a/src/config.go +++ b/src/config.go @@ -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 { diff --git a/src/main.go b/src/main.go index 82b0a70..3f4805b 100644 --- a/src/main.go +++ b/src/main.go @@ -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) } diff --git a/src/observe.go b/src/observe.go index 26ebea0..0522808 100644 --- a/src/observe.go +++ b/src/observe.go @@ -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() diff --git a/src/pages.go b/src/pages.go index 344f5ec..9455c03 100644 --- a/src/pages.go +++ b/src/pages.go @@ -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 diff --git a/src/update.go b/src/update.go index da6fa36..a109f6e 100644 --- a/src/update.go +++ b/src/update.go @@ -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 {