185 lines
6.8 KiB
Go
185 lines
6.8 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
|
|
},
|
|
})
|
|
|
|
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: "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)
|
|
}
|