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:
nemunaire 2026-04-08 12:02:04 +07:00
commit b9035bb6b4
2 changed files with 100 additions and 11 deletions

View file

@ -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 {

View file

@ -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)