@ddd-ts-kit/events 0.16.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 +719 -0
- package/README.md +239 -32
- package/package.json +3 -3
package/README.fr.md
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
[English](README.md) · **Français**
|
|
2
|
+
<!-- translation-of: README.md sha256:2e352f4884684a2a -->
|
|
3
|
+
|
|
4
|
+
# @ddd-ts-kit/events
|
|
5
|
+
|
|
6
|
+
Les événements d'intégration entre contextes délimités (bounded contexts), indépendants de tout
|
|
7
|
+
*broker* : des déclarations typées, un registre, une publication et une consommation validées,
|
|
8
|
+
une outbox transactionnelle, ainsi que le document AsyncAPI et le catalogue d'événements générés
|
|
9
|
+
à partir des déclarations.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npm i @ddd-ts-kit/events @ddd-ts-kit/validation zod
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
## Ce que contient le package
|
|
18
|
+
|
|
19
|
+
| Étape | À quoi elle sert | Exports |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Déclarer | le contrat d'un événement et d'une version, qui appartient au producteur | `defineIntegrationEvent`, `PayloadOf` |
|
|
22
|
+
| Enregistrer | tous les événements qu'un système publie, pour valider ce qui sort et générer les documents | `createEventRegistry`, `EventRegistry`, `validatingPublisher` |
|
|
23
|
+
| Stocker | écrire l'événement dans la transaction de la commande | `OutboxPublisher`, le port `OutboxStore`, `InMemoryOutboxStore` |
|
|
24
|
+
| Relayer | publier ce qui a été stocké, après la validation (commit), avec de nouvelles tentatives | `OutboxRelay`, le port `RelaySignal`, `InMemoryRelaySignal` |
|
|
25
|
+
| Consommer | un traitement typé, validé, dédupliqué | `consume`, `on`, `subscribeAll` |
|
|
26
|
+
| Documenter | le document AsyncAPI et le catalogue d'événements | `generateAsyncApi` (`@ddd-ts-kit/events/asyncapi`), `generateEventCatalog` |
|
|
27
|
+
|
|
28
|
+
Les concepts qui le sous-tendent sont dans [L'outbox transactionnelle](../../docs/concepts/transactional-outbox.fr.md),
|
|
29
|
+
[Livraison et idempotence](../../docs/concepts/delivery-and-idempotency.fr.md) et
|
|
30
|
+
[Versionnement des événements](../../docs/concepts/event-versioning.fr.md).
|
|
31
|
+
|
|
32
|
+
## Toute la boucle dans un seul processus
|
|
33
|
+
|
|
34
|
+
Tout ce qui suit est en mémoire, donc s'exécute tel quel : une déclaration, un consommateur,
|
|
35
|
+
l'outbox, le relais, et une nouvelle livraison que le consommateur absorbe. Une vraie application
|
|
36
|
+
remplace les pièces en mémoire par sa base de données et son *broker* ; le code du producteur et
|
|
37
|
+
celui du consommateur ne changent pas.
|
|
38
|
+
|
|
39
|
+
<!-- checked sample -->
|
|
40
|
+
```ts
|
|
41
|
+
import { z } from 'zod';
|
|
42
|
+
import { zodSchema } from '@ddd-ts-kit/validation/zod';
|
|
43
|
+
import { InMemoryEventBus, InMemoryProcessedEventStore, InMemoryUnitOfWork, SystemClock } from '@ddd-ts-kit/kernel';
|
|
44
|
+
import {
|
|
45
|
+
consume,
|
|
46
|
+
defineIntegrationEvent,
|
|
47
|
+
InMemoryOutboxStore,
|
|
48
|
+
OutboxPublisher,
|
|
49
|
+
OutboxRelay,
|
|
50
|
+
subscribeAll,
|
|
51
|
+
} from '@ddd-ts-kit/events';
|
|
52
|
+
|
|
53
|
+
// 1. Le contrat, déclaré une seule fois par le producteur.
|
|
54
|
+
const orderPlaced = defineIntegrationEvent({
|
|
55
|
+
type: 'ordering.order.placed',
|
|
56
|
+
version: 1,
|
|
57
|
+
payload: zodSchema(z.object({ orderId: z.string(), total: z.number().int() })),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// 2. Un consommateur d'un autre contexte : dédupliqué, dans une seule unité de travail.
|
|
61
|
+
const bus = new InMemoryEventBus();
|
|
62
|
+
const invoiced: string[] = [];
|
|
63
|
+
|
|
64
|
+
subscribeAll(bus, [
|
|
65
|
+
consume(
|
|
66
|
+
orderPlaced,
|
|
67
|
+
{
|
|
68
|
+
consumer: 'billing.invoice-on-order-placed',
|
|
69
|
+
processed: new InMemoryProcessedEventStore(),
|
|
70
|
+
unitOfWork: new InMemoryUnitOfWork(),
|
|
71
|
+
},
|
|
72
|
+
async (event) => {
|
|
73
|
+
invoiced.push(event.payload.orderId); // typé : { orderId: string; total: number }
|
|
74
|
+
},
|
|
75
|
+
),
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// 3. Le producteur ne fait que stocker : rien ne part encore.
|
|
79
|
+
const store = new InMemoryOutboxStore();
|
|
80
|
+
const event = orderPlaced.create({ orderId: 'order-1', total: 3000 }, { id: 'evt-1', occurredAt: new Date() });
|
|
81
|
+
|
|
82
|
+
await new OutboxPublisher(store).publish([event]);
|
|
83
|
+
console.log(await store.stats()); // { pending: 1, failed: 0, dead: 0 }
|
|
84
|
+
|
|
85
|
+
// 4. Le relais publie ce qui a été stocké, puis le marque publié.
|
|
86
|
+
const relay = new OutboxRelay({ store, publisher: bus, clock: new SystemClock() });
|
|
87
|
+
|
|
88
|
+
console.log(await relay.tick()); // { published: 1, failed: 0 }
|
|
89
|
+
|
|
90
|
+
// 5. Le même événement livré une seconde fois est absorbé par le consommateur.
|
|
91
|
+
await bus.publish([event]);
|
|
92
|
+
console.log(invoiced); // [ 'order-1' ]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Les sections qui suivent reprennent chaque étape, telle qu'une vraie application l'écrit.
|
|
96
|
+
|
|
97
|
+
## Déclarer
|
|
98
|
+
|
|
99
|
+
Une déclaration par événement et par version, dans le `published-language/` du producteur. Elle
|
|
100
|
+
donne le type de la charge utile (payload), valide ce qui est publié et consommé, et alimente
|
|
101
|
+
les générateurs.
|
|
102
|
+
|
|
103
|
+
<!-- checked sample -->
|
|
104
|
+
```ts
|
|
105
|
+
// contexts/ordering/published-language/order-placed.v1.integration-event.ts
|
|
106
|
+
import { z } from 'zod';
|
|
107
|
+
import { zodSchema } from '@ddd-ts-kit/validation/zod';
|
|
108
|
+
import { defineIntegrationEvent, type PayloadOf } from '@ddd-ts-kit/events';
|
|
109
|
+
|
|
110
|
+
export const orderPlaced = defineIntegrationEvent({
|
|
111
|
+
type: 'ordering.order.placed', // <context>.<aggregate>.<fact>, segments en kebab-case
|
|
112
|
+
version: 1,
|
|
113
|
+
description: 'An order was placed by a customer.',
|
|
114
|
+
payload: zodSchema(
|
|
115
|
+
z.object({
|
|
116
|
+
orderId: z.string().describe('Identifier of the order.'),
|
|
117
|
+
total: z.object({ amount: z.number().int(), currency: z.enum(['EUR', 'USD']) }),
|
|
118
|
+
lines: z.array(z.object({ sku: z.string(), quantity: z.number().int().positive() })),
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export type OrderPlaced = PayloadOf<typeof orderPlaced>;
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Le type est vérifié au chargement du module : trois segments en kebab-case,
|
|
127
|
+
`<context>.<aggregate>.<fact>`, et une version entière positive, sinon `defineIntegrationEvent`
|
|
128
|
+
lève `InvalidEventDefinitionError`. Le producteur est le premier segment du type, sauf si
|
|
129
|
+
`producer` le nomme. La déclaration répond ensuite à trois questions, une par côté :
|
|
130
|
+
|
|
131
|
+
| Membre | Côté | Ce qu'il fait |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| `create(payload, { id, occurredAt, correlationId?, traceparent? })` | producteur | valide la charge utile et construit l'enveloppe ; lève `InvalidEventPayloadError`, car publier un événement invalide est un bug |
|
|
134
|
+
| `parse(event)` | consommateur | vérifie le type, la version et la charge utile et renvoie un `Result` ; ne lève jamais d'exception |
|
|
135
|
+
| `is(event)` | l'un ou l'autre | une garde peu coûteuse sur le type et la version seulement, pour une charge utile déjà digne de confiance |
|
|
136
|
+
|
|
137
|
+
Un changement cassant est une nouvelle déclaration avec `version: 2` ; marquez l'ancienne
|
|
138
|
+
`deprecated: true` et continuez de publier les deux jusqu'à ce que les consommateurs aient migré.
|
|
139
|
+
Au lieu de changer la charge utile de `version: 1` dans le dos de ses consommateurs :
|
|
140
|
+
|
|
141
|
+
<!-- checked sample -->
|
|
142
|
+
```ts
|
|
143
|
+
// contexts/ordering/published-language/order-placed.v2.integration-event.ts
|
|
144
|
+
import { z } from 'zod';
|
|
145
|
+
import { zodSchema } from '@ddd-ts-kit/validation/zod';
|
|
146
|
+
import { defineIntegrationEvent } from '@ddd-ts-kit/events';
|
|
147
|
+
|
|
148
|
+
// `amount` renommé `minorUnits`, toute devise ISO acceptée : un consommateur de la version 1 casserait, donc une nouvelle version.
|
|
149
|
+
export const orderPlacedV2 = defineIntegrationEvent({
|
|
150
|
+
type: 'ordering.order.placed',
|
|
151
|
+
version: 2,
|
|
152
|
+
description: 'An order was placed by a customer.',
|
|
153
|
+
payload: zodSchema(
|
|
154
|
+
z.object({
|
|
155
|
+
orderId: z.string(),
|
|
156
|
+
total: z.object({ minorUnits: z.number().int(), currency: z.string().length(3) }),
|
|
157
|
+
}),
|
|
158
|
+
),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// order-placed.v1.integration-event.ts garde sa charge utile et gagne `deprecated: true` ;
|
|
162
|
+
// le publisher publie les deux versions jusqu'à ce qu'aucun consommateur ne lise la version 1.
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Un consommateur abonné à la version 1 ignore la version 2, et inversement : les deux cohabitent.
|
|
166
|
+
Le document AsyncAPI liste les deux versions sur le canal du type, et le catalogue marque celle
|
|
167
|
+
qui est dépréciée.
|
|
168
|
+
|
|
169
|
+
## Publier par l'outbox
|
|
170
|
+
|
|
171
|
+
Un *publisher* est un gestionnaire d'un événement du domaine de son propre contexte : il traduit
|
|
172
|
+
le fait en charge utile publique et publie vers un `IntegrationEventPublisher`, sans rien savoir
|
|
173
|
+
d'autre. Avec une base de données, ce *publisher* est l'`OutboxPublisher` : il ne fait que
|
|
174
|
+
stocker. Les événements du domaine sont distribués à la fin de l'unité de travail de la
|
|
175
|
+
commande, avant sa validation (la phase `before-commit`, celle par défaut), si bien que
|
|
176
|
+
l'événement d'intégration est validé ou annulé avec l'écriture métier : le relais le publie
|
|
177
|
+
après la validation, jamais pour une commande annulée. Un *publisher* n'est jamais déclaré
|
|
178
|
+
`phase: 'after-commit'` : la transaction qui porterait sa ligne d'outbox est terminée.
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// contexts/ordering/application/integration/publishers/order-placed.publisher.ts
|
|
182
|
+
import { IdGenerator, IntegrationEventPublisher } from '@ddd-ts-kit/kernel';
|
|
183
|
+
import { DomainEventHandler } from '@ddd-ts-kit/kernel/declarations';
|
|
184
|
+
|
|
185
|
+
@DomainEventHandler(OrderPlacedEvent, { name: OrderPlacedEvent.eventName })
|
|
186
|
+
export class OrderPlacedPublisher implements DomainEventHandler<OrderPlacedEvent> {
|
|
187
|
+
constructor(
|
|
188
|
+
private readonly publisher: IntegrationEventPublisher,
|
|
189
|
+
private readonly ids: IdGenerator,
|
|
190
|
+
) {}
|
|
191
|
+
|
|
192
|
+
async handle(event: OrderPlacedEvent): Promise<void> {
|
|
193
|
+
const published = orderPlaced.create(
|
|
194
|
+
{ orderId: event.aggregateId, total: event.total, lines: event.lines },
|
|
195
|
+
{ id: this.ids.next(), occurredAt: event.occurredAt },
|
|
196
|
+
); // lève InvalidEventPayloadError : publier un événement invalide est un bug
|
|
197
|
+
|
|
198
|
+
await this.publisher.publish([published]);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
L'identifiant de corrélation ne se passe pas à la main : `scopedPublisher` l'appose (voir « Le
|
|
204
|
+
contexte d'exécution d'un événement consommé »).
|
|
205
|
+
|
|
206
|
+
Le *publisher* connaît le port, jamais le transport. Au lieu de parler à un *broker* depuis le
|
|
207
|
+
gestionnaire, ce qui envoie l'événement même quand la transaction est ensuite annulée :
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
// Signalé par events/outbox-required : l'événement part avant la validation, ou sans elle.
|
|
211
|
+
export class OrderPlacedPublisher {
|
|
212
|
+
constructor(private readonly bus: RabbitMqEventBus) {}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
écrivez vers `IntegrationEventPublisher`, comme ci-dessus, et laissez la racine de composition
|
|
217
|
+
décider où il va. La règle `events/outbox-required` signale deux choses. Dans `domain/` ou
|
|
218
|
+
`application/`, l'import d'un client de *broker*, de n'importe quel bus d'événements (le port
|
|
219
|
+
`EventBus` du noyau compris), de `OutboxRelay`, de `OutboxPublisher` ou de `OutboxStore`. Dans
|
|
220
|
+
`application/integration/publishers/`, un paramètre de constructeur typé comme un bus ou un relais
|
|
221
|
+
(un type dont le nom finit par `EventBus` ou `Relay`) : c'est ainsi que l'exemple ci-dessus est
|
|
222
|
+
signalé, avec ou sans import.
|
|
223
|
+
|
|
224
|
+
La racine de composition lie le stockage sous son port, `OutboxStore`, et remet l'outbox au
|
|
225
|
+
module du noyau comme *publisher* qu'utilisent les gestionnaires. Les ports sont des classes
|
|
226
|
+
abstraites, et c'est la classe qu'un module lie et résout :
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
// platform/messaging.module.ts
|
|
230
|
+
import { defineContextModule } from '@ddd-ts-kit/di';
|
|
231
|
+
import { OutboxStore } from '@ddd-ts-kit/events';
|
|
232
|
+
|
|
233
|
+
export const messagingModule = defineContextModule({
|
|
234
|
+
name: 'messaging',
|
|
235
|
+
register(builder) {
|
|
236
|
+
// L'adaptateur de votre base de données, ou InMemoryOutboxStore dans un seul processus.
|
|
237
|
+
builder.bind(OutboxStore).toClass(SqlOutboxStore, [Database]);
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
<!-- checked sample -->
|
|
243
|
+
```ts
|
|
244
|
+
// main.ts
|
|
245
|
+
import { createApplication, kernelModule, type ContextModule } from '@ddd-ts-kit/di';
|
|
246
|
+
import { Clock, ExecutionScope, Logger, scopedPublisher, type IntegrationEventPublisher } from '@ddd-ts-kit/kernel';
|
|
247
|
+
import {
|
|
248
|
+
createEventRegistry,
|
|
249
|
+
OutboxPublisher,
|
|
250
|
+
OutboxRelay,
|
|
251
|
+
OutboxStore,
|
|
252
|
+
validatingPublisher,
|
|
253
|
+
type AnyIntegrationEventDefinition,
|
|
254
|
+
} from '@ddd-ts-kit/events';
|
|
255
|
+
|
|
256
|
+
// Écrits ailleurs dans le projet.
|
|
257
|
+
declare const persistenceModule: ContextModule, messagingModule: ContextModule, orderingModule: ContextModule;
|
|
258
|
+
declare const orderPlaced: AnyIntegrationEventDefinition, invoiceIssued: AnyIntegrationEventDefinition;
|
|
259
|
+
declare const broker: IntegrationEventPublisher; // le vrai fournisseur : un adaptateur de broker, ou le bus en mémoire
|
|
260
|
+
|
|
261
|
+
const registry = createEventRegistry([orderPlaced, invoiceIssued]);
|
|
262
|
+
|
|
263
|
+
const app = createApplication({
|
|
264
|
+
modules: [
|
|
265
|
+
kernelModule({
|
|
266
|
+
// Ce vers quoi publient les gestionnaires : l'outbox, derrière le registre, avec la corrélation apposée.
|
|
267
|
+
eventPublisher: (resolve) =>
|
|
268
|
+
scopedPublisher(
|
|
269
|
+
validatingPublisher(new OutboxPublisher(resolve(OutboxStore)), registry, { unknown: 'reject' }),
|
|
270
|
+
resolve(ExecutionScope),
|
|
271
|
+
),
|
|
272
|
+
}),
|
|
273
|
+
persistenceModule,
|
|
274
|
+
messagingModule,
|
|
275
|
+
orderingModule,
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
const logger = app.resolve(Logger);
|
|
279
|
+
|
|
280
|
+
const relay = new OutboxRelay({
|
|
281
|
+
store: app.resolve(OutboxStore),
|
|
282
|
+
publisher: broker,
|
|
283
|
+
clock: app.resolve(Clock),
|
|
284
|
+
maxAttempts: 8,
|
|
285
|
+
onFailure: (failure) => logger.warn('outbox relay failure', { ...failure }), // des codes seulement, jamais un message d'erreur
|
|
286
|
+
});
|
|
287
|
+
relay.start({ intervalMs: 500 });
|
|
288
|
+
process.on('SIGTERM', () => void relay.stop()); // attend la fin du tour en cours
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Donnez l'outbox à `kernelModule({ eventPublisher })` plutôt que de lier de nouveau
|
|
292
|
+
`IntegrationEventPublisher` dans un module ultérieur : l'`instrumentation` du module du noyau
|
|
293
|
+
enveloppe le *publisher* qu'il lie, et celui-là seulement ; un *publisher* lié ailleurs n'a donc
|
|
294
|
+
pas de *span* `outbox append` et ne stocke aucun `traceparent` avec ses événements.
|
|
295
|
+
|
|
296
|
+
Un adaptateur `implements OutboxStore` et ne l'étend jamais : le port n'a que des membres
|
|
297
|
+
abstraits. Sans conteneur, les mêmes objets se construisent à la main :
|
|
298
|
+
`new OutboxPublisher(store)`, `new OutboxRelay({ store, publisher: broker, clock })`.
|
|
299
|
+
|
|
300
|
+
L'outbox porte aussi les demandes de purge des caches HTTP (`CacheInvalidation` du noyau) : le
|
|
301
|
+
relais publie par `new PurgingPublisher(purger, broker)`, qui les remet au `CachePurger` au lieu
|
|
302
|
+
du *broker*. Voir « Tags de cache et purge après la validation » dans le README de
|
|
303
|
+
`@ddd-ts-kit/http`.
|
|
304
|
+
|
|
305
|
+
`relay.tick()` réclame un lot, publie chaque événement, le marque publié, ou planifie une
|
|
306
|
+
nouvelle tentative avec une attente exponentielle (backoff) ; après `maxAttempts`,
|
|
307
|
+
l'enregistrement est `dead` jusqu'à `store.retryDead(ids, now)`. `store.stats()` donne
|
|
308
|
+
`{ pending, failed, dead }` pour une vérification de santé, `store.purgePublished(before)` garde
|
|
309
|
+
la table petite.
|
|
310
|
+
|
|
311
|
+

|
|
312
|
+
|
|
313
|
+
Le relais publie les enregistrements d'un lot un par un, si bien qu'un événement refusé par le
|
|
314
|
+
fournisseur ne fait pas échouer ses voisins. Avec les valeurs par défaut (`baseMs: 1000`,
|
|
315
|
+
`factor: 2`, `maxMs: 300000`, `maxAttempts: 8`), un enregistrement qui échoue toujours suit ce
|
|
316
|
+
parcours :
|
|
317
|
+
|
|
318
|
+
| Tentative en échec | Tentative suivante | Ce que reçoit `onFailure` |
|
|
319
|
+
|---|---|---|
|
|
320
|
+
| 1 | 1 s plus tard | `{ kind: 'publish', eventId, eventType, code, attempts: 1, dead: false }` |
|
|
321
|
+
| 2 | 2 s plus tard | `attempts: 2, dead: false` |
|
|
322
|
+
| 3 à 7 | 4, 8, 16, 32, puis 64 s plus tard | `attempts` de 3 à 7, `dead: false` |
|
|
323
|
+
| 8 | aucune : l'enregistrement est `dead` | `attempts: 8, dead: true` |
|
|
324
|
+
|
|
325
|
+
Le délai vaut `min(maxMs, baseMs * factor ** (attempts - 1))` ; passez
|
|
326
|
+
`backoff: { baseMs, factor, maxMs }` pour le changer. `code` est le `code` de l'erreur quand il a
|
|
327
|
+
la forme d'un identifiant, sinon le nom de sa classe : jamais son message, qui peut citer la
|
|
328
|
+
charge utile, si bien que `onFailure` peut être journalisé tel quel.
|
|
329
|
+
|
|
330
|
+
**Le traçage à travers l'outbox.** L'enveloppe porte `traceparent`, et chaque stockage d'outbox
|
|
331
|
+
le persiste et le restitue exactement (la suite de contrat le vérifie). Avec
|
|
332
|
+
`kernelModule({ instrumentation: traceApplication(), eventPublisher: outbox })`, le contexte de
|
|
333
|
+
la requête HTTP est apposé quand l'événement est ajouté, et
|
|
334
|
+
`new OutboxRelay({ store, publisher: tracedPublisher(broker), clock })` poursuit cette trace
|
|
335
|
+
quand il publie plus tard : le relais n'a pas de *span* à lui, et un tour qui ne publie rien
|
|
336
|
+
n'en crée aucun. `OutboxPublisher` se nomme lui-même `messagingSystem: 'outbox'`, ce que
|
|
337
|
+
`validatingPublisher` et `scopedPublisher` transmettent. Voir le README de
|
|
338
|
+
`@ddd-ts-kit/tracing`.
|
|
339
|
+
|
|
340
|
+
La livraison est au moins une fois. **Par défaut, l'ordre n'est pas garanti, pas même par
|
|
341
|
+
agrégat** : un événement en échec est retenté pendant que ses successeurs partent, et plusieurs
|
|
342
|
+
relais peuvent tourner côte à côte. Quand l'ordre compte, portez une séquence ou une version dans
|
|
343
|
+
la charge utile, ou demandez-le au relais (ci-dessous).
|
|
344
|
+
|
|
345
|
+
### Réveiller le relais au lieu d'interroger souvent
|
|
346
|
+
|
|
347
|
+
Un relais peut dormir sur un long intervalle et publier quand même tout de suite, quand le
|
|
348
|
+
stockage lui signale qu'un ajout a eu lieu. `wakeOn` prend un `RelaySignal`, un port d'une seule
|
|
349
|
+
méthode (`subscribe(listener): { unsubscribe() }`) : `LISTEN/NOTIFY` de PostgreSQL, un flux de
|
|
350
|
+
changements (change stream), ou `InMemoryRelaySignal` dans un seul processus. Un module le lie
|
|
351
|
+
comme le stockage, `builder.bind(RelaySignal).toClass(PostgresRelaySignal, [Database])`, et le
|
|
352
|
+
relais prend `wakeOn: app.resolve(RelaySignal)`.
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
const signal = new InMemoryRelaySignal();
|
|
356
|
+
const store = new InMemoryOutboxStore({ signal }); // notifie après chaque ajout
|
|
357
|
+
const relay = new OutboxRelay({ store, publisher: broker, clock, wakeOn: signal });
|
|
358
|
+
relay.start({ intervalMs: 30_000 }); // l'intervalle n'est plus que le filet de sécurité
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Les rafales sont regroupées : un tour s'exécute, au plus un autre est mis en file, quel que soit
|
|
362
|
+
le nombre de notifications. **L'interrogation périodique (polling) reste le filet de
|
|
363
|
+
sécurité** : un signal est un indice, et une notification manquée pendant que l'écouteur se
|
|
364
|
+
reconnecte ou que le processus redémarre ne doit que retarder la publication jusqu'à l'intervalle
|
|
365
|
+
suivant, jamais la perdre. Gardez donc `intervalMs` au plus long délai que vous acceptez dans ce
|
|
366
|
+
cas, et ne vous fiez jamais au seul signal. Un stockage transactionnel doit notifier après la
|
|
367
|
+
validation (PostgreSQL le fait de lui-même : `NOTIFY` est délivré à la validation).
|
|
368
|
+
|
|
369
|
+
### L'ordre par clé
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
const relay = new OutboxRelay({
|
|
373
|
+
store,
|
|
374
|
+
publisher: broker,
|
|
375
|
+
clock,
|
|
376
|
+
ordering: 'per-key',
|
|
377
|
+
keyOf: (event) => (orderPlaced.is(event) ? event.payload.orderId : undefined),
|
|
378
|
+
});
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Avec `ordering: 'per-key'`, un enregistrement n'est jamais publié tant qu'un enregistrement
|
|
382
|
+
antérieur de même clé est encore en attente ou en échec. Le prix est le blocage en tête de file
|
|
383
|
+
(head-of-line blocking) : un événement qui ne peut pas être publié retient tous les événements
|
|
384
|
+
suivants de sa clé (et de sa clé seulement) jusqu'à ce qu'il parte ou soit mort. Les événements
|
|
385
|
+
pour lesquels `keyOf` donne `undefined` ne sont pas ordonnés.
|
|
386
|
+
|
|
387
|
+

|
|
388
|
+
|
|
389
|
+
Ce sur quoi il repose, et ce qu'il ne donne pas :
|
|
390
|
+
|
|
391
|
+
- **Faites tourner un seul relais.** La mémoire de quel enregistrement bloque quelle clé vit dans
|
|
392
|
+
le relais, pas dans le stockage : le port `OutboxStore` est inchangé, et `claimBatch` seul ne
|
|
393
|
+
peut pas savoir qu'un enregistrement antérieur attend sa nouvelle tentative. Deux relais côte à
|
|
394
|
+
côte publient en concurrence.
|
|
395
|
+
- **Un redémarrage l'oublie.** Si le processus redémarre pendant qu'un enregistrement est en
|
|
396
|
+
attente exponentielle ou sous bail, un enregistrement ultérieur de même clé qui devient dû le
|
|
397
|
+
premier est publié le premier, une fois. Un ordre strict à travers les redémarrages exige la
|
|
398
|
+
clé stockée avec l'enregistrement et une réclamation qui saute les clés ayant une ligne
|
|
399
|
+
antérieure non publiée : un changement du port et de la table, qui n'est pas fait ici.
|
|
400
|
+
- Un enregistrement retenu est replanifié avec la tête de sa clé et apparaît dans
|
|
401
|
+
`stats().failed` avec le code `OUTBOX_HELD_FOR_ORDER` ; les réclamations qu'il passe à attendre
|
|
402
|
+
ne sont pas comptées comme des tentatives de livraison par ce relais.
|
|
403
|
+
- Un enregistrement **mort** cesse de bloquer sa clé (`onFailure` le signale avec `dead: true`).
|
|
404
|
+
Réglez `maxAttempts: Infinity` si une clé doit plutôt rester bloquée que sauter un événement.
|
|
405
|
+
- Le *broker* et le consommateur doivent eux aussi garder l'ordre : une partition ou une file
|
|
406
|
+
d'attente avec un seul consommateur actif par clé, et un consommateur qui ne retente pas dans
|
|
407
|
+
le désordre.
|
|
408
|
+
|
|
409
|
+
Un `OutboxStore` accorde un bail sur ce qu'il remet : `claimBatch` masque les enregistrements
|
|
410
|
+
réclamés pendant le bail du stockage, si bien qu'un relais qui meurt les laisse revenir. Les
|
|
411
|
+
adaptateurs sont tenus par `outboxStoreContract` de `@ddd-ts-kit/testing/contracts`.
|
|
412
|
+
|
|
413
|
+
## Consommer de façon idempotente
|
|
414
|
+
|
|
415
|
+
Avec `@ddd-ts-kit/di`, un gestionnaire d'événement d'intégration déclaré est idempotent sans
|
|
416
|
+
rien écrire : sa marque d'idempotence et son travail s'exécutent dans une seule unité de travail
|
|
417
|
+
dès qu'un `UnitOfWork` et un `ProcessedEventStore` sont liés, ce que fait `kernelModule()`. La
|
|
418
|
+
racine de composition ne construit aucun gestionnaire à la main :
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
// contexts/billing/application/integration/handlers/invoice-on-order-placed.integration-handler.ts
|
|
422
|
+
import { CommandBus, type IntegrationEvent } from '@ddd-ts-kit/kernel';
|
|
423
|
+
import { IntegrationEventHandler } from '@ddd-ts-kit/kernel/declarations';
|
|
424
|
+
import { orderPlaced, type OrderPlaced } from '@/contexts/ordering/published-language/index';
|
|
425
|
+
|
|
426
|
+
@IntegrationEventHandler(orderPlaced, { consumer: 'billing.invoice-on-order-placed' })
|
|
427
|
+
export class InvoiceOnOrderPlaced {
|
|
428
|
+
constructor(private readonly commands: CommandBus) {}
|
|
429
|
+
|
|
430
|
+
async handle(event: IntegrationEvent<OrderPlaced>): Promise<void> {
|
|
431
|
+
await this.commands.execute(new IssueInvoice(event.payload.orderId, event.payload.total));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
`consume` est l'outil d'un projet qui câble sans di. Au lieu d'abonner une simple fonction de
|
|
437
|
+
rappel, qui traite une seconde fois un événement livré de nouveau et émet une seconde facture :
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
// Signalé par events/consumer-idempotent dans application/ et dans le fichier du module :
|
|
441
|
+
// pas de nom de consommateur, pas de stockage des événements traités, et une charge utile ni typée ni validée.
|
|
442
|
+
subscriber.subscribe(orderPlaced.type, async (event) => {
|
|
443
|
+
const { orderId, total } = event.payload as OrderPlaced;
|
|
444
|
+
await commandBus.execute(new IssueInvoice(orderId, total));
|
|
445
|
+
});
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
donnez à une simple fonction la même sémantique qu'au gestionnaire déclaré :
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
// contexts/billing/billing.subscriptions.ts
|
|
452
|
+
import { consume, subscribeAll } from '@ddd-ts-kit/events';
|
|
453
|
+
|
|
454
|
+
export const billingSubscriptions = ({ commandBus, processed, unitOfWork }: BillingDependencies) => [
|
|
455
|
+
consume(orderPlaced, { consumer: 'billing.invoice-on-order-placed', processed, unitOfWork }, async (event) => {
|
|
456
|
+
await commandBus.execute(new IssueInvoice(event.payload.orderId, event.payload.total));
|
|
457
|
+
}),
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
// main.ts
|
|
461
|
+
const subscription = subscribeAll(subscriber, billingSubscriptions(dependencies));
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Le gestionnaire reçoit un événement typé et validé. Un événement d'une autre version du même type
|
|
465
|
+
est ignoré (abonnez-vous-y avec sa propre déclaration) ; une charge utile invalide lève une
|
|
466
|
+
exception, si bien que le fournisseur retente ou envoie en file de rejet (dead letter).
|
|
467
|
+
|
|
468
|
+
`consume` exécute la déduplication et le gestionnaire dans **une seule unité de travail** : la
|
|
469
|
+
marque dans le stockage des événements traités et le changement d'état du consommateur sont
|
|
470
|
+
validés ensemble. Une nouvelle livraison est traitée une fois par consommateur, et un événement
|
|
471
|
+
dont le gestionnaire a levé une exception est traité de nouveau quand il revient, car sa marque a
|
|
472
|
+
été annulée avec le reste. Le *middleware* du bus de commandes rejoint cette unité de travail (un
|
|
473
|
+
`run` imbriqué rejoint la transaction englobante), si bien que le gestionnaire n'a besoin de rien
|
|
474
|
+
de particulier.
|
|
475
|
+
|
|
476
|
+

|
|
477
|
+
|
|
478
|
+
Le nom du consommateur est la portée de l'idempotence : deux consommateurs du même événement le
|
|
479
|
+
traitent chacun une fois, et renommer un consommateur lui fait traiter de nouveau chaque
|
|
480
|
+
événement qui lui est relivré. Écrivez-le une fois, sous la forme `<context>.<reaction>`, et ne le
|
|
481
|
+
dérivez jamais.
|
|
482
|
+
|
|
483
|
+
`on(definition, handler, { consumer, processed, unitOfWork })` est la même chose avec des parties
|
|
484
|
+
facultatives, pour un consommateur qui n'a pas besoin de déduplication ou n'a pas de transaction.
|
|
485
|
+
Attention à un cas : avec `processed` et sans `unitOfWork`, la marque est posée avant que le
|
|
486
|
+
gestionnaire s'exécute. Si le gestionnaire échoue ensuite, un stockage transactionnel garde la
|
|
487
|
+
marque (elle a été validée seule) et la nouvelle livraison est ignorée : l'événement est perdu
|
|
488
|
+
pour ce consommateur. Sans base de données, utilisez un stockage qui peut retirer sa marque, un
|
|
489
|
+
`ReleasableProcessedEventStore` comme l'`InMemoryProcessedEventStore` du noyau : `on` et
|
|
490
|
+
`consume` libèrent la marque d'un gestionnaire qui a échoué. `ReleasableProcessedEventStore` est
|
|
491
|
+
un type, pas un port à part entière : c'est le port `ProcessedEventStore` du noyau avec son
|
|
492
|
+
`release` facultatif rendu obligatoire, et un tel stockage se lie sous `ProcessedEventStore`.
|
|
493
|
+
|
|
494
|
+
### Le contexte d'exécution d'un événement consommé
|
|
495
|
+
|
|
496
|
+
`consume` et `on` prennent l'`ExecutionScope` du noyau et exécutent tout, l'unité de travail et
|
|
497
|
+
la marque d'idempotence comprises, à l'intérieur :
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
consume(orderPlaced, { consumer: 'billing.invoice-on-order-placed', processed, unitOfWork, scope }, handler);
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
| Champ du contexte | Valeur |
|
|
504
|
+
|---|---|
|
|
505
|
+
| `origin` | `event` |
|
|
506
|
+
| `correlationId` | le `correlationId` de l'événement, ou son `id` quand il n'en nomme aucun |
|
|
507
|
+
| `causationId` | l'`id` de l'événement |
|
|
508
|
+
| `actor` | `systemActor(consumer)`, sauf si `actorOf(event)` en renvoie un |
|
|
509
|
+
| `tenantId` | le `tenantId` de l'événement, quand il en nomme un : le consommateur ne lit et n'écrit que dans ce *tenant* |
|
|
510
|
+
|
|
511
|
+
Ainsi, les *repositories* du contexte consommateur remplissent les colonnes d'audit avec
|
|
512
|
+
`system:billing.invoice-on-order-placed`, et une ligne de journal du consommateur porte la
|
|
513
|
+
corrélation de la requête HTTP qui a tout déclenché. L'enveloppe ne porte aucun acteur par
|
|
514
|
+
défaut, à dessein : un acteur est une donnée personnelle, et un *broker* conserve les messages.
|
|
515
|
+
Un projet qui décide de mettre une référence dans sa propre charge utile ou sa propre enveloppe
|
|
516
|
+
la traduit avec `actorOf: (event) => Actor | undefined`.
|
|
517
|
+
|
|
518
|
+
Côté publication, `scopedPublisher` du noyau remplit la corrélation, et le *tenant* du travail
|
|
519
|
+
en cours, si bien que les cas d'usage ne les passent jamais à la main. C'est l'enveloppe la plus
|
|
520
|
+
externe du *publisher* donné à `kernelModule` dans le `main.ts` ci-dessus :
|
|
521
|
+
|
|
522
|
+
```ts
|
|
523
|
+
eventPublisher: (resolve) => scopedPublisher(new OutboxPublisher(resolve(OutboxStore)), resolve(ExecutionScope)),
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
Un événement créé avec un `correlationId` ou un `tenantId` le garde ; un événement publié hors de
|
|
527
|
+
tout contexte d'exécution est laissé tel quel. C'est un décorateur, pas une option
|
|
528
|
+
d'`OutboxPublisher` ni de `defineIntegrationEvent(...).create` : les deux restent purs, et
|
|
529
|
+
l'apposition s'applique à tout fournisseur.
|
|
530
|
+
|
|
531
|
+
## Générer le document AsyncAPI et le catalogue
|
|
532
|
+
|
|
533
|
+
<!-- checked sample -->
|
|
534
|
+
```ts
|
|
535
|
+
// scripts/generate-contracts.ts
|
|
536
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
537
|
+
import { generateEventCatalog, type EventRegistry } from '@ddd-ts-kit/events';
|
|
538
|
+
import { checkAsyncApiDrift, generateAsyncApi, stringifyAsyncApi } from '@ddd-ts-kit/events/asyncapi';
|
|
539
|
+
|
|
540
|
+
declare const registry: EventRegistry; // createEventRegistry([...toutes les déclarations publiées])
|
|
541
|
+
|
|
542
|
+
const document = generateAsyncApi({
|
|
543
|
+
info: { title: 'Shop events', version: '1.4.0' },
|
|
544
|
+
registry,
|
|
545
|
+
servers: { production: { host: 'broker.example.com:5671', protocol: 'amqps' } },
|
|
546
|
+
});
|
|
547
|
+
await writeFile('docs/integration/asyncapi.json', stringifyAsyncApi(document));
|
|
548
|
+
await writeFile(
|
|
549
|
+
'docs/integration/event-catalog.md',
|
|
550
|
+
generateEventCatalog(registry, { consumers: { 'ordering.order.placed': ['billing', 'shipping'] } }),
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
// En CI : échouer quand le fichier commité est périmé.
|
|
554
|
+
if (checkAsyncApiDrift(document, await readFile('docs/integration/asyncapi.json', 'utf8')).drift) {
|
|
555
|
+
process.exit(1);
|
|
556
|
+
}
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
`stringifyAsyncApi` écrit du JSON indenté de deux espaces. Un projet généré par la CLI commite
|
|
560
|
+
plutôt `docs/integration/asyncapi.yaml`, écrit par `stringifyContract(document, 'yaml')` de
|
|
561
|
+
`ddd-ts-kit/dist/contracts.js` dans son `scripts/events.ts` (`openapi.format: json` de sa
|
|
562
|
+
configuration garde le JSON).
|
|
563
|
+
|
|
564
|
+
Le document AsyncAPI 3.0.0 a un canal par type d'événement (son adresse est le type), un message
|
|
565
|
+
par version dont la charge utile est l'enveloppe (`id`, `type`, `version`, `occurredAt`,
|
|
566
|
+
`payload`, `traceparent`, `correlationId`), et une opération `send` par canal. Le catalogue est
|
|
567
|
+
en Markdown : une section par producteur, un tableau des versions par événement, et les champs
|
|
568
|
+
de la charge utile mis à plat (`customer.email`, `lines[].sku`). Les deux sorties ne dépendent
|
|
569
|
+
que des déclarations, jamais de leur ordre d'enregistrement, si bien qu'on peut les commiter et
|
|
570
|
+
les comparer.
|
|
571
|
+
Un projet généré par la CLI a ce script sous le nom `scripts/events.ts`, avec les deux mêmes
|
|
572
|
+
chemins.
|
|
573
|
+
|
|
574
|
+
## Choisir la stratégie pour votre transport
|
|
575
|
+
|
|
576
|
+
Il n'y a qu'une règle, quels que soient les outils : le gestionnaire écrit l'événement dans
|
|
577
|
+
l'outbox dans la transaction de la commande, un relais le publie ensuite, le consommateur
|
|
578
|
+
déduplique. Ce qui change avec le transport, c'est la façon dont le relais tourne et ce que le
|
|
579
|
+
côté consommateur doit faire.
|
|
580
|
+
|
|
581
|
+
| Transport | Relais | Côté consommateur |
|
|
582
|
+
|---|---|---|
|
|
583
|
+
| même processus, sans *broker* | relais par interrogation périodique vers le bus en mémoire (ou réveillé par la base de données : `LISTEN/NOTIFY` de PostgreSQL) | stockage des événements traités dans la transaction du consommateur |
|
|
584
|
+
| RabbitMQ et autres *brokers* à files d'attente | relais par interrogation périodique qui publie avec confirmations de publication (publisher confirms), marquage publié à la confirmation | une file d'attente durable par consommateur, acquittement manuel après la validation, échange de rejet (dead-letter exchange) après un nombre borné de nouvelles tentatives |
|
|
585
|
+
| Kafka et autres journaux | relais par interrogation périodique, ou capture des changements de données (change data capture) qui lit la table outbox (outbox router de Debezium) ; clé = identifiant de l'agrégat pour l'ordre par agrégat | un groupe de consommateurs par consommateur, *offset* validé après la transaction du gestionnaire, stockage des événements traités pour les nouvelles livraisons |
|
|
586
|
+
| files et sujets du cloud (SQS/SNS, Pub/Sub) | relais par interrogation périodique, publication par lots | délai de visibilité supérieur au pire cas du gestionnaire, politique de redirection (redrive policy) vers une file de rejet |
|
|
587
|
+
|
|
588
|
+
Sans base de données, il n'y a rien avec quoi être atomique : publiez directement vers le bus,
|
|
589
|
+
sans outbox.
|
|
590
|
+
|
|
591
|
+
Le kit a un *package* fournisseur pour RabbitMQ (`@ddd-ts-kit/rabbitmq`), Kafka
|
|
592
|
+
(`@ddd-ts-kit/kafka`), NATS JetStream (`@ddd-ts-kit/nats`) et Redis Streams
|
|
593
|
+
(`@ddd-ts-kit/redis/event-bus`) ; chaque README dit comment il remplit les exigences ci-dessous et
|
|
594
|
+
ce qu'il ne garantit pas.
|
|
595
|
+
|
|
596
|
+
### Même processus, sans broker
|
|
597
|
+
|
|
598
|
+
- **Mode du relais.** `OutboxRelay` qui publie vers l'`InMemoryEventBus`. Réveillez-le avec
|
|
599
|
+
`wakeOn` (le signal en mémoire, ou `LISTEN/NOTIFY` sur PostgreSQL) et gardez un long intervalle
|
|
600
|
+
comme filet de sécurité.
|
|
601
|
+
- **Exigences du publisher.** Aucune au-delà du port. Le bus en mémoire exécute les gestionnaires
|
|
602
|
+
dans `publish` et rejette quand l'un d'eux échoue : le relais retente alors l'enregistrement, si
|
|
603
|
+
bien que la politique de nouvelles tentatives des consommateurs EST l'attente exponentielle du
|
|
604
|
+
relais.
|
|
605
|
+
- **Exigences du consommateur.** `consume` avec le stockage des événements traités et l'unité de
|
|
606
|
+
travail du contexte consommateur. Une nouvelle livraison exécute de nouveau chaque gestionnaire
|
|
607
|
+
du type, si bien que chacun d'eux déduplique.
|
|
608
|
+
- **Ordre.** `per-key` le donne ici sans rien d'autre à mettre en place : un relais, un
|
|
609
|
+
processus, des gestionnaires attendus dans l'ordre (la réserve du relais sur le redémarrage
|
|
610
|
+
s'applique toujours).
|
|
611
|
+
- **File de rejet.** L'outbox est le stockage des rejets : un enregistrement est `dead` après
|
|
612
|
+
`maxAttempts`, et revient avec `store.retryDead(ids, now)`.
|
|
613
|
+
- **Surveiller.** `stats()` : `dead > 0` est une alerte ; un `failed` qui monte signifie qu'un
|
|
614
|
+
consommateur échoue sans cesse ; l'âge de l'enregistrement en attente le plus ancien est le
|
|
615
|
+
délai de livraison.
|
|
616
|
+
|
|
617
|
+
### Brokers à files d'attente (RabbitMQ, ActiveMQ)
|
|
618
|
+
|
|
619
|
+
- **Mode du relais.** Relais par interrogation périodique ou réveillé. L'enregistrement n'est
|
|
620
|
+
marqué publié que lorsque le *broker* l'a confirmé.
|
|
621
|
+
- **Exigences du publisher.** `publish` ne doit se résoudre qu'à la confirmation du *broker*
|
|
622
|
+
(publisher confirms) et rejeter sinon, avec des échanges durables et des messages persistants.
|
|
623
|
+
Une confirmation perdue après que le *broker* a pris le message fait republier le relais : les
|
|
624
|
+
doublons sont attendus. `@ddd-ts-kit/rabbitmq` met en œuvre cette ligne.
|
|
625
|
+
- **Exigences du consommateur.** Une file d'attente durable par consommateur et par type
|
|
626
|
+
d'événement, un acquittement manuel après la validation de l'unité de travail du gestionnaire,
|
|
627
|
+
`consume` pour la déduplication. N'acquittez jamais avant la validation.
|
|
628
|
+
- **Ordre.** Aucun par défaut : les nouvelles tentatives et les consommateurs concurrents
|
|
629
|
+
réordonnent. Par clé seulement avec le relais `per-key` ET un seul consommateur actif sur la
|
|
630
|
+
file ET aucune file de nouvelles tentatives, ce qui en vaut rarement la peine ; préférez une
|
|
631
|
+
version dans la charge utile.
|
|
632
|
+
- **File de rejet.** Deux endroits. Côté publication : l'outbox (`dead`, `retryDead`). Côté
|
|
633
|
+
consommation : un nombre borné de nouvelles tentatives différées, puis une file de rejet par
|
|
634
|
+
file de consommateur.
|
|
635
|
+
- **Surveiller.** Les `stats()` de l'outbox, et sur le *broker* la profondeur de chaque file de
|
|
636
|
+
travail (un consommateur qui prend du retard), de chaque file de rejet (qui doit être vide) et
|
|
637
|
+
le nombre de messages non acquittés.
|
|
638
|
+
|
|
639
|
+
### Journaux (Kafka, Redpanda, Pulsar, Kinesis)
|
|
640
|
+
|
|
641
|
+
- **Mode du relais.** Relais par interrogation périodique, ou capture des changements de données
|
|
642
|
+
(ci-dessous).
|
|
643
|
+
- **Exigences du publisher.** Un producteur idempotent avec `acks=all` ; `publish` se résout à
|
|
644
|
+
l'acquittement de l'écriture. La clé du message est l'identifiant de l'agrégat, si bien qu'un
|
|
645
|
+
agrégat vit dans une seule partition. Un sujet (topic) par type d'événement ou par contexte,
|
|
646
|
+
choisi une fois pour toutes.
|
|
647
|
+
- **Exigences du consommateur.** Un groupe de consommateurs par contexte consommateur. L'*offset*
|
|
648
|
+
est validé après la transaction du gestionnaire, jamais avant ; un plantage entre les deux
|
|
649
|
+
provoque une nouvelle livraison, d'où le stockage des événements traités. Un gestionnaire qui
|
|
650
|
+
échoue sans cesse bloque sa partition : retentez sur place un nombre borné de fois, puis
|
|
651
|
+
publiez l'enregistrement vers un sujet de rejet et validez.
|
|
652
|
+
- **Ordre.** Par partition, donc par clé, pourvu que le relais le garde : `ordering: 'per-key'`
|
|
653
|
+
avec un `keyOf` qui renvoie le même identifiant d'agrégat, un seul relais, et
|
|
654
|
+
`max.in.flight.requests.per.connection` à 5 au plus avec l'idempotence activée.
|
|
655
|
+
- **File de rejet.** L'outbox côté publication, un sujet de rejet par groupe de consommateurs
|
|
656
|
+
côté consommation.
|
|
657
|
+
- **Surveiller.** Les `stats()` de l'outbox, le retard des consommateurs par groupe et par
|
|
658
|
+
partition, la taille du sujet de rejet.
|
|
659
|
+
|
|
660
|
+
### Files et sujets du cloud (SQS/SNS, Pub/Sub)
|
|
661
|
+
|
|
662
|
+
- **Mode du relais.** Relais par interrogation périodique. La publication par lots (10 entrées
|
|
663
|
+
par appel sur SQS et SNS) est permise, mais le résultat est par entrée : publiez, puis rejetez
|
|
664
|
+
si une entrée a échoué, pour que le relais retente ; les entrées qui sont passées sont publiées
|
|
665
|
+
deux fois, ce que le consommateur absorbe.
|
|
666
|
+
- **Exigences du publisher.** Un sujet avec un abonnement (une file d'attente) par consommateur.
|
|
667
|
+
`publish` se résout à l'acquittement du service. Les attributs du message portent le type, la
|
|
668
|
+
version et la trace.
|
|
669
|
+
- **Exigences du consommateur.** Un délai de visibilité (ack deadline) supérieur au pire cas du
|
|
670
|
+
gestionnaire, prolongé pendant son exécution si besoin ; supprimez (acquittez) le message après
|
|
671
|
+
la validation. `consume` pour la déduplication : ces services livrent de nouveau par
|
|
672
|
+
construction.
|
|
673
|
+
- **Ordre.** Aucun sur les files standard. Les files FIFO et les clés d'ordre le donnent par
|
|
674
|
+
groupe de messages, au prix du débit, avec le relais `per-key` et l'identifiant de groupe =
|
|
675
|
+
`keyOf`.
|
|
676
|
+
- **File de rejet.** L'outbox côté publication ; une politique de redirection
|
|
677
|
+
(`maxReceiveCount`) vers une file de rejet par file de consommateur.
|
|
678
|
+
- **Surveiller.** Les `stats()` de l'outbox, l'âge du message le plus ancien par file, la
|
|
679
|
+
profondeur de la file de rejet.
|
|
680
|
+
|
|
681
|
+
### Ce qu'il faut surveiller sur l'outbox
|
|
682
|
+
|
|
683
|
+
`store.stats()` donne `{ pending, failed, dead }` ; exposez-les comme des jauges.
|
|
684
|
+
|
|
685
|
+
| Signal | Sens | Réaction |
|
|
686
|
+
|---|---|---|
|
|
687
|
+
| `dead > 0` | des enregistrements ont abandonné après `maxAttempts` | alerte ; corrigez la cause, puis `retryDead` |
|
|
688
|
+
| `failed > 0` longtemps | le fournisseur refuse ou est hors service ; sous `per-key`, les enregistrements retenus comptent aussi ici (`OUTBOX_HELD_FOR_ORDER`) | regardez les codes donnés à `onFailure` |
|
|
689
|
+
| `pending` qui grossit | le relais est plus lent que les producteurs, ou arrêté | vérifiez qu'il tourne ; augmentez `batchSize` |
|
|
690
|
+
| âge de l'enregistrement en attente le plus ancien | le vrai délai de livraison | alerte au-delà de votre tolérance ; c'est une requête sur la table (`min(created_at) where status = 'pending'`), `stats()` ne le porte pas |
|
|
691
|
+
| `onFailure({ kind: 'tick' })` | le stockage lui-même a échoué | santé de la base de données |
|
|
692
|
+
|
|
693
|
+
Purgez régulièrement les enregistrements publiés (`purgePublished(before)`), sinon la table et
|
|
694
|
+
son index grossissent sans limite.
|
|
695
|
+
|
|
696
|
+
### Où se place la capture des changements de données
|
|
697
|
+
|
|
698
|
+
La capture des changements de données (Debezium et son outbox event router, ou un lecteur de
|
|
699
|
+
réplication logique) remplace **le relais, jamais la table**. Les gestionnaires écrivent toujours
|
|
700
|
+
l'événement dans la table outbox dans la transaction de la commande ; au lieu d'un processus qui
|
|
701
|
+
interroge cette table, la capture lit les mêmes lignes dans le journal des transactions de la
|
|
702
|
+
base de données et les publie, dans l'ordre des validations. Conséquences :
|
|
703
|
+
|
|
704
|
+
- rien ne change dans les gestionnaires, dans `OutboxPublisher` ni dans les consommateurs ;
|
|
705
|
+
- ne faites pas tourner un `OutboxRelay` sur la même table pour la même destination : l'un ou
|
|
706
|
+
l'autre ;
|
|
707
|
+
- `status`, `attempts` et `next_attempt_at` ne sont plus tenus à jour : les nouvelles tentatives
|
|
708
|
+
et les échecs sont ceux du connecteur, donc surveillez le connecteur (retard, tâches en échec)
|
|
709
|
+
au lieu de `stats()`, et purgez par âge ;
|
|
710
|
+
- l'event router doit faire correspondre les colonnes : `id` à l'identifiant du message, `type`
|
|
711
|
+
au sujet ou à la clé de routage, l'identifiant de l'agrégat à la clé du message, `payload` et
|
|
712
|
+
les colonnes de trace au corps et aux en-têtes, pour que le consommateur reçoive la même
|
|
713
|
+
enveloppe que depuis le relais ;
|
|
714
|
+
- l'ordre par clé vient gratuitement (le journal est ordonné), et c'est la principale raison de
|
|
715
|
+
la choisir.
|
|
716
|
+
|
|
717
|
+
Écrire vers le *broker* depuis le gestionnaire et « capturer » plutôt les tables métier n'est
|
|
718
|
+
pas ce patron : cela publie des lignes, pas des événements, et couple les consommateurs au
|
|
719
|
+
schéma.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
**English** · [Français](README.fr.md)
|
|
2
|
+
|
|
1
3
|
# @ddd-ts-kit/events
|
|
2
4
|
|
|
3
5
|
Integration events between bounded contexts, independent of any broker: typed declarations,
|
|
@@ -8,11 +10,94 @@ document and event catalog generated from the declarations.
|
|
|
8
10
|
npm i @ddd-ts-kit/events @ddd-ts-kit/validation zod
|
|
9
11
|
```
|
|
10
12
|
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
## What is in the package
|
|
16
|
+
|
|
17
|
+
| Step | What it is for | Exports |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| Declare | the contract of one event and version, owned by the producer | `defineIntegrationEvent`, `PayloadOf` |
|
|
20
|
+
| Register | every event a system publishes, to validate what goes out and to generate the documents | `createEventRegistry`, `EventRegistry`, `validatingPublisher` |
|
|
21
|
+
| Store | write the event in the transaction of the command | `OutboxPublisher`, the `OutboxStore` port, `InMemoryOutboxStore` |
|
|
22
|
+
| Relay | publish what was stored, after the commit, with retries | `OutboxRelay`, the `RelaySignal` port, `InMemoryRelaySignal` |
|
|
23
|
+
| Consume | typed, validated, deduplicated handling | `consume`, `on`, `subscribeAll` |
|
|
24
|
+
| Document | the AsyncAPI document and the event catalog | `generateAsyncApi` (`@ddd-ts-kit/events/asyncapi`), `generateEventCatalog` |
|
|
25
|
+
|
|
26
|
+
The concepts behind it are in [The transactional outbox](../../docs/concepts/transactional-outbox.md),
|
|
27
|
+
[Delivery and idempotency](../../docs/concepts/delivery-and-idempotency.md) and
|
|
28
|
+
[Event versioning](../../docs/concepts/event-versioning.md).
|
|
29
|
+
|
|
30
|
+
## The whole loop in one process
|
|
31
|
+
|
|
32
|
+
Everything below is in memory, so it runs as it is: one declaration, a consumer, the outbox,
|
|
33
|
+
the relay, and a redelivery the consumer absorbs. A real application swaps the in-memory
|
|
34
|
+
pieces for its database and its broker; the code of the producer and of the consumer does not
|
|
35
|
+
change.
|
|
36
|
+
|
|
37
|
+
<!-- checked sample -->
|
|
38
|
+
```ts
|
|
39
|
+
import { z } from 'zod';
|
|
40
|
+
import { zodSchema } from '@ddd-ts-kit/validation/zod';
|
|
41
|
+
import { InMemoryEventBus, InMemoryProcessedEventStore, InMemoryUnitOfWork, SystemClock } from '@ddd-ts-kit/kernel';
|
|
42
|
+
import {
|
|
43
|
+
consume,
|
|
44
|
+
defineIntegrationEvent,
|
|
45
|
+
InMemoryOutboxStore,
|
|
46
|
+
OutboxPublisher,
|
|
47
|
+
OutboxRelay,
|
|
48
|
+
subscribeAll,
|
|
49
|
+
} from '@ddd-ts-kit/events';
|
|
50
|
+
|
|
51
|
+
// 1. The contract, declared once by the producer.
|
|
52
|
+
const orderPlaced = defineIntegrationEvent({
|
|
53
|
+
type: 'ordering.order.placed',
|
|
54
|
+
version: 1,
|
|
55
|
+
payload: zodSchema(z.object({ orderId: z.string(), total: z.number().int() })),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// 2. A consumer of another context: deduplicated, in one unit of work.
|
|
59
|
+
const bus = new InMemoryEventBus();
|
|
60
|
+
const invoiced: string[] = [];
|
|
61
|
+
|
|
62
|
+
subscribeAll(bus, [
|
|
63
|
+
consume(
|
|
64
|
+
orderPlaced,
|
|
65
|
+
{
|
|
66
|
+
consumer: 'billing.invoice-on-order-placed',
|
|
67
|
+
processed: new InMemoryProcessedEventStore(),
|
|
68
|
+
unitOfWork: new InMemoryUnitOfWork(),
|
|
69
|
+
},
|
|
70
|
+
async (event) => {
|
|
71
|
+
invoiced.push(event.payload.orderId); // typed: { orderId: string; total: number }
|
|
72
|
+
},
|
|
73
|
+
),
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
// 3. The producer only stores: nothing leaves yet.
|
|
77
|
+
const store = new InMemoryOutboxStore();
|
|
78
|
+
const event = orderPlaced.create({ orderId: 'order-1', total: 3000 }, { id: 'evt-1', occurredAt: new Date() });
|
|
79
|
+
|
|
80
|
+
await new OutboxPublisher(store).publish([event]);
|
|
81
|
+
console.log(await store.stats()); // { pending: 1, failed: 0, dead: 0 }
|
|
82
|
+
|
|
83
|
+
// 4. The relay publishes what was stored, then marks it published.
|
|
84
|
+
const relay = new OutboxRelay({ store, publisher: bus, clock: new SystemClock() });
|
|
85
|
+
|
|
86
|
+
console.log(await relay.tick()); // { published: 1, failed: 0 }
|
|
87
|
+
|
|
88
|
+
// 5. The same event delivered again is absorbed by the consumer.
|
|
89
|
+
await bus.publish([event]);
|
|
90
|
+
console.log(invoiced); // [ 'order-1' ]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The sections below take each step in turn, as a real application writes it.
|
|
94
|
+
|
|
11
95
|
## Declare
|
|
12
96
|
|
|
13
97
|
One declaration per event and version, in the producer's `published-language/`. It gives the
|
|
14
98
|
payload type, validates what is published and consumed, and feeds the generators.
|
|
15
99
|
|
|
100
|
+
<!-- checked sample -->
|
|
16
101
|
```ts
|
|
17
102
|
// contexts/ordering/published-language/order-placed.v1.integration-event.ts
|
|
18
103
|
import { z } from 'zod';
|
|
@@ -35,8 +120,48 @@ export const orderPlaced = defineIntegrationEvent({
|
|
|
35
120
|
export type OrderPlaced = PayloadOf<typeof orderPlaced>;
|
|
36
121
|
```
|
|
37
122
|
|
|
123
|
+
The type is checked when the module loads: three kebab-case segments, `<context>.<aggregate>.<fact>`,
|
|
124
|
+
and a positive integer version, or `defineIntegrationEvent` throws `InvalidEventDefinitionError`.
|
|
125
|
+
The producer is the first segment of the type unless `producer` names it. The declaration then
|
|
126
|
+
answers three questions, one per side:
|
|
127
|
+
|
|
128
|
+
| Member | Side | What it does |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `create(payload, { id, occurredAt, correlationId?, traceparent? })` | producer | validates the payload and builds the envelope; throws `InvalidEventPayloadError`, because publishing an invalid event is a bug |
|
|
131
|
+
| `parse(event)` | consumer | checks type, version and payload and answers a `Result`; never throws |
|
|
132
|
+
| `is(event)` | either | a cheap guard on type and version only, for a payload already trusted |
|
|
133
|
+
|
|
38
134
|
A breaking change is a new declaration with `version: 2`; mark the old one `deprecated: true`
|
|
39
|
-
and keep publishing both until the consumers have moved.
|
|
135
|
+
and keep publishing both until the consumers have moved. Instead of changing the payload of
|
|
136
|
+
`version: 1` under the feet of its consumers:
|
|
137
|
+
|
|
138
|
+
<!-- checked sample -->
|
|
139
|
+
```ts
|
|
140
|
+
// contexts/ordering/published-language/order-placed.v2.integration-event.ts
|
|
141
|
+
import { z } from 'zod';
|
|
142
|
+
import { zodSchema } from '@ddd-ts-kit/validation/zod';
|
|
143
|
+
import { defineIntegrationEvent } from '@ddd-ts-kit/events';
|
|
144
|
+
|
|
145
|
+
// `amount` renamed `minorUnits`, any ISO currency accepted: a consumer of version 1 would break, so a new version.
|
|
146
|
+
export const orderPlacedV2 = defineIntegrationEvent({
|
|
147
|
+
type: 'ordering.order.placed',
|
|
148
|
+
version: 2,
|
|
149
|
+
description: 'An order was placed by a customer.',
|
|
150
|
+
payload: zodSchema(
|
|
151
|
+
z.object({
|
|
152
|
+
orderId: z.string(),
|
|
153
|
+
total: z.object({ minorUnits: z.number().int(), currency: z.string().length(3) }),
|
|
154
|
+
}),
|
|
155
|
+
),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// order-placed.v1.integration-event.ts keeps its payload and gains `deprecated: true`;
|
|
159
|
+
// the publisher publishes both versions until no consumer reads version 1.
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A consumer subscribed to version 1 skips version 2 and the other way round, so both live side
|
|
163
|
+
by side. The AsyncAPI document lists both versions on the channel of the type, and the catalog
|
|
164
|
+
marks the deprecated one.
|
|
40
165
|
|
|
41
166
|
## Publish through the outbox
|
|
42
167
|
|
|
@@ -74,44 +199,82 @@ export class OrderPlacedPublisher implements DomainEventHandler<OrderPlacedEvent
|
|
|
74
199
|
The correlation id is not passed by hand: `scopedPublisher` stamps it (see "The execution
|
|
75
200
|
context of a consumed event").
|
|
76
201
|
|
|
77
|
-
The
|
|
78
|
-
|
|
79
|
-
|
|
202
|
+
The publisher knows the port, never the transport. Instead of talking to a broker from the
|
|
203
|
+
handler, which sends the event even when the transaction then rolls back:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
// Reported by events/outbox-required: the event leaves before the commit, or without it.
|
|
207
|
+
export class OrderPlacedPublisher {
|
|
208
|
+
constructor(private readonly bus: RabbitMqEventBus) {}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
write to `IntegrationEventPublisher`, as above, and let the composition root decide where it
|
|
213
|
+
goes. The rule `events/outbox-required` reports two things. In `domain/` or `application/`, an
|
|
214
|
+
import of a broker client, of any event bus (the `EventBus` port of the kernel included), of
|
|
215
|
+
`OutboxRelay`, of `OutboxPublisher` or of `OutboxStore`. In `application/integration/publishers/`,
|
|
216
|
+
a constructor parameter typed as a bus or a relay (a type whose name ends in `EventBus` or
|
|
217
|
+
`Relay`): the sample above is reported this way, import or not.
|
|
218
|
+
|
|
219
|
+
The composition root binds the store under its port, `OutboxStore`, and hands the outbox to
|
|
220
|
+
the kernel module as the publisher handlers use. Ports are abstract classes and the class is
|
|
221
|
+
what a module binds and resolves:
|
|
80
222
|
|
|
81
223
|
```ts
|
|
82
224
|
// platform/messaging.module.ts
|
|
83
225
|
import { defineContextModule } from '@ddd-ts-kit/di';
|
|
84
|
-
import {
|
|
85
|
-
import { createEventRegistry, OutboxPublisher, OutboxStore, validatingPublisher } from '@ddd-ts-kit/events';
|
|
86
|
-
|
|
87
|
-
const registry = createEventRegistry([orderPlaced, invoiceIssued]);
|
|
226
|
+
import { OutboxStore } from '@ddd-ts-kit/events';
|
|
88
227
|
|
|
89
228
|
export const messagingModule = defineContextModule({
|
|
90
229
|
name: 'messaging',
|
|
91
230
|
register(builder) {
|
|
92
231
|
// The adapter of your database, or InMemoryOutboxStore in a single process.
|
|
93
232
|
builder.bind(OutboxStore).toClass(SqlOutboxStore, [Database]);
|
|
94
|
-
builder
|
|
95
|
-
.rebind(IntegrationEventPublisher)
|
|
96
|
-
.toFactory((resolve) =>
|
|
97
|
-
validatingPublisher(new OutboxPublisher(resolve(OutboxStore)), registry, { unknown: 'reject' }),
|
|
98
|
-
);
|
|
99
233
|
},
|
|
100
234
|
});
|
|
101
235
|
```
|
|
102
236
|
|
|
237
|
+
<!-- checked sample -->
|
|
103
238
|
```ts
|
|
104
239
|
// main.ts
|
|
105
|
-
import { createApplication, kernelModule } from '@ddd-ts-kit/di';
|
|
106
|
-
import { Clock, Logger } from '@ddd-ts-kit/kernel';
|
|
107
|
-
import {
|
|
240
|
+
import { createApplication, kernelModule, type ContextModule } from '@ddd-ts-kit/di';
|
|
241
|
+
import { Clock, ExecutionScope, Logger, scopedPublisher, type IntegrationEventPublisher } from '@ddd-ts-kit/kernel';
|
|
242
|
+
import {
|
|
243
|
+
createEventRegistry,
|
|
244
|
+
OutboxPublisher,
|
|
245
|
+
OutboxRelay,
|
|
246
|
+
OutboxStore,
|
|
247
|
+
validatingPublisher,
|
|
248
|
+
type AnyIntegrationEventDefinition,
|
|
249
|
+
} from '@ddd-ts-kit/events';
|
|
250
|
+
|
|
251
|
+
// Written elsewhere in the project.
|
|
252
|
+
declare const persistenceModule: ContextModule, messagingModule: ContextModule, orderingModule: ContextModule;
|
|
253
|
+
declare const orderPlaced: AnyIntegrationEventDefinition, invoiceIssued: AnyIntegrationEventDefinition;
|
|
254
|
+
declare const broker: IntegrationEventPublisher; // the real provider: a broker adapter, or the in-memory bus
|
|
108
255
|
|
|
109
|
-
const
|
|
256
|
+
const registry = createEventRegistry([orderPlaced, invoiceIssued]);
|
|
257
|
+
|
|
258
|
+
const app = createApplication({
|
|
259
|
+
modules: [
|
|
260
|
+
kernelModule({
|
|
261
|
+
// What handlers publish to: the outbox, behind the registry, with the correlation stamped.
|
|
262
|
+
eventPublisher: (resolve) =>
|
|
263
|
+
scopedPublisher(
|
|
264
|
+
validatingPublisher(new OutboxPublisher(resolve(OutboxStore)), registry, { unknown: 'reject' }),
|
|
265
|
+
resolve(ExecutionScope),
|
|
266
|
+
),
|
|
267
|
+
}),
|
|
268
|
+
persistenceModule,
|
|
269
|
+
messagingModule,
|
|
270
|
+
orderingModule,
|
|
271
|
+
],
|
|
272
|
+
});
|
|
110
273
|
const logger = app.resolve(Logger);
|
|
111
274
|
|
|
112
275
|
const relay = new OutboxRelay({
|
|
113
276
|
store: app.resolve(OutboxStore),
|
|
114
|
-
publisher: broker,
|
|
277
|
+
publisher: broker,
|
|
115
278
|
clock: app.resolve(Clock),
|
|
116
279
|
maxAttempts: 8,
|
|
117
280
|
onFailure: (failure) => logger.warn('outbox relay failure', { ...failure }), // codes only, never an error message
|
|
@@ -120,6 +283,11 @@ relay.start({ intervalMs: 500 });
|
|
|
120
283
|
process.on('SIGTERM', () => void relay.stop()); // waits for the tick in progress
|
|
121
284
|
```
|
|
122
285
|
|
|
286
|
+
Give the outbox to `kernelModule({ eventPublisher })` rather than rebinding
|
|
287
|
+
`IntegrationEventPublisher` in a later module: the `instrumentation` of the kernel module wraps
|
|
288
|
+
the publisher it binds, and only that one, so a publisher rebound elsewhere has no
|
|
289
|
+
`outbox append` span and stores no `traceparent` with its events.
|
|
290
|
+
|
|
123
291
|
An adapter `implements OutboxStore` and never extends it: the port has abstract members only.
|
|
124
292
|
Without a container the same objects are built by hand:
|
|
125
293
|
`new OutboxPublisher(store)`, `new OutboxRelay({ store, publisher: broker, clock })`.
|
|
@@ -134,10 +302,28 @@ with an exponential backoff; after `maxAttempts` the record is `dead` until
|
|
|
134
302
|
`store.retryDead(ids, now)`. `store.stats()` gives `{ pending, failed, dead }` for a health
|
|
135
303
|
check, `store.purgePublished(before)` keeps the table small.
|
|
136
304
|
|
|
305
|
+

|
|
306
|
+
|
|
307
|
+
The relay publishes the records of a batch one by one, so one event the provider refuses does
|
|
308
|
+
not fail its neighbours. With the defaults (`baseMs: 1000`, `factor: 2`, `maxMs: 300000`,
|
|
309
|
+
`maxAttempts: 8`) a record that always fails goes like this:
|
|
310
|
+
|
|
311
|
+
| Attempt that failed | Next attempt | `onFailure` receives |
|
|
312
|
+
|---|---|---|
|
|
313
|
+
| 1 | 1 s later | `{ kind: 'publish', eventId, eventType, code, attempts: 1, dead: false }` |
|
|
314
|
+
| 2 | 2 s later | `attempts: 2, dead: false` |
|
|
315
|
+
| 3 to 7 | 4, 8, 16, 32, then 64 s later | `attempts` from 3 to 7, `dead: false` |
|
|
316
|
+
| 8 | none: the record is `dead` | `attempts: 8, dead: true` |
|
|
317
|
+
|
|
318
|
+
The delay is `min(maxMs, baseMs * factor ** (attempts - 1))`; pass `backoff: { baseMs, factor, maxMs }`
|
|
319
|
+
to change it. `code` is the `code` of the error when it is shaped like an identifier, else its
|
|
320
|
+
class name: never its message, which may quote the payload, so `onFailure` can be logged as it
|
|
321
|
+
is.
|
|
322
|
+
|
|
137
323
|
**Tracing across the outbox.** The envelope carries `traceparent`, and every outbox store
|
|
138
324
|
persists and restores it exactly (the contract suite checks it). With
|
|
139
|
-
`kernelModule({ instrumentation: traceApplication() })` the context of
|
|
140
|
-
when the event is appended, and `new OutboxRelay({ store, publisher: tracedPublisher(broker), clock })`
|
|
325
|
+
`kernelModule({ instrumentation: traceApplication(), eventPublisher: outbox })` the context of
|
|
326
|
+
the request is stamped when the event is appended, and `new OutboxRelay({ store, publisher: tracedPublisher(broker), clock })`
|
|
141
327
|
continues that trace when it publishes later: the relay has no span of its own, and a tick
|
|
142
328
|
that publishes nothing makes none. `OutboxPublisher` names itself `messagingSystem: 'outbox'`,
|
|
143
329
|
which `validatingPublisher` and `scopedPublisher` pass on. See the README of
|
|
@@ -188,6 +374,8 @@ key is still pending or failed. The price is head-of-line blocking: one event th
|
|
|
188
374
|
published holds back every later event of its key (and only of its key) until it goes out or
|
|
189
375
|
is dead. Events for which `keyOf` gives `undefined` are not ordered.
|
|
190
376
|
|
|
377
|
+

|
|
378
|
+
|
|
191
379
|
What it relies on, and what it does not give:
|
|
192
380
|
|
|
193
381
|
- **Run a single relay.** The memory of which record blocks which key lives in the relay, not
|
|
@@ -232,8 +420,19 @@ export class InvoiceOnOrderPlaced {
|
|
|
232
420
|
}
|
|
233
421
|
```
|
|
234
422
|
|
|
235
|
-
`consume` is the tool of a project that wires without di.
|
|
236
|
-
|
|
423
|
+
`consume` is the tool of a project that wires without di. Instead of subscribing a plain
|
|
424
|
+
callback, which handles a redelivered event a second time and issues a second invoice:
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
// Reported by events/consumer-idempotent in application/ and in the module file:
|
|
428
|
+
// no consumer name, no processed-event store, and a payload neither typed nor validated.
|
|
429
|
+
subscriber.subscribe(orderPlaced.type, async (event) => {
|
|
430
|
+
const { orderId, total } = event.payload as OrderPlaced;
|
|
431
|
+
await commandBus.execute(new IssueInvoice(orderId, total));
|
|
432
|
+
});
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
give the same semantics as the declared handler to a plain function:
|
|
237
436
|
|
|
238
437
|
```ts
|
|
239
438
|
// contexts/billing/billing.subscriptions.ts
|
|
@@ -260,6 +459,12 @@ back, because its mark was rolled back with the rest. The command bus middleware
|
|
|
260
459
|
unit of work (a nested `run` joins the outer transaction), so the handler needs nothing
|
|
261
460
|
special.
|
|
262
461
|
|
|
462
|
+

|
|
463
|
+
|
|
464
|
+
The consumer name is the idempotency scope: two consumers of the same event each handle it
|
|
465
|
+
once, and renaming a consumer makes it handle again every event it is redelivered. Write it
|
|
466
|
+
once, as `<context>.<reaction>`, and never derive it.
|
|
467
|
+
|
|
263
468
|
`on(definition, handler, { consumer, processed, unitOfWork })` is the same thing with
|
|
264
469
|
optional parts, for a consumer that needs no deduplication or has no transaction. Mind one
|
|
265
470
|
case: with `processed` and no `unitOfWork`, the mark is taken before the handler runs. If the
|
|
@@ -286,6 +491,7 @@ consume(orderPlaced, { consumer: 'billing.invoice-on-order-placed', processed, u
|
|
|
286
491
|
| `correlationId` | the `correlationId` of the event, or its `id` when it names none |
|
|
287
492
|
| `causationId` | the `id` of the event |
|
|
288
493
|
| `actor` | `systemActor(consumer)`, unless `actorOf(event)` answers one |
|
|
494
|
+
| `tenantId` | the `tenantId` of the event, when it names one: the consumer reads and writes in that tenant only |
|
|
289
495
|
|
|
290
496
|
So the repositories of the consuming context stamp audit columns with
|
|
291
497
|
`system:billing.invoice-on-order-placed`, and a log line of the consumer carries the correlation
|
|
@@ -293,28 +499,29 @@ of the request that started it all. The envelope carries no actor by default, on
|
|
|
293
499
|
actor is personal data, and a broker keeps messages. A project that decides to put a reference
|
|
294
500
|
in its own payload or envelope maps it with `actorOf: (event) => Actor | undefined`.
|
|
295
501
|
|
|
296
|
-
On the publishing side, `scopedPublisher` of the kernel fills the correlation in,
|
|
297
|
-
never pass
|
|
502
|
+
On the publishing side, `scopedPublisher` of the kernel fills the correlation in, and the
|
|
503
|
+
tenant of the work in progress, so use cases never pass them by hand. It is the outer wrapper
|
|
504
|
+
of the publisher given to `kernelModule` in `main.ts` above:
|
|
298
505
|
|
|
299
506
|
```ts
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
builder
|
|
303
|
-
.rebind(IntegrationEventPublisher) // what handlers publish to
|
|
304
|
-
.toFactory((resolve) => scopedPublisher(new OutboxPublisher(resolve(OutboxStore)), resolve(ExecutionScope)));
|
|
507
|
+
eventPublisher: (resolve) => scopedPublisher(new OutboxPublisher(resolve(OutboxStore)), resolve(ExecutionScope)),
|
|
305
508
|
```
|
|
306
509
|
|
|
307
|
-
An event created with a `correlationId` keeps it; one published outside of any
|
|
308
|
-
context is left as it is. It is a decorator, not an option of `OutboxPublisher` or of
|
|
510
|
+
An event created with a `correlationId` or a `tenantId` keeps it; one published outside of any
|
|
511
|
+
execution context is left as it is. It is a decorator, not an option of `OutboxPublisher` or of
|
|
309
512
|
`defineIntegrationEvent(...).create`: both stay pure, and the stamp applies to every provider.
|
|
310
513
|
|
|
311
514
|
## Generate the AsyncAPI document and the catalog
|
|
312
515
|
|
|
516
|
+
<!-- checked sample -->
|
|
313
517
|
```ts
|
|
314
518
|
// scripts/generate-contracts.ts
|
|
315
|
-
import {
|
|
519
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
520
|
+
import { generateEventCatalog, type EventRegistry } from '@ddd-ts-kit/events';
|
|
316
521
|
import { checkAsyncApiDrift, generateAsyncApi, stringifyAsyncApi } from '@ddd-ts-kit/events/asyncapi';
|
|
317
522
|
|
|
523
|
+
declare const registry: EventRegistry; // createEventRegistry([...every published declaration])
|
|
524
|
+
|
|
318
525
|
const document = generateAsyncApi({
|
|
319
526
|
info: { title: 'Shop events', version: '1.4.0' },
|
|
320
527
|
registry,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ddd-ts-kit/events",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Technology-agnostic integration events: typed declarations, registry, transactional outbox, AsyncAPI and catalog generation.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"dist"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@ddd-ts-kit/kernel": "0.16.
|
|
34
|
-
"@ddd-ts-kit/validation": "0.16.
|
|
33
|
+
"@ddd-ts-kit/kernel": "0.16.1",
|
|
34
|
+
"@ddd-ts-kit/validation": "0.16.1"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"zod": "^4.6.5"
|