Add prune retention planning
This commit is contained in:
121
internal/state/prune.go
Normal file
121
internal/state/prune.go
Normal 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
|
||||
})
|
||||
}
|
||||
96
internal/state/prune_test.go
Normal file
96
internal/state/prune_test.go
Normal 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, ",")
|
||||
}
|
||||
Reference in New Issue
Block a user