diff --git a/README.md b/README.md index 333c5268..c8a4e4b4 100644 --- a/README.md +++ b/README.md @@ -831,6 +831,34 @@ State changes are detected via **fsnotify** on the VM index file (sub-second lat This ensures blobs referenced by running VMs or saved snapshots are never deleted. +### Log Output + +Every collected item is logged at INFO level with a structured `key=value` payload under `gc.`, and a summary line ends the cycle. Sample: + +``` +INFO gc.snapshot collected id=XEOU... name=ubuntu-hot-testing:v1 bytes=3221225472 last_accessed=2026-04-12T10:30:00Z reason=lru-age +INFO gc.snapshot collected id=2GQVEA... name= bytes=0 last_accessed=never reason=orphan +INFO gc.cloudhypervisor collected id=ABC123 runDir=/var/lib/cocoon/run/cloudhypervisor/ABC123 logDir=/var/log/cocoon/cloudhypervisor/ABC123 reason=orphan-runDir +INFO gc.oci collected blob=b40150c1c2717d... reason=unreferenced +INFO gc.cni collected id=JKLMN netns=cocoon-JKLMN nics=2 reason=orphan +INFO gc.bridge collected id=MNOPQ iface=btMNOPQ-0 reason=orphan-tap +INFO gc.Run completed: cloudhypervisor=1 cni=1 oci=4 snapshot=3 (failures: 0, duration: 230ms) +``` + +Filter with `awk` / `grep`: + +```bash +journalctl -u cocoon-gc.service --since today | grep "gc.snapshot.*reason=lru-" +journalctl -u cocoon-gc.service --since today | awk '/gc.Run completed/' +``` + +Reasons: +- **snapshot**: `orphan` (dataDir without DB record), `stale-pending` (Create crashed >24h ago), `lru-all` / `lru-age` / `lru-keep` / `lru-size` (multi-criterion uses `+` joiner) +- **cloudhypervisor / firecracker**: `orphan-runDir`, `orphan-logDir`, `stale-creating` +- **images (oci, cloudimg)**: `unreferenced` +- **cni**: `orphan` (netns without active VM) +- **bridge**: `orphan-tap` + ### Snapshot LRU Eviction Bare `cocoon gc` only reclaims **orphans** (on-disk data with no DB record) and **stale pending** records (crashed mid-Create, older than 24h). To also evict healthy snapshots by access recency, pass `--snapshot`: diff --git a/cmd/others/handler.go b/cmd/others/handler.go index 4c14571e..619945aa 100644 --- a/cmd/others/handler.go +++ b/cmd/others/handler.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/docker/go-units" - "github.com/projecteru2/core/log" "github.com/spf13/cobra" cmdcore "github.com/cocoonstack/cocoon/cmd/core" @@ -56,11 +55,7 @@ func (h Handler) GC(cmd *cobra.Command, _ []string) error { netProvider.RegisterGC(o) gc.Register(o, bridge.GCModule(conf.RootDir)) snapBackend.RegisterGC(o) - if err := o.Run(ctx); err != nil { - return err - } - log.WithFunc("cmd.gc").Info(ctx, "GC completed") - return nil + return o.Run(ctx) } func (h Handler) Version(_ *cobra.Command, _ []string) error { diff --git a/gc/module.go b/gc/module.go index 386cc18e..6c162d0f 100644 --- a/gc/module.go +++ b/gc/module.go @@ -18,7 +18,7 @@ type Module[S any] struct { Resolve func(ctx context.Context, snap S, others map[string]any) []string // Collect removes the given IDs (called while the lock is held). - Collect func(ctx context.Context, ids []string) error + Collect func(ctx context.Context, ids []string, snap S) error } // Module[S] implements runner — internal to the gc package. @@ -37,6 +37,10 @@ func (m Module[S]) resolveTargets(ctx context.Context, snap any, others map[stri return m.Resolve(ctx, typed, others) } -func (m Module[S]) collect(ctx context.Context, ids []string) error { - return m.Collect(ctx, ids) +func (m Module[S]) collect(ctx context.Context, ids []string, snap any) error { + typed, ok := snap.(S) + if !ok { + return nil + } + return m.Collect(ctx, ids, typed) } diff --git a/gc/orchestrator.go b/gc/orchestrator.go index 4613cd31..62d2a008 100644 --- a/gc/orchestrator.go +++ b/gc/orchestrator.go @@ -4,7 +4,10 @@ import ( "context" "errors" "fmt" + "maps" + "slices" "strings" + "time" "github.com/projecteru2/core/log" ) @@ -25,6 +28,7 @@ func Register[S any](o *Orchestrator, m Module[S]) { // Run executes one GC cycle: lock all modules, snapshot, resolve, collect. // Fail-closed: any busy lock aborts the cycle so cross-module decisions stay consistent. func (o *Orchestrator) Run(ctx context.Context) error { + start := time.Now() logger := log.WithFunc("gc.Run") // Acquire all locks up front; hold until GC finishes. @@ -75,14 +79,36 @@ func (o *Orchestrator) Run(ctx context.Context) error { // Phase 3: collect (skip modules with no targets). var errs []error + summary := make(map[string]int, len(locked)) + failures := 0 for _, m := range locked { ids := targets[m.getName()] if len(ids) == 0 { continue } - if err := m.collect(ctx, ids); err != nil { + if err := m.collect(ctx, ids, snapshots[m.getName()]); err != nil { + failures++ errs = append(errs, fmt.Errorf("gc %s: %w", m.getName(), err)) } + summary[m.getName()] = len(ids) } + logger.Infof(ctx, "completed: %s (failures: %d, duration: %s)", + formatSummary(summary), failures, time.Since(start).Truncate(time.Millisecond)) return errors.Join(errs...) } + +// formatSummary renders the per-module collection counts as `m1=N m2=M`, sorted by module name. +func formatSummary(s map[string]int) string { + if len(s) == 0 { + return "nothing to collect" + } + keys := slices.Sorted(maps.Keys(s)) + var sb strings.Builder + for i, k := range keys { + if i > 0 { + sb.WriteByte(' ') + } + fmt.Fprintf(&sb, "%s=%d", k, s[k]) + } + return sb.String() +} diff --git a/gc/orchestrator_test.go b/gc/orchestrator_test.go new file mode 100644 index 00000000..6a2a055b --- /dev/null +++ b/gc/orchestrator_test.go @@ -0,0 +1,22 @@ +package gc + +import "testing" + +func TestFormatSummary(t *testing.T) { + cases := []struct { + name string + in map[string]int + want string + }{ + {"empty", map[string]int{}, "nothing to collect"}, + {"single", map[string]int{"snapshot": 3}, "snapshot=3"}, + {"sorted", map[string]int{"snapshot": 3, "cloudhypervisor": 1, "oci": 12}, "cloudhypervisor=1 oci=12 snapshot=3"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := formatSummary(tt.in); got != tt.want { + t.Errorf("formatSummary(%v) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/gc/runner.go b/gc/runner.go index 050c339a..95b48503 100644 --- a/gc/runner.go +++ b/gc/runner.go @@ -13,5 +13,5 @@ type runner interface { getLocker() lock.Locker readSnapshot(ctx context.Context) (any, error) resolveTargets(ctx context.Context, snap any, others map[string]any) []string - collect(ctx context.Context, ids []string) error + collect(ctx context.Context, ids []string, snap any) error } diff --git a/hypervisor/gc.go b/hypervisor/gc.go index 99d1f344..4bdf7d04 100644 --- a/hypervisor/gc.go +++ b/hypervisor/gc.go @@ -3,10 +3,13 @@ package hypervisor import ( "context" "errors" + "fmt" "maps" "slices" "time" + "github.com/projecteru2/core/log" + "github.com/cocoonstack/cocoon/gc" "github.com/cocoonstack/cocoon/types" "github.com/cocoonstack/cocoon/utils" @@ -19,6 +22,7 @@ type VMGCSnapshot struct { staleCreate []string runDirs []string logDirs []string + reasons map[string]string } func (s VMGCSnapshot) UsedBlobIDs() map[string]struct{} { return s.blobIDs } @@ -31,7 +35,7 @@ func (b *Backend) BuildGCModule() gc.Module[VMGCSnapshot] { Name: b.Typ, Locker: b.Locker, ReadDB: func(_ context.Context) (VMGCSnapshot, error) { - var snap VMGCSnapshot + snap := VMGCSnapshot{reasons: make(map[string]string)} cutoff := time.Now().Add(-CreatingStateGCGrace) if err := b.DB.ReadRaw(func(idx *VMIndex) error { snap.blobIDs = make(map[string]struct{}, len(idx.VMs)) @@ -64,11 +68,26 @@ func (b *Backend) BuildGCModule() gc.Module[VMGCSnapshot] { reserved := map[string]struct{}{"db": {}} runOrphans := utils.FilterUnreferenced(snap.runDirs, snap.vmIDs, reserved) logOrphans := utils.FilterUnreferenced(snap.logDirs, snap.vmIDs, reserved) + for _, id := range snap.staleCreate { + snap.reasons[id] = "stale-creating" + } + for _, id := range runOrphans { + if _, ok := snap.reasons[id]; !ok { + snap.reasons[id] = "orphan-runDir" + } + } + for _, id := range logOrphans { + if _, ok := snap.reasons[id]; !ok { + snap.reasons[id] = "orphan-logDir" + } + } candidates := slices.Concat(runOrphans, logOrphans, snap.staleCreate) slices.Sort(candidates) return slices.Compact(candidates) }, - Collect: b.GCCollect, + Collect: func(ctx context.Context, ids []string, snap VMGCSnapshot) error { + return b.gcCollect(ctx, ids, snap) + }, } } @@ -81,9 +100,9 @@ func (b *Backend) WatchPath() string { return b.Conf.IndexFile() } -// GCCollect kills leftover hypervisor processes and removes orphan dirs/records. -// Runs under the GC orchestrator's flock — uses lock-free DB access. -func (b *Backend) GCCollect(ctx context.Context, ids []string) error { +// gcCollect kills leftover hypervisor processes and removes orphan dirs/records under the orchestrator's flock. +func (b *Backend) gcCollect(ctx context.Context, ids []string, snap VMGCSnapshot) error { + logger := log.WithFunc("gc." + b.Typ) var errs []error for _, id := range ids { runDir, logDir := b.Conf.VMRunDir(id), b.Conf.VMLogDir(id) @@ -95,11 +114,14 @@ func (b *Backend) GCCollect(ctx context.Context, ids []string) error { }) b.killOrphanProcess(ctx, runDir) if err := RemoveVMDirs(runDir, logDir); err != nil { - errs = append(errs, err) + errs = append(errs, fmt.Errorf("remove vm %s: %w", id, err)) + continue } + logger.Infof(ctx, "collected id=%s runDir=%s logDir=%s reason=%s", + id, runDir, logDir, snap.reasons[id]) } if err := b.CleanStalePlaceholders(ctx, ids); err != nil { - errs = append(errs, err) + errs = append(errs, fmt.Errorf("clean stale placeholders: %w", err)) } return errors.Join(errs...) } diff --git a/images/gc.go b/images/gc.go index 6a311abc..b2d50900 100644 --- a/images/gc.go +++ b/images/gc.go @@ -69,8 +69,8 @@ func BuildGCModule[I any](cfg GCModuleConfig[I]) gc.Module[ImageGCSnapshot] { slices.Sort(candidates) return slices.Compact(candidates) }, - Collect: func(ctx context.Context, ids []string) error { - return GCCollectBlobs(ctx, cfg.TempDir, cfg.DirOnly, ids, cfg.Removers...) + Collect: func(ctx context.Context, ids []string, _ ImageGCSnapshot) error { + return GCCollectBlobs(ctx, cfg.Name, cfg.TempDir, cfg.DirOnly, ids, cfg.Removers...) }, } } diff --git a/images/index.go b/images/index.go index 556ff3dd..7838e843 100644 --- a/images/index.go +++ b/images/index.go @@ -3,6 +3,7 @@ package images import ( "context" "errors" + "fmt" "io/fs" "os" "strings" @@ -104,17 +105,24 @@ func GCStaleTemp(ctx context.Context, dir string, dirOnly bool) []error { }) } -// GCCollectBlobs removes temp files and blob artifacts by hex ID. +// GCCollectBlobs removes temp files and blob artifacts by hex ID; module names the gc subsystem for log routing. // removers are called for each hex; fs.ErrNotExist errors are ignored. -func GCCollectBlobs(ctx context.Context, tempDir string, dirOnly bool, ids []string, removers ...func(string) error) error { +func GCCollectBlobs(ctx context.Context, module, tempDir string, dirOnly bool, ids []string, removers ...func(string) error) error { + logger := log.WithFunc("gc." + module) var errs []error errs = append(errs, GCStaleTemp(ctx, tempDir, dirOnly)...) for _, hex := range ids { + var blobErr error for _, rm := range removers { if err := rm(hex); err != nil && !errors.Is(err, fs.ErrNotExist) { - errs = append(errs, err) + blobErr = errors.Join(blobErr, err) } } + if blobErr != nil { + errs = append(errs, fmt.Errorf("remove blob %s: %w", hex, blobErr)) + continue + } + logger.Infof(ctx, "collected blob=%s reason=unreferenced", hex) } return errors.Join(errs...) } diff --git a/network/bridge/gc_linux.go b/network/bridge/gc_linux.go index 8e847142..f626116a 100644 --- a/network/bridge/gc_linux.go +++ b/network/bridge/gc_linux.go @@ -65,8 +65,8 @@ func GCModule(rootDir string) gc.Module[bridgeSnapshot] { slices.Sort(orphans) return orphans }, - Collect: func(ctx context.Context, prefixes []string) error { - logger := log.WithFunc("bridge.gc.Collect") + Collect: func(ctx context.Context, prefixes []string, _ bridgeSnapshot) error { + logger := log.WithFunc("gc.bridge") orphanSet := make(map[string]struct{}, len(prefixes)) for _, p := range prefixes { @@ -89,7 +89,7 @@ func GCModule(rootDir string) gc.Module[bridgeSnapshot] { if err := netlink.LinkDel(l); err != nil { logger.Warnf(ctx, "delete orphan TAP %s: %v", name, err) } else { - logger.Infof(ctx, "collected orphan TAP %s", name) + logger.Infof(ctx, "collected id=%s iface=%s reason=orphan-tap", prefix, name) } } return nil diff --git a/network/bridge/gc_other.go b/network/bridge/gc_other.go index a0c76c3b..28f3f9d8 100644 --- a/network/bridge/gc_other.go +++ b/network/bridge/gc_other.go @@ -25,7 +25,7 @@ func GCModule(rootDir string) gc.Module[bridgeSnapshot] { Resolve: func(_ context.Context, _ bridgeSnapshot, _ map[string]any) []string { return nil }, - Collect: func(_ context.Context, _ []string) error { + Collect: func(_ context.Context, _ []string, _ bridgeSnapshot) error { return nil }, } diff --git a/network/cni/gc.go b/network/cni/gc.go index d6be2ebb..89095455 100644 --- a/network/cni/gc.go +++ b/network/cni/gc.go @@ -10,6 +10,8 @@ import ( "slices" "strings" + "github.com/projecteru2/core/log" + "github.com/cocoonstack/cocoon/gc" ) @@ -62,7 +64,8 @@ func (c *CNI) GCModule() gc.Module[cniSnapshot] { slices.Sort(orphans) return orphans }, - Collect: func(ctx context.Context, ids []string) error { + Collect: func(ctx context.Context, ids []string, _ cniSnapshot) error { + logger := log.WithFunc("gc.cni") var errs []error for _, vmID := range ids { // 1. Read CNI records for this VM (lockless — orchestrator holds flock). @@ -82,6 +85,7 @@ func (c *CNI) GCModule() gc.Module[cniSnapshot] { nsName := netnsName(vmID) if err := deleteNetns(ctx, nsName); err != nil && !errors.Is(err, fs.ErrNotExist) { errs = append(errs, fmt.Errorf("remove netns %s: %w", nsName, err)) + continue } // 4. Clean DB records (lockless write). @@ -95,8 +99,11 @@ func (c *CNI) GCModule() gc.Module[cniSnapshot] { return nil }); err != nil { errs = append(errs, fmt.Errorf("clean DB for %s: %w", vmID, err)) + continue } } + logger.Infof(ctx, "collected id=%s netns=%s nics=%d reason=orphan", + vmID, nsName, len(records)) } return errors.Join(errs...) }, diff --git a/snapshot/localfile/gc.go b/snapshot/localfile/gc.go index 127ac065..837ae9b6 100644 --- a/snapshot/localfile/gc.go +++ b/snapshot/localfile/gc.go @@ -21,9 +21,21 @@ import ( // pendingGCGrace lets a slow-storage snapshot finish before GC reclaims a pending record. const pendingGCGrace = 24 * time.Hour -// backfillSizeBytes computes DirSize for records with SizeBytes==0 and persists via WriteRaw. +// EvictionPolicy controls LRU snapshot eviction; Enabled with zero criteria evicts all non-pending. +type EvictionPolicy struct { + Enabled bool + DryRun bool + KeepLast int + MaxAge time.Duration + MaxSize int64 +} + +func (p EvictionPolicy) hasCriteria() bool { + return p.KeepLast > 0 || p.MaxAge > 0 || p.MaxSize > 0 +} + func backfillSizeBytes(ctx context.Context, conf *Config, store storage.Store[snapshot.SnapshotIndex], records map[string]snapshotMeta) { - logger := log.WithFunc("localfile.gc.backfillSizeBytes") + logger := log.WithFunc("gc.snapshot") var changed bool for id, m := range records { if m.sizeBytes > 0 { @@ -53,19 +65,6 @@ func backfillSizeBytes(ctx context.Context, conf *Config, store storage.Store[sn } } -// EvictionPolicy controls LRU snapshot eviction; Enabled with zero criteria evicts all non-pending. -type EvictionPolicy struct { - Enabled bool - DryRun bool - KeepLast int - MaxAge time.Duration - MaxSize int64 -} - -func (p EvictionPolicy) hasCriteria() bool { - return p.KeepLast > 0 || p.MaxAge > 0 || p.MaxSize > 0 -} - type snapshotMeta struct { name string lastAccessed time.Time @@ -78,6 +77,7 @@ type snapshotGCSnapshot struct { dataDirs []string stalePending []string records map[string]snapshotMeta + reasons map[string]string policy EvictionPolicy } @@ -88,7 +88,7 @@ func gcModule(conf *Config, store storage.Store[snapshot.SnapshotIndex], locker Name: "snapshot", Locker: locker, ReadDB: func(ctx context.Context) (snapshotGCSnapshot, error) { - snap := snapshotGCSnapshot{policy: policy} + snap := snapshotGCSnapshot{policy: policy, reasons: make(map[string]string)} cutoff := time.Now().Add(-pendingGCGrace) if err := store.ReadRaw(func(idx *snapshot.SnapshotIndex) error { snap.blobIDs = make(map[string]struct{}) @@ -127,20 +127,29 @@ func gcModule(conf *Config, store storage.Store[snapshot.SnapshotIndex], locker }, Resolve: func(ctx context.Context, snap snapshotGCSnapshot, _ map[string]any) []string { orphans := utils.FilterUnreferenced(snap.dataDirs, snap.snapshotIDs) + for _, id := range orphans { + snap.reasons[id] = "orphan" + } + for _, id := range snap.stalePending { + snap.reasons[id] = "stale-pending" + } candidates := slices.Concat(orphans, snap.stalePending) if snap.policy.Enabled { - evict := pickLRU(snap.records, snap.policy) - logEvictions(ctx, evict, snap.records, snap.policy.DryRun) - if !snap.policy.DryRun { - candidates = append(candidates, evict...) + lruReasons := pickLRU(snap.records, snap.policy) + if snap.policy.DryRun { + logWouldEvict(ctx, lruReasons, snap.records) + } else { + maps.Copy(snap.reasons, lruReasons) + candidates = append(candidates, slices.Collect(maps.Keys(lruReasons))...) } } slices.Sort(candidates) return slices.Compact(candidates) }, - Collect: func(ctx context.Context, ids []string) error { + Collect: func(ctx context.Context, ids []string, snap snapshotGCSnapshot) error { + logger := log.WithFunc("gc.snapshot") var ( errs []error removed = make([]string, 0, len(ids)) @@ -154,6 +163,7 @@ func gcModule(conf *Config, store storage.Store[snapshot.SnapshotIndex], locker errs = append(errs, fmt.Errorf("remove snapshot %s: %w", id, err)) continue } + logEvictRow(ctx, logger, "collected", id, snap.records[id], snap.reasons[id]) removed = append(removed, id) } if err := cleanResolvedRecords(store, removed); err != nil { @@ -164,30 +174,41 @@ func gcModule(conf *Config, store storage.Store[snapshot.SnapshotIndex], locker } } -// pickLRU returns evict IDs. No sub-criteria → all records; else union of age/keep/size. -func pickLRU(records map[string]snapshotMeta, p EvictionPolicy) []string { +// pickLRU returns evict IDs keyed by reason ("+" joins multi-match; no criteria → "lru-all"). +func pickLRU(records map[string]snapshotMeta, p EvictionPolicy) map[string]string { sorted := slices.SortedFunc(maps.Keys(records), func(a, b string) int { return records[a].lastAccessed.Compare(records[b].lastAccessed) }) + reasons := make(map[string]string) + if !p.hasCriteria() { - return sorted + for _, id := range sorted { + reasons[id] = "lru-all" + } + return reasons } - evict := make(map[string]struct{}) + add := func(id, label string) { + if existing, ok := reasons[id]; ok { + reasons[id] = existing + "+" + label + return + } + reasons[id] = label + } if p.MaxAge > 0 { cutoff := time.Now().Add(-p.MaxAge) for _, id := range sorted { if records[id].lastAccessed.Before(cutoff) { - evict[id] = struct{}{} + add(id, "lru-age") } } } if p.KeepLast > 0 && len(sorted) > p.KeepLast { for _, id := range sorted[:len(sorted)-p.KeepLast] { - evict[id] = struct{}{} + add(id, "lru-keep") } } @@ -200,41 +221,34 @@ func pickLRU(records map[string]snapshotMeta, p EvictionPolicy) []string { if total <= p.MaxSize { break } - evict[id] = struct{}{} + add(id, "lru-size") total -= records[id].sizeBytes } } - return slices.Sorted(maps.Keys(evict)) + return reasons } -func logEvictions(ctx context.Context, ids []string, records map[string]snapshotMeta, dryRun bool) { - if len(ids) == 0 { +func logWouldEvict(ctx context.Context, reasons map[string]string, records map[string]snapshotMeta) { + if len(reasons) == 0 { return } - logger := log.WithFunc("localfile.gc.lru") - verb := "evicting" - if dryRun { - verb = "would evict" - } - var freed int64 - for _, id := range ids { - m := records[id] - freed += m.sizeBytes - logger.Infof(ctx, "%s id=%s name=%s last_accessed=%s size_bytes=%d", - verb, id, m.name, formatTime(m.lastAccessed), m.sizeBytes) + logger := log.WithFunc("gc.snapshot") + for _, id := range slices.Sorted(maps.Keys(reasons)) { + logEvictRow(ctx, logger, "would-evict", id, records[id], reasons[id]) } - logger.Infof(ctx, "%s %d snapshot(s), freeing %d bytes", verb, len(ids), freed) } -func formatTime(t time.Time) string { - if t.IsZero() { - return "never" +func logEvictRow(ctx context.Context, logger *log.Fields, verb, id string, m snapshotMeta, reason string) { + accessed := "never" + if !m.lastAccessed.IsZero() { + accessed = m.lastAccessed.UTC().Format(time.RFC3339) } - return t.UTC().Format(time.RFC3339) + logger.Infof(ctx, "%s id=%s name=%s bytes=%d last_accessed=%s reason=%s", + verb, id, m.name, m.sizeBytes, accessed, reason) } -// cleanResolvedRecords drops GC-resolved records; pending only if past grace (protects in-flight Create). +// cleanResolvedRecords drops resolved records; pending only past grace. func cleanResolvedRecords(store storage.Store[snapshot.SnapshotIndex], ids []string) error { if len(ids) == 0 { return nil diff --git a/snapshot/localfile/gc_test.go b/snapshot/localfile/gc_test.go index 8c164863..ebec41af 100644 --- a/snapshot/localfile/gc_test.go +++ b/snapshot/localfile/gc_test.go @@ -2,6 +2,7 @@ package localfile import ( "fmt" + "maps" "os" "path/filepath" "slices" @@ -17,6 +18,10 @@ func meta(ageHours int, size int64) snapshotMeta { return snapshotMeta{lastAccessed: accessedAt, sizeBytes: size} } +func sortedKeys(m map[string]string) []string { + return slices.Sorted(maps.Keys(m)) +} + func TestPickLRU_NoCriteriaEvictsAll(t *testing.T) { records := map[string]snapshotMeta{ "a": meta(1, 100), @@ -37,9 +42,12 @@ func TestPickLRU_KeepLast(t *testing.T) { "oldester": meta(20, 10), } got := pickLRU(records, EvictionPolicy{Enabled: true, KeepLast: 2}) - if !slices.Equal(got, []string{"oldest", "oldester"}) { + if !slices.Equal(sortedKeys(got), []string{"oldest", "oldester"}) { t.Errorf("KeepLast=2: got %v", got) } + if got["oldest"] != "lru-keep" { + t.Errorf("reason: got %q, want lru-keep", got["oldest"]) + } } func TestPickLRU_KeepLastExceedsAll(t *testing.T) { @@ -56,9 +64,12 @@ func TestPickLRU_MaxAge(t *testing.T) { "stale": meta(48, 10), } got := pickLRU(records, EvictionPolicy{Enabled: true, MaxAge: 24 * time.Hour}) - if !slices.Equal(got, []string{"stale"}) { + if !slices.Equal(sortedKeys(got), []string{"stale"}) { t.Errorf("MaxAge=24h: got %v", got) } + if got["stale"] != "lru-age" { + t.Errorf("reason: got %q, want lru-age", got["stale"]) + } } func TestPickLRU_MaxSize(t *testing.T) { @@ -69,7 +80,7 @@ func TestPickLRU_MaxSize(t *testing.T) { "d": meta(4, 30), } got := pickLRU(records, EvictionPolicy{Enabled: true, MaxSize: 60}) - if !slices.Equal(got, []string{"c", "d"}) { + if !slices.Equal(sortedKeys(got), []string{"c", "d"}) { t.Errorf("MaxSize=60: got %v", got) } } @@ -83,9 +94,8 @@ func TestPickLRU_UnionOfCriteria(t *testing.T) { got := pickLRU(records, EvictionPolicy{ Enabled: true, MaxAge: 24 * time.Hour, MaxSize: 50, }) - want := []string{"fresh-big", "old-small"} - if !slices.Equal(got, want) { - t.Errorf("union: got %v, want %v", got, want) + if !slices.Equal(sortedKeys(got), []string{"fresh-big", "old-small"}) { + t.Errorf("union: got %v", got) } } @@ -95,11 +105,19 @@ func TestPickLRU_ZeroTimeIsOldest(t *testing.T) { "zero": {lastAccessed: time.Time{}, sizeBytes: 10}, } got := pickLRU(records, EvictionPolicy{Enabled: true, KeepLast: 1}) - if !slices.Equal(got, []string{"zero"}) { + if !slices.Equal(sortedKeys(got), []string{"zero"}) { t.Errorf("zero time should be evicted first: got %v", got) } } +func TestPickLRU_NoCriteriaAllReasonAll(t *testing.T) { + records := map[string]snapshotMeta{"a": meta(1, 10)} + got := pickLRU(records, EvictionPolicy{Enabled: true}) + if got["a"] != "lru-all" { + t.Errorf("reason: got %q, want lru-all", got["a"]) + } +} + func TestGCModule_LRUEndToEnd(t *testing.T) { lf := newTestLF(t) ctx := t.Context() @@ -136,7 +154,7 @@ func TestGCModule_LRUEndToEnd(t *testing.T) { if len(ids) != 2 { t.Errorf("want 2 evictions, got %v", ids) } - if err := mod.Collect(ctx, ids); err != nil { + if err := mod.Collect(ctx, ids, snap); err != nil { t.Fatalf("Collect: %v", err) } @@ -196,7 +214,7 @@ func TestGCModule_BareSnapshotEvictsAll(t *testing.T) { t.Fatal(err) } ids := mod.Resolve(ctx, snap, map[string]any{}) - if err := mod.Collect(ctx, ids); err != nil { + if err := mod.Collect(ctx, ids, snap); err != nil { t.Fatal(err) } @@ -285,14 +303,19 @@ func TestGCModule_RemovalFailureKeepsDBRecord(t *testing.T) { } } + mod := gcModule(lf.conf, lf.store, lf.locker, EvictionPolicy{Enabled: true}) + snap, err := mod.ReadDB(ctx) + if err != nil { + t.Fatalf("ReadDB: %v", err) + } + parent := lf.conf.DataDir() if err := os.Chmod(parent, 0o500); err != nil { t.Skipf("chmod failed: %v", err) } t.Cleanup(func() { _ = os.Chmod(parent, 0o750) }) - mod := gcModule(lf.conf, lf.store, lf.locker, EvictionPolicy{Enabled: true}) - if err := mod.Collect(ctx, ids); err == nil { + if err := mod.Collect(ctx, ids, snap); err == nil { t.Fatal("expected Collect to error on chmod-protected parent") } for i, name := range []string{"a", "b"} { @@ -320,7 +343,7 @@ func TestGCModule_OrphanDirCleaned(t *testing.T) { if !slices.Contains(ids, "ORPHAN_ID_NO_DB") { t.Errorf("orphan dir should be picked, got %v", ids) } - if err := mod.Collect(ctx, ids); err != nil { + if err := mod.Collect(ctx, ids, snap); err != nil { t.Fatal(err) } if _, err := os.Stat(orphanDir); !os.IsNotExist(err) {