@ddd-ts-kit/scheduling 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +424 -2
  2. package/dist/errors/error-code-of.d.ts +4 -0
  3. package/dist/errors/error-code-of.d.ts.map +1 -0
  4. package/dist/errors/error-code-of.js +11 -0
  5. package/dist/errors/error-code-of.js.map +1 -0
  6. package/dist/errors/job-aborted-error.d.ts +6 -0
  7. package/dist/errors/job-aborted-error.d.ts.map +1 -0
  8. package/dist/errors/job-aborted-error.js +9 -0
  9. package/dist/errors/job-aborted-error.js.map +1 -0
  10. package/dist/errors/job-lock-unavailable-error.d.ts +6 -0
  11. package/dist/errors/job-lock-unavailable-error.d.ts.map +1 -0
  12. package/dist/errors/job-lock-unavailable-error.js +9 -0
  13. package/dist/errors/job-lock-unavailable-error.js.map +1 -0
  14. package/dist/errors/job-timeout-error.d.ts +6 -0
  15. package/dist/errors/job-timeout-error.d.ts.map +1 -0
  16. package/dist/errors/job-timeout-error.js +9 -0
  17. package/dist/errors/job-timeout-error.js.map +1 -0
  18. package/dist/in-process-job-scheduler.d.ts +66 -0
  19. package/dist/in-process-job-scheduler.d.ts.map +1 -0
  20. package/dist/in-process-job-scheduler.js +189 -0
  21. package/dist/in-process-job-scheduler.js.map +1 -0
  22. package/dist/index.d.ts +14 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +13 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/job-checkpoints.d.ts +11 -0
  27. package/dist/job-checkpoints.d.ts.map +1 -0
  28. package/dist/job-checkpoints.js +9 -0
  29. package/dist/job-checkpoints.js.map +1 -0
  30. package/dist/job-recorder.d.ts +28 -0
  31. package/dist/job-recorder.d.ts.map +1 -0
  32. package/dist/job-recorder.js +114 -0
  33. package/dist/job-recorder.js.map +1 -0
  34. package/dist/job-run-observer.d.ts +9 -0
  35. package/dist/job-run-observer.d.ts.map +1 -0
  36. package/dist/job-run-observer.js +2 -0
  37. package/dist/job-run-observer.js.map +1 -0
  38. package/dist/job-runner.d.ts +29 -0
  39. package/dist/job-runner.d.ts.map +1 -0
  40. package/dist/job-runner.js +96 -0
  41. package/dist/job-runner.js.map +1 -0
  42. package/dist/job-timers.d.ts +7 -0
  43. package/dist/job-timers.d.ts.map +1 -0
  44. package/dist/job-timers.js +5 -0
  45. package/dist/job-timers.js.map +1 -0
  46. package/dist/metric-names.d.ts +3 -0
  47. package/dist/metric-names.d.ts.map +1 -0
  48. package/dist/metric-names.js +3 -0
  49. package/dist/metric-names.js.map +1 -0
  50. package/dist/occurrence.d.ts +6 -0
  51. package/dist/occurrence.d.ts.map +1 -0
  52. package/dist/occurrence.js +2 -0
  53. package/dist/occurrence.js.map +1 -0
  54. package/dist/occurrences/instants-of-wall-time.d.ts +7 -0
  55. package/dist/occurrences/instants-of-wall-time.d.ts.map +1 -0
  56. package/dist/occurrences/instants-of-wall-time.js +15 -0
  57. package/dist/occurrences/instants-of-wall-time.js.map +1 -0
  58. package/dist/occurrences/latest-occurrence.d.ts +10 -0
  59. package/dist/occurrences/latest-occurrence.d.ts.map +1 -0
  60. package/dist/occurrences/latest-occurrence.js +22 -0
  61. package/dist/occurrences/latest-occurrence.js.map +1 -0
  62. package/dist/occurrences/local-day-of.d.ts +4 -0
  63. package/dist/occurrences/local-day-of.d.ts.map +1 -0
  64. package/dist/occurrences/local-day-of.js +7 -0
  65. package/dist/occurrences/local-day-of.js.map +1 -0
  66. package/dist/occurrences/next-occurrence.d.ts +4 -0
  67. package/dist/occurrences/next-occurrence.d.ts.map +1 -0
  68. package/dist/occurrences/next-occurrence.js +20 -0
  69. package/dist/occurrences/next-occurrence.js.map +1 -0
  70. package/dist/occurrences/occurrences-of-day.d.ts +4 -0
  71. package/dist/occurrences/occurrences-of-day.d.ts.map +1 -0
  72. package/dist/occurrences/occurrences-of-day.js +35 -0
  73. package/dist/occurrences/occurrences-of-day.js.map +1 -0
  74. package/dist/occurrences/offset-at.d.ts +3 -0
  75. package/dist/occurrences/offset-at.d.ts.map +1 -0
  76. package/dist/occurrences/offset-at.js +32 -0
  77. package/dist/occurrences/offset-at.js.map +1 -0
  78. package/dist/registered-job.d.ts +21 -0
  79. package/dist/registered-job.d.ts.map +1 -0
  80. package/dist/registered-job.js +27 -0
  81. package/dist/registered-job.js.map +1 -0
  82. package/dist/report-failed-runs.d.ts +11 -0
  83. package/dist/report-failed-runs.d.ts.map +1 -0
  84. package/dist/report-failed-runs.js +23 -0
  85. package/dist/report-failed-runs.js.map +1 -0
  86. package/dist/started-run.d.ts +7 -0
  87. package/dist/started-run.d.ts.map +1 -0
  88. package/dist/started-run.js +2 -0
  89. package/dist/started-run.js.map +1 -0
  90. package/dist/system-job-timers.d.ts +3 -0
  91. package/dist/system-job-timers.d.ts.map +1 -0
  92. package/dist/system-job-timers.js +9 -0
  93. package/dist/system-job-timers.js.map +1 -0
  94. package/dist/testing/manual-job-scheduler.d.ts +24 -0
  95. package/dist/testing/manual-job-scheduler.d.ts.map +1 -0
  96. package/dist/testing/manual-job-scheduler.js +42 -0
  97. package/dist/testing/manual-job-scheduler.js.map +1 -0
  98. package/dist/testing/manual-job-timers.d.ts +23 -0
  99. package/dist/testing/manual-job-timers.d.ts.map +1 -0
  100. package/dist/testing/manual-job-timers.js +54 -0
  101. package/dist/testing/manual-job-timers.js.map +1 -0
  102. package/dist/testing.d.ts +3 -0
  103. package/dist/testing.d.ts.map +1 -0
  104. package/dist/testing.js +3 -0
  105. package/dist/testing.js.map +1 -0
  106. package/package.json +36 -4
package/README.md CHANGED
@@ -1,3 +1,425 @@
1
- # Temporary Holding Version
1
+ # @ddd-ts-kit/scheduling
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Scheduled jobs for a project built with the kit. A job is a class of the application layer that
4
+ says when it runs and triggers use cases: it decides nothing, the business rules stay in the
5
+ domain. The scheduler opens an execution scope around each run, makes sure one instance of the
6
+ application runs a given occurrence, and records what became of it.
7
+
8
+ ```
9
+ npm i @ddd-ts-kit/scheduling
10
+ ```
11
+
12
+ | Entry | Holds | Imports |
13
+ |---|---|---|
14
+ | `@ddd-ts-kit/scheduling` | `InProcessJobScheduler`, `nextOccurrence`, `latestOccurrence`, `systemJobTimers`, the `JobTimers` and `JobCheckpoints` ports, `JobTimeoutError`, `JobAbortedError`, `JobLockUnavailableError`, the metric names | the kernel |
15
+ | `@ddd-ts-kit/scheduling/testing` | `ManualJobScheduler`, `ManualJobTimers` | the kernel |
16
+
17
+ The declaration (`@Scheduled`), the port (`JobScheduler`) and the schedule parser live in
18
+ `@ddd-ts-kit/kernel`, so `application/` imports the kernel and nothing else.
19
+ `@ddd-ts-kit/di` binds `JobScheduler` to an `InProcessJobScheduler` in its kernel module and
20
+ registers the declared jobs: an application built with it needs no line of this README but
21
+ the job itself.
22
+
23
+ `JobScheduler` is not the `Scheduler` of `@ddd-ts-kit/aspects`. That one is a timer for retry
24
+ and timeout (`schedule(work, delayMs)`); this one knows jobs, schedules and instances.
25
+
26
+ ## A job
27
+
28
+ ```ts
29
+ // src/contexts/billing/application/scheduling/send-payment-reminders.job.ts
30
+ import { CommandBus, systemActor } from '@ddd-ts-kit/kernel';
31
+ import { Scheduled, type JobExecution, type ScheduledJob } from '@ddd-ts-kit/kernel/declarations';
32
+ import { SendPaymentReminders } from '@/contexts/billing/application/commands/send-payment-reminders/send-payment-reminders.command.js';
33
+
34
+ @Scheduled('billing.send-payment-reminders', { cron: '0 7 * * 1-5', timeZone: 'Europe/Paris' }, [CommandBus])
35
+ export class SendPaymentRemindersJob implements ScheduledJob {
36
+ constructor(private readonly commands: CommandBus) {}
37
+
38
+ async run(execution: JobExecution): Promise<void> {
39
+ await this.commands.execute(
40
+ new SendPaymentReminders(systemActor('billing.send-payment-reminders'), execution.scheduledAt),
41
+ );
42
+ }
43
+ }
44
+ ```
45
+
46
+ - **Where**: `application/scheduling/`, in a file named `*.job.ts`, one job per file.
47
+ - **The name** is `<context>.<job-name>`, in lower case, written as a string literal and unique
48
+ in the application: it is the system actor the job runs as, the key of its lock, its label in
49
+ the metrics and the key of its settings. It survives the renaming of the class.
50
+ - **The dependency list** names the ports of the constructor parameters in order, and the
51
+ compiler checks it, as for every declaration of the kit. A port is an abstract class, and the
52
+ class is what is listed: `[CommandBus]`. A job receives buses and ports. It does not receive
53
+ a repository: what it would do with one is a use case.
54
+ - **`run(execution)`** receives:
55
+
56
+ | Field | |
57
+ |---|---|
58
+ | `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 |
59
+ | `startedAt` | the instant it started: later by the jitter, a queue or a catch-up |
60
+ | `attempt` | 1 with this scheduler, which never retries |
61
+ | `signal` | aborted when the run exceeds its timeout or the scheduler stops; a long job checks it between two steps |
62
+
63
+ - **The command is sent as the system actor of the job**, as an integration event handler does
64
+ for its consumer. With `@ddd-ts-kit/auth`, grant that actor the permission of the command,
65
+ or declare the command `@Public()` when nothing else can send it.
66
+ - **Checked when the file loads**: a name that is not `<context>.<job-name>`, a cron that
67
+ cannot be read or never fires, a zero interval, an unknown time zone and an option out of
68
+ its values throw a `DeclarationError` that names the job.
69
+
70
+ ### The execution scope
71
+
72
+ The scheduler opens an `ExecutionScope` around each run, so the job writes none of it:
73
+
74
+ | Field of the context | Value |
75
+ |---|---|
76
+ | `origin` | `schedule` |
77
+ | `actor` | `systemActor('<job name>')` |
78
+ | `correlationId` | a fresh one for each run, from the `IdGenerator` |
79
+ | `scheduledAt` | the instant the run was due |
80
+ | `deadline` | the start plus `timeoutMs`, when the job has one |
81
+
82
+ Audit columns, the audit trail, log fields, tracing and the correlation stamped on published
83
+ events therefore work as for a request. A job has no tenant: persistence helpers that need
84
+ one (`tenantScopeOf`) are for the use case, which receives the tenant in its command, one
85
+ command per tenant when the work is per tenant.
86
+
87
+ ## Schedules
88
+
89
+ ```ts
90
+ { every: '15m' } // or a number of milliseconds
91
+ { cron: '0 7 * * 1-5', timeZone: 'Europe/Paris' } // timeZone defaults to UTC
92
+ ```
93
+
94
+ **Intervals** are a whole number of milliseconds or a number and a unit: `'500ms'`, `'30s'`,
95
+ `'15m'`, `'2h'`, `'1d'`. They are **aligned on the Unix epoch**, not on the start of the
96
+ process: `'15m'` falls at :00, :15, :30 and :45, `'1d'` at midnight UTC. This is what makes every
97
+ instance name the same instants, and a restart keep them. An interval that does not divide a
98
+ day (`'7h'`) drifts against the wall clock, and none follows a time zone: for "every morning",
99
+ write a cron.
100
+
101
+ **Cron** has the five standard fields, no seconds:
102
+
103
+ | Field | Values | Names |
104
+ |---|---|---|
105
+ | minute | 0-59 | |
106
+ | hour | 0-23 | |
107
+ | day of month | 1-31 | |
108
+ | month | 1-12 | `jan` to `dec` |
109
+ | day of week | 0-7, 0 and 7 are Sunday | `sun` to `sat` |
110
+
111
+ Each field takes `*`, a value, a range `a-b`, a step on either (`*/15`, `8-18/2`) and lists of
112
+ those (`0,30`, `mon-fri`). Names ignore case. When the day of month and the day of week are
113
+ both restricted (neither starts with `*`), a day that fits either one fits, as in every cron:
114
+ `0 0 13 * fri` is the 13th and every Friday. Not supported: `@daily` and the like, `?`, `L`,
115
+ `W`, `#`, a step on a single value (`5/15`), a range that wraps (`fri-mon`).
116
+
117
+ **Time zones** are IANA names, evaluated with `Intl.DateTimeFormat`, without a dependency: the
118
+ rules are those of the runtime, updated with it. The fields are read on the clocks of the zone,
119
+ day fields included.
120
+
121
+ | When clocks are | A job pinned to an hour (`30 2 * * *`) | A job of every hour (`0 * * * *`, `*/20 * * * *`) |
122
+ |---|---|---|
123
+ | 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 |
124
+ | 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 |
125
+
126
+ A job is "of every hour" when its hour field starts with `*`: it follows elapsed time. Any
127
+ other job follows the clocks. Do not schedule in the changed hour what must run every day:
128
+ `30 2 * * *` in `Europe/Paris` has no run on the last Sunday of March.
129
+
130
+ `nextOccurrence(parseJobSchedule(schedule), after)` and `latestOccurrence(parsed, { after,
131
+ notAfter })` answer the instants, for a screen that shows when a job runs next.
132
+
133
+ ## Options
134
+
135
+ Written next to the schedule: `{ every: '15m', overlap: 'queue', timeoutMs: 60_000 }`.
136
+
137
+ | Option | Default | |
138
+ |---|---|---|
139
+ | `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 |
140
+ | `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 |
141
+ | `timeoutMs` | none | the signal of a longer run is aborted with a `JobTimeoutError` and the run is recorded `failed`, `JOB_TIMEOUT` |
142
+ | `jitterMs` | `0` | a random delay up to this before each run, so that jobs do not all start on the minute |
143
+ | `enabled` | `true` | `false` never starts the job by time; `runNow` still runs it |
144
+ | `singleInstance` | `true` | one instance runs an occurrence: see below |
145
+
146
+ **Missed** means reached later than `lateAfterMs` (60 s by default, an option of the
147
+ scheduler) after the instant, jitter aside: the process was down, asleep, or its event loop
148
+ blocked. A timer a few seconds late still runs its occurrence. What passed **while the process
149
+ was down** is only known with `checkpoints`:
150
+
151
+ ```ts
152
+ // the port, exported by this package
153
+ export abstract class JobCheckpoints {
154
+ abstract read(job: string): Promise<Date | undefined>;
155
+
156
+ abstract write(job: string, handled: Date): Promise<void>;
157
+ }
158
+
159
+ // src/platform/scheduling/knex-job-checkpoints.ts, your adapter
160
+ export class KnexJobCheckpoints implements JobCheckpoints {
161
+ constructor(private readonly database: Database) {}
162
+
163
+ async read(job: string): Promise<Date | undefined> { /* one row per job */ }
164
+
165
+ async write(job: string, handled: Date): Promise<void> { /* upsert */ }
166
+ }
167
+ ```
168
+
169
+ The scheduler writes there the last occurrence it handled for each job and reads it when it
170
+ starts. Without it a restart forgets: every job starts from the instant of the start, and
171
+ `run-once` only covers what was missed while the process was alive. The store is yours, a
172
+ table or a key shared by the instances. It is given to the scheduler where the scheduler is
173
+ built: see "Replacing the scheduler".
174
+
175
+ **A timeout does not kill.** A run is a promise, and nothing can stop a promise: the scheduler
176
+ aborts the signal, stops waiting and frees the job for its next occurrence. A job that ignores
177
+ its signal goes on behind, and then overlaps the next run. Pass the signal on, or check it.
178
+
179
+ ## Switching a job off, per environment
180
+
181
+ A job is switched off, rescheduled or tuned by configuration, never by a code change. With
182
+ `@ddd-ts-kit/di`:
183
+
184
+ ```ts
185
+ createApplication({
186
+ modules,
187
+ jobs: {
188
+ settings: {
189
+ 'billing.send-payment-reminders': { enabled: config.jobs.reminders.enabled },
190
+ 'billing.export-ledger': { schedule: { cron: config.jobs.ledgerCron, timeZone: 'Europe/Paris' } },
191
+ },
192
+ },
193
+ });
194
+ ```
195
+
196
+ Settings for a job no module declares are refused (`UNKNOWN_JOB`), so a renamed job cannot stay
197
+ on by a stale key; settings that cannot be read are refused too (`INVALID_JOB_SETTINGS`).
198
+
199
+ ## Several instances
200
+
201
+ Every instance of a horizontally scaled application runs a scheduler and computes the same
202
+ instants. Before running an occurrence of a `singleInstance` job, the scheduler takes the
203
+ kernel `Lock` under the key `job:<name>:<scheduled instant>`. The instance that gets it runs;
204
+ the others record `skipped-locked`.
205
+
206
+ - **The lock is not released when the run ends.** An instance whose timer fires a little later
207
+ would take it and run the occurrence again. It is kept until this instance handles the next
208
+ occurrence of the job, or until its time to live has passed: `timeoutMs` (15 minutes for a
209
+ job without timeout, `lockTtlMs`) plus the jitter and `lateAfterMs`.
210
+ - **With `InMemoryLock`**, the default of `kernelModule()`, the lock excludes inside one
211
+ process only: two instances both run every job. It is correct for a single instance and for
212
+ tests, and nothing more.
213
+ - **With a lock between processes** (`RedisLock` of `@ddd-ts-kit/redis`, bound to `Lock`), the
214
+ guarantee is real, and it is this one: **at most one run per occurrence, best effort**.
215
+
216
+ What is not guaranteed, so that you design for it:
217
+
218
+ - An occurrence may run **twice**: the lock expires while its holder is paused or the run
219
+ outlives the time to live and a late instance arrives; the lock store fails over and forgets
220
+ a key; an instance restarts with `missed: 'run-once'` after the lock of the occurrence it
221
+ catches up has expired; clocks disagree by more than the time to live.
222
+ - An occurrence may run **zero times**: the instance that took the lock crashes before or
223
+ during the run (nobody retries, the lock stays), every instance is down at the instant
224
+ (`missed`), or the lock store cannot be reached, in which case the occurrence is not run and
225
+ is recorded `failed`, `JOB_LOCK_UNAVAILABLE`, rather than run everywhere.
226
+ - `runNow` takes no lock: it is one deliberate run.
227
+
228
+ **Therefore a job is idempotent, or the command it sends is.** A run that happens twice must
229
+ leave the same state as once: derive what to do from the state ("invoices due and not yet
230
+ reminded"), not from the fact of being called, or key the work on `scheduledAt` behind a unique
231
+ constraint. The kernel says it of `Lock` itself: an efficiency tool, never the guard of an
232
+ invariant. Work that must happen exactly once and survive a crash is not a scheduled job but
233
+ a row in a table, processed by a job that is safe to repeat.
234
+
235
+ Instances should keep their clocks within a few seconds of each other (NTP): the instants are
236
+ computed from the `Clock`, and the lock key is the instant.
237
+
238
+ ## A dedicated worker process
239
+
240
+ By default every process that starts the application runs the jobs. To run them in one
241
+ deployment and serve requests in another, tell the others not to:
242
+
243
+ ```ts
244
+ // src/main.ts, the process that serves requests
245
+ const app = createApplication({ modules, jobs: { start: false } });
246
+
247
+ // src/apps/worker/main.ts, the process that runs the jobs: the same modules, no HTTP server
248
+ const app = createApplication({ modules, jobs: { settings: jobSettings(config) } });
249
+ await app.start();
250
+ process.on('SIGTERM', () => void app.stop());
251
+ ```
252
+
253
+ With `start: false` the jobs are still registered and described, and `runNow` still runs one,
254
+ which gives an operations endpoint for free. One composition root reading a flag
255
+ (`jobs: { start: config.runJobs }`) is the same thing with one entry point. Keep
256
+ `singleInstance` on even with one worker: a rolling deployment runs two for a while.
257
+
258
+ A context extracted as its own service takes its jobs with it: they are declarations of the
259
+ context, registered by the application that lists its module. Nothing is global.
260
+
261
+ In a project generated by the CLI: `ddd-ts-kit generate job` writes the job and its line in
262
+ `src/platform/job-settings.ts` (switched off with `JOB_<CONTEXT>_<JOB>_ENABLED=false`),
263
+ `main.ts` passes `jobs: { start: config.runJobs, settings: jobSettings(process.env) }`
264
+ (`RUN_JOBS`), and `ddd-ts-kit generate worker` writes `src/apps/worker/main.ts` and turns the
265
+ default of `RUN_JOBS` off for the API.
266
+
267
+ ## Lifecycle
268
+
269
+ `application.start()` starts the scheduler last, once the consumers are subscribed, and
270
+ `application.stop()` stops it first. `stop()` starts no new run, waits for the runs in
271
+ progress up to `graceMs` (30 s by default), then aborts their signals with a `JobAbortedError`
272
+ and returns; a run that waited in a queue is dropped and recorded `missed`. Set the grace
273
+ below the termination grace of the platform.
274
+
275
+ ### Replacing the scheduler
276
+
277
+ `kernelModule()` builds the scheduler on the kernel ports it binds: `Clock`, `Lock`,
278
+ `ExecutionScope`, `IdGenerator`, `Logger` and `Metrics`, with `reportFailedRuns` on the bound
279
+ `ErrorReporter` as `onRun`. To build it otherwise (with checkpoints, another grace period,
280
+ another observer), give the kernel module yours:
281
+
282
+ ```ts
283
+ import { Clock, ExecutionScope, IdGenerator, Lock, Logger, Metrics } from '@ddd-ts-kit/kernel';
284
+ import { createApplication, kernelModule } from '@ddd-ts-kit/di';
285
+ import { InProcessJobScheduler, JobCheckpoints } from '@ddd-ts-kit/scheduling';
286
+
287
+ const kernel = kernelModule({
288
+ jobScheduler: (resolve) =>
289
+ new InProcessJobScheduler({
290
+ clock: resolve(Clock),
291
+ // asked at each occurrence: building the scheduler must not open a connection
292
+ lock: { acquire: (key, options) => resolve(Lock).acquire(key, options) },
293
+ scope: resolve(ExecutionScope),
294
+ ids: resolve(IdGenerator),
295
+ logger: resolve(Logger),
296
+ metrics: resolve(Metrics),
297
+ checkpoints: resolve(JobCheckpoints),
298
+ graceMs: 10_000,
299
+ }),
300
+ });
301
+
302
+ const app = createApplication({ modules: [kernel, platformModule, billingModule] });
303
+ ```
304
+
305
+ with, in the module of the platform, `builder.bind(JobCheckpoints).toClass(KnexJobCheckpoints, [Database])`.
306
+ A later module replaces the scheduler the same way, with
307
+ `builder.rebind(JobScheduler).toFactory((resolve) => new InProcessJobScheduler({ ... }))`, and
308
+ so does another implementation of the `JobScheduler` port (on a database, on a queue).
309
+
310
+ Without `@ddd-ts-kit/di`:
311
+
312
+ ```ts
313
+ const scheduler = new InProcessJobScheduler({ clock, lock, scope, ids, logger, metrics });
314
+ scheduler.register({ name: 'billing.send-payment-reminders', schedule: { every: '15m' }, run: (execution) => job.run(execution) });
315
+ await scheduler.start();
316
+ ```
317
+
318
+ | Option | Default | |
319
+ |---|---|---|
320
+ | `clock`, `lock`, `scope`, `ids` | required | every instant comes from the `Clock`; the system clock is never read |
321
+ | `logger`, `metrics` | write and measure nothing | |
322
+ | `timers` | `systemJobTimers` | how it sleeps; `ManualJobTimers` in a test |
323
+ | `checkpoints` | none | see "Options" |
324
+ | `lateAfterMs` | `60000` | later than this, an occurrence is missed |
325
+ | `graceMs` | `30000` | how long `stop()` waits |
326
+ | `lockTtlMs` | `900000` | time to live of the lock of a job without `timeoutMs` |
327
+ | `random` | `Math.random` | draws the jitter |
328
+ | `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` |
329
+
330
+ It sleeps at most one hour at a time and computes again when it wakes, so a clock that is
331
+ corrected, or a suspended machine, is noticed within the hour.
332
+
333
+ ## Observability
334
+
335
+ `scheduler.describe()` answers, for each job, `name`, `schedule` (`every 15m`,
336
+ `cron 0 7 * * 1-5 (Europe/Paris)`), `enabled`, `running`, `nextRunAt` and `lastRun`, as data.
337
+ `application.describe().jobs` lists the declared jobs with the settings they run with.
338
+
339
+ Every occurrence handled has one outcome, recorded three times:
340
+
341
+ | Outcome | Means | Log level |
342
+ |---|---|---|
343
+ | `succeeded` | the job ran | info |
344
+ | `failed` | the job threw, timed out, was aborted, or its lock could not be asked | error |
345
+ | `skipped-overlap` | the previous run was still in progress | warn |
346
+ | `skipped-locked` | another instance runs this occurrence | debug |
347
+ | `missed` | the occurrence passed and was not run | warn |
348
+
349
+ - **Log**: one entry, `Scheduled job <outcome>`, with `job`, `context`, `outcome`,
350
+ `scheduledAt`, and for a run `durationMs`, `correlationId`, `errorCode`, `errorType`.
351
+ - **Metrics**: the counter `scheduled_job_runs_total` and, for a run, the histogram
352
+ `scheduled_job_duration_ms`, labelled `job`, `context`, `status` (the outcome) and
353
+ `error_code`. An alert on "no `succeeded` for this job since N intervals" catches every way a
354
+ job stops running, which an alert on failures does not.
355
+ - **`onRun`**: the same record, plus the error and the correlation id of the run.
356
+
357
+ `missed` is recorded once for a gap, with the first instant that was missed, not once per
358
+ instant. The `errorCode` is the `code` of what was thrown (a `DomainError`, `JOB_TIMEOUT`,
359
+ `JOB_ABORTED`, `JOB_LOCK_UNAVAILABLE`) or `UNEXPECTED_ERROR`. **The message of an error is
360
+ never recorded**: it quotes data. A job that fails stops neither the scheduler nor the other
361
+ jobs, and its next occurrence runs as usual: there is no retry, the next occurrence is the
362
+ retry.
363
+
364
+ ## Testing
365
+
366
+ A spec of a job is a spec of the use case it triggers: run the job, assert the fakes. Time is
367
+ not involved, and the test application of `@ddd-ts-kit/di` never starts jobs by time.
368
+
369
+ ```ts
370
+ const app = createTestApplication({ modules: [contextModule('billing', billing)], fakes: [fake(Mailer, mailer)] });
371
+
372
+ await app.jobs.runNow('billing.send-payment-reminders');
373
+
374
+ expect(mailer.sent).toHaveLength(2);
375
+ ```
376
+
377
+ `app.jobs.runNow(name)` runs the job as the scheduler would (scope of origin `schedule`,
378
+ system actor, lock aside) and throws what the job threw. For a spec about time itself,
379
+ `app.jobs.scheduler` is a `ManualJobScheduler` on the clock of the application:
380
+
381
+ ```ts
382
+ import { FixedClock, FixedExecutionScope, InMemoryLock, SequentialIdGenerator } from '@ddd-ts-kit/kernel';
383
+ import { ManualJobScheduler } from '@ddd-ts-kit/scheduling/testing';
384
+
385
+ const clock = new FixedClock(new Date('2026-03-02T09:58:30Z'));
386
+ const scheduler = new ManualJobScheduler({ clock, lock: new InMemoryLock(clock), scope: new FixedExecutionScope(), ids: new SequentialIdGenerator() });
387
+ scheduler.register({ name: 'billing.send-payment-reminders', schedule: { every: '15m' }, run });
388
+ await scheduler.start();
389
+
390
+ await scheduler.advance(32 * 60_000);
391
+
392
+ 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']);
393
+ ```
394
+
395
+ - `advance(ms)` moves the clock and fires each timer at its instant, in order. When it
396
+ resolves, every job that waits on promises alone has ended; a job the test keeps blocked is
397
+ still running, which is how overlap, timeout and stop are tested.
398
+ - `runs` lists every occurrence handled with its outcome, `failures` what the failed runs
399
+ threw, `runsOf(job)` the runs of one job. There is no jitter.
400
+ - `ManualJobTimers` alone drives an `InProcessJobScheduler` built by hand, for a spec that sets
401
+ `random` or `checkpoints`.
402
+ - Two schedulers sharing a clock and a lock are two instances: this is how the single-instance
403
+ behaviour is specified in this package.
404
+
405
+ Another scheduler (on a database, on a queue) implements the `JobScheduler` port of the kernel
406
+ and passes `jobSchedulerContract` of `@ddd-ts-kit/testing/contracts`.
407
+
408
+ ## Limits
409
+
410
+ - **No persistence of its own.** Runs, outcomes and `lastRun` live in the memory of the
411
+ instance; `describe()` is the view of one instance, and only `checkpoints` crosses a restart.
412
+ - **No retry and no backoff.** A failed occurrence is over. Put retry where the failure is: an
413
+ aspect on the adapter (`retry` of `@ddd-ts-kit/aspects`), or a command that is safe to send
414
+ again at the next occurrence.
415
+ - **Minute resolution for cron**, millisecond for intervals; an interval under a second is
416
+ possible and rarely wise, as each occurrence costs a lock.
417
+ - **At most once per occurrence is best effort**, as said above. Exactly-once does not exist
418
+ here.
419
+ - **Not a queue.** A job does a bounded amount of work on a schedule. Work that arrives, must
420
+ not be lost and must be retried belongs to the outbox and to integration events.
421
+ - **One process, one event loop.** A job that blocks the loop delays every other job; past
422
+ `lateAfterMs` their occurrences are missed.
423
+ - **Time zone rules are those of the runtime.** A zone whose rules changed needs an updated
424
+ Node. Two changes of offset within two days of each other, which no zone has, are not
425
+ handled.
@@ -0,0 +1,4 @@
1
+ export declare const UNEXPECTED_ERROR = "UNEXPECTED_ERROR";
2
+ /** The `code` of a `DomainError` or of a fault that carries a short one. Never a message. */
3
+ export declare function errorCodeOf(error: unknown): string;
4
+ //# sourceMappingURL=error-code-of.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-code-of.d.ts","sourceRoot":"","sources":["../../src/errors/error-code-of.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,gBAAgB,qBAAqB,CAAC;AAInD,6FAA6F;AAC7F,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAQlD"}
@@ -0,0 +1,11 @@
1
+ export const UNEXPECTED_ERROR = 'UNEXPECTED_ERROR';
2
+ const SHORT_CODE = /^[A-Za-z0-9_.:-]{1,64}$/;
3
+ /** The `code` of a `DomainError` or of a fault that carries a short one. Never a message. */
4
+ export function errorCodeOf(error) {
5
+ if (typeof error !== 'object' || error === null || !('code' in error)) {
6
+ return UNEXPECTED_ERROR;
7
+ }
8
+ const { code } = error;
9
+ return typeof code === 'string' && SHORT_CODE.test(code) ? code : UNEXPECTED_ERROR;
10
+ }
11
+ //# sourceMappingURL=error-code-of.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-code-of.js","sourceRoot":"","sources":["../../src/errors/error-code-of.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,gBAAgB,GAAG,kBAAkB,CAAC;AAEnD,MAAM,UAAU,GAAG,yBAAyB,CAAC;AAE7C,6FAA6F;AAC7F,MAAM,UAAU,WAAW,CAAC,KAAc;IACxC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,CAAC,MAAM,IAAI,KAAK,CAAC,EAAE,CAAC;QACtE,OAAO,gBAAgB,CAAC;IAC1B,CAAC;IAED,MAAM,EAAE,IAAI,EAAE,GAAG,KAAK,CAAC;IAEvB,OAAO,OAAO,IAAI,KAAK,QAAQ,IAAI,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,gBAAgB,CAAC;AACrF,CAAC"}
@@ -0,0 +1,6 @@
1
+ /** The scheduler stopped and the run outlived the grace period. It is the reason of the aborted signal. */
2
+ export declare class JobAbortedError extends Error {
3
+ readonly code = "JOB_ABORTED";
4
+ constructor(job: string);
5
+ }
6
+ //# sourceMappingURL=job-aborted-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"job-aborted-error.d.ts","sourceRoot":"","sources":["../../src/errors/job-aborted-error.ts"],"names":[],"mappings":"AAAA,2GAA2G;AAC3G,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,IAAI,iBAAiB;IAE9B,YAAY,GAAG,EAAE,MAAM,EAGtB;CACF"}
@@ -0,0 +1,9 @@
1
+ /** The scheduler stopped and the run outlived the grace period. It is the reason of the aborted signal. */
2
+ export class JobAbortedError extends Error {
3
+ code = 'JOB_ABORTED';
4
+ constructor(job) {
5
+ super(`Job "${job}" was still running when the scheduler stopped.`);
6
+ this.name = 'JobAbortedError';
7
+ }
8
+ }
9
+ //# sourceMappingURL=job-aborted-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"job-aborted-error.js","sourceRoot":"","sources":["../../src/errors/job-aborted-error.ts"],"names":[],"mappings":"AAAA,2GAA2G;AAC3G,MAAM,OAAO,eAAgB,SAAQ,KAAK;IAC/B,IAAI,GAAG,aAAa,CAAC;IAE9B,YAAY,GAAW;QACrB,KAAK,CAAC,QAAQ,GAAG,iDAAiD,CAAC,CAAC;QACpE,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;IAChC,CAAC;CACF"}
@@ -0,0 +1,6 @@
1
+ /** The lock could not be asked, so nothing says that no other instance runs the occurrence. */
2
+ export declare class JobLockUnavailableError extends Error {
3
+ readonly code = "JOB_LOCK_UNAVAILABLE";
4
+ constructor(job: string, options: ErrorOptions);
5
+ }
6
+ //# sourceMappingURL=job-lock-unavailable-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"job-lock-unavailable-error.d.ts","sourceRoot":"","sources":["../../src/errors/job-lock-unavailable-error.ts"],"names":[],"mappings":"AAAA,+FAA+F;AAC/F,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,QAAQ,CAAC,IAAI,0BAA0B;IAEvC,YAAY,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAG7C;CACF"}
@@ -0,0 +1,9 @@
1
+ /** The lock could not be asked, so nothing says that no other instance runs the occurrence. */
2
+ export class JobLockUnavailableError extends Error {
3
+ code = 'JOB_LOCK_UNAVAILABLE';
4
+ constructor(job, options) {
5
+ super(`The lock of job "${job}" could not be asked: the occurrence is not run.`, options);
6
+ this.name = 'JobLockUnavailableError';
7
+ }
8
+ }
9
+ //# sourceMappingURL=job-lock-unavailable-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"job-lock-unavailable-error.js","sourceRoot":"","sources":["../../src/errors/job-lock-unavailable-error.ts"],"names":[],"mappings":"AAAA,+FAA+F;AAC/F,MAAM,OAAO,uBAAwB,SAAQ,KAAK;IACvC,IAAI,GAAG,sBAAsB,CAAC;IAEvC,YAAY,GAAW,EAAE,OAAqB;QAC5C,KAAK,CAAC,oBAAoB,GAAG,kDAAkD,EAAE,OAAO,CAAC,CAAC;QAC1F,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC;IACxC,CAAC;CACF"}
@@ -0,0 +1,6 @@
1
+ /** A run lasted longer than the `timeoutMs` of its job. It is the reason of the aborted signal. */
2
+ export declare class JobTimeoutError extends Error {
3
+ readonly code = "JOB_TIMEOUT";
4
+ constructor(job: string, timeoutMs: number);
5
+ }
6
+ //# sourceMappingURL=job-timeout-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"job-timeout-error.d.ts","sourceRoot":"","sources":["../../src/errors/job-timeout-error.ts"],"names":[],"mappings":"AAAA,mGAAmG;AACnG,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,IAAI,iBAAiB;IAE9B,YAAY,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAGzC;CACF"}
@@ -0,0 +1,9 @@
1
+ /** A run lasted longer than the `timeoutMs` of its job. It is the reason of the aborted signal. */
2
+ export class JobTimeoutError extends Error {
3
+ code = 'JOB_TIMEOUT';
4
+ constructor(job, timeoutMs) {
5
+ super(`Job "${job}" ran longer than ${timeoutMs} ms.`);
6
+ this.name = 'JobTimeoutError';
7
+ }
8
+ }
9
+ //# sourceMappingURL=job-timeout-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"job-timeout-error.js","sourceRoot":"","sources":["../../src/errors/job-timeout-error.ts"],"names":[],"mappings":"AAAA,mGAAmG;AACnG,MAAM,OAAO,eAAgB,SAAQ,KAAK;IAC/B,IAAI,GAAG,aAAa,CAAC;IAE9B,YAAY,GAAW,EAAE,SAAiB;QACxC,KAAK,CAAC,QAAQ,GAAG,qBAAqB,SAAS,MAAM,CAAC,CAAC;QACvD,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;IAChC,CAAC;CACF"}
@@ -0,0 +1,66 @@
1
+ import { type Clock, type ExecutionScope, type IdGenerator, type JobDefinition, type JobDescription, type JobRun, type JobScheduler, type Lock, type Logger, type Metrics } from '@ddd-ts-kit/kernel';
2
+ import type { JobCheckpoints } from './job-checkpoints.js';
3
+ import type { JobRunObserver } from './job-run-observer.js';
4
+ import type { JobTimers } from './job-timers.js';
5
+ export interface InProcessJobSchedulerOptions {
6
+ /** Every instant comes from it: the system clock is never read. */
7
+ readonly clock: Clock;
8
+ /** Taken for each occurrence of a `singleInstance` job. Between processes only with an adapter that is. */
9
+ readonly lock: Lock;
10
+ /** Each run happens inside it, in a context of origin `schedule`. */
11
+ readonly scope: ExecutionScope;
12
+ /** Gives each run its correlation id. */
13
+ readonly ids: IdGenerator;
14
+ /** Default: writes nothing. */
15
+ readonly logger?: Logger;
16
+ /** Default: measures nothing. */
17
+ readonly metrics?: Metrics;
18
+ /** Default: the timers of the runtime. */
19
+ readonly timers?: JobTimers;
20
+ /** Default: none, and a restart forgets what passed while the process was down. */
21
+ readonly checkpoints?: JobCheckpoints;
22
+ /** Draws the jitter, in [0, 1). Default `Math.random`. */
23
+ readonly random?: () => number;
24
+ /** An occurrence reached later than this, jitter aside, is missed. Default 60 000. */
25
+ readonly lateAfterMs?: number;
26
+ /** How long `stop()` waits for the runs in progress before aborting them. Default 30 000. */
27
+ readonly graceMs?: number;
28
+ /** How long the lock of an occurrence is kept for a job without `timeoutMs`. Default 900 000. */
29
+ readonly lockTtlMs?: number;
30
+ readonly onRun?: JobRunObserver;
31
+ }
32
+ /**
33
+ * The scheduler inside one process. Instants come from the `Clock`, sleeps go through the
34
+ * timers, and nothing is remembered across a restart unless `checkpoints` is given. Several
35
+ * instances of an application each run one: the `Lock` decides which of them runs an occurrence.
36
+ */
37
+ export declare class InProcessJobScheduler implements JobScheduler {
38
+ private readonly jobs;
39
+ private readonly ticks;
40
+ private readonly runsInProgress;
41
+ private readonly settings;
42
+ private readonly recorder;
43
+ private readonly runner;
44
+ private started;
45
+ private stopping;
46
+ constructor(options: InProcessJobSchedulerOptions);
47
+ register(definition: JobDefinition): void;
48
+ start(): Promise<void>;
49
+ stop(): Promise<void>;
50
+ runNow(name: string): Promise<JobRun>;
51
+ describe(): readonly JobDescription[];
52
+ /** A tick never rejects: what goes wrong while following a job is logged, and the others go on. */
53
+ private follow;
54
+ private begin;
55
+ /** A checkpoint that cannot be read is no reason to leave the job asleep: it starts from now. */
56
+ private remembered;
57
+ private tick;
58
+ /** Of the occurrences that became due, the latest is run when it is on time or owed; the others are missed. */
59
+ private handleDue;
60
+ private sleep;
61
+ private trigger;
62
+ private occupy;
63
+ /** A run that waited behind another is dropped when the scheduler is stopping. */
64
+ private afterPrevious;
65
+ }
66
+ //# sourceMappingURL=in-process-job-scheduler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"in-process-job-scheduler.d.ts","sourceRoot":"","sources":["../src/in-process-job-scheduler.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,KAAK,EACV,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,MAAM,EACX,KAAK,YAAY,EACjB,KAAK,IAAI,EACT,KAAK,MAAM,EACX,KAAK,OAAO,EACb,MAAM,oBAAoB,CAAC;AAE5B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE5D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAOjD,MAAM,WAAW,4BAA4B;IAC3C,mEAAmE;IACnE,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,2GAA2G;IAC3G,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IACpB,qEAAqE;IACrE,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,yCAAyC;IACzC,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAC;IAC1B,+BAA+B;IAC/B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,iCAAiC;IACjC,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,0CAA0C;IAC1C,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B,mFAAmF;IACnF,QAAQ,CAAC,WAAW,CAAC,EAAE,cAAc,CAAC;IACtC,0DAA0D;IAC1D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;IAC/B,sFAAsF;IACtF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,6FAA6F;IAC7F,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,iGAAiG;IACjG,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,CAAC,EAAE,cAAc,CAAC;CACjC;AAYD;;;;GAIG;AACH,qBAAa,qBAAsB,YAAW,YAAY;IACxD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAoC;IAEzD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4B;IAElD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA8B;IAE7D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAW;IAEpC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAc;IAEvC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAY;IAEnC,OAAO,CAAC,OAAO,CAAS;IAExB,OAAO,CAAC,QAAQ,CAAS;IAEzB,YAAY,OAAO,EAAE,4BAA4B,EA4BhD;IAED,QAAQ,CAAC,UAAU,EAAE,aAAa,GAAG,IAAI,CAYxC;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAO3B;IAEK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAmB1B;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ1C;IAED,QAAQ,IAAI,SAAS,cAAc,EAAE,CASpC;IAED,mGAAmG;IACnG,OAAO,CAAC,MAAM;YAUA,KAAK;IASnB,iGAAiG;YACnF,UAAU;YAUV,IAAI;IAQlB,+GAA+G;YACjG,SAAS;IA0BvB,OAAO,CAAC,KAAK;IAcb,OAAO,CAAC,OAAO;IAmBf,OAAO,CAAC,MAAM;IAed,kFAAkF;IAClF,OAAO,CAAC,aAAa;CAKtB"}