zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Operations and deployment

Task SchedulingSupported

Scale-out is the first decision#

Install the selected background-work capability before using schedules:

yarn add @zmdb/jobs@1.0.0-beta.2

Scheduling composes through jobsExtension; it is not installed or re-exported by the default @zmdb/core package.

Three replicas run an in-process timer three times. That is correct for a local cache refresh and a billing defect for a cluster-wide job, so every schedule must choose explicitly:

runs valueBehaviourTypical use
once-per-replicaevery application instance runs the tasklocal cache or connection state
once-per-clusterone instance acquires a renewable per-task leasebilling, cleanup, reconciliation

There is no default. Constructing a scheduler with a once-per-cluster task and no leases throws before the loop starts; it never silently degrades to one run per replica.

Per-replica timers need no database provider. For cluster work, explicitly select @zmdb/jobs-sqlite or @zmdb/jobs-postgres with pg, apply that provider's migrations, and pass its store as leases. Use storage shared by the participating replicas; separate memory stores cannot coordinate a cluster. The queues provider guide gives the exact installation commands and resource owners. Portable jobs does not select a database automatically.

LeaseStore is structural, so the application can also implement it over the database or coordination service it already operates:

interface LeaseStore {
  acquire(key: string, holder: string, ttlMs: number): Promise<boolean>;
  renew(key: string, holder: string, ttlMs: number): Promise<boolean>;
  release(key: string, holder: string): Promise<void>;
}

The scheduler acquires a lease named after the task before invoking it, renews at one third of leaseMs, and releases it after settlement or shutdown. A failed acquisition produces onSkipped({ reason: 'lease-not-held' }). A renewal failure reaches onTaskError and disables future fires for that task.

A lease bounds concurrent starters, not every possible runner. A process that stalls beyond its lease can resume after another replica has acquired the same task. Durable work must therefore still be idempotent.

Make a double fire harmless#

The recommended cluster-wide task is short: calculate a stable business-period key and enqueue durable work with that key.

import type { Clock, Queue } from '@zmdb/jobs';
import { Cron } from '@zmdb/jobs/schedule';

type Jobs = {
  readonly 'billing.run': { readonly runDate: string };
};

class BillingTasks {
  constructor(
    private readonly jobs: Queue<Jobs>,
    private readonly clock: Clock,
  ) {}

  @Cron('0 30 2 * * *', {
    name: 'billing.daily',
    runs: 'once-per-cluster',
    timeZone: 'Europe/Berlin',
    timeoutMs: 30_000,
  })
  async run(): Promise<void> {
    const runDate = new Intl.DateTimeFormat('en-CA', {
      timeZone: 'Europe/Berlin',
    }).format(new Date(this.clock.now()));

    await this.jobs.enqueue('billing.run', { runDate }, { dedupeKey: `billing:${runDate}` });
  }
}

The queue's unique deduplication key turns two scheduler fires into one job row. The handler should also use its ctx.idempotencyKey completion marker as described in Queues, because enqueue deduplication and at-least-once delivery are separate races.

Declare and start the scheduler#

@Cron and @Interval only record declarations. createScheduler receives the instances built for one application, so two applications in one process do not share a registry.

import { jobsExtension, type Clock } from '@zmdb/jobs';
import { Cron, Interval, createScheduler, type LeaseStore } from '@zmdb/jobs/schedule';

const clock: Clock = {
  now: () => Date.now(),
  sleep(ms, signal) {
    return new Promise<void>((resolve, reject) => {
      if (signal.aborted) {
        reject(signal.reason);
        return;
      }

      const timer = setTimeout(done, ms);

      function done(): void {
        signal.removeEventListener('abort', aborted);
        resolve();
      }

      function aborted(): void {
        clearTimeout(timer);
        reject(signal.reason);
      }

      signal.addEventListener('abort', aborted, { once: true });
    });
  },
};

class LocalTasks {
  @Interval(60_000, {
    name: 'cache.refresh',
    runs: 'once-per-replica',
    timeoutMs: 10_000,
  })
  refresh(): void {
    localCache.refresh();
  }
}

declare const billingTasks: BillingTasks;
declare const leases: LeaseStore;

const scheduler = createScheduler({
  tasks: [billingTasks, new LocalTasks()],
  clock,
  leases,
  leaseMs: 60_000,
  graceMs: 15_000,
  onTaskError(task, scheduledFor, error) {
    logger.error({ task, scheduledFor, error });
  },
  onSkipped(skipped) {
    logger.warn(skipped);
  },
});

const backgroundWork = jobsExtension({ schedulers: [scheduler] });

Pass backgroundWork to the application's extensions option. Application initialization starts schedulers after every worker in the same jobs extension, and disposal stops schedulers before workers under one remaining grace budget. A standalone program may call scheduler.start() and scheduler.onShutdown() directly. No process signal handler is installed.

Use the same Clock instance for queues and schedules. Tests can supply a controllable clock; production can use the system-clock implementation above.

Cron dialect#

A five-field expression has normal crontab(5) meaning. An optional leading seconds field makes six:

┌───────────── second (0-59), optional
│ ┌─────────── minute (0-59)
│ │ ┌───────── hour (0-23)
│ │ │ ┌─────── day of month (1-31)
│ │ │ │ ┌───── month (1-12 or JAN-DEC)
│ │ │ │ │ ┌─── day of week (0-7 or SUN-SAT)
* * * * * *

The parser runs once at scheduler construction.

ConstructResult
*, ranges, lists and stepssupported
JANDEC, SUNSAT, case-insensitivesupported
Sunday as 0 or 7supported
@yearly, @annually, @monthly, @weeklysupported
@daily, @midnight, @hourlysupported
@rebootrefused: startup is not a calendar instant
Quartz L, W, #, ? or a trailing yearrefused rather than assigned a different dialect

When both day-of-month and day-of-week are restricted, cron's POSIX OR rule applies. 0 0 1 * MON fires on the first of each month and on every Monday.

An invalid expression, unknown IANA time zone, duplicate task name, non-positive duration, or interval longer than 2_147_483_647 milliseconds is a construction error. Use @Cron rather than a multi-week interval for calendar time.

Time zones and daylight saving#

timeZone defaults to UTC, never the host's zone. State is stored as an absolute instant; Intl.DateTimeFormat converts the requested wall time in the declared IANA zone.

For this declaration:

@Cron('0 30 2 * * *', {
  runs: 'once-per-cluster',
  timeZone: 'Europe/Berlin',
})

the 2026 transitions are:

Local dateRequested wall timeFired instantRule
2026-03-29, spring forward02:30 (does not exist)2026-03-29T01:30:00.000Zshift forward to 03:30 local
2026-10-25, fall back02:30 (occurs twice)2026-10-25T00:30:00.000Zchoose the earlier occurrence; do not repeat

The host's TZ setting does not participate.

Overlap, missed runs and failures#

Overlap is always prevented; there is no option to enable it.

onTaskError(task, scheduledFor, error) receives thrown task errors, timeout reports and lease-renewal failures. The scheduler does not retry task bodies: enqueue work when it needs retries and a dead-letter path.

A scheduled method receives an AbortSignal. The scheduler aborts it when the task times out, loses its lease, or exhausts the shutdown grace period. Pass that signal to cancellable asynchronous work. A method that ignores cancellation remains the active invocation until it settles and continues to prevent overlap within that scheduler.

onShutdown() waits up to its configured grace, while onShutdown({ graceMs }) applies an owner-supplied cap. Both release held leases before returning; cancellation remains cooperative and cannot forcibly stop application code. A resumed old runner can overlap a replacement after its lease expires or is released, so idempotency remains required.

Both observation callbacks are isolated: if logging throws, it does not stop the scheduler or replace the original error.

When an external scheduler is still better#

A platform cron, Kubernetes CronJob, EventBridge or another managed scheduler remains a good fit when operations needs a provider-owned run history or does not want timers inside the application. Authenticate any HTTP endpoint it calls, and keep the same idempotency rule: an external trigger may also be retried or delivered again.

---

See also: Queues · Transactional Outbox · Standalone Applications