consumer-pgmq 2.0.1 → 3.0.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 +36 -22
- package/dist/consumer.js +32 -5
- package/dist/queueDriver/PostgresQueueDriver.js +31 -10
- package/dist/queueDriver/SupabaseQueueDriver.js +13 -0
- package/examples/consumerPostgresDriver.ts +39 -43
- package/examples/consumerSupabaseDriver.ts +15 -15
- package/package.json +2 -2
- package/src/consumer.ts +44 -6
- package/src/queueDriver/PostgresQueueDriver.ts +41 -14
- package/src/queueDriver/SupabaseQueueDriver.ts +15 -0
- package/src/type.ts +22 -0
- package/tests/consumer.spec.ts +174 -7
package/README.md
CHANGED
|
@@ -40,7 +40,13 @@ yarn add consumer-pgmq
|
|
|
40
40
|
- poolSize: The number of consumers. PS: this is the number of consumers that will be created to consume the messages and
|
|
41
41
|
if you use read consume type, the pool size is the number of messages will get at the same time.
|
|
42
42
|
- timeMsWaitBeforeNextPolling: The time in milliseconds to wait before the next polling
|
|
43
|
-
- enabledPolling: The enabled polling. PS: if true, the consumer will poll the message, if false, the consumer will consume the message one time and stop. PS: is required to the versions more than 1.0.5
|
|
43
|
+
- enabledPolling: The enabled polling. PS: if true, the consumer will poll the message, if false, the consumer will consume the message one time and stop. PS: is required to the versions more than 1.0.5.
|
|
44
|
+
- queueNameDlq: The name of the dead letter queue. PS: recommended to set the same name of the queue, but suffix with '_dlq'. For example: **messages_dlq**
|
|
45
|
+
- totalRetriesBeforeSendToDlq: The total retries before send to dlq. For example: if you set totalRetriesBeforeSendToDlq equal 2, the message will be sent to dlq if the handler fails 2 times, so the third time the message will be sent to dlq and remove the main queue to avoid infinite retries.
|
|
46
|
+
|
|
47
|
+
## Extra points to know when use the dlq feature
|
|
48
|
+
- The dead letter queue no work If you setted the consumerType option with value 'pop', because the pop get the message and remove from queue at same time, so if failed when you are processing you lose the message.
|
|
49
|
+
- Recommendation no set lower value to the option 'visibilityTime' if you are using the dead letter queue feature. For example: set visibilityTime value lower than 30 seconds, because if the message wasn't delete and the message be available again the consumer application can consume the message again.
|
|
44
50
|
|
|
45
51
|
## Events
|
|
46
52
|
|
|
@@ -89,11 +95,13 @@ async function start() {
|
|
|
89
95
|
const consumer = new Consumer(
|
|
90
96
|
{
|
|
91
97
|
queueName: 'subscriptions',
|
|
92
|
-
visibilityTime:
|
|
98
|
+
visibilityTime: 30,
|
|
93
99
|
consumeType: "read",
|
|
94
|
-
poolSize:
|
|
100
|
+
poolSize: 8,
|
|
95
101
|
timeMsWaitBeforeNextPolling: 1000,
|
|
96
|
-
enabledPolling:
|
|
102
|
+
enabledPolling: true,
|
|
103
|
+
queueNameDlq: "subscriptions_dlq",
|
|
104
|
+
totalRetriesBeforeSendToDlq: 2
|
|
97
105
|
},
|
|
98
106
|
async function (message: { [key: string]: any }, signal): Promise<void> {
|
|
99
107
|
try {
|
|
@@ -143,33 +151,39 @@ start()
|
|
|
143
151
|
import { config } from "dotenv"
|
|
144
152
|
config()
|
|
145
153
|
|
|
146
|
-
import
|
|
147
|
-
import
|
|
148
|
-
|
|
154
|
+
import Consumer from '../src/consumer';
|
|
155
|
+
import PostgresQueueDriver from '../src/queueDriver/PostgresQueueDriver';
|
|
156
|
+
|
|
157
|
+
import { Client } from 'pg'
|
|
149
158
|
|
|
150
159
|
async function start() {
|
|
151
|
-
const connection = knex({
|
|
152
|
-
client: 'pg',
|
|
153
|
-
connection: {
|
|
154
|
-
host: process.env.POSTGRES_HOST,
|
|
155
|
-
database: process.env.POSTGRES_DATABASE,
|
|
156
|
-
password: process.env.POSTGRES_PASSWORD,
|
|
157
|
-
port: Number(process.env.POSTGRES_PORT),
|
|
158
|
-
user: process.env.POSTGRES_USER,
|
|
159
|
-
ssl: false
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
160
|
|
|
163
|
-
const
|
|
161
|
+
const pgClient = new Client({
|
|
162
|
+
host: process.env.POSTGRES_HOST,
|
|
163
|
+
database: process.env.POSTGRES_DATABASE,
|
|
164
|
+
password: process.env.POSTGRES_PASSWORD,
|
|
165
|
+
port: Number(process.env.POSTGRES_PORT),
|
|
166
|
+
user: process.env.POSTGRES_USER,
|
|
167
|
+
ssl: false,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
await pgClient.connect()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
const postgresQueueDriver = new PostgresQueueDriver(
|
|
174
|
+
pgClient, "pgmq"
|
|
175
|
+
)
|
|
164
176
|
|
|
165
177
|
const consumer = new Consumer(
|
|
166
178
|
{
|
|
167
179
|
queueName: 'subscriptions',
|
|
168
|
-
visibilityTime:
|
|
180
|
+
visibilityTime: 30,
|
|
169
181
|
consumeType: "read",
|
|
170
|
-
poolSize:
|
|
182
|
+
poolSize: 8,
|
|
171
183
|
timeMsWaitBeforeNextPolling: 1000,
|
|
172
|
-
enabledPolling:
|
|
184
|
+
enabledPolling: true,
|
|
185
|
+
queueNameDlq: "subscriptions_dlq",
|
|
186
|
+
totalRetriesBeforeSendToDlq: 2
|
|
173
187
|
},
|
|
174
188
|
async function (message: { [key: string]: any }, signal): Promise<void> {
|
|
175
189
|
try {
|
package/dist/consumer.js
CHANGED
|
@@ -8,9 +8,17 @@ const READ = "read";
|
|
|
8
8
|
class Consumer extends events_1.EventEmitter {
|
|
9
9
|
constructor(options, callback, client) {
|
|
10
10
|
super();
|
|
11
|
+
this.setTimeoutId = null;
|
|
11
12
|
this.options = options;
|
|
12
13
|
this.callback = callback;
|
|
13
14
|
this.client = client;
|
|
15
|
+
this.valideOptions();
|
|
16
|
+
this.setTimeoutId = null;
|
|
17
|
+
}
|
|
18
|
+
valideOptions() {
|
|
19
|
+
if (this.options.queueNameDlq && !this.options.totalRetriesBeforeSendToDlq) {
|
|
20
|
+
throw new Error("The option totalRetriesBeforeSendToDlq is required when queueNameDlq is set");
|
|
21
|
+
}
|
|
14
22
|
}
|
|
15
23
|
/**
|
|
16
24
|
* Get the message
|
|
@@ -51,6 +59,9 @@ class Consumer extends events_1.EventEmitter {
|
|
|
51
59
|
* @private
|
|
52
60
|
*/
|
|
53
61
|
async pollMessage() {
|
|
62
|
+
if (this.setTimeoutId) {
|
|
63
|
+
clearTimeout(this.setTimeoutId);
|
|
64
|
+
}
|
|
54
65
|
let promises = [];
|
|
55
66
|
try {
|
|
56
67
|
const { data, error } = await this.getMessage();
|
|
@@ -58,19 +69,35 @@ class Consumer extends events_1.EventEmitter {
|
|
|
58
69
|
throw error;
|
|
59
70
|
}
|
|
60
71
|
if (data.length === 0 && this.options.enabledPolling) {
|
|
61
|
-
setTimeout(() => this.pollMessage(), (this.options.timeMsWaitBeforeNextPolling || 1000) * 10);
|
|
72
|
+
this.setTimeoutId = setTimeout(() => this.pollMessage(), (this.options.timeMsWaitBeforeNextPolling || 1000) * 10);
|
|
62
73
|
return;
|
|
63
74
|
}
|
|
64
75
|
const controller = new AbortController();
|
|
65
76
|
const signal = controller.signal;
|
|
66
|
-
for (let i = 0; i <
|
|
77
|
+
for (let i = 0; i < data.length; i++) {
|
|
78
|
+
const hasSendToDlq = data[i] &&
|
|
79
|
+
this.options.queueNameDlq &&
|
|
80
|
+
this.options.totalRetriesBeforeSendToDlq &&
|
|
81
|
+
data[i].read_ct > this.options.totalRetriesBeforeSendToDlq;
|
|
82
|
+
if (hasSendToDlq) {
|
|
83
|
+
promises.push(this.client.send(
|
|
84
|
+
// @ts-ignore
|
|
85
|
+
this.options.queueNameDlq, data[i].message, signal).then(async () => {
|
|
86
|
+
await this.deleteMessage(data[i], signal);
|
|
87
|
+
this.emit('send-to-dlq', data[i]);
|
|
88
|
+
}));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
67
91
|
promises.push(this.callback(data[i].message, signal).then(async () => {
|
|
68
92
|
await this.deleteMessage(data[i], signal);
|
|
69
93
|
this.emit('finish', data[i]);
|
|
70
94
|
}));
|
|
71
95
|
}
|
|
72
|
-
setTimeout(() => controller.abort(), (this.options.visibilityTime || 1) * 1000);
|
|
73
|
-
|
|
96
|
+
const timeoutId = setTimeout(() => controller.abort(), (this.options.visibilityTime || 1) * 1000);
|
|
97
|
+
if (promises.length > 0) {
|
|
98
|
+
await Promise.allSettled(promises);
|
|
99
|
+
}
|
|
100
|
+
clearTimeout(timeoutId);
|
|
74
101
|
promises = [];
|
|
75
102
|
}
|
|
76
103
|
catch (err) {
|
|
@@ -85,7 +112,7 @@ class Consumer extends events_1.EventEmitter {
|
|
|
85
112
|
if (!this.options.enabledPolling) {
|
|
86
113
|
return;
|
|
87
114
|
}
|
|
88
|
-
setTimeout(() => this.pollMessage(), this.options.timeMsWaitBeforeNextPolling || 1000);
|
|
115
|
+
this.setTimeoutId = setTimeout(() => this.pollMessage(), this.options.timeMsWaitBeforeNextPolling || 1000);
|
|
89
116
|
}
|
|
90
117
|
}
|
|
91
118
|
/**
|
|
@@ -5,6 +5,27 @@ class PostgresQueueDriver {
|
|
|
5
5
|
this.connection = connection;
|
|
6
6
|
this.schema = schema;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Send the message
|
|
10
|
+
* @param queueName The name of the queue
|
|
11
|
+
* @param message The message
|
|
12
|
+
* @returns Promise<{ error: any }>
|
|
13
|
+
*/
|
|
14
|
+
async send(queueName, message) {
|
|
15
|
+
try {
|
|
16
|
+
await this.connection.query(`
|
|
17
|
+
SELECT * FROM ${this.schema}.send(
|
|
18
|
+
queue_name => $1,
|
|
19
|
+
msg => $2,
|
|
20
|
+
delay => $3
|
|
21
|
+
);
|
|
22
|
+
`, [queueName, message, 1]);
|
|
23
|
+
return { error: null };
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return { error };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
8
29
|
/**
|
|
9
30
|
* Get the message
|
|
10
31
|
* @param queueName The name of the queue
|
|
@@ -14,11 +35,11 @@ class PostgresQueueDriver {
|
|
|
14
35
|
*/
|
|
15
36
|
async get(queueName, visibilityTime, totalMessages) {
|
|
16
37
|
try {
|
|
17
|
-
const register = await this.connection.
|
|
38
|
+
const register = await this.connection.query(`
|
|
18
39
|
SELECT * FROM ${this.schema}.read(
|
|
19
|
-
queue_name =>
|
|
20
|
-
vt =>
|
|
21
|
-
qty =>
|
|
40
|
+
queue_name => $1,
|
|
41
|
+
vt => $2,
|
|
42
|
+
qty => $3
|
|
22
43
|
);
|
|
23
44
|
`, [queueName, visibilityTime, totalMessages]);
|
|
24
45
|
if (!register.rows) {
|
|
@@ -37,10 +58,10 @@ class PostgresQueueDriver {
|
|
|
37
58
|
*/
|
|
38
59
|
async pop(queueName) {
|
|
39
60
|
try {
|
|
40
|
-
const register = await this.connection.
|
|
61
|
+
const register = await this.connection.query(`
|
|
41
62
|
SELECT * FROM ${this.schema}.pop(
|
|
42
|
-
queue_name =>
|
|
43
|
-
)
|
|
63
|
+
queue_name => $1
|
|
64
|
+
)
|
|
44
65
|
`, [queueName]);
|
|
45
66
|
if (!register.rows) {
|
|
46
67
|
return { data: [], error: null };
|
|
@@ -59,10 +80,10 @@ class PostgresQueueDriver {
|
|
|
59
80
|
*/
|
|
60
81
|
async delete(queueName, messageID) {
|
|
61
82
|
try {
|
|
62
|
-
await this.connection.
|
|
83
|
+
await this.connection.query(`
|
|
63
84
|
SELECT * FROM ${this.schema}.delete(
|
|
64
|
-
queue_name =>
|
|
65
|
-
msg_id =>
|
|
85
|
+
queue_name => $1,
|
|
86
|
+
msg_id => $2
|
|
66
87
|
);
|
|
67
88
|
`, [queueName, messageID]);
|
|
68
89
|
return { error: null };
|
|
@@ -4,6 +4,19 @@ class SupabaseQueueDriver {
|
|
|
4
4
|
constructor(supabase) {
|
|
5
5
|
this.supabase = supabase;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Send the message
|
|
9
|
+
* @param queueName The name of the queue
|
|
10
|
+
* @param message The message
|
|
11
|
+
* @returns Promise<{ error: any }>
|
|
12
|
+
*/
|
|
13
|
+
async send(queueName, message) {
|
|
14
|
+
const { error } = await this.supabase.rpc("send", {
|
|
15
|
+
queue_name: queueName,
|
|
16
|
+
message: message
|
|
17
|
+
});
|
|
18
|
+
return { error };
|
|
19
|
+
}
|
|
7
20
|
/**
|
|
8
21
|
* Get the message
|
|
9
22
|
* @param queueName The name of the queue
|
|
@@ -3,70 +3,66 @@ config()
|
|
|
3
3
|
|
|
4
4
|
import Consumer from '../src/consumer';
|
|
5
5
|
import PostgresQueueDriver from '../src/queueDriver/PostgresQueueDriver';
|
|
6
|
-
|
|
7
|
-
import
|
|
6
|
+
|
|
7
|
+
import { Client } from 'pg'
|
|
8
8
|
|
|
9
9
|
async function start() {
|
|
10
|
-
const connection = knex({
|
|
11
|
-
client: 'pg',
|
|
12
|
-
connection: {
|
|
13
|
-
host: process.env.POSTGRES_HOST,
|
|
14
|
-
database: process.env.POSTGRES_DATABASE,
|
|
15
|
-
password: process.env.POSTGRES_PASSWORD,
|
|
16
|
-
port: Number(process.env.POSTGRES_PORT),
|
|
17
|
-
user: process.env.POSTGRES_USER,
|
|
18
|
-
ssl: false,
|
|
19
|
-
}
|
|
20
|
-
});
|
|
21
10
|
|
|
22
|
-
const
|
|
11
|
+
const pgClient = new Client({
|
|
12
|
+
host: process.env.POSTGRES_HOST,
|
|
13
|
+
database: process.env.POSTGRES_DATABASE,
|
|
14
|
+
password: process.env.POSTGRES_PASSWORD,
|
|
15
|
+
port: Number(process.env.POSTGRES_PORT),
|
|
16
|
+
user: process.env.POSTGRES_USER,
|
|
17
|
+
ssl: false,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
await pgClient.connect()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
const postgresQueueDriver = new PostgresQueueDriver(
|
|
24
|
+
pgClient, "pgmq"
|
|
25
|
+
)
|
|
26
|
+
|
|
23
27
|
|
|
24
28
|
const consumer = new Consumer(
|
|
25
29
|
{
|
|
26
30
|
queueName: 'subscriptions',
|
|
27
|
-
visibilityTime:
|
|
31
|
+
visibilityTime: 30,
|
|
28
32
|
consumeType: "read",
|
|
29
|
-
poolSize:
|
|
33
|
+
poolSize: 8,
|
|
30
34
|
timeMsWaitBeforeNextPolling: 1000,
|
|
31
|
-
enabledPolling: true
|
|
35
|
+
enabledPolling: true,
|
|
36
|
+
queueNameDlq: "subscriptions_dlq",
|
|
37
|
+
totalRetriesBeforeSendToDlq: 2
|
|
32
38
|
},
|
|
33
39
|
async function (message: { [key: string]: any }, signal): Promise<void> {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} catch (error: any) {
|
|
43
|
-
if (error.name === "AbortError") {
|
|
44
|
-
console.log("Operation aborted");
|
|
45
|
-
} else {
|
|
46
|
-
console.error("Error:", error);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
40
|
+
console.log(message)
|
|
41
|
+
throw new Error("Error in message")
|
|
42
|
+
// const url = "https://jsonplaceholder.typicode.com/todos/1";
|
|
43
|
+
// await timersPromises.setTimeout(100, null, { signal });
|
|
44
|
+
// console.log("Fetching data...");
|
|
45
|
+
// const response = await fetch(url, { signal });
|
|
46
|
+
// const todo = await response.json();
|
|
47
|
+
// console.log("Todo:", todo);
|
|
49
48
|
},
|
|
50
49
|
postgresQueueDriver
|
|
51
50
|
);
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// });
|
|
56
|
-
|
|
57
|
-
consumer.on("abort-error", (err) => {
|
|
58
|
-
console.log("Abort error =>", err)
|
|
52
|
+
consumer.on("send-to-dlq", (message: { [key: string]: any }) => {
|
|
53
|
+
console.log("Send to DLQ =>", message)
|
|
59
54
|
})
|
|
60
55
|
|
|
61
56
|
consumer.on('error', (err: Error) => {
|
|
62
|
-
if (err.message.includes("TypeError: fetch failed")) {
|
|
63
|
-
console.log(err)
|
|
64
|
-
process.exit(1);
|
|
65
|
-
}
|
|
66
57
|
console.error('Error consuming message:', err.message);
|
|
67
58
|
});
|
|
68
59
|
|
|
69
|
-
consumer.start();
|
|
60
|
+
await consumer.start();
|
|
61
|
+
|
|
62
|
+
process.on("SIGINT", async () => {
|
|
63
|
+
await pgClient.end()
|
|
64
|
+
process.exit(0)
|
|
65
|
+
})
|
|
70
66
|
|
|
71
67
|
}
|
|
72
68
|
|
|
@@ -21,33 +21,30 @@ const supabaseQueueDriver = new SupabaseQueueDriver(
|
|
|
21
21
|
supabase as unknown as SupabaseClient
|
|
22
22
|
)
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
import timersPromises from "node:timers/promises";
|
|
26
|
-
|
|
27
24
|
async function start() {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
for (let i = 0; i < 50; i++) {
|
|
26
|
+
await supabase.rpc("send", {
|
|
27
|
+
queue_name: "subscriptions",
|
|
28
|
+
message: { "message": `Message triggered at ${Date.now()}` }
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
console.log("Total messages sent: ", 50)
|
|
35
32
|
|
|
36
33
|
const consumer = new Consumer(
|
|
37
34
|
{
|
|
38
35
|
queueName: 'subscriptions',
|
|
39
|
-
visibilityTime:
|
|
36
|
+
visibilityTime: 30,
|
|
40
37
|
consumeType: "read",
|
|
41
38
|
poolSize: 8,
|
|
42
39
|
timeMsWaitBeforeNextPolling: 1000,
|
|
43
|
-
enabledPolling:
|
|
40
|
+
enabledPolling: true,
|
|
41
|
+
queueNameDlq: "subscriptions_dlq",
|
|
42
|
+
totalRetriesBeforeSendToDlq: 2
|
|
44
43
|
},
|
|
45
44
|
async function (message: { [key: string]: any }, signal): Promise<void> {
|
|
46
45
|
try {
|
|
47
|
-
if (message.error) {
|
|
48
|
-
throw new Error("Error in message")
|
|
49
|
-
}
|
|
50
46
|
console.log(message)
|
|
47
|
+
throw new Error("Error in message")
|
|
51
48
|
} catch (error: any) {
|
|
52
49
|
throw error
|
|
53
50
|
}
|
|
@@ -59,6 +56,9 @@ async function start() {
|
|
|
59
56
|
// console.log('Consumed message =>', message);
|
|
60
57
|
// });
|
|
61
58
|
|
|
59
|
+
consumer.on("send-to-dlq", (message: { [key: string]: any }) => {
|
|
60
|
+
console.log("Send to DLQ =>", message)
|
|
61
|
+
})
|
|
62
62
|
consumer.on("abort-error", (err) => {
|
|
63
63
|
console.log("Abort error =>", err)
|
|
64
64
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "consumer-pgmq",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "The consumer of Supabase pgmq",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"packageManager": "pnpm@10.7.1",
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/jest": "^30.0.0",
|
|
25
|
+
"@types/pg": "^8.15.5",
|
|
25
26
|
"ts-jest": "^29.4.1",
|
|
26
27
|
"ts-node-dev": "^2.0.0",
|
|
27
28
|
"typescript": "^5.9.2"
|
|
@@ -30,7 +31,6 @@
|
|
|
30
31
|
"@supabase/supabase-js": "^2.56.0",
|
|
31
32
|
"dotenv": "^17.2.1",
|
|
32
33
|
"jest": "^30.1.1",
|
|
33
|
-
"knex": "^3.1.0",
|
|
34
34
|
"pg": "^8.16.3"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
package/src/consumer.ts
CHANGED
|
@@ -22,6 +22,8 @@ class Consumer extends EventEmitter {
|
|
|
22
22
|
*/
|
|
23
23
|
private client: QueueDriver
|
|
24
24
|
|
|
25
|
+
private setTimeoutId: NodeJS.Timeout | null = null;
|
|
26
|
+
|
|
25
27
|
constructor(
|
|
26
28
|
options: Options,
|
|
27
29
|
callback: HandlerCallback,
|
|
@@ -31,6 +33,14 @@ class Consumer extends EventEmitter {
|
|
|
31
33
|
this.options = options;
|
|
32
34
|
this.callback = callback;
|
|
33
35
|
this.client = client;
|
|
36
|
+
this.valideOptions();
|
|
37
|
+
this.setTimeoutId = null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private valideOptions() {
|
|
41
|
+
if (this.options.queueNameDlq && !this.options.totalRetriesBeforeSendToDlq) {
|
|
42
|
+
throw new Error("The option totalRetriesBeforeSendToDlq is required when queueNameDlq is set");
|
|
43
|
+
}
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
/**
|
|
@@ -84,6 +94,9 @@ class Consumer extends EventEmitter {
|
|
|
84
94
|
* @private
|
|
85
95
|
*/
|
|
86
96
|
private async pollMessage() {
|
|
97
|
+
if (this.setTimeoutId) {
|
|
98
|
+
clearTimeout(this.setTimeoutId);
|
|
99
|
+
}
|
|
87
100
|
let promises: Promise<any>[] = [];
|
|
88
101
|
|
|
89
102
|
try {
|
|
@@ -93,18 +106,39 @@ class Consumer extends EventEmitter {
|
|
|
93
106
|
}
|
|
94
107
|
|
|
95
108
|
if (data.length === 0 && this.options.enabledPolling) {
|
|
96
|
-
setTimeout(
|
|
109
|
+
this.setTimeoutId = setTimeout(
|
|
97
110
|
() => this.pollMessage(),
|
|
98
111
|
(this.options.timeMsWaitBeforeNextPolling || 1000) * 10
|
|
99
112
|
);
|
|
100
113
|
return;
|
|
101
114
|
}
|
|
102
115
|
|
|
103
|
-
|
|
104
116
|
const controller = new AbortController();
|
|
105
117
|
const signal = controller.signal;
|
|
106
118
|
|
|
107
|
-
for (let i = 0; i <
|
|
119
|
+
for (let i = 0; i < data.length; i++) {
|
|
120
|
+
const hasSendToDlq = data[i] &&
|
|
121
|
+
this.options.queueNameDlq &&
|
|
122
|
+
this.options.totalRetriesBeforeSendToDlq &&
|
|
123
|
+
data[i].read_ct > this.options.totalRetriesBeforeSendToDlq
|
|
124
|
+
if (
|
|
125
|
+
hasSendToDlq
|
|
126
|
+
) {
|
|
127
|
+
promises.push(
|
|
128
|
+
this.client.send(
|
|
129
|
+
// @ts-ignore
|
|
130
|
+
this.options.queueNameDlq,
|
|
131
|
+
data[i].message,
|
|
132
|
+
signal
|
|
133
|
+
).then(async () => {
|
|
134
|
+
await this.deleteMessage(data[i], signal);
|
|
135
|
+
this.emit('send-to-dlq', data[i]);
|
|
136
|
+
})
|
|
137
|
+
)
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
108
142
|
promises.push(
|
|
109
143
|
this.callback(data[i].message, signal).then(async () => {
|
|
110
144
|
await this.deleteMessage(data[i], signal);
|
|
@@ -113,8 +147,12 @@ class Consumer extends EventEmitter {
|
|
|
113
147
|
);
|
|
114
148
|
}
|
|
115
149
|
|
|
116
|
-
setTimeout(() => controller.abort(), (this.options.visibilityTime || 1) * 1000);
|
|
117
|
-
|
|
150
|
+
const timeoutId = setTimeout(() => controller.abort(), (this.options.visibilityTime || 1) * 1000);
|
|
151
|
+
if (promises.length > 0) {
|
|
152
|
+
await Promise.allSettled(promises);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
clearTimeout(timeoutId);
|
|
118
156
|
promises = [];
|
|
119
157
|
} catch (err: any) {
|
|
120
158
|
if (err.name === "AbortError") {
|
|
@@ -126,7 +164,7 @@ class Consumer extends EventEmitter {
|
|
|
126
164
|
if (!this.options.enabledPolling) {
|
|
127
165
|
return;
|
|
128
166
|
}
|
|
129
|
-
setTimeout(() => this.pollMessage(), this.options.timeMsWaitBeforeNextPolling || 1000);
|
|
167
|
+
this.setTimeoutId = setTimeout(() => this.pollMessage(), this.options.timeMsWaitBeforeNextPolling || 1000);
|
|
130
168
|
}
|
|
131
169
|
}
|
|
132
170
|
|
|
@@ -1,14 +1,40 @@
|
|
|
1
|
-
import { Knex } from "knex";
|
|
2
1
|
import { Message, QueueDriver } from "../type";
|
|
2
|
+
import { Client } from "pg";
|
|
3
3
|
|
|
4
4
|
class PostgresQueueDriver implements QueueDriver {
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
constructor(
|
|
8
|
-
private connection:
|
|
9
|
-
private schema: string = "public"
|
|
8
|
+
private connection: Client,
|
|
9
|
+
private schema: string = "public",
|
|
10
10
|
) { }
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Send the message
|
|
14
|
+
* @param queueName The name of the queue
|
|
15
|
+
* @param message The message
|
|
16
|
+
* @returns Promise<{ error: any }>
|
|
17
|
+
*/
|
|
18
|
+
async send(
|
|
19
|
+
queueName: string,
|
|
20
|
+
message: { [key: string]: any; },
|
|
21
|
+
): Promise<{ error: any; }> {
|
|
22
|
+
try {
|
|
23
|
+
await this.connection.query(`
|
|
24
|
+
SELECT * FROM ${this.schema}.send(
|
|
25
|
+
queue_name => $1,
|
|
26
|
+
msg => $2,
|
|
27
|
+
delay => $3
|
|
28
|
+
);
|
|
29
|
+
`, [queueName, message, 1]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return { error: null };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return { error };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
12
38
|
/**
|
|
13
39
|
* Get the message
|
|
14
40
|
* @param queueName The name of the queue
|
|
@@ -18,11 +44,11 @@ class PostgresQueueDriver implements QueueDriver {
|
|
|
18
44
|
*/
|
|
19
45
|
async get(queueName: string, visibilityTime: number, totalMessages: number): Promise<{ data: Message[]; error: any; }> {
|
|
20
46
|
try {
|
|
21
|
-
const register = await this.connection.
|
|
47
|
+
const register = await this.connection.query(`
|
|
22
48
|
SELECT * FROM ${this.schema}.read(
|
|
23
|
-
queue_name =>
|
|
24
|
-
vt =>
|
|
25
|
-
qty =>
|
|
49
|
+
queue_name => $1,
|
|
50
|
+
vt => $2,
|
|
51
|
+
qty => $3
|
|
26
52
|
);
|
|
27
53
|
`, [queueName, visibilityTime, totalMessages]
|
|
28
54
|
)
|
|
@@ -45,10 +71,10 @@ class PostgresQueueDriver implements QueueDriver {
|
|
|
45
71
|
*/
|
|
46
72
|
async pop(queueName: string): Promise<{ data: Message[]; error: any; }> {
|
|
47
73
|
try {
|
|
48
|
-
const register = await this.connection.
|
|
74
|
+
const register = await this.connection.query(`
|
|
49
75
|
SELECT * FROM ${this.schema}.pop(
|
|
50
|
-
queue_name =>
|
|
51
|
-
)
|
|
76
|
+
queue_name => $1
|
|
77
|
+
)
|
|
52
78
|
`, [queueName]
|
|
53
79
|
)
|
|
54
80
|
|
|
@@ -71,12 +97,13 @@ class PostgresQueueDriver implements QueueDriver {
|
|
|
71
97
|
*/
|
|
72
98
|
async delete(queueName: string, messageID: number): Promise<{ error: any; }> {
|
|
73
99
|
try {
|
|
74
|
-
await this.connection.
|
|
100
|
+
await this.connection.query(`
|
|
75
101
|
SELECT * FROM ${this.schema}.delete(
|
|
76
|
-
queue_name =>
|
|
77
|
-
msg_id =>
|
|
102
|
+
queue_name => $1,
|
|
103
|
+
msg_id => $2
|
|
78
104
|
);
|
|
79
|
-
`, [queueName, messageID]
|
|
105
|
+
`, [queueName, messageID],
|
|
106
|
+
|
|
80
107
|
)
|
|
81
108
|
|
|
82
109
|
return { error: null };
|
|
@@ -7,6 +7,21 @@ class SupabaseQueueDriver implements QueueDriver {
|
|
|
7
7
|
private supabase: SupabaseClient
|
|
8
8
|
) { }
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Send the message
|
|
12
|
+
* @param queueName The name of the queue
|
|
13
|
+
* @param message The message
|
|
14
|
+
* @returns Promise<{ error: any }>
|
|
15
|
+
*/
|
|
16
|
+
async send(queueName: string, message: { [key: string]: any; }): Promise<{ error: any; }> {
|
|
17
|
+
const { error } = await this.supabase.rpc("send", {
|
|
18
|
+
queue_name: queueName,
|
|
19
|
+
message: message
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return { error };
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
/**
|
|
11
26
|
* Get the message
|
|
12
27
|
* @param queueName The name of the queue
|
package/src/type.ts
CHANGED
|
@@ -30,6 +30,16 @@ interface Options {
|
|
|
30
30
|
* The enabled polling. PS: if true, the consumer will poll the message
|
|
31
31
|
*/
|
|
32
32
|
enabledPolling: boolean;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The name of the queue DLQ
|
|
36
|
+
*/
|
|
37
|
+
queueNameDlq?: string;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The total retries before send the message to DLQ. PS: if set queueNameDlq, this option is required
|
|
41
|
+
*/
|
|
42
|
+
totalRetriesBeforeSendToDlq?: number;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
/**
|
|
@@ -65,6 +75,18 @@ interface Message {
|
|
|
65
75
|
|
|
66
76
|
interface QueueDriver {
|
|
67
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Send the message
|
|
80
|
+
* @param queueName The name of the queue
|
|
81
|
+
* @param message The message
|
|
82
|
+
* @returns Promise<{ error: any }>
|
|
83
|
+
*/
|
|
84
|
+
send(
|
|
85
|
+
queueName: string,
|
|
86
|
+
message: { [key: string]: any },
|
|
87
|
+
signal: AbortSignal
|
|
88
|
+
): Promise<{ error: any }>;
|
|
89
|
+
|
|
68
90
|
/**
|
|
69
91
|
* Get the message
|
|
70
92
|
* @param queueName The name of the queue
|
package/tests/consumer.spec.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import Consumer from '../src/consumer'
|
|
2
2
|
import { Message, QueueDriver, Options } from '../src/type'
|
|
3
|
-
import timersPromises from 'node:timers/promises'
|
|
4
3
|
|
|
5
4
|
describe('Consumer', () => {
|
|
6
5
|
let queueDriver: jest.Mocked<QueueDriver>
|
|
@@ -18,6 +17,7 @@ describe('Consumer', () => {
|
|
|
18
17
|
get: jest.fn(),
|
|
19
18
|
pop: jest.fn(),
|
|
20
19
|
delete: jest.fn(),
|
|
20
|
+
send: jest.fn(),
|
|
21
21
|
}
|
|
22
22
|
jest.useFakeTimers();
|
|
23
23
|
})
|
|
@@ -28,6 +28,167 @@ describe('Consumer', () => {
|
|
|
28
28
|
})
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
it('Should throw error if set dead letter queue and no set total retries before send to dlq', async () => {
|
|
32
|
+
try {
|
|
33
|
+
queueDriver.get.mockResolvedValueOnce({ data: [message], error: null })
|
|
34
|
+
queueDriver.delete.mockResolvedValueOnce({ error: null })
|
|
35
|
+
queueDriver.get.mockResolvedValueOnce({ data: [], error: null })
|
|
36
|
+
|
|
37
|
+
const handler = jest.fn(async () => { })
|
|
38
|
+
const consumer = new Consumer(
|
|
39
|
+
{
|
|
40
|
+
queueName: 'q',
|
|
41
|
+
consumeType: 'read',
|
|
42
|
+
visibilityTime: 1,
|
|
43
|
+
poolSize: 1,
|
|
44
|
+
timeMsWaitBeforeNextPolling: 1,
|
|
45
|
+
enabledPolling: true,
|
|
46
|
+
queueNameDlq: 'q_dlq',
|
|
47
|
+
},
|
|
48
|
+
handler,
|
|
49
|
+
queueDriver
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const onFinish = jest.fn()
|
|
53
|
+
consumer.on('finish', onFinish)
|
|
54
|
+
await consumer.start()
|
|
55
|
+
} catch (error: any) {
|
|
56
|
+
expect(error).toBeInstanceOf(Error)
|
|
57
|
+
expect(error.message).toBe('The option totalRetriesBeforeSendToDlq is required when queueNameDlq is set')
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
it('Should send message to dlq if read count is greater than total retries before send to dlq', async () => {
|
|
63
|
+
const messageToSendDlq = { ...message }
|
|
64
|
+
messageToSendDlq.read_ct = 3
|
|
65
|
+
messageToSendDlq.msg_id = 2
|
|
66
|
+
queueDriver.get.mockResolvedValueOnce({ data: [messageToSendDlq], error: null })
|
|
67
|
+
queueDriver.delete.mockResolvedValueOnce({ error: null })
|
|
68
|
+
queueDriver.get.mockResolvedValueOnce({ data: [], error: null })
|
|
69
|
+
queueDriver.send.mockResolvedValueOnce({ error: null })
|
|
70
|
+
|
|
71
|
+
const handler = jest.fn(async () => { })
|
|
72
|
+
const consumer = new Consumer(
|
|
73
|
+
{
|
|
74
|
+
queueName: 'q',
|
|
75
|
+
consumeType: 'read',
|
|
76
|
+
visibilityTime: 1,
|
|
77
|
+
poolSize: 1,
|
|
78
|
+
timeMsWaitBeforeNextPolling: 1,
|
|
79
|
+
enabledPolling: true,
|
|
80
|
+
queueNameDlq: 'q_dlq',
|
|
81
|
+
totalRetriesBeforeSendToDlq: 2
|
|
82
|
+
},
|
|
83
|
+
handler,
|
|
84
|
+
queueDriver
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const onFinish = jest.fn()
|
|
88
|
+
consumer.on('finish', onFinish)
|
|
89
|
+
await consumer.start()
|
|
90
|
+
|
|
91
|
+
expect(handler).toHaveBeenCalledTimes(0)
|
|
92
|
+
expect(onFinish).toHaveBeenCalledTimes(0)
|
|
93
|
+
expect(queueDriver.delete).toHaveBeenCalledTimes(1)
|
|
94
|
+
expect(queueDriver.send).toHaveBeenCalledTimes(1)
|
|
95
|
+
expect(queueDriver.send).toHaveBeenCalledWith(
|
|
96
|
+
'q_dlq', messageToSendDlq.message, expect.any(AbortSignal)
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('Should send 2 messages to dlq if read count is greater than total retries before send to dlq', async () => {
|
|
101
|
+
const messageToSendDlq = { ...message }
|
|
102
|
+
messageToSendDlq.read_ct = 3
|
|
103
|
+
messageToSendDlq.msg_id = 2
|
|
104
|
+
queueDriver.get.mockResolvedValueOnce({
|
|
105
|
+
data: [
|
|
106
|
+
messageToSendDlq, messageToSendDlq
|
|
107
|
+
], error: null
|
|
108
|
+
})
|
|
109
|
+
queueDriver.delete.mockResolvedValueOnce({ error: null })
|
|
110
|
+
queueDriver.get.mockResolvedValueOnce({ data: [], error: null })
|
|
111
|
+
queueDriver.send.mockResolvedValue({ error: null })
|
|
112
|
+
|
|
113
|
+
const handler = jest.fn(async () => { })
|
|
114
|
+
const consumer = new Consumer(
|
|
115
|
+
{
|
|
116
|
+
queueName: 'q',
|
|
117
|
+
consumeType: 'read',
|
|
118
|
+
visibilityTime: 1,
|
|
119
|
+
poolSize: 2,
|
|
120
|
+
timeMsWaitBeforeNextPolling: 1,
|
|
121
|
+
enabledPolling: true,
|
|
122
|
+
queueNameDlq: 'q_dlq',
|
|
123
|
+
totalRetriesBeforeSendToDlq: 2
|
|
124
|
+
},
|
|
125
|
+
handler,
|
|
126
|
+
queueDriver
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const onFinish = jest.fn()
|
|
130
|
+
consumer.on('finish', onFinish)
|
|
131
|
+
await consumer.start()
|
|
132
|
+
|
|
133
|
+
expect(handler).toHaveBeenCalledTimes(0)
|
|
134
|
+
expect(onFinish).toHaveBeenCalledTimes(0)
|
|
135
|
+
expect(queueDriver.delete).toHaveBeenCalledTimes(2)
|
|
136
|
+
expect(queueDriver.send).toHaveBeenCalledTimes(2)
|
|
137
|
+
expect(queueDriver.send).toHaveBeenCalledWith(
|
|
138
|
+
'q_dlq', messageToSendDlq.message, expect.any(AbortSignal)
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
it('Should send only 1 message to dlq if read count is greater than total retries before send to dlq', async () => {
|
|
144
|
+
const messageToSendDlq = { ...message }
|
|
145
|
+
messageToSendDlq.read_ct = 3
|
|
146
|
+
messageToSendDlq.msg_id = 2
|
|
147
|
+
|
|
148
|
+
const messageToSendDlq2 = { ...message }
|
|
149
|
+
messageToSendDlq2.read_ct = 1
|
|
150
|
+
messageToSendDlq2.msg_id = 3
|
|
151
|
+
queueDriver.get.mockResolvedValueOnce({
|
|
152
|
+
data: [
|
|
153
|
+
messageToSendDlq, messageToSendDlq2
|
|
154
|
+
], error: null
|
|
155
|
+
})
|
|
156
|
+
queueDriver.delete.mockResolvedValue({ error: null })
|
|
157
|
+
queueDriver.get.mockResolvedValueOnce({ data: [], error: null })
|
|
158
|
+
queueDriver.send.mockResolvedValue({ error: null })
|
|
159
|
+
|
|
160
|
+
const handler = jest.fn(async () => {
|
|
161
|
+
return Promise.resolve()
|
|
162
|
+
})
|
|
163
|
+
const consumer = new Consumer(
|
|
164
|
+
{
|
|
165
|
+
queueName: 'q',
|
|
166
|
+
consumeType: 'read',
|
|
167
|
+
visibilityTime: 1,
|
|
168
|
+
poolSize: 2,
|
|
169
|
+
timeMsWaitBeforeNextPolling: 1,
|
|
170
|
+
enabledPolling: true,
|
|
171
|
+
queueNameDlq: 'q_dlq',
|
|
172
|
+
totalRetriesBeforeSendToDlq: 2
|
|
173
|
+
},
|
|
174
|
+
handler,
|
|
175
|
+
queueDriver
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const onFinish = jest.fn()
|
|
179
|
+
consumer.on('finish', onFinish)
|
|
180
|
+
await consumer.start()
|
|
181
|
+
|
|
182
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
183
|
+
expect(onFinish).toHaveBeenCalledTimes(1)
|
|
184
|
+
expect(queueDriver.delete).toHaveBeenCalledTimes(2)
|
|
185
|
+
expect(queueDriver.send).toHaveBeenCalledTimes(1)
|
|
186
|
+
expect(queueDriver.send).toHaveBeenCalledWith(
|
|
187
|
+
'q_dlq', messageToSendDlq.message, expect.any(AbortSignal)
|
|
188
|
+
)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
|
|
31
192
|
|
|
32
193
|
it('Should not process message if read method does not return any message second polling', async () => {
|
|
33
194
|
queueDriver.get.mockResolvedValueOnce({ data: [message], error: null })
|
|
@@ -41,7 +202,8 @@ describe('Consumer', () => {
|
|
|
41
202
|
consumeType: 'read',
|
|
42
203
|
visibilityTime: 1,
|
|
43
204
|
poolSize: 1,
|
|
44
|
-
timeMsWaitBeforeNextPolling: 1
|
|
205
|
+
timeMsWaitBeforeNextPolling: 1,
|
|
206
|
+
enabledPolling: true
|
|
45
207
|
},
|
|
46
208
|
handler,
|
|
47
209
|
queueDriver
|
|
@@ -69,7 +231,8 @@ describe('Consumer', () => {
|
|
|
69
231
|
consumeType: 'pop',
|
|
70
232
|
visibilityTime: 1,
|
|
71
233
|
poolSize: 1,
|
|
72
|
-
timeMsWaitBeforeNextPolling: 1
|
|
234
|
+
timeMsWaitBeforeNextPolling: 1,
|
|
235
|
+
enabledPolling: true
|
|
73
236
|
},
|
|
74
237
|
handler,
|
|
75
238
|
queueDriver
|
|
@@ -95,7 +258,8 @@ describe('Consumer', () => {
|
|
|
95
258
|
consumeType: 'pop',
|
|
96
259
|
visibilityTime: 1,
|
|
97
260
|
poolSize: 1,
|
|
98
|
-
timeMsWaitBeforeNextPolling: 1
|
|
261
|
+
timeMsWaitBeforeNextPolling: 1,
|
|
262
|
+
enabledPolling: true
|
|
99
263
|
},
|
|
100
264
|
handler,
|
|
101
265
|
queueDriver
|
|
@@ -121,7 +285,8 @@ describe('Consumer', () => {
|
|
|
121
285
|
consumeType: 'read',
|
|
122
286
|
visibilityTime: 1,
|
|
123
287
|
poolSize: 1,
|
|
124
|
-
timeMsWaitBeforeNextPolling: 1
|
|
288
|
+
timeMsWaitBeforeNextPolling: 1,
|
|
289
|
+
enabledPolling: true
|
|
125
290
|
},
|
|
126
291
|
handler,
|
|
127
292
|
queueDriver
|
|
@@ -150,7 +315,8 @@ describe('Consumer', () => {
|
|
|
150
315
|
consumeType: 'read',
|
|
151
316
|
visibilityTime: 1,
|
|
152
317
|
poolSize: 1,
|
|
153
|
-
timeMsWaitBeforeNextPolling: 1
|
|
318
|
+
timeMsWaitBeforeNextPolling: 1,
|
|
319
|
+
enabledPolling: true
|
|
154
320
|
},
|
|
155
321
|
handler,
|
|
156
322
|
queueDriver
|
|
@@ -179,7 +345,8 @@ describe('Consumer', () => {
|
|
|
179
345
|
consumeType: 'read',
|
|
180
346
|
visibilityTime: 1,
|
|
181
347
|
poolSize: 4,
|
|
182
|
-
timeMsWaitBeforeNextPolling: 1
|
|
348
|
+
timeMsWaitBeforeNextPolling: 1,
|
|
349
|
+
enabledPolling: true
|
|
183
350
|
},
|
|
184
351
|
handler,
|
|
185
352
|
queueDriver
|