Adding a scheduled job
Adding a scheduled (cron-style) job touches three places. The contract is intentionally split so the wire-name catalogue stays small, the descriptors live with the domain that owns them, and processors stay close to the worker that runs them.
The three places
Section titled “The three places”packages/business/jobs/src/job-names.ts— add the string name to theJOB_NAMESmap. This is the wire-contract identifier BullMQ uses; renaming it requires a coordinated rolling deploy, so pick well.packages/business/jobs/src/scheduled-jobs/my-new-job.ts— the descriptor: the cron expression and, when the job needs it, the overlap lock. It is a plain object literal typed asScheduledJobDescriptor, not a call to a factory helper. Imports the job name fromjob-names.ts.apps/backend/worker/src/processors/my-new-job.ts— the processor: a@Service()class extendingScheduledJobProcessorthat names its descriptor and implementshandle.
The worker reads the descriptor registry at boot and reconciles it
with BullMQ via JobScheduler.upsertAll, which also removes
schedules whose descriptor has since been deleted. There is no
separate cron container.
The step-by-step
Section titled “The step-by-step”-
Pick a wire-name. Kebab-case, descriptive, stable.
packages/business/jobs/src/job-names.ts export const JOB_NAMES = {// ...existing...myNewJob: 'my-new-job',} as const; -
Write the descriptor.
packages/business/jobs/src/scheduled-jobs/my-new-job.ts import type { ScheduledJobStepDescriptor } from '@scani/queue';import { JOB_NAMES } from '../job-names';// A step of the nightly group: no cron, the group owns the schedule.export const MY_NEW_JOB_SCHEDULE: ScheduledJobStepDescriptor = {name: JOB_NAMES.myNewJob,lockName: JOB_NAMES.myNewJob, // opt into overlap protection};ScheduledJobStepDescriptor(packages/infra/queue/src/core/job-descriptor.ts) hasnameplus the optionallockNameandjitterMs. A job that needs a schedule of its own usesScheduledJobDescriptor, which addscron,timezoneanddefaultOpts; since SC-1688 onlyactive-pricingandweekly-digestdo. It has noretryPolicyand nopayloadSchema— those belong to user-initiated jobs, and Retry policies below says where. -
Put it in a group. Add a
{ name: JOB_NAMES.myNewJob }line to its group inpackages/business/jobs/src/scheduled-jobs/groups.ts, at the place its dependencies put it, and add the descriptor toSCHEDULED_JOB_STEPSin that directory’sindex.ts. A step can takerunOn(a weekly step in the nightly group) andtimeoutMs(default 30 minutes). -
Write the processor. Extend
ScheduledJobProcessor. The base class appliesjitterMs, takes the lock when the descriptor names one, and records the run’s heartbeat — sohandleonly holds the work.apps/backend/worker/src/processors/my-new-job.ts import { MyService } from '@scani/domain';import { MY_NEW_JOB_SCHEDULE } from '@scani/jobs';import { ScheduledJobProcessor } from '@scani/queue';import { Container, Service } from 'typedi';@Service()export class MyNewJobProcessor extends ScheduledJobProcessor {readonly descriptor = MY_NEW_JOB_SCHEDULE;protected async handle(): Promise<void> {await Container.get(MyService).run();}} -
Register the processor in the worker’s single declarative list. There is no
processors/index.tsbarrel to re-export from:apps/backend/worker/src/index.ts import { MyNewJobProcessor } from './processors/my-new-job';function resolveProcessors() {return [// ...existing...Container.get(MyNewJobProcessor),];} -
Write tests for the underlying service. Don’t test the base class itself — it is shared infra and is tested in
packages/infra/queue/tests/consumer/scheduled-job-processor.test.ts. -
Document it in Job catalogue with the schedule, what it does, and what it depends on.
Overlap protection is opt-in, and there are two mechanisms
Section titled “Overlap protection is opt-in, and there are two mechanisms”Set lockName and ScheduledJobProcessor.process wraps
handle() in a Postgres advisory lock (PostgresJobLock,
packages/business/jobs/src/infrastructure/postgres-job-lock.ts).
A second fire is skipped and logged rather than raced, so two
worker pods on the same cron tick will not both run it. With no
lock implementation bound to the container the job runs
unlocked — that fall-through is deliberate, and it is the dev
and self-host path.
Leave lockName unset and overlap is tolerated by design,
not prevented. The reconcile-* sweepers do this on purpose: they
are idempotent re-scans, BullMQ’s deterministic job ID dedupes the
duplicate enqueue, and jitterMs spreads replicas that would
otherwise fire on the same wallclock second.
apps/backend/worker/src/lib/cron-lock.ts is a third thing and is
not this path: a worker-local logging wrapper over the same
primitive, with a single caller, and that caller is a
user-initiated job rather than a scheduled one. Don’t reach for
it when adding a scheduled job.
Full rationale in Why BullMQ + Postgres advisory locks.
Retry policies
Section titled “Retry policies”A scheduled job does not name one. ScheduledJobDescriptor has
no retryPolicy field. The named retry shapes are for
user-initiated jobs, whose UserJobDescriptor carries the
defaultOpts BullMQ reads.
They live in packages/business/jobs/src/retry-policies.ts and are
JobsOptions fragments rather than strings — RETRY_NONE,
RETRY_FAST, RETRY_EXTERNAL, RETRY_HEAVY. A user job spreads
one:
export const WALLET_IMPORT: UserJobDescriptor<WalletImportJob> = { name: JOB_NAMES.walletImport, schema: walletImportSchema, defaultOpts: { ...RETRY_EXTERNAL, removeOnComplete: 100, removeOnFail: 500, }, // computeJobId / summarizePayload …};A scheduled descriptor may set defaultOpts if it genuinely
needs BullMQ options, but none does today. A failing step is
retried inside its group’s run (3 attempts, 5s then 10s apart);
after that it waits for the group’s next run, and the steps after
it run regardless.
Schedule conventions
Section titled “Schedule conventions”The current schedule allocations are documented in Job catalogue. A heavy nightly job goes in the nightly group at the point its dependencies allow; the step order, not the clock, is what orders the chain.
User-initiated jobs (not the same thing)
Section titled “User-initiated jobs (not the same thing)”A user-initiated job (screenshot-parse, exchange-import,
wallet-import, …) lives under
packages/business/jobs/src/user-jobs/ and is enqueued by the api
in response to a user action. It carries a payload schema and a
retry policy — neither of which a scheduled descriptor has — and
each enqueue computes its own job ID. Different contract, similar
shape.