* Recompute next_run_at from now. Called after every run and on enable. * * Returns true if this caller successfully advanced next_run_at, false if it * lost a race (another process/tick already advanced the same row). The * advance is atomic: the UPDATE is conditional on the previously-
(scheduleId: string)
| 221 | * Callers on the run path MUST treat a false result as "do not fire". |
| 222 | */ |
| 223 | recomputeNext(scheduleId: string): boolean { |
| 224 | const e = this.get(scheduleId); |
| 225 | if (!e) return false; |
| 226 | let next: Date | null = null; |
| 227 | try { |
| 228 | const parsed = parseCron(e.cron); |
| 229 | next = nextAfter(parsed, new Date()); |
| 230 | } catch (err: any) { |
| 231 | // Invalid cron — leave next_run_at null so the tick loop ignores it, but |
| 232 | // warn so the user can learn why this schedule never fires instead of it |
| 233 | // being silently disabled. |
| 234 | logger.warn('Schedule has an invalid cron expression; it will never run until fixed', { |
| 235 | id: e.id, |
| 236 | name: e.name, |
| 237 | cron: e.cron, |
| 238 | err: err?.message ?? String(err), |
| 239 | }); |
| 240 | } |
| 241 | // Atomic claim: only advance if next_run_at still holds the value we read. |
| 242 | // A losing concurrent tick sees changes === 0 and must not fire. |
| 243 | const prev = e.next_run_at ?? null; |
| 244 | const res = prev === null |
| 245 | ? this.db.prepare(`UPDATE schedules SET next_run_at = ? WHERE id = ? AND next_run_at IS NULL`) |
| 246 | .run(next?.toISOString() ?? null, scheduleId) |
| 247 | : this.db.prepare(`UPDATE schedules SET next_run_at = ? WHERE id = ? AND next_run_at = ?`) |
| 248 | .run(next?.toISOString() ?? null, scheduleId, prev); |
| 249 | return res.changes === 1; |
| 250 | } |
| 251 | |
| 252 | /** Attach a parsed trust receipt (JSON) to a finished run — the audit record. Best-effort. */ |
| 253 | attachReceipt(runId: number, receiptJson: string): void { |