@ddd-ts-kit/rabbitmq 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.
Files changed (3) hide show
  1. package/README.fr.md +384 -0
  2. package/README.md +102 -12
  3. package/package.json +4 -4
package/README.fr.md ADDED
@@ -0,0 +1,384 @@
1
+ [English](README.md) · **Français**
2
+ <!-- translation-of: README.md sha256:e7ab91d746cae3cf -->
3
+
4
+ # @ddd-ts-kit/rabbitmq
5
+
6
+ Le fournisseur d'événements d'intégration pour RabbitMQ : un `EventBus` du noyau qui publie avec
7
+ des confirmations de publication (publisher confirms), pour que le relais de l'outbox puisse
8
+ marquer un enregistrement publié sans risque, et qui consomme depuis une file d'attente durable
9
+ par consommateur et par type d'événement, avec acquittement manuel, un nombre borné de nouvelles
10
+ tentatives différées et une file de rejet (dead letter). C'est la ligne « brokers à files
11
+ d'attente » du tableau des stratégies de `@ddd-ts-kit/events`, implémentée, et il passe
12
+ `eventBusContract` de `@ddd-ts-kit/testing`.
13
+
14
+ ```
15
+ npm i @ddd-ts-kit/rabbitmq @ddd-ts-kit/events amqplib
16
+ ```
17
+
18
+ Testé avec amqplib 2.2 contre RabbitMQ 4.3. Tout *broker* AMQP 0-9-1 doté de confirmations de
19
+ publication devrait fonctionner avec `queueType: 'classic'` ; seul RabbitMQ a été exécuté.
20
+
21
+ ## Racine de composition
22
+
23
+ Les gestionnaires ne voient jamais ce *package*. Ils publient dans l'outbox ; le relais publie
24
+ dans RabbitMQ ; chaque contexte consommateur écoute par le bus, sous son propre nom. Le bus est
25
+ donné à l'application sous la forme de deux ports du noyau : `IntegrationEventPublisher` reste
26
+ l'outbox, et `EventSubscribers` remet à chaque contexte l'abonné de son propre nom de
27
+ consommateur.
28
+
29
+ ![Le gestionnaire de ordering écrit la commande client et un enregistrement d'outbox dans une seule transaction ; le relais de l'outbox donne l'enregistrement à RabbitMqEventBus, qui le publie sur l'exchange topic integration-events avec le type d'événement comme clé de routage et attend la confirmation du broker ; l'exchange route une copie vers la file de travail de chaque consommateur lié pour ce type, billing.ordering.order.placed et shipping.ordering.order.placed ; chaque file la remet aux gestionnaires de son contexte, acquittée après la validation de leur unité de travail](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/rabbitmq-outbox-to-queues.fr.svg)
30
+
31
+ `init --broker rabbitmq` écrit ce câblage dans la plateforme du projet : le bus est construit par
32
+ `platform/events/create-event-provider.ts`, `platform/platform.module.ts` lie à nouveau
33
+ (`rebind`) `EventSubscribers` sur lui, et `main.ts` le donne, tracé, au relais de l'outbox. À la
34
+ main, la même chose s'écrit :
35
+
36
+ <!-- checked sample -->
37
+ ```ts
38
+ // platform/messaging.module.ts
39
+ import { defineContextModule } from '@ddd-ts-kit/di';
40
+ import { EventSubscribers, IntegrationEventPublisher, type Logger, type Metrics } from '@ddd-ts-kit/kernel';
41
+ import { OutboxPublisher, OutboxStore } from '@ddd-ts-kit/events';
42
+ import { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
43
+
44
+ declare const config: { readonly amqpUrl: string };
45
+ declare const logger: Logger;
46
+ declare const metrics: Metrics;
47
+
48
+ export const bus = new RabbitMqEventBus({
49
+ connection: config.amqpUrl, // ou des options amqplib, ou une fabrique qui ouvre une nouvelle connexion
50
+ exchange: 'integration-events', // la valeur par défaut
51
+ retry: { maxAttempts: 5, delayMs: 5_000 }, // les valeurs par défaut
52
+ prefetch: 10, // la valeur par défaut
53
+ onFailure: (failure) => logger.warn('event bus failure', { ...failure }), // des codes seulement, jamais un message d'erreur
54
+ // Le libellé `outcome` du noyau vaut success ou failure : les quatre issues d'une livraison prennent un autre nom.
55
+ onDelivery: ({ queue, outcome }) => metrics.counter('event_deliveries_total').increment({ queue, status: outcome }),
56
+ });
57
+
58
+ export const messagingModule = defineContextModule({
59
+ name: 'messaging',
60
+ register(builder) {
61
+ // Côté publication : les gestionnaires écrivent dans l'outbox, dans la transaction de la commande.
62
+ builder.rebind(IntegrationEventPublisher).toClass(OutboxPublisher, [OutboxStore]);
63
+ // Côté consommation : un abonné par contexte consommateur, nommé d'après lui.
64
+ builder.rebind(EventSubscribers).toValue({ forContext: (name) => bus.forConsumer(name) });
65
+ },
66
+ });
67
+ ```
68
+
69
+ <!-- checked sample -->
70
+ ```ts
71
+ import { Command, CommandBus, systemActor, type Actor, type IntegrationEvent } from '@ddd-ts-kit/kernel';
72
+ import { IntegrationEventHandler } from '@ddd-ts-kit/kernel/declarations';
73
+ import { defineIntegrationEvent, type PayloadOf } from '@ddd-ts-kit/events';
74
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
75
+ import { z } from 'zod';
76
+
77
+ // contexts/ordering/published-language : le contrat qu'écoute billing.
78
+ export const orderPlaced = defineIntegrationEvent({
79
+ type: 'ordering.order.placed',
80
+ version: 1,
81
+ payload: zodSchema(z.object({ orderId: z.string() })),
82
+ });
83
+
84
+ // contexts/billing/application/commands/issue-invoice/issue-invoice.command.ts
85
+ export class IssueInvoice extends Command {
86
+ constructor(
87
+ readonly actor: Actor,
88
+ readonly orderId: string,
89
+ ) {
90
+ super();
91
+ }
92
+ }
93
+
94
+ // contexts/billing/application/integration/handlers/invoice-on-order-placed.integration-handler.ts
95
+ @IntegrationEventHandler(orderPlaced, { consumer: 'billing.invoice-on-order-placed' })
96
+ export class InvoiceOnOrderPlacedIntegrationHandler {
97
+ constructor(private readonly commands: CommandBus) {}
98
+
99
+ // Personne n'appelle : le gestionnaire agit comme un acteur système nommé d'après son consommateur.
100
+ readonly actor = systemActor('billing.invoice-on-order-placed');
101
+
102
+ async handle(event: IntegrationEvent<PayloadOf<typeof orderPlaced>>): Promise<void> {
103
+ await this.commands.execute(new IssueInvoice(this.actor, event.payload.orderId));
104
+ }
105
+ }
106
+ ```
107
+
108
+ <!-- checked sample -->
109
+ ```ts
110
+ // main.ts
111
+ import { contextModule, createApplication, kernelModule, type ContextModule, type DeclaredSource } from '@ddd-ts-kit/di';
112
+ import { Clock } from '@ddd-ts-kit/kernel';
113
+ import { OutboxRelay, OutboxStore } from '@ddd-ts-kit/events';
114
+ import type { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
115
+
116
+ // Dans un projet : import { bus, messagingModule } from '@/platform/messaging.module';
117
+ declare const bus: RabbitMqEventBus;
118
+ declare const messagingModule: ContextModule;
119
+ declare const persistenceModule: ContextModule;
120
+ declare const orderingDeclarations: DeclaredSource;
121
+ declare const billingDeclarations: DeclaredSource;
122
+
123
+ const app = createApplication({
124
+ modules: [
125
+ kernelModule(),
126
+ persistenceModule,
127
+ messagingModule,
128
+ contextModule('ordering', orderingDeclarations),
129
+ contextModule('billing', billingDeclarations),
130
+ ],
131
+ });
132
+
133
+ // La cible du relais est le bus. Un enregistrement est marqué publié quand le broker l'a confirmé.
134
+ const relay = new OutboxRelay({ store: app.resolve(OutboxStore), publisher: bus, clock: app.resolve(Clock) });
135
+
136
+ await app.start(); // abonne le gestionnaire de billing sur bus.forConsumer('billing')
137
+ await bus.assertTopology(); // échouer au démarrage, pas au premier message
138
+ relay.start({ intervalMs: 500 });
139
+
140
+ process.on('SIGTERM', async () => {
141
+ await relay.stop(); // le tour en cours se termine
142
+ await app.stop(); // les gestionnaires sont désabonnés
143
+ await bus.close(); // les consommateurs s'arrêtent, les gestionnaires en cours se terminent et acquittent
144
+ });
145
+ ```
146
+
147
+ - `persistenceModule` lie `OutboxStore`, et lie à nouveau `UnitOfWork` et `ProcessedEventStore`
148
+ à la base de données : voir le README de l'adaptateur de persistance. Un gestionnaire d'événement
149
+ d'intégration déclaré est abonné sur l'abonné de son propre contexte (`forContext` reçoit le
150
+ nom de son module), et puisque `UnitOfWork` et `ProcessedEventStore` sont liés, sa marque
151
+ d'idempotence et son travail sont validés dans une seule unité de travail : une relivraison est
152
+ dédupliquée, un échec ne laisse rien derrière lui.
153
+ - Sans outbox, le bus est le bus d'événements du module du noyau, et les gestionnaires publient
154
+ directement dans le *broker* :
155
+ `kernelModule({ eventBus: bus, eventSubscribers: () => ({ forContext: (name) => bus.forConsumer(name) }) })`.
156
+ - Sans conteneur, les mêmes objets se câblent à la main : `bus.forConsumer('billing')` est un
157
+ `IntegrationEventSubscriber`, à donner à `subscribeAll` et à `consume` de `@ddd-ts-kit/events`.
158
+ Un bus construit sans l'option `consumer` n'a pas de nom à donner à ses files, si bien que
159
+ `bus.subscribe(...)` lève `ConsumerNameRequiredError` : nommez plutôt le contexte consommateur.
160
+
161
+ <!-- checked sample -->
162
+ ```ts
163
+ import { consume, defineIntegrationEvent, subscribeAll } from '@ddd-ts-kit/events';
164
+ import type { ProcessedEventStore, UnitOfWork } from '@ddd-ts-kit/kernel';
165
+ import { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
166
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
167
+ import { z } from 'zod';
168
+
169
+ declare const processed: ProcessedEventStore; // le store de l'adaptateur de persistance
170
+ declare const unitOfWork: UnitOfWork; // la transaction de la base de données
171
+ declare function issueInvoice(orderId: string): Promise<void>;
172
+
173
+ const orderPlaced = defineIntegrationEvent({
174
+ type: 'ordering.order.placed',
175
+ version: 1,
176
+ payload: zodSchema(z.object({ orderId: z.string() })),
177
+ });
178
+
179
+ const bus = new RabbitMqEventBus({ connection: 'amqp://kit:kit@127.0.0.1:5672' });
180
+
181
+ // Au lieu de : bus.subscribe(orderPlaced.type, handler), qui lève ici ConsumerNameRequiredError.
182
+ // Écrivez : l'abonné d'un contexte consommateur, dont les files portent le nom.
183
+ export const billing = subscribeAll(bus.forConsumer('billing'), [
184
+ consume(orderPlaced, { consumer: 'billing.invoice-on-order-placed', processed, unitOfWork }, async (event) => {
185
+ await issueInvoice(event.payload.orderId);
186
+ }),
187
+ ]);
188
+
189
+ await bus.assertTopology(); // déclare billing.ordering.order.placed ainsi que ses .retry et .dead, puis consomme
190
+ ```
191
+
192
+ - `publish(events)` ne se résout que lorsque le *broker* a confirmé **chaque** message du lot, et
193
+ rejette sinon (`PUBLISH_NOT_CONFIRMED`, `BROKER_UNAVAILABLE`, `UNROUTABLE_EVENT`,
194
+ `EVENT_BUS_CLOSED`). Rien n'est mis en mémoire pour plus tard : l'outbox est la mémoire tampon.
195
+ - Un nom de consommateur est requis pour s'abonner : `bus.forConsumer('billing')` donne un
196
+ `IntegrationEventSubscriber` pour un contexte sur la connexion partagée (un monolithe modulaire
197
+ en a plusieurs), et l'option `consumer` nomme celui pour lequel travaille `bus.subscribe` (un
198
+ service qui est un seul contexte).
199
+ - Le traçage est posé autour des deux côtés depuis l'extérieur, comme avec tout fournisseur :
200
+ `tracedPublisher(bus)` comme cible du relais, et côté consommation l'instrumentation du module
201
+ du noyau (`kernelModule({ instrumentation: traceApplication() })`), qui donne à chaque
202
+ gestionnaire déclaré un *span* `process <consumer>` par tentative.
203
+ `tracedSubscriber(bus.forConsumer(name))` sert à une composition sans `kernelModule`. Le bus
204
+ transporte `traceparent` et `correlationId` intacts, dans l'enveloppe et dans les en-têtes du
205
+ message.
206
+ - La corrélation fait l'aller-retour. Publiez par `scopedPublisher(publisher, scope)` du noyau :
207
+ un gestionnaire déclaré (ou `consume(definition, { ..., scope }, handler)` à la main)
208
+ s'exécute dans un contexte d'exécution dont le `correlationId` est celui de l'appel qui a
209
+ publié, dont le `causationId` est l'événement, et une relivraison après un échec s'exécute
210
+ dans le même. Un opérateur le lit sur le message sans en ouvrir le corps, dans la propriété
211
+ `correlation_id` et l'en-tête `x-correlation-id`.
212
+ - Un contexte extrait dans son propre service garde son nom de consommateur et donc ses files :
213
+ rien ne change sur le *broker*.
214
+
215
+ ## Topologie
216
+
217
+ `bus.describeTopology()` renvoie les *exchanges*, les files et les liaisons sous forme de données,
218
+ pour une revue ou un document généré ; `bus.assertTopology()` les déclare, de façon idempotente.
219
+ Le bus les déclare aussi à chaque (re)connexion. Pour `forConsumer('billing')` abonné à
220
+ `ordering.order.placed` :
221
+
222
+ | Objet | Nom | Propriétés |
223
+ |---|---|---|
224
+ | *exchange* | `integration-events` | topic, durable |
225
+ | file de travail | `billing.ordering.order.placed` | durable, quorum ; liée à l'*exchange* avec la clé de routage `ordering.order.placed` ; renvoie ses rejets (dead letters) vers la file de rejet |
226
+ | file de nouvelles tentatives | `billing.ordering.order.placed.retry` | durable, quorum ; `x-message-ttl` = `retry.delayMs` ; renvoie ses rejets vers la file de travail, au moins une fois |
227
+ | file de rejet | `billing.ordering.order.placed.dead` | durable, quorum ; aucun consommateur |
228
+
229
+ Les nouvelles tentatives et les rejets passent par l'*exchange* par défaut du *broker* (clé de
230
+ routage = nom de la file) : il n'y a donc qu'un *exchange* à gérer, et aucune liaison en dehors
231
+ de celle ci-dessus. Une file de nouvelles tentatives quorum prend aussi
232
+ `x-dead-letter-strategy: at-least-once` et `x-overflow: reject-publish`, pour qu'une nouvelle
233
+ tentative expirée ne se perde pas sur le chemin du retour vers la file de travail.
234
+
235
+ Un message est l'enveloppe en JSON (`id`, `type`, `version`, `occurredAt`, `payload`,
236
+ `traceparent`, `correlationId`, `tenantId`), persistant, avec la clé de routage et la propriété
237
+ `type` réglées sur le type d'événement, `messageId` sur l'identifiant de l'événement,
238
+ `correlationId` sur l'identifiant de corrélation, et les en-têtes `traceparent`,
239
+ `x-correlation-id`, `x-tenant-id` et `x-event-version`. N'importe quel client AMQP peut le
240
+ consommer.
241
+
242
+ Deux consommateurs d'un même type ont chacun une file et reçoivent tous deux chaque événement.
243
+ Deux instances d'un même consommateur partagent sa file : chaque événement va à l'une d'elles.
244
+
245
+ ## Comportement des nouvelles tentatives et de la file de rejet
246
+
247
+ ![Les trois files du consommateur billing pour le type ordering.order.placed : la file de travail remet un message aux gestionnaires avec sa tentative n lue dans x-attempt ; quand un gestionnaire a levé une exception et que n est inférieur à retry.maxAttempts, le bus envoie une copie confirmée à la file de nouvelles tentatives avec x-attempt n plus 1, puis acquitte l'original ; la copie attend retry.delayMs, la durée de vie de la file de nouvelles tentatives, et le broker la renvoie en dead-letter vers la file de travail ; à n égal à retry.maxAttempts, et aussitôt pour un message empoisonné, la copie va à la place dans la file de rejet ; un consommateur qui plante sans cesse voit le broker lui-même envoyer le message dans la file de rejet, par la limite de livraisons d'une file quorum](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/rabbitmq-retry-and-dead-letter.fr.svg)
248
+
249
+ 1. Un message est acquitté après que chaque gestionnaire abonné à sa file s'est résolu. Un
250
+ `@IntegrationEventHandler` déclaré (ou `consume` de `@ddd-ts-kit/events` à la main) valide son
251
+ travail et sa marque d'idempotence dans une seule unité de travail : l'acquittement suit alors
252
+ cette validation, et une relivraison est dédupliquée.
253
+ 2. Quand un gestionnaire lève une exception, le bus envoie une copie confirmée du message à la
254
+ file de nouvelles tentatives, avec l'en-tête `x-attempt` incrémenté et `x-error-code`
255
+ renseigné, puis acquitte l'original. La copie y attend `retry.delayMs` et revient dans la file
256
+ de travail. Un message en échec n'est jamais remis en file sur-le-champ : pas de boucle folle.
257
+ 3. À la `retry.maxAttempts`-ième livraison en échec, la copie va à la place dans la file de
258
+ rejet, avec `x-dead-reason: max-attempts`.
259
+ 4. Un message dont le corps n'est pas une enveloppe (illisible, sans `id`, `type`, `version` ou
260
+ `occurredAt`, ou avec un `tenantId` que `isTenantId` du noyau refuse) est empoisonné (poison) :
261
+ il va directement dans la file de rejet avec `x-dead-reason: poison` et
262
+ `x-error-code: POISON_MESSAGE`, et aucun gestionnaire ne le voit.
263
+ 5. Seuls des codes voyagent : `x-error-code` est le `code` de l'erreur ou le nom de sa classe. Le
264
+ message d'une erreur n'est jamais journalisé, remonté ni joint, car il peut citer des données
265
+ du *payload*.
266
+
267
+ La livraison est au moins une fois et sans ordre. L'ordre « copier puis acquitter » signifie
268
+ qu'un plantage entre les deux livre le message deux fois, jamais zéro fois.
269
+
270
+ Sur une connexion perdue, le bus se reconnecte avec un recul exponentiel (`reconnect`, 200 ms
271
+ doublés jusqu'à 30 s), déclare à nouveau sa topologie et reprend ses consommateurs. Les
272
+ publications en cours rejettent, tout comme `publish` entre deux tentatives, si bien que le
273
+ relais de l'outbox garde les enregistrements et les retente. Les messages qui étaient en cours de
274
+ traitement sont relivrés par le *broker*.
275
+
276
+ **Secrets de connexion.** L'URL ou les options de `connection` sont détenues par la fonction qui ouvre la connexion, dans aucun champ. Un `RabbitMqEventBus` journalisé, étalé ou
277
+ sérialisé montre `RabbitMqEventBus [connection concealed]` et rien d'autre : ses champs ne sont pas énumérables,
278
+ et `toJSON` et `util.inspect` répondent ce nom. Les échecs qu'il signale sont des codes. Ce que la
279
+ bibliothèque cliente montre elle-même quand l'un de ses propres objets est inspecté n'est pas
280
+ entre les mains de ce *package* : ne journalisez pas ces objets.
281
+
282
+ **Ce qu'on dit à un gestionnaire.** À côté de l'événement, chaque gestionnaire reçoit la
283
+ livraison (`EventDelivery` du noyau) : `{ attempt, maxAttempts, system: 'rabbitmq', destination, consumer }`,
284
+ où `destination` est la file de travail et `consumer` le nom du consommateur (`billing`). Les gestionnaires de l'application l'ignorent ; `@ddd-ts-kit/tracing` la lit
285
+ pour faire un *span* CONSUMER par tentative et pour marquer les relivraisons et la tentative après
286
+ laquelle l'événement est enterré. Le bus se nomme `messagingSystem: 'rabbitmq'`, et `traceparent` voyage dans
287
+ l'enveloppe et comme véritable en-tête, intact d'une nouvelle tentative à l'autre.
288
+
289
+ ## Exploitation
290
+
291
+ Ce qu'il faut surveiller :
292
+
293
+ | Signal | Où | Signification |
294
+ |---|---|---|
295
+ | `stats()` de l'outbox (`pending`, `failed`, `dead`) | le store de l'outbox | le côté publication : voir `@ddd-ts-kit/events` |
296
+ | profondeur de `<consumer>.<type>` | *broker* | le consommateur prend du retard ou est arrêté |
297
+ | profondeur de `<consumer>.<type>.dead` | *broker* | doit être 0 : alertez sur toute autre valeur |
298
+ | profondeur de `<consumer>.<type>.retry` | *broker* | des échecs en cours ; un plateau signale un gestionnaire qui échoue sans cesse |
299
+ | `onDelivery` compté par `outcome` | ce bus | le taux de `retried`, `dead`, `poison` rapporté à `acknowledged` |
300
+ | `onFailure` avec `kind: 'connection'` | ce bus | les reconnexions, et les déclarations que le *broker* a refusées (`AMQP_406`) |
301
+ | `bus.deliveriesInFlight` | ce bus | les gestionnaires en cours d'exécution ; bloqué au-dessus de 0, c'est un gestionnaire qui ne rend pas la main |
302
+
303
+ Avec un outil de suivi des erreurs, `DeadLetterReports` du noyau transforme `onFailure` en
304
+ rapports des événements abandonnés, et de ceux-là seulement :
305
+ `onFailure: (failure) => reports.onFailure(failure)`, et `reports.observing(bus.forConsumer(name))`
306
+ comme abonné de chaque contexte, pour qu'un rapport porte ce que le gestionnaire a levé. `init`
307
+ le câble quand le projet en a un.
308
+
309
+ Rejouer les messages rejetés, une fois la cause corrigée :
310
+
311
+ <!-- checked sample -->
312
+ ```ts
313
+ import type { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
314
+
315
+ declare const bus: RabbitMqEventBus;
316
+
317
+ // Jusqu'à 100 messages de billing.ordering.order.placed.dead, renvoyés dans la file de travail avec x-attempt 1.
318
+ const replayed = await bus.replayDead({ consumer: 'billing', type: 'ordering.order.placed' }, 100);
319
+ console.log(`${String(replayed)} message(s) replayed`);
320
+ ```
321
+
322
+ Il déplace jusqu'à ce nombre de messages de la file de rejet vers la file de travail avec un
323
+ budget de tentatives neuf, chacun avec une copie confirmée avant l'acquittement. L'interface de
324
+ gestion ou une *shovel* peuvent aussi les déplacer ; dans ce cas, remettez à zéro ou supprimez
325
+ l'en-tête `x-attempt`, sinon le message meurt à nouveau à son premier échec. Regardez d'abord
326
+ `x-dead-reason` et `x-error-code` : rejouer un message empoisonné ne fait que le tuer à nouveau.
327
+
328
+ Pour arrêter un consommateur pour de bon, supprimez ses trois files : le désabonnement les laisse
329
+ liées, et la file de travail continue de se remplir.
330
+
331
+ ## Limites
332
+
333
+ - **Pas d'ordre.** Les nouvelles tentatives et les instances concurrentes réordonnent. Portez une
334
+ version dans le *payload*.
335
+ - **Un seul délai.** L'attente avant une nouvelle tentative est le TTL de la file de nouvelles
336
+ tentatives : le même pour chaque tentative, sans recul exponentiel.
337
+ - **Les arguments d'une file sont fixés à sa création.** Changer `retry.delayMs` ou `queueType`
338
+ pour une file existante est refusé par le *broker* (signalé comme `AMQP_406` pendant que le bus
339
+ continue d'essayer de se reconnecter) : supprimez la file, ou migrez sous un autre nom de
340
+ consommateur.
341
+ - **Une relivraison exécute à nouveau chaque gestionnaire de la file**, y compris ceux qui ont
342
+ réussi, quand plusieurs gestionnaires d'un même consommateur s'abonnent au même type. Chacun
343
+ doit être idempotent.
344
+ - **Un lot n'est pas atomique.** Quand `publish` rejette, certains événements du lot ont pu être
345
+ pris par le *broker*. Le relais publie un par un et retente, les consommateurs dédupliquent.
346
+ - **`onUnroutable: 'fail'`** rejette un événement pour lequel aucune file n'est liée ; derrière le
347
+ relais de l'outbox, un tel enregistrement est retenté, puis passe à l'état mort (*dead*). Ne
348
+ l'utilisez que lorsqu'un consommateur manquant est un incident.
349
+ - **Un consommateur qui plante en plein gestionnaire** récupère ses messages sans que `x-attempt`
350
+ soit incrémenté. Les files quorum bornent cela par leur limite de livraisons, au-delà de
351
+ laquelle le *broker* envoie le message dans la file de rejet ; les files classiques ne le
352
+ bornent pas. Ce chemin appartient au *broker* et n'est pas exercé par la suite de tests.
353
+ - **Les files classiques** renvoient les nouvelles tentatives expirées au plus une fois : un
354
+ plantage du *broker* à cet instant peut en perdre une. Les files quorum (le défaut) utilisent la
355
+ stratégie au moins une fois.
356
+ - **Une fabrique de connexion doit ouvrir une nouvelle connexion à chaque appel** ; le bus ferme
357
+ ce qu'il a ouvert. Une connexion partagée avec d'autre code ne peut pas être donnée.
358
+ - Les gros lots sont écrits sans attendre que le *socket* se vide : la mémoire grossit avec le
359
+ lot. Le relais publie enregistrement par enregistrement, ce qui l'évite.
360
+ - Les noms de files sont `<consumer>.<type>` plus un suffixe, dans les 255 octets d'AMQP.
361
+
362
+ ## Autres brokers
363
+
364
+ [`@ddd-ts-kit/kafka`](../kafka/README.fr.md), [`@ddd-ts-kit/nats`](../nats/README.fr.md) et
365
+ [`@ddd-ts-kit/redis`](../redis/README.fr.md) (Redis Streams) implémentent les deux mêmes ports et
366
+ passent la même suite de contrat. [docs/other-brokers.md](docs/other-brokers.fr.md) dit ce que
367
+ chaque fournisseur doit, fait correspondre les concepts de celui-ci à Kafka et à JetStream comme
368
+ ces *packages* le font, et esquisse SQS/SNS, qui n'est pas construit.
369
+
370
+ ## Tests
371
+
372
+ Les suites ont besoin d'un vrai *broker* et sont ignorées sans `DDD_TS_KIT_TEST_AMQP_URL` :
373
+
374
+ ```
375
+ docker run -d --rm --name ddd-ts-kit-test-rabbitmq -e RABBITMQ_DEFAULT_USER=kit -e RABBITMQ_DEFAULT_PASS=kit -p 127.0.0.1:5672:5672 rabbitmq:4-management-alpine
376
+ DDD_TS_KIT_TEST_AMQP_URL=amqp://kit:kit@127.0.0.1:5672 npx vitest run packages/rabbitmq
377
+ docker rm -f ddd-ts-kit-test-rabbitmq
378
+ ```
379
+
380
+ Elles exécutent la suite de contrat sur des files quorum et sur des files classiques, puis les
381
+ comportements de ce fournisseur, une suite par comportement (`publishing`, `consuming`,
382
+ `correlation`, `retries`, `dead-letters`, `topology`, `connection-loss` à travers un relais TCP
383
+ que le test coupe, `closing`), et toute la chaîne de l'outbox jusqu'à un consommateur idempotent.
384
+ Chaque test travaille sur un *exchange* et des files qui lui sont propres, et les supprime.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ **English** · [Français](README.fr.md)
2
+
1
3
  # @ddd-ts-kit/rabbitmq
2
4
 
3
5
  The integration event provider for RabbitMQ: an `EventBus` of the kernel that publishes with
@@ -20,25 +22,33 @@ each consuming context listens through the bus under its own name. The bus is gi
20
22
  application as two ports of the kernel: `IntegrationEventPublisher` stays the outbox, and
21
23
  `EventSubscribers` hands each context the subscriber of its own consumer name.
22
24
 
25
+ ![The handler of ordering writes the order and an outbox record in one transaction; the outbox relay gives the record to RabbitMqEventBus, which publishes it to the topic exchange integration-events with the event type as routing key and waits for the confirmation of the broker; the exchange routes a copy to the work queue of each consumer bound for that type, billing.ordering.order.placed and shipping.ordering.order.placed; each queue hands it to the handlers of its context, acknowledged after their unit of work committed](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/rabbitmq-outbox-to-queues.svg)
26
+
23
27
  `init --broker rabbitmq` writes this wiring in the platform of the project: the bus is built by
24
28
  `platform/events/create-event-provider.ts`, `platform/platform.module.ts` rebinds
25
29
  `EventSubscribers` on it, and `main.ts` gives it, traced, to the outbox relay. By hand, the
26
30
  same thing reads:
27
31
 
32
+ <!-- checked sample -->
28
33
  ```ts
29
34
  // platform/messaging.module.ts
30
35
  import { defineContextModule } from '@ddd-ts-kit/di';
31
- import { EventSubscribers, IntegrationEventPublisher } from '@ddd-ts-kit/kernel';
36
+ import { EventSubscribers, IntegrationEventPublisher, type Logger, type Metrics } from '@ddd-ts-kit/kernel';
32
37
  import { OutboxPublisher, OutboxStore } from '@ddd-ts-kit/events';
33
38
  import { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
34
39
 
40
+ declare const config: { readonly amqpUrl: string };
41
+ declare const logger: Logger;
42
+ declare const metrics: Metrics;
43
+
35
44
  export const bus = new RabbitMqEventBus({
36
45
  connection: config.amqpUrl, // or amqplib options, or a factory opening a new connection
37
46
  exchange: 'integration-events', // the default
38
47
  retry: { maxAttempts: 5, delayMs: 5_000 }, // the defaults
39
48
  prefetch: 10, // the default
40
49
  onFailure: (failure) => logger.warn('event bus failure', { ...failure }), // codes only, never an error message
41
- onDelivery: ({ queue, outcome }) => deliveries.add(1, { queue, outcome }),
50
+ // The label `outcome` of the kernel is success or failure: the four outcomes of a delivery take another name.
51
+ onDelivery: ({ queue, outcome }) => metrics.counter('event_deliveries_total').increment({ queue, status: outcome }),
42
52
  });
43
53
 
44
54
  export const messagingModule = defineContextModule({
@@ -52,11 +62,32 @@ export const messagingModule = defineContextModule({
52
62
  });
53
63
  ```
54
64
 
65
+ <!-- checked sample -->
55
66
  ```ts
56
- // contexts/billing/application/integration/handlers/invoice-on-order-placed.integration-handler.ts
57
- import { CommandBus, systemActor, type IntegrationEvent } from '@ddd-ts-kit/kernel';
67
+ import { Command, CommandBus, systemActor, type Actor, type IntegrationEvent } from '@ddd-ts-kit/kernel';
58
68
  import { IntegrationEventHandler } from '@ddd-ts-kit/kernel/declarations';
69
+ import { defineIntegrationEvent, type PayloadOf } from '@ddd-ts-kit/events';
70
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
71
+ import { z } from 'zod';
72
+
73
+ // contexts/ordering/published-language: the contract billing listens to.
74
+ export const orderPlaced = defineIntegrationEvent({
75
+ type: 'ordering.order.placed',
76
+ version: 1,
77
+ payload: zodSchema(z.object({ orderId: z.string() })),
78
+ });
59
79
 
80
+ // contexts/billing/application/commands/issue-invoice/issue-invoice.command.ts
81
+ export class IssueInvoice extends Command {
82
+ constructor(
83
+ readonly actor: Actor,
84
+ readonly orderId: string,
85
+ ) {
86
+ super();
87
+ }
88
+ }
89
+
90
+ // contexts/billing/application/integration/handlers/invoice-on-order-placed.integration-handler.ts
60
91
  @IntegrationEventHandler(orderPlaced, { consumer: 'billing.invoice-on-order-placed' })
61
92
  export class InvoiceOnOrderPlacedIntegrationHandler {
62
93
  constructor(private readonly commands: CommandBus) {}
@@ -64,18 +95,26 @@ export class InvoiceOnOrderPlacedIntegrationHandler {
64
95
  // Nobody is calling: the handler acts as a system actor named after its consumer.
65
96
  readonly actor = systemActor('billing.invoice-on-order-placed');
66
97
 
67
- async handle(event: IntegrationEvent<OrderPlacedPayload>): Promise<void> {
98
+ async handle(event: IntegrationEvent<PayloadOf<typeof orderPlaced>>): Promise<void> {
68
99
  await this.commands.execute(new IssueInvoice(this.actor, event.payload.orderId));
69
100
  }
70
101
  }
71
102
  ```
72
103
 
104
+ <!-- checked sample -->
73
105
  ```ts
74
106
  // main.ts
75
- import { contextModule, createApplication, kernelModule } from '@ddd-ts-kit/di';
107
+ import { contextModule, createApplication, kernelModule, type ContextModule, type DeclaredSource } from '@ddd-ts-kit/di';
76
108
  import { Clock } from '@ddd-ts-kit/kernel';
77
109
  import { OutboxRelay, OutboxStore } from '@ddd-ts-kit/events';
78
- import { bus, messagingModule } from '@/platform/messaging.module';
110
+ import type { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
111
+
112
+ // In a project: import { bus, messagingModule } from '@/platform/messaging.module';
113
+ declare const bus: RabbitMqEventBus;
114
+ declare const messagingModule: ContextModule;
115
+ declare const persistenceModule: ContextModule;
116
+ declare const orderingDeclarations: DeclaredSource;
117
+ declare const billingDeclarations: DeclaredSource;
79
118
 
80
119
  const app = createApplication({
81
120
  modules: [
@@ -112,6 +151,40 @@ process.on('SIGTERM', async () => {
112
151
  `kernelModule({ eventBus: bus, eventSubscribers: () => ({ forContext: (name) => bus.forConsumer(name) }) })`.
113
152
  - Without a container the same objects are wired by hand: `bus.forConsumer('billing')` is an
114
153
  `IntegrationEventSubscriber`, to give to `subscribeAll` and `consume` of `@ddd-ts-kit/events`.
154
+ A bus built without the `consumer` option has no name to give its queues, so
155
+ `bus.subscribe(...)` throws `ConsumerNameRequiredError`: name the consuming context instead.
156
+
157
+ <!-- checked sample -->
158
+ ```ts
159
+ import { consume, defineIntegrationEvent, subscribeAll } from '@ddd-ts-kit/events';
160
+ import type { ProcessedEventStore, UnitOfWork } from '@ddd-ts-kit/kernel';
161
+ import { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
162
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
163
+ import { z } from 'zod';
164
+
165
+ declare const processed: ProcessedEventStore; // the store of the persistence adapter
166
+ declare const unitOfWork: UnitOfWork; // the transaction of the database
167
+ declare function issueInvoice(orderId: string): Promise<void>;
168
+
169
+ const orderPlaced = defineIntegrationEvent({
170
+ type: 'ordering.order.placed',
171
+ version: 1,
172
+ payload: zodSchema(z.object({ orderId: z.string() })),
173
+ });
174
+
175
+ const bus = new RabbitMqEventBus({ connection: 'amqp://kit:kit@127.0.0.1:5672' });
176
+
177
+ // Instead of: bus.subscribe(orderPlaced.type, handler), which throws ConsumerNameRequiredError here.
178
+ // Write: the subscriber of one consuming context, whose name its queues carry.
179
+ export const billing = subscribeAll(bus.forConsumer('billing'), [
180
+ consume(orderPlaced, { consumer: 'billing.invoice-on-order-placed', processed, unitOfWork }, async (event) => {
181
+ await issueInvoice(event.payload.orderId);
182
+ }),
183
+ ]);
184
+
185
+ await bus.assertTopology(); // declares billing.ordering.order.placed and its .retry and .dead, then consumes
186
+ ```
187
+
115
188
  - `publish(events)` resolves only when the broker confirmed **every** message of the batch and
116
189
  rejects otherwise (`PUBLISH_NOT_CONFIRMED`, `BROKER_UNAVAILABLE`, `UNROUTABLE_EVENT`,
117
190
  `EVENT_BUS_CLOSED`). Nothing is buffered for later: the outbox is the buffer.
@@ -149,7 +222,9 @@ them on every (re)connection. For `forConsumer('billing')` subscribed to
149
222
  | dead queue | `billing.ordering.order.placed.dead` | durable, quorum; no consumer |
150
223
 
151
224
  Retries and dead letters travel through the default exchange of the broker (routing key =
152
- queue name), so there is one exchange to manage and no binding besides the one above.
225
+ queue name), so there is one exchange to manage and no binding besides the one above. A quorum
226
+ retry queue also takes `x-dead-letter-strategy: at-least-once` and `x-overflow: reject-publish`,
227
+ so that an expired retry is not lost on its way back to the work queue.
153
228
 
154
229
  A message is the envelope as JSON (`id`, `type`, `version`, `occurredAt`, `payload`,
155
230
  `traceparent`, `correlationId`, `tenantId`), persistent, with the routing key and the `type`
@@ -162,6 +237,8 @@ one consumer share its queue: each event goes to one of them.
162
237
 
163
238
  ## Retry and dead-letter behaviour
164
239
 
240
+ ![The three queues of the consumer billing for the type ordering.order.placed: the work queue hands a message to the handlers with its attempt n read from x-attempt; when a handler threw and n is below retry.maxAttempts, the bus sends a confirmed copy to the retry queue with x-attempt n plus 1, then acknowledges the original; the copy waits retry.delayMs, the time to live of the retry queue, and the broker dead-letters it back to the work queue; at n equal to retry.maxAttempts, and at once for a poison message, the copy goes to the dead queue instead; a consumer that keeps crashing is dead-lettered to the dead queue by the broker itself, by the delivery limit of a quorum queue](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/rabbitmq-retry-and-dead-letter.svg)
241
+
165
242
  1. A message is acknowledged after every handler subscribed to its queue resolved. A declared
166
243
  `@IntegrationEventHandler` (or `consume` of `@ddd-ts-kit/events` by hand) commits its work
167
244
  and its idempotency mark in one unit of work: the acknowledgement then follows that commit,
@@ -172,9 +249,10 @@ one consumer share its queue: each event goes to one of them.
172
249
  message is never requeued on the spot: no hot loop.
173
250
  3. At the `retry.maxAttempts`-th failed delivery the copy goes to the dead queue instead, with
174
251
  `x-dead-reason: max-attempts`.
175
- 4. A message whose body is not an envelope (unparseable, or without `id`, `type`, `version`,
176
- `occurredAt`) is poison: it goes straight to the dead queue with
177
- `x-dead-reason: poison` and `x-error-code: POISON_MESSAGE`, and no handler sees it.
252
+ 4. A message whose body is not an envelope (unparseable, without `id`, `type`, `version` or
253
+ `occurredAt`, or with a `tenantId` that `isTenantId` of the kernel refuses) is poison: it
254
+ goes straight to the dead queue with `x-dead-reason: poison` and
255
+ `x-error-code: POISON_MESSAGE`, and no handler sees it.
178
256
  5. Only codes travel: `x-error-code` is the `code` of the error or its class name. The message
179
257
  of an error is never logged, reported or attached, because it may quote payload data.
180
258
 
@@ -194,7 +272,7 @@ this package: do not log those.
194
272
 
195
273
  **What a handler is told.** Next to the event, each handler receives the delivery
196
274
  (`EventDelivery` of the kernel): `{ attempt, maxAttempts, system: 'rabbitmq', destination, consumer }`,
197
- where `destination` is the work queue. Handlers of the application ignore it; `@ddd-ts-kit/tracing` reads it
275
+ where `destination` is the work queue and `consumer` the consumer name (`billing`). Handlers of the application ignore it; `@ddd-ts-kit/tracing` reads it
198
276
  to make one CONSUMER span per attempt and to mark the redeliveries and the attempt after which
199
277
  the event is buried. The bus names itself `messagingSystem: 'rabbitmq'`, and `traceparent` travels in
200
278
  the envelope and as a real header, intact across a retry.
@@ -213,10 +291,22 @@ What to watch:
213
291
  | `onFailure` with `kind: 'connection'` | this bus | reconnections, and declarations the broker refused (`AMQP_406`) |
214
292
  | `bus.deliveriesInFlight` | this bus | handlers running now; stuck above 0 means a handler that hangs |
215
293
 
294
+ With an error tracker, `DeadLetterReports` of the kernel turns `onFailure` into reports of the
295
+ events given up, and of those only: `onFailure: (failure) => reports.onFailure(failure)`, and
296
+ `reports.observing(bus.forConsumer(name))` as the subscriber of each context, so that a report
297
+ carries what the handler threw. `init` wires it when the project has one.
298
+
216
299
  Replaying dead messages, once the cause is fixed:
217
300
 
301
+ <!-- checked sample -->
218
302
  ```ts
303
+ import type { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
304
+
305
+ declare const bus: RabbitMqEventBus;
306
+
307
+ // Up to 100 messages of billing.ordering.order.placed.dead, back to the work queue with x-attempt 1.
219
308
  const replayed = await bus.replayDead({ consumer: 'billing', type: 'ordering.order.placed' }, 100);
309
+ console.log(`${String(replayed)} message(s) replayed`);
220
310
  ```
221
311
 
222
312
  It moves up to that many messages from the dead queue back to the work queue with a fresh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddd-ts-kit/rabbitmq",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "RabbitMQ provider for integration events: confirmed publishing for the outbox relay, durable queue per consumer, manual acknowledgement, bounded retries and dead-lettering.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,14 +26,14 @@
26
26
  "dist"
27
27
  ],
28
28
  "dependencies": {
29
- "@ddd-ts-kit/kernel": "0.16.0",
30
- "@ddd-ts-kit/events": "0.16.0"
29
+ "@ddd-ts-kit/kernel": "0.16.1",
30
+ "@ddd-ts-kit/events": "0.16.1"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "amqplib": ">=0.10.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "amqplib": "latest",
37
- "@ddd-ts-kit/testing": "0.16.0"
37
+ "@ddd-ts-kit/testing": "0.16.1"
38
38
  }
39
39
  }