2026-04-06 09:00:47 +00:00
|
|
|
// Phase B Step 7 — Event Persistence Layer (pruner).
|
|
|
|
|
//
|
|
|
|
|
// StartEventPruner runs a background goroutine that deletes events older than
|
|
|
|
|
// the configured retention window. It is the bounded-storage half of the
|
|
|
|
|
// event persistence design: the persister appends, the pruner trims.
|
2026-07-18 12:55:22 +02:00
|
|
|
|
2026-04-06 09:00:47 +00:00
|
|
|
package ws
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-06 13:48:41 +00:00
|
|
|
// maxStartupDelay caps how long StartEventPruner waits before its first
|
|
|
|
|
// prune pass. We want a short delay so a freshly started server with a
|
|
|
|
|
// tiny dataset doesn't keep stale rows around for a full interval, but we
|
|
|
|
|
// don't want the delay to exceed the interval itself (otherwise a server
|
|
|
|
|
// running with interval=5s would wait longer than its own tick).
|
|
|
|
|
const maxStartupDelay = time.Minute
|
|
|
|
|
|
2026-04-06 09:00:47 +00:00
|
|
|
// StartEventPruner launches a goroutine that wakes every interval and deletes
|
2026-08-15 20:50:47 +02:00
|
|
|
// events older than retention. The goroutine exits when ctx is cancelled; the
|
|
|
|
|
// returned channel closes when it has fully exited (i.e. no prune can still
|
|
|
|
|
// be touching the store) — the same join contract EventPersister.Stop gives,
|
|
|
|
|
// so shutdown can order "background work done" before "database closed".
|
|
|
|
|
func StartEventPruner(ctx context.Context, s EventStore, retention, interval time.Duration) <-chan struct{} {
|
|
|
|
|
done := make(chan struct{})
|
2026-04-06 09:00:47 +00:00
|
|
|
if s == nil {
|
2026-08-15 20:50:47 +02:00
|
|
|
close(done)
|
|
|
|
|
return done
|
2026-04-06 09:00:47 +00:00
|
|
|
}
|
|
|
|
|
if retention <= 0 {
|
|
|
|
|
retention = 24 * time.Hour
|
|
|
|
|
}
|
|
|
|
|
if interval <= 0 {
|
|
|
|
|
interval = time.Hour
|
|
|
|
|
}
|
2026-04-06 13:48:41 +00:00
|
|
|
// Bound the startup delay by the interval so short test intervals
|
|
|
|
|
// (e.g. 100ms in event_pruner_test.go) don't wait a full minute.
|
2026-07-29 13:25:46 +02:00
|
|
|
startupDelayDuration := min(interval, maxStartupDelay)
|
2026-04-06 09:00:47 +00:00
|
|
|
go func() {
|
2026-08-15 20:50:47 +02:00
|
|
|
defer close(done)
|
2026-04-06 09:00:47 +00:00
|
|
|
// Run once shortly after startup so a tiny dataset stays small.
|
2026-04-06 13:48:41 +00:00
|
|
|
startupDelay := time.NewTimer(startupDelayDuration)
|
2026-04-06 09:00:47 +00:00
|
|
|
defer startupDelay.Stop()
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case <-startupDelay.C:
|
|
|
|
|
}
|
|
|
|
|
runPrune(ctx, s, retention)
|
|
|
|
|
|
|
|
|
|
t := time.NewTicker(interval)
|
|
|
|
|
defer t.Stop()
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case <-t.C:
|
|
|
|
|
runPrune(ctx, s, retention)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
2026-08-15 20:50:47 +02:00
|
|
|
return done
|
2026-04-06 09:00:47 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:33:58 +00:00
|
|
|
func runPrune(ctx context.Context, s EventStore, retention time.Duration) {
|
2026-04-06 09:00:47 +00:00
|
|
|
cutoff := time.Now().Add(-retention)
|
|
|
|
|
deleted, err := s.PruneEventsOlderThan(ctx, cutoff)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Warn("event pruner: PruneEventsOlderThan failed", "err", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if deleted > 0 {
|
|
|
|
|
slog.Info("event pruner: pruned old events", "deleted", deleted, "cutoff", cutoff.Format(time.RFC3339))
|
|
|
|
|
}
|
|
|
|
|
}
|