@ddd-ts-kit/redis 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 +1122 -0
  2. package/README.md +394 -48
  3. package/package.json +6 -6
package/README.fr.md ADDED
@@ -0,0 +1,1122 @@
1
+ [English](README.md) · **Français**
2
+ <!-- translation-of: README.md sha256:89da4a854822bfca -->
3
+
4
+ # @ddd-ts-kit/redis
5
+
6
+ Des adaptateurs de ports du noyau sur le protocole Redis : les ports `Cache`, `Lock` et
7
+ `RateLimiter`, le signal de réveil du relais de l'outbox, un store de *jobs* en arrière-plan, et
8
+ un fournisseur d'événements d'intégration sur Redis Streams.
9
+ Ils utilisent un sous-ensemble commun du protocole, si bien que le même code tourne sur Redis,
10
+ Dragonfly et Valkey : le choix du serveur relève de l'exploitation, et il se fait dans la racine
11
+ de composition, par une URL de connexion.
12
+
13
+ ```
14
+ npm i @ddd-ts-kit/redis ioredis
15
+ ```
16
+
17
+ | Point d'entrée | Contient | Importe |
18
+ |---|---|---|
19
+ | `@ddd-ts-kit/redis` | `connectRedis`, `closeRedis`, `FAIL_FAST`, `redisHealth`, `RedisCache`, `jsonCodec`, `RedisLock`, `RedisRateLimiter`, `RedisRelaySignal`, `relayWakeUpMiddleware`, `RedisJobStore`, `reportLostJobs` ; réexportés depuis le noyau : les ports `Lock` et `RateLimiter`, `InMemoryLock`, `InMemoryRateLimiter` | le noyau, `@ddd-ts-kit/scheduling`, `ioredis` (*peer*) |
20
+ | `@ddd-ts-kit/redis/event-bus` | `RedisStreamsEventBus`, ses options, ses types de topologie et d'erreur, `streamNamesOf`, `DEFAULT_PREFIX`, les codes `ATTEMPTS_EXHAUSTED` et `POISON_ENTRY`, les noms des champs d'une entrée | le noyau, `ioredis` (*peer*) |
21
+ | `@ddd-ts-kit/redis/testing` | `cacheContract`, `lockContract`, `rateLimiterContract`, réexportés depuis `@ddd-ts-kit/testing/contracts` | `@ddd-ts-kit/testing` (*peer* facultatif), le *runner* que vous passez |
22
+
23
+ ## Sa place dans une application
24
+
25
+ Rien ici n'est importé depuis `domain/` ou `application/`. Un cas d'usage dépend du port
26
+ (`Cache`, `Lock`, `RateLimiter` du noyau) ; l'adaptateur est choisi dans `platform/` et lié dans
27
+ la racine de composition.
28
+
29
+ ![Les six ports que le code nomme (Cache, Lock, RateLimiter, RelaySignal, JobStore, EventBus), les six adaptateurs de @ddd-ts-kit/redis qui les implémentent, et l'unique serveur auquel ils envoient leurs commandes ; la plateforme les lie, l'outbox et les événements traités restent dans la base de données](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/redis-place-in-an-application.fr.svg)
30
+
31
+ Le même cas d'usage tourne alors contre Redis en production et contre les adaptateurs en mémoire
32
+ du noyau dans ses tests. Au lieu de nommer l'adaptateur, ce qui lie le cas d'usage à un seul
33
+ serveur et que [`layer/application-inward`](../eslint-plugin/docs/rules/layer-application-inward.fr.md)
34
+ signale dans `application/`, nommez le port :
35
+
36
+ <!-- checked sample -->
37
+ ```ts
38
+ import { Cache, FixedClock, InMemoryCache, cacheKey, type CacheKey } from '@ddd-ts-kit/kernel';
39
+
40
+ interface Quote {
41
+ readonly pair: string;
42
+ readonly rate: number;
43
+ }
44
+
45
+ interface ExchangeRates {
46
+ quoteOf(pair: string): Promise<Quote>;
47
+ }
48
+
49
+ const quoteKey = (pair: string): CacheKey<Quote> => cacheKey<Quote>('quote', pair);
50
+
51
+ // Au lieu de `constructor(private readonly cache: RedisCache)` : le port du noyau.
52
+ export class QuoteReader {
53
+ constructor(
54
+ private readonly cache: Cache,
55
+ private readonly rates: ExchangeRates,
56
+ ) {}
57
+
58
+ async quoteOf(pair: string): Promise<Quote> {
59
+ const cached = await this.cache.get(quoteKey(pair));
60
+
61
+ if (cached !== undefined) {
62
+ return cached;
63
+ }
64
+
65
+ const quote = await this.rates.quoteOf(pair);
66
+ await this.cache.set(quoteKey(pair), quote, { ttlMs: 30_000 });
67
+
68
+ return quote;
69
+ }
70
+ }
71
+
72
+ // Dans un test, le cache en mémoire du noyau ; en production, RedisCache, lié dans platform/.
73
+ const clock = new FixedClock(new Date('2026-10-13T09:00:00Z'));
74
+ export const reader = new QuoteReader(new InMemoryCache(clock), { quoteOf: async (pair) => ({ pair, rate: 1.08 }) });
75
+ ```
76
+
77
+ Avec la CLI, `init --cache redis` écrit `platform/redis/open-redis.ts` (une connexion, le cache,
78
+ le verrou, le limiteur de débit, le signal du relais quand les événements passent par une outbox,
79
+ la sonde de disponibilité), `init --broker redis` écrit `platform/events/create-event-provider.ts`
80
+ autour de `RedisStreamsEventBus`, et `init --queue redis` (qui exige `--cache redis`) ajoute le
81
+ store de *jobs* à la même connexion. La suite présente chaque pièce, câblée à la main.
82
+
83
+ ## Connexion
84
+
85
+ <!-- checked sample -->
86
+ ```ts
87
+ // src/platform/redis/redis.ts
88
+ import { connectRedis, redisHealth } from '@ddd-ts-kit/redis';
89
+
90
+ declare const config: { readonly redisUrl: string };
91
+
92
+ export const redis = connectRedis(config.redisUrl); // ou des options ioredis, ou un client que vous avez déjà
93
+
94
+ export const ready = () => redisHealth(redis.client); // { status: 'up', latencyMs } | { status: 'down', reason }
95
+ export const shutdown = () => redis.close();
96
+ ```
97
+
98
+ - `connectRedis(url | options | client)` répond `{ client, close() }`. Une connexion qu'il a
99
+ ouverte est fermée par `close()` ; un client qu'on lui a remis est utilisé tel quel et laissé
100
+ ouvert, puisque c'est son propriétaire qui le ferme.
101
+ - Une connexion ouverte ici ajoute deux réglages aux valeurs par défaut d'ioredis (`FAIL_FAST`),
102
+ que vos options remplacent : `commandTimeout: 2000` et `maxRetriesPerRequest: 1`. Avec les
103
+ seules valeurs par défaut, une commande envoyée pendant que le serveur est absent attend vingt
104
+ reconnexions avant d'échouer, et chaque requête de l'application attend avec elle.
105
+ - `redisHealth(client, { timeoutMs? })` envoie un seul `PING`, ne lève jamais d'exception, et
106
+ répond dans le délai (1 s par défaut) : utilisez-le dans une sonde de disponibilité (readiness
107
+ probe). Un cache en panne est rarement une raison de cesser de recevoir du trafic ; un verrou ou
108
+ un limiteur de débit en panne peut l'être.
109
+ - Chaque adaptateur prend le client ioredis, si bien que plusieurs partagent une connexion. Le
110
+ signal du relais en ouvre une seconde, pour écouter, et le bus d'événements une par nom de
111
+ consommateur, pour bloquer sur ses lectures.
112
+
113
+ Développement local, avec Dragonfly :
114
+
115
+ ```yaml
116
+ # compose.yaml
117
+ services:
118
+ dragonfly:
119
+ image: docker.dragonflydb.io/dragonflydb/dragonfly:v2.0.2
120
+ ulimits:
121
+ memlock: -1
122
+ command: ['--maxmemory=512mb', '--proactor_threads=2']
123
+ ports:
124
+ - '127.0.0.1:6379:6379'
125
+ volumes:
126
+ - dragonfly:/data
127
+ volumes:
128
+ dragonfly:
129
+ ```
130
+
131
+ `REDIS_URL=redis://127.0.0.1:6379` convient alors aux trois serveurs.
132
+
133
+ **Identifiants de connexion.** Un client d'ioredis garde ses options, mot de passe compris, et les
134
+ montre à quiconque l'inspecte : cela ne peut pas être changé d'ici, alors ne journalisez jamais un
135
+ client. Les adaptateurs de ce *package* (`RedisCache`, `RedisLock`, `RedisRateLimiter`,
136
+ `RedisRelaySignal`, `RedisJobStore`, `RedisStreamsEventBus`) gardent leur client dans un champ non
137
+ énumérable, et répondent leur nom à `JSON.stringify` et à `util.inspect` : journaliser, étaler
138
+ (spread) ou sérialiser un adaptateur ne mène pas au client. L'objet que répond `connectRedis`
139
+ donne son `client` par son nom (`redis.client`, `const { client } = redis`) et le laisse hors d'un
140
+ étalement ou d'un journal.
141
+
142
+ <!-- checked sample -->
143
+ ```ts
144
+ import type { Logger } from '@ddd-ts-kit/kernel';
145
+ import { redisHealth, type RedisConnection } from '@ddd-ts-kit/redis';
146
+
147
+ declare const redis: RedisConnection;
148
+ declare const logger: Logger;
149
+
150
+ export async function logStartup(): Promise<void> {
151
+ // Au lieu de : logger.info('redis connected', { client: redis.client }), et le mot de passe avec.
152
+ // Écrivez ce dont un opérateur a besoin, qui ne contient aucun secret :
153
+ const health = await redisHealth(redis.client);
154
+ logger.info('redis connected', { status: health.status });
155
+ }
156
+ ```
157
+
158
+ ## Cache
159
+
160
+ `RedisCache` implémente le port `Cache` du noyau, avec sa `CacheKey` typée.
161
+
162
+ <!-- checked sample -->
163
+ ```ts
164
+ // src/main.ts
165
+ import { Cache, Lock, Metrics, RateLimiter } from '@ddd-ts-kit/kernel';
166
+ import { contextModule, createApplication, defineContextModule, kernelModule, type DeclaredSource } from '@ddd-ts-kit/di';
167
+ import { RedisCache, RedisLock, RedisRateLimiter, type RedisConnection } from '@ddd-ts-kit/redis';
168
+
169
+ declare const redis: RedisConnection; // connectRedis(config.redisUrl), plus haut
170
+ declare const catalogDeclarations: DeclaredSource;
171
+
172
+ const platformModule = defineContextModule({
173
+ name: 'platform',
174
+ register(builder) {
175
+ builder.rebind(Cache).toFactory((resolve) => new RedisCache(redis.client, { namespace: 'app', metrics: resolve(Metrics) }));
176
+ builder.rebind(Lock).toValue(new RedisLock(redis.client, { namespace: 'app:lock' }));
177
+ builder.rebind(RateLimiter).toValue(new RedisRateLimiter(redis.client, { namespace: 'app:rate' }));
178
+ },
179
+ });
180
+
181
+ export const app = createApplication({ modules: [kernelModule(), platformModule, contextModule('catalog', catalogDeclarations)] });
182
+ ```
183
+
184
+ Un port du noyau est une classe abstraite, et c'est la classe qui sert de clé à une liaison : les
185
+ trois adaptateurs `implements` le leur, et un cas d'usage qui nomme `Cache`, `Lock` ou
186
+ `RateLimiter` dans ses dépendances les reçoit. `kernelModule({ cache: (resolve) => new RedisCache(...), lock: ..., rateLimiter: ... })`
187
+ fait de même par ses options. `cached` de `@ddd-ts-kit/aspects` et tout ce qui résout `Cache`
188
+ l'utilisent alors sans le savoir. Un contexte qui veut garder ses clés à part construit sa propre
189
+ instance, avec son propre `namespace`. Donnez à chaque adaptateur un espace de noms à lui, comme
190
+ ci-dessus : sans cela, le verrou écrit sous `lock:` et le limiteur de débit sous `rate:`, et deux
191
+ applications qui partagent un serveur les partageraient aussi.
192
+
193
+ | Option | Défaut | Effet |
194
+ |---|---|---|
195
+ | `namespace` | aucun | chaque clé est `<namespace>:<name>` : un par contexte délimité, ou par application qui partage un serveur |
196
+ | `codec` | `jsonCodec` | comment une valeur devient du texte, et inversement. Avec JSON, une `Date` revient sous forme de chaîne et une instance de classe sous forme d'objet simple : mettez en cache des données, pas des objets |
197
+ | `onError` | `miss` | un serveur défaillant coûte un défaut de cache (miss) en lecture et une écriture abandonnée, jamais une exception. `throw` laisse passer l'échec |
198
+ | `metrics` | aucun | compte les échecs dans `cache_errors_total`, avec les étiquettes `operation` (`get`, `set`, `delete`) et `context` (l'espace de noms) |
199
+ | `lock` | aucun | un `Lock` que `getOrLoad` utilise entre processus |
200
+
201
+ - `set(key, value, { ttlMs })` stocke avec `PX`, en millisecondes. Sans `ttlMs`, l'entrée reste
202
+ jusqu'à ce qu'elle soit supprimée, ou évincée par le serveur ; `ttlMs: 0` ou moins la supprime.
203
+ - `deleteByPrefix('price:')` supprime chaque entrée de l'espace de noms dont le nom commence par
204
+ le préfixe, et répond combien. Il parcourt les clés avec `SCAN` (200 à la fois), jamais `KEYS`,
205
+ et lit le préfixe littéralement (`*`, `?`, `[` sont échappés). Il n'est pas atomique : une entrée
206
+ écrite pendant le parcours peut survivre. C'est un outil d'administration, pas un appel à faire
207
+ à chaque requête ; préférez des durées de vie courtes à l'invalidation.
208
+ - Une entrée qui ne peut pas être décodée est un défaut de cache, et elle est comptée.
209
+
210
+ Le type que porte une `CacheKey` est une promesse que le codec doit tenir. JSON ne la tient que
211
+ pour des données simples : mettez donc en cache la forme qu'a réellement la valeur après
212
+ l'aller-retour :
213
+
214
+ <!-- checked sample -->
215
+ ```ts
216
+ import { cacheKey, type Cache } from '@ddd-ts-kit/kernel';
217
+
218
+ declare const cache: Cache;
219
+
220
+ interface Slot {
221
+ readonly id: string;
222
+ readonly startsAt: Date;
223
+ }
224
+
225
+ // Au lieu de : la clé promet une Date, et le cache répond une chaîne.
226
+ export async function cacheSlot(slot: Slot): Promise<void> {
227
+ await cache.set(cacheKey<Slot>('slot', slot.id), slot, { ttlMs: 60_000 });
228
+ }
229
+
230
+ // Écrivez : des données simples, dans la forme qu'elles ont une fois relues.
231
+ interface SlotView {
232
+ readonly id: string;
233
+ readonly startsAt: string;
234
+ }
235
+
236
+ export async function cacheSlotView(slot: Slot): Promise<void> {
237
+ const view: SlotView = { id: slot.id, startsAt: slot.startsAt.toISOString() };
238
+
239
+ await cache.set(cacheKey<SlotView>('slot', slot.id), view, { ttlMs: 60_000 });
240
+ }
241
+ ```
242
+
243
+ Ou donnez un `codec` (`{ encode(value): string; decode(text): unknown }`) qui connaît vos types.
244
+
245
+ **Un cache ne doit pas faire tomber l'application.** Avec `onError: 'miss'`, la valeur par défaut,
246
+ un serveur absent, plus lent que le délai d'expiration des commandes ou qui répond des erreurs
247
+ fait tourner l'application comme si elle n'avait pas de cache. Surveillez `cache_errors_total` :
248
+ un cache qui a cessé de fonctionner en silence est un incident de latence qui n'attend que la
249
+ charge.
250
+
251
+ ### Protection contre la ruée (stampede)
252
+
253
+ Quand une entrée populaire expire, chaque requête qui la manque la recalcule au même moment.
254
+
255
+ - Dans un même processus, `cached` de `@ddd-ts-kit/aspects` partage déjà un seul appel
256
+ sous-jacent entre les appels identiques concurrents (single flight). Donnez-lui un `RedisCache`
257
+ et cette partie est réglée.
258
+ - Entre processus, utilisez `getOrLoad` avec un `lock` :
259
+
260
+ <!-- checked sample -->
261
+ ```ts
262
+ import { cacheKey, type CacheKey } from '@ddd-ts-kit/kernel';
263
+ import { RedisCache, RedisLock, type RedisConnection } from '@ddd-ts-kit/redis';
264
+
265
+ declare const redis: RedisConnection;
266
+
267
+ interface Quote {
268
+ readonly pair: string;
269
+ readonly rate: number;
270
+ }
271
+
272
+ declare const rates: { quoteOf(pair: string): Promise<Quote> };
273
+
274
+ const quoteKey = (pair: string): CacheKey<Quote> => cacheKey<Quote>('quote', pair);
275
+
276
+ const cache = new RedisCache(redis.client, {
277
+ namespace: 'catalog',
278
+ lock: new RedisLock(redis.client, { namespace: 'catalog:lock' }),
279
+ });
280
+
281
+ export function quoteOf(pair: string): Promise<Quote> {
282
+ return cache.getOrLoad(quoteKey(pair), () => rates.quoteOf(pair), { ttlMs: 30_000 });
283
+ }
284
+ ```
285
+
286
+ `getOrLoad` lit l'entrée ; sur un défaut de cache, les appelants de ce processus partagent un seul
287
+ calcul, et avec un verrou un seul processus calcule : les autres attendent sa valeur jusqu'à
288
+ `waitMs` (2 s par défaut), puis la calculent eux-mêmes. Le processus qui calcule tient le verrou
289
+ pendant `lockTtlMs` au plus (10 s par défaut) : au-delà, un autre peut commencer. Il n'échoue
290
+ jamais à cause du verrou ou du serveur : le pire cas est celui d'un cache sans protection. Un
291
+ calcul qui a échoué n'est pas mis en cache. Le
292
+ [guide du cache et de la coordination](../../docs/tools/cache-and-coordination.fr.md#ce-que-ce-kit-prend-en-charge-et-a-vérifié)
293
+ dessine ces étapes.
294
+
295
+ ## Verrou
296
+
297
+ `RedisLock` implémente le port `Lock` du noyau :
298
+
299
+ ```ts
300
+ abstract class Lock {
301
+ abstract acquire(key: string, options: { ttlMs: number; waitMs?: number }): Promise<LockHandle | undefined>;
302
+ }
303
+ interface LockHandle { readonly key: string; readonly fencingToken: number; release(): Promise<void> }
304
+ ```
305
+
306
+ <!-- checked sample -->
307
+ ```ts
308
+ import type { Lock } from '@ddd-ts-kit/kernel';
309
+
310
+ declare const lock: Lock;
311
+ declare function exportEverything(): Promise<void>;
312
+
313
+ export async function nightlyExport(): Promise<void> {
314
+ const handle = await lock.acquire('nightly-export', { ttlMs: 60_000 });
315
+
316
+ if (handle === undefined) {
317
+ return; // une autre instance s'en charge déjà
318
+ }
319
+
320
+ try {
321
+ await exportEverything();
322
+ } finally {
323
+ await handle.release();
324
+ }
325
+ }
326
+ ```
327
+
328
+ `RedisLock` est l'algorithme à instance unique de la documentation de Redis :
329
+ `SET key <random owner> NX PX ttl`, et une libération qui ne supprime la clé que si elle porte
330
+ encore ce propriétaire (comparer puis supprimer, compare-and-delete), si bien qu'un détenteur dont
331
+ le verrou a expiré ne peut pas libérer le verrou du suivant. `waitMs` interroge le serveur toutes
332
+ les `retryDelayMs` (25 ms par défaut). `InMemoryLock` du noyau est le même contrat dans un seul
333
+ processus, pour les tests et les instances uniques ; c'est à lui que `kernelModule()` de
334
+ `@ddd-ts-kit/di` lie `Lock` tant que la plateforme ne le lie pas à nouveau
335
+ (`builder.rebind(Lock)`, montré sous [Cache](#cache)).
336
+
337
+ **Ce qu'il garantit, et ce qu'il ne garantit pas.** Au plus un détenteur à la fois, à condition
338
+ que le serveur garde ses données et que le détenteur termine dans le délai `ttlMs`. Ce n'est
339
+ **pas un verrou de consensus**. Deux détenteurs sont possibles quand :
340
+
341
+ - le détenteur est mis en pause (ramasse-miettes, disque bloqué, conteneur suspendu) plus
342
+ longtemps que la durée de vie, et continue de travailler après que le verrou est passé à
343
+ quelqu'un d'autre ;
344
+ - le serveur bascule vers une réplique qui n'avait pas encore reçu la clé ;
345
+ - le serveur redémarre sans persistance, ou évince la clé sous la pression de la mémoire ;
346
+ - les horloges, côté serveur, font un saut.
347
+
348
+ Utilisez-le donc pour **éviter de faire un travail deux fois** (un *job* planifié sur plusieurs
349
+ instances, le recalcul d'une entrée de cache, un import) : des cas où une rare double exécution
350
+ coûte un peu de travail perdu, et rien d'autre. Ne l'utilisez **pas** là où deux détenteurs
351
+ briseraient un invariant. Empêcher une double réservation, un double paiement ou un numéro de
352
+ facture en double relève d'une contrainte de la base de données (un index unique, une contrainte
353
+ d'exclusion, la version optimiste de l'agrégat), qui tient quoi que croie un processus à propos
354
+ d'un verrou.
355
+
356
+ ```ts
357
+ // Au lieu de : un verrou comme garde d'un invariant.
358
+ const handle = await lock.acquire(`slot:${slotId}`, { ttlMs: 5_000 });
359
+ if (handle !== undefined && !(await bookings.existsForSlot(slotId))) {
360
+ await bookings.save(booking); // deux détenteurs (une pause, une bascule) arrivent tous deux ici : réservé deux fois
361
+ }
362
+
363
+ // Écrivez : la base de données refuse la seconde réservation, quoi que croie un processus.
364
+ // CREATE UNIQUE INDEX bookings_one_per_slot ON bookings (slot_id);
365
+ await bookings.save(booking); // la seconde insertion échoue sur l'index : le gestionnaire répond un refus
366
+ ```
367
+
368
+ Le **jeton de clôture** (fencing token) croît à chaque acquisition d'une clé (`INCR`, dans le même
369
+ script que le `SET`). Une ressource qui stocke le plus grand jeton qu'elle a vu peut refuser une
370
+ écriture venue d'un détenteur périmé. Il réduit l'écart, pour les ressources capables de comparer ;
371
+ son compteur vit dans le même serveur et repart de zéro si ce serveur perd ses données : ce n'est
372
+ donc pas une preuve non plus.
373
+
374
+ <!-- checked sample -->
375
+ ```ts
376
+ import type { Lock } from '@ddd-ts-kit/kernel';
377
+
378
+ declare const lock: Lock;
379
+ declare function buildReport(): Promise<string>;
380
+
381
+ /** En pratique, une colonne de la ligne écrite : ici, le plus grand jeton que le store a accepté. */
382
+ export class ReportStore {
383
+ private highestToken = 0;
384
+
385
+ private readonly reports: string[] = [];
386
+
387
+ write(report: string, fencingToken: number): void {
388
+ if (fencingToken < this.highestToken) {
389
+ throw new Error(`Stale holder: token ${String(fencingToken)} after ${String(this.highestToken)}`);
390
+ }
391
+
392
+ this.highestToken = fencingToken;
393
+ this.reports.push(report);
394
+ }
395
+ }
396
+
397
+ export async function nightlyReport(store: ReportStore): Promise<void> {
398
+ const handle = await lock.acquire('nightly-report', { ttlMs: 60_000 });
399
+
400
+ if (handle === undefined) {
401
+ return;
402
+ }
403
+
404
+ try {
405
+ store.write(await buildReport(), handle.fencingToken);
406
+ } finally {
407
+ await handle.release();
408
+ }
409
+ }
410
+ ```
411
+
412
+ Le [guide du cache et de la coordination](../../docs/tools/cache-and-coordination.fr.md#ce-que-le-verrou-garantit-et-ce-quil-ne-garantit-pas)
413
+ dessine deux détenteurs d'une même clé, et la façon dont le jeton les distingue.
414
+
415
+ Un serveur absent fait rejeter `acquire` : on ne peut pas savoir si quelqu'un détient la clé, et
416
+ c'est l'appelant qui décide.
417
+
418
+ ## Limiteur de débit
419
+
420
+ `RedisRateLimiter` implémente le port `RateLimiter` du noyau :
421
+
422
+ ```ts
423
+ abstract class RateLimiter {
424
+ abstract consume(key: string, limit: { limit: number; windowMs: number }): Promise<{ allowed: boolean; remaining: number; retryAfterMs: number }>;
425
+ }
426
+ ```
427
+
428
+ Une fenêtre glissante : à tout instant, au plus `limit` appels autorisés pendant les `windowMs`
429
+ qui le précèdent. Un appel refusé n'est pas compté. `RedisRateLimiter` garde un ensemble trié
430
+ (sorted set) par clé, un membre par appel autorisé, et décide dans un seul script, si bien que des
431
+ appels concurrents venus de plusieurs instances ne peuvent pas prendre tous deux la dernière place.
432
+
433
+ ![Une limite de trois appels par minute : les appels à 20, 30 et 45 secondes sont autorisés ; l'appel à 60 secondes voit trois appels dans sa fenêtre, il est refusé, non compté, et invité à réessayer dans 20 secondes ; l'appel à 82 secondes en voit deux et il est autorisé](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/redis-rate-limiter-sliding-window.fr.svg)
434
+
435
+ `remaining` compte les places qui restent une fois cet appel compté ; `retryAfterMs` vaut 0 pour
436
+ un appel autorisé et, pour un appel refusé, le temps qui reste avant que l'appel le plus ancien de
437
+ la fenêtre en sorte.
438
+
439
+ Devant la couche HTTP, avant que l'authentification ne coûte quoi que ce soit (avec Fastify ; les
440
+ trois mêmes lignes conviennent à un *middleware* Express) :
441
+
442
+ <!-- checked sample -->
443
+ ```ts
444
+ // src/platform/http/rate-limit.ts
445
+ import Fastify from 'fastify';
446
+ import { RedisRateLimiter, type RedisConnection } from '@ddd-ts-kit/redis';
447
+
448
+ declare const redis: RedisConnection;
449
+
450
+ export const server = Fastify();
451
+ const limiter = new RedisRateLimiter(redis.client, { namespace: 'api', onError: 'allow' });
452
+
453
+ server.addHook('onRequest', async (request, reply) => {
454
+ const decision = await limiter.consume(`ip:${request.ip}`, { limit: 120, windowMs: 60_000 });
455
+ reply.header('RateLimit-Remaining', decision.remaining);
456
+
457
+ if (decision.allowed) {
458
+ return;
459
+ }
460
+
461
+ reply.header('Retry-After', Math.ceil(decision.retryAfterMs / 1000));
462
+ return reply.code(429).send({ type: 'about:blank', title: 'Too Many Requests', status: 429, code: 'RATE_LIMITED' });
463
+ });
464
+ ```
465
+
466
+ Un cas d'usage qui doit être limité pour une raison métier (trois réinitialisations de mot de
467
+ passe par heure) dépend du port `RateLimiter` du noyau et reçoit l'adaptateur par injection
468
+ (`builder.rebind(RateLimiter)`, montré sous [Cache](#cache)) ; `InMemoryRateLimiter` sur une
469
+ `FixedClock` est son *fake*. Le refus est une réponse métier, avec son code, pas un statut HTTP
470
+ écrit dans le cas d'usage :
471
+
472
+ <!-- checked sample -->
473
+ ```ts
474
+ import { Command, ConceptError, FixedClock, InMemoryRateLimiter, RateLimiter, type CommandHandler } from '@ddd-ts-kit/kernel';
475
+
476
+ export class RequestPasswordReset extends Command {
477
+ constructor(readonly accountId: string) {
478
+ super();
479
+ }
480
+ }
481
+
482
+ export class PasswordResetError extends ConceptError {
483
+ static tooMany(retryAfterMs: number) {
484
+ return PasswordResetError.refusal('PASSWORD_RESETS_EXHAUSTED', 'Three password resets per hour at most.', { retryAfterMs });
485
+ }
486
+ }
487
+
488
+ export class RequestPasswordResetHandler implements CommandHandler<RequestPasswordReset> {
489
+ constructor(private readonly limiter: RateLimiter) {}
490
+
491
+ async execute({ accountId }: RequestPasswordReset): Promise<void> {
492
+ const decision = await this.limiter.consume(`password-reset:${accountId}`, { limit: 3, windowMs: 3_600_000 });
493
+
494
+ if (!decision.allowed) {
495
+ throw PasswordResetError.tooMany(decision.retryAfterMs);
496
+ }
497
+
498
+ // envoyer le lien
499
+ }
500
+ }
501
+
502
+ // Dans un test : le limiteur en mémoire sur une horloge fixe, et le temps avance quand le test le dit.
503
+ const clock = new FixedClock(new Date('2026-10-13T09:00:00Z'));
504
+ export const handler = new RequestPasswordResetHandler(new InMemoryRateLimiter(clock));
505
+ ```
506
+
507
+ - **Exact, au prix de la mémoire.** Un membre par appel autorisé : une clé coûte de la mémoire en
508
+ proportion de sa limite. Cela convient jusqu'à des milliers d'appels par fenêtre ; pour des
509
+ dizaines de milliers, limitez une clé plus grossière ou une fenêtre plus courte.
510
+ - **L'horloge est celle de l'appelant.** Les instants viennent de la `Clock` donnée au limiteur
511
+ (son option `clock`, l'horloge système par défaut), pas du serveur, si bien que chaque instance
512
+ doit être synchronisée (NTP) : un décalage entre deux instances décale la fenêtre d'autant. En
513
+ échange, le limiteur se teste sur une horloge fixe, et le script ne contient aucune arithmétique
514
+ qui diffère d'une version de Lua à l'autre.
515
+ - **`onError`** : `throw` (par défaut) laisse l'appelant décider ; `allow` continue de servir sans
516
+ limite pendant que le serveur est absent (le choix habituel devant une API) ; `deny` refuse tout
517
+ (pour les rares limites qui sont un contrôle de sécurité, comme les tentatives de connexion).
518
+ - La clé expire d'elle-même une fenêtre après son dernier appel autorisé.
519
+
520
+ ## Signal du relais
521
+
522
+ Un relais d'outbox interroge sa table à intervalles réguliers (polling). `RedisRelaySignal` lui
523
+ permet d'interroger rarement et de publier quand même tout de suite, entre processus : le
524
+ processus qui a validé une commande notifie, et un relais, dans n'importe quel autre processus, se
525
+ réveille.
526
+
527
+ <!-- checked sample -->
528
+ ```ts
529
+ import { InMemoryCommandBus, SystemClock, unitOfWorkMiddleware, type IntegrationEventPublisher, type UnitOfWork } from '@ddd-ts-kit/kernel';
530
+ import { OutboxRelay, type OutboxStore } from '@ddd-ts-kit/events';
531
+ import { RedisRelaySignal, relayWakeUpMiddleware, type RedisConnection } from '@ddd-ts-kit/redis';
532
+
533
+ declare const redis: RedisConnection;
534
+ declare const store: OutboxStore;
535
+ declare const publisher: IntegrationEventPublisher;
536
+ declare const unitOfWork: UnitOfWork;
537
+
538
+ const signal = new RedisRelaySignal(redis.client, { channel: 'app:outbox' });
539
+
540
+ // Le worker qui relaie : il se réveille sur une notification, et interroge quand même toutes les 5 s.
541
+ export const relay = new OutboxRelay({ store, publisher, clock: new SystemClock(), wakeOn: signal });
542
+ relay.start({ intervalMs: 5_000 });
543
+
544
+ // Le processus de l'API. Au lieu de `.use(unitOfWorkMiddleware(unitOfWork)).use(relayWakeUpMiddleware(signal))`,
545
+ // qui notifie dans la transaction, avant que l'enregistrement soit visible, placez le réveil en premier :
546
+ // il est alors le middleware extérieur, et notifie une fois l'unité de travail validée.
547
+ export const commandBus = new InMemoryCommandBus().use(relayWakeUpMiddleware(signal)).use(unitOfWorkMiddleware(unitOfWork));
548
+ ```
549
+
550
+ Il satisfait structurellement le port `RelaySignal` de `@ddd-ts-kit/events` : ce *package* ne
551
+ dépend pas de `@ddd-ts-kit/events`, donc la classe ne nomme pas le port, et elle est acceptée
552
+ partout où un `RelaySignal` est attendu, `builder.bind(RelaySignal).toValue(signal)` compris. Elle
553
+ a la sémantique du signal en mémoire : **un indice, jamais une livraison**. La
554
+ publication-abonnement (publish/subscribe) ne garde rien pour un abonné absent ; une notification
555
+ envoyée pendant que le relais se reconnecte, ou avant qu'il se soit abonné, est perdue, et
556
+ l'événement part à l'interrogation suivante. Gardez un vrai `intervalMs`. `notify()` ne lève
557
+ jamais d'exception. `close()` ferme la connexion d'écoute que le signal a ouverte.
558
+
559
+ `relayWakeUpMiddleware` notifie après chaque commande qui a réussi, qu'elle ait publié ou non : un
560
+ réveil inutile coûte au relais une prise vide. Avec PostgreSQL, `LISTEN/NOTIFY` depuis la table de
561
+ l'outbox fait la même chose sans autre serveur.
562
+
563
+ ## Bus d'événements sur Redis Streams
564
+
565
+ `RedisStreamsEventBus` est un fournisseur d'événements d'intégration : un `EventBus` du noyau qui
566
+ publie avec `XADD` et ne se résout que lorsque le serveur a acquitté chaque entrée, pour que le
567
+ relais de l'outbox puisse marquer un enregistrement comme publié, et qui consomme par un groupe de
568
+ consommateurs (consumer group) par consommateur, avec une lecture bloquante sur une connexion
569
+ dédiée, un acquittement après le gestionnaire, un nombre borné de relivraisons différées et un
570
+ flux de rejet (dead stream). Il se range à côté des fournisseurs RabbitMQ, Kafka et NATS, passe
571
+ `eventBusContract` de `@ddd-ts-kit/testing`, et tourne sans changement sur Redis, Dragonfly et
572
+ Valkey.
573
+
574
+ ```
575
+ npm i @ddd-ts-kit/redis @ddd-ts-kit/events ioredis
576
+ ```
577
+
578
+ **Quand le choisir.** Vous faites déjà tourner et exploitez Redis, Dragonfly ou Valkey, le volume
579
+ est modéré (les gestionnaires d'un flux s'exécutent un par un dans chaque instance), et un serveur
580
+ de plus à exploiter vaut la peine d'être évité. Vous obtenez des groupes de consommateurs, la
581
+ relivraison, des files de rejet (dead letters) et un journal lisible, avec une seule dépendance.
582
+ Choisissez RabbitMQ quand vous voulez un *broker* qui maîtrise la livraison (confirmations de
583
+ publication écrites sur disque, files quorum répliquées avant la confirmation, routage message par
584
+ message), et Kafka quand vous voulez un journal durable et répliqué, avec des partitions, un ordre
585
+ par clé et une longue rétention. Ici, la durabilité est celle pour laquelle le serveur est
586
+ configuré, et la rétention est bornée par la mémoire : voir
587
+ [Garanties et limites](#garanties-et-limites).
588
+
589
+ ### Racine de composition
590
+
591
+ Les gestionnaires ne voient jamais ce *package*. Ils publient dans l'outbox ; le relais publie
592
+ dans les flux ; chaque contexte consommateur s'abonne par le bus, sous son propre nom.
593
+
594
+ ![Le relais de l'outbox prend les enregistrements en attente dans la table de l'outbox et les publie avec XADD dans un seul MULTI ; chaque type d'événement a un flux ; chaque nom de consommateur y a son propre groupe, notifications et billing, qui reçoivent tous deux chaque événement ; les instances de billing se partagent ses entrées ; une entrée en échec ou empoisonnée va dans le flux de rejet de billing](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/redis-streams-topology.fr.svg)
595
+
596
+ <!-- checked sample -->
597
+ ```ts
598
+ // main.ts
599
+ import { Command, SystemClock, type CommandBus, type Logger, type Metrics, type ProcessedEventStore, type UnitOfWork } from '@ddd-ts-kit/kernel';
600
+ import {
601
+ OutboxPublisher,
602
+ OutboxRelay,
603
+ consume,
604
+ subscribeAll,
605
+ validatingPublisher,
606
+ type EventRegistry,
607
+ type IntegrationEventDefinition,
608
+ type OutboxStore,
609
+ } from '@ddd-ts-kit/events';
610
+ import { RedisStreamsEventBus } from '@ddd-ts-kit/redis/event-bus';
611
+ import { tracedPublisher, tracedSubscriber } from '@ddd-ts-kit/tracing';
612
+
613
+ declare const config: { readonly redisUrl: string };
614
+ declare const logger: Logger;
615
+ declare const metrics: Metrics;
616
+ declare const outboxStore: OutboxStore;
617
+ declare const registry: EventRegistry;
618
+ declare const processed: ProcessedEventStore;
619
+ declare const unitOfWork: UnitOfWork;
620
+ declare const commandBus: CommandBus;
621
+ declare const orderPlaced: IntegrationEventDefinition<{ readonly orderId: string }>;
622
+ declare class IssueInvoice extends Command {
623
+ constructor(orderId: string);
624
+ }
625
+
626
+ const deliveries = metrics.counter('event_deliveries_total');
627
+
628
+ const bus = new RedisStreamsEventBus({
629
+ connection: config.redisUrl, // ou des options ioredis, ou un client que vous avez déjà (redis.client)
630
+ prefix: 'integration-events', // la valeur par défaut : un flux par type, `<prefix>:<type>`
631
+ retry: { maxAttempts: 5, delayMs: 5_000 }, // les valeurs par défaut
632
+ visibilityTimeoutMs: 60_000, // la valeur par défaut : au-dessus du pire cas d'un lot de gestionnaires
633
+ batchSize: 10, // la valeur par défaut
634
+ onFailure: (failure) => logger.warn('event bus failure', { ...failure }), // des codes seulement, jamais un message d'erreur
635
+ onDelivery: ({ stream, consumer, outcome }) => deliveries.increment({ stream, consumer, result: outcome }),
636
+ });
637
+
638
+ // Côté publication : les gestionnaires écrivent dans l'outbox, dans la transaction de la commande.
639
+ export const publisher = validatingPublisher(new OutboxPublisher(outboxStore), registry);
640
+
641
+ // La cible du relais est le bus. Un enregistrement est marqué publié quand le serveur a répondu l'identifiant de son entrée.
642
+ const relay = new OutboxRelay({ store: outboxStore, publisher: tracedPublisher(bus), clock: new SystemClock() });
643
+ relay.start({ intervalMs: 500 });
644
+
645
+ // Côté consommation : un abonné par contexte consommateur, qui porte son nom.
646
+ const billingEvents = tracedSubscriber(bus.forConsumer('billing'));
647
+ subscribeAll(billingEvents, [
648
+ consume(orderPlaced, { consumer: 'billing.ordering-order-placed', processed, unitOfWork }, async (event) => {
649
+ await commandBus.execute(new IssueInvoice(event.payload.orderId));
650
+ }),
651
+ ]);
652
+
653
+ await bus.assertTopology(); // échouer au démarrage, pas au premier événement
654
+
655
+ process.on('SIGTERM', async () => {
656
+ await relay.stop(); // le tour en cours se termine
657
+ await bus.close(); // les lecteurs s'arrêtent, le lot en cours se termine et acquitte
658
+ });
659
+ ```
660
+
661
+ - `publish(events)` envoie le lot comme un seul `MULTI` de `XADD`, et ne se résout que lorsque le
662
+ serveur a répondu un identifiant d'entrée pour **chaque** événement. Sinon, il rejette
663
+ (`PUBLISH_NOT_CONFIRMED` avec l'`eventId` et un code `reason`, `BROKER_UNAVAILABLE`,
664
+ `EVENT_BUS_CLOSED`). Rien n'est mis en tampon pour plus tard : l'outbox est le tampon.
665
+ - Un nom de consommateur est requis pour s'abonner : `bus.forConsumer('billing')` donne un
666
+ `IntegrationEventSubscriber` pour un contexte (un monolithe modulaire en a plusieurs, chacun avec
667
+ sa propre connexion de lecture), et l'option `consumer` nomme celui pour lequel travaille
668
+ `bus.subscribe` (un service qui est un seul contexte).
669
+ - Avec `@ddd-ts-kit/di`, les gestionnaires d'événements d'intégration déclarés d'un contexte sont
670
+ abonnés sur `EventSubscribers.forContext(<module name>)`. Donnez-lui le bus, et chaque contexte
671
+ lit sous son propre nom :
672
+
673
+ ```ts
674
+ import { EventSubscribers } from '@ddd-ts-kit/kernel';
675
+
676
+ const app = createApplication({
677
+ modules: [
678
+ kernelModule({ eventSubscribers: () => ({ forContext: (name) => bus.forConsumer(name) }) }),
679
+ contextModule('billing', billingDeclarations),
680
+ ],
681
+ });
682
+
683
+ // ou, depuis un module ultérieur
684
+ builder.rebind(EventSubscribers).toValue({ forContext: (name) => bus.forConsumer(name) });
685
+ ```
686
+ - Les *wrappers* de traçage fonctionnent comme avec tout fournisseur (avec `@ddd-ts-kit/di`, le
687
+ côté consommation est tracé par `kernelModule({ instrumentation: traceApplication() })` et
688
+ `tracedSubscriber` est laissé de côté) : le bus transporte `traceparent` et `correlationId`
689
+ intacts, dans l'enveloppe et comme champs de l'entrée, y compris à travers une nouvelle
690
+ tentative. À côté de l'événement, chaque gestionnaire reçoit la livraison (`EventDelivery` du
691
+ noyau) : `{ attempt, maxAttempts, system: 'redis', destination, consumer }`, où `destination`
692
+ est le flux. `@ddd-ts-kit/tracing` la lit pour faire un *span* CONSUMER par tentative ; le bus se
693
+ nomme `messagingSystem: 'redis'`.
694
+ - La corrélation fait l'aller-retour. Publiez par `scopedPublisher(publisher, scope)` du noyau et
695
+ consommez avec `consume(definition, { ..., scope }, handler)` : le consommateur s'exécute dans un
696
+ contexte d'exécution dont le `correlationId` est celui de la requête qui a publié, dont le
697
+ `causationId` est l'événement, et une relivraison s'exécute dans le même contexte. Ou donnez au
698
+ bus l'option `scope` : chaque gestionnaire s'exécute alors à l'intérieur, sous le nom de son
699
+ consommateur.
700
+ - Un contexte extrait dans son propre service garde son nom de consommateur, donc ses groupes :
701
+ rien ne change sur le serveur.
702
+ - La connexion de publication est celle que vous donnez, partagée avec les autres adaptateurs de
703
+ ce *package* si vous remettez `redis.client`. Chaque nom de consommateur lit sur un double de
704
+ cette connexion (`client.duplicate()`), parce qu'une lecture bloquante occupe sa connexion.
705
+ `close()` ferme ce que le bus a ouvert et laisse ouvert un client qu'on lui a remis.
706
+
707
+ | Option | Défaut | Effet |
708
+ |---|---|---|
709
+ | `connection` | requis | URL, options ioredis ou client. Une connexion ouverte ici prend `FAIL_FAST` et un délai de reconnexion tiré de `reconnect` |
710
+ | `prefix`, `streamOf` | `integration-events` | le flux d'un type est `<prefix>:<type>`, ou ce que répond `streamOf(type)` |
711
+ | `consumer` | aucun | le consommateur pour lequel lit `bus.subscribe` |
712
+ | `instance` | hôte, identifiant de processus, suffixe aléatoire | le nom de ce processus dans ses groupes ; unique par processus |
713
+ | `batchSize` | 10 | entrées lues à la fois par flux (`COUNT`), puis traitées dans l'ordre, une par une |
714
+ | `blockMs` | 1000 | combien de temps une lecture attend (`BLOCK`) : cela borne `close()` et la précision du délai avant une nouvelle tentative |
715
+ | `retry` | 5 tentatives, 5000 ms | les livraisons avant le flux de rejet, et l'attente après un échec |
716
+ | `visibilityTimeoutMs` | 60000 | temps d'inactivité après lequel les entrées en attente d'une autre instance sont reprises ; jamais moins que `retry.delayMs` |
717
+ | `startFrom` | `new` | où commence un groupe que ce bus crée : ce qui est publié à partir de ce moment, ou `beginning` pour tout le flux |
718
+ | `retention` | aucune | `{ maxLength }` ou `{ maxAgeMs }` : élagage appliqué par chaque publication. Voir les limites |
719
+ | `deduplication` | aucune | `{ windowMs }` : la garde côté publication décrite plus bas |
720
+ | `scope` | aucune | une `ExecutionScope` dans laquelle s'exécute chaque gestionnaire |
721
+ | `reconnect` | 200 ms, doublé jusqu'à 30 s | délai entre les reconnexions, et entre les tentatives d'un lecteur qui échoue |
722
+
723
+ ### Topologie
724
+
725
+ `bus.describeTopology()` renvoie les flux et les groupes sous forme de données, pour une revue ou
726
+ un document généré ; `bus.assertTopology()` les crée, de façon idempotente. Le bus crée aussi ses
727
+ groupes avant de lire ou de publier, et de nouveau après tout échec d'un lecteur (un serveur
728
+ revenu sans ses données les a perdus). Pour `forConsumer('billing')` abonné à
729
+ `ordering.order.placed` :
730
+
731
+ | Objet | Nom | Propriétés |
732
+ |---|---|---|
733
+ | flux d'événements | `integration-events:ordering.order.placed` | un par type d'événement, partagé par tous les consommateurs ; créé vide par le premier groupe (`MKSTREAM`) ou par la première publication |
734
+ | groupe de consommateurs | `billing`, sur ce flux | un par nom de consommateur ; détient sa position et ses entrées en attente ; créé à `$` (ou à `0` avec `startFrom: 'beginning'`) |
735
+ | membre du groupe | le nom `instance` | un par processus ; retiré à `close()` quand il ne laisse rien en attente |
736
+ | flux de rejet | `integration-events:ordering.order.placed:billing:dead` | un par consommateur et par type, créé par la première entrée rejetée ; aucun groupe ne le lit |
737
+
738
+ Une entrée contient l'enveloppe en JSON dans le champ `envelope` (`id`, `type`, `version`,
739
+ `occurredAt`, `payload`, `traceparent`, `correlationId`, `tenantId`) et, à côté, les champs `id`,
740
+ `type`, `version`, `traceparent`, `correlation-id` et `tenant-id` : un opérateur les lit avec
741
+ `XRANGE` sans analyser le corps, et n'importe quel client du protocole peut consommer le flux.
742
+
743
+ Deux consommateurs d'un même type ont chacun un groupe, et reçoivent tous deux chaque événement.
744
+ Deux instances d'un même consommateur sont deux membres de son groupe : chaque événement va à
745
+ l'une d'elles.
746
+
747
+ **Ordre.** Une instance reçoit les événements d'un type dans l'ordre du flux, qui est l'ordre dans
748
+ lequel le serveur les a reçus. Cet ordre est rompu par une relivraison (l'événement en échec
749
+ revient après que des événements ultérieurs ont été traités) et par des instances concurrentes
750
+ (deux membres d'un groupe progressent indépendamment l'un de l'autre). Il n'y a pas d'ordre entre
751
+ types, ni par agrégat : portez une version dans le contenu de l'événement (payload) quand cela
752
+ compte.
753
+
754
+ ### Comportement des nouvelles tentatives et de la file de rejet
755
+
756
+ ![Une entrée livrée avec la tentative n : sans enveloppe, elle va dans le flux de rejet comme entrée empoisonnée ; au-delà de retry.maxAttempts, après que ses instances ont planté sur elle, dans le flux de rejet comme ATTEMPTS_EXHAUSTED ; sinon chaque gestionnaire de son type s'exécute ; s'ils se sont tous résolus, elle est acquittée ; si l'un a levé une exception à la dernière tentative, elle est copiée dans le flux de rejet et acquittée en un seul script ; sinon elle reste en attente et elle est reprise après retry.delayMs ; une autre instance reprend les entrées d'une instance plantée après visibilityTimeoutMs](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/redis-streams-retry-and-dead-letter.fr.svg)
757
+
758
+ 1. Une entrée est acquittée (`XACK`) après que chaque gestionnaire abonné à son type s'est résolu.
759
+ Utilisez `consume` de `@ddd-ts-kit/events` : l'acquittement suit alors la validation (commit)
760
+ de l'unité de travail, et une relivraison est dédupliquée.
761
+ 2. Quand un gestionnaire lève une exception, l'entrée n'est pas acquittée : elle reste dans la
762
+ liste des entrées en attente (pending entry list) du groupe, détenue par cette instance, et son
763
+ temps d'inactivité repart de zéro à l'échec. Une fois qu'elle est inactive depuis
764
+ `retry.delayMs`, l'instance la reprend (`XPENDING` avec `IDLE`, puis `XCLAIM`). Une entrée en
765
+ échec n'est jamais retraitée sur-le-champ : pas de boucle effrénée (hot loop).
766
+ 3. Le numéro de tentative est le compteur de livraisons que le serveur tient dans la liste des
767
+ entrées en attente : 1 à la première lecture, incrémenté à chaque reprise. À la
768
+ `retry.maxAttempts`-ième livraison en échec, l'entrée est copiée dans le flux de rejet avec
769
+ `dead-reason: max-attempts`, `error-code`, `attempt`, `consumer` et `entry-id` (son identifiant
770
+ dans le flux d'événements), et acquittée, en un seul script : jamais l'un sans l'autre.
771
+ 4. Les entrées en attente d'une instance qui est morte, ou bloquée, sont reprises par une autre
772
+ instance du consommateur une fois inactives depuis `visibilityTimeoutMs`. Cette livraison compte
773
+ comme une tentative. Une entrée qui a épuisé toutes ses tentatives de cette façon, sans qu'aucun
774
+ gestionnaire ait jamais répondu, va dans le flux de rejet avec `error-code: ATTEMPTS_EXHAUSTED`
775
+ sans être traitée de nouveau : un événement qui tue ses consommateurs ne les tue pas
776
+ indéfiniment.
777
+ 5. Une entrée qui ne contient pas d'enveloppe (pas de champ `envelope`, illisible, ou sans `id`,
778
+ `type`, `version`, `occurredAt`) est empoisonnée (poison) : elle va directement dans le flux de
779
+ rejet avec `dead-reason: poison` et `error-code: POISON_ENTRY`, et aucun gestionnaire ne la
780
+ voit.
781
+ 6. Seuls les codes voyagent : `error-code` est le `code` de l'erreur, ou le nom de sa classe. Le
782
+ message d'une erreur n'est jamais journalisé, remonté ni joint, parce qu'il peut citer des
783
+ données du contenu de l'événement.
784
+
785
+ La livraison se fait au moins une fois. Un acquittement perdu lors d'une déconnexion, une reprise
786
+ en concurrence avec un gestionnaire lent, une nouvelle tentative du relais : chacun livre un
787
+ événement deux fois, jamais zéro fois.
788
+
789
+ Quand la connexion est perdue, ioredis se reconnecte avec le délai de `reconnect` ; `publish`
790
+ rejette aussitôt entre deux tentatives (`BROKER_UNAVAILABLE`), si bien que le relais de l'outbox
791
+ garde les enregistrements et les retente, et les lecteurs reprennent là où en sont leurs groupes.
792
+ Les entrées qui étaient en cours de traitement restent en attente et sont reprises.
793
+
794
+ ### Publier deux fois
795
+
796
+ Redis Streams n'offre **aucune déduplication côté publication** : un `XADD` qui a réussi mais dont
797
+ la réponse a été perdue est ajouté de nouveau par la nouvelle tentative du relais, et les deux
798
+ entrées sont livrées. La réponse est la même que pour tout fournisseur : le consommateur
799
+ déduplique, avec un store d'événements traités dans la transaction de son gestionnaire
800
+ (`consume`).
801
+
802
+ Une garde peu coûteuse réduit la fenêtre, en option :
803
+
804
+ ```ts
805
+ new RedisStreamsEventBus({ connection, deduplication: { windowMs: 3_600_000 } });
806
+ ```
807
+
808
+ Chaque événement est alors ajouté par un script qui cherche d'abord la clé
809
+ `<stream>:<event id>:published`, n'ajoute l'entrée que si la clé est absente, et l'écrit avec la
810
+ fenêtre comme durée de vie. Une nouvelle tentative du relais dans la fenêtre n'ajoute rien et
811
+ reçoit un acquittement. C'est une garde, pas une garantie : la marque est une clé de plus, évincée
812
+ ou perdue avec le serveur comme n'importe quelle autre, et elle coûte une clé par événement pendant
813
+ toute la fenêtre. Les consommateurs dédupliquent toujours. Vérifié sur Dragonfly v2.0.2, Valkey
814
+ 9.1.2 et Redis 8.10.2. (Redis 8.10.2 annonce un ajout idempotent côté serveur dans
815
+ `XINFO STREAM` ; il n'est pas utilisé, les deux autres serveurs ne l'ont pas.)
816
+
817
+ ### Exploitation
818
+
819
+ Ce qu'il faut surveiller, avec les commandes de n'importe quel client (`<stream>` est
820
+ `<prefix>:<type>`) :
821
+
822
+ | Signal | Commande | Signification |
823
+ |---|---|---|
824
+ | `stats()` de l'outbox (`pending`, `failed`, `dead`) | le store de l'outbox | le côté publication : voir `@ddd-ts-kit/events` |
825
+ | retard d'un consommateur | `XINFO GROUPS <stream>` : `lag`, `last-delivered-id` | les entrées pas encore lues : le consommateur est en retard ou arrêté |
826
+ | entrées en attente | `XINFO GROUPS <stream>` : `pending` ; le détail avec `XPENDING <stream> <consumer> - + 10` (propriétaire, temps d'inactivité, livraisons) | en cours de traitement, ou en attente d'une relivraison ; un plateau signale un gestionnaire qui échoue sans cesse, un temps d'inactivité au-dessus du délai de visibilité signale que personne ne lit |
827
+ | profondeur du flux de rejet | `XLEN <stream>:<consumer>:dead` | doit valoir 0 : alertez sur toute autre valeur |
828
+ | ce qui est mort, et pourquoi | `XRANGE <stream>:<consumer>:dead - + COUNT 10` : `error-code`, `dead-reason`, `attempt` | le code à chercher dans les journaux du consommateur |
829
+ | membres d'un groupe | `XINFO CONSUMERS <stream> <consumer>` | les instances ; une instance qui a planté reste listée jusqu'à ce qu'on la retire |
830
+ | mémoire d'un flux | `XLEN <stream>`, `MEMORY USAGE <stream>` | un flux ne fait que grandir tant qu'il n'est pas élagué |
831
+ | `onDelivery` compté par `outcome` | ce bus | le taux de `retried`, `dead`, `poison` par rapport à `acknowledged` |
832
+ | `onFailure` avec `kind: 'connection'` | ce bus | les reconnexions (`CONNECTION_LOST`, `ECONNREFUSED`) et ce que le serveur a refusé à un lecteur (`REDIS_NOGROUP`) |
833
+ | `bus.deliveriesInFlight` | ce bus | les entrées lues et pas encore réglées ; bloqué au-dessus de 0, il signale un gestionnaire qui ne rend pas la main |
834
+
835
+ `onFailure` ne dit que des codes. Pour remonter un événement que le bus abandonne avec l'erreur que
836
+ son gestionnaire a réellement levée, comme le fait l'application d'exemple, remettez le *hook* à
837
+ `DeadLetterReports` du noyau et abonnez-vous à travers lui : une livraison qui échoue puis est
838
+ retentée n'est pas remontée, une entrée morte ou empoisonnée l'est.
839
+
840
+ <!-- checked sample -->
841
+ ```ts
842
+ import { DeadLetterReports, type ErrorReporter } from '@ddd-ts-kit/kernel';
843
+ import { RedisStreamsEventBus } from '@ddd-ts-kit/redis/event-bus';
844
+
845
+ declare const config: { readonly redisUrl: string };
846
+ declare const errors: ErrorReporter;
847
+
848
+ const deadLetters = new DeadLetterReports(errors);
849
+
850
+ export const bus = new RedisStreamsEventBus({
851
+ connection: config.redisUrl,
852
+ onFailure: (failure) => deadLetters.onFailure(failure),
853
+ });
854
+
855
+ // Retient ce que chaque gestionnaire a levé, jusqu'à ce que onFailure dise si l'événement a été abandonné.
856
+ export const billingEvents = deadLetters.observing(bus.forConsumer('billing'));
857
+ ```
858
+
859
+ Rejouer les entrées mortes, une fois la cause corrigée :
860
+
861
+ <!-- checked sample -->
862
+ ```ts
863
+ import { streamNamesOf, type RedisStreamsEventBus } from '@ddd-ts-kit/redis/event-bus';
864
+
865
+ declare const bus: RedisStreamsEventBus;
866
+
867
+ const billing = { consumer: 'billing', type: 'ordering.order.placed' };
868
+
869
+ // Les clés à lire d'abord, avec XLEN et XRANGE : le flux d'événements et le flux de rejet de billing.
870
+ export const { events, dead } = streamNamesOf(billing); // avec le préfixe par défaut ; passez `streamOf` sinon
871
+
872
+ // Jusqu'à 100 entrées renvoyées vers le flux d'événements, les plus anciennes d'abord, pour billing seulement.
873
+ export const replayed = await bus.replayDead(billing, 100);
874
+ ```
875
+
876
+ Il déplace jusqu'à ce nombre d'entrées, les plus anciennes d'abord, du flux de rejet vers le flux
877
+ d'événements, chacune en un seul script (ajoutée, puis supprimée du flux de rejet). La nouvelle
878
+ entrée a un budget de tentatives neuf et porte `replay-for: billing` : les autres consommateurs du
879
+ type l'acquittent sans la traiter. Un client à vous qui lit le flux doit ignorer une entrée dont
880
+ `replay-for` nomme quelqu'un d'autre. Regardez d'abord `dead-reason` et `error-code` : rejouer une
881
+ entrée empoisonnée ne fait que la tuer de nouveau.
882
+
883
+ Entretien :
884
+
885
+ - Un consommateur arrêté pour de bon : `XGROUP DESTROY <stream> <consumer>`, et supprimez son flux
886
+ de rejet. Se désabonner ne fait qu'arrêter la lecture ; le groupe garde sa position, et son
887
+ retard grandit.
888
+ - Une instance qui a planté reste membre de ses groupes. Ses entrées en attente sont reprises après
889
+ le délai de visibilité ; retirez son nom avec
890
+ `XGROUP DELCONSUMER <stream> <consumer> <instance>` une fois que `XINFO CONSUMERS` la montre avec
891
+ 0 entrée en attente (supprimer un membre abandonne ses entrées en attente).
892
+ - Élaguer à la main, quand aucune `retention` n'est configurée : `XTRIM <stream> MINID ~ <id>`
893
+ avec un identifiant que chaque groupe a dépassé (`last-delivered-id` de `XINFO GROUPS`) et rien
894
+ en attente en dessous.
895
+
896
+ ### Garanties et limites
897
+
898
+ - **La durabilité est celle du serveur, et c'est un réglage.** Une entrée est acquittée quand le
899
+ serveur la détient en mémoire. Ce qui survit à un plantage dépend de la persistance : avec Redis
900
+ ou Valkey et `appendonly yes`, `appendfsync everysec` (le réglage habituel) peut perdre environ
901
+ la dernière seconde d'entrées acquittées, et `always` aucune, au prix d'un coût sur chaque
902
+ écriture ; avec des *snapshots* seulement (RDB, et Dragonfly, qui persiste par *snapshots*),
903
+ tout ce qui suit le dernier *snapshot* est perdu ; sans persistance, un redémarrage repart à
904
+ vide. La réplication est asynchrone : une bascule (failover) peut perdre ce que la réplique
905
+ n'avait pas reçu. Pour un journal d'événements, cela veut dire qu'un événement que le relais a
906
+ marqué publié peut disparaître, et qu'aucun consommateur ne le verra jamais. Un *broker* qui
907
+ confirme après avoir écrit dans un journal répliqué n'a pas cette fenêtre. Décidez si vos
908
+ événements la tolèrent, réglez la persistance en conséquence, et ne purgez pas les
909
+ enregistrements publiés de l'outbox avant l'horizon de vos sauvegardes.
910
+ - **Les positions sont dans le même serveur.** Les groupes, les entrées en attente et les
911
+ compteurs de livraisons sont perdus ou ramenés en arrière avec les données. Un serveur restauré
912
+ depuis un *snapshot* plus ancien livre de nouveau ce qui a été traité depuis (les consommateurs
913
+ dédupliquent) ; un serveur qui a tout perdu voit ses groupes recréés à la fin de flux vides.
914
+ - **Aucune déduplication côté publication**, comme dit plus haut.
915
+ - **La rétention est bornée par la mémoire.** Un flux vit en mémoire, tout entier. Par défaut, rien
916
+ n'est élagué. `retention: { maxLength }` (`MAXLEN ~`) et `retention: { maxAgeMs }` (`MINID ~`,
917
+ selon l'horloge du processus qui publie) élaguent à chaque publication, approximativement, et **sans égard
918
+ pour les groupes** : une entrée qu'aucun consommateur n'a encore lue, ou qui attend sa
919
+ relivraison, est supprimée avec le reste et quitte en silence la liste des entrées en attente à
920
+ sa reprise suivante. Dimensionnez le plafond bien au-dessus du pire retard que vous acceptez, ou
921
+ élaguez à la main. Les flux de rejet ne sont jamais élagués.
922
+ - **L'éviction s'applique à tout.** Sous `allkeys-*`, un flux entier peut être évincé. Faites
923
+ tourner le bus d'événements sur un serveur en `noeviction` (une publication échoue alors quand la
924
+ mémoire est pleine, et le relais retente), pas sur celui qui vous sert de cache.
925
+ - **Un à la fois, par flux et par instance.** Le débit est celui de vos gestionnaires exécutés en
926
+ séquence ; montez en charge avec des instances, au prix de l'ordre. Les types sont traités en
927
+ parallèle à l'intérieur d'un lot, mais un tour se termine quand son flux le plus lent se termine.
928
+ - **Le délai de visibilité doit dépasser le pire cas d'un lot** (`batchSize` fois le gestionnaire
929
+ le plus lent) : au-delà, une autre instance reprend les entrées pendant que la première travaille
930
+ encore, et elles sont traitées deux fois. Avec des gestionnaires lents, baissez `batchSize`.
931
+ - **Un seul délai.** La même attente avant chaque nouvelle tentative, sans temporisation
932
+ exponentielle (exponential backoff), précise à `blockMs` près. Une entrée en échec dont
933
+ l'instance s'arrête est retentée par une autre instance après le délai de visibilité, pas après
934
+ le délai de nouvelle tentative.
935
+ - **Une relivraison exécute de nouveau chaque gestionnaire du type**, y compris ceux qui ont
936
+ réussi, quand plusieurs gestionnaires d'un même consommateur s'abonnent au même type. Chacun doit
937
+ être idempotent.
938
+ - **Un lot est une transaction sur un serveur** : toutes ses entrées sont ajoutées, ou la
939
+ publication rejette. Un rejet ne prouve pas que rien n'a été ajouté (la réponse a pu être
940
+ perdue) ; le relais retente et les consommateurs dédupliquent.
941
+ - **Un nouveau groupe commence à la fin du flux** par défaut : ce qui a été publié avant qu'un
942
+ consommateur s'abonne pour la première fois ne lui est pas livré. `startFrom: 'beginning'` lit
943
+ ce que le flux contient encore.
944
+ - **Un abonnement ajouté pendant que le consommateur lit** est lu à partir du tour suivant, jusqu'à
945
+ `blockMs` plus tard. Rien n'est manqué : son groupe existe avant que le bus publie.
946
+ - **Un serveur, ou un primaire avec des répliques.** Une lecture couvre les flux d'un consommateur
947
+ en une seule commande, et une entrée rejetée touche deux clés en un seul script : sur un Redis
948
+ Cluster, les clés doivent partager un *slot*, ce que donne un `prefix` entre accolades
949
+ (`{events}`). Pas testé sur un cluster.
950
+ - **Un processus qui a planté laisse son nom** dans ses groupes, et une marque de déduplication par
951
+ événement pour la durée de la fenêtre, quand la garde est active : voir l'entretien plus haut.
952
+
953
+ ## Ce qui n'est pas ici : un store d'événements traités
954
+
955
+ Il n'y a délibérément pas de `RedisProcessedEventStore`. L'idempotence d'un consommateur ne tient
956
+ que si « cet événement a été traité » est validé **avec** le changement d'état qu'il représente
957
+ (`events/consumer-idempotent`). Une marque dans Redis et un changement dans PostgreSQL sont deux
958
+ systèmes : un plantage entre les deux soit perd l'événement (marque gardée, changement annulé),
959
+ soit le fait traiter deux fois. Utilisez le store de votre adaptateur de base de données
960
+ (`KnexProcessedEventStore`, `DrizzleProcessedEventStore`), dans l'unité de travail du
961
+ consommateur.
962
+
963
+ Le même raisonnement s'applique à l'outbox elle-même, et à tout ce qui doit être atomique avec les
964
+ données métier : cela vit dans la base de données.
965
+
966
+ ## Compatibilité : les commandes utilisées
967
+
968
+ | Adaptateur | Commandes |
969
+ |---|---|
970
+ | `RedisCache` | `GET`, `SET` (avec `PX`), `DEL`, `SCAN` (avec `MATCH`, `COUNT`) |
971
+ | `RedisLock` | `EVAL` de deux scripts appelant `SET` (avec `NX`, `PX`), `INCR`, `GET`, `DEL` |
972
+ | `RedisRateLimiter` | `EVAL` d'un script appelant `ZREMRANGEBYSCORE`, `ZCARD`, `ZADD`, `PEXPIRE`, `ZRANGE` (avec `WITHSCORES`) |
973
+ | `RedisRelaySignal` | `PUBLISH`, `SUBSCRIBE` |
974
+ | `RedisStreamsEventBus`, publication | `MULTI`, `EXEC`, `XADD` (avec `MAXLEN ~` ou `MINID ~` sous `retention`) ; sous `deduplication`, `EVAL` d'un script appelant `GET`, `XADD`, `SET` (avec `PX`) |
975
+ | `RedisStreamsEventBus`, consommation | `XGROUP CREATE` (avec `MKSTREAM`), `XREADGROUP` (avec `GROUP`, `COUNT`, `BLOCK`, plusieurs flux), `XACK`, `XPENDING` (forme étendue, avec `IDLE` et avec un consommateur), `XCLAIM` (avec un temps d'inactivité minimal, et avec `JUSTID`), `XGROUP DELCONSUMER`, `EVAL` d'un script appelant `XADD`, `XACK` |
976
+ | `RedisStreamsEventBus`, `replayDead` | `XRANGE` (avec `COUNT`), `EVAL` d'un script appelant `XRANGE`, `XADD`, `XDEL` |
977
+ | `redisHealth`, `close` | `PING`, `QUIT` |
978
+ | ioredis lui-même, à la connexion | `HELLO 3` (ioredis 6 ; les réponses sont ramenées à leur forme classique), `INFO`, `CLIENT SETINFO`, et `AUTH`, `SELECT` quand l'URL le demande |
979
+
980
+ Les choix faits pour la portabilité :
981
+
982
+ - Les scripts sont envoyés avec `EVAL`, pas `EVALSHA` : aucun cache de scripts à préchauffer, ou à
983
+ perdre derrière un proxy ou après une bascule. Ils font quelques lignes chacun.
984
+ - Chaque clé qu'un script touche est déclarée dans `KEYS` : Dragonfly refuse un script qui atteint
985
+ une clé non déclarée (Valkey l'exécute), et un cluster a besoin des clés pour le routage. Les
986
+ deux clés du verrou partagent un *hash tag* (`{key}`).
987
+ - Le Lua se limite aux commandes et aux comparaisons. Pas d'arithmétique sur les scores, pas de
988
+ `redis.call('TIME')`, pas de `cjson`, pas de `redis.setresp` : Redis et Valkey embarquent Lua
989
+ 5.1, Dragonfly Lua 5.4, et ils diffèrent sur les nombres. Mesuré sur les deux serveurs
990
+ ci-dessus : un script qui renvoie `10/4` répond `2` sur Valkey et `2.5` sur Dragonfly, et
991
+ `tostring(10/2)` vaut `5` sur l'un et `5.0` sur l'autre.
992
+ - Pas de `KEYS`, pas de `UNLINK`, pas de notifications d'espace de clés (keyspace notifications),
993
+ pas de modules, pas de fonctions. Les flux ne servent qu'au bus d'événements.
994
+ - Les scripts du bus d'événements passent un nombre variable de champs avec `unpack(ARGV, n)` et
995
+ testent une réponse vide avec `#` : les deux se comportent de la même façon sous Lua 5.1 et 5.4,
996
+ ce qui a été vérifié sur les trois serveurs. Une entrée rejetée passe par un script, pas par un
997
+ `MULTI` : une transaction acquitterait l'entrée même si sa copie dans le flux de rejet avait
998
+ échoué.
999
+ - Les relivraisons utilisent `XPENDING` puis `XCLAIM`, pas `XAUTOCLAIM`. `XAUTOCLAIM` fonctionne
1000
+ sur les trois serveurs, mais ne répond aucun compteur de livraisons, et les nouvelles tentatives
1001
+ bornées en ont besoin.
1002
+ - La lecture bloquante passe par la commande nommée d'ioredis (`client.xreadgroup`), jamais par
1003
+ `client.call('XREADGROUP')` : sous RESP3, que parle ioredis 6, seules les commandes qu'il connaît
1004
+ voient leur réponse ramenée à la forme classique.
1005
+ - `deleteByPrefix` supprime une clé par `DEL`, puisqu'un cluster refuse un `DEL` dont les clés
1006
+ vivent dans des *slots* différents.
1007
+
1008
+ Ce qui diffère entre les serveurs, pour ce qui concerne le bus d'événements, et qui est absorbé :
1009
+
1010
+ - `XREADGROUP` sur plusieurs flux répond pour chaque flux sur Dragonfly, avec une liste vide pour
1011
+ ceux qui n'ont pas de nouvelle entrée ; Redis et Valkey les omettent.
1012
+ - Une commande sur un flux qui n'existe plus est refusée comme `NOGROUP` par Redis et Valkey ;
1013
+ Dragonfly répond `ERR no such key` à `XPENDING`. Dans les deux cas, le lecteur recrée ses
1014
+ groupes ; le code remonté est `REDIS_NOGROUP` ou `REDIS_ERR`.
1015
+ - Une lecture bloquante vide a duré son temps de `BLOCK` sur Dragonfly et Redis, et a été mesurée
1016
+ à environ 100 ms de plus sur Valkey.
1017
+
1018
+ Testé contre Dragonfly v2.0.2, Valkey 9.1.2 et Redis 8.10.2 avec ioredis 6 : même suite, mêmes
1019
+ résultats. La suite a besoin d'un serveur et elle est ignorée sans `DDD_TS_KIT_TEST_REDIS_URL` :
1020
+
1021
+ ```
1022
+ docker run -d --rm --name ddd-ts-kit-test-redis -p 127.0.0.1:6390:6379 docker.dragonflydb.io/dragonflydb/dragonfly:v2.0.2
1023
+ DDD_TS_KIT_TEST_REDIS_URL=redis://127.0.0.1:6390 npx vitest run packages/redis
1024
+ ```
1025
+
1026
+ Les *specs* écrivent sous un espace de noms aléatoire par test et ne vident jamais le serveur.
1027
+ Celles du bus d'événements exécutent la suite de contrat (avec ses valeurs par défaut, puis avec la
1028
+ garde de publication et des flux plafonnés), les comportements du fournisseur (publication
1029
+ acquittée coupée en plein vol, relivraison, passage en file de rejet, rejeu, entrée empoisonnée,
1030
+ consommateurs concurrents et distincts, reprise après le délai de visibilité, groupes perdus puis
1031
+ recréés, reconnexion à travers un relais TCP que le test coupe, fermeture propre) et toute la
1032
+ chaîne, de l'outbox jusqu'à un consommateur idempotent. Chaque test travaille sous un préfixe qui
1033
+ lui est propre, et supprime ses clés.
1034
+
1035
+ ## Store de *jobs* en arrière-plan
1036
+
1037
+ `RedisJobStore` est le `JobStore` de `@ddd-ts-kit/scheduling` sur Redis, Dragonfly ou Valkey,
1038
+ pour un projet sans base de données, ou qui veut garder sa file d'attente hors de sa base :
1039
+ quelques scripts Lua sur le client du *package*, chaque clé sous `{<namespace>}`, pour qu'un
1040
+ cluster les garde dans un seul *slot* et que Dragonfly voie déclarée chaque clé qu'un script
1041
+ touche.
1042
+
1043
+ <!-- checked sample -->
1044
+ ```ts
1045
+ import type { ErrorReporter, Logger } from '@ddd-ts-kit/kernel';
1046
+ import { kernelModule } from '@ddd-ts-kit/di';
1047
+ import { RedisJobStore, reportLostJobs, type RedisConnection } from '@ddd-ts-kit/redis';
1048
+
1049
+ declare const redis: RedisConnection;
1050
+ declare const logger: Logger;
1051
+ declare const errors: ErrorReporter;
1052
+
1053
+ const jobStore = new RedisJobStore(redis.client, { namespace: 'jobs', onLostJob: reportLostJobs(logger, errors) });
1054
+
1055
+ export const kernel = kernelModule({ jobStore });
1056
+ ```
1057
+
1058
+ `reportLostJobs(logger, errors?)` journalise une écriture perdue après la validation de son unité
1059
+ de travail (l'identifiant du *job*, la classe et le code de l'erreur) et remet l'erreur à
1060
+ l'`ErrorReporter`, avec l'origine `queue` ; un projet initialisé avec `--queue redis` l'a lié par
1061
+ son module de plateforme.
1062
+
1063
+ Redis ne peut pas rejoindre la transaction d'une base de données. Dans une unité de travail,
1064
+ `add`, `cancel` et `retryDead` répondent d'après ce que Redis contient, et écrivent une fois
1065
+ l'unité validée (`whenCommitted` du noyau) : une commande dont la transaction est annulée
1066
+ (rollback) ne laisse aucun *job* et n'en annule aucun, mais un processus qui meurt entre la
1067
+ validation et l'écriture perd cette écriture, et `onLostJob` est prévenu quand elle échoue. Avec
1068
+ une base de données, préférez la table de *jobs* du *package* de votre ORM. La clé d'idempotence,
1069
+ propre au *tenant* du *job*, est vérifiée avant que le *job* soit préparé, puis de nouveau quand il
1070
+ est écrit : deux *jobs* de même clé préparés avant que l'un ou l'autre soit validé répondent
1071
+ chacun leur propre identifiant, et seul le premier est gardé.
1072
+
1073
+ Pas de BullMQ : il apporterait un second modèle de *worker*, sur lequel le contrat du store
1074
+ (prise, bail, signal de vie, rapports par tentative) ne se calque pas, et le mode cluster émulé
1075
+ sur Dragonfly. Le store passe `jobStoreContract` sur Dragonfly dans le *job* d'intégration de la
1076
+ CI ; il a aussi été lancé à la main sur Redis 8 (dernier passage : Dragonfly 2.0.2, Redis 8.2,
1077
+ 2026-10-13). Ce que garantissent les *jobs* : [Jobs en arrière-plan](../../docs/concepts/background-jobs.fr.md).
1078
+
1079
+ ## Suites de contrat
1080
+
1081
+ Chaque port a une suite que son implémentation en mémoire et son adaptateur passent tous les deux.
1082
+ Un adaptateur à vous (une autre bibliothèque cliente, un autre store) est tenu à la même :
1083
+
1084
+ <!-- checked sample -->
1085
+ ```ts
1086
+ import { beforeEach, describe, expect, it } from 'vitest';
1087
+ import { FixedClock, type Cache, type Clock, type Lock, type RateLimiter } from '@ddd-ts-kit/kernel';
1088
+ import { cacheContract, lockContract, rateLimiterContract } from '@ddd-ts-kit/testing/contracts';
1089
+
1090
+ // Vos adaptateurs, sous test : chaque appel en répond un neuf.
1091
+ declare function createMyCache(): Cache;
1092
+ declare function createMyLock(): Lock;
1093
+ declare function createMyLimiter(clock: Clock): RateLimiter;
1094
+
1095
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
1096
+ const clock = new FixedClock(new Date('2026-10-13T09:00:00Z'));
1097
+
1098
+ const runner = { describe, it, expect, beforeEach };
1099
+ cacheContract(runner, { name: 'MyCache', createCache: () => createMyCache(), elapse: (ms) => sleep(ms) });
1100
+ lockContract(runner, { name: 'MyLock', createLock: () => createMyLock(), elapse: (ms) => sleep(ms) });
1101
+ rateLimiterContract(runner, { name: 'MyLimiter', createLimiter: () => createMyLimiter(clock), elapse: (ms) => clock.advance(ms) });
1102
+ ```
1103
+
1104
+ `elapse` fait passer le temps pour le sujet du test : avancez l'horloge qu'il lit, ou attendez
1105
+ réellement quand le temps est celui du serveur. Les suites vivent dans
1106
+ `@ddd-ts-kit/testing/contracts`, avec celles des autres ports du noyau, où les implémentations en
1107
+ mémoire du noyau sont exécutées contre elles ; `@ddd-ts-kit/redis/testing` les réexporte.
1108
+
1109
+ ## Limites
1110
+
1111
+ - **Un serveur, ou un primaire avec des répliques.** Un Redis Cluster fonctionne pour le cache
1112
+ (une clé par commande) et pour le verrou (*hash tag*) ; `deleteByPrefix` devrait parcourir
1113
+ chaque nœud, ce qu'il ne fait pas. Pas testé sur un cluster.
1114
+ - **L'éviction s'applique à tout.** Un serveur configuré pour évincer (`allkeys-lru`) peut évincer
1115
+ une clé de verrou ou de limite de débit aussi bien qu'une entrée de cache. Utilisez une politique
1116
+ `volatile-*`, ou un second serveur pour ce qui n'est pas un cache.
1117
+ - **Pas de cache côté client, pas de *pipelining* des `get`** : chaque lecture est un aller-retour.
1118
+ - **Les valeurs sont en JSON par défaut** : `undefined`, `Date`, `Map`, `bigint` et les instances
1119
+ de classe ne survivent pas. Donnez un codec, ou mettez en cache des données simples.
1120
+ - **Le verrou n'est pas réentrant et il n'est pas renouvelé** : choisissez un `ttlMs` au-dessus du
1121
+ pire cas du travail, ou découpez le travail.
1122
+ - **ioredis 5** est accepté comme *peer* ; les suites n'ont été exécutées qu'avec ioredis 6.