diff --git a/internal/imap/scheduler.go b/internal/imap/scheduler.go index a406303..86ecf65 100644 --- a/internal/imap/scheduler.go +++ b/internal/imap/scheduler.go @@ -81,6 +81,12 @@ func (s *Scheduler) SetAuditLogger(a *audit.Logger) { // Start launches the background scheduler goroutine. // Call Stop to shut it down gracefully. func (s *Scheduler) Start() { + if n, err := s.store.ClearStaleSyncRunning(context.Background()); err != nil { + s.logger.Error("imap scheduler: clear stale sync_running failed", "err", err) + } else if n > 0 { + s.logger.Warn("imap scheduler: cleared stale sync_running flags left over from a prior crash/kill", "accounts", n) + } + ctx, cancel := context.WithCancel(context.Background()) s.cancel = cancel diff --git a/internal/imap/store.go b/internal/imap/store.go index e0bda4f..9958450 100644 --- a/internal/imap/store.go +++ b/internal/imap/store.go @@ -349,6 +349,21 @@ func (s *Store) UpdateSyncInterval(ctx context.Context, id int64, intervalMin in return nil } +// ClearStaleSyncRunning resets sync_running=true for every account back to +// false. Called once at process startup: sync_running is only ever set on +// this same process, so any row still marked running is a leftover from a +// prior process that crashed/was killed mid-sync (the defer in +// runSyncWithRetry never ran) — without this, the scheduler would skip that +// account forever (checkAccounts skips accounts with SyncRunning=true). +// Returns the number of accounts reset. +func (s *Store) ClearStaleSyncRunning(ctx context.Context) (int, error) { + tag, err := s.pool.Exec(ctx, `UPDATE imap_accounts SET sync_running = false WHERE sync_running = true`) + if err != nil { + return 0, fmt.Errorf("imap store: clear stale sync running: %w", err) + } + return int(tag.RowsAffected()), nil +} + // SetSyncRunning marks whether a background sync is currently active for an account. func (s *Store) SetSyncRunning(ctx context.Context, id int64, running bool) error { _, err := s.pool.Exec(ctx,