De tre sidste af de fem. Alle tre samme sted hen: det, man ellers selv skulle sidde og gøre klokken syv om morgenen. Dagens plan. Én besked om morgenen om det, der forfalder i dag, og det, der er sprunget over. Tallene tælles i basen og gives til modellen som kendsgerninger, den ikke må regne om — en plan, der siger fire opgaver, hvor der er tre, er værre end ingen plan. Er der ingen model, står tallene alene, og det er stadig det, man havde brug for at vide. Er der hverken noget, der forfalder, eller noget, der er sprunget over, kommer der ingen besked: en morgen uden noget at gøre er en god morgen, og en besked om ingenting lærer folk at lade være med at åbne beskeder. Timen er personens egen — klokken syv er syv forskellige steder — og jobbet kører hver time frem for på et klokkeslæt, så en instans, der sov, ikke springer dagen over. Flytter man timen, ryger dagens mærke, så man kan se virkningen i dag i stedet for i morgen. Mails, der giver mening. Opgaven hed, hvad mailen hed: "Anders Jensen: SV: SV: Vedr. levering uge 12". Nok til at genkende, ikke nok til at gøre noget ved. Nu skriver modellen den handling, mailen beder om, og lader være, hvis den ikke beder om nogen. Ét kald pr. gennemløb frem for ét pr. mail — femogtyve mails er ellers femogtyve gange ventetid — og emnet bliver stående i beskrivelsen, så en misforstået mail stadig kan læses. Falder modellen ud, hedder opgaven afsender og emne som før: en model, der er nede, må ikke kunne stoppe posten. Og svar på et spørgsmål. "Hvad lovede jeg Anders i august?" — svaret står spredt over tre noter, og ingen af dem indeholder ordet "lovede". Søgningen kræver, at alle ord optræder, hvilket er rigtigt for et søgefelt og forkert for et spørgsmål, så kandidaterne hentes ord for ord og de noter, flere ord rammer, kommer øverst. Modellen får kun dem: den slår ikke selv noget op, så et svar kan ikke handle om nogen andens noter. Og svaret bærer, hvad det står på — en model, der siger "det gjorde du den 14.", er kun brugbar, hvis man kan slå op i det, den læste det i. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
457 lines
16 KiB
Go
457 lines
16 KiB
Go
// Package jobs runs the work that happens without anybody asking: reminders going
|
|
// out, nightly backups, and the trash emptying itself after thirty days.
|
|
//
|
|
// All of it lives in one goroutine per job inside the same process. There is no
|
|
// queue and no worker pool, because there is nothing here that would benefit from
|
|
// one — a homelab instance has a handful of reminders an hour, and a second moving
|
|
// part would be more likely to fail than the thing it was protecting.
|
|
package jobs
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/config"
|
|
"github.com/kristianwind/verdande/internal/mail"
|
|
"github.com/kristianwind/verdande/internal/realtime"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
type Runner struct {
|
|
cfg *config.Config
|
|
db *store.DB
|
|
log *slog.Logger
|
|
mail *mail.Sender
|
|
hub *realtime.Hub
|
|
|
|
// SyncGmail is supplied by the HTTP layer, which owns the token refresh and
|
|
// the task creation. Passed in as a function rather than importing it, because
|
|
// the other direction would be a cycle: httpapi already depends on jobs being
|
|
// startable from main.
|
|
SyncGmail func(ctx context.Context, user *store.User) (int, error)
|
|
|
|
// SyncMailbox reads one IMAP mailbox. Supplied by the HTTP layer for the same
|
|
// reason as SyncGmail: it owns turning a message into a task.
|
|
SyncMailbox func(ctx context.Context, user *store.User, m *store.Mailbox) (int, error)
|
|
|
|
// SyncCalendars refreshes one person's connected calendars. Supplied the same
|
|
// way and for the same reason: the HTTP layer owns the token refresh.
|
|
SyncCalendars func(ctx context.Context, user *store.User) (int, error)
|
|
|
|
// Push delivers a notification to somebody's devices. Supplied by the HTTP
|
|
// layer, which owns the VAPID keys and the subscription list.
|
|
Push func(userID, title, body, projectID string)
|
|
|
|
// DailyPlans sender morgenens plan til dem, der har bedt om en. Sat af
|
|
// serveren af samme grund som SendBeacon: opsætningen, tidszonen og
|
|
// AI-nøglen ligger derovre, og jobbet skal ikke kende nogen af delene.
|
|
DailyPlans func(ctx context.Context) error
|
|
|
|
// SendBeacon reports this installation to the collector, if it is switched on
|
|
// and a day has passed. Supplied by the HTTP layer for the same reason as the
|
|
// syncs: that is where the instance settings and the version live.
|
|
SendBeacon func(ctx context.Context) error
|
|
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
func New(cfg *config.Config, db *store.DB, log *slog.Logger, sender *mail.Sender, hub *realtime.Hub) *Runner {
|
|
return &Runner{cfg: cfg, db: db, log: log, mail: sender, hub: hub}
|
|
}
|
|
|
|
// Start launches every background job. They stop when ctx is cancelled, and Wait
|
|
// blocks until each has finished the pass it was in the middle of — so a shutdown
|
|
// during a backup does not leave half a file behind.
|
|
func (r *Runner) Start(ctx context.Context) {
|
|
r.every(ctx, "reminders", time.Minute, r.sendDueReminders)
|
|
// Nightly work is checked hourly rather than scheduled at a wall-clock time:
|
|
// a container that was asleep or restarting at 03:00 would otherwise skip a
|
|
// day entirely, and this way it catches up on the next hour it is awake.
|
|
r.every(ctx, "backup", time.Hour, r.nightlyBackup)
|
|
r.every(ctx, "trash", time.Hour, r.emptyTrash)
|
|
r.every(ctx, "sessions", 6*time.Hour, r.purgeSessions)
|
|
r.every(ctx, "errors", 12*time.Hour, r.purgeErrors)
|
|
// Ten minutes is a compromise: Gmail's push notifications would be instant but
|
|
// need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google
|
|
// account for a to-do app. Ten minutes is fast enough for something somebody
|
|
// starred and slow enough not to look like abuse.
|
|
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
|
|
// Fifteen minutes rather than ten. A calendar is not a mailbox: nothing here
|
|
// becomes a task, so being a quarter of an hour behind on a meeting next
|
|
// Tuesday costs nothing — and the "fetch now" button covers the one case where
|
|
// it matters, which is somebody who has just moved something and is looking at
|
|
// the grid to check. A person who has just connected does not wait for this
|
|
// either; connecting fetches the list, and the first sync is one click away.
|
|
r.every(ctx, "calendars", 15*time.Minute, r.syncCalendars)
|
|
// Checked hourly, sent at most daily — the same shape as the backup, and for
|
|
// the same reason: a container asleep at the scheduled minute would otherwise
|
|
// skip the day. SendBeacon decides whether a day has passed.
|
|
r.every(ctx, "beacon", time.Hour, r.sendBeacon)
|
|
// Dagens plan. Hver time, sendt højst én gang om dagen pr. person — samme
|
|
// form som ovenstående, og af samme grund. Timen er personens egen: klokken
|
|
// syv er syv forskellige steder.
|
|
r.every(ctx, "dailyplan", time.Hour, r.dailyPlans)
|
|
}
|
|
|
|
func (r *Runner) Wait() { r.wg.Wait() }
|
|
|
|
func (r *Runner) every(ctx context.Context, name string, interval time.Duration, fn func(context.Context) error) {
|
|
r.wg.Add(1)
|
|
go func() {
|
|
defer r.wg.Done()
|
|
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
// A job that panics must not take the process down with it, and
|
|
// must not stop the other jobs either.
|
|
func() {
|
|
defer func() {
|
|
if p := recover(); p != nil {
|
|
r.log.Error("background job panicked", "job", name, "panic", p)
|
|
}
|
|
}()
|
|
if err := fn(ctx); err != nil {
|
|
r.log.Error("background job failed", "job", name, "err", err)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (r *Runner) dailyPlans(ctx context.Context) error {
|
|
if r.DailyPlans == nil {
|
|
return nil
|
|
}
|
|
return r.DailyPlans(ctx)
|
|
}
|
|
|
|
// sendBeacon reports that this installation exists, at most once a day.
|
|
//
|
|
// Every decision about what is sent, and why this is on by default rather than
|
|
// compulsory, lives in internal/beacon. This is only the clock.
|
|
func (r *Runner) sendBeacon(ctx context.Context) error {
|
|
if r.SendBeacon == nil {
|
|
return nil
|
|
}
|
|
return r.SendBeacon(ctx)
|
|
}
|
|
|
|
// --- reminders ------------------------------------------------------------------
|
|
|
|
// sendDueReminders delivers everything that has come due since the last pass.
|
|
//
|
|
// Reminders are marked sent before they are delivered, not after. A reminder that
|
|
// goes out and is not recorded will go out again every minute for ever, which is
|
|
// far worse than one that is recorded and then fails to send — the second is a
|
|
// missed notification, the first is somebody's inbox filling up overnight.
|
|
func (r *Runner) sendDueReminders(ctx context.Context) error {
|
|
due, err := r.db.DueReminders(ctx, time.Now())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, rem := range due {
|
|
if err := r.db.MarkReminderSent(ctx, rem.ID); err != nil {
|
|
r.log.Warn("mark reminder sent", "err", err, "reminder", rem.ID)
|
|
continue
|
|
}
|
|
|
|
// In-app first: it is instant, it cannot fail, and for somebody with the
|
|
// tab open it is the only one that matters.
|
|
r.hub.PublishToUser(rem.UserID, "reminder", map[string]any{
|
|
"task_id": rem.TaskID,
|
|
"content": rem.TaskContent,
|
|
"due": rem.RemindAt.Format(time.RFC3339),
|
|
})
|
|
|
|
// And to the devices that asked for it. This was missing: reminders went out
|
|
// in-app and by mail, so somebody with the tab closed and no mail configured
|
|
// got a notification that never arrived — which is the case a reminder is
|
|
// for. Notifications from other people's actions did push; only the timed
|
|
// ones did not, and the interface said "on for this device" either way.
|
|
if r.Push != nil {
|
|
r.Push(rem.UserID, rem.TaskContent, reminderBody(rem.RemindAt), rem.ProjectID)
|
|
}
|
|
|
|
if r.mail.Configured() && rem.UserEmail != "" {
|
|
link := r.cfg.BaseURL + "/projekt/" + rem.ProjectID
|
|
if err := r.mail.SendReminder(ctx, rem.UserEmail, rem.UserName, rem.TaskContent, link); err != nil {
|
|
r.log.Error("send reminder", "err", err, "reminder", rem.ID)
|
|
}
|
|
}
|
|
}
|
|
if len(due) > 0 {
|
|
r.log.Info("reminders sent", "count", len(due))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reminderBody is the line under the task's title. The time rather than the date:
|
|
// a reminder arrives when it is due, so the day is today and saying so is noise.
|
|
func reminderBody(at time.Time) string {
|
|
return at.Format("15:04")
|
|
}
|
|
|
|
// --- backups ---------------------------------------------------------------------
|
|
|
|
const (
|
|
backupKeepDays = 14
|
|
backupPrefix = "verdande-"
|
|
)
|
|
|
|
// nightlyBackup snapshots the database once a day.
|
|
//
|
|
// It uses SQLite's own VACUUM INTO rather than copying the file. A plain copy of a
|
|
// live WAL-mode database can miss the most recent writes or catch a page mid-update;
|
|
// VACUUM INTO produces a consistent, already-compacted database without blocking
|
|
// writers for the duration.
|
|
//
|
|
// Attachments are deliberately not copied. They are content-addressed files that
|
|
// only ever get added, so duplicating gigabytes of them every night to hold
|
|
// fourteen identical copies would fill the volume it is meant to protect. The
|
|
// README says to back up the whole /data directory; this covers the one part that
|
|
// cannot simply be copied.
|
|
func (r *Runner) nightlyBackup(ctx context.Context) error {
|
|
last, err := r.db.LastBackupAt(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if time.Since(last) < 23*time.Hour {
|
|
return nil
|
|
}
|
|
return Backup(ctx, r.cfg, r.db, r.log)
|
|
}
|
|
|
|
// Backup takes one snapshot now, records the run and rotates the old files.
|
|
//
|
|
// Exported and free of the Runner, because the settings page can ask for one too:
|
|
// waiting until tonight to find out whether backups work at all is how somebody
|
|
// discovers on the day they need one that the volume has been read-only for a
|
|
// month. The nightly job is this function behind a "has one run today" check; the
|
|
// button is this function without it.
|
|
//
|
|
// It uses SQLite's own VACUUM INTO rather than copying the file. A plain copy of a
|
|
// live WAL-mode database can miss the most recent writes or catch a page
|
|
// mid-update; VACUUM INTO produces a consistent, already-compacted database
|
|
// without blocking writers for the duration.
|
|
func Backup(ctx context.Context, cfg *config.Config, db *store.DB, log *slog.Logger) error {
|
|
started := time.Now()
|
|
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
|
|
path := filepath.Join(cfg.BackupsDir(), name)
|
|
|
|
if err := os.MkdirAll(cfg.BackupsDir(), 0o755); err != nil {
|
|
return fmt.Errorf("backups dir: %w", err)
|
|
}
|
|
|
|
runID, err := db.StartBackupRun(ctx, started)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := db.Snapshot(ctx, path); err != nil {
|
|
// A failed attempt can leave a partial file, which would then look like a
|
|
// backup to anybody reading the directory.
|
|
os.Remove(path)
|
|
if recErr := db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
|
|
log.Warn("record backup failure", "err", recErr)
|
|
}
|
|
return fmt.Errorf("snapshot: %w", err)
|
|
}
|
|
|
|
var size int64
|
|
if info, err := os.Stat(path); err == nil {
|
|
size = info.Size()
|
|
}
|
|
if err := db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
|
|
log.Warn("record backup", "err", err)
|
|
}
|
|
log.Info("backup written", "path", path, "bytes", size,
|
|
"took", time.Since(started).Round(time.Millisecond).String())
|
|
|
|
return rotateBackups(cfg, log)
|
|
}
|
|
|
|
// rotateBackups keeps the most recent fourteen and deletes the rest.
|
|
//
|
|
// Counted by file, not by age: a container that was off for a month would
|
|
// otherwise come back, find every backup older than fourteen days, and delete all
|
|
// of them — leaving none at exactly the moment they were most likely to be needed.
|
|
func rotateBackups(cfg *config.Config, log *slog.Logger) error {
|
|
entries, err := os.ReadDir(cfg.BackupsDir())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var backups []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() && strings.HasPrefix(e.Name(), backupPrefix) && strings.HasSuffix(e.Name(), ".db") {
|
|
backups = append(backups, e.Name())
|
|
}
|
|
}
|
|
// The filenames are timestamps, so sorting them sorts by age.
|
|
sort.Sort(sort.Reverse(sort.StringSlice(backups)))
|
|
|
|
for _, name := range backups[min(len(backups), backupKeepDays):] {
|
|
path := filepath.Join(cfg.BackupsDir(), name)
|
|
if err := os.Remove(path); err != nil {
|
|
log.Warn("remove old backup", "err", err, "path", path)
|
|
continue
|
|
}
|
|
log.Info("old backup removed", "path", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- gmail ---------------------------------------------------------------------------
|
|
|
|
// syncMailboxes polls every connected mailbox, Gmail and IMAP alike.
|
|
//
|
|
// One sweep rather than two, now that both live in the same table. Nobody is
|
|
// waiting, so no budget of its own — the request handler has one because a person
|
|
// is watching a spinner. A host that has gone quiet costs this run its own dial
|
|
// timeout and nothing else: the loop moves on, per mailbox and per person, so one
|
|
// bad password does not stop everybody else's mail.
|
|
func (r *Runner) syncMailboxes(ctx context.Context) error {
|
|
if r.SyncMailbox == nil {
|
|
return nil
|
|
}
|
|
userIDs, err := r.db.UsersWithMailboxes(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, id := range userIDs {
|
|
user, err := r.db.UserByID(ctx, id)
|
|
if err != nil || user == nil {
|
|
continue
|
|
}
|
|
boxes, err := r.db.Mailboxes(ctx, id)
|
|
if err != nil {
|
|
r.log.Warn("list mailboxes", "err", err, "user", id)
|
|
continue
|
|
}
|
|
for i := range boxes {
|
|
var created int
|
|
var err error
|
|
// Gmail is fetched over HTTP with a token, IMAP over a connection with a
|
|
// password. Which one is a property of the row, not of the loop.
|
|
if boxes[i].Kind == "gmail" {
|
|
if r.SyncGmail == nil {
|
|
continue
|
|
}
|
|
created, err = r.SyncGmail(ctx, user)
|
|
} else {
|
|
created, err = r.SyncMailbox(ctx, user, &boxes[i])
|
|
}
|
|
if err != nil {
|
|
r.log.Warn("mailbox sync", "err", err, "user", id,
|
|
"kind", boxes[i].Kind, "mailbox", boxes[i].ID)
|
|
continue
|
|
}
|
|
if created > 0 {
|
|
r.log.Info("tasks created from a mailbox", "user", id,
|
|
"kind", boxes[i].Kind, "count", created)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- calendars ------------------------------------------------------------------------
|
|
|
|
// syncCalendars refreshes everybody's connected calendars.
|
|
//
|
|
// Its own sweep rather than a branch inside the mailbox one. They share nothing
|
|
// past the word "poll": that loop asks each mailbox how far it has read and makes
|
|
// tasks, this one replaces a window of somebody else's events. Folding them
|
|
// together would mean one interval for two questions that do not want the same
|
|
// answer, and a Google that is down would count against a mailbox that is not.
|
|
//
|
|
// Nobody is waiting, so no budget of its own — the request handler has one because
|
|
// a person is watching a spinner. One account that has gone quiet costs this run
|
|
// its own timeout and nothing else: the loop moves on per person.
|
|
func (r *Runner) syncCalendars(ctx context.Context) error {
|
|
if r.SyncCalendars == nil {
|
|
return nil
|
|
}
|
|
userIDs, err := r.db.UsersWithCalendars(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, id := range userIDs {
|
|
user, err := r.db.UserByID(ctx, id)
|
|
if err != nil || user == nil {
|
|
continue
|
|
}
|
|
written, err := r.SyncCalendars(ctx, user)
|
|
if err != nil {
|
|
r.log.Warn("calendar sync", "err", err, "user", id)
|
|
continue
|
|
}
|
|
if written > 0 {
|
|
r.log.Info("calendar refreshed", "user", id, "events", written)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- housekeeping -----------------------------------------------------------------
|
|
|
|
func (r *Runner) emptyTrash(ctx context.Context) error {
|
|
n, err := r.db.PurgeTrash(ctx, r.cfg.TrashRetention)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
r.log.Info("trash emptied", "rows", n)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// purgeErrors keeps the diagnostic bounded. Thirty days is long enough to answer
|
|
// "what happened last week" and short enough that a fault loop cannot fill the
|
|
// volume the tasks live on.
|
|
func (r *Runner) purgeErrors(ctx context.Context) error {
|
|
n, err := r.db.PurgeOldErrors(ctx, 30*24*time.Hour)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
r.log.Debug("old error rows removed", "count", n)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Runner) purgeSessions(ctx context.Context) error {
|
|
n, err := r.db.PurgeExpiredSessions(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
r.log.Debug("expired sessions removed", "count", n)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RunBackupNow is the Runner's way in, for the tests that already hold one.
|
|
//
|
|
// It was a second copy of Backup — written when the button that would call it did
|
|
// not exist yet — and the two had already drifted: this one never created the
|
|
// backups directory, and logged nothing. One implementation, so a fix to the
|
|
// nightly path is a fix to the button as well.
|
|
func (r *Runner) RunBackupNow(ctx context.Context) error {
|
|
return Backup(ctx, r.cfg, r.db, r.log)
|
|
}
|