Skip to content

@ddd-ts-kit/scheduling

Scheduled jobs and background jobs for a project built with the kit. A scheduled job is a class of the application layer that says when it runs and triggers use cases: it decides nothing, the business rules stay in the domain. The scheduler opens an execution scope around each run, makes sure one instance of the application runs a given occurrence, and records what became of it. A background job is enqueued by a use case and run later by the worker of this package (see "Background jobs" below). Why a job only triggers a use case, and how to design one that is safe to run twice: Scheduled jobs.

npm i @ddd-ts-kit/scheduling
Entry Holds Imports
@ddd-ts-kit/scheduling InProcessJobScheduler, nextOccurrence, latestOccurrence, systemJobTimers, the JobTimers and JobCheckpoints ports, JobTimeoutError, JobAbortedError, JobLockUnavailableError, reportFailedRuns, UNEXPECTED_ERROR, the metric names; for background jobs QueueWorker, StoredJobQueue, the JobStore port, InMemoryJobStore, tenantKeyOf, reportFailedBackgroundJobs, JobInterruptedError, InvalidEnqueueError, UnknownBackgroundJobError, AttemptsExhaustedError; for process managers StoredProcessRunner, NoTimeoutJobError the kernel
@ddd-ts-kit/scheduling/testing ManualJobScheduler, ManualJobTimers the kernel

The declarations (@Scheduled, @Background), the ports (JobScheduler, JobQueue, ProcessRunner) and the schedule parser live in @ddd-ts-kit/kernel, so application/ imports the kernel and nothing else. @ddd-ts-kit/di binds JobScheduler to an InProcessJobScheduler in its kernel module and registers the declared jobs: an application built with it needs no line of this README but the job itself.

The application layer of a bounded context declares its jobs against the kernel, which holds the declarations and the JobScheduler, JobQueue and ProcessRunner ports, and never imports this package; this package implements those ports, runs the jobs and calls the Lock, the JobStore and the JobCheckpoints the platform binds; the kernel module of @ddd-ts-kit/di binds every port to its implementation and registers the declared jobs

JobScheduler is not the Scheduler of @ddd-ts-kit/aspects. That one is a timer for retry and timeout (schedule(work, delayMs)); this one knows jobs, schedules and instances.

src/contexts/billing/application/scheduling/send-payment-reminders.job.ts
import { AuthorizedCommand, CommandBus, systemActor, type Actor } from '@ddd-ts-kit/kernel';
import { Scheduled, type JobExecution, type ScheduledJob } from '@ddd-ts-kit/kernel/declarations';
// the command of the use case, in application/commands/send-payment-reminders/
declare class SendPaymentReminders extends AuthorizedCommand {
constructor(actor: Actor, dueOn: Date);
}
@Scheduled('billing.send-payment-reminders', { cron: '0 7 * * 1-5', timeZone: 'Europe/Paris' })
export class SendPaymentRemindersJob implements ScheduledJob {
constructor(private readonly commands: CommandBus) {}
async run(execution: JobExecution): Promise<void> {
await this.commands.execute(
new SendPaymentReminders(systemActor('billing.send-payment-reminders'), execution.scheduledAt),
);
}
}
  • Where: application/scheduling/, in a file named *.job.ts, one job per file.

  • The name is <context>.<job-name>, in lower case, written as a string literal and unique in the application: it is the system actor the job runs as, the key of its lock, its label in the metrics and the key of its settings. It survives the renaming of the class.

  • Its dependencies are what its constructor takes, each parameter typed by a port, as for every declaration of the kit: constructor(private readonly commands: CommandBus) {}, with no list in the decorator. A job receives buses and ports. It does not receive a repository: what it would do with one is a use case.

  • run(execution) receives:

    Field
    scheduledAt the instant the run was due. Pass it to the command: "the reminders of that morning" is the same work whenever it really runs
    startedAt the instant it started: later by the jitter, a queue or a catch-up
    attempt 1 with this scheduler, which never retries
    signal aborted when the run exceeds its timeout or the scheduler stops; a long job checks it between two steps
  • The command is sent as the system actor of the job, as an integration event handler does for its consumer. With @ddd-ts-kit/auth, grant that actor the permission of the command, or declare the command @Public() when nothing else can send it.

  • Checked when the file loads: a name that is not <context>.<job-name>, a cron that cannot be read or never fires, a zero interval, an unknown time zone and an option out of its values throw a DeclarationError that names the job.

The scheduler opens an ExecutionScope around each run, so the job writes none of it:

Field of the context Value
origin schedule
actor systemActor('<job name>')
correlationId a fresh one for each run, from the IdGenerator
scheduledAt the instant the run was due
deadline the start plus timeoutMs, when the job has one

Audit columns, the audit trail, log fields, tracing and the correlation stamped on published events therefore work as for a request.

A job says which, explicitly. tenancy: 'global' (the default) runs once per occurrence in no tenant: persistence helpers that need one (tenantScopeOf) refuse it, and a use case that works per tenant receives the tenant in its command. tenancy: 'per-tenant' runs once per tenant of the TenantDirectory given to the scheduler (tenants option; registering such a job without it throws InvalidJobScheduleError):

import {
InMemoryTenantDirectory,
type Clock,
type ExecutionScope,
type IdGenerator,
type JobExecution,
type Lock,
} from '@ddd-ts-kit/kernel';
import { InProcessJobScheduler } from '@ddd-ts-kit/scheduling';
declare const clock: Clock, lock: Lock, scope: ExecutionScope, ids: IdGenerator;
declare function closeMonthOf(tenantId: string, month: Date): Promise<void>;
const scheduler = new InProcessJobScheduler({ clock, lock, scope, ids, tenants: new InMemoryTenantDirectory(['acme', 'globex']) });
scheduler.register({
name: 'billing.close-month',
schedule: { cron: '0 2 1 * *' },
tenancy: 'per-tenant',
// called twice per occurrence: once with tenantId 'acme', then once with 'globex'
run: async (execution: JobExecution) => {
if (execution.tenantId !== undefined) {
await closeMonthOf(execution.tenantId, execution.scheduledAt);
}
},
});

Each tenant's run happens in its own execution scope, tenantId set, and execution.tenantId tells the job which; the runs of one occurrence share its correlation id and run one after the other. A tenant that fails does not stop the others: the occurrence fails with the first error once every tenant had its run. An aborted signal (timeout, stop) stops before the next tenant.

{ every: '15m' } // or a number of milliseconds
{ cron: '0 7 * * 1-5', timeZone: 'Europe/Paris' } // timeZone defaults to UTC

Intervals are a whole number of milliseconds or a number and a unit: '500ms', '30s', '15m', '2h', '1d'. They are aligned on the Unix epoch, not on the start of the process: '15m' falls at :00, :15, :30 and :45, '1d' at midnight UTC. This is what makes every instance name the same instants, and a restart keep them. An interval that does not divide a day ('7h') drifts against the wall clock, and none follows a time zone: for "every morning", write a cron.

Two instances started at 10 and 10:11: aligned on the epoch, both name the occurrences 10, 10, 10 and 11, so one lock key per occurrence and one instance runs it; counted from the start of each process, the first would name 10, 10, 10 and the second 10, 10, 10, different keys, and both would run

Cron has the five standard fields, no seconds:

Field Values Names
minute 0-59
hour 0-23
day of month 1-31
month 1-12 jan to dec
day of week 0-7, 0 and 7 are Sunday sun to sat

Each field takes *, a value, a range a-b, a step on either (*/15, 8-18/2) and lists of those (0,30, mon-fri). Names ignore case. When the day of month and the day of week are both restricted (neither starts with *), a day that fits either one fits, as in every cron: 0 0 13 * fri is the 13th and every Friday. Not supported: @daily and the like, ?, L, W, #, a step on a single value (5/15), a range that wraps (fri-mon).

Time zones are IANA names, evaluated with Intl.DateTimeFormat, without a dependency: the rules are those of the runtime, updated with it. The fields are read on the clocks of the zone, day fields included.

When clocks are A job pinned to an hour (30 2 * * *) A job of every hour (0 * * * *, */20 * * * *)
set forward (02:00 to 03:00) a local time that does not exist is skipped: no run that day, nothing is moved to 03:00 keeps its rhythm: 01:00, then 03:00, one hour later
set back (03:00 to 02:00) a local time that occurs twice runs once, the first time keeps its rhythm: the repeated hour is run through twice

A job is "of every hour" when its hour field starts with *: it follows elapsed time. Any other job follows the clocks. Do not schedule in the changed hour what must run every day: 30 2 * * * in Europe/Paris has no run on the last Sunday of March.

nextOccurrence(parseJobSchedule(schedule), after) and latestOccurrence(parsed, { after, notAfter }) answer the instants, for a screen that shows when a job runs next, or for a spec that pins a schedule before it is deployed:

import { describeJobSchedule, parseJobSchedule } from '@ddd-ts-kit/kernel';
import { latestOccurrence, nextOccurrence } from '@ddd-ts-kit/scheduling';
const every15 = parseJobSchedule({ every: '15m' });
nextOccurrence(every15, new Date('2026-03-02T10:07:00Z')); // 2026-03-02T10:15:00Z, whenever the process started
const weekdayMornings = { cron: '0 7 * * 1-5', timeZone: 'Europe/Paris' };
const parsed = parseJobSchedule(weekdayMornings);
describeJobSchedule(weekdayMornings); // 'cron 0 7 * * 1-5 (Europe/Paris)'
nextOccurrence(parsed, new Date('2026-03-06T08:00:00Z')); // Monday 2026-03-09T06:00:00Z: 07:00 in Paris, UTC+1
latestOccurrence(parsed, { after: new Date('2026-03-02T00:00:00Z'), notAfter: new Date('2026-03-04T12:00:00Z') }); // 2026-03-04T06:00:00Z
// 02:30 does not exist in Paris on 2026-03-29: that day has no run
nextOccurrence(parseJobSchedule({ cron: '30 2 * * *', timeZone: 'Europe/Paris' }), new Date('2026-03-28T12:00:00Z')); // 2026-03-30T00:30:00Z

Written next to the schedule: { every: '15m', overlap: 'queue', timeoutMs: 60_000 }.

Option Default
overlap 'skip' an occurrence that comes while the previous run is in progress is not started (skipped-overlap). 'queue' starts it when the run ends; one occurrence waits at most, the next ones are skipped
missed 'skip' occurrences the scheduler did not reach in time are dropped (missed) and the job waits for its next one. 'run-once' runs the latest of them, once, however many passed
timeoutMs none the signal of a longer run is aborted with a JobTimeoutError and the run is recorded failed, JOB_TIMEOUT
jitterMs 0 a random delay up to this before each run, so that jobs do not all start on the minute
enabled true false never starts the job by time; runNow still runs it
singleInstance true one instance runs an occurrence: see below

Each option acts at one step of an occurrence, and each step that stops it records an outcome (see "Observability"):

An occurrence becomes due; reached later than lateAfterMs with missed skip, it is recorded missed; with the previous run still in progress and overlap skip, skipped-overlap; for a singleInstance job the scheduler takes the lock of the occurrence, held by another instance it records skipped-locked, unreachable failed with JOB_LOCK_UNAVAILABLE; taken, the job runs in an execution scope and succeeds or fails with its code, JOB_TIMEOUT or JOB_ABORTED; every outcome is logged, counted and given to onRun

Missed means reached later than lateAfterMs (60 s by default, an option of the scheduler) after the instant, jitter aside: the process was down, asleep, or its event loop blocked. A timer a few seconds late still runs its occurrence. What passed while the process was down is only known with checkpoints:

// the port, exported by this package
export abstract class JobCheckpoints {
abstract read(job: string): Promise<Date | undefined>;
abstract write(job: string, handled: Date): Promise<void>;
}

The scheduler writes there the last occurrence it handled for each job and reads it when it starts. Without it a restart forgets: every job starts from the instant of the start, and run-once only covers what was missed while the process was alive. The store is yours, a table or a key shared by the instances. It is given to the scheduler where the scheduler is built: see "Replacing the scheduler".

An adapter on a SQL client of your own, one row per job:

src/platform/scheduling/sql-job-checkpoints.ts
import { JobCheckpoints } from '@ddd-ts-kit/scheduling';
// the client of your database, a port your platform binds
declare abstract class Sql {
abstract rows<Row>(text: string, values: readonly unknown[]): Promise<readonly Row[]>;
abstract execute(text: string, values: readonly unknown[]): Promise<void>;
}
export class SqlJobCheckpoints implements JobCheckpoints {
constructor(private readonly sql: Sql) {}
async read(job: string): Promise<Date | undefined> {
const [row] = await this.sql.rows<{ handled_at: Date }>('select handled_at from job_checkpoints where job = $1', [job]);
return row?.handled_at;
}
async write(job: string, handled: Date): Promise<void> {
await this.sql.execute(
`insert into job_checkpoints (job, handled_at) values ($1, $2)
on conflict (job) do update set handled_at = greatest(job_checkpoints.handled_at, excluded.handled_at)`,
[job, handled],
);
}
}

greatest keeps the latest instant when two instances write at once: the checkpoint only moves forward.

A timeout does not kill. A run is a promise, and nothing can stop a promise: the scheduler aborts the signal, stops waiting and frees the job for its next occurrence. A job that ignores its signal goes on behind, and then overlaps the next run. Pass the signal on, or check it.

// instead of: a loop that never looks at the signal, still sending commands after the timeout
for (const batch of batches) {
await this.commands.execute(new ArchiveBatch(actor, batch, execution.scheduledAt));
}
import { AuthorizedCommand, CommandBus, systemActor, type Actor } from '@ddd-ts-kit/kernel';
import { Scheduled, type JobExecution, type ScheduledJob } from '@ddd-ts-kit/kernel/declarations';
declare class ArchiveBatch extends AuthorizedCommand {
constructor(actor: Actor, batch: number, asOf: Date);
}
const BATCHES = 20;
// write: stop between two steps once the run is aborted (timeout or stop())
@Scheduled('archive.archive-old-records', { cron: '0 3 * * *', timeoutMs: 10 * 60_000 })
export class ArchiveOldRecordsJob implements ScheduledJob {
constructor(private readonly commands: CommandBus) {}
async run(execution: JobExecution): Promise<void> {
const actor = systemActor('archive.archive-old-records');
for (let batch = 0; batch < BATCHES; batch += 1) {
execution.signal.throwIfAborted(); // throws the JobTimeoutError or the JobAbortedError
await this.commands.execute(new ArchiveBatch(actor, batch, execution.scheduledAt));
}
}
}

Make the command of each batch safe to send again: what a timeout leaves undone, the next occurrence picks up from the state.

A job is switched off, rescheduled or tuned by configuration, never by a code change. With @ddd-ts-kit/di:

import { createApplication, type ContextModule } from '@ddd-ts-kit/di';
declare const modules: readonly ContextModule[];
declare const config: { readonly jobs: { readonly reminders: { readonly enabled: boolean }; readonly ledgerCron: string } };
createApplication({
modules,
jobs: {
settings: {
'billing.send-payment-reminders': { enabled: config.jobs.reminders.enabled },
'billing.export-ledger': { schedule: { cron: config.jobs.ledgerCron, timeZone: 'Europe/Paris' } },
},
},
});

Settings for a job no module declares are refused (UNKNOWN_JOB), so a renamed job cannot stay on by a stale key; settings that cannot be read are refused too (INVALID_JOB_SETTINGS).

Every instance of a horizontally scaled application runs a scheduler and computes the same instants. Before running an occurrence of a singleInstance job, the scheduler takes the kernel Lock under the key job:<name>:<scheduled instant>. The instance that gets it runs; the others record skipped-locked.

  • The lock is not released when the run ends. An instance whose timer fires a little later would take it and run the occurrence again. It is kept until this instance handles the next occurrence of the job, or until its time to live has passed: timeoutMs (15 minutes for a job without timeout, lockTtlMs) plus the jitter and lateAfterMs.
  • With InMemoryLock, the default of kernelModule(), the lock excludes inside one process only: two instances both run every job. It is correct for a single instance and for tests, and nothing more.
  • With a lock between processes (RedisLock of @ddd-ts-kit/redis, bound to Lock), the guarantee is real, and it is this one: at most one run per occurrence, best effort.

What is not guaranteed, so that you design for it:

  • An occurrence may run twice: the lock expires while its holder is paused or the run outlives the time to live and a late instance arrives; the lock store fails over and forgets a key; an instance restarts with missed: 'run-once' after the lock of the occurrence it catches up has expired; clocks disagree by more than the time to live.
  • An occurrence may run zero times: the instance that took the lock crashes before or during the run (nobody retries, the lock stays), every instance is down at the instant (missed), or the lock store cannot be reached, in which case the occurrence is not run and is recorded failed, JOB_LOCK_UNAVAILABLE, rather than run everywhere.
  • runNow takes no lock: it is one deliberate run.

Therefore a job is idempotent, or the command it sends is. A run that happens twice must leave the same state as once: derive what to do from the state ("invoices due and not yet reminded"), not from the fact of being called, or key the work on scheduledAt behind a unique constraint. The kernel says it of Lock itself: an efficiency tool, never the guard of an invariant. Work that must happen exactly once and survive a crash is not a scheduled job but a row in a table, processed by a job that is safe to repeat.

Instances should keep their clocks within a few seconds of each other (NTP): the instants are computed from the Clock, and the lock key is the instant.

By default every process that starts the application runs the jobs. To run them in one deployment and serve requests in another, tell the others not to:

// src/main.ts, the process that serves requests
const app = createApplication({ modules, jobs: { start: false } });
// src/apps/worker/main.ts, the process that runs the jobs: the same modules, no HTTP server
const app = createApplication({ modules, jobs: { settings: jobSettings(config) } });
await app.start();
process.on('SIGTERM', () => void app.stop());

With start: false the jobs are still registered and described, and runNow still runs one, which gives an operations endpoint for free. One composition root reading a flag (jobs: { start: config.runJobs }) is the same thing with one entry point. Keep singleInstance on even with one worker: a rolling deployment runs two for a while.

A context extracted as its own service takes its jobs with it: they are declarations of the context, registered by the application that lists its module. Nothing is global.

In a project generated by the CLI: ddd-ts-kit generate job writes the job and its line in src/platform/job-settings.ts (switched off with JOB_<CONTEXT>_<JOB>_ENABLED=false), main.ts passes jobs: { start: config.runJobs, settings: jobSettings(process.env) } (RUN_JOBS), and ddd-ts-kit generate worker writes src/apps/worker/main.ts and turns the default of RUN_JOBS off for the API.

application.start() starts the scheduler last, once the consumers are subscribed, and application.stop() stops it first. stop() starts no new run, waits for the runs in progress up to graceMs (30 s by default), then aborts their signals with a JobAbortedError and returns; a run that waited in a queue is dropped and recorded missed. Set the grace below the termination grace of the platform.

kernelModule() builds the scheduler on the kernel ports it binds: Clock, Lock, ExecutionScope, IdGenerator, Logger and Metrics, with reportFailedRuns on the bound ErrorReporter as onRun. To build it otherwise (with checkpoints, another grace period, another observer), give the kernel module yours:

import { Clock, ErrorReporter, ExecutionScope, IdGenerator, Lock, Logger, Metrics } from '@ddd-ts-kit/kernel';
import { createApplication, kernelModule, type ContextModule } from '@ddd-ts-kit/di';
import { InProcessJobScheduler, JobCheckpoints, reportFailedRuns } from '@ddd-ts-kit/scheduling';
declare const platformModule: ContextModule, billingModule: ContextModule;
const kernel = kernelModule({
jobScheduler: (resolve) =>
new InProcessJobScheduler({
clock: resolve(Clock),
// asked at each occurrence: building the scheduler must not open a connection
lock: { acquire: (key, options) => resolve(Lock).acquire(key, options) },
scope: resolve(ExecutionScope),
ids: resolve(IdGenerator),
logger: resolve(Logger),
metrics: resolve(Metrics),
checkpoints: resolve(JobCheckpoints),
graceMs: 10_000,
// what the default scheduler does with failed runs: keep it
onRun: reportFailedRuns(resolve(ErrorReporter)),
}),
});
const app = createApplication({ modules: [kernel, platformModule, billingModule] });

with, in the module of the platform, builder.bind(JobCheckpoints).toClass(SqlJobCheckpoints, [Sql]). A later module replaces the scheduler the same way, with builder.rebind(JobScheduler).toFactory((resolve) => new InProcessJobScheduler({ ... })), and so does another implementation of the JobScheduler port (on a database, on a queue).

Without @ddd-ts-kit/di:

import type { Clock, ExecutionScope, IdGenerator, Lock, Logger, Metrics, ScheduledJob } from '@ddd-ts-kit/kernel';
import { InProcessJobScheduler } from '@ddd-ts-kit/scheduling';
declare const clock: Clock, lock: Lock, scope: ExecutionScope, ids: IdGenerator, logger: Logger, metrics: Metrics;
declare const job: ScheduledJob;
const scheduler = new InProcessJobScheduler({ clock, lock, scope, ids, logger, metrics });
scheduler.register({ name: 'billing.send-payment-reminders', schedule: { every: '15m' }, run: (execution) => job.run(execution) });
await scheduler.start();
Option Default
clock, lock, scope, ids required every instant comes from the Clock; the system clock is never read
logger, metrics write and measure nothing
timers systemJobTimers how it sleeps; ManualJobTimers in a test
checkpoints none see "Options"
lateAfterMs 60000 later than this, an occurrence is missed
graceMs 30000 how long stop() waits
lockTtlMs 900000 time to live of the lock of a job without timeoutMs
random Math.random draws the jitter
onRun none (run, error?, detail?) => void for every occurrence handled, with what the job threw and the correlation id of the run: reportFailedRuns(errorReporter) is the observer that reports to the kernel ErrorReporter every run that failed with anything but a DomainError

It sleeps at most one hour at a time and computes again when it wakes, so a clock that is corrected, or a suspended machine, is noticed within the hour.

scheduler.describe() answers, for each job, name, schedule (every 15m, cron 0 7 * * 1-5 (Europe/Paris)), enabled, running, nextRunAt and lastRun, as data. application.describe().jobs lists the declared jobs with the settings they run with.

Every occurrence handled has one outcome, recorded three times:

Outcome Means Log level
succeeded the job ran info
failed the job threw, timed out, was aborted, or its lock could not be asked error
skipped-overlap the previous run was still in progress warn
skipped-locked another instance runs this occurrence debug
missed the occurrence passed and was not run warn
  • Log: one entry, Scheduled job succeeded, Scheduled job failed, Scheduled job missed, Scheduled job skipped: the previous run is still in progress or Scheduled job skipped: another instance runs this occurrence, with job, context, outcome, scheduledAt, and for a run durationMs, correlationId, errorCode, errorType. What goes wrong around the runs (a checkpoint that cannot be read or written) is logged at error as Scheduled job could not be followed, and the job goes on.
  • Metrics: the counter scheduled_job_runs_total and, for a run, the histogram scheduled_job_duration_ms, labelled job, context, status (the outcome) and error_code. An alert on "no succeeded for this job since N intervals" catches every way a job stops running, which an alert on failures does not.
  • onRun: the same record, plus the error and the correlation id of the run.

missed is recorded once for a gap, with the first instant that was missed, not once per instant. The errorCode is the code of what was thrown (a DomainError, JOB_TIMEOUT, JOB_ABORTED, JOB_LOCK_UNAVAILABLE) or UNEXPECTED_ERROR. The message of an error is never recorded: it quotes data. A job that fails stops neither the scheduler nor the other jobs, and its next occurrence runs as usual: there is no retry, the next occurrence is the retry.

A spec of a job is a spec of the use case it triggers: run the job, assert the fakes. Time is not involved, and the test application of @ddd-ts-kit/di never starts jobs by time.

import { contextModule, createTestApplication, fake, type DeclaredSource } from '@ddd-ts-kit/di';
import { expect } from 'vitest';
// the barrel of the context, and the fake of its Mailer port
declare const billing: DeclaredSource;
declare abstract class Mailer {
abstract send(to: string, subject: string): Promise<void>;
}
declare class FakeMailer extends Mailer {
readonly sent: readonly { readonly to: string; readonly subject: string }[];
send(to: string, subject: string): Promise<void>;
}
const mailer = new FakeMailer();
const app = createTestApplication({ modules: [contextModule('billing', billing)], fakes: [fake(Mailer, mailer)] });
await app.jobs.runNow('billing.send-payment-reminders');
expect(mailer.sent).toHaveLength(2);

app.jobs.runNow(name) runs the job as the scheduler would (scope of origin schedule, system actor, lock aside) and throws what the job threw. For a spec about time itself, app.jobs.scheduler is a ManualJobScheduler on the clock of the application:

import { FixedClock, FixedExecutionScope, InMemoryLock, SequentialIdGenerator } from '@ddd-ts-kit/kernel';
import { ManualJobScheduler } from '@ddd-ts-kit/scheduling/testing';
import { expect } from 'vitest';
const run = async (): Promise<void> => undefined;
const clock = new FixedClock(new Date('2026-03-02T09:58:30Z'));
const scheduler = new ManualJobScheduler({ clock, lock: new InMemoryLock(clock), scope: new FixedExecutionScope(), ids: new SequentialIdGenerator() });
scheduler.register({ name: 'billing.send-payment-reminders', schedule: { every: '15m' }, run });
await scheduler.start();
await scheduler.advance(32 * 60_000);
expect(scheduler.runs.map((run) => run.scheduledAt.toISOString())).toEqual(['2026-03-02T10:00:00.000Z', '2026-03-02T10:15:00.000Z', '2026-03-02T10:30:00.000Z']);
  • advance(ms) moves the clock and fires each timer at its instant, in order. When it resolves, every job that waits on promises alone has ended; a job the test keeps blocked is still running, which is how overlap, timeout and stop are tested.
  • runs lists every occurrence handled with its outcome, failures what the failed runs threw, runsOf(job) the runs of one job. There is no jitter.
  • ManualJobTimers alone drives an InProcessJobScheduler built by hand, for a spec that sets random or checkpoints.
  • Two schedulers sharing a clock and a lock are two instances: this is how the single-instance behaviour is specified in this package.

Another scheduler (on a database, on a queue) implements the JobScheduler port of the kernel and passes jobSchedulerContract of @ddd-ts-kit/testing/contracts.

Work a use case hands to a worker, to run later: the port (JobQueue), the declaration (@Background) and the payload type live in the kernel; this package runs them. Why and when, with the guarantees: Background jobs.

Piece
StoredJobQueue the JobQueue of the kernel on a JobStore: names the job, captures the correlation, the actor, the tenant and the traceparent of the work that enqueues, adds it to the store; find and cancel reach the jobs of the tenant of the work in progress only (of the tenants of its elevation; every job when the work has no tenant)
JobStore the port of the stores: add, claim (a lease), heartbeat, update (progress, checkpoint), complete, fail, release, cancel, find, retryDead, purgeFinished, stats; every report names its attempt (JobLease)
InMemoryJobStore the store of tests and of a process without a database: add, cancel and retryDead wait for the commit of the unit of work; list() for assertions
tenantKeyOf(enqueuedBy) the tenant part of the idempotency key, for a store that keeps it in a unique index: the tenant, or the empty text
QueueWorker claims and runs the jobs of the queues it has jobs for: register, start, stop (waits for a claim in flight, whose jobs go back uncounted, then for the attempts in progress), runDue
reportFailedBackgroundJobs(reporter) the observer that sends an attempt that failed unexpectedly to the error reporter
import type {
BackgroundJob,
BackgroundJobExecution,
Clock,
ErrorReporter,
ExecutionScope,
IdGenerator,
Logger,
Metrics,
} from '@ddd-ts-kit/kernel';
import { InMemoryJobStore, QueueWorker, reportFailedBackgroundJobs, StoredJobQueue } from '@ddd-ts-kit/scheduling';
import { captureTraceparent } from '@ddd-ts-kit/tracing';
declare const clock: Clock, ids: IdGenerator, scope: ExecutionScope, logger: Logger, metrics: Metrics, errors: ErrorReporter;
interface CompileStatementsPayload {
readonly month: string;
}
declare class CompileStatementsBackgroundJob implements BackgroundJob<CompileStatementsPayload> {
static readonly jobName: 'billing.compile-statements';
run(payload: CompileStatementsPayload, execution: BackgroundJobExecution): Promise<void>;
}
declare const job: CompileStatementsBackgroundJob;
const store = new InMemoryJobStore(); // KnexJobStore, DrizzleJobStore or RedisJobStore in production
// the JobQueue a use case enqueues on: jobs.enqueue(CompileStatementsBackgroundJob, { month: '2026-10' })
export const jobs = new StoredJobQueue({ store, clock, ids, scope, traceparent: captureTraceparent });
const worker = new QueueWorker({
store,
clock,
scope,
logger,
metrics,
concurrency: { billing: 4, default: 1 }, // the queue of a job is its context
onRun: reportFailedBackgroundJobs(errors),
});
worker.register({
name: CompileStatementsBackgroundJob.jobName,
options: { maxAttempts: 3 },
// the payload comes back from the store as plain JSON
run: (payload, execution) => job.run(payload as CompileStatementsPayload, execution),
});
await worker.start();

@ddd-ts-kit/di does all of it: kernelModule({ jobStore }) binds the store, the queue and the worker, the declared jobs are registered on the worker, and the worker runs in the processes that run the jobs (jobs: { start: true }).

Option of QueueWorker Default
concurrency 1 attempts of one queue at once in this process: a number, or { <queue>: n, default: n }
pollIntervalMs 1000 how long an idle queue waits before asking the store again
graceMs 30000 how long stop() waits for the attempts in progress before aborting them; an aborted attempt gives its job back uncounted
timers the runtime ManualJobTimers in a spec

Each attempt is recorded as a BackgroundJobRun (succeeded, retried, dead, released, interrupted), logged at info, warn or error, and counted in background_job_runs_total and background_job_duration_ms (BACKGROUND_RUNS_COUNTER, BACKGROUND_DURATION_HISTOGRAM), labelled job, context, status and error_code. A store of your own passes jobStoreContract of @ddd-ts-kit/testing/contracts, with its unit of work.

StoredProcessRunner is the ProcessRunner of the kernel: new StoredProcessRunner({ store, jobs, commands, unitOfWork }). A step runs in one unit of work: the instance is loaded, the step decides, the timeouts it disarms are cancelled and those it arms are enqueued on the JobQueue as delayed jobs of the timeoutJob of the process (NoTimeoutJobError without one), the state is saved at the next version, then the commands are sent. timeout(process, { id, timeout, jobId }, step) runs only while jobId is the armed job of that timeout. @ddd-ts-kit/di binds it on ProcessStore, JobQueue, CommandBus and UnitOfWork. See Sagas and process managers.

  • No persistence of its own. Runs, outcomes and lastRun live in the memory of the instance; describe() is the view of one instance, and only checkpoints crosses a restart.
  • No retry and no backoff. A failed occurrence is over. Put retry where the failure is: an aspect on the adapter (retry of @ddd-ts-kit/aspects), or a command that is safe to send again at the next occurrence.
  • Minute resolution for cron, millisecond for intervals; an interval under a second is possible and rarely wise, as each occurrence costs a lock.
  • At most once per occurrence is best effort, as said above. Exactly-once does not exist here.
  • The scheduler is not a queue. A scheduled job does a bounded amount of work on a schedule. Work a command asks for, which must not be lost and must be retried, is a background job (above).
  • The worker: at least once, no ordering, concurrency per process, polling: see the limits of Background jobs.
  • One process, one event loop. A job that blocks the loop delays every other job; past lateAfterMs their occurrences are missed.
  • Time zone rules are those of the runtime. A zone whose rules changed needs an updated Node. Two changes of offset within two days of each other, which no zone has, are not handled.