Evict remote objects from every cache layer (#128)

Evicting a remote object only deleted its Postgres row. The S3 index and the Redis TTL/ETag keys survived, so stale mirror metadata such as EPEL repodata kept being served.

- evict the artifact row, S3 index and Redis keys via `Engine.Evict`
- evict a directory with `<dir>/*`; other wildcards return 400, unknown remotes 404
- wait on the per-path fetch lock for single-path evicts; return 503 on timeout or a Redis error
- wildcard evicts take no lock; a fetch already in flight may re-cache its path
- return S3 list errors from `DeletePrefix` instead of deleting nothing

Reviewed-on: #128
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 #128.
This commit is contained in:
2026-10-04 22:07:14 +11:00
committed by BenVincent
parent 0b159dad90
commit 6a08539a78
10 changed files with 575 additions and 6 deletions
+69
View File
@@ -131,3 +131,72 @@ func TestFlushRemote(t *testing.T) {
t.Error("expected keys flushed")
}
}
func setPathKeys(t *testing.T, remote string, paths ...string) {
t.Helper()
ctx := context.Background()
for _, p := range paths {
if err := testRedis.SetTTL(ctx, remote, p, time.Minute); err != nil {
t.Fatal(err)
}
if err := testRedis.SetETag(ctx, remote, p, `"e"`, time.Minute); err != nil {
t.Fatal(err)
}
}
}
func pathKeysExist(t *testing.T, remote, path string) (ttl, etag bool) {
t.Helper()
ctx := context.Background()
ttl, _ = testRedis.CheckTTL(ctx, remote, path)
e, _ := testRedis.GetETag(ctx, remote, path)
return ttl, e != ""
}
func TestForgetPath(t *testing.T) {
requireRedis(t)
const meta = `repo/a*b?[c]\d.xml`
setPathKeys(t, "fp", meta, "repo/aXb.xml")
setPathKeys(t, "fp-other", meta)
if err := testRedis.ForgetPath(context.Background(), "fp", meta); err != nil {
t.Fatal(err)
}
if ttl, etag := pathKeysExist(t, "fp", meta); ttl || etag {
t.Errorf("forgotten path keys remain: ttl=%v etag=%v", ttl, etag)
}
for _, k := range [][2]string{{"fp", "repo/aXb.xml"}, {"fp-other", meta}} {
if ttl, etag := pathKeysExist(t, k[0], k[1]); !ttl || !etag {
t.Errorf("%s:%s lost keys: ttl=%v etag=%v", k[0], k[1], ttl, etag)
}
}
}
func TestForgetPrefix(t *testing.T) {
requireRedis(t)
const prefix = `r*[1]?\/`
under := []string{prefix + "repomd.xml", prefix + "sub/x.rpm"}
// Each would match the prefix if its glob metacharacters were left unescaped.
globMatches := []string{`rX11/repomd.xml`, `r[1]?\/x`, `r*1Z/x`}
setPathKeys(t, "fx", append(under, globMatches...)...)
setPathKeys(t, "fx-other", under...)
if err := testRedis.ForgetPrefix(context.Background(), "fx", prefix); err != nil {
t.Fatal(err)
}
for _, p := range under {
if ttl, etag := pathKeysExist(t, "fx", p); ttl || etag {
t.Errorf("%s keys remain: ttl=%v etag=%v", p, ttl, etag)
}
}
for _, p := range globMatches {
if ttl, etag := pathKeysExist(t, "fx", p); !ttl || !etag {
t.Errorf("%s outside prefix lost keys: ttl=%v etag=%v", p, ttl, etag)
}
}
for _, p := range under {
if ttl, etag := pathKeysExist(t, "fx-other", p); !ttl || !etag {
t.Errorf("other remote %s lost keys: ttl=%v etag=%v", p, ttl, etag)
}
}
}
+25
View File
@@ -3,6 +3,7 @@ package cache
import (
"context"
"fmt"
"strings"
"time"
"github.com/redis/go-redis/v9"
@@ -115,3 +116,27 @@ func (r *Redis) FlushRemote(ctx context.Context, remote string) error {
}
return iter.Err()
}
// ForgetPath drops the freshness and ETag keys of one cached path.
func (r *Redis) ForgetPath(ctx context.Context, remote, path string) error {
return r.client.Del(ctx, fmt.Sprintf("ttl:%s:%s", remote, path), fmt.Sprintf("etag:%s:%s", remote, path)).Err()
}
// ForgetPrefix drops the freshness and ETag keys of every cached path under prefix.
func (r *Redis) ForgetPrefix(ctx context.Context, remote, prefix string) error {
glob := globEscaper.Replace(remote + ":" + prefix)
for _, kind := range []string{"ttl:", "etag:"} {
iter := r.client.Scan(ctx, 0, kind+glob+"*", 100).Iterator()
for iter.Next(ctx) {
if err := r.client.Del(ctx, iter.Val()).Err(); err != nil {
return err
}
}
if err := iter.Err(); err != nil {
return err
}
}
return nil
}
var globEscaper = strings.NewReplacer(`\`, `\\`, `*`, `\*`, `?`, `\?`, `[`, `\[`, `]`, `\]`)