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 }, }) 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: "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.cfg.SessionIdleExpiry/(24*time.Hour))), Interval: 6 * time.Hour, Run: func(ctx context.Context) (string, error) { removed, err := s.store.DeleteIdleSessions(ctx, s.cfg.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) }