Schedules
Synchronize named cron definitions during deployment and let any matching worker fire them safely.
Some work runs on a clock: a nightly report, an hourly sync, a weekly cleanup. The usual costs are a separate scheduler process to deploy and keep alive, and a duplicate-firing problem the moment you run more than one of them. Workhorse has neither. Schedules are cron definitions stored and evaluated in PostgreSQL, declared by your deployment, and offered by the workers you already run. Any number can offer the same namespace while PostgreSQL still creates exactly one job.
Synchronize the namespace
Call queue.syncSchedules during deployment with every definition the namespace should contain.
It is a desired-state call, like a migration: definitions you list are created or updated, and
with pruning enabled — the default — definitions you dropped from the list are disabled.
await queue.syncSchedules("billing-production", [{
name: "invoice-run",
schedule: "0 2 * * *",
timezone: "America/New_York",
job: { type: "invoice.generate", queue: "billing", payload: { scope: "due" } },
}]);Disabled, not deleted: the old definition stays, so jobs it fired in the past still have something to point at.
Each ScheduleDefinition names the occurrence (name, schedule, optional timezone and enabled) and
describes the job every occurrence creates — its type, payload, and optionally queue,
concurrencyKey, maxAttempts, and retryPolicy.
The namespace keeps one service's schedules apart from another's, so two deployments sharing a
database cannot prune each other's definitions. Cron validation happens before the write; one
invalid expression fails the call rather than leaving a partial deployment. Pass
{ prune: false } only when you deliberately want an additive update — the normal deployment path
supplies the complete namespace so code remains the owner of intent.
Let workers offer schedule namespaces
There is no scheduler process and no PostgreSQL extension. A Worker offers the namespaces listed
in WorkerOptions.scheduleNamespaces as part of its normal maintenance cadence. PostgreSQL then
evaluates and fires each due definition through fire_due_schedules_v1, so every SDK uses one
parser and one stored IANA timezone.
const worker = new Worker(queue, { scheduleNamespaces: ["billing-production"] });A worker with an empty list fires nothing. Point at least one worker at each namespace you synchronize, or its schedules sit idle.
The synchronous Python Worker provides the same participation through schedule_namespaces and
schedule_catchup_limit. Go uses WorkerOptions.ScheduleNamespaces and
WorkerOptions.ScheduleCatchupLimit. Every runtime evaluates definitions only when PostgreSQL
grants the maintenance tick, so one process offers the cron work for each pass.
Why competing workers create one job
Ten workers all notice the 02:00 occurrence is due. Each firing writes a durable key built from the namespace, schedule name, and planned occurrence time. The first worker claims the key; every other call converges on the job that already exists. No leader election, no single scheduler — any number of workers can race and the outcome is one job.
Rolling deployments are covered the same way. Every definition carries a revision that increments on change. PostgreSQL reads that revision while evaluating the namespace, then requires the same revision when it reserves the occurrence. If a deployment changes or disables the definition between those operations, the fire becomes a no-op.
If the whole fleet is down, nothing fires. When workers return, catch-up is bounded rather than
replaying every missed occurrence. Schedules provide durable recurring work, not hard real-time
alarms; prefer UTC unless the schedule follows local civil time. If local clocks skip a scheduled time, Workhorse fires after
the clock advances. If clocks repeat a time, Workhorse fires its first occurrence only. Hashed H
fields choose a stable offset across TypeScript, Python, and Go workers, so every runtime fires the
same occurrence. If several cron fields land on one instant, Workhorse creates one occurrence.
What happens after firing
The occurrence creates an ordinary job with the definition's queue, payload, attempt budget, and retry policy. From there the usual rules apply — at-least-once delivery, retries, cancellation. Canceling one fired job does not disable its definition; tomorrow's occurrence still runs. Disable future occurrences through the next synchronization.
Three related calls round out the API:
admin.schedules(namespaces)reads stored definitions and their last occurrence, for operator tools.admin.runTaskNow(jobId, audit)releases one scheduled job immediately with the actor, reason, and request identity used by operator controls.queue.fireSchedule(namespace, name, revision, occurrenceAt)exposes the revision-fenced firing operation itself, for tests and controlled integrations.
Next
- Workers — run the process that offers schedule namespaces
- Retries — control failures of each fired job
- Cancellation — stop one occurrence without changing the schedule
Exact reconciliation, cron, revision, and occurrence semantics: architecture reference.