tools.
Why
You need wall-clock triggers (daily cleanup, periodic reconciliation) without duplicating retry/DLQ logic. The scheduler is a thin clock; queues still own heavy execution, retries, and observability when work is enqueued.tools.scheduler and tools.queue are independent. Production may use BullMQ for schedules and SQS for business queues — handlers call tools.queue.sendToQueue when needed.
How it works
On consumer startup,registerSchedules reconciles Redis job schedulers with tools.scheduler.schedules (removes orphans, upserts changed patterns). A tick worker invokes the registered handler for that schedule name.
📖 See also: BullMQ scheduler for Redis client details and config fields.
Scaling
Run exactly oneconsumer replica. Scale vertically (CPU/RAM) only. Multiple replicas would duplicate ticks. Horizontal scaling is not supported until a future design (leader election or dedicated scheduler service).
The API backend service does not register schedules or run tick workers.
Adding a new schedule
- Add a value to
ScheduleNameandREGISTERED_SCHEDULE_NAMESinapps/backend/src/tools/scheduler/scheduleName.ts. - Add
{ name, pattern, tz? }totools.scheduler.schedulesin env config (development.ts,staging.ts,production.ts). Pattern must be a 6-field cron string (e.g.0 15 3 * * *). SeecronPatternSchemainapps/shared/src/scheduler/schema.ts. - Implement a handler in
apps/backend/src/tools/scheduler/schedules/<name>.ts—(tick, tools) => Promise<void | boolean | string>. - Register lazy loader in
apps/backend/src/tools/scheduler/scheduleRegistry.ts. - If the handler enqueues, target queue must exist in
tools.queue.queuesandconsumerRegistry.ts. - Restart the consumer process (reconcile runs on startup).
- Run
make test module=backend path=apps/backend/src/tools/scheduler.
schedules[] (or use schedules: [] when you have no cron work yet). Reconcile drops orphans on the next consumer restart — there is no per-item enabled flag.
Schedule handlers
Handlers mirror queue consumers: they receivescheduleName, firedAt (ISO string), and tools. Use buildEnqueueJobId from shared when enqueueing for idempotency. Return false to fail a test-client runScheduleNow call; thrown errors fail the tick (BullMQ retries).
Prefer enqueueing a sweep job and doing heavy work in an existing queue consumer when possible.
Monitoring
Structured logs and StatsD metrics cover the full path: consumer bootstrap → reconcile → tick worker → handler.
Metric tags use bounded values:
scheduleName from config/registry, action (removed / registered / updated / unchanged), reason (handler_not_found, handler_returned_false, handler_threw). Constants live in apps/shared/src/scheduler/metrics.ts.
- Downstream queues: depth, failures, and DLQ on business queues handlers target.
- BullMQ client: when
tools.scheduler.clientisbullmq, see BullMQ scheduler. - Missed ticks: no automatic backfill of missed windows if consumer or Redis was down.
Test environment
tools.scheduler.client is test (SchedulerClientType.TEST) only in apps/backend/src/configuration/test.ts. That client has no cron and no Redis — call tools.scheduler.runScheduleNow(scheduleName, tools) in Jest to run a handler once. Do not use the test client in dev/staging/prod.
📖 See also: Test scheduler client for behavior, config, and examples.
Gotchas
- Changing
patternortzin config requires a consumer restart so reconcile can upsert the job scheduler. - Removing a schedule from config without restarting leaves the old scheduler in Redis until the next reconcile.
schedules: []is valid — the consumer still starts the tick worker, but no cron jobs are registered until you add entries.- Do not scale
consumerhorizontally.
What’s next?
- BullMQ scheduler client — Redis, job schedulers, config options.
- Test scheduler client —
runScheduleNowfor Jest only. - Queue overview — consumers and deferred
sendToQueue. - Configuration — env and
tools.*config. - Tooling system — loaders and workspace packages.
