package signals import ( "context" "github.com/andrew/go-computer/internal/db" "log" "sync" "time" ) const ( // pollConcurrency bounds simultaneous mailbox passes; each pass holds one // IMAP login and a 45-second budget (review SCALE-01). pollConcurrency = 3 perUserTimeout = 45 * time.Second // maxBackoffShift caps failure backoff at 2^4 ticks (~80 minutes). maxBackoffShift = 4 ) // Scheduler polls users with enabled signal sources. Review SCALE-01: // bounded concurrency, per-user exponential backoff on consecutive failures, // logged outcomes (counts only — never credentials or mail content), and a // database-backed advisory lease so multiple app instances never poll the // same user simultaneously. Database deduplication would hide duplicate // records but not the duplicated external work the lease prevents. type Scheduler struct { mu sync.Mutex failures map[string]int tick int } func NewScheduler() *Scheduler { return &Scheduler{failures: map[string]int{}} } // RunOnce performs a single scheduler pass over every user with enabled // sources. Extracted from RunLoop so tests can drive one tick directly. func RunOnce(ctx context.Context, d *db.DB) { NewScheduler().RunOnce(ctx, d) } func (s *Scheduler) RunOnce(ctx context.Context, d *db.DB) { users, e := db.Many(ctx, d, `SELECT jsonb_build_object('id',"ownerId") FROM "SignalSource" WHERE enabled GROUP BY "ownerId"`) if e != nil { log.Printf("watchers: query failed: %v", e) return } s.mu.Lock() s.tick++ tick := s.tick s.mu.Unlock() sem := make(chan struct{}, pollConcurrency) var wg sync.WaitGroup for _, u := range users { if ctx.Err() != nil { break } id, _ := u["id"].(string) if id == "" || s.skipTick(id, tick) { continue } wg.Add(1) sem <- struct{}{} go func() { defer wg.Done() defer func() { <-sem }() s.checkUser(ctx, d, id) }() } wg.Wait() } // skipTick applies per-user exponential backoff: after n consecutive failed // passes the user is polled on every 2^n-th tick instead of every tick. func (s *Scheduler) skipTick(id string, tick int) bool { s.mu.Lock() defer s.mu.Unlock() f := s.failures[id] if f == 0 { return false } shift := min(f, maxBackoffShift) return tick%(1< 0 { // Review CR-16: real integration failures (mailbox auth/connection, // search, insert, checkpoint or status writes) must drive backoff — // previously they were recorded per source only and the pass counted // as scheduler success, retrying every tick forever. s.note(id, false) log.Printf("watchers: user %s: %d of %d sources failed", id, res.Failed, res.Sources) return } s.note(id, true) if res.Sources > 0 || res.Created > 0 { log.Printf("watchers: user %s: %d sources checked, %d signals created", id, res.Sources, res.Created) } } func (s *Scheduler) note(id string, ok bool) { s.mu.Lock() defer s.mu.Unlock() if ok { delete(s.failures, id) } else { s.failures[id]++ } } // Browser alerts remain browser-only. Email watchers are checked independently // of an open tab, with source/external-ID deduplication and a durable per-source // UID checkpoint on every run. A slow pass delays the next one because the // ticker drops missed ticks. func RunLoop(ctx context.Context, d *db.DB) { tick := time.NewTicker(5 * time.Minute) defer tick.Stop() s := NewScheduler() s.RunOnce(ctx, d) for { select { case <-ctx.Done(): return case <-tick.C: s.RunOnce(ctx, d) } } }