package engine
import (
"context"
"errors"
"slices"
"testing"
"time"
"tangled.org/core/spindle/db"
)
func TestPruneCachesExpiryPolicies(t *testing.T) {
ctx := context.Background()
now := time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC)
type spec struct {
id string
state string
age time.Duration
}
tests := []struct {
name string
retention time.Duration
pendingMax time.Duration
entries []spec
wantPruned int
wantSurvive []string
}{
{
name: "ready entries expire by last use",
retention: time.Hour,
pendingMax: 15 * time.Minute,
entries: []spec{{"old", "ready", 2 * time.Hour}, {"fresh", "ready", 30 * time.Minute}},
wantPruned: 1,
wantSurvive: []string{"fresh"},
},
{
name: "zero retention keeps ready entries",
retention: 0,
pendingMax: time.Hour,
entries: []spec{{"ready", "ready", 24 * time.Hour}, {"pending", "pending", 2 * time.Hour}},
wantPruned: 1,
wantSurvive: []string{"ready"},
},
{
name: "pending entries expire by age",
retention: time.Hour,
pendingMax: time.Hour,
entries: []spec{{"old", "pending", 2 * time.Hour}, {"fresh", "pending", 10 * time.Minute}},
wantPruned: 1,
wantSurvive: []string{"fresh"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := newCacheTestDB(t)
store := &fakeStorage{objects: map[string][]byte{}}
keys := map[string]string{}
for _, e := range tt.entries {
entry := cacheTestEntry(e.id, "did:plc:repo", "microvm", "deps", "hash", e.state, now.Add(-e.age))
insertCacheTestEntry(t, d, entry)
store.objects[entry.StorageKey] = []byte(e.id)
keys[e.id] = entry.StorageKey
}
pruned, err := PruneCaches(ctx, d, store, now, tt.retention, tt.pendingMax, 0, 10)
if err != nil {
t.Fatalf("PruneCaches: %v", err)
}
if pruned != tt.wantPruned {
t.Fatalf("pruned %d entries, want %d", pruned, tt.wantPruned)
}
for _, e := range tt.entries {
survives := slices.Contains(tt.wantSurvive, e.id)
if store.has(keys[e.id]) != survives || cacheTestEntryExists(t, d, e.id) != survives {
t.Fatalf("entry %q survived = %v, want %v", e.id, !survives, survives)
}
}
})
}
}
func TestPruneCachesSkipsEntryRefreshedAfterScan(t *testing.T) {
ctx := context.Background()
d := newCacheTestDB(t)
now := time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC)
first := cacheTestEntry("first", "did:plc:repo", "microvm", "deps", "first", "ready", now.Add(-3*time.Hour))
refreshed := cacheTestEntry("refreshed", "did:plc:repo", "microvm", "deps", "refreshed", "ready", now.Add(-2*time.Hour))
insertCacheTestEntry(t, d, first)
insertCacheTestEntry(t, d, refreshed)
store := &fakeStorage{objects: map[string][]byte{
first.StorageKey: []byte("first"),
refreshed.StorageKey: []byte("refreshed"),
}}
store.onDelete = func(key string) {
if key != first.StorageKey {
return
}
if err := d.TouchCacheEntry(ctx, refreshed.ID, now); err != nil {
t.Fatalf("TouchCacheEntry: %v", err)
}
}
pruned, err := PruneCaches(ctx, d, store, now, time.Hour, time.Hour, 0, 10)
if err != nil {
t.Fatalf("PruneCaches: %v", err)
}
if pruned != 1 {
t.Fatalf("pruned %d entries, want 1", pruned)
}
if !store.has(refreshed.StorageKey) || !cacheTestEntryExists(t, d, refreshed.ID) {
t.Fatal("entry refreshed after expiry scan was pruned")
}
}
func TestPruneCachesDeleteFailureRemainsRetryable(t *testing.T) {
for _, initialState := range []string{"ready", "deleting"} {
t.Run(initialState, func(t *testing.T) {
ctx := context.Background()
d := newCacheTestDB(t)
now := time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC)
entry := cacheTestEntry("retry", "did:plc:repo", "microvm", "deps", "hash", initialState, now.Add(-2*time.Hour))
insertCacheTestEntry(t, d, entry)
deleteErr := errors.New("delete failed")
store := &fakeStorage{
objects: map[string][]byte{entry.StorageKey: []byte("archive")},
deleteErr: deleteErr,
}
pruned, err := PruneCaches(ctx, d, store, now, time.Hour, time.Hour, 0, 10)
if !errors.Is(err, deleteErr) {
t.Fatalf("first PruneCaches error = %v, want %v", err, deleteErr)
}
if pruned != 0 {
t.Fatalf("first prune count = %d, want 0", pruned)
}
state, _, _ := cacheTestEntryState(t, d, entry.ID)
if state != initialState {
t.Fatalf("state after delete failure = %q, want %q", state, initialState)
}
if !store.has(entry.StorageKey) {
t.Fatal("failed delete removed object")
}
pruned, err = PruneCaches(ctx, d, store, now, time.Hour, time.Hour, 0, 10)
if err != nil {
t.Fatalf("retry PruneCaches: %v", err)
}
if pruned != 1 || store.has(entry.StorageKey) || cacheTestEntryExists(t, d, entry.ID) {
t.Fatalf("retry result = pruned %d, object %v, metadata %v", pruned, store.has(entry.StorageKey), cacheTestEntryExists(t, d, entry.ID))
}
})
}
}
func TestPruneCachesRetriesQueuedObjectDeletion(t *testing.T) {
ctx := context.Background()
d := newCacheTestDB(t)
now := time.Now()
key := "objects/did:web:example.com/orphan"
if err := d.ApplyEventBatch(nil, func(tx *db.EventBatchTx) error {
return tx.QueueCacheObjectDeletion(ctx, key, now)
}); err != nil {
t.Fatal(err)
}
deleteErr := errors.New("delete failed")
store := &fakeStorage{
objects: map[string][]byte{key: []byte("archive")},
deleteErr: deleteErr,
}
if _, err := PruneCaches(ctx, d, store, now, time.Hour, time.Hour, 0, 10); !errors.Is(err, deleteErr) {
t.Fatalf("first prune error = %v, want %v", err, deleteErr)
}
pending, err := d.PendingCacheObjectDeletions(ctx, 10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 || pending[0] != key {
t.Fatalf("pending object deletions = %v", pending)
}
pruned, err := PruneCaches(ctx, d, store, now, time.Hour, time.Hour, 0, 10)
if err != nil {
t.Fatal(err)
}
if pruned != 1 || store.has(key) {
t.Fatalf("retry pruned %d, object remains %t", pruned, store.has(key))
}
}
func TestPruneCachesEvictsLeastRecentlyUsedOverTotal(t *testing.T) {
ctx := context.Background()
d := newCacheTestDB(t)
now := time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC)
store := &fakeStorage{objects: map[string][]byte{}}
add := func(id, state string, age time.Duration, size, restores int64) {
entry := cacheTestEntry(id, "did:plc:repo", "microvm", id, "hash", state, now.Add(-age))
entry.SizeBytes, entry.RestoreCount = size, restores
insertCacheTestEntry(t, d, entry)
store.objects[entry.StorageKey] = []byte(id)
}
add("pinned", "ready", 4*time.Hour, 40, 1)
add("oldest", "ready", 3*time.Hour, 40, 0)
add("older", "ready", 2*time.Hour, 40, 0)
add("newest", "ready", time.Hour, 40, 0)
add("reserved", "pending", time.Minute, 1000, 0)
pruned, err := PruneCaches(ctx, d, store, now, 0, 24*time.Hour, 90, 10)
if err != nil {
t.Fatalf("PruneCaches: %v", err)
}
if pruned != 2 {
t.Fatalf("pruned %d entries, want 2", pruned)
}
for id, survives := range map[string]bool{"pinned": true, "oldest": false, "older": false, "newest": true, "reserved": true} {
if store.has("objects/"+id) != survives || cacheTestEntryExists(t, d, id) != survives {
t.Fatalf("entry %q survived = %v, want %v", id, !survives, survives)
}
}
}