diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go index 2104558e..9f88737f 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go @@ -74,6 +74,7 @@ func main() { b := &Bundle{Env: envFromOS(), Store: store.get} go hearLoop(store.get, stdio.Subscribe, time.Sleep) go batchLoop(store.get) + go removeEachNight(store.get, time.Now, time.Sleep, logf) if err := stdio.ServeOn("", b.Tools(), os.Stdin, os.Stdout); err != nil { logf("[mesh-mail-reader] %v", err) os.Exit(1) diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/prune.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/prune.go new file mode 100644 index 00000000..de0f347f --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/prune.go @@ -0,0 +1,49 @@ +package main + +// The nightly removal (novox/hq ADR 0517 §9, issue 1858): the action log keeps a line 365 days; each night at +// 03:00 (Europe/Brussels) the older ones are removed, and the journal says how many. Handling and reminders +// need no removal of their own: they go with their entry (ADR 0517 §10, the schema's on delete cascade). + +import ( + "context" + "time" +) + +// ActionsKeptFor is how long an action line is kept. +const ActionsKeptFor = 365 * 24 * time.Hour + +// RemoveOldActions removes the lines older than ActionsKeptFor, answering how many. +func RemoveOldActions(ctx context.Context, store Store, now time.Time, logf func(string, ...any)) (int64, error) { + removed, err := store.RemoveActionsBefore(ctx, now.Add(-ActionsKeptFor)) + if err != nil { + return 0, err + } + logf("[mesh-mail-reader] the nightly removal: %d action lines older than 365 days removed", removed) + return removed, nil +} + +// nextNight is the next 03:00 in the operator's zone after now. +func nextNight(now time.Time) time.Time { + local := now.In(Brussels) + night := time.Date(local.Year(), local.Month(), local.Day(), 3, 0, 0, 0, Brussels) + if !night.After(now) { + night = time.Date(local.Year(), local.Month(), local.Day()+1, 3, 0, 0, 0, Brussels) + } + return night +} + +// removeEachNight runs the removal at once, then each night, forever; a failure is said and tried again the +// next night. +func removeEachNight(storeFor func(context.Context) (Store, error), now func() time.Time, sleep func(time.Duration), logf func(string, ...any)) { + for { + ctx := context.Background() + store, err := storeFor(ctx) + if err == nil { + _, err = RemoveOldActions(ctx, store, now(), logf) + } + if err != nil { + logf("[mesh-mail-reader] the nightly removal failed: %v; it tries again the next night", err) + } + sleep(nextNight(now()).Sub(now())) + } +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/prune_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/prune_test.go new file mode 100644 index 00000000..b32516fa --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/prune_test.go @@ -0,0 +1,46 @@ +package main + +// Issue 1858/AC7 (novox/hq ADR 0517 §9): each night, the action lines older than 365 days are removed. + +import ( + "context" + "testing" + "time" +) + +// 1858/AC7: the removal drops the lines older than 365 days, and keeps the rest. +func TestTheNightlyRemovalDropsLinesOlderThan365Days(t *testing.T) { + ctx := context.Background() + store := newMemStore() + now := brusselsAt(2026, 10, 22, 3, 0) + for _, age := range []time.Duration{366 * 24 * time.Hour, 365*24*time.Hour + time.Minute, 365*24*time.Hour - time.Hour, time.Hour} { + _ = store.Record(ctx, ActionLine{What: ActPageOpened, Mode: "work", At: now.Add(-age)}) + } + removed, err := RemoveOldActions(ctx, store, now, noopLogf) + if err != nil { + t.Fatal(err) + } + left := lines(t, store) + if removed != 2 || len(left) != 2 { + t.Fatalf("removed %d, %d left; want 2 removed (older than 365 days) and 2 kept", removed, len(left)) + } + for _, l := range left { + if now.Sub(l.At) > ActionsKeptFor { + t.Fatalf("kept a line of %s, older than 365 days", l.At) + } + } +} + +// 1858/AC7: the removal runs each night at 03:00 in the operator's zone: the next one is tonight's, or +// tomorrow's once tonight's has passed. +func TestTheRemovalRunsEachNight(t *testing.T) { + for _, tc := range []struct{ now, want time.Time }{ + {brusselsAt(2026, 10, 22, 1, 0), brusselsAt(2026, 10, 22, 3, 0)}, + {brusselsAt(2026, 10, 22, 3, 0), brusselsAt(2026, 10, 23, 3, 0)}, + {brusselsAt(2026, 10, 24, 22, 0), brusselsAt(2026, 10, 25, 3, 0)}, // the night the clocks go back + } { + if got := nextNight(tc.now); !got.Equal(tc.want) { + t.Fatalf("nextNight(%s) = %s, want %s", tc.now, got, tc.want) + } + } +}