Add prune retention planning

This commit is contained in:
2026-06-08 19:26:48 +00:00
parent 2abd09bde3
commit c67ecf86a9
12 changed files with 628 additions and 7 deletions

121
internal/state/prune.go Normal file
View File

@@ -0,0 +1,121 @@
package state
import (
"sort"
"time"
)
type PruneCandidate struct {
Path string
UpdatedAt time.Time
Owner *OwnerScope
}
type PrunePlanOptions struct {
Now time.Time
OlderThan *time.Duration
KeepLatest *int
}
type PrunePlan struct {
Pruned []PruneCandidate
Preserved []PruneCandidate
}
func SingleOwnerPruneCandidates(s DistributorState) []PruneCandidate {
candidates := make([]PruneCandidate, 0, len(s.Outputs))
for _, output := range s.Outputs {
candidates = append(candidates, PruneCandidate{
Path: output.Path,
UpdatedAt: output.UpdatedAt,
})
}
return candidates
}
func SharedRootPruneCandidates(s SharedRootState, scope OwnerScope) []PruneCandidate {
candidates := make([]PruneCandidate, 0, len(s.Outputs))
for _, output := range s.Outputs {
if output.Owner != scope {
continue
}
owner := output.Owner
candidates = append(candidates, PruneCandidate{
Path: output.Path,
UpdatedAt: output.UpdatedAt,
Owner: &owner,
})
}
return candidates
}
func PlanPrune(candidates []PruneCandidate, options PrunePlanOptions) PrunePlan {
ordered := append([]PruneCandidate(nil), candidates...)
sortPruneCandidatesNewestFirst(ordered)
if options.OlderThan == nil && options.KeepLatest == nil {
return PrunePlan{
Pruned: []PruneCandidate{},
Preserved: ordered,
}
}
preservedByPath := make(map[string]struct{})
if options.KeepLatest != nil {
keep := *options.KeepLatest
if keep < 0 {
keep = 0
}
if keep > len(ordered) {
keep = len(ordered)
}
for _, candidate := range ordered[:keep] {
preservedByPath[candidate.Path] = struct{}{}
}
}
plan := PrunePlan{
Pruned: []PruneCandidate{},
Preserved: []PruneCandidate{},
}
cutoff := time.Time{}
if options.OlderThan != nil {
now := options.Now.UTC()
if now.IsZero() {
now = time.Now().UTC()
}
cutoff = now.Add(-*options.OlderThan)
}
for _, candidate := range ordered {
if _, preserved := preservedByPath[candidate.Path]; preserved {
plan.Preserved = append(plan.Preserved, candidate)
continue
}
if options.OlderThan == nil || candidate.UpdatedAt.Before(cutoff) {
plan.Pruned = append(plan.Pruned, candidate)
continue
}
plan.Preserved = append(plan.Preserved, candidate)
}
sortPruneCandidatesOldestFirst(plan.Pruned)
sortPruneCandidatesNewestFirst(plan.Preserved)
return plan
}
func sortPruneCandidatesNewestFirst(candidates []PruneCandidate) {
sort.Slice(candidates, func(i, j int) bool {
if !candidates[i].UpdatedAt.Equal(candidates[j].UpdatedAt) {
return candidates[i].UpdatedAt.After(candidates[j].UpdatedAt)
}
return candidates[i].Path < candidates[j].Path
})
}
func sortPruneCandidatesOldestFirst(candidates []PruneCandidate) {
sort.Slice(candidates, func(i, j int) bool {
if !candidates[i].UpdatedAt.Equal(candidates[j].UpdatedAt) {
return candidates[i].UpdatedAt.Before(candidates[j].UpdatedAt)
}
return candidates[i].Path < candidates[j].Path
})
}

View File

@@ -0,0 +1,96 @@
package state
import (
"strings"
"testing"
"time"
)
func TestPlanPruneOlderThan(t *testing.T) {
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
olderThan := 48 * time.Hour
plan := PlanPrune([]PruneCandidate{
{Path: "old.txt", UpdatedAt: now.Add(-72 * time.Hour)},
{Path: "fresh.txt", UpdatedAt: now.Add(-24 * time.Hour)},
}, PrunePlanOptions{Now: now, OlderThan: &olderThan})
if got, want := pruneCandidatePaths(plan.Pruned), "old.txt"; got != want {
t.Fatalf("pruned = %q, want %q", got, want)
}
if got, want := pruneCandidatePaths(plan.Preserved), "fresh.txt"; got != want {
t.Fatalf("preserved = %q, want %q", got, want)
}
}
func TestPlanPruneKeepLatest(t *testing.T) {
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
keepLatest := 2
plan := PlanPrune([]PruneCandidate{
{Path: "old.txt", UpdatedAt: now.Add(-72 * time.Hour)},
{Path: "new.txt", UpdatedAt: now.Add(-1 * time.Hour)},
{Path: "middle.txt", UpdatedAt: now.Add(-24 * time.Hour)},
}, PrunePlanOptions{KeepLatest: &keepLatest})
if got, want := pruneCandidatePaths(plan.Pruned), "old.txt"; got != want {
t.Fatalf("pruned = %q, want %q", got, want)
}
if got, want := pruneCandidatePaths(plan.Preserved), "new.txt,middle.txt"; got != want {
t.Fatalf("preserved = %q, want %q", got, want)
}
}
func TestPlanPruneCombinedPolicyPreservesLatestBeforeAgeCheck(t *testing.T) {
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
olderThan := 48 * time.Hour
keepLatest := 1
plan := PlanPrune([]PruneCandidate{
{Path: "oldest.txt", UpdatedAt: now.Add(-96 * time.Hour)},
{Path: "old.txt", UpdatedAt: now.Add(-72 * time.Hour)},
{Path: "fresh.txt", UpdatedAt: now.Add(-24 * time.Hour)},
}, PrunePlanOptions{Now: now, OlderThan: &olderThan, KeepLatest: &keepLatest})
if got, want := pruneCandidatePaths(plan.Pruned), "oldest.txt,old.txt"; got != want {
t.Fatalf("pruned = %q, want %q", got, want)
}
if got, want := pruneCandidatePaths(plan.Preserved), "fresh.txt"; got != want {
t.Fatalf("preserved = %q, want %q", got, want)
}
}
func TestPlanPruneDeterministicTieBreaking(t *testing.T) {
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
keepLatest := 1
plan := PlanPrune([]PruneCandidate{
{Path: "b.txt", UpdatedAt: now},
{Path: "a.txt", UpdatedAt: now},
{Path: "c.txt", UpdatedAt: now.Add(-time.Hour)},
}, PrunePlanOptions{KeepLatest: &keepLatest})
if got, want := pruneCandidatePaths(plan.Preserved), "a.txt"; got != want {
t.Fatalf("preserved = %q, want %q", got, want)
}
if got, want := pruneCandidatePaths(plan.Pruned), "c.txt,b.txt"; got != want {
t.Fatalf("pruned = %q, want %q", got, want)
}
}
func TestSharedRootPruneCandidatesPreserveOtherOwners(t *testing.T) {
sharedRoot := validSharedRootState(t)
scope := CurrentOwnerScope("reports", "archive")
candidates := SharedRootPruneCandidates(sharedRoot, scope)
if got, want := pruneCandidatePaths(candidates), "report.md"; got != want {
t.Fatalf("candidates = %q, want %q", got, want)
}
if candidates[0].Owner == nil || *candidates[0].Owner != scope {
t.Fatalf("candidate owner = %#v, want current owner", candidates[0].Owner)
}
}
func pruneCandidatePaths(candidates []PruneCandidate) string {
paths := make([]string, 0, len(candidates))
for _, candidate := range candidates {
paths = append(paths, candidate.Path)
}
return strings.Join(paths, ",")
}