docs/references/job-and-scheduler/scheduler-usage.md
The project has three mechanisms for "fire a callback periodically / later". Picking the wrong one creates the problem the v2 unification was designed to fix: scattered ad-hoc timers with no observability and no central control.
| Need | Use |
|---|---|
| Persistent recurring background work with state machine, retry, observability | JobManager — registerJobSchedule() |
| Cron / interval / one-shot callback across services, no persistence | SchedulerService — registerSchedule() |
| Service-private GC / self-check / cache sweep, single interval, no observability | BaseService.registerInterval() |
| Timer that reacts to runtime state (protocol heartbeats, streaming keep-alive) | Raw setInterval / setTimeout inside the owning module |
Walk the questions in order. The first "yes" picks the mechanism.
Yes → JobManager. Build a JobHandler, register it, then call jobManager.registerJobSchedule({ type, trigger, jobInputTemplate, catchUpPolicy }).
You get: persistent schedule row in jobScheduleTable; recovery on next process start; retry backoff; user-visible status; DataApi listing; renderer progress hooks. See handler-authoring.md.
Yes → SchedulerService. Call scheduler.registerSchedule(id, trigger, callback). Returns a Disposable.
You get: cron / interval / once triggers in one API; croner's pause/resume/triggerNow for cron schedules; correct timezone via Intl; auto-unrefed timers; no SQLite touches; no persistence.
Re-register in your service's onReady — SchedulerService does not persist anything.
Yes → BaseService.registerInterval(). Already wired into the lifecycle (auto-unref, exception isolation, auto-cleanup on onStop / onDestroy).
Why not SchedulerService here? registerInterval is the project convention for "service-internal implementation detail". It keeps timer ownership inside the service, which is the right scope for GC / self-check — those callbacks are not interesting to anyone else.
Use raw setInterval / setTimeout inside the owning module. The classic example is a protocol heartbeat whose interval is dictated by the server's hello frame and may change on reconnect. SchedulerService's Trigger type is deliberately closed to keep its surface small — heartbeats stay outside it.
This is a conscious design boundary, not a deficiency. The rationale: SchedulerService accepts only declarative triggers (cron / interval / once); a heartbeat whose cadence is dictated by the peer is fundamentally a state-machine concern that belongs to the owning module. Forcing it through SchedulerService would require imperative reschedule APIs that pollute the simple surface.
registerInterval would suffice. SchedulerService is for cross-cutting / cron / user-visible schedules. A service that just needs "sweep every 5 minutes for its own caches" should use registerInterval. SchedulerService adds nothing here and the timer becomes harder to reason about.setInterval for a cron-style cadence. "Once per day at 03:00 in user's timezone" is what croner solves. Don't write a 86_400_000 ms interval — it drifts and ignores DST.jobScheduleTable, owned by JobManager. Need persistence? Build a JobHandler. Hard constraint: SchedulerService is the project's single general-purpose scheduler — every recurring task should reach time via JobManager (persistent) or SchedulerService (transient), never via a private parallel scheduler.onReady if you call it directly.The three triggers (cron / interval / once) differ in how their entry survives across a callback. Both subtleties are observable from inside the callback.
once: self-clean before invokeWhen a once timer fires, SchedulerService removes its schedule entry from the internal map before invoking the callback. The motivating consequence: a callback may re-register a schedule with the same id without conflict.
scheduler.registerSchedule('reminder.foo', { kind: 'once', at: Date.now() + 1000 }, () => {
// Safe: the previous entry was removed before we got here.
scheduler.registerSchedule('reminder.foo', { kind: 'once', at: Date.now() + 5000 }, () => { /* ... */ })
})
If you need "fire once, then maybe fire again later" semantics this is the path. Note that the schedule id is also removed if the callback throws — once is always one-shot from SchedulerService's perspective.
interval: re-arm safety checkAfter each tick, SchedulerService re-checks that the schedule entry is still in its map before re-arming the next interval. Consequence: a callback can synchronously call scheduler.unregisterSchedule(id) and the loop will stop cleanly, without a final stray tick.
scheduler.registerSchedule('healthcheck.foo', { kind: 'interval', ms: 30_000 }, async () => {
if (await everythingIsTerminal()) {
scheduler.unregisterSchedule('healthcheck.foo')
return // No further tick.
}
// ...
})
The check is on map.has(id), not a flag — if you re-register the same id during the callback you re-arm the loop with the new trigger.
JobManager owns these prefixes — third-party callers should avoid them to prevent collisions:
| Prefix | Owner | Purpose |
|---|---|---|
schedule:${scheduleId} | JobManager | Repeatable schedule armed from jobScheduleTable |
job:${jobId} | JobManager | One-shot timer for a delayed job's scheduledAt |
retry:${jobId}:${nextAttempt} | JobManager | Retry backoff timer (attempt number prevents same-jobId collision) |
When a business module uses SchedulerService directly, pick a namespaced id (e.g. myservice.cleanup) to avoid collision with future JobManager prefixes.