@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.
- package/README.fr.md +1122 -0
- package/README.md +394 -48
- 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
|
+

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

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

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

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