@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.
- package/README.fr.md +728 -0
- package/README.md +223 -30
- 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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
|
20
|
-
`@ddd-ts-kit/kernel`, so `application/` imports
|
|
21
|
-
`@ddd-ts-kit/di` binds `JobScheduler` to an `InProcessJobScheduler`
|
|
22
|
-
registers the declared jobs: an application built with it needs no line
|
|
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
|
+

|
|
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
|
-
|
|
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({
|
|
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
|
+

|
|
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
|
+

|
|
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(
|
|
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
|
|
369
|
-
`
|
|
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 (
|
|
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
|
-
|
|
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: {
|
|
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({
|
|
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.
|
|
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.
|
|
33
|
+
"@ddd-ts-kit/kernel": "0.16.1"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@ddd-ts-kit/testing": "0.
|
|
36
|
+
"@ddd-ts-kit/testing": "0.16.1"
|
|
37
37
|
}
|
|
38
38
|
}
|