feat(notifications): the inbox store, one writer, coalescing and retention (#5338)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m34s
release / go (push) Successful in 1m51s
release / integration (push) Successful in 4m59s
release / android (push) Successful in 5m24s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m34s
release / go (push) Successful in 1m51s
release / integration (push) Successful in 4m59s
release / android (push) Successful in 5m24s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
M489 step 1. The event bus is fire-and-forget, so a client that isn't connected never hears that a request completed or that tracks went missing. user_notifications is the durable record; the bus only nudges. - Migration 0073: user_notifications (kind CHECK-gated, payload jsonb, read_at, coalesce_key, emailed_at) and user_notification_prefs (per user, per kind: inbox, phone, email). A missing pref row means the kind's defaults, so nothing is seeded. - internal/notifications.Notifier is the only writer. It resolves recipients (admin kinds reach admins only, and never the excepted user), honours the inbox pref (phone and email ride on it), writes, and publishes a contentless notification.created nudge per recipient. - Burst-prone admin kinds coalesce into one unread row: tracks_missing and scan_failed add up their counts, duplicates_found and playback_errors take the latest total. Once read, the next event is a new row. - Retention: read rows go after 90 days, anything after a year, on the library_changes compactor's daily shape. Tests: unit (channel rules, kind table) and integration (recipients, nudge, coalescing both ways, prefs, owner-scoped idempotent mark-read, every kind against both schema CHECKs, retention cut-offs). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
const (
|
||||
// ReadRetention is how long a read notification is kept. An inbox is a
|
||||
// record of what happened lately, not an archive.
|
||||
ReadRetention = 90 * 24 * time.Hour
|
||||
// AnyRetention bounds an inbox nobody opens: past it, even unread rows go.
|
||||
AnyRetention = 365 * 24 * time.Hour
|
||||
// retentionInterval matches the library_changes compactor's daily tick.
|
||||
retentionInterval = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Retention trims old notifications on a daily tick, in the shape of the
|
||||
// library_changes compactor (internal/sync/compactor.go).
|
||||
type Retention struct {
|
||||
db dbq.DBTX
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewRetention returns a Retention trimming through db.
|
||||
func NewRetention(db dbq.DBTX, logger *slog.Logger) *Retention {
|
||||
return &Retention{db: db, logger: logger}
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled. It trims once at startup, so a process
|
||||
// that has been down a while catches up, then daily.
|
||||
func (r *Retention) Run(ctx context.Context) {
|
||||
r.TrimOnce(ctx, time.Now())
|
||||
t := time.NewTicker(retentionInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-t.C:
|
||||
r.TrimOnce(ctx, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TrimOnce deletes what has outlived its retention as of now. Errors are
|
||||
// logged, never fatal: the next tick tries again.
|
||||
func (r *Retention) TrimOnce(ctx context.Context, now time.Time) int64 {
|
||||
n, err := dbq.New(r.db).TrimNotifications(ctx, dbq.TrimNotificationsParams{
|
||||
ReadCutoff: pgtype.Timestamptz{Time: now.Add(-ReadRetention), Valid: true},
|
||||
AnyCutoff: pgtype.Timestamptz{Time: now.Add(-AnyRetention), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
r.logger.Warn("notifications retention: trim failed", "err", err)
|
||||
return 0
|
||||
}
|
||||
if n > 0 {
|
||||
r.logger.Info("notifications retention: trimmed rows", "count", n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in New Issue
Block a user