Default omitted mutable_ttl and keep the ETag past TTL expiry
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

- default mutable_ttl to 3600 on create and keep the stored value on update
- store mutable ETags without a TTL so expiry revalidates with If-None-Match
- drop the stored ETag when a refetch returns none
This commit is contained in:
2026-10-17 16:25:22 +11:00
parent 6e6481e09a
commit 9eb8ef384f
7 changed files with 114 additions and 35 deletions
+2 -4
View File
@@ -160,7 +160,6 @@ 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)
result, err := e.serveFromStore(ctx, remote, path)
if err == nil {
result.Source = "cache"
@@ -491,9 +490,8 @@ 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)
}
// The ETag outlives the freshness key so an expired path is revalidated.
_ = e.cache.SetETag(ctx, remote.Name, path, resp.Header.Get("ETag"), 0)
return &FetchResult{
Reader: io.NopCloser(bytesReader(body)),
+27 -18
View File
@@ -262,28 +262,37 @@ func TestCircuitOpenServesStale(t *testing.T) {
func TestMutableRevalidation(t *testing.T) {
requireStack(t)
ctx := context.Background()
r := seed(t, models.Remote{Name: "eng-npm", PackageType: models.PackageNPM, RepoType: models.RepoTypeRemote, BaseURL: upstream.URL, CheckMutable: true, MutableTTL: 3600, StaleOnError: true})
p := prov(t, models.PackageNPM)
srv, rev, conditional := changingUpstream(t)
r := seed(t, models.Remote{Name: "eng-reval", PackageType: models.PackageRPM, RepoType: models.RepoTypeRemote, BaseURL: srv.URL, CheckMutable: true, MutableTTL: 1})
const path = "8/Everything/x86_64/repodata/repomd.xml"
fetch := func() (string, string) {
t.Helper()
res, err := testEngine.Fetch(context.Background(), r, path, prov(t, models.PackageRPM))
if err != nil {
t.Fatalf("fetch: %v", err)
}
return readAll(t, res), res.Source
}
res, err := testEngine.Fetch(ctx, r, "pkg", p)
if err != nil {
t.Fatalf("initial mutable fetch: %v", err)
if body, _ := fetch(); body != "rev1:/"+path {
t.Fatalf("initial fetch = %q", body)
}
time.Sleep(1100 * time.Millisecond)
if body, src := fetch(); body != "rev1:/"+path || src != "cache" {
t.Errorf("after expiry = %q from %s, want cached rev1", body, src)
}
if n := conditional.Load(); n != 1 {
t.Errorf("conditional requests = %d, want 1 revalidation", n)
}
if fresh, _ := testCache.CheckTTL(context.Background(), r.Name, path); !fresh {
t.Error("304 should re-arm the freshness key")
}
res.Reader.Close()
// Expire only the freshness marker; the ETag persists, forcing a
// conditional revalidation that the upstream answers with 304.
testCache.SetTTL(ctx, "eng-npm", "pkg", time.Millisecond)
time.Sleep(10 * time.Millisecond)
res, err = testEngine.Fetch(ctx, r, "pkg", p)
if err != nil {
t.Fatalf("revalidation fetch: %v", err)
rev.Store("rev2")
time.Sleep(1100 * time.Millisecond)
if body, src := fetch(); body != "rev2:/"+path || src != "remote" {
t.Errorf("after upstream change = %q from %s, want remote rev2", body, src)
}
if res.Source != "cache" {
t.Errorf("revalidated response should come from cache, got %s", res.Source)
}
res.Reader.Close()
}
func TestBearerTokenFlow(t *testing.T) {