Back to Cherry Studio

Job & Scheduler — Architecture Overview

docs/references/job-and-scheduler/overview.md

2.0.014.8 KB
Original Source

Job & Scheduler — Architecture Overview

Two independent main-process lifecycle services:

ServiceRolePersistenceDirect consumer
SchedulerService"When to fire a callback" — cron / interval / once. Stateless.NoneJobManager + any module needing simple time scheduling
JobManager"Job lifecycle" — registry, persistence, 6-state machine, dispatch, recoveryjobTable + jobScheduleTableAll background work

Layering rule: SchedulerService is unaware of Jobs. JobManager uses SchedulerService to arm schedules. Business modules pick one based on need:

  • Need cron + persistent observability + retry → register a JobHandler + use jobManager.registerJobSchedule()
  • Need cron only (heartbeat-style, no persistence) → schedulerService.registerSchedule() directly
  • Need recurring service-internal GC / self-check → BaseService.registerInterval (project convention, not SchedulerService)

DB-driven dispatch

jobTable is the single source of truth. Memory state (handlers Map, queues Map, AbortControllers) is a derived view that JobManager rebuilds on every startup.

Each queue has a DispatchQueue instance holding { name, concurrency, mutex }. The dispatch loop (JobManager.dispatch):

  1. Acquire Layer 1 per-queue mutex first
  2. Enter Layer 0 — the synchronous BEGIN IMMEDIATE write transaction (withWriteTx) — second
  3. Inside that one DB transaction:
    • Count queue-active jobs → check queue.concurrency
    • Count globally-running jobs → check globalMaxConcurrency
    • SELECT next pending → UPDATE to running (claim)
  4. The Layer 0 transaction commits, then release the Layer 1 per-queue mutex
  5. Spawn handler.execute outside the lock
  6. Queue a microtask to dispatch the same queue again (fill next slot)

Spawning happens outside the lock — the handler executes for seconds/minutes while new dispatches proceed.

Acquisition order is fixed (Layer 1 mutex, then the Layer 0 write transaction). Layer 0 holds no async lock — it is a synchronous transaction — so Layer 1 is the only mutex in the dispatch path and the two layers cannot deadlock against each other.

Six-state state machine

                  ┌── retry backoff (delayed) ──┐
                  ▼                              │
   enqueue → pending → running → completed       │
                  │       │                      │
                  │       └→ failed ─────────────┘ (if retryable && attempt < max)
                  │       └→ cancelled (terminal)
                  └→ delayed → (scheduledAt ≤ now) → pending

Terminal states (completed / failed / cancelled) are never reopened. Retry re-enters delayed then transitions back to pending when scheduledAt elapses.

Startup Recovery

Startup recovery is JobManager's deferred sweep that reconciles the DB-driven state machine with the freshly booted process. It is service-level business work, not a bootstrap initialization side effect — see onAllReady business work pattern for the framework-level rationale.

Sequence

  1. JobManager.onAllReady() schedules a setTimeout with a 60-second "quiet window" and returns synchronously. LifecycleManager.allReady() is fire-and-forget; bootstrap is not blocked.
  2. After 60 s, the timer callback assigns the recovery flow promise to this._recoveryDone (only if shutdown has not been requested) and the flow starts running.
  3. The flow runs four IO steps in order:
    1. runStartupRecovery(handlers, isJobInFlight) — resets non-terminal rows per handler recovery strategy (abandon / retry / singleton); cancelRequested=true overrides every strategy. Rows the current process is already executing (reported via isJobInFlight, backed by JobManager.inFlightExecuted) are excluded before any strategy, so a job enqueued during the quiet window and still running when the sweep fires is never reset or re-dispatched (#16291).
    2. Resurrect queues — walks distinct (queue, type) pairs over non-terminal rows and ensures a DispatchQueue exists for each. Without this step dispatchAll would iterate an empty queues map and pending rows would wait until the next enqueue.
    3. Catch-up THEN arm — calls detectAndDispatchOverdue(schedules) before armSchedule(schedule) for every enabled schedule. The order is load-bearing: if we armed first, a cron with protect: true could fire its natural calendar concurrently with a catch-up enqueue (protect only blocks overlapping callbacks, not external callers). Sequencing catch-up first guarantees the make-up enqueue lands before croner's first natural fire.
    4. dispatchAll() kicks every per-queue pump so pending rows reset by step 1 start running immediately rather than waiting on the next enqueue.

The 60 s quiet window

The delay (JOB_MANAGER_STARTUP_DELAY_MS = 60_000, hardcoded) gives cold-start IO — DB warm-up, window paints, client bootstrap — time to settle before scheduled work piles on. Tests bypass it via vi.useFakeTimers + advanceTimersByTimeAsync(60_000), then await _recoveryDone.

Shutdown safety — three layers

The flow can be interrupted at any point by onStop. Three mechanisms cooperate:

WindowDefence
Quiet window (timer not yet fired)registerDisposable(() => clearTimeout(handle)) clears the timer during _cleanupDisposables; the callback also re-checks _isShuttingDown so a teardown that races with clearTimeout is still safe.
Flow mid-flightEvery IO step re-checks _isShuttingDown before the next await, returning early on shutdown.
Flow already startedonStop awaits this._recoveryDone before tearing down resources, so the current step finishes gracefully before queues, abort controllers, and disposables are released.

Handler registration timing

Handlers must be registered in the owning service's onInit (see handler-authoring.md — Registration Timing). By the time the 60-second timer fires every consumer has finished onInit / onReady, so runStartupRecovery sees the full handler set. Registering a handler from another service's onAllReady is unsafe: that hook runs in parallel with JobManager's, and any non-terminal job for an unregistered type during recovery gets treated as an orphan and cancelled.

Pause and drain (write quiesce)

Serves backup restore (#16850): after the restore snapshot is taken at time T, any JobManager write to the old live DB fails the fingerprint re-check and wastes the whole restore attempt. pause() stops autonomous writes to avoid that waste; the fingerprint gate stays the correctness backstop. The restore orchestrator must NOT run as a JobManager job — a handler that pauses and drains its own manager deadlocks until timeout.

ts
const hold = jobManager.pause('backup restore')
const verdict = await jobManager.drainInFlight({ timeoutMs: 15_000 })
const clean = verdict.stragglerIds.length === 0 && !verdict.startupRecoveryPending
if (!clean) {
  hold.dispose() // abort path ONLY — give the manager back its autonomy
  return abortRestoreAttempt()
}
await createSnapshot()
// Happy path: NEVER dispose. The release pass writes to the old live DB
// (promotion, markFired, catch-up enqueues) — post-snapshot that fails the
// fingerprint re-check and voids the attempt. The hold stands until the
// process relaunches into the restored DB (a lost hold fails closed).
RuleDetail
No resume()Release = dispose your own hold. Holds are refcounted; the last dispose runs the compensation pass: any outstanding recovery settles FIRST — an internal release barrier keeps autonomous fires/claims frozen until it does (interval chains and croner timers would otherwise resume the moment the holds are gone and race the flow's stale-snapshot catch-up) — then delayed promotion + dispatch, suppressed-once re-arm, croner resume. A lost hold fails closed — paused until relaunch.
Drain preconditionCaller must hold a live pause hold. Without one the verdict is a point-in-time snapshot (warn, no throw) and MUST NOT gate a DB snapshot.
Clean verdictstragglerIds empty and startupRecoveryPending === false. The deferred startup recovery is a JM-internal writer that is not a job, so it gets its own verdict field — never fake ids in stragglerIds. true means the flow is still blocked inside a step; a flow that short-circuited at a step boundary writes nothing more and reports false (the remainder is release's debt).
TimeoutdrainInFlight never rejects. Stragglers are not aborted — an abort settles them as cancelled into the snapshot and they would never re-run after a restore; left running, startup recovery applies the handler strategy. Orchestrator rule: any drain timeout → abort the restore attempt.
No error surfaceNo API throws because of a pause; there is no pause-related error code.

Blocked while paused (autonomous writes): dispatch claims (entry check + post-mutex re-check), schedule fire callbacks (crons are additionally paused at the croner layer so limit quotas survive the window), GC / delayed-promotion ticks, delayed/retry promotion fires, and new startup-recovery steps — a started step (one schedule's onMissed + catch-up enqueue, atomic) runs to completion and is awaited by drain.

Allowed while paused (request-driven): enqueue / enqueueTx (rows land at rest and the snapshot captures them), cancel / cancelMany, schedule mutations, and triggerJobScheduleNow* — forced onto its direct-enqueue fallback (row lands pending + markFired; true still means "row persisted").

Missed cron fires are skipped, not caught up (croner semantics). A suppressed once fire is re-armed on release from the recorded id set — exactly once; never rebuild by scanning "enabled ∧ missing scheduler entry", which also matches historical completed one-shots.

Why DB-driven and not in-memory queue?

We considered BullMQ / bee-queue / better-queue / agenda / graphile-worker / bree etc. and selected this design because:

  • All persistence already in SQLite (no Redis / MongoDB / PostgreSQL dependency)
  • Restart recovery is automatic — memory replays from DB
  • Race safety needs only one mutex pair (Layer 0 + Layer 1) around count → claim
  • No double-source-of-truth bookkeeping (PQueue + DB) and its sync discipline

Throughput: ~200 dispatch/s at single-process better-sqlite3 throughput, well above Cherry Studio's largest scenario (1000+ knowledge bases, each with concurrency=5, never exceeds globalMaxConcurrency=50 simultaneous running jobs).

Strongly-typed JobRegistry

Business modules use TypeScript declaration merging to register type → payload mapping:

typescript
declare module '@main/core/job/jobRegistry' {
  interface JobRegistry {
    'agent.task': AgentTaskPayload
    'knowledge.index-leaf': IndexLeafPayload
  }
}

After this declaration:

  • jobManager.enqueue('agent.task', payload) is compile-time type-checked
  • Renaming a type surfaces every call site via the TypeScript error pipeline
  • Wrong payload shape is a compile error

Transactional enqueue (enqueueTx)

enqueue persists the row on the bare connection — fine when the enqueue is the only write. When a business-state flip and the job INSERT must commit atomically (e.g. mark items deleting and enqueue the purge job), use the transactional variant inside a DbService.withWriteTx callback:

ts
application.get('DbService').withWriteTx((tx) => {
  itemService.setStatusTx(tx, ids, 'deleting') // business write
  return jobManager.enqueueTx(tx, 'my.purge', { ids }) // job INSERT, same tx
})

Post-commit side effects (state publish, dispatch / delayed arming) are deferred one microtask past the synchronous transaction. On rollback the row never existed: the returned handle's finished never resolves, and an idempotency-key unique-index collision aborts the whole caller transaction. See the enqueueTx JSDoc for the full contract.

Transactional schedule mutation (registerJobScheduleTx / updateJobScheduleTx + syncJobScheduleTimerById)

When a schedule row and a related business write must commit atomically, compose the transactional primitives inside a DbService.withWriteTx callback, then sync the timer after the transaction returns:

ts
const { id } = application.get('DbService').withWriteTx((tx) => {
  const created = jobManager.registerJobScheduleTx(tx, { type: 'agent.task', ... }) // schedule row
  agentChannelService.replaceTaskSubscriptionsTx(tx, created.id, channelIds) // business write, same tx
  return created
})
jobManager.syncJobScheduleTimerById(id) // post-commit timer sync (create: always; update: when the patch carried trigger/enabled)

The *Tx primitives validate up front (handler, name, trigger semantics → JOB_SCHEDULE_TRIGGER_INVALID) and never touch the timer, so a rollback has zero timer side effects. Timer sync is the caller's explicit post-commit step — enqueueTx's post-commit re-read cannot be reused here because its rollback test ("row absent") only holds for INSERT; an UPDATE rollback would read as committed and re-arm, resetting the interval phase. See the registerJobScheduleTx JSDoc for the full contract.

Renderer-side consumers

The renderer never enqueues, cancels, or otherwise mutates jobs through the DataApi. It only observes job state read-only:

  • useJob(jobId) → current JobSnapshot (status / counters / error / ...). Source: shared cache jobs.state.${id} with GET /jobs/:id as a cold-start fallback.
  • useJobProgress(jobId) → fine-grained progress. Source: shared cache jobs.progress.${id} only.

Triggering a job is owned by the relevant business module in main:

  1. The business service decides the semantics — which job type, what payload, queue, idempotency key, max attempts, timeout.
  2. It calls application.get('JobManager').enqueue(...) directly.
  3. If the renderer needs to initiate the work, the business module exposes a dedicated IPC route (e.g. the knowledge.add_items IpcApi route); the route handler internally calls JobManager.enqueue(...).

Schedule mutations (CRUD / pause / resume / run-now) follow the same pattern: renderer → dedicated IpcApi route (e.g. ai.agent.task.*AgentJobsService) → JobManager schedule APIs; schedule reads stay on the GET-only DataApi.

This keeps JobRegistry's compile-time JobPayloadOf<K> type safety intact and prevents the renderer from depending on JobManager infrastructure details (queue names, retry policies, idempotency keys).

See also