Fix mutable path refresh: default mutable_ttl, revalidate after expiry (#151)

A remote created through the API without `mutable_ttl` was stored as 0, so its mutable paths never refreshed. With `check_mutable`, the ETag expired together with the freshness key, so an expired mutable path was always refetched in full and never revalidated.

- default an omitted `mutable_ttl` to 3600 on create; keep the stored value on update; explicit 0 still means never expire
- keep mutable ETags for `max(10 × mutable_ttl, 7d)`, re-armed on each 304, only when `check_mutable` is set and `mutable_ttl` is non-zero
- drop the stored ETag when a refetch returns none; never store one for immutable paths
- flush a remote's Redis keys on delete
- update docs/common/caching.md

Reviewed-on: #151
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
This commit was merged in pull request #151.
This commit is contained in:
2026-10-17 17:17:41 +11:00
committed by BenVincent
parent 6e6481e09a
commit d69a58e54a
9 changed files with 286 additions and 37 deletions
+10 -6
View File
@@ -160,7 +160,7 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
notModified, err := e.checkUpstream(ctx, remote, path, etag, prov)
if err == nil && notModified {
_ = e.cache.SetTTL(ctx, remote.Name, path, ttl)
_ = e.cache.SetETag(ctx, remote.Name, path, etag, ttl)
_ = e.cache.SetETag(ctx, remote.Name, path, etag, etagTTL(ttl))
result, err := e.serveFromStore(ctx, remote, path)
if err == nil {
result.Source = "cache"
@@ -491,8 +491,9 @@ func (e *Engine) fetchFromUpstreamOnce(ctx context.Context, remote models.Remote
}
_ = e.cache.SetTTL(ctx, remote.Name, path, ttl)
if etag := resp.Header.Get("ETag"); etag != "" {
_ = e.cache.SetETag(ctx, remote.Name, path, etag, ttl)
// A zero TTL never expires, so it never needs revalidating.
if remote.CheckMutable && ttl > 0 {
_ = e.cache.SetETag(ctx, remote.Name, path, resp.Header.Get("ETag"), etagTTL(ttl))
}
return &FetchResult{
@@ -520,9 +521,6 @@ func (e *Engine) fetchFromUpstreamOnce(ctx context.Context, remote models.Remote
}
_ = e.cache.SetTTL(ctx, remote.Name, path, ttl)
if etag := resp.Header.Get("ETag"); etag != "" {
_ = e.cache.SetETag(ctx, remote.Name, path, etag, ttl)
}
reader, info, err := e.store.Download(ctx, casResult.S3Key)
if err != nil {
@@ -642,6 +640,12 @@ func (e *Engine) checkUpstreamOnce(ctx context.Context, remote models.Remote, pa
return resp.StatusCode == http.StatusNotModified, nil
}
// etagTTL keeps a mutable path's ETag well past its freshness key so an expired
// path revalidates with a conditional HEAD, while idle paths still age out.
func etagTTL(ttl time.Duration) time.Duration {
return max(10*ttl, 7*24*time.Hour)
}
func (e *Engine) ttlFor(remote models.Remote, class Classification) time.Duration {
switch class {
case ClassImmutable: