2026-08-17 07:05:24 +02:00
|
|
|
// 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
|
|
|
|
|
|
2026-08-17 08:21:51 +02:00
|
|
|
// 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)
|
|
|
|
|
|
2026-08-18 22:15:50 +02:00
|
|
|
// 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)
|
|
|
|
|
|
2026-08-20 07:01:14 +02:00
|
|
|
// 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)
|
|
|
|
|
|
2026-08-19 11:00:48 +02:00
|
|
|
// 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)
|
|
|
|
|
|
2026-09-08 15:05:36 +02:00
|
|
|
// 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
|
|
|
|
|
|
2026-08-20 06:59:50 +02:00
|
|
|
// 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
|
|
|
|
|
|
2026-08-17 07:05:24 +02:00
|
|
|
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)
|
2026-08-17 22:58:02 +02:00
|
|
|
r.every(ctx, "errors", 12*time.Hour, r.purgeErrors)
|
2026-08-17 08:21:51 +02:00
|
|
|
// 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.
|
2026-08-18 22:15:50 +02:00
|
|
|
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
|
2026-08-20 07:01:14 +02:00
|
|
|
// 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)
|
2026-08-20 06:59:50 +02:00
|
|
|
// 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)
|
2026-09-08 15:05:36 +02:00
|
|
|
// 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)
|
2026-08-17 07:05:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 15:05:36 +02:00
|
|
|
func (r *Runner) dailyPlans(ctx context.Context) error {
|
|
|
|
|
if r.DailyPlans == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return r.DailyPlans(ctx)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 06:59:50 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 07:05:24 +02:00
|
|
|
// --- 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),
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-19 11:00:48 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 07:05:24 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 11:00:48 +02:00
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 07:05:24 +02:00
|
|
|
// --- 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
|
|
|
|
|
}
|
2026-08-18 09:04:43 +02:00
|
|
|
return Backup(ctx, r.cfg, r.db, r.log)
|
|
|
|
|
}
|
2026-08-17 07:05:24 +02:00
|
|
|
|
2026-08-18 09:04:43 +02:00
|
|
|
// 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 {
|
2026-08-17 07:05:24 +02:00
|
|
|
started := time.Now()
|
|
|
|
|
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
|
2026-08-18 09:04:43 +02:00
|
|
|
path := filepath.Join(cfg.BackupsDir(), name)
|
2026-08-17 07:05:24 +02:00
|
|
|
|
2026-08-18 09:04:43 +02:00
|
|
|
if err := os.MkdirAll(cfg.BackupsDir(), 0o755); err != nil {
|
|
|
|
|
return fmt.Errorf("backups dir: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
runID, err := db.StartBackupRun(ctx, started)
|
2026-08-17 07:05:24 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 09:04:43 +02:00
|
|
|
if err := db.Snapshot(ctx, path); err != nil {
|
2026-08-17 07:05:24 +02:00
|
|
|
// A failed attempt can leave a partial file, which would then look like a
|
|
|
|
|
// backup to anybody reading the directory.
|
|
|
|
|
os.Remove(path)
|
2026-08-18 09:04:43 +02:00
|
|
|
if recErr := db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
|
|
|
|
|
log.Warn("record backup failure", "err", recErr)
|
2026-08-17 07:05:24 +02:00
|
|
|
}
|
|
|
|
|
return fmt.Errorf("snapshot: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var size int64
|
|
|
|
|
if info, err := os.Stat(path); err == nil {
|
|
|
|
|
size = info.Size()
|
|
|
|
|
}
|
2026-08-18 09:04:43 +02:00
|
|
|
if err := db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
|
|
|
|
|
log.Warn("record backup", "err", err)
|
2026-08-17 07:05:24 +02:00
|
|
|
}
|
2026-08-18 09:04:43 +02:00
|
|
|
log.Info("backup written", "path", path, "bytes", size,
|
2026-08-17 07:05:24 +02:00
|
|
|
"took", time.Since(started).Round(time.Millisecond).String())
|
|
|
|
|
|
2026-08-18 09:04:43 +02:00
|
|
|
return rotateBackups(cfg, log)
|
2026-08-17 07:05:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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.
|
2026-08-18 09:04:43 +02:00
|
|
|
func rotateBackups(cfg *config.Config, log *slog.Logger) error {
|
|
|
|
|
entries, err := os.ReadDir(cfg.BackupsDir())
|
2026-08-17 07:05:24 +02:00
|
|
|
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):] {
|
2026-08-18 09:04:43 +02:00
|
|
|
path := filepath.Join(cfg.BackupsDir(), name)
|
2026-08-17 07:05:24 +02:00
|
|
|
if err := os.Remove(path); err != nil {
|
2026-08-18 09:04:43 +02:00
|
|
|
log.Warn("remove old backup", "err", err, "path", path)
|
2026-08-17 07:05:24 +02:00
|
|
|
continue
|
|
|
|
|
}
|
2026-08-18 09:04:43 +02:00
|
|
|
log.Info("old backup removed", "path", path)
|
2026-08-17 07:05:24 +02:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 08:21:51 +02:00
|
|
|
// --- gmail ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-08-18 22:59:40 +02:00
|
|
|
// syncMailboxes polls every connected mailbox, Gmail and IMAP alike.
|
2026-08-17 08:21:51 +02:00
|
|
|
//
|
2026-08-18 22:59:40 +02:00
|
|
|
// 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.
|
2026-08-18 22:15:50 +02:00
|
|
|
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 {
|
2026-08-18 22:59:40 +02:00
|
|
|
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])
|
|
|
|
|
}
|
2026-08-18 22:15:50 +02:00
|
|
|
if err != nil {
|
2026-08-18 22:59:40 +02:00
|
|
|
r.log.Warn("mailbox sync", "err", err, "user", id,
|
|
|
|
|
"kind", boxes[i].Kind, "mailbox", boxes[i].ID)
|
2026-08-18 22:15:50 +02:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if created > 0 {
|
2026-08-18 22:59:40 +02:00
|
|
|
r.log.Info("tasks created from a mailbox", "user", id,
|
|
|
|
|
"kind", boxes[i].Kind, "count", created)
|
2026-08-18 22:15:50 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 07:01:14 +02:00
|
|
|
// --- 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 07:05:24 +02:00
|
|
|
// --- 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 22:58:02 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 07:05:24 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 09:04:43 +02:00
|
|
|
// 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.
|
2026-08-17 07:05:24 +02:00
|
|
|
func (r *Runner) RunBackupNow(ctx context.Context) error {
|
2026-08-18 09:04:43 +02:00
|
|
|
return Backup(ctx, r.cfg, r.db, r.log)
|
2026-08-17 07:05:24 +02:00
|
|
|
}
|