package engine
import (
"context"
"slices"
"testing"
"time"
"tangled.org/core/spindle/db"
"tangled.org/core/spindle/nixcache"
"tangled.org/core/spindle/quota"
)
func readyCharged(t *testing.T, d *db.DB, qs *db.QuotaStore, entry db.CacheEntry, size int64) {
t.Helper()
insertCacheTestEntry(t, d, entry)
if _, err := d.MarkCacheEntryReady(context.Background(), entry.ID, size, qs, entry.LastUsedAt); err != nil {
t.Fatal(err)
}
}
func TestCacheEntriesKindSkipsPinnedAndTouchedEntries(t *testing.T) {
ctx := context.Background()
d := newCacheTestDB(t)
qs := db.NewQuotaStore(d, nil)
base := time.Now().Add(-time.Hour)
for i, id := range []string{"touched", "restoring", "cold"} {
readyCharged(t, d, qs, cacheTestEntry(id, "did:plc:repo", "microvm", id, "h", "pending", base.Add(time.Duration(i)*time.Minute)), 10)
}
readyCharged(t, d, qs, cacheTestEntry("other-repo", "did:plc:other", "microvm", "k", "h", "pending", base), 10)
if pinned, err := d.BeginCacheRestore(ctx, "restoring"); err != nil || !pinned {
t.Fatal(pinned, err)
}
store := &fakeStorage{objects: map[string][]byte{"objects/touched": []byte("t"), "objects/cold": []byte("c")}}
kind := CacheEntries(d, store)
listed, err := kind.Evictable(ctx, quota.Subject{Scope: quota.ScopeRepo, DID: "did:plc:repo"}, 10)
if err != nil || len(listed) != 2 || listed[0].Key != "touched" || listed[1].Key != "cold" {
t.Fatalf("listed %v, %v; want the repo's unpinned entries oldest first", listed, err)
}
owned, err := kind.Evictable(ctx, quota.Subject{Scope: quota.ScopeUser, DID: "did:plc:owner"}, 10)
if err != nil || len(owned) != 3 {
t.Fatalf("owner listed %v, %v; want both repos' unpinned entries", owned, err)
}
if err := d.TouchCacheEntry(ctx, "touched", time.Now()); err != nil {
t.Fatal(err)
}
gone, err := kind.Evict(ctx, listed)
if err != nil || len(gone) != 1 || gone[0].Key != "cold" {
t.Fatalf("Evict = %v, %v; want only cold", gone, err)
}
if !cacheTestEntryExists(t, d, "touched") || !store.has("objects/touched") || !genericCharged(t, d, "touched") {
t.Fatal("evicted an entry restored after it was listed")
}
if cacheTestEntryExists(t, d, "cold") || store.has("objects/cold") || genericCharged(t, d, "cold") {
t.Fatal("cold entry left its row, object or charge")
}
}
type fakeAttic struct{ deleted []string }
func (a *fakeAttic) DeletePaths(_ context.Context, _ string, hashes []string) ([]string, error) {
a.deleted = append(a.deleted, hashes...)
return hashes, nil
}
func (a *fakeAttic) MissingPaths(context.Context, string, []string) ([]string, error) {
return nil, nil
}
func TestEvictorTrimsNixPathsAndCacheEntriesInOneOrder(t *testing.T) {
ctx := context.Background()
d := newCacheTestDB(t)
qs := db.NewQuotaStore(d, quota.Defaults{quota.ScopeRepo: {quota.ResourceCacheStorageBytes: 30}})
id := quota.Identity{OwnerDID: "did:plc:owner", RepoDID: "did:plc:repo"}
now := time.Now()
nixPath := func(hash string, ago time.Duration) {
t.Helper()
res, err := qs.Reserve(ctx, quota.ReserveRequest{
Kind: quota.KindNixCache, Key: quota.NixCacheKey("cache", hash), Identity: id,
Resources: quota.Resources{quota.ResourceCacheStorageBytes: 10},
NixObject: "a.NixObject{Cache: "cache", StoreHash: hash, StorePath: "/nix/store/" + hash + "-x", NarSize: 10},
})
if err != nil || !res.Allowed {
t.Fatal(res, err)
}
if err := qs.Commit(ctx, res.ID); err != nil {
t.Fatal(err)
}
if _, err := d.Exec(`update nix_objects set last_used_at = ? where store_hash = ?`, now.Add(-ago).Unix(), hash); err != nil {
t.Fatal(err)
}
}
nixPath("nix-oldest", 5*time.Hour)
readyCharged(t, d, qs, cacheTestEntry("generic-old", id.RepoDID, "microvm", "a", "h", "pending", now.Add(-4*time.Hour)), 10)
nixPath("nix-new", 2*time.Hour)
readyCharged(t, d, qs, cacheTestEntry("generic-new", id.RepoDID, "microvm", "b", "h", "pending", now.Add(-time.Hour)), 10)
attic := &fakeAttic{}
store := &fakeStorage{objects: map[string][]byte{"objects/generic-old": []byte("o"), "objects/generic-new": []byte("n")}}
evictor := "a.Evictor{
Usage: qs,
Limits: qs,
Kinds: []quota.CacheKind{
CacheEntries(d, store),
&nixcache.Pruner{Index: qs, Attic: attic},
},
}
if n, err := evictor.Evict(ctx); err != nil || n != 1 {
t.Fatalf("Evict = %d, %v; want 1 (40 bytes down to 30)", n, err)
}
if !slices.Equal(attic.deleted, []string{"nix-oldest"}) || !cacheTestEntryExists(t, d, "generic-old") {
t.Fatalf("attic deleted %v, want only the oldest path of either kind", attic.deleted)
}
if err := qs.SetLimit(ctx, id.RepoDID, quota.ResourceCacheStorageBytes, 15); err != nil {
t.Fatal(err)
}
if n, err := evictor.Evict(ctx); err != nil || n != 2 {
t.Fatalf("Evict = %d, %v; want 2 (30 bytes down to 10)", n, err)
}
if !slices.Equal(attic.deleted, []string{"nix-oldest", "nix-new"}) || cacheTestEntryExists(t, d, "generic-old") || !cacheTestEntryExists(t, d, "generic-new") {
t.Fatalf("attic deleted %v; want the next two oldest gone and generic-new kept", attic.deleted)
}
}