@saga-bus/transport-redis 0.1.2
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 +177 -0
- package/dist/index.cjs +339 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +129 -0
- package/dist/index.d.ts +129 -0
- package/dist/index.js +312 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# @saga-bus/transport-redis
|
|
2
|
+
|
|
3
|
+
Redis Streams transport for saga-bus using ioredis.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Redis Streams** - Uses XADD/XREADGROUP for reliable message delivery
|
|
8
|
+
- **Consumer Groups** - Competing consumers with automatic load balancing
|
|
9
|
+
- **Message Acknowledgment** - Manual XACK after successful processing
|
|
10
|
+
- **Delayed Messages** - Sorted set-based delayed delivery (ZADD/ZRANGEBYSCORE)
|
|
11
|
+
- **Pending Recovery** - Automatic claiming of unacknowledged messages (XCLAIM)
|
|
12
|
+
- **Stream Trimming** - Configurable MAXLEN for memory management
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @saga-bus/transport-redis ioredis
|
|
18
|
+
# or
|
|
19
|
+
pnpm add @saga-bus/transport-redis ioredis
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
### Basic Setup
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import Redis from "ioredis";
|
|
28
|
+
import { RedisTransport } from "@saga-bus/transport-redis";
|
|
29
|
+
|
|
30
|
+
const redis = new Redis({
|
|
31
|
+
host: "localhost",
|
|
32
|
+
port: 6379,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const transport = new RedisTransport({
|
|
36
|
+
redis,
|
|
37
|
+
consumerGroup: "order-processor",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await transport.start();
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### With Connection Options
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { RedisTransport } from "@saga-bus/transport-redis";
|
|
47
|
+
|
|
48
|
+
const transport = new RedisTransport({
|
|
49
|
+
connection: {
|
|
50
|
+
host: "localhost",
|
|
51
|
+
port: 6379,
|
|
52
|
+
password: "secret",
|
|
53
|
+
db: 0,
|
|
54
|
+
},
|
|
55
|
+
consumerGroup: "order-processor",
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Publishing Messages
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
interface OrderCreated {
|
|
63
|
+
type: "OrderCreated";
|
|
64
|
+
orderId: string;
|
|
65
|
+
amount: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Immediate delivery
|
|
69
|
+
await transport.publish<OrderCreated>(
|
|
70
|
+
{ type: "OrderCreated", orderId: "123", amount: 99.99 },
|
|
71
|
+
{ endpoint: "orders" }
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// With partition key (for ordering)
|
|
75
|
+
await transport.publish<OrderCreated>(
|
|
76
|
+
{ type: "OrderCreated", orderId: "123", amount: 99.99 },
|
|
77
|
+
{ endpoint: "orders", key: "customer-456" }
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Delayed delivery (5 minutes)
|
|
81
|
+
await transport.publish<OrderCreated>(
|
|
82
|
+
{ type: "OrderCreated", orderId: "123", amount: 99.99 },
|
|
83
|
+
{ endpoint: "orders", delayMs: 5 * 60 * 1000 }
|
|
84
|
+
);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Subscribing to Messages
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
await transport.subscribe(
|
|
91
|
+
{ endpoint: "orders", concurrency: 5 },
|
|
92
|
+
async (envelope) => {
|
|
93
|
+
console.log("Received:", envelope.type, envelope.payload);
|
|
94
|
+
// Message is automatically acknowledged after successful processing
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await transport.start();
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### With saga-bus
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import { createBus } from "@saga-bus/core";
|
|
105
|
+
import { RedisTransport } from "@saga-bus/transport-redis";
|
|
106
|
+
import Redis from "ioredis";
|
|
107
|
+
|
|
108
|
+
const bus = createBus({
|
|
109
|
+
transport: new RedisTransport({
|
|
110
|
+
redis: new Redis(),
|
|
111
|
+
consumerGroup: "my-app",
|
|
112
|
+
}),
|
|
113
|
+
// ... other config
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Configuration Options
|
|
118
|
+
|
|
119
|
+
| Option | Type | Default | Description |
|
|
120
|
+
|--------|------|---------|-------------|
|
|
121
|
+
| `redis` | `Redis` | - | ioredis client instance |
|
|
122
|
+
| `connection` | `RedisOptions` | - | Connection options (alternative to `redis`) |
|
|
123
|
+
| `keyPrefix` | `string` | `"saga-bus:"` | Prefix for all Redis keys |
|
|
124
|
+
| `consumerGroup` | `string` | - | Consumer group name (required for subscribing) |
|
|
125
|
+
| `consumerName` | `string` | Auto UUID | Consumer name within the group |
|
|
126
|
+
| `autoCreateGroup` | `boolean` | `true` | Create consumer groups automatically |
|
|
127
|
+
| `batchSize` | `number` | `10` | Messages to fetch per read |
|
|
128
|
+
| `blockTimeoutMs` | `number` | `5000` | Block timeout for XREADGROUP |
|
|
129
|
+
| `maxStreamLength` | `number` | `0` | Max stream length (0 = unlimited) |
|
|
130
|
+
| `approximateMaxLen` | `boolean` | `true` | Use approximate MAXLEN (~) |
|
|
131
|
+
| `delayedPollIntervalMs` | `number` | `1000` | How often to check delayed messages |
|
|
132
|
+
| `delayedSetKey` | `string` | `"saga-bus:delayed"` | Key for delayed messages sorted set |
|
|
133
|
+
| `pendingClaimIntervalMs` | `number` | `30000` | How often to claim pending messages |
|
|
134
|
+
| `minIdleTimeMs` | `number` | `60000` | Min idle time before claiming |
|
|
135
|
+
|
|
136
|
+
## Redis Data Structures
|
|
137
|
+
|
|
138
|
+
### Streams
|
|
139
|
+
|
|
140
|
+
Messages are stored in Redis Streams with key pattern:
|
|
141
|
+
```
|
|
142
|
+
{keyPrefix}stream:{endpoint}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Example: `saga-bus:stream:orders`
|
|
146
|
+
|
|
147
|
+
Each message contains:
|
|
148
|
+
```
|
|
149
|
+
data: <JSON envelope>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Delayed Messages
|
|
153
|
+
|
|
154
|
+
Delayed messages use a sorted set:
|
|
155
|
+
```
|
|
156
|
+
{delayedSetKey}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Score: Unix timestamp (ms) when message should be delivered
|
|
160
|
+
Value: JSON with `{ streamKey, envelope, deliverAt }`
|
|
161
|
+
|
|
162
|
+
## Error Handling
|
|
163
|
+
|
|
164
|
+
- Failed messages are NOT acknowledged, allowing retry via pending recovery
|
|
165
|
+
- Pending messages older than `minIdleTimeMs` are claimed by active consumers
|
|
166
|
+
- Consumer group creation ignores "BUSYGROUP" errors (already exists)
|
|
167
|
+
|
|
168
|
+
## Performance Tips
|
|
169
|
+
|
|
170
|
+
1. **Batch Size**: Increase `batchSize` for high-throughput scenarios
|
|
171
|
+
2. **Stream Trimming**: Set `maxStreamLength` to prevent unbounded growth
|
|
172
|
+
3. **Approximate MAXLEN**: Keep `approximateMaxLen: true` for better performance
|
|
173
|
+
4. **Connection Pooling**: Pass a shared Redis client for connection reuse
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
RedisTransport: () => RedisTransport
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/RedisTransport.ts
|
|
28
|
+
var import_ioredis = require("ioredis");
|
|
29
|
+
var import_crypto = require("crypto");
|
|
30
|
+
var RedisTransport = class {
|
|
31
|
+
redis = null;
|
|
32
|
+
subscriberRedis = null;
|
|
33
|
+
options;
|
|
34
|
+
subscriptions = [];
|
|
35
|
+
started = false;
|
|
36
|
+
stopping = false;
|
|
37
|
+
readLoopPromise = null;
|
|
38
|
+
delayedPollInterval = null;
|
|
39
|
+
pendingClaimInterval = null;
|
|
40
|
+
constructor(options) {
|
|
41
|
+
if (!options.redis && !options.connection) {
|
|
42
|
+
throw new Error("Either redis client or connection options must be provided");
|
|
43
|
+
}
|
|
44
|
+
this.options = {
|
|
45
|
+
keyPrefix: "saga-bus:",
|
|
46
|
+
consumerGroup: "",
|
|
47
|
+
consumerName: `consumer-${(0, import_crypto.randomUUID)()}`,
|
|
48
|
+
autoCreateGroup: true,
|
|
49
|
+
batchSize: 10,
|
|
50
|
+
blockTimeoutMs: 5e3,
|
|
51
|
+
maxStreamLength: 0,
|
|
52
|
+
approximateMaxLen: true,
|
|
53
|
+
delayedPollIntervalMs: 1e3,
|
|
54
|
+
delayedSetKey: "saga-bus:delayed",
|
|
55
|
+
pendingClaimIntervalMs: 3e4,
|
|
56
|
+
minIdleTimeMs: 6e4,
|
|
57
|
+
...options
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async start() {
|
|
61
|
+
if (this.started) return;
|
|
62
|
+
if (this.options.redis) {
|
|
63
|
+
this.redis = this.options.redis;
|
|
64
|
+
this.subscriberRedis = this.options.redis.duplicate();
|
|
65
|
+
} else if (this.options.connection) {
|
|
66
|
+
this.redis = new import_ioredis.Redis(this.options.connection);
|
|
67
|
+
this.subscriberRedis = new import_ioredis.Redis(this.options.connection);
|
|
68
|
+
} else {
|
|
69
|
+
throw new Error("Invalid configuration");
|
|
70
|
+
}
|
|
71
|
+
if (this.options.autoCreateGroup && this.options.consumerGroup) {
|
|
72
|
+
for (const sub of this.subscriptions) {
|
|
73
|
+
await this.ensureConsumerGroup(sub.streamKey);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.started = true;
|
|
77
|
+
if (this.subscriptions.length > 0) {
|
|
78
|
+
this.readLoopPromise = this.readLoop();
|
|
79
|
+
}
|
|
80
|
+
if (this.options.delayedPollIntervalMs > 0) {
|
|
81
|
+
this.delayedPollInterval = setInterval(
|
|
82
|
+
() => void this.processDelayedMessages(),
|
|
83
|
+
this.options.delayedPollIntervalMs
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (this.options.pendingClaimIntervalMs > 0) {
|
|
87
|
+
this.pendingClaimInterval = setInterval(
|
|
88
|
+
() => void this.claimPendingMessages(),
|
|
89
|
+
this.options.pendingClaimIntervalMs
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async stop() {
|
|
94
|
+
if (!this.started || this.stopping) return;
|
|
95
|
+
this.stopping = true;
|
|
96
|
+
if (this.delayedPollInterval) {
|
|
97
|
+
clearInterval(this.delayedPollInterval);
|
|
98
|
+
this.delayedPollInterval = null;
|
|
99
|
+
}
|
|
100
|
+
if (this.pendingClaimInterval) {
|
|
101
|
+
clearInterval(this.pendingClaimInterval);
|
|
102
|
+
this.pendingClaimInterval = null;
|
|
103
|
+
}
|
|
104
|
+
if (this.readLoopPromise) {
|
|
105
|
+
await this.readLoopPromise;
|
|
106
|
+
this.readLoopPromise = null;
|
|
107
|
+
}
|
|
108
|
+
if (this.subscriberRedis && this.subscriberRedis !== this.options.redis) {
|
|
109
|
+
await this.subscriberRedis.quit();
|
|
110
|
+
}
|
|
111
|
+
this.subscriberRedis = null;
|
|
112
|
+
if (this.redis && !this.options.redis) {
|
|
113
|
+
await this.redis.quit();
|
|
114
|
+
}
|
|
115
|
+
this.redis = null;
|
|
116
|
+
this.started = false;
|
|
117
|
+
this.stopping = false;
|
|
118
|
+
}
|
|
119
|
+
async subscribe(options, handler) {
|
|
120
|
+
const { endpoint, concurrency = 1 } = options;
|
|
121
|
+
const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;
|
|
122
|
+
const subscription = {
|
|
123
|
+
streamKey,
|
|
124
|
+
handler,
|
|
125
|
+
concurrency
|
|
126
|
+
};
|
|
127
|
+
this.subscriptions.push(subscription);
|
|
128
|
+
if (this.started && this.redis) {
|
|
129
|
+
await this.ensureConsumerGroup(streamKey);
|
|
130
|
+
if (!this.readLoopPromise) {
|
|
131
|
+
this.readLoopPromise = this.readLoop();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async publish(message, options) {
|
|
136
|
+
if (!this.redis) {
|
|
137
|
+
throw new Error("Transport not started");
|
|
138
|
+
}
|
|
139
|
+
const { endpoint, key, headers = {}, delayMs } = options;
|
|
140
|
+
const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;
|
|
141
|
+
const envelope = {
|
|
142
|
+
id: (0, import_crypto.randomUUID)(),
|
|
143
|
+
type: message.type,
|
|
144
|
+
payload: message,
|
|
145
|
+
headers,
|
|
146
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
147
|
+
partitionKey: key
|
|
148
|
+
};
|
|
149
|
+
const envelopeJson = JSON.stringify(envelope);
|
|
150
|
+
if (delayMs && delayMs > 0) {
|
|
151
|
+
const deliverAt = Date.now() + delayMs;
|
|
152
|
+
const delayedEntry = {
|
|
153
|
+
streamKey,
|
|
154
|
+
envelope: envelopeJson,
|
|
155
|
+
deliverAt
|
|
156
|
+
};
|
|
157
|
+
await this.redis.zadd(
|
|
158
|
+
this.options.delayedSetKey,
|
|
159
|
+
deliverAt,
|
|
160
|
+
JSON.stringify(delayedEntry)
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
await this.addToStream(streamKey, envelopeJson);
|
|
165
|
+
}
|
|
166
|
+
async addToStream(streamKey, envelopeJson) {
|
|
167
|
+
if (!this.redis) return;
|
|
168
|
+
const args = [streamKey];
|
|
169
|
+
if (this.options.maxStreamLength > 0) {
|
|
170
|
+
if (this.options.approximateMaxLen) {
|
|
171
|
+
args.push("MAXLEN", "~", this.options.maxStreamLength);
|
|
172
|
+
} else {
|
|
173
|
+
args.push("MAXLEN", this.options.maxStreamLength);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
args.push("*", "data", envelopeJson);
|
|
177
|
+
await this.redis.xadd(...args);
|
|
178
|
+
}
|
|
179
|
+
async ensureConsumerGroup(streamKey) {
|
|
180
|
+
if (!this.redis || !this.options.consumerGroup) return;
|
|
181
|
+
try {
|
|
182
|
+
await this.redis.xgroup(
|
|
183
|
+
"CREATE",
|
|
184
|
+
streamKey,
|
|
185
|
+
this.options.consumerGroup,
|
|
186
|
+
"0",
|
|
187
|
+
"MKSTREAM"
|
|
188
|
+
);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof Error && !error.message.includes("BUSYGROUP")) {
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async readLoop() {
|
|
196
|
+
if (!this.subscriberRedis || !this.options.consumerGroup) return;
|
|
197
|
+
const streams = this.subscriptions.map((s) => s.streamKey);
|
|
198
|
+
const ids = streams.map(() => ">");
|
|
199
|
+
while (this.started && !this.stopping) {
|
|
200
|
+
try {
|
|
201
|
+
const results = await this.subscriberRedis.xreadgroup(
|
|
202
|
+
"GROUP",
|
|
203
|
+
this.options.consumerGroup,
|
|
204
|
+
this.options.consumerName,
|
|
205
|
+
"COUNT",
|
|
206
|
+
this.options.batchSize,
|
|
207
|
+
"BLOCK",
|
|
208
|
+
this.options.blockTimeoutMs,
|
|
209
|
+
"STREAMS",
|
|
210
|
+
...streams,
|
|
211
|
+
...ids
|
|
212
|
+
);
|
|
213
|
+
if (!results) continue;
|
|
214
|
+
const typedResults = results;
|
|
215
|
+
for (const [streamKey, messages] of typedResults) {
|
|
216
|
+
const subscription = this.subscriptions.find(
|
|
217
|
+
(s) => s.streamKey === streamKey
|
|
218
|
+
);
|
|
219
|
+
if (!subscription) continue;
|
|
220
|
+
for (const [messageId, fields] of messages) {
|
|
221
|
+
await this.processMessage(
|
|
222
|
+
streamKey,
|
|
223
|
+
messageId,
|
|
224
|
+
fields,
|
|
225
|
+
subscription
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (!this.stopping) {
|
|
231
|
+
console.error("[RedisTransport] Read loop error:", error);
|
|
232
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async processMessage(streamKey, messageId, fields, subscription) {
|
|
238
|
+
if (!this.redis) return;
|
|
239
|
+
try {
|
|
240
|
+
const data = {};
|
|
241
|
+
for (let i = 0; i < fields.length; i += 2) {
|
|
242
|
+
data[fields[i]] = fields[i + 1];
|
|
243
|
+
}
|
|
244
|
+
if (!data.data) {
|
|
245
|
+
console.error("[RedisTransport] Message missing data field:", messageId);
|
|
246
|
+
await this.acknowledgeMessage(streamKey, messageId);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const rawEnvelope = JSON.parse(data.data);
|
|
250
|
+
const envelope = {
|
|
251
|
+
id: rawEnvelope.id,
|
|
252
|
+
type: rawEnvelope.type,
|
|
253
|
+
payload: rawEnvelope.payload,
|
|
254
|
+
headers: rawEnvelope.headers,
|
|
255
|
+
timestamp: new Date(rawEnvelope.timestamp),
|
|
256
|
+
partitionKey: rawEnvelope.partitionKey
|
|
257
|
+
};
|
|
258
|
+
await subscription.handler(envelope);
|
|
259
|
+
await this.acknowledgeMessage(streamKey, messageId);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
console.error("[RedisTransport] Message processing error:", error);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async acknowledgeMessage(streamKey, messageId) {
|
|
265
|
+
if (!this.redis || !this.options.consumerGroup) return;
|
|
266
|
+
await this.redis.xack(streamKey, this.options.consumerGroup, messageId);
|
|
267
|
+
}
|
|
268
|
+
async processDelayedMessages() {
|
|
269
|
+
if (!this.redis || this.stopping) return;
|
|
270
|
+
try {
|
|
271
|
+
const now = Date.now();
|
|
272
|
+
const entries = await this.redis.zrangebyscore(
|
|
273
|
+
this.options.delayedSetKey,
|
|
274
|
+
"-inf",
|
|
275
|
+
now
|
|
276
|
+
);
|
|
277
|
+
if (entries.length === 0) return;
|
|
278
|
+
for (const entryJson of entries) {
|
|
279
|
+
try {
|
|
280
|
+
const entry = JSON.parse(entryJson);
|
|
281
|
+
await this.addToStream(entry.streamKey, entry.envelope);
|
|
282
|
+
await this.redis.zrem(this.options.delayedSetKey, entryJson);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.error("[RedisTransport] Error processing delayed message:", error);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} catch (error) {
|
|
288
|
+
console.error("[RedisTransport] Error in delayed message poll:", error);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async claimPendingMessages() {
|
|
292
|
+
if (!this.redis || !this.options.consumerGroup || this.stopping) return;
|
|
293
|
+
for (const subscription of this.subscriptions) {
|
|
294
|
+
try {
|
|
295
|
+
const pending = await this.redis.xpending(
|
|
296
|
+
subscription.streamKey,
|
|
297
|
+
this.options.consumerGroup,
|
|
298
|
+
"-",
|
|
299
|
+
"+",
|
|
300
|
+
10
|
|
301
|
+
// Max entries to check
|
|
302
|
+
);
|
|
303
|
+
if (!Array.isArray(pending) || pending.length === 0) continue;
|
|
304
|
+
for (const entry of pending) {
|
|
305
|
+
if (!Array.isArray(entry) || entry.length < 4) continue;
|
|
306
|
+
const [messageId, , idleTime] = entry;
|
|
307
|
+
if (idleTime < this.options.minIdleTimeMs) continue;
|
|
308
|
+
try {
|
|
309
|
+
const claimed = await this.redis.xclaim(
|
|
310
|
+
subscription.streamKey,
|
|
311
|
+
this.options.consumerGroup,
|
|
312
|
+
this.options.consumerName,
|
|
313
|
+
this.options.minIdleTimeMs,
|
|
314
|
+
messageId
|
|
315
|
+
);
|
|
316
|
+
if (claimed && claimed.length > 0) {
|
|
317
|
+
const [claimedId, fields] = claimed[0];
|
|
318
|
+
await this.processMessage(
|
|
319
|
+
subscription.streamKey,
|
|
320
|
+
claimedId,
|
|
321
|
+
fields,
|
|
322
|
+
subscription
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
} catch (error) {
|
|
326
|
+
console.error("[RedisTransport] Error claiming message:", error);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error("[RedisTransport] Error in pending claim:", error);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
336
|
+
0 && (module.exports = {
|
|
337
|
+
RedisTransport
|
|
338
|
+
});
|
|
339
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/RedisTransport.ts"],"sourcesContent":["export { RedisTransport } from \"./RedisTransport.js\";\nexport type { RedisTransportOptions } from \"./types.js\";\n","import { Redis } from \"ioredis\";\nimport { randomUUID } from \"crypto\";\nimport {\n type Transport,\n type TransportSubscribeOptions,\n type TransportPublishOptions,\n type MessageEnvelope,\n type BaseMessage,\n} from \"@saga-bus/core\";\nimport type {\n RedisTransportOptions,\n StreamSubscription,\n DelayedMessageEntry,\n} from \"./types.js\";\n\n/**\n * Redis Streams transport implementation for saga-bus.\n *\n * Uses Redis Streams (XADD/XREADGROUP) for message delivery with:\n * - Consumer groups for competing consumers\n * - Message acknowledgment (XACK)\n * - Delayed messages via sorted sets (ZADD/ZRANGEBYSCORE)\n * - Pending message claiming (XCLAIM) for recovery\n *\n * @example\n * ```typescript\n * import Redis from \"ioredis\";\n * import { RedisTransport } from \"@saga-bus/transport-redis\";\n *\n * const transport = new RedisTransport({\n * redis: new Redis(),\n * consumerGroup: \"order-processor\",\n * });\n *\n * await transport.start();\n * ```\n */\nexport class RedisTransport implements Transport {\n private redis: Redis | null = null;\n private subscriberRedis: Redis | null = null;\n private readonly options: Required<\n Pick<\n RedisTransportOptions,\n | \"keyPrefix\"\n | \"consumerGroup\"\n | \"consumerName\"\n | \"autoCreateGroup\"\n | \"batchSize\"\n | \"blockTimeoutMs\"\n | \"maxStreamLength\"\n | \"approximateMaxLen\"\n | \"delayedPollIntervalMs\"\n | \"delayedSetKey\"\n | \"pendingClaimIntervalMs\"\n | \"minIdleTimeMs\"\n >\n > &\n RedisTransportOptions;\n\n private readonly subscriptions: StreamSubscription[] = [];\n private started = false;\n private stopping = false;\n private readLoopPromise: Promise<void> | null = null;\n private delayedPollInterval: ReturnType<typeof setInterval> | null = null;\n private pendingClaimInterval: ReturnType<typeof setInterval> | null = null;\n\n constructor(options: RedisTransportOptions) {\n if (!options.redis && !options.connection) {\n throw new Error(\"Either redis client or connection options must be provided\");\n }\n\n this.options = {\n keyPrefix: \"saga-bus:\",\n consumerGroup: \"\",\n consumerName: `consumer-${randomUUID()}`,\n autoCreateGroup: true,\n batchSize: 10,\n blockTimeoutMs: 5000,\n maxStreamLength: 0,\n approximateMaxLen: true,\n delayedPollIntervalMs: 1000,\n delayedSetKey: \"saga-bus:delayed\",\n pendingClaimIntervalMs: 30000,\n minIdleTimeMs: 60000,\n ...options,\n };\n }\n\n async start(): Promise<void> {\n if (this.started) return;\n\n // Create Redis clients\n if (this.options.redis) {\n this.redis = this.options.redis;\n // Create a separate connection for blocking reads\n this.subscriberRedis = this.options.redis.duplicate();\n } else if (this.options.connection) {\n this.redis = new Redis(this.options.connection);\n this.subscriberRedis = new Redis(this.options.connection);\n } else {\n throw new Error(\"Invalid configuration\");\n }\n\n // Create consumer groups for all subscribed streams\n if (this.options.autoCreateGroup && this.options.consumerGroup) {\n for (const sub of this.subscriptions) {\n await this.ensureConsumerGroup(sub.streamKey);\n }\n }\n\n this.started = true;\n\n // Start the read loop if we have subscriptions\n if (this.subscriptions.length > 0) {\n this.readLoopPromise = this.readLoop();\n }\n\n // Start delayed message polling\n if (this.options.delayedPollIntervalMs > 0) {\n this.delayedPollInterval = setInterval(\n () => void this.processDelayedMessages(),\n this.options.delayedPollIntervalMs\n );\n }\n\n // Start pending message claiming\n if (this.options.pendingClaimIntervalMs > 0) {\n this.pendingClaimInterval = setInterval(\n () => void this.claimPendingMessages(),\n this.options.pendingClaimIntervalMs\n );\n }\n }\n\n async stop(): Promise<void> {\n if (!this.started || this.stopping) return;\n this.stopping = true;\n\n // Stop polling intervals\n if (this.delayedPollInterval) {\n clearInterval(this.delayedPollInterval);\n this.delayedPollInterval = null;\n }\n\n if (this.pendingClaimInterval) {\n clearInterval(this.pendingClaimInterval);\n this.pendingClaimInterval = null;\n }\n\n // Wait for read loop to finish\n if (this.readLoopPromise) {\n await this.readLoopPromise;\n this.readLoopPromise = null;\n }\n\n // Close subscriber connection\n if (this.subscriberRedis && this.subscriberRedis !== this.options.redis) {\n await this.subscriberRedis.quit();\n }\n this.subscriberRedis = null;\n\n // Close main connection if we created it\n if (this.redis && !this.options.redis) {\n await this.redis.quit();\n }\n this.redis = null;\n\n this.started = false;\n this.stopping = false;\n }\n\n async subscribe<TMessage extends BaseMessage>(\n options: TransportSubscribeOptions,\n handler: (envelope: MessageEnvelope<TMessage>) => Promise<void>\n ): Promise<void> {\n const { endpoint, concurrency = 1 } = options;\n const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;\n\n const subscription: StreamSubscription = {\n streamKey,\n handler: handler as (envelope: unknown) => Promise<void>,\n concurrency,\n };\n\n this.subscriptions.push(subscription);\n\n // If already started, create consumer group and restart read loop\n if (this.started && this.redis) {\n await this.ensureConsumerGroup(streamKey);\n\n // Restart read loop with new subscription\n if (!this.readLoopPromise) {\n this.readLoopPromise = this.readLoop();\n }\n }\n }\n\n async publish<TMessage extends BaseMessage>(\n message: TMessage,\n options: TransportPublishOptions\n ): Promise<void> {\n if (!this.redis) {\n throw new Error(\"Transport not started\");\n }\n\n const { endpoint, key, headers = {}, delayMs } = options;\n const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;\n\n // Create message envelope\n const envelope: MessageEnvelope<TMessage> = {\n id: randomUUID(),\n type: message.type,\n payload: message,\n headers: headers as Record<string, string>,\n timestamp: new Date(),\n partitionKey: key,\n };\n\n const envelopeJson = JSON.stringify(envelope);\n\n // Handle delayed delivery\n if (delayMs && delayMs > 0) {\n const deliverAt = Date.now() + delayMs;\n const delayedEntry: DelayedMessageEntry = {\n streamKey,\n envelope: envelopeJson,\n deliverAt,\n };\n\n // Store in sorted set with score = delivery timestamp\n await this.redis.zadd(\n this.options.delayedSetKey,\n deliverAt,\n JSON.stringify(delayedEntry)\n );\n return;\n }\n\n // Immediate delivery via stream\n await this.addToStream(streamKey, envelopeJson);\n }\n\n private async addToStream(streamKey: string, envelopeJson: string): Promise<void> {\n if (!this.redis) return;\n\n const args: (string | number)[] = [streamKey];\n\n // Add MAXLEN if configured\n if (this.options.maxStreamLength > 0) {\n if (this.options.approximateMaxLen) {\n args.push(\"MAXLEN\", \"~\", this.options.maxStreamLength);\n } else {\n args.push(\"MAXLEN\", this.options.maxStreamLength);\n }\n }\n\n args.push(\"*\", \"data\", envelopeJson);\n\n await this.redis.xadd(...(args as [string, ...Array<string | number>]));\n }\n\n private async ensureConsumerGroup(streamKey: string): Promise<void> {\n if (!this.redis || !this.options.consumerGroup) return;\n\n try {\n // Create stream with empty entry if it doesn't exist, then create group\n await this.redis.xgroup(\n \"CREATE\",\n streamKey,\n this.options.consumerGroup,\n \"0\",\n \"MKSTREAM\"\n );\n } catch (error) {\n // Ignore \"BUSYGROUP Consumer Group name already exists\" error\n if (\n error instanceof Error &&\n !error.message.includes(\"BUSYGROUP\")\n ) {\n throw error;\n }\n }\n }\n\n private async readLoop(): Promise<void> {\n if (!this.subscriberRedis || !this.options.consumerGroup) return;\n\n const streams = this.subscriptions.map((s) => s.streamKey);\n const ids = streams.map(() => \">\"); // Only new messages\n\n while (this.started && !this.stopping) {\n try {\n const results = await this.subscriberRedis.xreadgroup(\n \"GROUP\",\n this.options.consumerGroup,\n this.options.consumerName,\n \"COUNT\",\n this.options.batchSize,\n \"BLOCK\",\n this.options.blockTimeoutMs,\n \"STREAMS\",\n ...streams,\n ...ids\n );\n\n if (!results) continue;\n\n // Process messages - results is Array<[streamKey, messages]>\n const typedResults = results as Array<\n [string, Array<[string, string[]]>]\n >;\n for (const [streamKey, messages] of typedResults) {\n const subscription = this.subscriptions.find(\n (s) => s.streamKey === streamKey\n );\n if (!subscription) continue;\n\n for (const [messageId, fields] of messages) {\n await this.processMessage(\n streamKey,\n messageId,\n fields,\n subscription\n );\n }\n }\n } catch (error) {\n if (!this.stopping) {\n console.error(\"[RedisTransport] Read loop error:\", error);\n // Brief pause before retrying\n await new Promise((resolve) => setTimeout(resolve, 1000));\n }\n }\n }\n }\n\n private async processMessage(\n streamKey: string,\n messageId: string,\n fields: string[],\n subscription: StreamSubscription\n ): Promise<void> {\n if (!this.redis) return;\n\n try {\n // Parse fields array into object\n const data: Record<string, string> = {};\n for (let i = 0; i < fields.length; i += 2) {\n data[fields[i]!] = fields[i + 1]!;\n }\n\n if (!data.data) {\n console.error(\"[RedisTransport] Message missing data field:\", messageId);\n await this.acknowledgeMessage(streamKey, messageId);\n return;\n }\n\n const rawEnvelope = JSON.parse(data.data) as {\n id: string;\n type: string;\n payload: unknown;\n headers: Record<string, string>;\n timestamp: string | Date;\n partitionKey?: string;\n };\n\n // Reconstruct Date objects into proper envelope\n const envelope: MessageEnvelope = {\n id: rawEnvelope.id,\n type: rawEnvelope.type,\n payload: rawEnvelope.payload as BaseMessage,\n headers: rawEnvelope.headers,\n timestamp: new Date(rawEnvelope.timestamp),\n partitionKey: rawEnvelope.partitionKey,\n };\n\n await subscription.handler(envelope);\n\n // Acknowledge successful processing\n await this.acknowledgeMessage(streamKey, messageId);\n } catch (error) {\n console.error(\"[RedisTransport] Message processing error:\", error);\n // Don't acknowledge - message will be claimed by pending recovery\n }\n }\n\n private async acknowledgeMessage(\n streamKey: string,\n messageId: string\n ): Promise<void> {\n if (!this.redis || !this.options.consumerGroup) return;\n\n await this.redis.xack(streamKey, this.options.consumerGroup, messageId);\n }\n\n private async processDelayedMessages(): Promise<void> {\n if (!this.redis || this.stopping) return;\n\n try {\n const now = Date.now();\n\n // Get all messages due for delivery\n const entries = await this.redis.zrangebyscore(\n this.options.delayedSetKey,\n \"-inf\",\n now\n );\n\n if (entries.length === 0) return;\n\n // Process each delayed message\n for (const entryJson of entries) {\n try {\n const entry = JSON.parse(entryJson) as DelayedMessageEntry;\n\n // Add to the target stream\n await this.addToStream(entry.streamKey, entry.envelope);\n\n // Remove from delayed set\n await this.redis.zrem(this.options.delayedSetKey, entryJson);\n } catch (error) {\n console.error(\"[RedisTransport] Error processing delayed message:\", error);\n }\n }\n } catch (error) {\n console.error(\"[RedisTransport] Error in delayed message poll:\", error);\n }\n }\n\n private async claimPendingMessages(): Promise<void> {\n if (!this.redis || !this.options.consumerGroup || this.stopping) return;\n\n for (const subscription of this.subscriptions) {\n try {\n // Get pending messages for this stream\n const pending = await this.redis.xpending(\n subscription.streamKey,\n this.options.consumerGroup,\n \"-\",\n \"+\",\n 10 // Max entries to check\n );\n\n if (!Array.isArray(pending) || pending.length === 0) continue;\n\n for (const entry of pending) {\n if (!Array.isArray(entry) || entry.length < 4) continue;\n\n const [messageId, , idleTime] = entry as [string, string, number, number];\n\n // Only claim if idle time exceeds threshold\n if (idleTime < this.options.minIdleTimeMs) continue;\n\n try {\n // Claim the message\n const claimed = await this.redis.xclaim(\n subscription.streamKey,\n this.options.consumerGroup,\n this.options.consumerName,\n this.options.minIdleTimeMs,\n messageId\n );\n\n if (claimed && claimed.length > 0) {\n // Process the claimed message\n const [claimedId, fields] = claimed[0] as [string, string[]];\n await this.processMessage(\n subscription.streamKey,\n claimedId,\n fields,\n subscription\n );\n }\n } catch (error) {\n console.error(\"[RedisTransport] Error claiming message:\", error);\n }\n }\n } catch (error) {\n console.error(\"[RedisTransport] Error in pending claim:\", error);\n }\n }\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Transport, BaseMessage, TransportSubscribeOptions, MessageEnvelope, TransportPublishOptions } from '@saga-bus/core';
|
|
2
|
+
import { Redis, RedisOptions } from 'ioredis';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration options for Redis Streams transport.
|
|
6
|
+
*/
|
|
7
|
+
interface RedisTransportOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Redis client instance.
|
|
10
|
+
* Either redis or connection must be provided.
|
|
11
|
+
*/
|
|
12
|
+
redis?: Redis;
|
|
13
|
+
/**
|
|
14
|
+
* Redis connection options.
|
|
15
|
+
* Used to create a new Redis client if redis is not provided.
|
|
16
|
+
*/
|
|
17
|
+
connection?: RedisOptions;
|
|
18
|
+
/**
|
|
19
|
+
* Prefix for all stream keys.
|
|
20
|
+
* @default "saga-bus:"
|
|
21
|
+
*/
|
|
22
|
+
keyPrefix?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Consumer group name.
|
|
25
|
+
* Required for subscribing.
|
|
26
|
+
*/
|
|
27
|
+
consumerGroup?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Consumer name within the group.
|
|
30
|
+
* @default Auto-generated UUID
|
|
31
|
+
*/
|
|
32
|
+
consumerName?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Whether to create consumer groups automatically.
|
|
35
|
+
* @default true
|
|
36
|
+
*/
|
|
37
|
+
autoCreateGroup?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Maximum number of messages to fetch per read.
|
|
40
|
+
* @default 10
|
|
41
|
+
*/
|
|
42
|
+
batchSize?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Block timeout in milliseconds when waiting for messages.
|
|
45
|
+
* @default 5000
|
|
46
|
+
*/
|
|
47
|
+
blockTimeoutMs?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Maximum stream length (MAXLEN for XADD).
|
|
50
|
+
* Set to 0 for unlimited.
|
|
51
|
+
* @default 0
|
|
52
|
+
*/
|
|
53
|
+
maxStreamLength?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Whether to use approximate MAXLEN (~).
|
|
56
|
+
* More efficient but less precise.
|
|
57
|
+
* @default true
|
|
58
|
+
*/
|
|
59
|
+
approximateMaxLen?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* How often to check for delayed messages in milliseconds.
|
|
62
|
+
* @default 1000
|
|
63
|
+
*/
|
|
64
|
+
delayedPollIntervalMs?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Key for the delayed messages sorted set.
|
|
67
|
+
* @default "saga-bus:delayed"
|
|
68
|
+
*/
|
|
69
|
+
delayedSetKey?: string;
|
|
70
|
+
/**
|
|
71
|
+
* How often to claim pending messages in milliseconds.
|
|
72
|
+
* Set to 0 to disable.
|
|
73
|
+
* @default 30000
|
|
74
|
+
*/
|
|
75
|
+
pendingClaimIntervalMs?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Minimum idle time before claiming a pending message in milliseconds.
|
|
78
|
+
* @default 60000
|
|
79
|
+
*/
|
|
80
|
+
minIdleTimeMs?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Redis Streams transport implementation for saga-bus.
|
|
85
|
+
*
|
|
86
|
+
* Uses Redis Streams (XADD/XREADGROUP) for message delivery with:
|
|
87
|
+
* - Consumer groups for competing consumers
|
|
88
|
+
* - Message acknowledgment (XACK)
|
|
89
|
+
* - Delayed messages via sorted sets (ZADD/ZRANGEBYSCORE)
|
|
90
|
+
* - Pending message claiming (XCLAIM) for recovery
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```typescript
|
|
94
|
+
* import Redis from "ioredis";
|
|
95
|
+
* import { RedisTransport } from "@saga-bus/transport-redis";
|
|
96
|
+
*
|
|
97
|
+
* const transport = new RedisTransport({
|
|
98
|
+
* redis: new Redis(),
|
|
99
|
+
* consumerGroup: "order-processor",
|
|
100
|
+
* });
|
|
101
|
+
*
|
|
102
|
+
* await transport.start();
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
declare class RedisTransport implements Transport {
|
|
106
|
+
private redis;
|
|
107
|
+
private subscriberRedis;
|
|
108
|
+
private readonly options;
|
|
109
|
+
private readonly subscriptions;
|
|
110
|
+
private started;
|
|
111
|
+
private stopping;
|
|
112
|
+
private readLoopPromise;
|
|
113
|
+
private delayedPollInterval;
|
|
114
|
+
private pendingClaimInterval;
|
|
115
|
+
constructor(options: RedisTransportOptions);
|
|
116
|
+
start(): Promise<void>;
|
|
117
|
+
stop(): Promise<void>;
|
|
118
|
+
subscribe<TMessage extends BaseMessage>(options: TransportSubscribeOptions, handler: (envelope: MessageEnvelope<TMessage>) => Promise<void>): Promise<void>;
|
|
119
|
+
publish<TMessage extends BaseMessage>(message: TMessage, options: TransportPublishOptions): Promise<void>;
|
|
120
|
+
private addToStream;
|
|
121
|
+
private ensureConsumerGroup;
|
|
122
|
+
private readLoop;
|
|
123
|
+
private processMessage;
|
|
124
|
+
private acknowledgeMessage;
|
|
125
|
+
private processDelayedMessages;
|
|
126
|
+
private claimPendingMessages;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { RedisTransport, type RedisTransportOptions };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Transport, BaseMessage, TransportSubscribeOptions, MessageEnvelope, TransportPublishOptions } from '@saga-bus/core';
|
|
2
|
+
import { Redis, RedisOptions } from 'ioredis';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration options for Redis Streams transport.
|
|
6
|
+
*/
|
|
7
|
+
interface RedisTransportOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Redis client instance.
|
|
10
|
+
* Either redis or connection must be provided.
|
|
11
|
+
*/
|
|
12
|
+
redis?: Redis;
|
|
13
|
+
/**
|
|
14
|
+
* Redis connection options.
|
|
15
|
+
* Used to create a new Redis client if redis is not provided.
|
|
16
|
+
*/
|
|
17
|
+
connection?: RedisOptions;
|
|
18
|
+
/**
|
|
19
|
+
* Prefix for all stream keys.
|
|
20
|
+
* @default "saga-bus:"
|
|
21
|
+
*/
|
|
22
|
+
keyPrefix?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Consumer group name.
|
|
25
|
+
* Required for subscribing.
|
|
26
|
+
*/
|
|
27
|
+
consumerGroup?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Consumer name within the group.
|
|
30
|
+
* @default Auto-generated UUID
|
|
31
|
+
*/
|
|
32
|
+
consumerName?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Whether to create consumer groups automatically.
|
|
35
|
+
* @default true
|
|
36
|
+
*/
|
|
37
|
+
autoCreateGroup?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Maximum number of messages to fetch per read.
|
|
40
|
+
* @default 10
|
|
41
|
+
*/
|
|
42
|
+
batchSize?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Block timeout in milliseconds when waiting for messages.
|
|
45
|
+
* @default 5000
|
|
46
|
+
*/
|
|
47
|
+
blockTimeoutMs?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Maximum stream length (MAXLEN for XADD).
|
|
50
|
+
* Set to 0 for unlimited.
|
|
51
|
+
* @default 0
|
|
52
|
+
*/
|
|
53
|
+
maxStreamLength?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Whether to use approximate MAXLEN (~).
|
|
56
|
+
* More efficient but less precise.
|
|
57
|
+
* @default true
|
|
58
|
+
*/
|
|
59
|
+
approximateMaxLen?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* How often to check for delayed messages in milliseconds.
|
|
62
|
+
* @default 1000
|
|
63
|
+
*/
|
|
64
|
+
delayedPollIntervalMs?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Key for the delayed messages sorted set.
|
|
67
|
+
* @default "saga-bus:delayed"
|
|
68
|
+
*/
|
|
69
|
+
delayedSetKey?: string;
|
|
70
|
+
/**
|
|
71
|
+
* How often to claim pending messages in milliseconds.
|
|
72
|
+
* Set to 0 to disable.
|
|
73
|
+
* @default 30000
|
|
74
|
+
*/
|
|
75
|
+
pendingClaimIntervalMs?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Minimum idle time before claiming a pending message in milliseconds.
|
|
78
|
+
* @default 60000
|
|
79
|
+
*/
|
|
80
|
+
minIdleTimeMs?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Redis Streams transport implementation for saga-bus.
|
|
85
|
+
*
|
|
86
|
+
* Uses Redis Streams (XADD/XREADGROUP) for message delivery with:
|
|
87
|
+
* - Consumer groups for competing consumers
|
|
88
|
+
* - Message acknowledgment (XACK)
|
|
89
|
+
* - Delayed messages via sorted sets (ZADD/ZRANGEBYSCORE)
|
|
90
|
+
* - Pending message claiming (XCLAIM) for recovery
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```typescript
|
|
94
|
+
* import Redis from "ioredis";
|
|
95
|
+
* import { RedisTransport } from "@saga-bus/transport-redis";
|
|
96
|
+
*
|
|
97
|
+
* const transport = new RedisTransport({
|
|
98
|
+
* redis: new Redis(),
|
|
99
|
+
* consumerGroup: "order-processor",
|
|
100
|
+
* });
|
|
101
|
+
*
|
|
102
|
+
* await transport.start();
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
declare class RedisTransport implements Transport {
|
|
106
|
+
private redis;
|
|
107
|
+
private subscriberRedis;
|
|
108
|
+
private readonly options;
|
|
109
|
+
private readonly subscriptions;
|
|
110
|
+
private started;
|
|
111
|
+
private stopping;
|
|
112
|
+
private readLoopPromise;
|
|
113
|
+
private delayedPollInterval;
|
|
114
|
+
private pendingClaimInterval;
|
|
115
|
+
constructor(options: RedisTransportOptions);
|
|
116
|
+
start(): Promise<void>;
|
|
117
|
+
stop(): Promise<void>;
|
|
118
|
+
subscribe<TMessage extends BaseMessage>(options: TransportSubscribeOptions, handler: (envelope: MessageEnvelope<TMessage>) => Promise<void>): Promise<void>;
|
|
119
|
+
publish<TMessage extends BaseMessage>(message: TMessage, options: TransportPublishOptions): Promise<void>;
|
|
120
|
+
private addToStream;
|
|
121
|
+
private ensureConsumerGroup;
|
|
122
|
+
private readLoop;
|
|
123
|
+
private processMessage;
|
|
124
|
+
private acknowledgeMessage;
|
|
125
|
+
private processDelayedMessages;
|
|
126
|
+
private claimPendingMessages;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { RedisTransport, type RedisTransportOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// src/RedisTransport.ts
|
|
2
|
+
import { Redis } from "ioredis";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
var RedisTransport = class {
|
|
5
|
+
redis = null;
|
|
6
|
+
subscriberRedis = null;
|
|
7
|
+
options;
|
|
8
|
+
subscriptions = [];
|
|
9
|
+
started = false;
|
|
10
|
+
stopping = false;
|
|
11
|
+
readLoopPromise = null;
|
|
12
|
+
delayedPollInterval = null;
|
|
13
|
+
pendingClaimInterval = null;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
if (!options.redis && !options.connection) {
|
|
16
|
+
throw new Error("Either redis client or connection options must be provided");
|
|
17
|
+
}
|
|
18
|
+
this.options = {
|
|
19
|
+
keyPrefix: "saga-bus:",
|
|
20
|
+
consumerGroup: "",
|
|
21
|
+
consumerName: `consumer-${randomUUID()}`,
|
|
22
|
+
autoCreateGroup: true,
|
|
23
|
+
batchSize: 10,
|
|
24
|
+
blockTimeoutMs: 5e3,
|
|
25
|
+
maxStreamLength: 0,
|
|
26
|
+
approximateMaxLen: true,
|
|
27
|
+
delayedPollIntervalMs: 1e3,
|
|
28
|
+
delayedSetKey: "saga-bus:delayed",
|
|
29
|
+
pendingClaimIntervalMs: 3e4,
|
|
30
|
+
minIdleTimeMs: 6e4,
|
|
31
|
+
...options
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async start() {
|
|
35
|
+
if (this.started) return;
|
|
36
|
+
if (this.options.redis) {
|
|
37
|
+
this.redis = this.options.redis;
|
|
38
|
+
this.subscriberRedis = this.options.redis.duplicate();
|
|
39
|
+
} else if (this.options.connection) {
|
|
40
|
+
this.redis = new Redis(this.options.connection);
|
|
41
|
+
this.subscriberRedis = new Redis(this.options.connection);
|
|
42
|
+
} else {
|
|
43
|
+
throw new Error("Invalid configuration");
|
|
44
|
+
}
|
|
45
|
+
if (this.options.autoCreateGroup && this.options.consumerGroup) {
|
|
46
|
+
for (const sub of this.subscriptions) {
|
|
47
|
+
await this.ensureConsumerGroup(sub.streamKey);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
this.started = true;
|
|
51
|
+
if (this.subscriptions.length > 0) {
|
|
52
|
+
this.readLoopPromise = this.readLoop();
|
|
53
|
+
}
|
|
54
|
+
if (this.options.delayedPollIntervalMs > 0) {
|
|
55
|
+
this.delayedPollInterval = setInterval(
|
|
56
|
+
() => void this.processDelayedMessages(),
|
|
57
|
+
this.options.delayedPollIntervalMs
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (this.options.pendingClaimIntervalMs > 0) {
|
|
61
|
+
this.pendingClaimInterval = setInterval(
|
|
62
|
+
() => void this.claimPendingMessages(),
|
|
63
|
+
this.options.pendingClaimIntervalMs
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async stop() {
|
|
68
|
+
if (!this.started || this.stopping) return;
|
|
69
|
+
this.stopping = true;
|
|
70
|
+
if (this.delayedPollInterval) {
|
|
71
|
+
clearInterval(this.delayedPollInterval);
|
|
72
|
+
this.delayedPollInterval = null;
|
|
73
|
+
}
|
|
74
|
+
if (this.pendingClaimInterval) {
|
|
75
|
+
clearInterval(this.pendingClaimInterval);
|
|
76
|
+
this.pendingClaimInterval = null;
|
|
77
|
+
}
|
|
78
|
+
if (this.readLoopPromise) {
|
|
79
|
+
await this.readLoopPromise;
|
|
80
|
+
this.readLoopPromise = null;
|
|
81
|
+
}
|
|
82
|
+
if (this.subscriberRedis && this.subscriberRedis !== this.options.redis) {
|
|
83
|
+
await this.subscriberRedis.quit();
|
|
84
|
+
}
|
|
85
|
+
this.subscriberRedis = null;
|
|
86
|
+
if (this.redis && !this.options.redis) {
|
|
87
|
+
await this.redis.quit();
|
|
88
|
+
}
|
|
89
|
+
this.redis = null;
|
|
90
|
+
this.started = false;
|
|
91
|
+
this.stopping = false;
|
|
92
|
+
}
|
|
93
|
+
async subscribe(options, handler) {
|
|
94
|
+
const { endpoint, concurrency = 1 } = options;
|
|
95
|
+
const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;
|
|
96
|
+
const subscription = {
|
|
97
|
+
streamKey,
|
|
98
|
+
handler,
|
|
99
|
+
concurrency
|
|
100
|
+
};
|
|
101
|
+
this.subscriptions.push(subscription);
|
|
102
|
+
if (this.started && this.redis) {
|
|
103
|
+
await this.ensureConsumerGroup(streamKey);
|
|
104
|
+
if (!this.readLoopPromise) {
|
|
105
|
+
this.readLoopPromise = this.readLoop();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async publish(message, options) {
|
|
110
|
+
if (!this.redis) {
|
|
111
|
+
throw new Error("Transport not started");
|
|
112
|
+
}
|
|
113
|
+
const { endpoint, key, headers = {}, delayMs } = options;
|
|
114
|
+
const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;
|
|
115
|
+
const envelope = {
|
|
116
|
+
id: randomUUID(),
|
|
117
|
+
type: message.type,
|
|
118
|
+
payload: message,
|
|
119
|
+
headers,
|
|
120
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
121
|
+
partitionKey: key
|
|
122
|
+
};
|
|
123
|
+
const envelopeJson = JSON.stringify(envelope);
|
|
124
|
+
if (delayMs && delayMs > 0) {
|
|
125
|
+
const deliverAt = Date.now() + delayMs;
|
|
126
|
+
const delayedEntry = {
|
|
127
|
+
streamKey,
|
|
128
|
+
envelope: envelopeJson,
|
|
129
|
+
deliverAt
|
|
130
|
+
};
|
|
131
|
+
await this.redis.zadd(
|
|
132
|
+
this.options.delayedSetKey,
|
|
133
|
+
deliverAt,
|
|
134
|
+
JSON.stringify(delayedEntry)
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
await this.addToStream(streamKey, envelopeJson);
|
|
139
|
+
}
|
|
140
|
+
async addToStream(streamKey, envelopeJson) {
|
|
141
|
+
if (!this.redis) return;
|
|
142
|
+
const args = [streamKey];
|
|
143
|
+
if (this.options.maxStreamLength > 0) {
|
|
144
|
+
if (this.options.approximateMaxLen) {
|
|
145
|
+
args.push("MAXLEN", "~", this.options.maxStreamLength);
|
|
146
|
+
} else {
|
|
147
|
+
args.push("MAXLEN", this.options.maxStreamLength);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
args.push("*", "data", envelopeJson);
|
|
151
|
+
await this.redis.xadd(...args);
|
|
152
|
+
}
|
|
153
|
+
async ensureConsumerGroup(streamKey) {
|
|
154
|
+
if (!this.redis || !this.options.consumerGroup) return;
|
|
155
|
+
try {
|
|
156
|
+
await this.redis.xgroup(
|
|
157
|
+
"CREATE",
|
|
158
|
+
streamKey,
|
|
159
|
+
this.options.consumerGroup,
|
|
160
|
+
"0",
|
|
161
|
+
"MKSTREAM"
|
|
162
|
+
);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error instanceof Error && !error.message.includes("BUSYGROUP")) {
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async readLoop() {
|
|
170
|
+
if (!this.subscriberRedis || !this.options.consumerGroup) return;
|
|
171
|
+
const streams = this.subscriptions.map((s) => s.streamKey);
|
|
172
|
+
const ids = streams.map(() => ">");
|
|
173
|
+
while (this.started && !this.stopping) {
|
|
174
|
+
try {
|
|
175
|
+
const results = await this.subscriberRedis.xreadgroup(
|
|
176
|
+
"GROUP",
|
|
177
|
+
this.options.consumerGroup,
|
|
178
|
+
this.options.consumerName,
|
|
179
|
+
"COUNT",
|
|
180
|
+
this.options.batchSize,
|
|
181
|
+
"BLOCK",
|
|
182
|
+
this.options.blockTimeoutMs,
|
|
183
|
+
"STREAMS",
|
|
184
|
+
...streams,
|
|
185
|
+
...ids
|
|
186
|
+
);
|
|
187
|
+
if (!results) continue;
|
|
188
|
+
const typedResults = results;
|
|
189
|
+
for (const [streamKey, messages] of typedResults) {
|
|
190
|
+
const subscription = this.subscriptions.find(
|
|
191
|
+
(s) => s.streamKey === streamKey
|
|
192
|
+
);
|
|
193
|
+
if (!subscription) continue;
|
|
194
|
+
for (const [messageId, fields] of messages) {
|
|
195
|
+
await this.processMessage(
|
|
196
|
+
streamKey,
|
|
197
|
+
messageId,
|
|
198
|
+
fields,
|
|
199
|
+
subscription
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (!this.stopping) {
|
|
205
|
+
console.error("[RedisTransport] Read loop error:", error);
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async processMessage(streamKey, messageId, fields, subscription) {
|
|
212
|
+
if (!this.redis) return;
|
|
213
|
+
try {
|
|
214
|
+
const data = {};
|
|
215
|
+
for (let i = 0; i < fields.length; i += 2) {
|
|
216
|
+
data[fields[i]] = fields[i + 1];
|
|
217
|
+
}
|
|
218
|
+
if (!data.data) {
|
|
219
|
+
console.error("[RedisTransport] Message missing data field:", messageId);
|
|
220
|
+
await this.acknowledgeMessage(streamKey, messageId);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const rawEnvelope = JSON.parse(data.data);
|
|
224
|
+
const envelope = {
|
|
225
|
+
id: rawEnvelope.id,
|
|
226
|
+
type: rawEnvelope.type,
|
|
227
|
+
payload: rawEnvelope.payload,
|
|
228
|
+
headers: rawEnvelope.headers,
|
|
229
|
+
timestamp: new Date(rawEnvelope.timestamp),
|
|
230
|
+
partitionKey: rawEnvelope.partitionKey
|
|
231
|
+
};
|
|
232
|
+
await subscription.handler(envelope);
|
|
233
|
+
await this.acknowledgeMessage(streamKey, messageId);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
console.error("[RedisTransport] Message processing error:", error);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async acknowledgeMessage(streamKey, messageId) {
|
|
239
|
+
if (!this.redis || !this.options.consumerGroup) return;
|
|
240
|
+
await this.redis.xack(streamKey, this.options.consumerGroup, messageId);
|
|
241
|
+
}
|
|
242
|
+
async processDelayedMessages() {
|
|
243
|
+
if (!this.redis || this.stopping) return;
|
|
244
|
+
try {
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
const entries = await this.redis.zrangebyscore(
|
|
247
|
+
this.options.delayedSetKey,
|
|
248
|
+
"-inf",
|
|
249
|
+
now
|
|
250
|
+
);
|
|
251
|
+
if (entries.length === 0) return;
|
|
252
|
+
for (const entryJson of entries) {
|
|
253
|
+
try {
|
|
254
|
+
const entry = JSON.parse(entryJson);
|
|
255
|
+
await this.addToStream(entry.streamKey, entry.envelope);
|
|
256
|
+
await this.redis.zrem(this.options.delayedSetKey, entryJson);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
console.error("[RedisTransport] Error processing delayed message:", error);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
console.error("[RedisTransport] Error in delayed message poll:", error);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async claimPendingMessages() {
|
|
266
|
+
if (!this.redis || !this.options.consumerGroup || this.stopping) return;
|
|
267
|
+
for (const subscription of this.subscriptions) {
|
|
268
|
+
try {
|
|
269
|
+
const pending = await this.redis.xpending(
|
|
270
|
+
subscription.streamKey,
|
|
271
|
+
this.options.consumerGroup,
|
|
272
|
+
"-",
|
|
273
|
+
"+",
|
|
274
|
+
10
|
|
275
|
+
// Max entries to check
|
|
276
|
+
);
|
|
277
|
+
if (!Array.isArray(pending) || pending.length === 0) continue;
|
|
278
|
+
for (const entry of pending) {
|
|
279
|
+
if (!Array.isArray(entry) || entry.length < 4) continue;
|
|
280
|
+
const [messageId, , idleTime] = entry;
|
|
281
|
+
if (idleTime < this.options.minIdleTimeMs) continue;
|
|
282
|
+
try {
|
|
283
|
+
const claimed = await this.redis.xclaim(
|
|
284
|
+
subscription.streamKey,
|
|
285
|
+
this.options.consumerGroup,
|
|
286
|
+
this.options.consumerName,
|
|
287
|
+
this.options.minIdleTimeMs,
|
|
288
|
+
messageId
|
|
289
|
+
);
|
|
290
|
+
if (claimed && claimed.length > 0) {
|
|
291
|
+
const [claimedId, fields] = claimed[0];
|
|
292
|
+
await this.processMessage(
|
|
293
|
+
subscription.streamKey,
|
|
294
|
+
claimedId,
|
|
295
|
+
fields,
|
|
296
|
+
subscription
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.error("[RedisTransport] Error claiming message:", error);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} catch (error) {
|
|
304
|
+
console.error("[RedisTransport] Error in pending claim:", error);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
export {
|
|
310
|
+
RedisTransport
|
|
311
|
+
};
|
|
312
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/RedisTransport.ts"],"sourcesContent":["import { Redis } from \"ioredis\";\nimport { randomUUID } from \"crypto\";\nimport {\n type Transport,\n type TransportSubscribeOptions,\n type TransportPublishOptions,\n type MessageEnvelope,\n type BaseMessage,\n} from \"@saga-bus/core\";\nimport type {\n RedisTransportOptions,\n StreamSubscription,\n DelayedMessageEntry,\n} from \"./types.js\";\n\n/**\n * Redis Streams transport implementation for saga-bus.\n *\n * Uses Redis Streams (XADD/XREADGROUP) for message delivery with:\n * - Consumer groups for competing consumers\n * - Message acknowledgment (XACK)\n * - Delayed messages via sorted sets (ZADD/ZRANGEBYSCORE)\n * - Pending message claiming (XCLAIM) for recovery\n *\n * @example\n * ```typescript\n * import Redis from \"ioredis\";\n * import { RedisTransport } from \"@saga-bus/transport-redis\";\n *\n * const transport = new RedisTransport({\n * redis: new Redis(),\n * consumerGroup: \"order-processor\",\n * });\n *\n * await transport.start();\n * ```\n */\nexport class RedisTransport implements Transport {\n private redis: Redis | null = null;\n private subscriberRedis: Redis | null = null;\n private readonly options: Required<\n Pick<\n RedisTransportOptions,\n | \"keyPrefix\"\n | \"consumerGroup\"\n | \"consumerName\"\n | \"autoCreateGroup\"\n | \"batchSize\"\n | \"blockTimeoutMs\"\n | \"maxStreamLength\"\n | \"approximateMaxLen\"\n | \"delayedPollIntervalMs\"\n | \"delayedSetKey\"\n | \"pendingClaimIntervalMs\"\n | \"minIdleTimeMs\"\n >\n > &\n RedisTransportOptions;\n\n private readonly subscriptions: StreamSubscription[] = [];\n private started = false;\n private stopping = false;\n private readLoopPromise: Promise<void> | null = null;\n private delayedPollInterval: ReturnType<typeof setInterval> | null = null;\n private pendingClaimInterval: ReturnType<typeof setInterval> | null = null;\n\n constructor(options: RedisTransportOptions) {\n if (!options.redis && !options.connection) {\n throw new Error(\"Either redis client or connection options must be provided\");\n }\n\n this.options = {\n keyPrefix: \"saga-bus:\",\n consumerGroup: \"\",\n consumerName: `consumer-${randomUUID()}`,\n autoCreateGroup: true,\n batchSize: 10,\n blockTimeoutMs: 5000,\n maxStreamLength: 0,\n approximateMaxLen: true,\n delayedPollIntervalMs: 1000,\n delayedSetKey: \"saga-bus:delayed\",\n pendingClaimIntervalMs: 30000,\n minIdleTimeMs: 60000,\n ...options,\n };\n }\n\n async start(): Promise<void> {\n if (this.started) return;\n\n // Create Redis clients\n if (this.options.redis) {\n this.redis = this.options.redis;\n // Create a separate connection for blocking reads\n this.subscriberRedis = this.options.redis.duplicate();\n } else if (this.options.connection) {\n this.redis = new Redis(this.options.connection);\n this.subscriberRedis = new Redis(this.options.connection);\n } else {\n throw new Error(\"Invalid configuration\");\n }\n\n // Create consumer groups for all subscribed streams\n if (this.options.autoCreateGroup && this.options.consumerGroup) {\n for (const sub of this.subscriptions) {\n await this.ensureConsumerGroup(sub.streamKey);\n }\n }\n\n this.started = true;\n\n // Start the read loop if we have subscriptions\n if (this.subscriptions.length > 0) {\n this.readLoopPromise = this.readLoop();\n }\n\n // Start delayed message polling\n if (this.options.delayedPollIntervalMs > 0) {\n this.delayedPollInterval = setInterval(\n () => void this.processDelayedMessages(),\n this.options.delayedPollIntervalMs\n );\n }\n\n // Start pending message claiming\n if (this.options.pendingClaimIntervalMs > 0) {\n this.pendingClaimInterval = setInterval(\n () => void this.claimPendingMessages(),\n this.options.pendingClaimIntervalMs\n );\n }\n }\n\n async stop(): Promise<void> {\n if (!this.started || this.stopping) return;\n this.stopping = true;\n\n // Stop polling intervals\n if (this.delayedPollInterval) {\n clearInterval(this.delayedPollInterval);\n this.delayedPollInterval = null;\n }\n\n if (this.pendingClaimInterval) {\n clearInterval(this.pendingClaimInterval);\n this.pendingClaimInterval = null;\n }\n\n // Wait for read loop to finish\n if (this.readLoopPromise) {\n await this.readLoopPromise;\n this.readLoopPromise = null;\n }\n\n // Close subscriber connection\n if (this.subscriberRedis && this.subscriberRedis !== this.options.redis) {\n await this.subscriberRedis.quit();\n }\n this.subscriberRedis = null;\n\n // Close main connection if we created it\n if (this.redis && !this.options.redis) {\n await this.redis.quit();\n }\n this.redis = null;\n\n this.started = false;\n this.stopping = false;\n }\n\n async subscribe<TMessage extends BaseMessage>(\n options: TransportSubscribeOptions,\n handler: (envelope: MessageEnvelope<TMessage>) => Promise<void>\n ): Promise<void> {\n const { endpoint, concurrency = 1 } = options;\n const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;\n\n const subscription: StreamSubscription = {\n streamKey,\n handler: handler as (envelope: unknown) => Promise<void>,\n concurrency,\n };\n\n this.subscriptions.push(subscription);\n\n // If already started, create consumer group and restart read loop\n if (this.started && this.redis) {\n await this.ensureConsumerGroup(streamKey);\n\n // Restart read loop with new subscription\n if (!this.readLoopPromise) {\n this.readLoopPromise = this.readLoop();\n }\n }\n }\n\n async publish<TMessage extends BaseMessage>(\n message: TMessage,\n options: TransportPublishOptions\n ): Promise<void> {\n if (!this.redis) {\n throw new Error(\"Transport not started\");\n }\n\n const { endpoint, key, headers = {}, delayMs } = options;\n const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;\n\n // Create message envelope\n const envelope: MessageEnvelope<TMessage> = {\n id: randomUUID(),\n type: message.type,\n payload: message,\n headers: headers as Record<string, string>,\n timestamp: new Date(),\n partitionKey: key,\n };\n\n const envelopeJson = JSON.stringify(envelope);\n\n // Handle delayed delivery\n if (delayMs && delayMs > 0) {\n const deliverAt = Date.now() + delayMs;\n const delayedEntry: DelayedMessageEntry = {\n streamKey,\n envelope: envelopeJson,\n deliverAt,\n };\n\n // Store in sorted set with score = delivery timestamp\n await this.redis.zadd(\n this.options.delayedSetKey,\n deliverAt,\n JSON.stringify(delayedEntry)\n );\n return;\n }\n\n // Immediate delivery via stream\n await this.addToStream(streamKey, envelopeJson);\n }\n\n private async addToStream(streamKey: string, envelopeJson: string): Promise<void> {\n if (!this.redis) return;\n\n const args: (string | number)[] = [streamKey];\n\n // Add MAXLEN if configured\n if (this.options.maxStreamLength > 0) {\n if (this.options.approximateMaxLen) {\n args.push(\"MAXLEN\", \"~\", this.options.maxStreamLength);\n } else {\n args.push(\"MAXLEN\", this.options.maxStreamLength);\n }\n }\n\n args.push(\"*\", \"data\", envelopeJson);\n\n await this.redis.xadd(...(args as [string, ...Array<string | number>]));\n }\n\n private async ensureConsumerGroup(streamKey: string): Promise<void> {\n if (!this.redis || !this.options.consumerGroup) return;\n\n try {\n // Create stream with empty entry if it doesn't exist, then create group\n await this.redis.xgroup(\n \"CREATE\",\n streamKey,\n this.options.consumerGroup,\n \"0\",\n \"MKSTREAM\"\n );\n } catch (error) {\n // Ignore \"BUSYGROUP Consumer Group name already exists\" error\n if (\n error instanceof Error &&\n !error.message.includes(\"BUSYGROUP\")\n ) {\n throw error;\n }\n }\n }\n\n private async readLoop(): Promise<void> {\n if (!this.subscriberRedis || !this.options.consumerGroup) return;\n\n const streams = this.subscriptions.map((s) => s.streamKey);\n const ids = streams.map(() => \">\"); // Only new messages\n\n while (this.started && !this.stopping) {\n try {\n const results = await this.subscriberRedis.xreadgroup(\n \"GROUP\",\n this.options.consumerGroup,\n this.options.consumerName,\n \"COUNT\",\n this.options.batchSize,\n \"BLOCK\",\n this.options.blockTimeoutMs,\n \"STREAMS\",\n ...streams,\n ...ids\n );\n\n if (!results) continue;\n\n // Process messages - results is Array<[streamKey, messages]>\n const typedResults = results as Array<\n [string, Array<[string, string[]]>]\n >;\n for (const [streamKey, messages] of typedResults) {\n const subscription = this.subscriptions.find(\n (s) => s.streamKey === streamKey\n );\n if (!subscription) continue;\n\n for (const [messageId, fields] of messages) {\n await this.processMessage(\n streamKey,\n messageId,\n fields,\n subscription\n );\n }\n }\n } catch (error) {\n if (!this.stopping) {\n console.error(\"[RedisTransport] Read loop error:\", error);\n // Brief pause before retrying\n await new Promise((resolve) => setTimeout(resolve, 1000));\n }\n }\n }\n }\n\n private async processMessage(\n streamKey: string,\n messageId: string,\n fields: string[],\n subscription: StreamSubscription\n ): Promise<void> {\n if (!this.redis) return;\n\n try {\n // Parse fields array into object\n const data: Record<string, string> = {};\n for (let i = 0; i < fields.length; i += 2) {\n data[fields[i]!] = fields[i + 1]!;\n }\n\n if (!data.data) {\n console.error(\"[RedisTransport] Message missing data field:\", messageId);\n await this.acknowledgeMessage(streamKey, messageId);\n return;\n }\n\n const rawEnvelope = JSON.parse(data.data) as {\n id: string;\n type: string;\n payload: unknown;\n headers: Record<string, string>;\n timestamp: string | Date;\n partitionKey?: string;\n };\n\n // Reconstruct Date objects into proper envelope\n const envelope: MessageEnvelope = {\n id: rawEnvelope.id,\n type: rawEnvelope.type,\n payload: rawEnvelope.payload as BaseMessage,\n headers: rawEnvelope.headers,\n timestamp: new Date(rawEnvelope.timestamp),\n partitionKey: rawEnvelope.partitionKey,\n };\n\n await subscription.handler(envelope);\n\n // Acknowledge successful processing\n await this.acknowledgeMessage(streamKey, messageId);\n } catch (error) {\n console.error(\"[RedisTransport] Message processing error:\", error);\n // Don't acknowledge - message will be claimed by pending recovery\n }\n }\n\n private async acknowledgeMessage(\n streamKey: string,\n messageId: string\n ): Promise<void> {\n if (!this.redis || !this.options.consumerGroup) return;\n\n await this.redis.xack(streamKey, this.options.consumerGroup, messageId);\n }\n\n private async processDelayedMessages(): Promise<void> {\n if (!this.redis || this.stopping) return;\n\n try {\n const now = Date.now();\n\n // Get all messages due for delivery\n const entries = await this.redis.zrangebyscore(\n this.options.delayedSetKey,\n \"-inf\",\n now\n );\n\n if (entries.length === 0) return;\n\n // Process each delayed message\n for (const entryJson of entries) {\n try {\n const entry = JSON.parse(entryJson) as DelayedMessageEntry;\n\n // Add to the target stream\n await this.addToStream(entry.streamKey, entry.envelope);\n\n // Remove from delayed set\n await this.redis.zrem(this.options.delayedSetKey, entryJson);\n } catch (error) {\n console.error(\"[RedisTransport] Error processing delayed message:\", error);\n }\n }\n } catch (error) {\n console.error(\"[RedisTransport] Error in delayed message poll:\", error);\n }\n }\n\n private async claimPendingMessages(): Promise<void> {\n if (!this.redis || !this.options.consumerGroup || this.stopping) return;\n\n for (const subscription of this.subscriptions) {\n try {\n // Get pending messages for this stream\n const pending = await this.redis.xpending(\n subscription.streamKey,\n this.options.consumerGroup,\n \"-\",\n \"+\",\n 10 // Max entries to check\n );\n\n if (!Array.isArray(pending) || pending.length === 0) continue;\n\n for (const entry of pending) {\n if (!Array.isArray(entry) || entry.length < 4) continue;\n\n const [messageId, , idleTime] = entry as [string, string, number, number];\n\n // Only claim if idle time exceeds threshold\n if (idleTime < this.options.minIdleTimeMs) continue;\n\n try {\n // Claim the message\n const claimed = await this.redis.xclaim(\n subscription.streamKey,\n this.options.consumerGroup,\n this.options.consumerName,\n this.options.minIdleTimeMs,\n messageId\n );\n\n if (claimed && claimed.length > 0) {\n // Process the claimed message\n const [claimedId, fields] = claimed[0] as [string, string[]];\n await this.processMessage(\n subscription.streamKey,\n claimedId,\n fields,\n subscription\n );\n }\n } catch (error) {\n console.error(\"[RedisTransport] Error claiming message:\", error);\n }\n }\n } catch (error) {\n console.error(\"[RedisTransport] Error in pending claim:\", error);\n }\n }\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saga-bus/transport-redis",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Redis Streams transport for saga-bus",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/d-e-a-n-f/saga-bus.git",
|
|
26
|
+
"directory": "packages/transport-redis"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"saga",
|
|
30
|
+
"message-bus",
|
|
31
|
+
"transport",
|
|
32
|
+
"redis",
|
|
33
|
+
"redis-streams"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup",
|
|
37
|
+
"dev": "tsup --watch",
|
|
38
|
+
"lint": "eslint src/",
|
|
39
|
+
"check-types": "tsc --noEmit",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:watch": "vitest"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@saga-bus/core": "workspace:*"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"ioredis": ">=5.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@repo/eslint-config": "workspace:*",
|
|
51
|
+
"@repo/typescript-config": "workspace:*",
|
|
52
|
+
"ioredis": "^5.4.2",
|
|
53
|
+
"tsup": "^8.0.0",
|
|
54
|
+
"typescript": "^5.9.2",
|
|
55
|
+
"vitest": "^3.0.0"
|
|
56
|
+
}
|
|
57
|
+
}
|