@sprout-idws/sprout-redis 1.0.0 → 1.0.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.md +351 -0
- package/package.json +4 -4
package/README.md
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# @sprout-idws/sprout-redis
|
|
2
|
+
|
|
3
|
+
NestJS integration for **Redis** (`ioredis`): shared client, **layered cache** (memory and/or Redis), **distributed locks**, and **streams** (publish, consume, retry, monitoring).
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
- Standardize Redis connection settings from `ConfigService` (`REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`, `REDIS_DB`).
|
|
8
|
+
- Provide injectable building blocks used across Sprout backends: cache abstraction, locking, and stream-based workers.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @sprout-idws/sprout-redis @sprout-idws/sprout-context ioredis @nestjs/config
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Peer dependencies: `@nestjs/common`, `@nestjs/config`, `@sprout-idws/sprout-context`, `ioredis` (v5+).
|
|
17
|
+
|
|
18
|
+
## Configuration
|
|
19
|
+
|
|
20
|
+
| Variable | Default | Description |
|
|
21
|
+
|----------|---------|-------------|
|
|
22
|
+
| `REDIS_HOST` | `localhost` | Redis host |
|
|
23
|
+
| `REDIS_PORT` | `6379` | Redis port |
|
|
24
|
+
| `REDIS_PASSWORD` | *(none)* | Optional auth |
|
|
25
|
+
| `REDIS_DB` | `0` | Database index |
|
|
26
|
+
|
|
27
|
+
## Modules and exports
|
|
28
|
+
|
|
29
|
+
### `RedisBaseModule` (`@Global`)
|
|
30
|
+
|
|
31
|
+
- Provider token **`REDIS_CLIENT`**: `ioredis` instance from env.
|
|
32
|
+
- Exports: `REDIS_CLIENT`, `RedisLockService`, `RedisStreamService`, `StreamPublisherService`.
|
|
33
|
+
- Implements graceful shutdown in `OnModuleDestroy`.
|
|
34
|
+
|
|
35
|
+
Import `ContextModule` from `@sprout-idws/sprout-context` is required internally; ensure your app registers `ContextModule` where stream/cache features need request context.
|
|
36
|
+
|
|
37
|
+
### `CacheModule` (`@Global`)
|
|
38
|
+
|
|
39
|
+
Builds three `CacheManager` tokens (multi-level LRU + optional Redis):
|
|
40
|
+
|
|
41
|
+
| Token | Behavior |
|
|
42
|
+
|-------|----------|
|
|
43
|
+
| `REDIS_CACHE_MANAGER` | Redis only |
|
|
44
|
+
| `IN_MEMORY_CACHE_MANAGER` | In-memory LRU (max 500 entries) |
|
|
45
|
+
| `IN_MEMORY_THEN_REDIS_CACHE_MANAGER` | Memory first, then Redis |
|
|
46
|
+
|
|
47
|
+
`CacheManager` supports TTL and pluggable `ICacheStore` levels.
|
|
48
|
+
|
|
49
|
+
### `RedisStreamConsumerModule`
|
|
50
|
+
|
|
51
|
+
Registers stream processing services:
|
|
52
|
+
|
|
53
|
+
- `StreamConsumerService`, `StreamMessageHandlerService`, `StreamRetryService`, `StreamConsumerMonitorService`
|
|
54
|
+
|
|
55
|
+
Use with `RedisBaseModule` for consumers that read Redis streams, handle messages, retries, and monitoring.
|
|
56
|
+
|
|
57
|
+
### Other notable exports
|
|
58
|
+
|
|
59
|
+
- `RedisLockService` — distributed locks with configurable options.
|
|
60
|
+
- `RedisStreamService`, `StreamPublisherService` — stream I/O helpers.
|
|
61
|
+
- Constants: `REDIS_CACHE_MANAGER`, `IN_MEMORY_CACHE_MANAGER`, `IN_MEMORY_THEN_REDIS_CACHE_MANAGER`, `DEFAULT_CACHE_TTL_SECONDS`, `CONSUMER_INTERVAL_TIME`, and related types.
|
|
62
|
+
|
|
63
|
+
The build copies Lua assets used by the implementation (`scripts/copy-lua.sh`).
|
|
64
|
+
|
|
65
|
+
## Examples
|
|
66
|
+
|
|
67
|
+
### Register modules
|
|
68
|
+
|
|
69
|
+
`RedisBaseModule` is global and provides the Redis client, locking, and stream helpers. Add `CacheModule` for cache tokens. For stream consumers (read, retry loop, idle-consumer monitor), import `RedisStreamConsumerModule`.
|
|
70
|
+
|
|
71
|
+
Register **`ConfigModule`** so `REDIS_*` is available, and **`ContextModule`** from `@sprout-idws/sprout-context` wherever publishing or consumer handlers should propagate request context.
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import { Module } from '@nestjs/common';
|
|
75
|
+
import { ConfigModule } from '@nestjs/config';
|
|
76
|
+
import { ContextModule } from '@sprout-idws/sprout-context';
|
|
77
|
+
import {
|
|
78
|
+
RedisBaseModule,
|
|
79
|
+
CacheModule,
|
|
80
|
+
RedisStreamConsumerModule,
|
|
81
|
+
} from '@sprout-idws/sprout-redis';
|
|
82
|
+
|
|
83
|
+
@Module({
|
|
84
|
+
imports: [
|
|
85
|
+
ConfigModule.forRoot({ isGlobal: true }),
|
|
86
|
+
ContextModule,
|
|
87
|
+
RedisBaseModule,
|
|
88
|
+
CacheModule,
|
|
89
|
+
RedisStreamConsumerModule, // optional: omit if you only use cache, locks, or publishing
|
|
90
|
+
],
|
|
91
|
+
})
|
|
92
|
+
export class AppModule {}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Inject the Redis client (`REDIS_CLIENT`)
|
|
96
|
+
|
|
97
|
+
Use the shared `ioredis` instance for commands not wrapped by this package.
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { Inject, Injectable } from '@nestjs/common';
|
|
101
|
+
import Redis from 'ioredis';
|
|
102
|
+
|
|
103
|
+
@Injectable()
|
|
104
|
+
export class HealthService {
|
|
105
|
+
constructor(@Inject('REDIS_CLIENT') private readonly redis: Redis) {}
|
|
106
|
+
|
|
107
|
+
async ping(): Promise<string> {
|
|
108
|
+
return this.redis.ping();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Layered cache (`CacheManager`)
|
|
114
|
+
|
|
115
|
+
Inject one of `REDIS_CACHE_MANAGER`, `IN_MEMORY_CACHE_MANAGER`, or `IN_MEMORY_THEN_REDIS_CACHE_MANAGER`. `get` calls `notFoundFn` when the key is missing at every level, then writes through all levels. `requiredGet` throws if the resolved value is null or undefined. `set` and `delete` apply to every level.
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import { Inject, Injectable } from '@nestjs/common';
|
|
119
|
+
import {
|
|
120
|
+
CacheManager,
|
|
121
|
+
IN_MEMORY_THEN_REDIS_CACHE_MANAGER,
|
|
122
|
+
DEFAULT_CACHE_TTL_SECONDS,
|
|
123
|
+
} from '@sprout-idws/sprout-redis';
|
|
124
|
+
|
|
125
|
+
@Injectable()
|
|
126
|
+
export class UserCacheService {
|
|
127
|
+
constructor(
|
|
128
|
+
@Inject(IN_MEMORY_THEN_REDIS_CACHE_MANAGER)
|
|
129
|
+
private readonly cache: CacheManager
|
|
130
|
+
) {}
|
|
131
|
+
|
|
132
|
+
async getUser(id: string) {
|
|
133
|
+
return this.cache.get(
|
|
134
|
+
`user:${id}`,
|
|
135
|
+
async () => fetchUserFromDb(id),
|
|
136
|
+
{ ttlSeconds: DEFAULT_CACHE_TTL_SECONDS }
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
warmUser(id: string, user: unknown) {
|
|
141
|
+
this.cache.set(`user:${id}`, user, { ttlSeconds: 600 });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async invalidateUser(id: string) {
|
|
145
|
+
await this.cache.delete(`user:${id}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- **`REDIS_CACHE_MANAGER`** — Redis only; shared across app instances.
|
|
151
|
+
- **`IN_MEMORY_CACHE_MANAGER`** — process-local LRU (max 500 entries).
|
|
152
|
+
- **`IN_MEMORY_THEN_REDIS_CACHE_MANAGER`** — memory first; promotes hits to upper levels; on miss, fills memory and Redis.
|
|
153
|
+
|
|
154
|
+
### Custom `CacheManager` (optional)
|
|
155
|
+
|
|
156
|
+
Build your own stack with `InMemoryCacheStore` and `RedisCacheStore` if you register providers manually (see `cache.module.ts` in this package for the Nest pattern).
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
import { CacheManager, InMemoryCacheStore, RedisCacheStore } from '@sprout-idws/sprout-redis';
|
|
160
|
+
import Redis from 'ioredis';
|
|
161
|
+
|
|
162
|
+
const redis = new Redis(/* ... */);
|
|
163
|
+
const cache = new CacheManager({
|
|
164
|
+
levels: [new InMemoryCacheStore({ maxSize: 1000 }), new RedisCacheStore(redis)],
|
|
165
|
+
defaultTtlSeconds: 300,
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Distributed locks (`RedisLockService`)
|
|
170
|
+
|
|
171
|
+
**`runWithLock`** acquires the lock, runs the callback, then releases. If the lock cannot be acquired after optional retries, it returns without invoking the callback.
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
import { Injectable } from '@nestjs/common';
|
|
175
|
+
import { RedisLockService } from '@sprout-idws/sprout-redis';
|
|
176
|
+
|
|
177
|
+
@Injectable()
|
|
178
|
+
export class ReconciliationService {
|
|
179
|
+
constructor(private readonly locks: RedisLockService) {}
|
|
180
|
+
|
|
181
|
+
async runOncePerCluster() {
|
|
182
|
+
await this.locks.runWithLock(
|
|
183
|
+
'lock:reconciliation:daily',
|
|
184
|
+
{ ttl: 600, retryDelay: 200, maxRetries: 5 },
|
|
185
|
+
async () => {
|
|
186
|
+
// only one holder across processes until TTL expires
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Manual acquire / release** — when you need the lock token (e.g. work split across several steps):
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
const token = `my-lock:${Date.now()}:${Math.random()}:${process.pid}`;
|
|
197
|
+
const acquired = await this.locks.acquireLock('lock:resource:1', token, {
|
|
198
|
+
ttl: 120,
|
|
199
|
+
maxRetries: 10,
|
|
200
|
+
retryDelay: 50,
|
|
201
|
+
});
|
|
202
|
+
if (acquired) {
|
|
203
|
+
try {
|
|
204
|
+
// ...
|
|
205
|
+
} finally {
|
|
206
|
+
await this.locks.releaseLock('lock:resource:1', token);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Publish to a stream
|
|
212
|
+
|
|
213
|
+
**`StreamPublisherService`** — use from HTTP/gRPC handlers: fills `metadata` and attaches the current encoded context from `ContextService`.
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
import { Injectable } from '@nestjs/common';
|
|
217
|
+
import { StreamPublisherService } from '@sprout-idws/sprout-redis';
|
|
218
|
+
|
|
219
|
+
@Injectable()
|
|
220
|
+
export class OrderEventsService {
|
|
221
|
+
constructor(private readonly publisher: StreamPublisherService) {}
|
|
222
|
+
|
|
223
|
+
async emitCreated(orderId: string) {
|
|
224
|
+
return this.publisher.publishToStream('orders:events', {
|
|
225
|
+
type: 'created',
|
|
226
|
+
orderId,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**`RedisStreamService.publishToStream`** — full `StreamMessage` shape; if `context` is omitted, it defaults to the current encoded context.
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
import { Injectable } from '@nestjs/common';
|
|
236
|
+
import { RedisStreamService } from '@sprout-idws/sprout-redis';
|
|
237
|
+
|
|
238
|
+
@Injectable()
|
|
239
|
+
export class AdminPublisher {
|
|
240
|
+
constructor(private readonly streams: RedisStreamService) {}
|
|
241
|
+
|
|
242
|
+
async publish() {
|
|
243
|
+
await this.streams.publishToStream('my:stream', {
|
|
244
|
+
data: { action: 'reindex' },
|
|
245
|
+
metadata: {
|
|
246
|
+
timestamp: Date.now(),
|
|
247
|
+
streamName: 'my:stream',
|
|
248
|
+
retryCount: 0,
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Low-level stream helpers (`RedisStreamService`)
|
|
256
|
+
|
|
257
|
+
Create a consumer group, read pending-to-group messages, and acknowledge (used internally by the consumer stack; useful for custom tooling).
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
await this.streams.createConsumerGroup('orders:events', 'order-processors', '0');
|
|
261
|
+
|
|
262
|
+
const batch = await this.streams.readFromConsumerGroup({
|
|
263
|
+
streamName: 'orders:events',
|
|
264
|
+
groupName: 'order-processors',
|
|
265
|
+
consumerName: 'worker-1',
|
|
266
|
+
count: 10,
|
|
267
|
+
blockTime: 5000,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
for (const msg of batch) {
|
|
271
|
+
// process msg.data, msg.context, msg.id
|
|
272
|
+
await this.streams.ackDelete('orders:events', 'order-processors', msg.id!);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const streamNames = await this.streams.getAllStreams('orders:*');
|
|
276
|
+
const groups = await this.streams.getConsumerGroups('orders:events');
|
|
277
|
+
const consumers = await this.streams.getConsumers('orders:events', 'order-processors');
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`sendToDlq`, `delayedRetry`, `republishDelayedMessages`, and `republishInactiveConsumerMessages` power retries, the DLQ, and the consumer monitor; call them directly only if you extend the same semantics.
|
|
281
|
+
|
|
282
|
+
### Consume messages (`StreamConsumerService`)
|
|
283
|
+
|
|
284
|
+
Registers the stream’s delayed-retry key, ensures the consumer group exists, then polls every `CONSUMER_INTERVAL_TIME` (1s). The consumer name is suffixed with a unique id. Handler failures trigger retries through `StreamRetryService`; after `maxRetries`, messages go to the DLQ stream `{streamName}:dlq`.
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
import { Injectable, OnModuleInit } from '@nestjs/common';
|
|
288
|
+
import { StreamConsumerService } from '@sprout-idws/sprout-redis';
|
|
289
|
+
|
|
290
|
+
@Injectable()
|
|
291
|
+
export class OrderConsumer implements OnModuleInit {
|
|
292
|
+
constructor(private readonly consumer: StreamConsumerService) {}
|
|
293
|
+
|
|
294
|
+
onModuleInit() {
|
|
295
|
+
void this.consumer.consumeMessages(
|
|
296
|
+
{
|
|
297
|
+
streamName: 'orders:events',
|
|
298
|
+
groupName: 'order-processors',
|
|
299
|
+
consumerName: 'api-worker',
|
|
300
|
+
maxRetries: 3,
|
|
301
|
+
retryDelay: 5 * 60 * 1000, // ms before retry (scheduled via sorted set)
|
|
302
|
+
blockTime: 2000,
|
|
303
|
+
prefetchCount: 50,
|
|
304
|
+
maxConcurrency: 25,
|
|
305
|
+
},
|
|
306
|
+
async (entry) => {
|
|
307
|
+
// entry.data, entry.context (restored into ContextService for this callback)
|
|
308
|
+
await handleOrderEvent(entry.data);
|
|
309
|
+
}
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Process a batch yourself (`StreamMessageHandlerService`)
|
|
316
|
+
|
|
317
|
+
Normally used by `StreamConsumerService`. You can call `processMessages` if you already have `StreamMessage[]` (e.g. tests or a custom reader). Exposes `getProcessingMessagesCount()` / `isMessageProcessing(id)` for observability.
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
await this.messageHandler.processMessages({
|
|
321
|
+
streamName: 'orders:events',
|
|
322
|
+
groupName: 'order-processors',
|
|
323
|
+
maxRetries: 3,
|
|
324
|
+
retryDelay: 300_000,
|
|
325
|
+
maxConcurrency: 10,
|
|
326
|
+
messages: fetchedMessages,
|
|
327
|
+
messageHandler: async (entry) => {
|
|
328
|
+
/* ... */
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Retries and delayed republish (`StreamRetryService`)
|
|
334
|
+
|
|
335
|
+
When `RedisStreamConsumerModule` loads, `StreamRetryService` starts a periodic job that calls `republishDelayedMessages` on every registered delayed queue (`{streamName}:delayed`). `StreamConsumerService.consumeMessages` registers its stream automatically. You rarely call `delayedRetry` yourself; it is used on handler failure to re-queue the message after `retryDelay`.
|
|
336
|
+
|
|
337
|
+
### Idle consumers and reclaim (`StreamConsumerMonitorService`)
|
|
338
|
+
|
|
339
|
+
Also starts on module init. Periodically scans Redis for stream keys, lists groups and consumers, and for consumers idle longer than `10 × CONSUMER_INTERVAL_TIME` with pending messages, republishes those messages and removes the stale consumer name. No application code is required beyond importing `RedisStreamConsumerModule`.
|
|
340
|
+
|
|
341
|
+
## Tests
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
npm test
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
(from this package directory, or via the monorepo test task)
|
|
348
|
+
|
|
349
|
+
## Repository
|
|
350
|
+
|
|
351
|
+
[`sprout-typescript-backend`](https://github.com/sprout-libs/sprout-typescript-backend) — `packages/sprout-redis`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sprout-idws/sprout-redis",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Reusable NestJS Redis package: cache, lock, and stream modules",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -16,16 +16,16 @@
|
|
|
16
16
|
"format:check": "biome format .",
|
|
17
17
|
"check": "biome check .",
|
|
18
18
|
"check:fix": "biome check --write .",
|
|
19
|
-
"publish:npm": "
|
|
19
|
+
"publish:npm": "bash ../../scripts/publish-npm.sh"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@sprout-idws/sprout-context": "^1.0.
|
|
22
|
+
"@sprout-idws/sprout-context": "^1.0.1",
|
|
23
23
|
"lru-cache": "^10.0.0"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"@nestjs/common": ">=10.0.0",
|
|
27
27
|
"@nestjs/config": ">=3.0.0",
|
|
28
|
-
"@sprout-idws/sprout-context": "^1.0.
|
|
28
|
+
"@sprout-idws/sprout-context": "^1.0.1",
|
|
29
29
|
"ioredis": ">=5.0.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|