Skip to main content
Scheduler fires recurring ticks from config; each tick runs a schedule handler (like a queue consumer) that may enqueue work or run light logic via 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 one consumer 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

  1. Add a value to ScheduleName and REGISTERED_SCHEDULE_NAMES in apps/backend/src/tools/scheduler/scheduleName.ts.
  2. Add { name, pattern, tz? } to tools.scheduler.schedules in env config (development.ts, staging.ts, production.ts). Pattern must be a 6-field cron string (e.g. 0 15 3 * * *). See cronPatternSchema in apps/shared/src/scheduler/schema.ts.
  3. Implement a handler in apps/backend/src/tools/scheduler/schedules/<name>.ts — (tick, tools) => Promise<void | boolean | string>.
  4. Register lazy loader in apps/backend/src/tools/scheduler/scheduleRegistry.ts.
  5. If the handler enqueues, target queue must exist in tools.queue.queues and consumerRegistry.ts.
  6. Restart the consumer process (reconcile runs on startup).
  7. Run make test module=backend path=apps/backend/src/tools/scheduler.
Turning a schedule off: remove its entry from 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 receive scheduleName, 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.client is bullmq, 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 pattern or tz in 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 consumer horizontally.

What’s next?