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) } }