Files
memby/server/internal/api/housekeeping.go
T
2026-08-19 06:57:59 +12:00

215 lines
8.0 KiB
Go

package api
import (
"context"
"fmt"
"time"
"github.com/ponzischeme89/memby/server/internal/scheduler"
"github.com/ponzischeme89/memby/server/internal/store"
)
// RegisterHousekeeping declares the gateway's background jobs.
//
// This list is deliberately readable top to bottom: it is the answer to "what does the
// server do when nobody is watching", and before the scheduler existed that answer was
// spread across four `go someTicker(ctx, …)` calls in main with no shared vocabulary,
// no history and no way for an operator to run one by hand. Two of the jobs below —
// analytics retention and the idle-session sweep — were exactly those tickers.
//
// Each Run returns the sentence the console prints beside the run, and returns an *empty*
// one when nothing happened. That emptiness is what keeps the notification bell quiet:
// see scheduler.announce.
func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) {
if sched == nil {
return
}
sched.Register(scheduler.Task{
ID: "login-retention",
Name: "Login history retention",
Group: "Housekeeping",
Description: fmt.Sprintf("Removes sign-in records older than %d days.",
int(store.LoginRetention/(24*time.Hour))),
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneLoginEvents(ctx, store.LoginRetention)
return countDetail(removed, "sign-in record"), err
},
})
sched.Register(scheduler.Task{
ID: "notification-cleanup",
Name: "Notification cleanup",
Group: "Housekeeping",
Description: fmt.Sprintf("Removes administrative events older than %d days.",
int(store.AdminEventRetention/(24*time.Hour))),
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneAdminEvents(ctx, store.AdminEventRetention)
return countDetail(removed, "notification"), err
},
})
// The outbound notification history, which is a different table from the one above:
// that prunes the operator's activity feed, this prunes the record of what Memby sent
// to viewers and to external services.
sched.Register(scheduler.Task{
ID: "notification-log-retention",
Name: "Notification history retention",
Group: "Housekeeping",
Description: fmt.Sprintf("Removes outbound notification records older than %d days.",
int(store.NotificationRetention/(24*time.Hour))),
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneNotificationLog(ctx, store.NotificationRetention)
return countDetail(removed, "notification record"), err
},
})
sched.Register(scheduler.Task{
ID: "device-activity-cleanup",
Name: "Device activity cleanup",
Group: "Housekeeping",
Description: fmt.Sprintf(
"Removes the daily first-use marks that are older than %d days.",
int(store.DeviceActivityRetention/(24*time.Hour))),
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneDeviceActivityDays(ctx, store.DeviceActivityRetention)
return countDetail(removed, "activity mark"), err
},
})
sched.Register(scheduler.Task{
ID: "integration-cleanup",
Name: "Integration delivery cleanup",
Group: "Housekeeping",
Description: "Keeps the most recent hundred delivery attempts for each integration.",
Interval: 12 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneIntegrationDeliveries(ctx, 100)
return countDetail(removed, "delivery record"), err
},
})
sched.Register(scheduler.Task{
ID: "ingest-cleanup",
Name: "Import queue cleanup",
Group: "Housekeeping",
Description: fmt.Sprintf(
"Removes settled Sonarr and Radarr import records older than %d days. Work still waiting is never removed.",
int(store.IngestRetention/(24*time.Hour))),
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneIngests(ctx, store.IngestRetention)
return countDetail(removed, "import record"), err
},
})
sched.Register(scheduler.Task{
ID: "task-history-cleanup",
Name: "Task history cleanup",
Group: "Housekeeping",
Description: "Removes this table's own old rows, so the scheduler cannot outgrow the database it reports from.",
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneTaskRuns(ctx, store.TaskRunRetention)
return countDetail(removed, "task run"), err
},
})
// Was pruneAnalytics in main. Retention is configuration rather than a constant here
// because engagement telemetry is the one dataset an operator might genuinely want to
// keep for a season or discard within a week.
if s.cfg.AnalyticsRetention > 0 {
sched.Register(scheduler.Task{
ID: "analytics-retention",
Name: "Analytics retention",
Group: "Analytics",
Description: fmt.Sprintf(
"Removes row engagement and journey events older than %d days.",
int(s.cfg.AnalyticsRetention/(24*time.Hour))),
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneRowEvents(ctx, s.cfg.AnalyticsRetention)
return countDetail(removed, "analytics event"), err
},
})
}
// Was sweepIdleSessions in main. It matters more than it looks: a session row holds a
// live Emby token, so a television that was factory-reset leaves working upstream
// credentials in the database until this runs.
sched.Register(scheduler.Task{
ID: "session-sweep",
Name: "Idle session sweep",
Group: "Housekeeping",
Description: fmt.Sprintf(
"Retires gateway tokens unused for %d days, along with the Emby token each one holds.",
int(s.sessionIdleExpiry()/(24*time.Hour))),
Interval: 6 * time.Hour,
Run: func(ctx context.Context) (string, error) {
// Read at run time rather than closed over: the description above is written
// once when the task is registered, but the sweep itself must follow the
// operator's setting without a restart.
removed, err := s.store.DeleteIdleSessions(ctx, s.sessionIdleExpiry())
return countDetail(removed, "idle session"), err
},
})
// Not a prune: Redis is configured with no persistence and an LRU eviction policy, so
// nothing here has to delete anything. What it checks is that the cache is *reachable*
// — a gateway whose Redis has gone away still serves every page, slowly, with no error
// anybody sees, and this is the one thing that would say so.
sched.Register(scheduler.Task{
ID: "cache-check",
Name: "Cache health check",
Group: "System",
Description: "Confirms Redis is answering. Nothing is deleted: the cache evicts by itself.",
Interval: 30 * time.Minute,
Timeout: 30 * time.Second,
Run: func(ctx context.Context) (string, error) {
if s.cache == nil {
return "", nil
}
if err := s.cache.Ping(ctx); err != nil {
return "", fmt.Errorf("redis did not answer: %w", err)
}
// A successful check reports nothing, so a healthy cache is silent and only a
// failure reaches the notification feed.
return "", nil
},
})
sched.Register(scheduler.Task{
ID: "database-check",
Name: "Database health check",
Group: "System",
Description: "Confirms Postgres is answering.",
Interval: 30 * time.Minute,
Timeout: 30 * time.Second,
Run: func(ctx context.Context) (string, error) {
if err := s.store.Ping(ctx); err != nil {
return "", fmt.Errorf("postgres did not answer: %w", err)
}
return "", nil
},
})
}
// countDetail is the one line a housekeeping run reports, and returns empty for zero.
//
// Empty is not a formatting nicety — it is what stops a job running every six hours
// announcing "0 removed" into the operator's notification bell every six hours, which is
// how a feed becomes something nobody reads.
func countDetail(count int64, noun string) string {
if count <= 0 {
return ""
}
if count == 1 {
return "1 " + noun + " removed"
}
return fmt.Sprintf("%d %ss removed", count, noun)
}