redis-consumer 1.1.10 → 1.2.0
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 +255 -116
- package/dist/client.d.ts +8 -7
- package/dist/client.js +30 -20
- package/dist/consumer.d.ts +61 -9
- package/dist/consumer.js +273 -163
- package/dist/helpers.d.ts +5 -2
- package/dist/helpers.js +59 -5
- package/dist/index.d.ts +5 -9
- package/dist/index.js +3 -22
- package/dist/producer.d.ts +4 -4
- package/dist/producer.js +2 -6
- package/dist/retry-processor.d.ts +14 -11
- package/dist/retry-processor.js +51 -22
- package/package.json +13 -8
package/dist/consumer.d.ts
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
|
-
import { RedisClientType, RedisScripts } from 'redis';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export
|
|
1
|
+
import type { RedisArgument, RedisClientType, RedisScripts } from 'redis';
|
|
2
|
+
import type { RedisClient } from './client.js';
|
|
3
|
+
export type StreamMessageId = string;
|
|
4
|
+
export type StreamMessageData = Record<string, RedisArgument>;
|
|
5
|
+
export interface StreamMessageReply<T = StreamMessageData> {
|
|
6
|
+
id: StreamMessageId;
|
|
6
7
|
message: T;
|
|
7
|
-
|
|
8
|
+
millisElapsedFromDelivery?: number;
|
|
9
|
+
deliveriesCounter?: number;
|
|
10
|
+
}
|
|
11
|
+
/** Called once per message, with that single message. */
|
|
8
12
|
export type StreamProcessingFunction<T> = (data: StreamMessageReply<T>, stream: string) => void;
|
|
13
|
+
/** Called once per read, with the whole array of messages of that read. */
|
|
14
|
+
export type StreamBatchProcessingFunction<T> = (messages: StreamMessageReply<T>[], stream: string) => void;
|
|
15
|
+
/**
|
|
16
|
+
* How the consumer holds an executable.
|
|
17
|
+
*
|
|
18
|
+
* Which of the two shapes above it is actually called with depends on the
|
|
19
|
+
* `batchMessages` option, so the payload is intentionally untyped here. Annotate
|
|
20
|
+
* your own function as `StreamProcessingFunction<T>` or
|
|
21
|
+
* `StreamBatchProcessingFunction<T>` to have its parameter checked.
|
|
22
|
+
*/
|
|
23
|
+
export type StreamExecutable = (payload: any, stream: string) => void;
|
|
9
24
|
export interface StreamToListen {
|
|
10
25
|
name: string;
|
|
11
|
-
executable:
|
|
26
|
+
executable: StreamExecutable;
|
|
12
27
|
id?: string;
|
|
13
28
|
}
|
|
14
29
|
export type StreamsToListen = StreamToListen[];
|
|
@@ -17,10 +32,15 @@ export interface ConsumerOptions {
|
|
|
17
32
|
BLOCK?: number;
|
|
18
33
|
retries?: number;
|
|
19
34
|
retryTime?: string[];
|
|
35
|
+
/**
|
|
36
|
+
* When `true`, every read hands the whole batch of messages to the
|
|
37
|
+
* executable as a single array instead of one call per message.
|
|
38
|
+
*/
|
|
39
|
+
batchMessages?: boolean;
|
|
20
40
|
}
|
|
21
41
|
export interface ProcessErrorData {
|
|
22
42
|
stream: string;
|
|
23
|
-
message:
|
|
43
|
+
message: StreamMessageReply;
|
|
24
44
|
retries: number;
|
|
25
45
|
}
|
|
26
46
|
export declare class RedisConsumer<S extends RedisScripts = RedisScripts> {
|
|
@@ -32,6 +52,14 @@ export declare class RedisConsumer<S extends RedisScripts = RedisScripts> {
|
|
|
32
52
|
private BLOCK;
|
|
33
53
|
private COUNT;
|
|
34
54
|
private RETRIES;
|
|
55
|
+
private batchMessages;
|
|
56
|
+
private ready;
|
|
57
|
+
private listenPromise;
|
|
58
|
+
private stopped;
|
|
59
|
+
/** Tail of the serialised acknowledgement flush chain. */
|
|
60
|
+
private ackFlush;
|
|
61
|
+
/** Aborts the read loop's error back-off so `close()` does not have to wait it out. */
|
|
62
|
+
private abort;
|
|
35
63
|
constructor(client: RedisClient<S>, options?: ConsumerOptions);
|
|
36
64
|
set block(block: number);
|
|
37
65
|
set count(count: number);
|
|
@@ -40,13 +68,37 @@ export declare class RedisConsumer<S extends RedisScripts = RedisScripts> {
|
|
|
40
68
|
block: number;
|
|
41
69
|
count: number;
|
|
42
70
|
};
|
|
43
|
-
listen(streams: StreamToListen | StreamsToListen): Promise<void>;
|
|
71
|
+
listen(streams: StreamToListen | StreamsToListen, flag?: boolean): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Stops reading and closes the consumer's connection, so the process can exit.
|
|
74
|
+
*/
|
|
75
|
+
close(): Promise<void>;
|
|
44
76
|
addAckMessage(stream: string, id: StreamMessageId): void;
|
|
77
|
+
/**
|
|
78
|
+
* Sends the queued acknowledgements (`XACK` + `XDEL`) to Redis.
|
|
79
|
+
*
|
|
80
|
+
* Call this after {@link addAckMessage} to make the acknowledgement leave the
|
|
81
|
+
* pending list right away instead of waiting for the read loop's next flush.
|
|
82
|
+
*
|
|
83
|
+
* Flushes are serialised, so the read loop and the retry processor can both
|
|
84
|
+
* call this without interleaving their `XACK`/`XDEL` batches. Ids are only
|
|
85
|
+
* dropped once Redis accepted them, so a failed flush is retried by the next
|
|
86
|
+
* one.
|
|
87
|
+
*/
|
|
88
|
+
flushAcks(): Promise<void>;
|
|
45
89
|
private listenForStreams;
|
|
46
90
|
private hasStreamState;
|
|
47
91
|
private getStreamState;
|
|
48
92
|
private initStreamState;
|
|
49
93
|
private readStreams;
|
|
94
|
+
/**
|
|
95
|
+
* Runs the executable for a single read and acknowledges its messages only
|
|
96
|
+
* when processing succeeded, so a failed message stays in the pending list.
|
|
97
|
+
*
|
|
98
|
+
* In batch mode `payload` is the whole array. A failed batch call cannot tell
|
|
99
|
+
* which message failed, so every message of the read is retried on its own.
|
|
100
|
+
*/
|
|
101
|
+
private handle;
|
|
50
102
|
private processStreamMessages;
|
|
51
103
|
private acknowlegdeMessages;
|
|
52
104
|
}
|
package/dist/consumer.js
CHANGED
|
@@ -1,164 +1,274 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
1
|
+
import { RetryProcessor } from './retry-processor.js';
|
|
2
|
+
import { timeout } from './helpers.js';
|
|
3
|
+
/** Delay before retrying a read that failed, to avoid a hot error loop. */
|
|
4
|
+
const LISTEN_ERROR_RETRY_DELAY = 1000;
|
|
5
|
+
/**
|
|
6
|
+
* Wraps a batch executable so that a single retried message is delivered to it
|
|
7
|
+
* as a one-item batch, keeping retry bookkeeping per message.
|
|
8
|
+
*/
|
|
9
|
+
function singleMessage(fnc) {
|
|
10
|
+
return (message, stream) => fnc([message], stream);
|
|
11
|
+
}
|
|
12
|
+
export class RedisConsumer {
|
|
13
|
+
client;
|
|
14
|
+
originalClient;
|
|
15
|
+
state;
|
|
16
|
+
retryProcessor;
|
|
17
|
+
successfullMessages = new Map();
|
|
18
|
+
BLOCK;
|
|
19
|
+
COUNT;
|
|
20
|
+
RETRIES;
|
|
21
|
+
batchMessages;
|
|
22
|
+
ready;
|
|
23
|
+
listenPromise = null;
|
|
24
|
+
stopped = false;
|
|
25
|
+
/** Tail of the serialised acknowledgement flush chain. */
|
|
26
|
+
ackFlush = Promise.resolve();
|
|
27
|
+
/** Aborts the read loop's error back-off so `close()` does not have to wait it out. */
|
|
28
|
+
abort = new AbortController();
|
|
29
|
+
constructor(client, options = {}) {
|
|
30
|
+
this.originalClient = client;
|
|
31
|
+
this.client = client.duplicate();
|
|
32
|
+
this.state = {};
|
|
33
|
+
this.COUNT = options.COUNT ?? 5000;
|
|
34
|
+
this.BLOCK = options.BLOCK ?? 0;
|
|
35
|
+
this.RETRIES = options.retries ?? 3;
|
|
36
|
+
this.batchMessages = options.batchMessages ?? false;
|
|
37
|
+
this.retryProcessor = new RetryProcessor(this, {
|
|
38
|
+
retryTime: options.retryTime,
|
|
39
|
+
maxRetry: this.RETRIES,
|
|
40
|
+
});
|
|
41
|
+
// node-redis rejects commands issued before the connection is open, so keep
|
|
42
|
+
// the promise around and let the read loop await it before the first read.
|
|
43
|
+
this.ready = this.client.connect();
|
|
44
|
+
// Also handled here, so a consumer that is never listened on or closed
|
|
45
|
+
// cannot take the process down with an unhandled rejection.
|
|
46
|
+
this.ready.catch(() => undefined);
|
|
47
|
+
}
|
|
48
|
+
set block(block) {
|
|
49
|
+
this.BLOCK = block;
|
|
50
|
+
}
|
|
51
|
+
set count(count) {
|
|
52
|
+
this.COUNT = count;
|
|
53
|
+
}
|
|
54
|
+
set retries(retries) {
|
|
55
|
+
this.RETRIES = retries;
|
|
56
|
+
this.retryProcessor.maxRetry = retries;
|
|
57
|
+
}
|
|
58
|
+
get settings() {
|
|
59
|
+
return { block: this.BLOCK, count: this.COUNT };
|
|
60
|
+
}
|
|
61
|
+
async listen(streams, flag) {
|
|
62
|
+
// `flag` is the legacy positional form of `batchMessages`.
|
|
63
|
+
const batch = flag ?? this.batchMessages;
|
|
64
|
+
const streamList = Array.isArray(streams) ? streams : [streams];
|
|
65
|
+
for (const stream of streamList) {
|
|
66
|
+
// Checked before any Redis call, so a repeated listen() stays cheap.
|
|
67
|
+
if (this.hasStreamState(stream.name))
|
|
68
|
+
continue;
|
|
69
|
+
await this.originalClient.createGroup(stream.name);
|
|
70
|
+
this.initStreamState(stream);
|
|
71
|
+
}
|
|
72
|
+
// Nothing to read from: don't start a loop that would only fail.
|
|
73
|
+
if (Object.keys(this.state).length === 0)
|
|
74
|
+
return;
|
|
75
|
+
// A second loop would consume every stream a second time.
|
|
76
|
+
if (this.listenPromise)
|
|
77
|
+
return;
|
|
78
|
+
this.listenPromise = this.listenForStreams(batch);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Stops reading and closes the consumer's connection, so the process can exit.
|
|
82
|
+
*/
|
|
83
|
+
async close() {
|
|
84
|
+
this.stopped = true;
|
|
85
|
+
// Pending retries sleep inside a timer; without this they would keep the
|
|
86
|
+
// event loop alive for up to the longest `retryTime`.
|
|
87
|
+
this.retryProcessor.stop();
|
|
88
|
+
this.abort.abort();
|
|
89
|
+
try {
|
|
90
|
+
await this.ready;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// The client never connected, so there is nothing to close.
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// Best effort: get acknowledgements that are still queued to Redis before
|
|
97
|
+
// the connections go away, so a message processed just before close() is not
|
|
98
|
+
// left in the pending list.
|
|
99
|
+
await this.flushAcks().catch(() => undefined);
|
|
100
|
+
// `destroy()` throws when the socket is already closed, which is the state
|
|
101
|
+
// we are aiming for anyway.
|
|
102
|
+
if (this.client.isOpen)
|
|
103
|
+
this.client.destroy();
|
|
104
|
+
await this.listenPromise;
|
|
105
|
+
}
|
|
106
|
+
addAckMessage(stream, id) {
|
|
107
|
+
const ackMessages = this.successfullMessages.get(stream);
|
|
108
|
+
if (ackMessages) {
|
|
109
|
+
ackMessages.push(id);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
this.successfullMessages.set(stream, [id]);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Sends the queued acknowledgements (`XACK` + `XDEL`) to Redis.
|
|
117
|
+
*
|
|
118
|
+
* Call this after {@link addAckMessage} to make the acknowledgement leave the
|
|
119
|
+
* pending list right away instead of waiting for the read loop's next flush.
|
|
120
|
+
*
|
|
121
|
+
* Flushes are serialised, so the read loop and the retry processor can both
|
|
122
|
+
* call this without interleaving their `XACK`/`XDEL` batches. Ids are only
|
|
123
|
+
* dropped once Redis accepted them, so a failed flush is retried by the next
|
|
124
|
+
* one.
|
|
125
|
+
*/
|
|
126
|
+
flushAcks() {
|
|
127
|
+
const run = this.ackFlush.then(() => this.acknowlegdeMessages(),
|
|
128
|
+
// A previous flush failed, which must not block this one.
|
|
129
|
+
() => this.acknowlegdeMessages());
|
|
130
|
+
// Keep the internal chain usable even when this flush rejects.
|
|
131
|
+
this.ackFlush = run.catch(() => undefined);
|
|
132
|
+
return run;
|
|
133
|
+
}
|
|
134
|
+
async listenForStreams(batch) {
|
|
135
|
+
try {
|
|
136
|
+
await this.ready;
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
this.client.emit('listen-error', err);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
while (!this.stopped) {
|
|
143
|
+
try {
|
|
144
|
+
const streamsToListen = [];
|
|
145
|
+
for (const [key, streamState] of Object.entries(this.state)) {
|
|
146
|
+
streamsToListen.push({ key, id: streamState.nextId });
|
|
147
|
+
}
|
|
148
|
+
const streamsMessages = await this.readStreams(streamsToListen);
|
|
149
|
+
if (streamsMessages) {
|
|
150
|
+
for (const streamMessages of streamsMessages) {
|
|
151
|
+
await this.processStreamMessages(streamMessages, batch);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
await this.flushAcks();
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
// A deliberate close() rejects the in-flight read; that is not an error.
|
|
158
|
+
if (this.stopped)
|
|
159
|
+
break;
|
|
160
|
+
this.client.emit('listen-error', err);
|
|
161
|
+
await timeout(LISTEN_ERROR_RETRY_DELAY, this.abort.signal);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
hasStreamState(name) {
|
|
166
|
+
return !!this.getStreamState(name);
|
|
167
|
+
}
|
|
168
|
+
getStreamState(name) {
|
|
169
|
+
return this.state[name] ?? null;
|
|
170
|
+
}
|
|
171
|
+
initStreamState(stream) {
|
|
172
|
+
const startId = stream.id ?? '0-0';
|
|
173
|
+
this.state[stream.name] = {
|
|
174
|
+
nextId: startId,
|
|
175
|
+
executable: stream.executable,
|
|
176
|
+
// '>' means "new messages only": there is no pending list to drain first.
|
|
177
|
+
recovering: startId !== '>',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
async readStreams(streamsToListen) {
|
|
181
|
+
const messages = await this.client.xReadGroup(this.originalClient.groupName, this.originalClient.clientName, streamsToListen, { BLOCK: this.BLOCK, COUNT: this.COUNT });
|
|
182
|
+
if (!messages)
|
|
183
|
+
return null;
|
|
184
|
+
return messages;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Runs the executable for a single read and acknowledges its messages only
|
|
188
|
+
* when processing succeeded, so a failed message stays in the pending list.
|
|
189
|
+
*
|
|
190
|
+
* In batch mode `payload` is the whole array. A failed batch call cannot tell
|
|
191
|
+
* which message failed, so every message of the read is retried on its own.
|
|
192
|
+
*/
|
|
193
|
+
async handle(fnc, payload, readMessages, stream, batch) {
|
|
194
|
+
try {
|
|
195
|
+
await fnc(payload, stream);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
199
|
+
for (const message of readMessages) {
|
|
200
|
+
this.client.emit('process-error', error, { stream, message, retries: 0 });
|
|
201
|
+
if (this.RETRIES === 0)
|
|
202
|
+
continue;
|
|
203
|
+
this.retryProcessor.add(error, stream, message, batch ? singleMessage(fnc) : fnc);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
for (const message of readMessages) {
|
|
208
|
+
this.addAckMessage(stream, message.id);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async processStreamMessages(streamMessages, batch) {
|
|
212
|
+
const stream = streamMessages.name;
|
|
213
|
+
const state = this.getStreamState(stream);
|
|
214
|
+
if (!state)
|
|
215
|
+
throw new Error('No state was found for stream processing of ' + stream);
|
|
216
|
+
const fnc = state.executable;
|
|
217
|
+
const messages = streamMessages.messages;
|
|
218
|
+
if (batch) {
|
|
219
|
+
// A read that only served an empty pending list has nothing to hand over.
|
|
220
|
+
if (messages.length > 0) {
|
|
221
|
+
await this.handle(fnc, messages, messages, stream, true);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
for (const message of messages) {
|
|
226
|
+
await this.handle(fnc, message, [message], stream, false);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (state.recovering) {
|
|
230
|
+
if (messages.length === 0) {
|
|
231
|
+
state.nextId = '>';
|
|
232
|
+
state.recovering = false;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
const lastMessage = messages[messages.length - 1];
|
|
236
|
+
if (lastMessage)
|
|
237
|
+
state.nextId = lastMessage.id;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async acknowlegdeMessages() {
|
|
242
|
+
// Snapshot the entries: ids queued while this flush is awaiting belong to
|
|
243
|
+
// the next flush, not to this one.
|
|
244
|
+
const batches = [...this.successfullMessages];
|
|
245
|
+
if (batches.length === 0)
|
|
246
|
+
return;
|
|
247
|
+
// Detach before awaiting. Otherwise an `addAckMessage` for the same stream
|
|
248
|
+
// would append to the array being flushed and then be dropped by the
|
|
249
|
+
// removal below without ever reaching Redis.
|
|
250
|
+
for (const [stream] of batches)
|
|
251
|
+
this.successfullMessages.delete(stream);
|
|
252
|
+
// `XACK` and `XDEL` are independent, and node-redis pipelines commands that
|
|
253
|
+
// are in flight at the same time. Issuing all of them before awaiting costs
|
|
254
|
+
// one round trip for the whole flush instead of two per stream.
|
|
255
|
+
const inFlight = [];
|
|
256
|
+
for (const [stream, ids] of batches) {
|
|
257
|
+
// `XACK` reports ids that were already acknowledged as 0. The entry is
|
|
258
|
+
// deleted regardless, so a redelivered message cannot keep the queue
|
|
259
|
+
// growing without bound.
|
|
260
|
+
inFlight.push(this.originalClient.xAck(stream, this.originalClient.groupName, ids), this.originalClient.xDel(stream, ids));
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
await Promise.all(inFlight);
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
// Put the ids back so the next flush retries them.
|
|
267
|
+
for (const [stream, ids] of batches) {
|
|
268
|
+
for (const id of ids)
|
|
269
|
+
this.addAckMessage(stream, id);
|
|
270
|
+
}
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
163
274
|
}
|
|
164
|
-
exports.RedisConsumer = RedisConsumer;
|
package/dist/helpers.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Resolves after `ms`, or as soon as `signal` aborts. The timer is always
|
|
3
|
+
* cleared, so a pending sleep never keeps the process alive.
|
|
4
|
+
*/
|
|
5
|
+
export declare const timeout: (ms: number, signal?: AbortSignal) => Promise<void>;
|
package/dist/helpers.js
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Pending cancellations, one set per signal.
|
|
3
|
+
*
|
|
4
|
+
* `AbortSignal` is an `EventTarget`, and adding a listener scans the existing
|
|
5
|
+
* ones for a duplicate - so attaching one listener per sleep is O(n) per call
|
|
6
|
+
* and O(n^2) for a batch. A failed read of 20k messages arms 20k retry sleeps
|
|
7
|
+
* against the same signal, which took ~14s before a single timer fired and
|
|
8
|
+
* blocked the read loop for that whole time. Sharing one listener per signal
|
|
9
|
+
* keeps it linear.
|
|
10
|
+
*/
|
|
11
|
+
const cancellations = new WeakMap();
|
|
12
|
+
/** Registers `cancel` to run when `signal` aborts. Returns an unregister fn. */
|
|
13
|
+
function onAbort(signal, cancel) {
|
|
14
|
+
let pending = cancellations.get(signal);
|
|
15
|
+
if (!pending) {
|
|
16
|
+
pending = new Set();
|
|
17
|
+
cancellations.set(signal, pending);
|
|
18
|
+
signal.addEventListener('abort', () => {
|
|
19
|
+
const waiting = cancellations.get(signal);
|
|
20
|
+
cancellations.delete(signal);
|
|
21
|
+
if (!waiting)
|
|
22
|
+
return;
|
|
23
|
+
// Snapshot: each callback unregisters itself.
|
|
24
|
+
const callbacks = [...waiting];
|
|
25
|
+
waiting.clear();
|
|
26
|
+
for (const callback of callbacks)
|
|
27
|
+
callback();
|
|
28
|
+
}, { once: true });
|
|
29
|
+
}
|
|
30
|
+
pending.add(cancel);
|
|
31
|
+
return () => pending.delete(cancel);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolves after `ms`, or as soon as `signal` aborts. The timer is always
|
|
35
|
+
* cleared, so a pending sleep never keeps the process alive.
|
|
36
|
+
*/
|
|
37
|
+
export const timeout = async (ms, signal) => {
|
|
38
|
+
if (!signal) {
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, ms));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (signal.aborted)
|
|
43
|
+
return;
|
|
44
|
+
await new Promise(resolve => {
|
|
45
|
+
let settled = false;
|
|
46
|
+
const done = () => {
|
|
47
|
+
if (settled)
|
|
48
|
+
return;
|
|
49
|
+
settled = true;
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
unregister();
|
|
52
|
+
resolve();
|
|
53
|
+
};
|
|
54
|
+
// No `await` between the `aborted` check above and this registration, so the
|
|
55
|
+
// signal cannot fire before `done` is reachable.
|
|
56
|
+
const unregister = onAbort(signal, done);
|
|
57
|
+
const timer = setTimeout(done, ms);
|
|
58
|
+
});
|
|
59
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export * from '
|
|
4
|
-
export
|
|
5
|
-
export
|
|
6
|
-
export * from './producer';
|
|
7
|
-
export { RetryFailedMessage } from './retry-processor';
|
|
8
|
-
export type StreamMessageId = PropType<StreamMessageReply, 'id'>;
|
|
9
|
-
export type StreamMessageData = PropType<StreamMessageReply, 'message'>;
|
|
1
|
+
export type * from 'redis';
|
|
2
|
+
export { RedisClient } from './client.js';
|
|
3
|
+
export * from './consumer.js';
|
|
4
|
+
export * from './producer.js';
|
|
5
|
+
export type { RetryFailedMessage, RetryMessage } from './retry-processor.js';
|