issue 1858: remove action lines older than 365 days each night (1858.5)

ADR 0517 §9: the action log keeps a line one year; the removal runs at start and each night at 03:00
(Europe/Brussels) and says how many it removed. Handling goes with its entry by the schema's cascade.
This commit is contained in:
jochen
2026-10-22 19:54:57 +02:00
parent da8a6a6df4
commit 0ed94e5466
3 changed files with 96 additions and 0 deletions
@@ -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)
@@ -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()))
}
}
@@ -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)
}
}
}