This commit is contained in:
ponzischeme89
2026-08-19 14:25:44 +12:00
parent 2b43b9ef12
commit 590e069366
83 changed files with 8948 additions and 1266 deletions
+47 -10
View File
@@ -17,6 +17,7 @@ import (
"time"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/runtimestats"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -31,6 +32,22 @@ const tick = 30 * time.Second
// minutes announcing itself into the notification bell every ten minutes.
type TaskFunc func(ctx context.Context) (detail string, err error)
// Outcome is the richer form: the sentence, plus what the run counted.
//
// It exists because "412 movies checked, 7 updated, 2 skipped" is the question an operator
// has about an integration and a sentence is a poor place to keep numbers — they cannot be
// compared between runs, sorted, or drawn as anything but prose. Most tasks count nothing
// and keep the plain TaskFunc; only work that processes a batch has anything to say here.
type Outcome struct {
// Detail is the one line the console prints beside the run, and is empty when nothing
// happened — the same rule TaskFunc's return follows, and for the same reason.
Detail string
store.RunCounts
}
// WorkFunc is a task that counts what it did. A task declares Run or Work, never both.
type WorkFunc func(ctx context.Context) (Outcome, error)
// Task is a declaration. Everything about it except the operator's overrides is code, so
// the registry is readable as a list of what the gateway does in the background.
type Task struct {
@@ -45,7 +62,14 @@ type Task struct {
// RunOnStart runs the task once shortly after boot regardless of when it last ran.
// For jobs whose cost is trivial and whose value is highest immediately.
RunOnStart bool
Run TaskFunc
// Integration names the external service this job belongs to, and is empty for the
// gateway's own work. It is what lets the integrations area read its operational
// history out of this registry rather than keeping a second one: a run is filed
// against the service as well as against the job, and nothing else is needed.
Integration string
Run TaskFunc
// Work is Run's counting form. Exactly one of the two must be set.
Work WorkFunc
}
// Status is one task as the console reads it: the declaration, the operator's overrides,
@@ -55,6 +79,9 @@ type Status struct {
Name string `json:"name"`
Description string `json:"description"`
Group string `json:"group"`
// Integration is the external service this job belongs to, empty for the gateway's
// own work. The integrations area lists a service's jobs by matching on it.
Integration string `json:"integration,omitempty"`
Interval int64 `json:"intervalSeconds"`
// DefaultInterval is the cadence declared in code, which Interval hides whenever an
// operator has overridden it. Both are sent because the console cannot otherwise tell
@@ -132,8 +159,8 @@ func (s *Scheduler) Register(task Task) {
if s == nil {
return
}
if task.ID == "" || task.Run == nil {
panic("scheduler: a task needs an id and a function")
if task.ID == "" || (task.Run == nil) == (task.Work == nil) {
panic("scheduler: a task needs an id and exactly one of Run or Work")
}
s.mu.Lock()
defer s.mu.Unlock()
@@ -166,7 +193,9 @@ func (s *Scheduler) Start(ctx context.Context) {
s.mu.Unlock()
s.restore(ctx)
go s.loop(ctx)
// Named rather than launched bare, so the console can say this worker is running
// without having to infer it from a stack. See internal/runtimestats.
runtimestats.Go("Task scheduler", "Scheduled jobs", func() { s.loop(ctx) })
}
func (s *Scheduler) restore(ctx context.Context) {
@@ -320,7 +349,7 @@ func (s *Scheduler) execute(ctx context.Context, entry *registered, trigger stri
var runID int64
if s.store != nil {
id, err := s.store.BeginTaskRun(ctx, task.ID, trigger)
id, err := s.store.BeginTaskRun(ctx, task.ID, task.Integration, trigger)
if err != nil {
s.log.Warn("could not open task run", "task", task.ID, "error", err)
} else {
@@ -329,9 +358,10 @@ func (s *Scheduler) execute(ctx context.Context, entry *registered, trigger stri
}
runCtx, cancel := context.WithTimeout(ctx, task.Timeout)
detail, err := s.safeRun(runCtx, task)
outcome, err := s.safeRun(runCtx, task)
cancel()
elapsed := time.Since(started)
detail := outcome.Detail
status := store.TaskSuccess
failure := ""
@@ -340,7 +370,7 @@ func (s *Scheduler) execute(ctx context.Context, entry *registered, trigger stri
}
if s.store != nil && runID != 0 {
if closeErr := s.store.FinishTaskRun(
context.WithoutCancel(ctx), runID, status, detail, failure,
context.WithoutCancel(ctx), runID, status, detail, failure, outcome.RunCounts,
); closeErr != nil {
s.log.Warn("could not close task run", "task", task.ID, "error", closeErr)
}
@@ -349,9 +379,11 @@ func (s *Scheduler) execute(ctx context.Context, entry *registered, trigger stri
finished := time.Now()
entry.mu.Lock()
entry.lastRun = &store.TaskRun{
ID: runID, TaskID: task.ID, Trigger: trigger, Status: status,
ID: runID, TaskID: task.ID, IntegrationID: task.Integration,
Trigger: trigger, Status: status,
StartedAt: started, FinishedAt: &finished,
DurationMS: elapsed.Milliseconds(), Detail: detail, Error: failure,
Counts: outcome.RunCounts,
}
entry.mu.Unlock()
@@ -362,13 +394,17 @@ func (s *Scheduler) execute(ctx context.Context, entry *registered, trigger stri
// panic takes the whole process down for a reason nobody is watching for, and one
// housekeeping job with a nil map must not be able to stop the gateway serving
// television.
func (s *Scheduler) safeRun(ctx context.Context, task Task) (detail string, err error) {
func (s *Scheduler) safeRun(ctx context.Context, task Task) (outcome Outcome, err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("task panicked: %v", recovered)
}
}()
return task.Run(ctx)
if task.Work != nil {
return task.Work(ctx)
}
detail, err := task.Run(ctx)
return Outcome{Detail: detail}, err
}
// announce writes the log line and, when it is worth an operator's attention, publishes
@@ -489,6 +525,7 @@ func (s *Scheduler) Snapshot() []Status {
status := Status{
ID: entry.task.ID, Name: entry.task.Name,
Description: entry.task.Description, Group: entry.task.Group,
Integration: entry.task.Integration,
Interval: int64(entry.effectiveInterval() / time.Second),
DefaultInterval: int64(entry.task.Interval / time.Second),
Enabled: entry.enabled, Running: entry.running,