@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.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
**English** · [Français](README.fr.md)
|
|
2
|
+
|
|
1
3
|
# @ddd-ts-kit/redis
|
|
2
4
|
|
|
3
5
|
Adapters of kernel ports on the Redis protocol: the `Cache`, `Lock` and `RateLimiter` ports,
|
|
@@ -13,28 +15,82 @@ npm i @ddd-ts-kit/redis ioredis
|
|
|
13
15
|
|
|
14
16
|
| Entry | Holds | Imports |
|
|
15
17
|
|---|---|---|
|
|
16
|
-
| `@ddd-ts-kit/redis` | `connectRedis`, `redisHealth`, `RedisCache`, `RedisLock`, `RedisRateLimiter`, `RedisRelaySignal`, `relayWakeUpMiddleware`, `RedisJobStore`, `reportLostJobs`; re-exported from the kernel: the `Lock` and `RateLimiter` ports, `InMemoryLock`, `InMemoryRateLimiter` | the kernel, `@ddd-ts-kit/scheduling`, `ioredis` (peer) |
|
|
17
|
-
| `@ddd-ts-kit/redis/event-bus` | `RedisStreamsEventBus`, its options, topology and error types, the names of the entry fields | the kernel, `ioredis` (peer) |
|
|
18
|
+
| `@ddd-ts-kit/redis` | `connectRedis`, `closeRedis`, `FAIL_FAST`, `redisHealth`, `RedisCache`, `jsonCodec`, `RedisLock`, `RedisRateLimiter`, `RedisRelaySignal`, `relayWakeUpMiddleware`, `RedisJobStore`, `reportLostJobs`; re-exported from the kernel: the `Lock` and `RateLimiter` ports, `InMemoryLock`, `InMemoryRateLimiter` | the kernel, `@ddd-ts-kit/scheduling`, `ioredis` (peer) |
|
|
19
|
+
| `@ddd-ts-kit/redis/event-bus` | `RedisStreamsEventBus`, its options, topology and error types, `streamNamesOf`, `DEFAULT_PREFIX`, the codes `ATTEMPTS_EXHAUSTED` and `POISON_ENTRY`, the names of the entry fields | the kernel, `ioredis` (peer) |
|
|
18
20
|
| `@ddd-ts-kit/redis/testing` | `cacheContract`, `lockContract`, `rateLimiterContract`, re-exported from `@ddd-ts-kit/testing/contracts` | `@ddd-ts-kit/testing` (optional peer), the runner you pass |
|
|
19
21
|
|
|
22
|
+
## Where it sits in an application
|
|
23
|
+
|
|
20
24
|
Nothing here is imported from `domain/` or `application/`. A use case depends on the port
|
|
21
25
|
(`Cache`, `Lock`, `RateLimiter` of the kernel); the adapter is chosen in `platform/` and bound
|
|
22
26
|
in the composition root.
|
|
23
27
|
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
The same use case then runs against Redis in production and against the in-memory adapters of
|
|
31
|
+
the kernel in its tests. Instead of naming the adapter, which ties the use case to one server
|
|
32
|
+
and which [`layer/application-inward`](../eslint-plugin/docs/rules/layer-application-inward.md)
|
|
33
|
+
reports in `application/`, name the port:
|
|
34
|
+
|
|
35
|
+
<!-- checked sample -->
|
|
36
|
+
```ts
|
|
37
|
+
import { Cache, FixedClock, InMemoryCache, cacheKey, type CacheKey } from '@ddd-ts-kit/kernel';
|
|
38
|
+
|
|
39
|
+
interface Quote {
|
|
40
|
+
readonly pair: string;
|
|
41
|
+
readonly rate: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ExchangeRates {
|
|
45
|
+
quoteOf(pair: string): Promise<Quote>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const quoteKey = (pair: string): CacheKey<Quote> => cacheKey<Quote>('quote', pair);
|
|
49
|
+
|
|
50
|
+
// Instead of `constructor(private readonly cache: RedisCache)`: the port of the kernel.
|
|
51
|
+
export class QuoteReader {
|
|
52
|
+
constructor(
|
|
53
|
+
private readonly cache: Cache,
|
|
54
|
+
private readonly rates: ExchangeRates,
|
|
55
|
+
) {}
|
|
56
|
+
|
|
57
|
+
async quoteOf(pair: string): Promise<Quote> {
|
|
58
|
+
const cached = await this.cache.get(quoteKey(pair));
|
|
59
|
+
|
|
60
|
+
if (cached !== undefined) {
|
|
61
|
+
return cached;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const quote = await this.rates.quoteOf(pair);
|
|
65
|
+
await this.cache.set(quoteKey(pair), quote, { ttlMs: 30_000 });
|
|
66
|
+
|
|
67
|
+
return quote;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// In a test, the in-memory cache of the kernel; in production, RedisCache, bound in platform/.
|
|
72
|
+
const clock = new FixedClock(new Date('2026-10-13T09:00:00Z'));
|
|
73
|
+
export const reader = new QuoteReader(new InMemoryCache(clock), { quoteOf: async (pair) => ({ pair, rate: 1.08 }) });
|
|
74
|
+
```
|
|
75
|
+
|
|
24
76
|
With the CLI, `init --cache redis` writes `platform/redis/open-redis.ts` (one connection, the
|
|
25
|
-
cache, the lock, the rate limiter, the relay signal
|
|
26
|
-
`init --broker redis` writes `platform/events/create-event-provider.ts`
|
|
27
|
-
`RedisStreamsEventBus
|
|
77
|
+
cache, the lock, the rate limiter, the relay signal when events go through an outbox, the
|
|
78
|
+
readiness probe), `init --broker redis` writes `platform/events/create-event-provider.ts`
|
|
79
|
+
around `RedisStreamsEventBus`, and `init --queue redis` (which needs `--cache redis`) adds the
|
|
80
|
+
job store to the same connection. What follows is each piece, wired by hand.
|
|
28
81
|
|
|
29
82
|
## Connection
|
|
30
83
|
|
|
84
|
+
<!-- checked sample -->
|
|
31
85
|
```ts
|
|
32
86
|
// src/platform/redis/redis.ts
|
|
33
87
|
import { connectRedis, redisHealth } from '@ddd-ts-kit/redis';
|
|
34
88
|
|
|
35
|
-
|
|
89
|
+
declare const config: { readonly redisUrl: string };
|
|
90
|
+
|
|
91
|
+
export const redis = connectRedis(config.redisUrl); // or ioredis options, or a client you already have
|
|
36
92
|
|
|
37
|
-
export const ready = () => redisHealth(redis.client);
|
|
93
|
+
export const ready = () => redisHealth(redis.client); // { status: 'up', latencyMs } | { status: 'down', reason }
|
|
38
94
|
export const shutdown = () => redis.close();
|
|
39
95
|
```
|
|
40
96
|
|
|
@@ -46,7 +102,7 @@ export const shutdown = () => redis.close();
|
|
|
46
102
|
defaults, a command sent while the server is away waits for twenty reconnections before
|
|
47
103
|
failing, and every request of the application waits with it.
|
|
48
104
|
- `redisHealth(client, { timeoutMs? })` sends one `PING`, never throws, and answers within the
|
|
49
|
-
timeout: use it in a readiness probe. A cache being down is rarely a reason to stop
|
|
105
|
+
timeout (1 s by default): use it in a readiness probe. A cache being down is rarely a reason to stop
|
|
50
106
|
receiving traffic; a lock or a rate limiter being down may be.
|
|
51
107
|
- Every adapter takes the ioredis client, so several share one connection. The relay signal
|
|
52
108
|
opens a second one, to listen, and the event bus one per consumer name, to block on its
|
|
@@ -75,31 +131,51 @@ volumes:
|
|
|
75
131
|
**Credentials.** A client of ioredis keeps its options, password included, and shows them to
|
|
76
132
|
whoever inspects it: that cannot be changed from here, so never log a client. The adapters of
|
|
77
133
|
this package (`RedisCache`, `RedisLock`, `RedisRateLimiter`, `RedisRelaySignal`,
|
|
78
|
-
`RedisStreamsEventBus`) hold their client in a field that is not enumerable,
|
|
79
|
-
name to `JSON.stringify` and `util.inspect`: logging, spreading or serialising
|
|
80
|
-
not lead to the client. The object `connectRedis` answers gives its `client` by
|
|
81
|
-
(`redis.client`, `const { client } = redis`) and leaves it out of a spread or a log.
|
|
134
|
+
`RedisJobStore`, `RedisStreamsEventBus`) hold their client in a field that is not enumerable,
|
|
135
|
+
and answer their name to `JSON.stringify` and `util.inspect`: logging, spreading or serialising
|
|
136
|
+
an adapter does not lead to the client. The object `connectRedis` answers gives its `client` by
|
|
137
|
+
name (`redis.client`, `const { client } = redis`) and leaves it out of a spread or a log.
|
|
138
|
+
|
|
139
|
+
<!-- checked sample -->
|
|
140
|
+
```ts
|
|
141
|
+
import type { Logger } from '@ddd-ts-kit/kernel';
|
|
142
|
+
import { redisHealth, type RedisConnection } from '@ddd-ts-kit/redis';
|
|
143
|
+
|
|
144
|
+
declare const redis: RedisConnection;
|
|
145
|
+
declare const logger: Logger;
|
|
146
|
+
|
|
147
|
+
export async function logStartup(): Promise<void> {
|
|
148
|
+
// Instead of: logger.info('redis connected', { client: redis.client }), the password with it.
|
|
149
|
+
// Write what an operator needs, which holds no secret:
|
|
150
|
+
const health = await redisHealth(redis.client);
|
|
151
|
+
logger.info('redis connected', { status: health.status });
|
|
152
|
+
}
|
|
153
|
+
```
|
|
82
154
|
|
|
83
155
|
## Cache
|
|
84
156
|
|
|
85
157
|
`RedisCache` implements the kernel `Cache` port with its typed `CacheKey`.
|
|
86
158
|
|
|
159
|
+
<!-- checked sample -->
|
|
87
160
|
```ts
|
|
88
161
|
// src/main.ts
|
|
89
162
|
import { Cache, Lock, Metrics, RateLimiter } from '@ddd-ts-kit/kernel';
|
|
90
|
-
import { contextModule, createApplication, defineContextModule, kernelModule } from '@ddd-ts-kit/di';
|
|
91
|
-
import { RedisCache, RedisLock, RedisRateLimiter } from '@ddd-ts-kit/redis';
|
|
163
|
+
import { contextModule, createApplication, defineContextModule, kernelModule, type DeclaredSource } from '@ddd-ts-kit/di';
|
|
164
|
+
import { RedisCache, RedisLock, RedisRateLimiter, type RedisConnection } from '@ddd-ts-kit/redis';
|
|
165
|
+
|
|
166
|
+
declare const redis: RedisConnection; // connectRedis(config.redisUrl), above
|
|
167
|
+
declare const catalogDeclarations: DeclaredSource;
|
|
92
168
|
|
|
93
169
|
const platformModule = defineContextModule({
|
|
94
170
|
name: 'platform',
|
|
95
171
|
register(builder) {
|
|
96
172
|
builder.rebind(Cache).toFactory((resolve) => new RedisCache(redis.client, { namespace: 'app', metrics: resolve(Metrics) }));
|
|
97
|
-
builder.rebind(Lock).toValue(new RedisLock(redis.client, { namespace: 'app' }));
|
|
98
|
-
builder.rebind(RateLimiter).toValue(new RedisRateLimiter(redis.client, { namespace: 'app' }));
|
|
173
|
+
builder.rebind(Lock).toValue(new RedisLock(redis.client, { namespace: 'app:lock' }));
|
|
174
|
+
builder.rebind(RateLimiter).toValue(new RedisRateLimiter(redis.client, { namespace: 'app:rate' }));
|
|
99
175
|
},
|
|
100
176
|
});
|
|
101
177
|
|
|
102
|
-
const app = createApplication({ modules: [kernelModule(), platformModule, contextModule('catalog', catalogDeclarations)] });
|
|
178
|
+
export const app = createApplication({ modules: [kernelModule(), platformModule, contextModule('catalog', catalogDeclarations)] });
|
|
103
179
|
```
|
|
104
180
|
|
|
105
181
|
A port of the kernel is an abstract class, and the class is what a binding is keyed by: the
|
|
@@ -107,7 +183,9 @@ three adapters `implements` theirs, and a use case that names `Cache`, `Lock` or
|
|
|
107
183
|
in its dependencies receives them. `kernelModule({ cache: (resolve) => new RedisCache(...), lock: ..., rateLimiter: ... })`
|
|
108
184
|
does the same through its options. `cached` of `@ddd-ts-kit/aspects` and everything that
|
|
109
185
|
resolves `Cache` then use it without knowing. A context that wants its keys apart builds its
|
|
110
|
-
own instance with its own `namespace`.
|
|
186
|
+
own instance with its own `namespace`. Give each adapter a namespace of its own, as above:
|
|
187
|
+
without one, the lock writes under `lock:` and the rate limiter under `rate:`, which two
|
|
188
|
+
applications sharing a server would share too.
|
|
111
189
|
|
|
112
190
|
| Option | Default | Effect |
|
|
113
191
|
|---|---|---|
|
|
@@ -126,6 +204,40 @@ own instance with its own `namespace`.
|
|
|
126
204
|
request; prefer short time to live values to invalidation.
|
|
127
205
|
- An entry that cannot be decoded is a miss and is counted.
|
|
128
206
|
|
|
207
|
+
The type a `CacheKey` carries is a promise the codec has to keep. JSON keeps it for plain data
|
|
208
|
+
only, so cache the shape the value really has after the round trip:
|
|
209
|
+
|
|
210
|
+
<!-- checked sample -->
|
|
211
|
+
```ts
|
|
212
|
+
import { cacheKey, type Cache } from '@ddd-ts-kit/kernel';
|
|
213
|
+
|
|
214
|
+
declare const cache: Cache;
|
|
215
|
+
|
|
216
|
+
interface Slot {
|
|
217
|
+
readonly id: string;
|
|
218
|
+
readonly startsAt: Date;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Instead of: the key promises a Date, and the cache answers a string.
|
|
222
|
+
export async function cacheSlot(slot: Slot): Promise<void> {
|
|
223
|
+
await cache.set(cacheKey<Slot>('slot', slot.id), slot, { ttlMs: 60_000 });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Write: plain data, in the shape it has once read back.
|
|
227
|
+
interface SlotView {
|
|
228
|
+
readonly id: string;
|
|
229
|
+
readonly startsAt: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function cacheSlotView(slot: Slot): Promise<void> {
|
|
233
|
+
const view: SlotView = { id: slot.id, startsAt: slot.startsAt.toISOString() };
|
|
234
|
+
|
|
235
|
+
await cache.set(cacheKey<SlotView>('slot', slot.id), view, { ttlMs: 60_000 });
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Or give a `codec` (`{ encode(value): string; decode(text): unknown }`) that knows your types.
|
|
240
|
+
|
|
129
241
|
**A cache must not take the application down.** With the default `onError: 'miss'`, a server
|
|
130
242
|
that is away, slow beyond the command timeout or answering errors makes the application run
|
|
131
243
|
as if it had no cache. Watch `cache_errors_total`: a cache that silently stopped working is a
|
|
@@ -139,16 +251,39 @@ When a popular entry expires, every request that misses it computes it again at
|
|
|
139
251
|
among concurrent identical calls (single flight). Give it a `RedisCache` and that part is done.
|
|
140
252
|
- Across processes, use `getOrLoad` with a `lock`:
|
|
141
253
|
|
|
254
|
+
<!-- checked sample -->
|
|
142
255
|
```ts
|
|
143
|
-
|
|
256
|
+
import { cacheKey, type CacheKey } from '@ddd-ts-kit/kernel';
|
|
257
|
+
import { RedisCache, RedisLock, type RedisConnection } from '@ddd-ts-kit/redis';
|
|
258
|
+
|
|
259
|
+
declare const redis: RedisConnection;
|
|
260
|
+
|
|
261
|
+
interface Quote {
|
|
262
|
+
readonly pair: string;
|
|
263
|
+
readonly rate: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
declare const rates: { quoteOf(pair: string): Promise<Quote> };
|
|
267
|
+
|
|
268
|
+
const quoteKey = (pair: string): CacheKey<Quote> => cacheKey<Quote>('quote', pair);
|
|
269
|
+
|
|
270
|
+
const cache = new RedisCache(redis.client, {
|
|
271
|
+
namespace: 'catalog',
|
|
272
|
+
lock: new RedisLock(redis.client, { namespace: 'catalog:lock' }),
|
|
273
|
+
});
|
|
144
274
|
|
|
145
|
-
|
|
275
|
+
export function quoteOf(pair: string): Promise<Quote> {
|
|
276
|
+
return cache.getOrLoad(quoteKey(pair), () => rates.quoteOf(pair), { ttlMs: 30_000 });
|
|
277
|
+
}
|
|
146
278
|
```
|
|
147
279
|
|
|
148
280
|
`getOrLoad` reads the entry; on a miss the callers of this process share one computation, and
|
|
149
281
|
with a lock only one process computes: the others wait for its value up to `waitMs` (default
|
|
150
|
-
2 s), then compute it themselves.
|
|
151
|
-
|
|
282
|
+
2 s), then compute it themselves. The process that computes holds the lock for `lockTtlMs` at
|
|
283
|
+
most (default 10 s): past it, another one may start. It never fails because of the lock or the
|
|
284
|
+
server: the worst case is the unprotected one. A failed computation is not cached. The
|
|
285
|
+
[cache and coordination guide](../../docs/tools/cache-and-coordination.md#what-this-kit-supports-and-verified)
|
|
286
|
+
draws these steps.
|
|
152
287
|
|
|
153
288
|
## Lock
|
|
154
289
|
|
|
@@ -161,15 +296,25 @@ abstract class Lock {
|
|
|
161
296
|
interface LockHandle { readonly key: string; readonly fencingToken: number; release(): Promise<void> }
|
|
162
297
|
```
|
|
163
298
|
|
|
299
|
+
<!-- checked sample -->
|
|
164
300
|
```ts
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
301
|
+
import type { Lock } from '@ddd-ts-kit/kernel';
|
|
302
|
+
|
|
303
|
+
declare const lock: Lock;
|
|
304
|
+
declare function exportEverything(): Promise<void>;
|
|
305
|
+
|
|
306
|
+
export async function nightlyExport(): Promise<void> {
|
|
307
|
+
const handle = await lock.acquire('nightly-export', { ttlMs: 60_000 });
|
|
308
|
+
|
|
309
|
+
if (handle === undefined) {
|
|
310
|
+
return; // another instance is already doing it
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
await exportEverything();
|
|
315
|
+
} finally {
|
|
316
|
+
await handle.release();
|
|
317
|
+
}
|
|
173
318
|
}
|
|
174
319
|
```
|
|
175
320
|
|
|
@@ -198,11 +343,64 @@ double booking, a double payment or a duplicated invoice number belongs to a dat
|
|
|
198
343
|
constraint (a unique index, an exclusion constraint, the optimistic version of the aggregate),
|
|
199
344
|
which holds whatever any process believes about a lock.
|
|
200
345
|
|
|
346
|
+
```ts
|
|
347
|
+
// Instead of: a lock as the guard of an invariant.
|
|
348
|
+
const handle = await lock.acquire(`slot:${slotId}`, { ttlMs: 5_000 });
|
|
349
|
+
if (handle !== undefined && !(await bookings.existsForSlot(slotId))) {
|
|
350
|
+
await bookings.save(booking); // two holders (a pause, a failover) both get here: booked twice
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Write: the database refuses the second booking, whatever any process believes.
|
|
354
|
+
// CREATE UNIQUE INDEX bookings_one_per_slot ON bookings (slot_id);
|
|
355
|
+
await bookings.save(booking); // the second insert fails on the index: the handler answers a refusal
|
|
356
|
+
```
|
|
357
|
+
|
|
201
358
|
The **fencing token** grows with each acquisition of a key (`INCR`, in the same script as the
|
|
202
359
|
`SET`). A resource that stores the highest token it has seen can refuse a write from a stale
|
|
203
360
|
holder. It narrows the gap, for resources able to compare; its counter lives in the same
|
|
204
361
|
server and starts again if that server loses its data, so it is not a proof either.
|
|
205
362
|
|
|
363
|
+
<!-- checked sample -->
|
|
364
|
+
```ts
|
|
365
|
+
import type { Lock } from '@ddd-ts-kit/kernel';
|
|
366
|
+
|
|
367
|
+
declare const lock: Lock;
|
|
368
|
+
declare function buildReport(): Promise<string>;
|
|
369
|
+
|
|
370
|
+
/** In practice a column of the row being written: here, the highest token the store accepted. */
|
|
371
|
+
export class ReportStore {
|
|
372
|
+
private highestToken = 0;
|
|
373
|
+
|
|
374
|
+
private readonly reports: string[] = [];
|
|
375
|
+
|
|
376
|
+
write(report: string, fencingToken: number): void {
|
|
377
|
+
if (fencingToken < this.highestToken) {
|
|
378
|
+
throw new Error(`Stale holder: token ${String(fencingToken)} after ${String(this.highestToken)}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
this.highestToken = fencingToken;
|
|
382
|
+
this.reports.push(report);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function nightlyReport(store: ReportStore): Promise<void> {
|
|
387
|
+
const handle = await lock.acquire('nightly-report', { ttlMs: 60_000 });
|
|
388
|
+
|
|
389
|
+
if (handle === undefined) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
store.write(await buildReport(), handle.fencingToken);
|
|
395
|
+
} finally {
|
|
396
|
+
await handle.release();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
The [cache and coordination guide](../../docs/tools/cache-and-coordination.md#what-the-lock-does-and-does-not-guarantee)
|
|
402
|
+
draws two holders of one key and how the token tells them apart.
|
|
403
|
+
|
|
206
404
|
A server that is away makes `acquire` reject: whether somebody holds the key cannot be known,
|
|
207
405
|
and the caller decides.
|
|
208
406
|
|
|
@@ -221,19 +419,33 @@ it. A refused call is not counted. `RedisRateLimiter` keeps one sorted set per k
|
|
|
221
419
|
per allowed call, and decides in a single script, so concurrent calls from several instances
|
|
222
420
|
cannot both take the last place.
|
|
223
421
|
|
|
422
|
+

|
|
423
|
+
|
|
424
|
+
`remaining` counts the places left once this call is counted; `retryAfterMs` is 0 for an
|
|
425
|
+
allowed call, and for a refused one the time until the oldest call of the window leaves it.
|
|
426
|
+
|
|
224
427
|
In front of the HTTP layer, before authentication cost anything (Fastify shown; the same three
|
|
225
428
|
lines fit an Express middleware):
|
|
226
429
|
|
|
430
|
+
<!-- checked sample -->
|
|
227
431
|
```ts
|
|
228
432
|
// src/platform/http/rate-limit.ts
|
|
433
|
+
import Fastify from 'fastify';
|
|
434
|
+
import { RedisRateLimiter, type RedisConnection } from '@ddd-ts-kit/redis';
|
|
435
|
+
|
|
436
|
+
declare const redis: RedisConnection;
|
|
437
|
+
|
|
438
|
+
export const server = Fastify();
|
|
229
439
|
const limiter = new RedisRateLimiter(redis.client, { namespace: 'api', onError: 'allow' });
|
|
230
440
|
|
|
231
441
|
server.addHook('onRequest', async (request, reply) => {
|
|
232
442
|
const decision = await limiter.consume(`ip:${request.ip}`, { limit: 120, windowMs: 60_000 });
|
|
233
443
|
reply.header('RateLimit-Remaining', decision.remaining);
|
|
444
|
+
|
|
234
445
|
if (decision.allowed) {
|
|
235
446
|
return;
|
|
236
447
|
}
|
|
448
|
+
|
|
237
449
|
reply.header('Retry-After', Math.ceil(decision.retryAfterMs / 1000));
|
|
238
450
|
return reply.code(429).send({ type: 'about:blank', title: 'Too Many Requests', status: 429, code: 'RATE_LIMITED' });
|
|
239
451
|
});
|
|
@@ -242,13 +454,49 @@ server.addHook('onRequest', async (request, reply) => {
|
|
|
242
454
|
A use case that must be limited for a business reason (three password resets per hour)
|
|
243
455
|
depends on the `RateLimiter` port of the kernel and receives the adapter by injection
|
|
244
456
|
(`builder.rebind(RateLimiter)`, shown under [Cache](#cache)); `InMemoryRateLimiter` on a
|
|
245
|
-
`FixedClock` is its fake.
|
|
457
|
+
`FixedClock` is its fake. The refusal is a business answer, with its code, not an HTTP status
|
|
458
|
+
written in the use case:
|
|
459
|
+
|
|
460
|
+
<!-- checked sample -->
|
|
461
|
+
```ts
|
|
462
|
+
import { Command, ConceptError, FixedClock, InMemoryRateLimiter, RateLimiter, type CommandHandler } from '@ddd-ts-kit/kernel';
|
|
463
|
+
|
|
464
|
+
export class RequestPasswordReset extends Command {
|
|
465
|
+
constructor(readonly accountId: string) {
|
|
466
|
+
super();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export class PasswordResetError extends ConceptError {
|
|
471
|
+
static tooMany(retryAfterMs: number) {
|
|
472
|
+
return PasswordResetError.refusal('PASSWORD_RESETS_EXHAUSTED', 'Three password resets per hour at most.', { retryAfterMs });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export class RequestPasswordResetHandler implements CommandHandler<RequestPasswordReset> {
|
|
477
|
+
constructor(private readonly limiter: RateLimiter) {}
|
|
478
|
+
|
|
479
|
+
async execute({ accountId }: RequestPasswordReset): Promise<void> {
|
|
480
|
+
const decision = await this.limiter.consume(`password-reset:${accountId}`, { limit: 3, windowMs: 3_600_000 });
|
|
481
|
+
|
|
482
|
+
if (!decision.allowed) {
|
|
483
|
+
throw PasswordResetError.tooMany(decision.retryAfterMs);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// send the link
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// In a test: the in-memory limiter on a fixed clock, and time moves when the test says so.
|
|
491
|
+
const clock = new FixedClock(new Date('2026-10-13T09:00:00Z'));
|
|
492
|
+
export const handler = new RequestPasswordResetHandler(new InMemoryRateLimiter(clock));
|
|
493
|
+
```
|
|
246
494
|
|
|
247
495
|
- **Exact, at a memory cost.** One member per allowed call: a key costs memory in proportion
|
|
248
496
|
to its limit. Fine up to thousands per window; for tens of thousands, limit a coarser key or
|
|
249
497
|
a shorter window.
|
|
250
|
-
- **The clock is the caller's.** Instants come from the `Clock` given to the limiter (
|
|
251
|
-
system clock by default), not from the server, so every instance must be synchronized (NTP):
|
|
498
|
+
- **The clock is the caller's.** Instants come from the `Clock` given to the limiter (its
|
|
499
|
+
`clock` option, the system clock by default), not from the server, so every instance must be synchronized (NTP):
|
|
252
500
|
a skew between two instances shifts the window by as much. In exchange the limiter is tested
|
|
253
501
|
on a fixed clock, and the script holds no arithmetic that differs between Lua versions.
|
|
254
502
|
- **`onError`**: `throw` (default) lets the caller decide; `allow` keeps serving without a
|
|
@@ -261,13 +509,27 @@ depends on the `RateLimiter` port of the kernel and receives the adapter by inje
|
|
|
261
509
|
An outbox relay polls. `RedisRelaySignal` lets it poll rarely and still publish at once, across
|
|
262
510
|
processes: the process that committed a command notifies, a relay in any other process wakes.
|
|
263
511
|
|
|
512
|
+
<!-- checked sample -->
|
|
264
513
|
```ts
|
|
265
|
-
|
|
514
|
+
import { InMemoryCommandBus, SystemClock, unitOfWorkMiddleware, type IntegrationEventPublisher, type UnitOfWork } from '@ddd-ts-kit/kernel';
|
|
515
|
+
import { OutboxRelay, type OutboxStore } from '@ddd-ts-kit/events';
|
|
516
|
+
import { RedisRelaySignal, relayWakeUpMiddleware, type RedisConnection } from '@ddd-ts-kit/redis';
|
|
517
|
+
|
|
518
|
+
declare const redis: RedisConnection;
|
|
519
|
+
declare const store: OutboxStore;
|
|
520
|
+
declare const publisher: IntegrationEventPublisher;
|
|
521
|
+
declare const unitOfWork: UnitOfWork;
|
|
522
|
+
|
|
266
523
|
const signal = new RedisRelaySignal(redis.client, { channel: 'app:outbox' });
|
|
267
|
-
new OutboxRelay({ store, publisher, clock, wakeOn: signal }).start({ intervalMs: 5_000 });
|
|
268
524
|
|
|
269
|
-
//
|
|
270
|
-
|
|
525
|
+
// The worker that relays: it wakes on a notification, and still polls every 5 s.
|
|
526
|
+
export const relay = new OutboxRelay({ store, publisher, clock: new SystemClock(), wakeOn: signal });
|
|
527
|
+
relay.start({ intervalMs: 5_000 });
|
|
528
|
+
|
|
529
|
+
// The API process. Instead of `.use(unitOfWorkMiddleware(unitOfWork)).use(relayWakeUpMiddleware(signal))`,
|
|
530
|
+
// which notifies inside the transaction, before the record is visible, put the wake-up first:
|
|
531
|
+
// it is then the outer middleware, and notifies once the unit of work committed.
|
|
532
|
+
export const commandBus = new InMemoryCommandBus().use(relayWakeUpMiddleware(signal)).use(unitOfWorkMiddleware(unitOfWork));
|
|
271
533
|
```
|
|
272
534
|
|
|
273
535
|
It satisfies the `RelaySignal` port of `@ddd-ts-kit/events` structurally: this package does not
|
|
@@ -311,25 +573,52 @@ memory: see [Guarantees and limits](#guarantees-and-limits).
|
|
|
311
573
|
Handlers never see this package. They publish to the outbox; the relay publishes to the
|
|
312
574
|
streams; each consuming context subscribes through the bus under its own name.
|
|
313
575
|
|
|
576
|
+

|
|
577
|
+
|
|
578
|
+
<!-- checked sample -->
|
|
314
579
|
```ts
|
|
315
580
|
// main.ts
|
|
316
|
-
import { SystemClock } from '@ddd-ts-kit/kernel';
|
|
317
|
-
import {
|
|
581
|
+
import { Command, SystemClock, type CommandBus, type Logger, type Metrics, type ProcessedEventStore, type UnitOfWork } from '@ddd-ts-kit/kernel';
|
|
582
|
+
import {
|
|
583
|
+
OutboxPublisher,
|
|
584
|
+
OutboxRelay,
|
|
585
|
+
consume,
|
|
586
|
+
subscribeAll,
|
|
587
|
+
validatingPublisher,
|
|
588
|
+
type EventRegistry,
|
|
589
|
+
type IntegrationEventDefinition,
|
|
590
|
+
type OutboxStore,
|
|
591
|
+
} from '@ddd-ts-kit/events';
|
|
318
592
|
import { RedisStreamsEventBus } from '@ddd-ts-kit/redis/event-bus';
|
|
319
593
|
import { tracedPublisher, tracedSubscriber } from '@ddd-ts-kit/tracing';
|
|
320
594
|
|
|
595
|
+
declare const config: { readonly redisUrl: string };
|
|
596
|
+
declare const logger: Logger;
|
|
597
|
+
declare const metrics: Metrics;
|
|
598
|
+
declare const outboxStore: OutboxStore;
|
|
599
|
+
declare const registry: EventRegistry;
|
|
600
|
+
declare const processed: ProcessedEventStore;
|
|
601
|
+
declare const unitOfWork: UnitOfWork;
|
|
602
|
+
declare const commandBus: CommandBus;
|
|
603
|
+
declare const orderPlaced: IntegrationEventDefinition<{ readonly orderId: string }>;
|
|
604
|
+
declare class IssueInvoice extends Command {
|
|
605
|
+
constructor(orderId: string);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const deliveries = metrics.counter('event_deliveries_total');
|
|
609
|
+
|
|
321
610
|
const bus = new RedisStreamsEventBus({
|
|
322
611
|
connection: config.redisUrl, // or ioredis options, or a client you already have (redis.client)
|
|
323
612
|
prefix: 'integration-events', // the default: one stream per type, `<prefix>:<type>`
|
|
324
613
|
retry: { maxAttempts: 5, delayMs: 5_000 }, // the defaults
|
|
325
614
|
visibilityTimeoutMs: 60_000, // the default: above the worst case of a batch of handlers
|
|
326
615
|
batchSize: 10, // the default
|
|
327
|
-
onFailure: (failure) => logger.warn(failure), // codes only, never an error message
|
|
328
|
-
onDelivery: ({ stream, consumer, outcome }) => deliveries.
|
|
616
|
+
onFailure: (failure) => logger.warn('event bus failure', { ...failure }), // codes only, never an error message
|
|
617
|
+
onDelivery: ({ stream, consumer, outcome }) => deliveries.increment({ stream, consumer, result: outcome }),
|
|
329
618
|
});
|
|
330
619
|
|
|
331
620
|
// Publishing side: handlers write to the outbox, in the transaction of the command.
|
|
332
|
-
const publisher = validatingPublisher(new OutboxPublisher(outboxStore), registry);
|
|
621
|
+
export const publisher = validatingPublisher(new OutboxPublisher(outboxStore), registry);
|
|
333
622
|
|
|
334
623
|
// The relay target is the bus. A record is marked published when the server answered its entry id.
|
|
335
624
|
const relay = new OutboxRelay({ store: outboxStore, publisher: tracedPublisher(bus), clock: new SystemClock() });
|
|
@@ -443,6 +732,8 @@ version in the payload when it matters.
|
|
|
443
732
|
|
|
444
733
|
### Retry and dead-letter behaviour
|
|
445
734
|
|
|
735
|
+

|
|
736
|
+
|
|
446
737
|
1. An entry is acknowledged (`XACK`) after every handler subscribed to its type resolved. Use
|
|
447
738
|
`consume` of `@ddd-ts-kit/events`: the acknowledgement then follows the commit of the unit
|
|
448
739
|
of work, and a redelivery is deduplicated.
|
|
@@ -513,10 +804,45 @@ What to watch, with the commands of any client (`<stream>` is `<prefix>:<type>`)
|
|
|
513
804
|
| `onFailure` with `kind: 'connection'` | this bus | reconnections (`CONNECTION_LOST`, `ECONNREFUSED`) and what the server refused to a reader (`REDIS_NOGROUP`) |
|
|
514
805
|
| `bus.deliveriesInFlight` | this bus | entries read and not settled; stuck above 0 means a handler that hangs |
|
|
515
806
|
|
|
807
|
+
`onFailure` says codes only. To report an event the bus gives up with the error its handler
|
|
808
|
+
really threw, as the example application does, hand the hook to `DeadLetterReports` of the
|
|
809
|
+
kernel and subscribe through it: a delivery that fails and is retried is not reported, a dead
|
|
810
|
+
or poison entry is.
|
|
811
|
+
|
|
812
|
+
<!-- checked sample -->
|
|
813
|
+
```ts
|
|
814
|
+
import { DeadLetterReports, type ErrorReporter } from '@ddd-ts-kit/kernel';
|
|
815
|
+
import { RedisStreamsEventBus } from '@ddd-ts-kit/redis/event-bus';
|
|
816
|
+
|
|
817
|
+
declare const config: { readonly redisUrl: string };
|
|
818
|
+
declare const errors: ErrorReporter;
|
|
819
|
+
|
|
820
|
+
const deadLetters = new DeadLetterReports(errors);
|
|
821
|
+
|
|
822
|
+
export const bus = new RedisStreamsEventBus({
|
|
823
|
+
connection: config.redisUrl,
|
|
824
|
+
onFailure: (failure) => deadLetters.onFailure(failure),
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// Remembers what each handler threw, until onFailure says whether the event was given up.
|
|
828
|
+
export const billingEvents = deadLetters.observing(bus.forConsumer('billing'));
|
|
829
|
+
```
|
|
830
|
+
|
|
516
831
|
Replaying dead entries, once the cause is fixed:
|
|
517
832
|
|
|
833
|
+
<!-- checked sample -->
|
|
518
834
|
```ts
|
|
519
|
-
|
|
835
|
+
import { streamNamesOf, type RedisStreamsEventBus } from '@ddd-ts-kit/redis/event-bus';
|
|
836
|
+
|
|
837
|
+
declare const bus: RedisStreamsEventBus;
|
|
838
|
+
|
|
839
|
+
const billing = { consumer: 'billing', type: 'ordering.order.placed' };
|
|
840
|
+
|
|
841
|
+
// The keys to read first, with XLEN and XRANGE: the events stream and the dead stream of billing.
|
|
842
|
+
export const { events, dead } = streamNamesOf(billing); // with the default prefix; pass `streamOf` otherwise
|
|
843
|
+
|
|
844
|
+
// Up to 100 entries back to the events stream, oldest first, for billing only.
|
|
845
|
+
export const replayed = await bus.replayDead(billing, 100);
|
|
520
846
|
```
|
|
521
847
|
|
|
522
848
|
It moves up to that many entries, oldest first, from the dead stream back to the events
|
|
@@ -673,10 +999,19 @@ for a project without a database or a queue it wants out of it: a few Lua script
|
|
|
673
999
|
client of the package, every key under `{<namespace>}` so that a cluster keeps them in one slot
|
|
674
1000
|
and Dragonfly sees every key a script touches declared.
|
|
675
1001
|
|
|
1002
|
+
<!-- checked sample -->
|
|
676
1003
|
```ts
|
|
677
|
-
|
|
1004
|
+
import type { ErrorReporter, Logger } from '@ddd-ts-kit/kernel';
|
|
1005
|
+
import { kernelModule } from '@ddd-ts-kit/di';
|
|
1006
|
+
import { RedisJobStore, reportLostJobs, type RedisConnection } from '@ddd-ts-kit/redis';
|
|
678
1007
|
|
|
679
|
-
|
|
1008
|
+
declare const redis: RedisConnection;
|
|
1009
|
+
declare const logger: Logger;
|
|
1010
|
+
declare const errors: ErrorReporter;
|
|
1011
|
+
|
|
1012
|
+
const jobStore = new RedisJobStore(redis.client, { namespace: 'jobs', onLostJob: reportLostJobs(logger, errors) });
|
|
1013
|
+
|
|
1014
|
+
export const kernel = kernelModule({ jobStore });
|
|
680
1015
|
```
|
|
681
1016
|
|
|
682
1017
|
`reportLostJobs(logger, errors?)` logs a write lost after its unit of work committed (the id of
|
|
@@ -701,13 +1036,24 @@ hand on Redis 8 (last run: Dragonfly 2.0.2, Redis 8.2, 2026-10-13). What the job
|
|
|
701
1036
|
Each port has a suite that its in-memory implementation and its adapter both pass. An adapter
|
|
702
1037
|
of your own (another client library, another store) is held to the same:
|
|
703
1038
|
|
|
1039
|
+
<!-- checked sample -->
|
|
704
1040
|
```ts
|
|
1041
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
1042
|
+
import { FixedClock, type Cache, type Clock, type Lock, type RateLimiter } from '@ddd-ts-kit/kernel';
|
|
705
1043
|
import { cacheContract, lockContract, rateLimiterContract } from '@ddd-ts-kit/testing/contracts';
|
|
706
1044
|
|
|
1045
|
+
// Your adapters, under test: each call answers a fresh one.
|
|
1046
|
+
declare function createMyCache(): Cache;
|
|
1047
|
+
declare function createMyLock(): Lock;
|
|
1048
|
+
declare function createMyLimiter(clock: Clock): RateLimiter;
|
|
1049
|
+
|
|
1050
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
1051
|
+
const clock = new FixedClock(new Date('2026-10-13T09:00:00Z'));
|
|
1052
|
+
|
|
707
1053
|
const runner = { describe, it, expect, beforeEach };
|
|
708
|
-
cacheContract(runner, { name: 'MyCache', createCache: () =>
|
|
709
|
-
lockContract(runner, { name: 'MyLock', createLock: () =>
|
|
710
|
-
rateLimiterContract(runner, { name: 'MyLimiter', createLimiter: () =>
|
|
1054
|
+
cacheContract(runner, { name: 'MyCache', createCache: () => createMyCache(), elapse: (ms) => sleep(ms) });
|
|
1055
|
+
lockContract(runner, { name: 'MyLock', createLock: () => createMyLock(), elapse: (ms) => sleep(ms) });
|
|
1056
|
+
rateLimiterContract(runner, { name: 'MyLimiter', createLimiter: () => createMyLimiter(clock), elapse: (ms) => clock.advance(ms) });
|
|
711
1057
|
```
|
|
712
1058
|
|
|
713
1059
|
`elapse` makes time pass for the subject: advance the clock it reads, or really wait when the
|