@ddd-ts-kit/scheduling 0.15.0 → 0.16.1

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 (3) hide show
  1. package/README.fr.md +728 -0
  2. package/README.md +223 -30
  3. package/package.json +3 -3
package/README.fr.md ADDED
@@ -0,0 +1,728 @@
1
+ [English](README.md) · **Français**
2
+ <!-- translation-of: README.md sha256:2f1532d34dd0d64d -->
3
+
4
+ # @ddd-ts-kit/scheduling
5
+
6
+ Les *jobs* planifiés et les *jobs* en arrière-plan d'un projet construit avec le kit. Un *job*
7
+ planifié est une classe de la couche application qui dit quand elle s'exécute et déclenche des
8
+ cas d'usage : il ne décide rien, les règles métier restent dans le domaine. Le planificateur ouvre
9
+ une portée d'exécution autour de chaque exécution, s'assure qu'une seule instance de l'application
10
+ exécute une occurrence donnée, et enregistre ce qu'il en est advenu. Un *job* en arrière-plan est
11
+ mis en file par un cas d'usage et exécuté plus tard par le *worker* de ce *package* (voir « Jobs
12
+ en arrière-plan » plus bas). Pourquoi un *job* ne fait que déclencher un cas d'usage, et comment
13
+ en concevoir un qui supporte d'être exécuté deux fois : [Jobs planifiés](../../docs/concepts/scheduled-jobs.fr.md).
14
+
15
+ ```
16
+ npm i @ddd-ts-kit/scheduling
17
+ ```
18
+
19
+ | Point d'entrée | Contient | Importe |
20
+ |---|---|---|
21
+ | `@ddd-ts-kit/scheduling` | `InProcessJobScheduler`, `nextOccurrence`, `latestOccurrence`, `systemJobTimers`, les ports `JobTimers` et `JobCheckpoints`, `JobTimeoutError`, `JobAbortedError`, `JobLockUnavailableError`, `reportFailedRuns`, `UNEXPECTED_ERROR`, les noms des métriques ; pour les *jobs* en arrière-plan `QueueWorker`, `StoredJobQueue`, le port `JobStore`, `InMemoryJobStore`, `tenantKeyOf`, `reportFailedBackgroundJobs`, `JobInterruptedError`, `InvalidEnqueueError`, `UnknownBackgroundJobError`, `AttemptsExhaustedError` ; pour les gestionnaires de processus `StoredProcessRunner`, `NoTimeoutJobError` | le kernel |
22
+ | `@ddd-ts-kit/scheduling/testing` | `ManualJobScheduler`, `ManualJobTimers` | le kernel |
23
+
24
+ Les déclarations (`@Scheduled`, `@Background`), les ports (`JobScheduler`, `JobQueue`,
25
+ `ProcessRunner`) et l'analyseur des planifications vivent dans `@ddd-ts-kit/kernel`, si bien que
26
+ `application/` importe le kernel et rien d'autre. `@ddd-ts-kit/di` lie `JobScheduler` à un
27
+ `InProcessJobScheduler` dans son module kernel et enregistre les *jobs* déclarés : une application
28
+ construite avec lui n'a besoin d'aucune ligne de ce README, hormis le *job* lui-même.
29
+
30
+ ![La couche application d'un contexte délimité déclare ses jobs à l'aide du kernel, qui porte les déclarations et les ports JobScheduler, JobQueue et ProcessRunner, et n'importe jamais ce package ; ce package implémente ces ports, exécute les jobs et appelle le Lock, le JobStore et les JobCheckpoints que lie la plateforme ; le module kernel de @ddd-ts-kit/di lie chaque port à son implémentation et enregistre les jobs déclarés](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/scheduling-package-place.fr.svg)
31
+
32
+ `JobScheduler` n'est pas le `Scheduler` de `@ddd-ts-kit/aspects`. Celui-là est un minuteur pour
33
+ les nouvelles tentatives et les délais d'expiration (`schedule(work, delayMs)`) ; celui-ci connaît
34
+ les *jobs*, les planifications et les instances.
35
+
36
+ ## Un job
37
+
38
+ <!-- checked sample -->
39
+ ```ts
40
+ // src/contexts/billing/application/scheduling/send-payment-reminders.job.ts
41
+ import { AuthorizedCommand, CommandBus, systemActor, type Actor } from '@ddd-ts-kit/kernel';
42
+ import { Scheduled, type JobExecution, type ScheduledJob } from '@ddd-ts-kit/kernel/declarations';
43
+
44
+ // la commande du cas d'usage, dans application/commands/send-payment-reminders/
45
+ declare class SendPaymentReminders extends AuthorizedCommand {
46
+ constructor(actor: Actor, dueOn: Date);
47
+ }
48
+
49
+ @Scheduled('billing.send-payment-reminders', { cron: '0 7 * * 1-5', timeZone: 'Europe/Paris' })
50
+ export class SendPaymentRemindersJob implements ScheduledJob {
51
+ constructor(private readonly commands: CommandBus) {}
52
+
53
+ async run(execution: JobExecution): Promise<void> {
54
+ await this.commands.execute(
55
+ new SendPaymentReminders(systemActor('billing.send-payment-reminders'), execution.scheduledAt),
56
+ );
57
+ }
58
+ }
59
+ ```
60
+
61
+ - **Où** : `application/scheduling/`, dans un fichier nommé `*.job.ts`, un *job* par fichier.
62
+ - **Le nom** est `<context>.<job-name>`, en minuscules, écrit comme une chaîne littérale et
63
+ unique dans l'application : c'est l'acteur système sous lequel le *job* s'exécute, la clé de
64
+ son verrou, son libellé dans les métriques et la clé de ses réglages. Il survit au renommage de
65
+ la classe.
66
+ - **Ses dépendances** sont ce que prend son constructeur, chaque paramètre typé par un port, comme
67
+ pour toute déclaration du kit : `constructor(private readonly commands: CommandBus) {}`, sans
68
+ liste dans le décorateur. Un *job* reçoit des bus et des ports. Il ne reçoit pas de
69
+ *repository* : ce qu'il en ferait est un cas d'usage.
70
+ - **`run(execution)`** reçoit :
71
+
72
+ | Champ | |
73
+ |---|---|
74
+ | `scheduledAt` | l'instant où l'exécution était due. Passez-le à la commande : « les rappels de ce matin-là » sont le même travail, quel que soit le moment où il s'exécute réellement |
75
+ | `startedAt` | l'instant où elle a commencé : plus tard, du fait de la gigue, d'une attente en file ou d'un rattrapage |
76
+ | `attempt` | 1 avec ce planificateur, qui ne retente jamais |
77
+ | `signal` | annulé quand l'exécution dépasse son délai d'expiration ou que le planificateur s'arrête ; un *job* long le vérifie entre deux étapes |
78
+
79
+ - **La commande est envoyée sous l'acteur système du *job***, comme le fait un gestionnaire
80
+ d'événement d'intégration pour son consommateur. Avec `@ddd-ts-kit/auth`, accordez à cet acteur
81
+ la permission de la commande, ou déclarez la commande `@Public()` quand rien d'autre ne peut
82
+ l'envoyer.
83
+ - **Vérifié au chargement du fichier** : un nom qui n'est pas `<context>.<job-name>`, un cron
84
+ illisible ou qui ne se déclenche jamais, un intervalle nul, un fuseau horaire inconnu et une
85
+ option hors de ses valeurs lèvent une `DeclarationError` qui nomme le *job*.
86
+
87
+ ### La portée d'exécution
88
+
89
+ Le planificateur ouvre une `ExecutionScope` autour de chaque exécution, si bien que le *job* n'en
90
+ écrit rien :
91
+
92
+ | Champ du contexte | Valeur |
93
+ |---|---|
94
+ | `origin` | `schedule` |
95
+ | `actor` | `systemActor('<job name>')` |
96
+ | `correlationId` | un nouveau à chaque exécution, tiré de l'`IdGenerator` |
97
+ | `scheduledAt` | l'instant où l'exécution était due |
98
+ | `deadline` | le démarrage plus `timeoutMs`, quand le *job* en a un |
99
+
100
+ Les colonnes d'audit, le journal d'audit, les champs des journaux, le traçage et la corrélation
101
+ apposée sur les événements publiés fonctionnent donc comme pour une requête.
102
+
103
+ ### Global ou par tenant
104
+
105
+ Un *job* le dit, explicitement. `tenancy: 'global'` (la valeur par défaut) s'exécute une fois par
106
+ occurrence dans aucun *tenant* : les utilitaires de persistance qui en exigent un
107
+ (`tenantScopeOf`) le refusent, et un cas d'usage qui travaille par *tenant* reçoit le *tenant*
108
+ dans sa commande. `tenancy: 'per-tenant'` s'exécute une fois par *tenant* du `TenantDirectory`
109
+ donné au planificateur (option `tenants` ; enregistrer un tel *job* sans elle lève
110
+ `InvalidJobScheduleError`) :
111
+
112
+ <!-- checked sample -->
113
+ ```ts
114
+ import {
115
+ InMemoryTenantDirectory,
116
+ type Clock,
117
+ type ExecutionScope,
118
+ type IdGenerator,
119
+ type JobExecution,
120
+ type Lock,
121
+ } from '@ddd-ts-kit/kernel';
122
+ import { InProcessJobScheduler } from '@ddd-ts-kit/scheduling';
123
+
124
+ declare const clock: Clock, lock: Lock, scope: ExecutionScope, ids: IdGenerator;
125
+ declare function closeMonthOf(tenantId: string, month: Date): Promise<void>;
126
+
127
+ const scheduler = new InProcessJobScheduler({ clock, lock, scope, ids, tenants: new InMemoryTenantDirectory(['acme', 'globex']) });
128
+
129
+ scheduler.register({
130
+ name: 'billing.close-month',
131
+ schedule: { cron: '0 2 1 * *' },
132
+ tenancy: 'per-tenant',
133
+ // appelé deux fois par occurrence : une fois avec le tenantId 'acme', puis une fois avec 'globex'
134
+ run: async (execution: JobExecution) => {
135
+ if (execution.tenantId !== undefined) {
136
+ await closeMonthOf(execution.tenantId, execution.scheduledAt);
137
+ }
138
+ },
139
+ });
140
+ ```
141
+
142
+ L'exécution de chaque *tenant* a lieu dans sa propre portée d'exécution, `tenantId` renseigné, et
143
+ `execution.tenantId` dit au *job* lequel ; les exécutions d'une même occurrence partagent son
144
+ identifiant de corrélation et s'exécutent l'une après l'autre. Un *tenant* qui échoue n'arrête pas
145
+ les autres : l'occurrence échoue avec la première erreur une fois que chaque *tenant* a eu son
146
+ exécution. Un signal annulé (délai d'expiration, arrêt) s'arrête avant le *tenant* suivant.
147
+
148
+ ## Planifications
149
+
150
+ ```ts
151
+ { every: '15m' } // ou un nombre de millisecondes
152
+ { cron: '0 7 * * 1-5', timeZone: 'Europe/Paris' } // timeZone vaut UTC par défaut
153
+ ```
154
+
155
+ **Les intervalles** sont un nombre entier de millisecondes, ou un nombre et une unité : `'500ms'`,
156
+ `'30s'`, `'15m'`, `'2h'`, `'1d'`. Ils sont **alignés sur l'époque Unix**, pas sur le démarrage du
157
+ processus : `'15m'` tombe à :00, :15, :30 et :45, `'1d'` à minuit UTC. C'est ce qui fait que
158
+ chaque instance nomme les mêmes instants, et qu'un redémarrage les conserve. Un intervalle qui ne
159
+ divise pas une journée (`'7h'`) dérive par rapport à l'heure murale, et aucun ne suit un fuseau
160
+ horaire : pour « chaque matin », écrivez un cron.
161
+
162
+ ![Deux instances démarrées à 10:07 et à 10:11 : alignées sur l'époque, les deux nomment les occurrences 10:15, 10:30, 10:45 et 11:00, donc une clé de verrou par occurrence et une seule instance l'exécute ; en comptant depuis le démarrage de chaque processus, la première nommerait 10:22, 10:37, 10:52 et la seconde 10:26, 10:41, 10:56, des clés différentes, et les deux exécuteraient](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/scheduling-package-interval-alignment.fr.svg)
163
+
164
+ **Le cron** a les cinq champs standard, sans les secondes :
165
+
166
+ | Champ | Valeurs | Noms |
167
+ |---|---|---|
168
+ | minute | 0-59 | |
169
+ | heure | 0-23 | |
170
+ | jour du mois | 1-31 | |
171
+ | mois | 1-12 | `jan` à `dec` |
172
+ | jour de la semaine | 0-7, 0 et 7 sont le dimanche | `sun` à `sat` |
173
+
174
+ Chaque champ accepte `*`, une valeur, un intervalle `a-b`, un pas sur l'un ou l'autre (`*/15`,
175
+ `8-18/2`) et des listes de ceux-ci (`0,30`, `mon-fri`). Les noms ignorent la casse. Quand le jour
176
+ du mois et le jour de la semaine sont tous deux restreints (aucun ne commence par `*`), un jour qui
177
+ convient à l'un ou à l'autre convient, comme dans tout cron : `0 0 13 * fri` est le 13 et chaque
178
+ vendredi. Non pris en charge : `@daily` et ses semblables, `?`, `L`, `W`, `#`, un pas sur une
179
+ valeur seule (`5/15`), un intervalle qui fait le tour (`fri-mon`).
180
+
181
+ **Les fuseaux horaires** sont des noms IANA, évalués avec `Intl.DateTimeFormat`, sans dépendance :
182
+ les règles sont celles du *runtime*, mises à jour avec lui. Les champs sont lus sur les horloges
183
+ du fuseau, champs de jour compris.
184
+
185
+ | Quand les horloges sont | Un *job* fixé à une heure (`30 2 * * *`) | Un *job* de chaque heure (`0 * * * *`, `*/20 * * * *`) |
186
+ |---|---|---|
187
+ | avancées (de 02:00 à 03:00) | une heure locale qui n'existe pas est **sautée** : aucune exécution ce jour-là, rien n'est déplacé à 03:00 | garde son rythme : 01:00, puis 03:00, une heure plus tard |
188
+ | reculées (de 03:00 à 02:00) | une heure locale qui survient deux fois **s'exécute une fois**, la première | garde son rythme : l'heure répétée est parcourue deux fois |
189
+
190
+ Un *job* est « de chaque heure » quand son champ d'heure commence par `*` : il suit le temps
191
+ écoulé. Tout autre *job* suit les horloges. Ne planifiez pas dans l'heure qui change ce qui doit
192
+ s'exécuter chaque jour : `30 2 * * *` en `Europe/Paris` n'a aucune exécution le dernier dimanche
193
+ de mars.
194
+
195
+ `nextOccurrence(parseJobSchedule(schedule), after)` et `latestOccurrence(parsed, { after,
196
+ notAfter })` donnent les instants, pour un écran qui montre quand un *job* s'exécute la prochaine
197
+ fois, ou pour une *spec* qui fige une planification avant son déploiement :
198
+
199
+ <!-- checked sample -->
200
+ ```ts
201
+ import { describeJobSchedule, parseJobSchedule } from '@ddd-ts-kit/kernel';
202
+ import { latestOccurrence, nextOccurrence } from '@ddd-ts-kit/scheduling';
203
+
204
+ const every15 = parseJobSchedule({ every: '15m' });
205
+ nextOccurrence(every15, new Date('2026-03-02T10:07:00Z')); // 2026-03-02T10:15:00Z, quel que soit le démarrage du processus
206
+
207
+ const weekdayMornings = { cron: '0 7 * * 1-5', timeZone: 'Europe/Paris' };
208
+ const parsed = parseJobSchedule(weekdayMornings);
209
+ describeJobSchedule(weekdayMornings); // 'cron 0 7 * * 1-5 (Europe/Paris)'
210
+ nextOccurrence(parsed, new Date('2026-03-06T08:00:00Z')); // lundi 2026-03-09T06:00:00Z : 07:00 à Paris, UTC+1
211
+ latestOccurrence(parsed, { after: new Date('2026-03-02T00:00:00Z'), notAfter: new Date('2026-03-04T12:00:00Z') }); // 2026-03-04T06:00:00Z
212
+
213
+ // 02:30 n'existe pas à Paris le 2026-03-29 : ce jour-là n'a aucune exécution
214
+ nextOccurrence(parseJobSchedule({ cron: '30 2 * * *', timeZone: 'Europe/Paris' }), new Date('2026-03-28T12:00:00Z')); // 2026-03-30T00:30:00Z
215
+ ```
216
+
217
+ ## Options
218
+
219
+ Écrites à côté de la planification : `{ every: '15m', overlap: 'queue', timeoutMs: 60_000 }`.
220
+
221
+ | Option | Par défaut | |
222
+ |---|---|---|
223
+ | `overlap` | `'skip'` | une occurrence qui arrive pendant que l'exécution précédente est en cours n'est pas démarrée (`skipped-overlap`). `'queue'` la démarre quand l'exécution se termine ; une occurrence attend au plus, les suivantes sont sautées |
224
+ | `missed` | `'skip'` | les occurrences que le planificateur n'a pas atteintes à temps sont abandonnées (`missed`) et le *job* attend la suivante. `'run-once'` exécute la plus récente d'entre elles, une fois, quel que soit le nombre écoulé |
225
+ | `timeoutMs` | aucun | le signal d'une exécution plus longue est annulé avec une `JobTimeoutError` et l'exécution est enregistrée `failed`, `JOB_TIMEOUT` |
226
+ | `jitterMs` | `0` | un délai aléatoire, jusqu'à cette valeur, avant chaque exécution, pour que les *jobs* ne démarrent pas tous à la minute pile |
227
+ | `enabled` | `true` | `false` ne démarre jamais le *job* selon l'heure ; `runNow` l'exécute quand même |
228
+ | `singleInstance` | `true` | une seule instance exécute une occurrence : voir plus bas |
229
+
230
+ Chaque option agit à une étape d'une occurrence, et chaque étape qui l'arrête enregistre une issue
231
+ (voir « Observabilité ») :
232
+
233
+ ![Une occurrence devient due ; atteinte plus tard que lateAfterMs avec missed skip, elle est enregistrée missed ; avec l'exécution précédente encore en cours et overlap skip, skipped-overlap ; pour un job singleInstance, le planificateur prend le verrou de l'occurrence, détenu par une autre instance il enregistre skipped-locked, injoignable failed avec JOB_LOCK_UNAVAILABLE ; une fois le verrou pris, le job s'exécute dans une portée d'exécution et réussit, ou échoue avec son code, JOB_TIMEOUT ou JOB_ABORTED ; chaque issue est journalisée, comptée et donnée à onRun](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/scheduling-package-one-occurrence.fr.svg)
234
+
235
+ **Manquée** veut dire atteinte plus tard que `lateAfterMs` (60 s par défaut, une option du
236
+ planificateur) après l'instant, gigue mise à part : le processus était arrêté, en veille, ou sa
237
+ boucle d'événements bloquée. Un minuteur en retard de quelques secondes exécute quand même son
238
+ occurrence. Ce qui est passé **pendant que le processus était arrêté** n'est connu qu'avec
239
+ `checkpoints` :
240
+
241
+ ```ts
242
+ // le port, exporté par ce package
243
+ export abstract class JobCheckpoints {
244
+ abstract read(job: string): Promise<Date | undefined>;
245
+
246
+ abstract write(job: string, handled: Date): Promise<void>;
247
+ }
248
+ ```
249
+
250
+ Le planificateur y écrit la dernière occurrence qu'il a traitée pour chaque *job* et la lit à son
251
+ démarrage. Sans lui, un redémarrage oublie : chaque *job* repart de l'instant du démarrage, et
252
+ `run-once` ne couvre que ce qui a été manqué pendant que le processus était vivant. Le store est
253
+ le vôtre, une table ou une clé partagée par les instances. Il est donné au planificateur là où le
254
+ planificateur est construit : voir « Remplacer le planificateur ».
255
+
256
+ Un adaptateur sur un client SQL à vous, une ligne par *job* :
257
+
258
+ <!-- checked sample -->
259
+ ```ts
260
+ // src/platform/scheduling/sql-job-checkpoints.ts
261
+ import { JobCheckpoints } from '@ddd-ts-kit/scheduling';
262
+
263
+ // le client de votre base de données, un port que lie votre plateforme
264
+ declare abstract class Sql {
265
+ abstract rows<Row>(text: string, values: readonly unknown[]): Promise<readonly Row[]>;
266
+ abstract execute(text: string, values: readonly unknown[]): Promise<void>;
267
+ }
268
+
269
+ export class SqlJobCheckpoints implements JobCheckpoints {
270
+ constructor(private readonly sql: Sql) {}
271
+
272
+ async read(job: string): Promise<Date | undefined> {
273
+ const [row] = await this.sql.rows<{ handled_at: Date }>('select handled_at from job_checkpoints where job = $1', [job]);
274
+
275
+ return row?.handled_at;
276
+ }
277
+
278
+ async write(job: string, handled: Date): Promise<void> {
279
+ await this.sql.execute(
280
+ `insert into job_checkpoints (job, handled_at) values ($1, $2)
281
+ on conflict (job) do update set handled_at = greatest(job_checkpoints.handled_at, excluded.handled_at)`,
282
+ [job, handled],
283
+ );
284
+ }
285
+ }
286
+ ```
287
+
288
+ `greatest` garde l'instant le plus récent quand deux instances écrivent en même temps : le point
289
+ de reprise ne fait qu'avancer.
290
+
291
+ **Un délai d'expiration ne tue pas.** Une exécution est une promesse, et rien ne peut arrêter une
292
+ promesse : le planificateur annule le signal, cesse d'attendre et libère le *job* pour sa
293
+ prochaine occurrence. Un *job* qui ignore son signal continue en arrière-plan, puis chevauche
294
+ l'exécution suivante. Transmettez le signal, ou vérifiez-le.
295
+
296
+ ```ts
297
+ // au lieu de : une boucle qui ne regarde jamais le signal, et envoie encore des commandes après le délai d'expiration
298
+ for (const batch of batches) {
299
+ await this.commands.execute(new ArchiveBatch(actor, batch, execution.scheduledAt));
300
+ }
301
+ ```
302
+
303
+ <!-- checked sample -->
304
+ ```ts
305
+ import { AuthorizedCommand, CommandBus, systemActor, type Actor } from '@ddd-ts-kit/kernel';
306
+ import { Scheduled, type JobExecution, type ScheduledJob } from '@ddd-ts-kit/kernel/declarations';
307
+
308
+ declare class ArchiveBatch extends AuthorizedCommand {
309
+ constructor(actor: Actor, batch: number, asOf: Date);
310
+ }
311
+
312
+ const BATCHES = 20;
313
+
314
+ // écrivez : s'arrêter entre deux étapes dès que l'exécution est annulée (délai d'expiration ou stop())
315
+ @Scheduled('archive.archive-old-records', { cron: '0 3 * * *', timeoutMs: 10 * 60_000 })
316
+ export class ArchiveOldRecordsJob implements ScheduledJob {
317
+ constructor(private readonly commands: CommandBus) {}
318
+
319
+ async run(execution: JobExecution): Promise<void> {
320
+ const actor = systemActor('archive.archive-old-records');
321
+
322
+ for (let batch = 0; batch < BATCHES; batch += 1) {
323
+ execution.signal.throwIfAborted(); // lève la JobTimeoutError ou la JobAbortedError
324
+ await this.commands.execute(new ArchiveBatch(actor, batch, execution.scheduledAt));
325
+ }
326
+ }
327
+ }
328
+ ```
329
+
330
+ Rendez la commande de chaque lot sûre à renvoyer : ce qu'un délai d'expiration laisse inachevé,
331
+ l'occurrence suivante le reprend à partir de l'état.
332
+
333
+ ## Désactiver un job, par environnement
334
+
335
+ Un *job* se désactive, se replanifie ou se règle par la configuration, jamais par un changement de
336
+ code. Avec `@ddd-ts-kit/di` :
337
+
338
+ <!-- checked sample -->
339
+ ```ts
340
+ import { createApplication, type ContextModule } from '@ddd-ts-kit/di';
341
+
342
+ declare const modules: readonly ContextModule[];
343
+ declare const config: { readonly jobs: { readonly reminders: { readonly enabled: boolean }; readonly ledgerCron: string } };
344
+
345
+ createApplication({
346
+ modules,
347
+ jobs: {
348
+ settings: {
349
+ 'billing.send-payment-reminders': { enabled: config.jobs.reminders.enabled },
350
+ 'billing.export-ledger': { schedule: { cron: config.jobs.ledgerCron, timeZone: 'Europe/Paris' } },
351
+ },
352
+ },
353
+ });
354
+ ```
355
+
356
+ Les réglages d'un *job* qu'aucun module ne déclare sont refusés (`UNKNOWN_JOB`), si bien qu'un
357
+ *job* renommé ne peut pas rester actif par une clé périmée ; les réglages illisibles sont refusés
358
+ aussi (`INVALID_JOB_SETTINGS`).
359
+
360
+ ## Plusieurs instances
361
+
362
+ Chaque instance d'une application mise à l'échelle horizontalement exécute un planificateur et
363
+ calcule les mêmes instants. Avant d'exécuter une occurrence d'un *job* `singleInstance`, le
364
+ planificateur prend le `Lock` du kernel sous la clé `job:<name>:<scheduled instant>`. L'instance
365
+ qui l'obtient exécute ; les autres enregistrent `skipped-locked`.
366
+
367
+ - **Le verrou n'est pas libéré à la fin de l'exécution.** Une instance dont le minuteur se
368
+ déclenche un peu plus tard le prendrait et exécuterait l'occurrence à nouveau. Il est gardé
369
+ jusqu'à ce que cette instance traite l'occurrence suivante du *job*, ou jusqu'à ce que sa durée
370
+ de vie soit écoulée : `timeoutMs` (15 minutes pour un *job* sans délai d'expiration,
371
+ `lockTtlMs`) plus la gigue et `lateAfterMs`.
372
+ - **Avec `InMemoryLock`**, la valeur par défaut de `kernelModule()`, le verrou n'exclut qu'à
373
+ l'intérieur d'un processus : deux instances exécutent toutes deux chaque *job*. Il convient pour
374
+ une instance unique et pour les tests, et rien de plus.
375
+ - **Avec un verrou entre processus** (`RedisLock` de `@ddd-ts-kit/redis`, lié à `Lock`), la
376
+ garantie est réelle, et c'est celle-ci : **au plus une exécution par occurrence, au mieux**.
377
+
378
+ Ce qui n'est pas garanti, pour que vous en teniez compte dans la conception :
379
+
380
+ - Une occurrence peut s'exécuter **deux fois** : le verrou expire pendant que son détenteur est en
381
+ pause ou que l'exécution survit à la durée de vie et qu'une instance en retard arrive ; le store
382
+ du verrou bascule et oublie une clé ; une instance redémarre avec `missed: 'run-once'` après
383
+ l'expiration du verrou de l'occurrence qu'elle rattrape ; les horloges divergent de plus que la
384
+ durée de vie.
385
+ - Une occurrence peut s'exécuter **zéro fois** : l'instance qui a pris le verrou plante avant ou
386
+ pendant l'exécution (personne ne retente, le verrou reste), toutes les instances sont arrêtées à
387
+ l'instant voulu (`missed`), ou le store du verrou est injoignable, auquel cas l'occurrence n'est
388
+ pas exécutée et est enregistrée `failed`, `JOB_LOCK_UNAVAILABLE`, plutôt qu'exécutée partout.
389
+ - `runNow` ne prend aucun verrou : c'est une exécution délibérée.
390
+
391
+ **Un *job* est donc idempotent, ou la commande qu'il envoie l'est.** Une exécution qui a lieu deux
392
+ fois doit laisser le même état qu'une seule : déduisez ce qu'il faut faire de l'état (« les
393
+ factures échues et pas encore relancées »), pas du fait d'être appelé, ou faites de
394
+ `scheduledAt` la clé du travail, derrière une contrainte d'unicité. Le kernel le dit de `Lock` lui-même : un outil
395
+ d'efficacité, jamais la garde d'un invariant. Un travail qui doit avoir lieu exactement une fois
396
+ et survivre à un plantage n'est pas un *job* planifié mais une ligne dans une table, traitée par un
397
+ *job* qui supporte d'être répété.
398
+
399
+ Les instances devraient garder leurs horloges à quelques secondes les unes des autres (NTP) : les
400
+ instants sont calculés à partir de la `Clock`, et la clé du verrou est l'instant.
401
+
402
+ ## Un processus worker dédié
403
+
404
+ Par défaut, chaque processus qui démarre l'application exécute les *jobs*. Pour les exécuter dans
405
+ un déploiement et servir les requêtes dans un autre, dites aux autres de ne pas le faire :
406
+
407
+ ```ts
408
+ // src/main.ts, le processus qui sert les requêtes
409
+ const app = createApplication({ modules, jobs: { start: false } });
410
+
411
+ // src/apps/worker/main.ts, le processus qui exécute les jobs : les mêmes modules, pas de serveur HTTP
412
+ const app = createApplication({ modules, jobs: { settings: jobSettings(config) } });
413
+ await app.start();
414
+ process.on('SIGTERM', () => void app.stop());
415
+ ```
416
+
417
+ Avec `start: false`, les *jobs* sont toujours enregistrés et décrits, et `runNow` en exécute
418
+ toujours un, ce qui donne gratuitement un point d'accès (endpoint) d'exploitation. Une seule racine
419
+ de composition qui lit un indicateur (`jobs: { start: config.runJobs }`) revient au même avec un
420
+ seul point d'entrée. Gardez `singleInstance` activé même avec un seul *worker* : un déploiement
421
+ progressif en fait tourner deux pendant un moment.
422
+
423
+ Un contexte extrait en service à part emporte ses *jobs* avec lui : ce sont des déclarations du
424
+ contexte, enregistrées par l'application qui liste son module. Rien n'est global.
425
+
426
+ Dans un projet généré par la CLI : `ddd-ts-kit generate job` écrit le *job* et sa ligne dans
427
+ `src/platform/job-settings.ts` (désactivé avec `JOB_<CONTEXT>_<JOB>_ENABLED=false`), `main.ts`
428
+ passe `jobs: { start: config.runJobs, settings: jobSettings(process.env) }` (`RUN_JOBS`), et
429
+ `ddd-ts-kit generate worker` écrit `src/apps/worker/main.ts` et désactive par défaut `RUN_JOBS`
430
+ pour l'API.
431
+
432
+ ## Cycle de vie
433
+
434
+ `application.start()` démarre le planificateur en dernier, une fois les consommateurs abonnés, et
435
+ `application.stop()` l'arrête en premier. `stop()` ne démarre aucune nouvelle exécution, attend
436
+ les exécutions en cours jusqu'à `graceMs` (30 s par défaut), puis annule leurs signaux avec une
437
+ `JobAbortedError` et rend la main ; une exécution qui attendait dans une file est abandonnée et
438
+ enregistrée `missed`. Réglez ce délai de grâce en dessous de celui d'arrêt de la plateforme.
439
+
440
+ ### Remplacer le planificateur
441
+
442
+ `kernelModule()` construit le planificateur sur les ports du kernel qu'il lie : `Clock`, `Lock`,
443
+ `ExecutionScope`, `IdGenerator`, `Logger` et `Metrics`, avec `reportFailedRuns` sur l'`ErrorReporter`
444
+ lié comme `onRun`. Pour le construire autrement (avec des points de reprise, un autre délai de
445
+ grâce, un autre observateur), donnez le vôtre au module kernel :
446
+
447
+ <!-- checked sample -->
448
+ ```ts
449
+ import { Clock, ErrorReporter, ExecutionScope, IdGenerator, Lock, Logger, Metrics } from '@ddd-ts-kit/kernel';
450
+ import { createApplication, kernelModule, type ContextModule } from '@ddd-ts-kit/di';
451
+ import { InProcessJobScheduler, JobCheckpoints, reportFailedRuns } from '@ddd-ts-kit/scheduling';
452
+
453
+ declare const platformModule: ContextModule, billingModule: ContextModule;
454
+
455
+ const kernel = kernelModule({
456
+ jobScheduler: (resolve) =>
457
+ new InProcessJobScheduler({
458
+ clock: resolve(Clock),
459
+ // demandé à chaque occurrence : construire le planificateur ne doit ouvrir aucune connexion
460
+ lock: { acquire: (key, options) => resolve(Lock).acquire(key, options) },
461
+ scope: resolve(ExecutionScope),
462
+ ids: resolve(IdGenerator),
463
+ logger: resolve(Logger),
464
+ metrics: resolve(Metrics),
465
+ checkpoints: resolve(JobCheckpoints),
466
+ graceMs: 10_000,
467
+ // ce que fait le planificateur par défaut des exécutions en échec : gardez-le
468
+ onRun: reportFailedRuns(resolve(ErrorReporter)),
469
+ }),
470
+ });
471
+
472
+ const app = createApplication({ modules: [kernel, platformModule, billingModule] });
473
+ ```
474
+
475
+ avec, dans le module de la plateforme, `builder.bind(JobCheckpoints).toClass(SqlJobCheckpoints, [Sql])`.
476
+ Un module ultérieur remplace le planificateur de la même façon, avec
477
+ `builder.rebind(JobScheduler).toFactory((resolve) => new InProcessJobScheduler({ ... }))`, et il
478
+ en va de même d'une autre implémentation du port `JobScheduler` (sur une base de données, sur une
479
+ file).
480
+
481
+ Sans `@ddd-ts-kit/di` :
482
+
483
+ <!-- checked sample -->
484
+ ```ts
485
+ import type { Clock, ExecutionScope, IdGenerator, Lock, Logger, Metrics, ScheduledJob } from '@ddd-ts-kit/kernel';
486
+ import { InProcessJobScheduler } from '@ddd-ts-kit/scheduling';
487
+
488
+ declare const clock: Clock, lock: Lock, scope: ExecutionScope, ids: IdGenerator, logger: Logger, metrics: Metrics;
489
+ declare const job: ScheduledJob;
490
+
491
+ const scheduler = new InProcessJobScheduler({ clock, lock, scope, ids, logger, metrics });
492
+ scheduler.register({ name: 'billing.send-payment-reminders', schedule: { every: '15m' }, run: (execution) => job.run(execution) });
493
+ await scheduler.start();
494
+ ```
495
+
496
+ | Option | Par défaut | |
497
+ |---|---|---|
498
+ | `clock`, `lock`, `scope`, `ids` | requises | chaque instant vient de la `Clock` ; l'horloge système n'est jamais lue |
499
+ | `logger`, `metrics` | n'écrivent et ne mesurent rien | |
500
+ | `timers` | `systemJobTimers` | comment il dort ; `ManualJobTimers` dans un test |
501
+ | `checkpoints` | aucun | voir « Options » |
502
+ | `lateAfterMs` | `60000` | au-delà, une occurrence est manquée |
503
+ | `graceMs` | `30000` | combien de temps `stop()` attend |
504
+ | `lockTtlMs` | `900000` | durée de vie du verrou d'un *job* sans `timeoutMs` |
505
+ | `random` | `Math.random` | tire la gigue |
506
+ | `onRun` | aucun | `(run, error?, detail?) => void` pour chaque occurrence traitée, avec ce que le *job* a levé et l'identifiant de corrélation de l'exécution : `reportFailedRuns(errorReporter)` est l'observateur qui remonte à l'`ErrorReporter` du kernel chaque exécution en échec pour toute autre cause qu'une `DomainError` |
507
+
508
+ Il dort au plus une heure d'affilée et recalcule à son réveil, si bien qu'une horloge corrigée, ou
509
+ une machine mise en veille, est remarquée dans l'heure.
510
+
511
+ ## Observabilité
512
+
513
+ `scheduler.describe()` donne, pour chaque *job*, `name`, `schedule` (`every 15m`,
514
+ `cron 0 7 * * 1-5 (Europe/Paris)`), `enabled`, `running`, `nextRunAt` et `lastRun`, sous forme de
515
+ données. `application.describe().jobs` liste les *jobs* déclarés avec les réglages sous lesquels
516
+ ils s'exécutent.
517
+
518
+ Chaque occurrence traitée a une issue, enregistrée trois fois :
519
+
520
+ | Issue | Signifie | Niveau de journal |
521
+ |---|---|---|
522
+ | `succeeded` | le *job* s'est exécuté | info |
523
+ | `failed` | le *job* a levé une erreur, a dépassé son délai d'expiration, a été annulé, ou son verrou n'a pas pu être demandé | error |
524
+ | `skipped-overlap` | l'exécution précédente était encore en cours | warn |
525
+ | `skipped-locked` | une autre instance exécute cette occurrence | debug |
526
+ | `missed` | l'occurrence est passée sans être exécutée | warn |
527
+
528
+ - **Journal** : une entrée, `Scheduled job succeeded`, `Scheduled job failed`, `Scheduled job missed`,
529
+ `Scheduled job skipped: the previous run is still in progress` ou `Scheduled job skipped:
530
+ another instance runs this occurrence`, avec `job`, `context`, `outcome`, `scheduledAt` et,
531
+ pour une exécution, `durationMs`, `correlationId`, `errorCode`, `errorType`. Ce qui se passe mal
532
+ autour des exécutions (un point de reprise qui ne peut être ni lu ni écrit) est journalisé au
533
+ niveau `error` sous `Scheduled job could not be followed`, et le *job* continue.
534
+ - **Métriques** : le compteur `scheduled_job_runs_total` et, pour une exécution, l'histogramme
535
+ `scheduled_job_duration_ms`, étiquetés `job`, `context`, `status` (l'issue) et `error_code`. Une
536
+ alerte sur « aucun `succeeded` pour ce *job* depuis N intervalles » détecte toutes les façons
537
+ dont un *job* cesse de s'exécuter, ce que ne fait pas une alerte sur les échecs.
538
+ - **`onRun`** : le même enregistrement, plus l'erreur et l'identifiant de corrélation de
539
+ l'exécution.
540
+
541
+ `missed` est enregistré une fois pour un trou, avec le premier instant manqué, et non une fois par
542
+ instant. L'`errorCode` est le `code` de ce qui a été levé (une `DomainError`, `JOB_TIMEOUT`,
543
+ `JOB_ABORTED`, `JOB_LOCK_UNAVAILABLE`) ou `UNEXPECTED_ERROR`. **Le message d'une erreur n'est
544
+ jamais enregistré** : il cite des données. Un *job* qui échoue n'arrête ni le planificateur ni les
545
+ autres *jobs*, et son occurrence suivante s'exécute normalement : il n'y a pas de nouvelle
546
+ tentative, l'occurrence suivante est la nouvelle tentative.
547
+
548
+ ## Tests
549
+
550
+ La *spec* d'un *job* est une *spec* du cas d'usage qu'il déclenche : exécutez le *job*, vérifiez
551
+ les *fakes*. Le temps n'intervient pas, et l'application de test de `@ddd-ts-kit/di` ne démarre
552
+ jamais les *jobs* selon l'heure.
553
+
554
+ <!-- checked sample -->
555
+ ```ts
556
+ import { contextModule, createTestApplication, fake, type DeclaredSource } from '@ddd-ts-kit/di';
557
+ import { expect } from 'vitest';
558
+
559
+ // le barrel du contexte, et le fake de son port Mailer
560
+ declare const billing: DeclaredSource;
561
+ declare abstract class Mailer {
562
+ abstract send(to: string, subject: string): Promise<void>;
563
+ }
564
+ declare class FakeMailer extends Mailer {
565
+ readonly sent: readonly { readonly to: string; readonly subject: string }[];
566
+ send(to: string, subject: string): Promise<void>;
567
+ }
568
+ const mailer = new FakeMailer();
569
+
570
+ const app = createTestApplication({ modules: [contextModule('billing', billing)], fakes: [fake(Mailer, mailer)] });
571
+
572
+ await app.jobs.runNow('billing.send-payment-reminders');
573
+
574
+ expect(mailer.sent).toHaveLength(2);
575
+ ```
576
+
577
+ `app.jobs.runNow(name)` exécute le *job* comme le ferait le planificateur (portée d'origine
578
+ `schedule`, acteur système, verrou mis à part) et lève ce que le *job* a levé. Pour une *spec*
579
+ qui porte sur le temps lui-même, `app.jobs.scheduler` est un `ManualJobScheduler` sur l'horloge de
580
+ l'application :
581
+
582
+ <!-- checked sample -->
583
+ ```ts
584
+ import { FixedClock, FixedExecutionScope, InMemoryLock, SequentialIdGenerator } from '@ddd-ts-kit/kernel';
585
+ import { ManualJobScheduler } from '@ddd-ts-kit/scheduling/testing';
586
+ import { expect } from 'vitest';
587
+
588
+ const run = async (): Promise<void> => undefined;
589
+ const clock = new FixedClock(new Date('2026-03-02T09:58:30Z'));
590
+ const scheduler = new ManualJobScheduler({ clock, lock: new InMemoryLock(clock), scope: new FixedExecutionScope(), ids: new SequentialIdGenerator() });
591
+ scheduler.register({ name: 'billing.send-payment-reminders', schedule: { every: '15m' }, run });
592
+ await scheduler.start();
593
+
594
+ await scheduler.advance(32 * 60_000);
595
+
596
+ 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']);
597
+ ```
598
+
599
+ - `advance(ms)` avance l'horloge et déclenche chaque minuteur à son instant, dans l'ordre. Quand
600
+ elle se résout, chaque *job* qui n'attend que des promesses est terminé ; un *job* que le test
601
+ garde bloqué est encore en cours, et c'est ainsi que se testent le chevauchement, le délai
602
+ d'expiration et l'arrêt.
603
+ - `runs` liste chaque occurrence traitée avec son issue, `failures` ce qu'ont levé les exécutions
604
+ en échec, `runsOf(job)` les exécutions d'un *job*. Il n'y a pas de gigue.
605
+ - `ManualJobTimers` seul pilote un `InProcessJobScheduler` construit à la main, pour une *spec*
606
+ qui règle `random` ou `checkpoints`.
607
+ - Deux planificateurs qui partagent une horloge et un verrou sont deux instances : c'est ainsi que
608
+ le comportement à instance unique est spécifié dans ce *package*.
609
+
610
+ Un autre planificateur (sur une base de données, sur une file) implémente le port `JobScheduler`
611
+ du kernel et passe `jobSchedulerContract` de `@ddd-ts-kit/testing/contracts`.
612
+
613
+ ## Jobs en arrière-plan
614
+
615
+ Un travail qu'un cas d'usage confie à un *worker*, pour l'exécuter plus tard : le port
616
+ (`JobQueue`), la déclaration (`@Background`) et le type de la charge utile (payload) vivent dans
617
+ le kernel ; ce *package* les exécute. Pourquoi et quand, avec les garanties :
618
+ [Jobs en arrière-plan](../../docs/concepts/background-jobs.fr.md).
619
+
620
+ | Pièce | |
621
+ |---|---|
622
+ | `StoredJobQueue` | la `JobQueue` du kernel sur un `JobStore` : nomme le *job*, capture la corrélation, l'acteur, le *tenant* et le `traceparent` du travail qui met en file, l'ajoute au store ; `find` et `cancel` n'atteignent que les *jobs* du *tenant* du travail en cours (des *tenants* de son élévation ; tous les *jobs* quand le travail n'a pas de *tenant*) |
623
+ | `JobStore` | le port des stores : `add`, `claim` (un bail), `heartbeat`, `update` (progression, point de reprise), `complete`, `fail`, `release`, `cancel`, `find`, `retryDead`, `purgeFinished`, `stats` ; chaque compte rendu nomme sa tentative (`JobLease`) |
624
+ | `InMemoryJobStore` | le store des tests et d'un processus sans base de données : `add`, `cancel` et `retryDead` attendent la validation (commit) de l'unité de travail ; `list()` pour les assertions |
625
+ | `tenantKeyOf(enqueuedBy)` | la partie *tenant* de la clé d'idempotence, pour un store qui la garde dans un index unique : le *tenant*, ou le texte vide |
626
+ | `QueueWorker` | prend et exécute les *jobs* des files pour lesquelles il a des *jobs* : `register`, `start`, `stop` (attend une prise en vol, dont les *jobs* reviennent sans être comptés, puis les tentatives en cours), `runDue` |
627
+ | `reportFailedBackgroundJobs(reporter)` | l'observateur qui envoie au rapporteur d'erreurs une tentative en échec inattendu |
628
+
629
+ <!-- checked sample -->
630
+ ```ts
631
+ import type {
632
+ BackgroundJob,
633
+ BackgroundJobExecution,
634
+ Clock,
635
+ ErrorReporter,
636
+ ExecutionScope,
637
+ IdGenerator,
638
+ Logger,
639
+ Metrics,
640
+ } from '@ddd-ts-kit/kernel';
641
+ import { InMemoryJobStore, QueueWorker, reportFailedBackgroundJobs, StoredJobQueue } from '@ddd-ts-kit/scheduling';
642
+ import { captureTraceparent } from '@ddd-ts-kit/tracing';
643
+
644
+ declare const clock: Clock, ids: IdGenerator, scope: ExecutionScope, logger: Logger, metrics: Metrics, errors: ErrorReporter;
645
+ interface CompileStatementsPayload {
646
+ readonly month: string;
647
+ }
648
+ declare class CompileStatementsBackgroundJob implements BackgroundJob<CompileStatementsPayload> {
649
+ static readonly jobName: 'billing.compile-statements';
650
+ run(payload: CompileStatementsPayload, execution: BackgroundJobExecution): Promise<void>;
651
+ }
652
+ declare const job: CompileStatementsBackgroundJob;
653
+
654
+ const store = new InMemoryJobStore(); // KnexJobStore, DrizzleJobStore ou RedisJobStore en production
655
+ // la JobQueue sur laquelle un cas d'usage met en file : jobs.enqueue(CompileStatementsBackgroundJob, { month: '2026-10' })
656
+ export const jobs = new StoredJobQueue({ store, clock, ids, scope, traceparent: captureTraceparent });
657
+
658
+ const worker = new QueueWorker({
659
+ store,
660
+ clock,
661
+ scope,
662
+ logger,
663
+ metrics,
664
+ concurrency: { billing: 4, default: 1 }, // la file d'un job est son contexte
665
+ onRun: reportFailedBackgroundJobs(errors),
666
+ });
667
+
668
+ worker.register({
669
+ name: CompileStatementsBackgroundJob.jobName,
670
+ options: { maxAttempts: 3 },
671
+ // la charge utile revient du store en JSON brut
672
+ run: (payload, execution) => job.run(payload as CompileStatementsPayload, execution),
673
+ });
674
+ await worker.start();
675
+ ```
676
+
677
+ `@ddd-ts-kit/di` fait tout cela : `kernelModule({ jobStore })` lie le store, la file et le
678
+ *worker*, les *jobs* déclarés sont enregistrés sur le *worker*, et le *worker* tourne dans les
679
+ processus qui exécutent les *jobs* (`jobs: { start: true }`).
680
+
681
+ | Option de `QueueWorker` | Par défaut | |
682
+ |---|---|---|
683
+ | `concurrency` | `1` | tentatives simultanées d'une file dans ce processus : un nombre, ou `{ <queue>: n, default: n }` |
684
+ | `pollIntervalMs` | `1000` | combien de temps une file inactive attend avant de redemander au store |
685
+ | `graceMs` | `30000` | combien de temps `stop()` attend les tentatives en cours avant de les annuler ; une tentative annulée rend son *job* sans qu'il soit compté |
686
+ | `timers` | le *runtime* | `ManualJobTimers` dans une *spec* |
687
+
688
+ Chaque tentative est enregistrée comme un `BackgroundJobRun` (`succeeded`, `retried`, `dead`,
689
+ `released`, `interrupted`), journalisée au niveau `info`, `warn` ou `error`, et comptée dans
690
+ `background_job_runs_total` et `background_job_duration_ms` (`BACKGROUND_RUNS_COUNTER`,
691
+ `BACKGROUND_DURATION_HISTOGRAM`), étiquetés `job`, `context`, `status` et `error_code`. Un store à
692
+ vous passe `jobStoreContract` de `@ddd-ts-kit/testing/contracts`, avec son unité de travail.
693
+
694
+ ## Gestionnaires de processus
695
+
696
+ `StoredProcessRunner` est le `ProcessRunner` du kernel : `new StoredProcessRunner({ store,
697
+ jobs, commands, unitOfWork })`. Une étape s'exécute dans une unité de travail : l'instance est
698
+ chargée, l'étape décide, les délais d'expiration qu'elle désarme sont annulés et ceux qu'elle arme
699
+ sont mis en file sur la `JobQueue` comme *jobs* différés du `timeoutJob` du processus
700
+ (`NoTimeoutJobError` s'il n'en a pas), l'état est enregistré à la version suivante, puis les
701
+ commandes sont envoyées. `timeout(process, { id, timeout, jobId }, step)` ne s'exécute que tant
702
+ que `jobId` est le *job* armé de ce délai d'expiration. `@ddd-ts-kit/di` le lie sur
703
+ `ProcessStore`, `JobQueue`, `CommandBus` et `UnitOfWork`. Voir
704
+ [Sagas et gestionnaires de processus](../../docs/concepts/sagas-and-process-managers.fr.md).
705
+
706
+ ## Limites
707
+
708
+ - **Aucune persistance propre.** Les exécutions, les issues et `lastRun` vivent dans la mémoire de
709
+ l'instance ; `describe()` est la vue d'une instance, et seul `checkpoints` traverse un
710
+ redémarrage.
711
+ - **Ni nouvelle tentative ni temporisation (backoff).** Une occurrence en échec est terminée.
712
+ Placez la nouvelle tentative là où est l'échec : un aspect sur l'adaptateur (`retry` de
713
+ `@ddd-ts-kit/aspects`), ou une commande qui supporte d'être renvoyée à l'occurrence suivante.
714
+ - **Résolution à la minute pour le cron**, à la milliseconde pour les intervalles ; un intervalle
715
+ inférieur à une seconde est possible et rarement judicieux, car chaque occurrence coûte un
716
+ verrou.
717
+ - **Au plus une fois par occurrence, au mieux**, comme dit plus haut. Exactement une fois n'existe
718
+ pas ici.
719
+ - **Le planificateur n'est pas une file.** Un *job* planifié fait une quantité bornée de travail
720
+ selon une planification. Le travail qu'une commande demande, qui ne doit pas être perdu et doit
721
+ être retenté, est un *job* en arrière-plan (plus haut).
722
+ - **Le *worker* :** au moins une fois, aucun ordre, concurrence par processus, interrogation
723
+ périodique (polling) : voir les limites de [Jobs en arrière-plan](../../docs/concepts/background-jobs.fr.md).
724
+ - **Un processus, une boucle d'événements.** Un *job* qui bloque la boucle retarde tous les autres
725
+ *jobs* ; au-delà de `lateAfterMs`, leurs occurrences sont manquées.
726
+ - **Les règles des fuseaux horaires sont celles du *runtime*.** Un fuseau dont les règles ont
727
+ changé demande un Node à jour. Deux changements de décalage à moins de deux jours d'intervalle,
728
+ ce qu'aucun fuseau ne connaît, ne sont pas gérés.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ **English** · [Français](README.fr.md)
2
+
1
3
  # @ddd-ts-kit/scheduling
2
4
 
3
5
  Scheduled jobs and background jobs for a project built with the kit. A scheduled job is a class
@@ -5,7 +7,8 @@ of the application layer that says when it runs and triggers use cases: it decid
5
7
  business rules stay in the domain. The scheduler opens an execution scope around each run, makes
6
8
  sure one instance of the application runs a given occurrence, and records what became of it. A
7
9
  background job is enqueued by a use case and run later by the worker of this package (see
8
- "Background jobs" below).
10
+ "Background jobs" below). Why a job only triggers a use case, and how to design one that is safe
11
+ to run twice: [Scheduled jobs](../../docs/concepts/scheduled-jobs.md).
9
12
 
10
13
  ```
11
14
  npm i @ddd-ts-kit/scheduling
@@ -13,25 +16,32 @@ npm i @ddd-ts-kit/scheduling
13
16
 
14
17
  | Entry | Holds | Imports |
15
18
  |---|---|---|
16
- | `@ddd-ts-kit/scheduling` | `InProcessJobScheduler`, `nextOccurrence`, `latestOccurrence`, `systemJobTimers`, the `JobTimers` and `JobCheckpoints` ports, `JobTimeoutError`, `JobAbortedError`, `JobLockUnavailableError`, the metric names; for background jobs `QueueWorker`, `StoredJobQueue`, the `JobStore` port, `InMemoryJobStore`, `reportFailedBackgroundJobs`, `JobInterruptedError`, `InvalidEnqueueError` | the kernel |
19
+ | `@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 |
17
20
  | `@ddd-ts-kit/scheduling/testing` | `ManualJobScheduler`, `ManualJobTimers` | the kernel |
18
21
 
19
- The declaration (`@Scheduled`), the port (`JobScheduler`) and the schedule parser live in
20
- `@ddd-ts-kit/kernel`, so `application/` imports the kernel and nothing else.
21
- `@ddd-ts-kit/di` binds `JobScheduler` to an `InProcessJobScheduler` in its kernel module and
22
- registers the declared jobs: an application built with it needs no line of this README but
23
- the job itself.
22
+ The declarations (`@Scheduled`, `@Background`), the ports (`JobScheduler`, `JobQueue`,
23
+ `ProcessRunner`) and the schedule parser live in `@ddd-ts-kit/kernel`, so `application/` imports
24
+ the kernel and nothing else. `@ddd-ts-kit/di` binds `JobScheduler` to an `InProcessJobScheduler`
25
+ in its kernel module and registers the declared jobs: an application built with it needs no line
26
+ of this README but the job itself.
27
+
28
+ ![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](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/scheduling-package-place.svg)
24
29
 
25
30
  `JobScheduler` is not the `Scheduler` of `@ddd-ts-kit/aspects`. That one is a timer for retry
26
31
  and timeout (`schedule(work, delayMs)`); this one knows jobs, schedules and instances.
27
32
 
28
33
  ## A job
29
34
 
35
+ <!-- checked sample -->
30
36
  ```ts
31
37
  // src/contexts/billing/application/scheduling/send-payment-reminders.job.ts
32
- import { CommandBus, systemActor } from '@ddd-ts-kit/kernel';
38
+ import { AuthorizedCommand, CommandBus, systemActor, type Actor } from '@ddd-ts-kit/kernel';
33
39
  import { Scheduled, type JobExecution, type ScheduledJob } from '@ddd-ts-kit/kernel/declarations';
34
- import { SendPaymentReminders } from '@/contexts/billing/application/commands/send-payment-reminders/send-payment-reminders.command';
40
+
41
+ // the command of the use case, in application/commands/send-payment-reminders/
42
+ declare class SendPaymentReminders extends AuthorizedCommand {
43
+ constructor(actor: Actor, dueOn: Date);
44
+ }
35
45
 
36
46
  @Scheduled('billing.send-payment-reminders', { cron: '0 7 * * 1-5', timeZone: 'Europe/Paris' })
37
47
  export class SendPaymentRemindersJob implements ScheduledJob {
@@ -92,10 +102,34 @@ per tenant receives the tenant in its command. `tenancy: 'per-tenant'` runs once
92
102
  the `TenantDirectory` given to the scheduler (`tenants` option; registering such a job without
93
103
  it throws `InvalidJobScheduleError`):
94
104
 
105
+ <!-- checked sample -->
95
106
  ```ts
107
+ import {
108
+ InMemoryTenantDirectory,
109
+ type Clock,
110
+ type ExecutionScope,
111
+ type IdGenerator,
112
+ type JobExecution,
113
+ type Lock,
114
+ } from '@ddd-ts-kit/kernel';
115
+ import { InProcessJobScheduler } from '@ddd-ts-kit/scheduling';
116
+
117
+ declare const clock: Clock, lock: Lock, scope: ExecutionScope, ids: IdGenerator;
118
+ declare function closeMonthOf(tenantId: string, month: Date): Promise<void>;
119
+
96
120
  const scheduler = new InProcessJobScheduler({ clock, lock, scope, ids, tenants: new InMemoryTenantDirectory(['acme', 'globex']) });
97
121
 
98
- scheduler.register({ name: 'billing.close-month', schedule: { cron: '0 2 1 * *' }, tenancy: 'per-tenant', run });
122
+ scheduler.register({
123
+ name: 'billing.close-month',
124
+ schedule: { cron: '0 2 1 * *' },
125
+ tenancy: 'per-tenant',
126
+ // called twice per occurrence: once with tenantId 'acme', then once with 'globex'
127
+ run: async (execution: JobExecution) => {
128
+ if (execution.tenantId !== undefined) {
129
+ await closeMonthOf(execution.tenantId, execution.scheduledAt);
130
+ }
131
+ },
132
+ });
99
133
  ```
100
134
 
101
135
  Each tenant's run happens in its own execution scope, `tenantId` set, and `execution.tenantId`
@@ -117,6 +151,8 @@ instance name the same instants, and a restart keep them. An interval that does
117
151
  day (`'7h'`) drifts against the wall clock, and none follows a time zone: for "every morning",
118
152
  write a cron.
119
153
 
154
+ ![Two instances started at 10:07 and 10:11: aligned on the epoch, both name the occurrences 10:15, 10:30, 10:45 and 11:00, so one lock key per occurrence and one instance runs it; counted from the start of each process, the first would name 10:22, 10:37, 10:52 and the second 10:26, 10:41, 10:56, different keys, and both would run](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/scheduling-package-interval-alignment.svg)
155
+
120
156
  **Cron** has the five standard fields, no seconds:
121
157
 
122
158
  | Field | Values | Names |
@@ -147,7 +183,26 @@ other job follows the clocks. Do not schedule in the changed hour what must run
147
183
  `30 2 * * *` in `Europe/Paris` has no run on the last Sunday of March.
148
184
 
149
185
  `nextOccurrence(parseJobSchedule(schedule), after)` and `latestOccurrence(parsed, { after,
150
- notAfter })` answer the instants, for a screen that shows when a job runs next.
186
+ notAfter })` answer the instants, for a screen that shows when a job runs next, or for a spec
187
+ that pins a schedule before it is deployed:
188
+
189
+ <!-- checked sample -->
190
+ ```ts
191
+ import { describeJobSchedule, parseJobSchedule } from '@ddd-ts-kit/kernel';
192
+ import { latestOccurrence, nextOccurrence } from '@ddd-ts-kit/scheduling';
193
+
194
+ const every15 = parseJobSchedule({ every: '15m' });
195
+ nextOccurrence(every15, new Date('2026-03-02T10:07:00Z')); // 2026-03-02T10:15:00Z, whenever the process started
196
+
197
+ const weekdayMornings = { cron: '0 7 * * 1-5', timeZone: 'Europe/Paris' };
198
+ const parsed = parseJobSchedule(weekdayMornings);
199
+ describeJobSchedule(weekdayMornings); // 'cron 0 7 * * 1-5 (Europe/Paris)'
200
+ nextOccurrence(parsed, new Date('2026-03-06T08:00:00Z')); // Monday 2026-03-09T06:00:00Z: 07:00 in Paris, UTC+1
201
+ latestOccurrence(parsed, { after: new Date('2026-03-02T00:00:00Z'), notAfter: new Date('2026-03-04T12:00:00Z') }); // 2026-03-04T06:00:00Z
202
+
203
+ // 02:30 does not exist in Paris on 2026-03-29: that day has no run
204
+ nextOccurrence(parseJobSchedule({ cron: '30 2 * * *', timeZone: 'Europe/Paris' }), new Date('2026-03-28T12:00:00Z')); // 2026-03-30T00:30:00Z
205
+ ```
151
206
 
152
207
  ## Options
153
208
 
@@ -162,6 +217,11 @@ Written next to the schedule: `{ every: '15m', overlap: 'queue', timeoutMs: 60_0
162
217
  | `enabled` | `true` | `false` never starts the job by time; `runNow` still runs it |
163
218
  | `singleInstance` | `true` | one instance runs an occurrence: see below |
164
219
 
220
+ Each option acts at one step of an occurrence, and each step that stops it records an outcome
221
+ (see "Observability"):
222
+
223
+ ![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](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/scheduling-package-one-occurrence.svg)
224
+
165
225
  **Missed** means reached later than `lateAfterMs` (60 s by default, an option of the
166
226
  scheduler) after the instant, jitter aside: the process was down, asleep, or its event loop
167
227
  blocked. A timer a few seconds late still runs its occurrence. What passed **while the process
@@ -174,15 +234,6 @@ export abstract class JobCheckpoints {
174
234
 
175
235
  abstract write(job: string, handled: Date): Promise<void>;
176
236
  }
177
-
178
- // src/platform/scheduling/knex-job-checkpoints.ts, your adapter
179
- export class KnexJobCheckpoints implements JobCheckpoints {
180
- constructor(private readonly database: Database) {}
181
-
182
- async read(job: string): Promise<Date | undefined> { /* one row per job */ }
183
-
184
- async write(job: string, handled: Date): Promise<void> { /* upsert */ }
185
- }
186
237
  ```
187
238
 
188
239
  The scheduler writes there the last occurrence it handled for each job and reads it when it
@@ -191,16 +242,94 @@ starts. Without it a restart forgets: every job starts from the instant of the s
191
242
  table or a key shared by the instances. It is given to the scheduler where the scheduler is
192
243
  built: see "Replacing the scheduler".
193
244
 
245
+ An adapter on a SQL client of your own, one row per job:
246
+
247
+ <!-- checked sample -->
248
+ ```ts
249
+ // src/platform/scheduling/sql-job-checkpoints.ts
250
+ import { JobCheckpoints } from '@ddd-ts-kit/scheduling';
251
+
252
+ // the client of your database, a port your platform binds
253
+ declare abstract class Sql {
254
+ abstract rows<Row>(text: string, values: readonly unknown[]): Promise<readonly Row[]>;
255
+ abstract execute(text: string, values: readonly unknown[]): Promise<void>;
256
+ }
257
+
258
+ export class SqlJobCheckpoints implements JobCheckpoints {
259
+ constructor(private readonly sql: Sql) {}
260
+
261
+ async read(job: string): Promise<Date | undefined> {
262
+ const [row] = await this.sql.rows<{ handled_at: Date }>('select handled_at from job_checkpoints where job = $1', [job]);
263
+
264
+ return row?.handled_at;
265
+ }
266
+
267
+ async write(job: string, handled: Date): Promise<void> {
268
+ await this.sql.execute(
269
+ `insert into job_checkpoints (job, handled_at) values ($1, $2)
270
+ on conflict (job) do update set handled_at = greatest(job_checkpoints.handled_at, excluded.handled_at)`,
271
+ [job, handled],
272
+ );
273
+ }
274
+ }
275
+ ```
276
+
277
+ `greatest` keeps the latest instant when two instances write at once: the checkpoint only moves
278
+ forward.
279
+
194
280
  **A timeout does not kill.** A run is a promise, and nothing can stop a promise: the scheduler
195
281
  aborts the signal, stops waiting and frees the job for its next occurrence. A job that ignores
196
282
  its signal goes on behind, and then overlaps the next run. Pass the signal on, or check it.
197
283
 
284
+ ```ts
285
+ // instead of: a loop that never looks at the signal, still sending commands after the timeout
286
+ for (const batch of batches) {
287
+ await this.commands.execute(new ArchiveBatch(actor, batch, execution.scheduledAt));
288
+ }
289
+ ```
290
+
291
+ <!-- checked sample -->
292
+ ```ts
293
+ import { AuthorizedCommand, CommandBus, systemActor, type Actor } from '@ddd-ts-kit/kernel';
294
+ import { Scheduled, type JobExecution, type ScheduledJob } from '@ddd-ts-kit/kernel/declarations';
295
+
296
+ declare class ArchiveBatch extends AuthorizedCommand {
297
+ constructor(actor: Actor, batch: number, asOf: Date);
298
+ }
299
+
300
+ const BATCHES = 20;
301
+
302
+ // write: stop between two steps once the run is aborted (timeout or stop())
303
+ @Scheduled('archive.archive-old-records', { cron: '0 3 * * *', timeoutMs: 10 * 60_000 })
304
+ export class ArchiveOldRecordsJob implements ScheduledJob {
305
+ constructor(private readonly commands: CommandBus) {}
306
+
307
+ async run(execution: JobExecution): Promise<void> {
308
+ const actor = systemActor('archive.archive-old-records');
309
+
310
+ for (let batch = 0; batch < BATCHES; batch += 1) {
311
+ execution.signal.throwIfAborted(); // throws the JobTimeoutError or the JobAbortedError
312
+ await this.commands.execute(new ArchiveBatch(actor, batch, execution.scheduledAt));
313
+ }
314
+ }
315
+ }
316
+ ```
317
+
318
+ Make the command of each batch safe to send again: what a timeout leaves undone, the next
319
+ occurrence picks up from the state.
320
+
198
321
  ## Switching a job off, per environment
199
322
 
200
323
  A job is switched off, rescheduled or tuned by configuration, never by a code change. With
201
324
  `@ddd-ts-kit/di`:
202
325
 
326
+ <!-- checked sample -->
203
327
  ```ts
328
+ import { createApplication, type ContextModule } from '@ddd-ts-kit/di';
329
+
330
+ declare const modules: readonly ContextModule[];
331
+ declare const config: { readonly jobs: { readonly reminders: { readonly enabled: boolean }; readonly ledgerCron: string } };
332
+
204
333
  createApplication({
205
334
  modules,
206
335
  jobs: {
@@ -298,10 +427,13 @@ below the termination grace of the platform.
298
427
  `ErrorReporter` as `onRun`. To build it otherwise (with checkpoints, another grace period,
299
428
  another observer), give the kernel module yours:
300
429
 
430
+ <!-- checked sample -->
301
431
  ```ts
302
- import { Clock, ExecutionScope, IdGenerator, Lock, Logger, Metrics } from '@ddd-ts-kit/kernel';
303
- import { createApplication, kernelModule } from '@ddd-ts-kit/di';
304
- import { InProcessJobScheduler, JobCheckpoints } from '@ddd-ts-kit/scheduling';
432
+ import { Clock, ErrorReporter, ExecutionScope, IdGenerator, Lock, Logger, Metrics } from '@ddd-ts-kit/kernel';
433
+ import { createApplication, kernelModule, type ContextModule } from '@ddd-ts-kit/di';
434
+ import { InProcessJobScheduler, JobCheckpoints, reportFailedRuns } from '@ddd-ts-kit/scheduling';
435
+
436
+ declare const platformModule: ContextModule, billingModule: ContextModule;
305
437
 
306
438
  const kernel = kernelModule({
307
439
  jobScheduler: (resolve) =>
@@ -315,20 +447,29 @@ const kernel = kernelModule({
315
447
  metrics: resolve(Metrics),
316
448
  checkpoints: resolve(JobCheckpoints),
317
449
  graceMs: 10_000,
450
+ // what the default scheduler does with failed runs: keep it
451
+ onRun: reportFailedRuns(resolve(ErrorReporter)),
318
452
  }),
319
453
  });
320
454
 
321
455
  const app = createApplication({ modules: [kernel, platformModule, billingModule] });
322
456
  ```
323
457
 
324
- with, in the module of the platform, `builder.bind(JobCheckpoints).toClass(KnexJobCheckpoints, [Database])`.
458
+ with, in the module of the platform, `builder.bind(JobCheckpoints).toClass(SqlJobCheckpoints, [Sql])`.
325
459
  A later module replaces the scheduler the same way, with
326
460
  `builder.rebind(JobScheduler).toFactory((resolve) => new InProcessJobScheduler({ ... }))`, and
327
461
  so does another implementation of the `JobScheduler` port (on a database, on a queue).
328
462
 
329
463
  Without `@ddd-ts-kit/di`:
330
464
 
465
+ <!-- checked sample -->
331
466
  ```ts
467
+ import type { Clock, ExecutionScope, IdGenerator, Lock, Logger, Metrics, ScheduledJob } from '@ddd-ts-kit/kernel';
468
+ import { InProcessJobScheduler } from '@ddd-ts-kit/scheduling';
469
+
470
+ declare const clock: Clock, lock: Lock, scope: ExecutionScope, ids: IdGenerator, logger: Logger, metrics: Metrics;
471
+ declare const job: ScheduledJob;
472
+
332
473
  const scheduler = new InProcessJobScheduler({ clock, lock, scope, ids, logger, metrics });
333
474
  scheduler.register({ name: 'billing.send-payment-reminders', schedule: { every: '15m' }, run: (execution) => job.run(execution) });
334
475
  await scheduler.start();
@@ -365,8 +506,12 @@ Every occurrence handled has one outcome, recorded three times:
365
506
  | `skipped-locked` | another instance runs this occurrence | debug |
366
507
  | `missed` | the occurrence passed and was not run | warn |
367
508
 
368
- - **Log**: one entry, `Scheduled job <outcome>`, with `job`, `context`, `outcome`,
369
- `scheduledAt`, and for a run `durationMs`, `correlationId`, `errorCode`, `errorType`.
509
+ - **Log**: one entry, `Scheduled job succeeded`, `Scheduled job failed`, `Scheduled job missed`,
510
+ `Scheduled job skipped: the previous run is still in progress` or `Scheduled job skipped:
511
+ another instance runs this occurrence`, with `job`, `context`, `outcome`, `scheduledAt`, and
512
+ for a run `durationMs`, `correlationId`, `errorCode`, `errorType`. What goes wrong around the
513
+ runs (a checkpoint that cannot be read or written) is logged at `error` as `Scheduled job could
514
+ not be followed`, and the job goes on.
370
515
  - **Metrics**: the counter `scheduled_job_runs_total` and, for a run, the histogram
371
516
  `scheduled_job_duration_ms`, labelled `job`, `context`, `status` (the outcome) and
372
517
  `error_code`. An alert on "no `succeeded` for this job since N intervals" catches every way a
@@ -385,7 +530,22 @@ retry.
385
530
  A spec of a job is a spec of the use case it triggers: run the job, assert the fakes. Time is
386
531
  not involved, and the test application of `@ddd-ts-kit/di` never starts jobs by time.
387
532
 
533
+ <!-- checked sample -->
388
534
  ```ts
535
+ import { contextModule, createTestApplication, fake, type DeclaredSource } from '@ddd-ts-kit/di';
536
+ import { expect } from 'vitest';
537
+
538
+ // the barrel of the context, and the fake of its Mailer port
539
+ declare const billing: DeclaredSource;
540
+ declare abstract class Mailer {
541
+ abstract send(to: string, subject: string): Promise<void>;
542
+ }
543
+ declare class FakeMailer extends Mailer {
544
+ readonly sent: readonly { readonly to: string; readonly subject: string }[];
545
+ send(to: string, subject: string): Promise<void>;
546
+ }
547
+ const mailer = new FakeMailer();
548
+
389
549
  const app = createTestApplication({ modules: [contextModule('billing', billing)], fakes: [fake(Mailer, mailer)] });
390
550
 
391
551
  await app.jobs.runNow('billing.send-payment-reminders');
@@ -397,10 +557,13 @@ expect(mailer.sent).toHaveLength(2);
397
557
  system actor, lock aside) and throws what the job threw. For a spec about time itself,
398
558
  `app.jobs.scheduler` is a `ManualJobScheduler` on the clock of the application:
399
559
 
560
+ <!-- checked sample -->
400
561
  ```ts
401
562
  import { FixedClock, FixedExecutionScope, InMemoryLock, SequentialIdGenerator } from '@ddd-ts-kit/kernel';
402
563
  import { ManualJobScheduler } from '@ddd-ts-kit/scheduling/testing';
564
+ import { expect } from 'vitest';
403
565
 
566
+ const run = async (): Promise<void> => undefined;
404
567
  const clock = new FixedClock(new Date('2026-03-02T09:58:30Z'));
405
568
  const scheduler = new ManualJobScheduler({ clock, lock: new InMemoryLock(clock), scope: new FixedExecutionScope(), ids: new SequentialIdGenerator() });
406
569
  scheduler.register({ name: 'billing.send-payment-reminders', schedule: { every: '15m' }, run });
@@ -432,16 +595,41 @@ with the guarantees: [Background jobs](../../docs/concepts/background-jobs.md).
432
595
 
433
596
  | Piece | |
434
597
  |---|---|
435
- | `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 (those of an elevation's tenants; every job without a tenant) |
598
+ | `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) |
436
599
  | `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`) |
437
600
  | `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 |
438
601
  | `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 |
439
602
  | `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` |
440
603
  | `reportFailedBackgroundJobs(reporter)` | the observer that sends an attempt that failed unexpectedly to the error reporter |
441
604
 
605
+ <!-- checked sample -->
442
606
  ```ts
607
+ import type {
608
+ BackgroundJob,
609
+ BackgroundJobExecution,
610
+ Clock,
611
+ ErrorReporter,
612
+ ExecutionScope,
613
+ IdGenerator,
614
+ Logger,
615
+ Metrics,
616
+ } from '@ddd-ts-kit/kernel';
617
+ import { InMemoryJobStore, QueueWorker, reportFailedBackgroundJobs, StoredJobQueue } from '@ddd-ts-kit/scheduling';
618
+ import { captureTraceparent } from '@ddd-ts-kit/tracing';
619
+
620
+ declare const clock: Clock, ids: IdGenerator, scope: ExecutionScope, logger: Logger, metrics: Metrics, errors: ErrorReporter;
621
+ interface CompileStatementsPayload {
622
+ readonly month: string;
623
+ }
624
+ declare class CompileStatementsBackgroundJob implements BackgroundJob<CompileStatementsPayload> {
625
+ static readonly jobName: 'billing.compile-statements';
626
+ run(payload: CompileStatementsPayload, execution: BackgroundJobExecution): Promise<void>;
627
+ }
628
+ declare const job: CompileStatementsBackgroundJob;
629
+
443
630
  const store = new InMemoryJobStore(); // KnexJobStore, DrizzleJobStore or RedisJobStore in production
444
- const jobs = new StoredJobQueue({ store, clock, ids, scope, traceparent: captureTraceparent });
631
+ // the JobQueue a use case enqueues on: jobs.enqueue(CompileStatementsBackgroundJob, { month: '2026-10' })
632
+ export const jobs = new StoredJobQueue({ store, clock, ids, scope, traceparent: captureTraceparent });
445
633
 
446
634
  const worker = new QueueWorker({
447
635
  store,
@@ -449,11 +637,16 @@ const worker = new QueueWorker({
449
637
  scope,
450
638
  logger,
451
639
  metrics,
452
- concurrency: { scheduling: 4, default: 1 },
640
+ concurrency: { billing: 4, default: 1 }, // the queue of a job is its context
453
641
  onRun: reportFailedBackgroundJobs(errors),
454
642
  });
455
643
 
456
- worker.register({ name: CompileStatementsBackgroundJob.jobName, options: { maxAttempts: 3 }, run: (payload, execution) => job.run(payload, execution) });
644
+ worker.register({
645
+ name: CompileStatementsBackgroundJob.jobName,
646
+ options: { maxAttempts: 3 },
647
+ // the payload comes back from the store as plain JSON
648
+ run: (payload, execution) => job.run(payload as CompileStatementsPayload, execution),
649
+ });
457
650
  await worker.start();
458
651
  ```
459
652
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddd-ts-kit/scheduling",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Scheduled jobs that only trigger use cases: interval and cron schedules, one run across instances, no overlap, each run in its own execution scope.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,9 +30,9 @@
30
30
  "dist"
31
31
  ],
32
32
  "dependencies": {
33
- "@ddd-ts-kit/kernel": "0.15.0"
33
+ "@ddd-ts-kit/kernel": "0.16.1"
34
34
  },
35
35
  "devDependencies": {
36
- "@ddd-ts-kit/testing": "0.15.0"
36
+ "@ddd-ts-kit/testing": "0.16.1"
37
37
  }
38
38
  }