checker: keep 1 report per hour after the first day
Insert an hourly tier between the full-detail window and the daily bucket so users still get sub-day resolution for the first week: 0..1 day -> all 1..7 days -> 1 per hour 7..30 -> 2 per day ...
This commit is contained in:
parent
fd5bfb637d
commit
b9035bb6b4
2 changed files with 100 additions and 11 deletions
|
|
@ -38,7 +38,8 @@ import (
|
|||
//
|
||||
// age window | kept
|
||||
// ------------------------- | ------------------------------------------
|
||||
// 0 .. 7 days | every execution
|
||||
// 0 .. 1 day | every execution
|
||||
// 1 .. 7 days | up to 1 execution per hour per (checker,target)
|
||||
// 7 .. 30 days | up to 2 executions per day per (checker,target)
|
||||
// 30 .. D/2 days | up to 1 execution per week per (checker,target)
|
||||
// D/2 .. D days | up to 1 execution per month per (checker,target)
|
||||
|
|
@ -53,7 +54,11 @@ type RetentionPolicy struct {
|
|||
|
||||
// FullDetailDays: every execution kept under this age.
|
||||
FullDetailDays int
|
||||
// DailyBucketDays: between FullDetailDays and DailyBucketDays, keep
|
||||
// HourlyBucketDays: between FullDetailDays and HourlyBucketDays, keep
|
||||
// PerHourKept executions per UTC hour per (checker,target).
|
||||
HourlyBucketDays int
|
||||
PerHourKept int
|
||||
// DailyBucketDays: between HourlyBucketDays and DailyBucketDays, keep
|
||||
// PerDayKept executions per UTC day per (checker,target).
|
||||
DailyBucketDays int
|
||||
PerDayKept int
|
||||
|
|
@ -74,10 +79,12 @@ func DefaultRetentionPolicy(retentionDays int) RetentionPolicy {
|
|||
}
|
||||
return RetentionPolicy{
|
||||
RetentionDays: retentionDays,
|
||||
FullDetailDays: 7,
|
||||
DailyBucketDays: 30,
|
||||
FullDetailDays: min(1, retentionDays),
|
||||
HourlyBucketDays: min(7, retentionDays),
|
||||
PerHourKept: 1,
|
||||
DailyBucketDays: min(30, retentionDays),
|
||||
PerDayKept: 2,
|
||||
WeeklyBucketDays: max(retentionDays/2, 31),
|
||||
WeeklyBucketDays: min(max(retentionDays/2, 31), retentionDays),
|
||||
PerWeekKept: 1,
|
||||
PerMonthKept: 1,
|
||||
}
|
||||
|
|
@ -121,6 +128,7 @@ func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time)
|
|||
|
||||
hardCutoff := now.AddDate(0, 0, -p.RetentionDays)
|
||||
fullCutoff := now.AddDate(0, 0, -p.FullDetailDays)
|
||||
hourlyCutoff := now.AddDate(0, 0, -p.HourlyBucketDays)
|
||||
dailyCutoff := now.AddDate(0, 0, -p.DailyBucketDays)
|
||||
weeklyCutoff := now.AddDate(0, 0, -p.WeeklyBucketDays)
|
||||
|
||||
|
|
@ -130,6 +138,7 @@ func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time)
|
|||
return group[i].StartedAt.After(group[j].StartedAt)
|
||||
})
|
||||
|
||||
hourBuckets := map[string]int{}
|
||||
dayBuckets := map[string]int{}
|
||||
weekBuckets := map[string]int{}
|
||||
monthBuckets := map[string]int{}
|
||||
|
|
@ -140,8 +149,16 @@ func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time)
|
|||
case t.Before(hardCutoff):
|
||||
drop = append(drop, e.Id)
|
||||
case !t.Before(fullCutoff):
|
||||
// 0 .. FullDetailDays - keep everything.
|
||||
// 0 .. FullDetailDays: keep everything.
|
||||
keep = append(keep, e.Id)
|
||||
case !t.Before(hourlyCutoff):
|
||||
k := t.UTC().Format("2006-01-02T15")
|
||||
if hourBuckets[k] < p.PerHourKept {
|
||||
hourBuckets[k]++
|
||||
keep = append(keep, e.Id)
|
||||
} else {
|
||||
drop = append(drop, e.Id)
|
||||
}
|
||||
case !t.Before(dailyCutoff):
|
||||
k := t.UTC().Format("2006-01-02")
|
||||
if dayBuckets[k] < p.PerDayKept {
|
||||
|
|
|
|||
|
|
@ -50,17 +50,38 @@ func TestDecide_FullDetailWindow(t *testing.T) {
|
|||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(365)
|
||||
|
||||
// 20 executions in the first 20 minutes, all inside 0..1 day window.
|
||||
var execs []*happydns.Execution
|
||||
for i := 0; i < 50; i++ {
|
||||
execs = append(execs, mkExec(fmt.Sprintf("e%d", i), time.Duration(i)*time.Hour, now))
|
||||
for i := 0; i < 20; i++ {
|
||||
execs = append(execs, mkExec(fmt.Sprintf("e%d", i), time.Duration(i)*time.Minute, now))
|
||||
}
|
||||
|
||||
keep, drop := p.Decide(execs, now)
|
||||
if len(drop) != 0 {
|
||||
t.Fatalf("expected no drops in <7d window, got %d", len(drop))
|
||||
t.Fatalf("expected no drops in <1d window, got %d", len(drop))
|
||||
}
|
||||
if len(keep) != 50 {
|
||||
t.Fatalf("expected 50 keeps, got %d", len(keep))
|
||||
if len(keep) != 20 {
|
||||
t.Fatalf("expected 20 keeps, got %d", len(keep))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_HourlyBucket(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(365)
|
||||
|
||||
// 6 executions in the same hour ~3 days ago (inside hourly window).
|
||||
var execs []*happydns.Execution
|
||||
base := 3*24*time.Hour + 30*time.Minute
|
||||
for i := 0; i < 6; i++ {
|
||||
execs = append(execs, mkExec(fmt.Sprintf("e%d", i), base+time.Duration(i)*time.Minute, now))
|
||||
}
|
||||
|
||||
keep, drop := p.Decide(execs, now)
|
||||
if len(keep) != p.PerHourKept {
|
||||
t.Fatalf("expected %d keeps in hourly bucket, got %d", p.PerHourKept, len(keep))
|
||||
}
|
||||
if len(drop) != 6-p.PerHourKept {
|
||||
t.Fatalf("expected %d drops, got %d", 6-p.PerHourKept, len(drop))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +184,57 @@ func TestDecide_HardCutoff(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDecide_SmallRetentionCollapseTiers(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(3)
|
||||
|
||||
// With retentionDays=3, tiers collapse:
|
||||
// FullDetailDays=1, HourlyBucketDays=3, DailyBucketDays=3,
|
||||
// WeeklyBucketDays=3 - only full-detail and hourly tiers are reachable.
|
||||
|
||||
var execs []*happydns.Execution
|
||||
// 3 executions inside full-detail window (< 1 day).
|
||||
for i := 0; i < 3; i++ {
|
||||
execs = append(execs, mkExec(fmt.Sprintf("recent%d", i), time.Duration(i)*time.Minute, now))
|
||||
}
|
||||
// 4 executions in the same hour, ~2 days ago (hourly tier).
|
||||
base := 2*24*time.Hour + 30*time.Minute
|
||||
for i := 0; i < 4; i++ {
|
||||
execs = append(execs, mkExec(fmt.Sprintf("hourly%d", i), base+time.Duration(i)*time.Minute, now))
|
||||
}
|
||||
// 1 execution beyond retention (5 days ago).
|
||||
execs = append(execs, mkExec("expired", 5*24*time.Hour, now))
|
||||
|
||||
keep, drop := p.Decide(execs, now)
|
||||
// 3 full-detail + 1 hourly kept + 3 hourly dropped + 1 expired dropped
|
||||
if len(keep) != 3+p.PerHourKept {
|
||||
t.Fatalf("expected %d keeps, got %d", 3+p.PerHourKept, len(keep))
|
||||
}
|
||||
if len(drop) != 4-p.PerHourKept+1 {
|
||||
t.Fatalf("expected %d drops, got %d", 4-p.PerHourKept+1, len(drop))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_BoundaryFullDetailToHourly(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(365)
|
||||
|
||||
// Execution exactly at the full-detail boundary (age == exactly 1 day).
|
||||
// !t.Before(fullCutoff) is true when t == fullCutoff, so this lands in full-detail.
|
||||
exactBoundary := mkExec("boundary", 24*time.Hour, now)
|
||||
// Execution 1 second past the boundary (age == 1 day + 1s) lands in hourly.
|
||||
pastBoundary := mkExec("past", 24*time.Hour+time.Second, now)
|
||||
|
||||
keep, drop := p.Decide([]*happydns.Execution{exactBoundary, pastBoundary}, now)
|
||||
// Both should be kept (one as full-detail, one as hourly).
|
||||
if len(keep) != 2 {
|
||||
t.Fatalf("expected 2 keeps, got %d (keep=%v, drop=%v)", len(keep), keep, drop)
|
||||
}
|
||||
if len(drop) != 0 {
|
||||
t.Fatalf("expected 0 drops, got %d", len(drop))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_GroupedByTarget(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(365)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue