Release / Build and publish image (push) Failing after 9m58s
Læs bredt, skriv snævert — decision 5 i planen, og kun den første halvdel: kalenderen læses, og der skrives ingenting tilbage. **OAuth-blokeringen er reel og blev ikke gået udenom.** En klient registreret som *Internal* tager kun konti i sit eget workspace — også til kalendere, ikke kun Gmail. Andreas' private konto bliver afvist med `org_internal`, og det er klienten, der virker efter hensigten, ikke en fejl. Fladen siger det nu med ord, man kan handle på, frem for at vise Googles egen skærm, der ikke gør. Vejen udenom, som ikke er bygget: kalenderens egen hemmelige ICS-adresse. Ingen OAuth, virker for enhver konto, og `internal/ics` findes allerede. Det er det næste stykke, og det eneste, der låser den private konto op. `internal/google` er flyttet ud af `internal/gmail`, fordi de to er det samme flow, der er uenige om én streng. Egen callback-adresse frem for at dele Gmails: én callback, der finder ud af hvilken token-butik den skal skrive i, går galt ved at overskrive en Gmail-forbindelse, der virkede. Egne tabeller frem for `mailboxes`. `trigger_kind`, `seen` og `last_uid` svarer alle på "hvad gør en besked til en opgave", og ingenting her bliver til en opgave; én konto har mange kalendere, og `mailboxes` har ikke plads til en en-til-mange. Afgørende i praksis: `mailboxes.kind` har en CHECK, og SQLite kan ikke ændre en — en tredje værdi koster den tabelombygning, som migration 0021 skulle igennem. Begivenheder erstattes pr. kalender pr. vindue frem for at flettes med et sync-token: et vindue, der slettes og skrives forfra, kan ikke komme ud af trit, og et sync-token er endnu en ting, der skal være rigtig for altid, for at gitteret ikke stille viser noget forkert. `CalendarView` er udvidet, ikke kopieret. En begivenhed er et fyldt bånd, hvor en opgave er et kort med en farvet kant, så de to læses forskelligt før et ord er læst — og den kan ikke trækkes og er aldrig en knap: der er ikke noget at gøre færdigt ved en andens møde. To værn på det, der kommer fra Google: farven skal matche et hex-mønster, før den må stå i en style, og linket bliver kun til et href, hvis det begynder med https. Konflikten i jobs.go var to nye jobs skrevet på samme linje. Begge skal med. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
441 lines
16 KiB
Go
441 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)
|
|
|
|
// 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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// 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)
|
|
}
|