package nixcache
import (
"context"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"sync"
"testing"
"time"
"tangled.org/core/spindle/db"
"tangled.org/core/spindle/quota"
)
const (
repoA = "did:plc:z72i7hdynmk6r22z27h6tvur"
repoB = "did:plc:abcdefghijklmnopqrstuvwx"
ownerX = "did:plc:ownerxxxxxxxxxxxxxxxxxxx"
ownerY = "did:plc:owneryyyyyyyyyyyyyyyyyyy"
)
var pruneNow = time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC)
type fakeRow struct {
db.NixObjectRow
state string
refs []string
}
type fakeIndex struct {
mu sync.Mutex
rows map[string]*fakeRow
}
func rowKey(cache, hash string) string { return cache + "/" + hash }
func (f *fakeIndex) add(repo, owner, hash string, size int64, usedAgo time.Duration, refs ...string) {
f.mu.Lock()
defer f.mu.Unlock()
cache, _ := CacheName(repo)
f.rows[rowKey(cache, hash)] = &fakeRow{
NixObjectRow: db.NixObjectRow{Cache: cache, StoreHash: hash, RepoDID: repo, OwnerDID: owner, NarSize: size, LastUsedAt: pruneNow.Add(-usedAgo), Gen: 1},
state: "live",
refs: refs,
}
}
// the new gen makes an eviction marked earlier stale
func (f *fakeIndex) reupload(cache, hash string) {
f.mu.Lock()
defer f.mu.Unlock()
r := f.rows[rowKey(cache, hash)]
r.state, r.Gen, r.LastUsedAt = "live", r.Gen+1, pruneNow
}
func (f *fakeIndex) state(repo, hash string) string {
f.mu.Lock()
defer f.mu.Unlock()
cache, _ := CacheName(repo)
if r := f.rows[rowKey(cache, hash)]; r != nil {
return r.state
}
return "gone"
}
func (f *fakeIndex) hashes(repo string) []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, r := range f.rows {
if r.RepoDID == repo {
out = append(out, r.StoreHash)
}
}
slices.Sort(out)
return out
}
func (f *fakeIndex) NixCacheUsage(context.Context) (map[string]int64, map[string]int64, int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
byRepo, byOwner, total := map[string]int64{}, map[string]int64{}, int64(0)
for _, r := range f.rows {
byRepo[r.RepoDID] += r.NarSize
byOwner[r.OwnerDID] += r.NarSize
total += r.NarSize
}
return byRepo, byOwner, total, nil
}
func (f *fakeIndex) ListUsage(context.Context) ([]quota.Usage, error) {
byRepo, byOwner, _, _ := f.NixCacheUsage(context.Background())
var out []quota.Usage
for scope, used := range map[quota.Scope]map[string]int64{quota.ScopeRepo: byRepo, quota.ScopeUser: byOwner} {
for did, n := range used {
out = append(out, quota.Usage{Scope: scope, DID: did, Resource: quota.ResourceCacheStorageBytes, Used: n})
}
}
return out, nil
}
func (f *fakeIndex) NixEvictable(_ context.Context, filter db.NixEvictFilter, usedBefore time.Time, limit int) ([]db.NixObjectRow, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []db.NixObjectRow
for _, r := range f.rows {
if r.state != "live" || !r.LastUsedAt.Before(usedBefore) ||
filter.RepoDID != "" && r.RepoDID != filter.RepoDID || filter.OwnerDID != "" && r.OwnerDID != filter.OwnerDID {
continue
}
referenced := false
for _, other := range f.rows {
if other.state == "live" && other.Cache == r.Cache && slices.Contains(other.refs, r.StoreHash) {
referenced = true
}
}
if !referenced {
out = append(out, r.NixObjectRow)
}
}
slices.SortFunc(out, func(a, b db.NixObjectRow) int {
if c := a.LastUsedAt.Compare(b.LastUsedAt); c != 0 {
return c
}
return strings.Compare(a.StoreHash, b.StoreHash)
})
return out[:min(limit, len(out))], nil
}
func (f *fakeIndex) MarkNixEvicting(_ context.Context, rows []db.NixObjectRow, usedBefore time.Time) ([]db.NixObjectRow, error) {
f.mu.Lock()
defer f.mu.Unlock()
var flipped []db.NixObjectRow
for _, row := range rows {
if r := f.rows[rowKey(row.Cache, row.StoreHash)]; r != nil && r.state == "live" && r.Gen == row.Gen && r.LastUsedAt.Before(usedBefore) {
r.state = "evicting"
flipped = append(flipped, r.NixObjectRow)
}
}
return flipped, nil
}
func (f *fakeIndex) NixEvicting(context.Context) ([]db.NixObjectRow, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []db.NixObjectRow
for _, r := range f.rows {
if r.state == "evicting" {
out = append(out, r.NixObjectRow)
}
}
return out, nil
}
func (f *fakeIndex) FinishNixEviction(_ context.Context, cache string, gone []db.NixObjectRow) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, row := range gone {
if r := f.rows[rowKey(cache, row.StoreHash)]; r != nil && r.state == "evicting" && r.Gen == row.Gen {
delete(f.rows, rowKey(cache, row.StoreHash))
}
}
return nil
}
type fakeLimits map[string]int64
func (l fakeLimits) LimitFor(_ context.Context, _ quota.Scope, did, resource string) (*int64, error) {
limit, ok := l[did]
if !ok || resource != quota.ResourceCacheStorageBytes {
return nil, nil
}
return &limit, nil
}
type deleteCall struct {
cache string
hashes []string
token string
}
type atticDouble struct {
mu sync.Mutex
has map[string]bool
deletes []deleteCall
failDelete bool
// 404 is attic's NoSuchCache
failCache map[string]int
missingCalls []string
onDelete func(cache string, hashes []string)
}
func (a *atticDouble) store(repo string, hashes ...string) {
cache, _ := CacheName(repo)
for _, h := range hashes {
a.has[rowKey(cache, h)] = true
}
}
func (a *atticDouble) handler(w http.ResponseWriter, r *http.Request) {
var req hashesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
a.mu.Lock()
defer a.mu.Unlock()
if r.URL.Path == "/_api/v1/get-missing-paths" {
a.missingCalls = append(a.missingCalls, req.Cache)
}
if status, ok := a.failCache[req.Cache]; ok {
if status == http.StatusNotFound {
http.Error(w, `{"code":404,"error":"NoSuchCache","message":"gone"}`, status)
} else {
http.Error(w, "no", status)
}
return
}
switch r.URL.Path {
case "/_api/v1/delete-paths":
a.deletes = append(a.deletes, deleteCall{req.Cache, req.StorePathHash, token})
if a.failDelete {
http.Error(w, "down", http.StatusBadGateway)
return
}
if a.onDelete != nil {
a.onDelete(req.Cache, req.StorePathHash)
}
deleted := []string{}
for _, h := range req.StorePathHash {
if a.has[rowKey(req.Cache, h)] {
delete(a.has, rowKey(req.Cache, h))
deleted = append(deleted, h)
}
}
json.NewEncoder(w).Encode(map[string]any{"deleted": deleted})
case "/_api/v1/get-missing-paths":
missing := []string{}
for _, h := range req.StorePathHash {
if !a.has[rowKey(req.Cache, h)] {
missing = append(missing, h)
}
}
json.NewEncoder(w).Encode(map[string]any{"missing_paths": missing})
default:
io.WriteString(w, "{}")
}
}
type pruneTest struct {
t *testing.T
index *fakeIndex
attic *atticDouble
pruner *Pruner
limits fakeLimits
key *rsa.PrivateKey
}
func newPruneTest(t *testing.T) *pruneTest {
t.Helper()
pt := &pruneTest{
t: t,
index: &fakeIndex{rows: map[string]*fakeRow{}},
attic: &atticDouble{has: map[string]bool{}},
limits: fakeLimits{},
key: testKey(t),
}
srv := httptest.NewServer(http.HandlerFunc(pt.attic.handler))
t.Cleanup(srv.Close)
pt.pruner = &Pruner{
Index: pt.index,
Attic: &Issuer{Client: Client{BaseURL: srv.URL}, Key: pt.key},
Grace: time.Hour,
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
now: func() time.Time { return pruneNow },
}
return pt
}
func (pt *pruneTest) add(repo, owner, hash string, size int64, usedAgo time.Duration, refs ...string) {
pt.index.add(repo, owner, hash, size, usedAgo, refs...)
pt.attic.store(repo, hash)
}
// one tick: the pruner's own pass, then the quota evictor's
func (pt *pruneTest) pass() (int, error) {
ctx := context.Background()
n, err := pt.pruner.Prune(ctx)
evictor := "a.Evictor{Usage: pt.index, Limits: pt.limits, Kinds: []quota.CacheKind{pt.pruner}}
m, evictErr := evictor.Evict(ctx)
return n + m, errors.Join(err, evictErr)
}
func (pt *pruneTest) prune() int {
pt.t.Helper()
n, err := pt.pass()
if err != nil {
pt.t.Fatalf("pass: %v", err)
}
return n
}
func hash(c byte) string { return strings.Repeat(string(c), 32) }
const day = 24 * time.Hour
func TestPrunePeelsAChainFromTheTopOverPasses(t *testing.T) {
pt := newPruneTest(t)
pt.limits = fakeLimits{repoA: 1}
pt.add(repoA, ownerX, hash('a'), 10, 3*day, hash('b'))
pt.add(repoA, ownerX, hash('b'), 10, 5*day, hash('c'))
pt.add(repoA, ownerX, hash('c'), 10, 9*day)
for pass, want := range [][]string{{hash('b'), hash('c')}, {hash('c')}, nil} {
if n := pt.prune(); n != 1 {
t.Fatalf("pass %d evicted %d, want 1", pass+1, n)
}
if got := pt.index.hashes(repoA); !slices.Equal(got, want) {
t.Fatalf("after pass %d the index holds %v, want %v", pass+1, got, want)
}
}
if n := pt.prune(); n != 0 {
t.Errorf("an empty cache still evicted %d", n)
}
}
type liveGrants time.Duration
func (liveGrants) RecordNixGrant(context.Context, time.Time, time.Duration) error { return nil }
func (g liveGrants) LongestLiveNixGrant(context.Context, time.Time) (time.Duration, error) {
return time.Duration(g), nil
}
func TestPruneGraceFollowsTheLongestLiveToken(t *testing.T) {
for _, tt := range []struct {
name string
life time.Duration
want []string
}{
{"a short run's token spares only its window", 20 * time.Minute, []string{hash('a')}},
{"a long run's token spares more", 3 * time.Hour, []string{hash('a'), hash('b')}},
{"no live token still keeps the floor", 0, []string{hash('a')}},
} {
t.Run(tt.name, func(t *testing.T) {
pt := newPruneTest(t)
pt.pruner.MaxBytes = 1
pt.pruner.Grace = 15 * time.Minute
pt.pruner.Grants = liveGrants(tt.life)
pt.add(repoA, ownerX, hash('a'), 10, 10*time.Minute)
pt.add(repoA, ownerX, hash('b'), 10, 2*time.Hour)
pt.prune()
if got := pt.index.hashes(repoA); !slices.Equal(got, tt.want) {
t.Errorf("index holds %v, want %v", got, tt.want)
}
})
}
}
func TestEvictableFollowsTheLongestLiveToken(t *testing.T) {
for _, tt := range []struct {
life time.Duration
want int
}{{20 * time.Minute, 1}, {3 * time.Hour, 0}} {
pt := newPruneTest(t)
pt.pruner.Grace = 15 * time.Minute
pt.pruner.Grants = liveGrants(tt.life)
pt.add(repoA, ownerX, hash('a'), 10, 10*time.Minute)
pt.add(repoA, ownerX, hash('b'), 10, 2*time.Hour)
listed, err := pt.pruner.Evictable(context.Background(), quota.Subject{Scope: quota.ScopeRepo, DID: repoA}, 10)
if err != nil || len(listed) != tt.want {
t.Errorf("with a %s token live: Evictable = %v, %v; want %d", tt.life, listed, err, tt.want)
}
}
}
func TestPruneShrinksAnOverQuotaRepoUnderItsLimitAndStops(t *testing.T) {
pt := newPruneTest(t)
pt.limits = fakeLimits{repoA: 25}
for i, h := range []byte("abcde") {
pt.add(repoA, ownerX, hash(h), 10, time.Duration(10-i)*day)
}
pt.add(repoB, ownerX, hash('z'), 1000, 30*day)
if n := pt.prune(); n != 3 {
t.Fatalf("evicted %d, want 3 (50 bytes down to 20 under the limit of 25)", n)
}
if got := pt.index.hashes(repoA); !slices.Equal(got, []string{hash('d'), hash('e')}) {
t.Errorf("index holds %v, want the two most recently used", got)
}
if n := pt.prune(); n != 0 {
t.Errorf("a repo under its limit lost %d more paths", n)
}
if got := pt.index.state(repoB, hash('z')); got != "live" {
t.Errorf("another repo's path is %s, want live", got)
}
}
func TestPruneShrinksAnOverQuotaOwnerAcrossItsRepos(t *testing.T) {
pt := newPruneTest(t)
pt.limits = fakeLimits{ownerX: 15}
pt.add(repoA, ownerX, hash('a'), 10, 4*day)
pt.add(repoB, ownerX, hash('b'), 10, 6*day)
pt.add(repoB, ownerY, hash('c'), 10, 9*day)
if n := pt.prune(); n != 1 {
t.Fatalf("evicted %d, want 1", n)
}
if got := pt.index.state(repoB, hash('b')); got != "gone" {
t.Errorf("the owner's least recently used path is %s, want gone", got)
}
if pt.index.state(repoB, hash('c')) != "live" || pt.index.state(repoA, hash('a')) != "live" {
t.Error("evicted more than the owner's overage, or another owner's path")
}
}
func TestPruneEnforcesTheGlobalCap(t *testing.T) {
pt := newPruneTest(t)
pt.pruner.MaxBytes = 25
pt.add(repoA, ownerX, hash('a'), 10, 2*day)
pt.add(repoB, ownerY, hash('b'), 10, 6*day)
pt.add(repoA, ownerY, hash('c'), 10, 4*day)
if n := pt.prune(); n != 1 {
t.Fatalf("evicted %d, want 1", n)
}
if got := pt.index.state(repoB, hash('b')); got != "gone" {
t.Errorf("the least recently used path of all repos is %s, want gone", got)
}
}
func TestPruneEvictsPathsUnusedForTheRetention(t *testing.T) {
pt := newPruneTest(t)
pt.pruner.Retention = 14 * day
pt.add(repoA, ownerX, hash('a'), 10, 13*day)
pt.add(repoA, ownerX, hash('b'), 10, 15*day)
pt.add(repoB, ownerY, hash('c'), 10, 40*day)
if n := pt.prune(); n != 2 {
t.Fatalf("evicted %d, want 2", n)
}
if pt.index.state(repoA, hash('a')) != "live" || pt.index.state(repoA, hash('b')) != "gone" || pt.index.state(repoB, hash('c')) != "gone" {
t.Errorf("states a=%s b=%s c=%s, want live gone gone", pt.index.state(repoA, hash('a')), pt.index.state(repoA, hash('b')), pt.index.state(repoB, hash('c')))
}
}
func TestPruneRetentionNeverBeatsGrace(t *testing.T) {
pt := newPruneTest(t)
pt.pruner.Retention = 30 * time.Minute
pt.add(repoA, ownerX, hash('a'), 10, 45*time.Minute)
if n := pt.prune(); n != 0 {
t.Errorf("evicted %d paths used within the grace window", n)
}
}
func TestPruneRetriesAFailedDeleteCall(t *testing.T) {
pt := newPruneTest(t)
pt.pruner.Retention = day
pt.add(repoA, ownerX, hash('a'), 10, 3*day)
pt.attic.failDelete = true
if n, err := pt.pruner.Prune(context.Background()); err == nil || n != 0 {
t.Fatalf("Prune with attic failing = %d, %v; want an error and nothing evicted", n, err)
}
if got := pt.index.state(repoA, hash('a')); got != "evicting" {
t.Fatalf("after the failed call the path is %s, want evicting", got)
}
pt.attic.failDelete = false
if n := pt.prune(); n != 1 {
t.Fatalf("the retry evicted %d, want 1", n)
}
if got := pt.index.state(repoA, hash('a')); got != "gone" {
t.Errorf("after the retry the path is %s, want gone", got)
}
cache, _ := CacheName(repoA)
if pt.attic.has[rowKey(cache, hash('a'))] {
t.Error("attic still has the path")
}
}
func TestPruneOneFailingCacheDoesNotStopTheOthers(t *testing.T) {
pt := newPruneTest(t)
pt.limits = fakeLimits{repoA: 10, repoB: 100}
pt.add(repoA, ownerX, hash('a'), 100, 3*day)
pt.add(repoA, ownerX, hash('b'), 100, 4*day)
pt.add(repoB, ownerY, hash('c'), 100, 3*day)
pt.add(repoB, ownerY, hash('d'), 100, 4*day)
cacheA, _ := CacheName(repoA)
pt.index.rows[rowKey(cacheA, hash('a'))].state = "evicting"
pt.attic.failCache = map[string]int{cacheA: http.StatusForbidden}
n, err := pt.pass()
if err == nil || n != 1 {
t.Fatalf("Prune = %d, %v; want one path evicted and the failing cache's error", n, err)
}
for _, c := range []struct {
repo, hash, want string
}{{repoA, hash('a'), "evicting"}, {repoA, hash('b'), "live"}, {repoB, hash('d'), "gone"}, {repoB, hash('c'), "live"}} {
if got := pt.index.state(c.repo, c.hash); got != c.want {
t.Errorf("path %s of %s is %s, want %s", c.hash[:1], c.repo, got, c.want)
}
}
}
func TestPruneMarksNothingNewWhileAtticIsDown(t *testing.T) {
pt := newPruneTest(t)
pt.pruner.MaxBytes = 200
pt.add(repoA, ownerX, hash('a'), 100, 3*day)
pt.add(repoB, ownerY, hash('c'), 100, 3*day)
pt.add(repoB, ownerY, hash('d'), 100, 4*day)
cacheA, _ := CacheName(repoA)
pt.index.rows[rowKey(cacheA, hash('a'))].state = "evicting"
pt.attic.failDelete = true
if _, err := pt.pruner.Prune(context.Background()); err == nil {
t.Fatal("Prune hid attic being down")
}
for _, h := range []string{hash('c'), hash('d')} {
if got := pt.index.state(repoB, h); got != "live" {
t.Errorf("path %s is %s, want live: nothing new is marked while attic is down", h[:1], got)
}
}
if len(pt.attic.deletes) != 1 {
t.Errorf("attic got %d delete calls, want the one retry", len(pt.attic.deletes))
}
}
func TestPruneFinishesRowsOfACacheAtticLost(t *testing.T) {
pt := newPruneTest(t)
pt.add(repoA, ownerX, hash('a'), 10, 3*day)
cache, _ := CacheName(repoA)
pt.index.rows[rowKey(cache, hash('a'))].state = "evicting"
pt.attic.failCache = map[string]int{cache: http.StatusNotFound}
if n, err := pt.pruner.Prune(context.Background()); err != nil || n != 1 {
t.Fatalf("Prune = %d, %v; want the row finished", n, err)
}
if got := pt.index.state(repoA, hash('a')); got != "gone" {
t.Errorf("the row of a lost cache is %s, want gone", got)
}
}
func TestUnreachable(t *testing.T) {
for _, tc := range []struct {
name string
err error
want bool
}{
{"transport", &url.Error{Op: "Post", URL: "http://attic", Err: io.EOF}, true},
{"5xx", &Error{Status: 502}, true},
{"4xx", &Error{Status: 403}, false},
{"no such cache", &Error{Status: 404, Kind: "NoSuchCache"}, false},
{"minting", errors.New("no signing key"), false},
{"nil", nil, false},
} {
if got := unreachable(tc.err); got != tc.want {
t.Errorf("%s: unreachable = %v, want %v", tc.name, got, tc.want)
}
}
}
func TestPruneFinishesAPathAtticDidNotHave(t *testing.T) {
pt := newPruneTest(t)
pt.pruner.Retention = day
pt.index.add(repoA, ownerX, hash('a'), 10, 3*day)
if n := pt.prune(); n != 1 {
t.Fatalf("evicted %d, want 1", n)
}
if got := pt.index.state(repoA, hash('a')); got != "gone" {
t.Errorf("a path attic never listed as deleted is %s, want gone", got)
}
}
func TestPruneKeepsAPathUploadedAgainBetweenMarkAndFinish(t *testing.T) {
pt := newPruneTest(t)
pt.pruner.Retention = day
pt.add(repoA, ownerX, hash('a'), 10, 3*day)
pt.add(repoA, ownerX, hash('b'), 10, 3*day)
pt.attic.onDelete = func(cache string, _ []string) { pt.index.reupload(cache, hash('a')) }
if n := pt.prune(); n != 2 {
t.Fatalf("evicted %d, want 2 handled", n)
}
if got := pt.index.state(repoA, hash('a')); got != "live" {
t.Errorf("the re-uploaded path is %s, want live", got)
}
if got := pt.index.state(repoA, hash('b')); got != "gone" {
t.Errorf("the other path is %s, want gone", got)
}
}
func TestPruneFinishesOnlyTheGenItMarked(t *testing.T) {
pt := newPruneTest(t)
pt.pruner.Retention = day
pt.add(repoA, ownerX, hash('a'), 10, 3*day)
pt.attic.onDelete = func(cache string, _ []string) {
pt.index.reupload(cache, hash('a'))
pt.index.rows[rowKey(cache, hash('a'))].state = "evicting"
}
pt.prune()
if got := pt.index.state(repoA, hash('a')); got != "evicting" {
t.Errorf("a row marked again by a later pass is %s, want it left evicting", got)
}
}
func TestPruneDeleteTokenHoldsOnlyDeleteOnThatCacheForAMinute(t *testing.T) {
pt := newPruneTest(t)
pt.pruner.Retention = day
pt.add(repoA, ownerX, hash('a'), 10, 3*day)
before := time.Now()
pt.prune()
if len(pt.attic.deletes) != 1 {
t.Fatalf("saw %d delete calls, want 1", len(pt.attic.deletes))
}
call := pt.attic.deletes[0]
perms := atticPerms(t, pt.key, call.token)
cache, _ := CacheName(repoA)
if len(perms) != 1 || len(perms[cache]) != 1 || perms[cache]["d"] != 1.0 {
t.Errorf("delete token perms = %v, want only d on %s", perms, cache)
}
exp := time.Unix(int64(parseClaims(t, pt.key, call.token)["exp"].(float64)), 0)
if ttl := exp.Sub(before); ttl < 55*time.Second || ttl > 65*time.Second {
t.Errorf("delete token lives %v, want about a minute", ttl)
}
}
func TestPruneDeletesOneCacheAtATime(t *testing.T) {
pt := newPruneTest(t)
pt.pruner.Retention = day
pt.add(repoA, ownerX, hash('a'), 10, 3*day)
pt.add(repoB, ownerX, hash('b'), 10, 3*day)
pt.prune()
cacheA, _ := CacheName(repoA)
cacheB, _ := CacheName(repoB)
want := map[string][]string{cacheA: {hash('a')}, cacheB: {hash('b')}}
if len(pt.attic.deletes) != len(want) {
t.Fatalf("%d delete calls, want one per cache", len(pt.attic.deletes))
}
for _, c := range pt.attic.deletes {
if !slices.Equal(c.hashes, want[c.cache]) {
t.Errorf("delete on %s = %v, want %v", c.cache, c.hashes, want[c.cache])
}
}
}
func TestPruneSplitsADeleteIntoBatchesOfFiveHundred(t *testing.T) {
pt := newPruneTest(t)
pt.pruner.Retention = day
for i := range deleteBatch + 1 {
pt.add(repoA, ownerX, fmt.Sprintf("%032d", i), 1, 3*day)
}
if n := pt.prune(); n != deleteBatch+1 {
t.Fatalf("evicted %d, want all %d", n, deleteBatch+1)
}
var sizes []int
for _, c := range pt.attic.deletes {
sizes = append(sizes, len(c.hashes))
}
if !slices.Equal(sizes, []int{deleteBatch, 1}) {
t.Errorf("delete calls carried %v hashes, want %d and 1", sizes, deleteBatch)
}
}
func TestEvictorMarksNothingMoreWhileARepoHasPathsEvicting(t *testing.T) {
pt := newPruneTest(t)
pt.limits = fakeLimits{repoA: 10}
pt.add(repoA, ownerX, hash('a'), 100, 3*day)
pt.add(repoA, ownerX, hash('b'), 100, 4*day)
cacheA, _ := CacheName(repoA)
pt.index.rows[rowKey(cacheA, hash('a'))].state = "evicting"
pt.attic.failCache = map[string]int{cacheA: http.StatusForbidden}
evictor := "a.Evictor{Usage: pt.index, Limits: pt.limits, Kinds: []quota.CacheKind{pt.pruner}}
for range 2 {
if _, err := evictor.Evict(context.Background()); err != nil {
t.Fatal(err)
}
}
if got := pt.index.state(repoA, hash('b')); got != "live" {
t.Errorf("b is %s, want live until a's eviction finishes", got)
}
}
func TestPrunerEvictReportsOnlyWhatAtticDeleted(t *testing.T) {
pt := newPruneTest(t)
pt.add(repoA, ownerX, hash('a'), 10, 3*day)
pt.attic.failDelete = true
ctx := context.Background()
listed, err := pt.pruner.Evictable(ctx, quota.Subject{Scope: quota.ScopeRepo, DID: repoA}, 10)
if err != nil || len(listed) != 1 {
t.Fatalf("Evictable = %v, %v; want a", listed, err)
}
gone, err := pt.pruner.Evict(ctx, listed)
if err == nil || len(gone) != 0 {
t.Fatalf("Evict with attic down = %v, %v; want nothing gone and an error", gone, err)
}
if got := pt.index.state(repoA, hash('a')); got != "evicting" {
t.Errorf("a is %s, want evicting for Prune to retry", got)
}
}