consumer-pgmq 2.0.1 → 3.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "consumer-pgmq",
3
- "version": "2.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "The consumer of Supabase pgmq",
5
5
  "main": "dist/index.js",
6
6
  "type": "commonjs",
@@ -22,7 +22,9 @@
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",
27
+ "ts-node": "^10.9.2",
26
28
  "ts-node-dev": "^2.0.0",
27
29
  "typescript": "^5.9.2"
28
30
  },
@@ -30,7 +32,6 @@
30
32
  "@supabase/supabase-js": "^2.56.0",
31
33
  "dotenv": "^17.2.1",
32
34
  "jest": "^30.1.1",
33
- "knex": "^3.1.0",
34
35
  "pg": "^8.16.3"
35
36
  },
36
37
  "engines": {
package/schema.sql ADDED
@@ -0,0 +1,50 @@
1
+ CREATE TABLE workers (
2
+ id SERIAL PRIMARY KEY,
3
+ status worker_status NOT NULL DEFAULT 'idle', -- Tracks the job lifecycle
4
+ created_at TIMESTAMP DEFAULT now(),
5
+ updated_at TIMESTAMP DEFAULT now()
6
+ );
7
+
8
+ CREATE TABLE jobs (
9
+ id SERIAL PRIMARY KEY,
10
+ status job_status NOT NULL DEFAULT 'pending', -- Tracks the job lifecycle
11
+ payload JSONB, -- Chose payload as `jsonb` but could have gone with `bytea` instead.
12
+ visible_at TIMESTAMP DEFAULT now(), -- SQS visibility timeout (will come back to this later)
13
+ retry_count INT DEFAULT 0, -- Tracks retry attempts
14
+ created_at TIMESTAMP DEFAULT now(),
15
+ updated_at TIMESTAMP DEFAULT now()
16
+ );
17
+
18
+ CREATE TABLE jobs_dlq (
19
+ id SERIAL PRIMARY KEY,
20
+ status job_status NOT NULL DEFAULT 'pending', -- Tracks the job lifecycle
21
+ payload JSONB, -- Chose payload as `jsonb` but could have gone with `bytea` instead.
22
+ visible_at TIMESTAMP DEFAULT now(), -- SQS visibility timeout (will come back to this later)
23
+ retry_count INT DEFAULT 0, -- Tracks retry attempts
24
+ created_at TIMESTAMP DEFAULT now(),
25
+ updated_at TIMESTAMP DEFAULT now()
26
+ );
27
+
28
+
29
+ WITH next_workers AS (
30
+ SELECT id
31
+ FROM workers as w
32
+ WHERE
33
+ status = 'idle'
34
+ LIMIT 1
35
+ FOR UPDATE SKIP LOCKED
36
+ )
37
+ UPDATE workers
38
+ SET status = 'working',
39
+ updated_at = now()
40
+ FROM next_workers
41
+ WHERE workers.id = next_workers.id
42
+ RETURNING workers.*;
43
+
44
+ CREATE INDEX ON public.workers (status);
45
+
46
+ insert into workers(status)
47
+ values ('idle'),
48
+ ('idle');
49
+
50
+
package/src/consumer.ts CHANGED
@@ -22,6 +22,10 @@ class Consumer extends EventEmitter {
22
22
  */
23
23
  private client: QueueDriver
24
24
 
25
+ private setTimeoutId: NodeJS.Timeout | null = null;
26
+
27
+ private id: string | null = null;
28
+
25
29
  constructor(
26
30
  options: Options,
27
31
  callback: HandlerCallback,
@@ -31,6 +35,14 @@ class Consumer extends EventEmitter {
31
35
  this.options = options;
32
36
  this.callback = callback;
33
37
  this.client = client;
38
+ this.valideOptions();
39
+ this.setTimeoutId = null;
40
+ }
41
+
42
+ private valideOptions() {
43
+ if (this.options.queueNameDlq && !this.options.totalRetriesBeforeSendToDlq) {
44
+ throw new Error("The option totalRetriesBeforeSendToDlq is required when queueNameDlq is set");
45
+ }
34
46
  }
35
47
 
36
48
  /**
@@ -84,6 +96,9 @@ class Consumer extends EventEmitter {
84
96
  * @private
85
97
  */
86
98
  private async pollMessage() {
99
+ if (this.setTimeoutId) {
100
+ clearTimeout(this.setTimeoutId);
101
+ }
87
102
  let promises: Promise<any>[] = [];
88
103
 
89
104
  try {
@@ -93,18 +108,39 @@ class Consumer extends EventEmitter {
93
108
  }
94
109
 
95
110
  if (data.length === 0 && this.options.enabledPolling) {
96
- setTimeout(
111
+ this.setTimeoutId = setTimeout(
97
112
  () => this.pollMessage(),
98
113
  (this.options.timeMsWaitBeforeNextPolling || 1000) * 10
99
114
  );
100
115
  return;
101
116
  }
102
117
 
103
-
104
118
  const controller = new AbortController();
105
119
  const signal = controller.signal;
106
120
 
107
- for (let i = 0; i < (data.length || 1); i++) {
121
+ for (let i = 0; i < data.length; i++) {
122
+ const hasSendToDlq = data[i] &&
123
+ this.options.queueNameDlq &&
124
+ this.options.totalRetriesBeforeSendToDlq &&
125
+ data[i].read_ct > this.options.totalRetriesBeforeSendToDlq
126
+ if (
127
+ hasSendToDlq
128
+ ) {
129
+ promises.push(
130
+ this.client.send(
131
+ // @ts-ignore
132
+ this.options.queueNameDlq,
133
+ data[i].message,
134
+ signal
135
+ ).then(async () => {
136
+ await this.deleteMessage(data[i], signal);
137
+ this.emit('send-to-dlq', data[i]);
138
+ })
139
+ )
140
+ continue;
141
+ }
142
+
143
+
108
144
  promises.push(
109
145
  this.callback(data[i].message, signal).then(async () => {
110
146
  await this.deleteMessage(data[i], signal);
@@ -113,8 +149,12 @@ class Consumer extends EventEmitter {
113
149
  );
114
150
  }
115
151
 
116
- setTimeout(() => controller.abort(), (this.options.visibilityTime || 1) * 1000);
117
- await Promise.allSettled(promises);
152
+ const timeoutId = setTimeout(() => controller.abort(), (this.options.visibilityTime || 1) * 1000);
153
+ if (promises.length > 0) {
154
+ await Promise.allSettled(promises);
155
+ }
156
+
157
+ clearTimeout(timeoutId);
118
158
  promises = [];
119
159
  } catch (err: any) {
120
160
  if (err.name === "AbortError") {
@@ -126,7 +166,7 @@ class Consumer extends EventEmitter {
126
166
  if (!this.options.enabledPolling) {
127
167
  return;
128
168
  }
129
- setTimeout(() => this.pollMessage(), this.options.timeMsWaitBeforeNextPolling || 1000);
169
+ this.setTimeoutId = setTimeout(() => this.pollMessage(), this.options.timeMsWaitBeforeNextPolling || 1000);
130
170
  }
131
171
  }
132
172
 
@@ -136,8 +176,21 @@ class Consumer extends EventEmitter {
136
176
  * @public
137
177
  */
138
178
  async start() {
179
+ if (this.options.enableControlConsumer) {
180
+ const { id } = await this.client.allocateConsumer()
181
+ this.id = id
182
+ }
183
+
184
+
139
185
  await this.pollMessage();
140
186
  }
187
+
188
+ async freeConsumer() {
189
+ if (this.id) {
190
+ console.log("passed on here")
191
+ await this.client.freeConsumer(this.id)
192
+ }
193
+ }
141
194
  }
142
195
 
143
196
 
@@ -1,14 +1,152 @@
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: Knex,
9
- private schema: string = "public"
8
+ private connection: Client,
9
+ private schema: string = "public",
10
+ private isCustomQueueImplementation = true
10
11
  ) { }
11
12
 
13
+ /**
14
+ * Allocate the consumer
15
+ * @returns Promise<{ id: string; }>
16
+ */
17
+ async allocateConsumer(): Promise<{ id: string; }> {
18
+ const register = await this.connection.query(`
19
+ WITH next_workers AS (
20
+ SELECT id
21
+ FROM workers as w
22
+ WHERE
23
+ status = 'idle'
24
+ LIMIT 1
25
+ FOR UPDATE SKIP LOCKED
26
+ )
27
+ UPDATE workers
28
+ SET status = 'working',
29
+ updated_at = now()
30
+ FROM next_workers
31
+ WHERE workers.id = next_workers.id
32
+ RETURNING workers.*;
33
+ `)
34
+
35
+ if (register.rows.length == 0) {
36
+ throw new Error("No available consumer(worker) to allocate");
37
+ }
38
+
39
+ return { id: register.rows[0].id };
40
+ }
41
+
42
+
43
+ async freeConsumer(id: string): Promise<void> {
44
+ try {
45
+ await this.connection.query(`UPDATE workers set status = 'idle' WHERE id = $1`, [id])
46
+ } catch (error) {
47
+ throw error
48
+ }
49
+ }
50
+
51
+
52
+ private async sendCustomQueue(
53
+ queueName: string,
54
+ message: { [key: string]: any; }
55
+ ) {
56
+ try {
57
+ const query = `INSERT INTO ${this.schema}.${queueName}(payload) VALUES($1)`
58
+ await this.connection.query(query, [JSON.stringify(message)])
59
+ return { error: null };
60
+ } catch (error) {
61
+ return { error };
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Send the message
67
+ * @param queueName The name of the queue
68
+ * @param message The message
69
+ * @returns Promise<{ error: any }>
70
+ */
71
+ async send(
72
+ queueName: string,
73
+ message: { [key: string]: any; },
74
+ ): Promise<{ error: any; }> {
75
+ if (this.isCustomQueueImplementation) {
76
+ return this.sendCustomQueue(queueName, message)
77
+ }
78
+
79
+ try {
80
+ await this.connection.query(`
81
+ SELECT * FROM ${this.schema}.send(
82
+ queue_name => $1,
83
+ msg => $2,
84
+ delay => $3
85
+ );
86
+ `, [queueName, message, 1]
87
+ )
88
+
89
+ return { error: null };
90
+ } catch (error) {
91
+ return { error };
92
+ }
93
+ }
94
+
95
+ private async getCustomQueue(
96
+ queueName: string,
97
+ visibilityTime: number,
98
+ totalMessages: number
99
+ ): Promise<{ data: Message[]; error: any; }> {
100
+ try {
101
+ const register = await this.connection.query(
102
+ `
103
+ WITH next_job AS (
104
+ SELECT id
105
+ FROM ${queueName} as jobs
106
+ WHERE
107
+ (
108
+ status = 'pending'
109
+ OR (status = 'in_progress' AND visible_at <= now())
110
+ )
111
+ ORDER BY created_at
112
+ LIMIT $1
113
+ FOR UPDATE SKIP LOCKED
114
+ )
115
+ UPDATE jobs
116
+ SET status = 'in_progress',
117
+ updated_at = now(),
118
+ visible_at = now() + interval '${visibilityTime} seconds',
119
+ retry_count = retry_count + 1
120
+ FROM next_job
121
+ WHERE jobs.id = next_job.id
122
+ RETURNING jobs.*;
123
+ `,
124
+ [
125
+ totalMessages,
126
+ ]
127
+ );
128
+
129
+ if (!register.rows) {
130
+ return { data: [], error: null };
131
+ }
132
+
133
+ const items: Message[] = [];
134
+ for (const row of register.rows) {
135
+ items.push({
136
+ msg_id: row.id,
137
+ read_ct: row.retry_count,
138
+ enqueued_at: row.created_at,
139
+ vt: row.visible_at,
140
+ message: row.payload, // Assuming the message content is stored in a column named 'payload' or '
141
+ });
142
+ }
143
+
144
+ return { data: items, error: null };
145
+ } catch (error) {
146
+ return { data: [], error };
147
+ }
148
+ }
149
+
12
150
  /**
13
151
  * Get the message
14
152
  * @param queueName The name of the queue
@@ -17,12 +155,16 @@ class PostgresQueueDriver implements QueueDriver {
17
155
  * @returns Promise<{ data: Message[], error: any }>
18
156
  */
19
157
  async get(queueName: string, visibilityTime: number, totalMessages: number): Promise<{ data: Message[]; error: any; }> {
158
+ if (this.isCustomQueueImplementation) {
159
+ return this.getCustomQueue(queueName, visibilityTime, totalMessages);
160
+ }
161
+
20
162
  try {
21
- const register = await this.connection.raw(`
163
+ const register = await this.connection.query(`
22
164
  SELECT * FROM ${this.schema}.read(
23
- queue_name => ?,
24
- vt => ?,
25
- qty => ?
165
+ queue_name => $1,
166
+ vt => $2,
167
+ qty => $3
26
168
  );
27
169
  `, [queueName, visibilityTime, totalMessages]
28
170
  )
@@ -44,11 +186,18 @@ class PostgresQueueDriver implements QueueDriver {
44
186
  * @returns Promise<{ data: Message[], error: any }>
45
187
  */
46
188
  async pop(queueName: string): Promise<{ data: Message[]; error: any; }> {
189
+ if (this.isCustomQueueImplementation) {
190
+ const result = await this.getCustomQueue(queueName, 30, 1);
191
+ if (result.data && result.data[0]) {
192
+ await this.delete(queueName, result.data[0].msg_id);
193
+ }
194
+ return result
195
+ }
47
196
  try {
48
- const register = await this.connection.raw(`
197
+ const register = await this.connection.query(`
49
198
  SELECT * FROM ${this.schema}.pop(
50
- queue_name => ?
51
- );
199
+ queue_name => $1
200
+ )
52
201
  `, [queueName]
53
202
  )
54
203
 
@@ -63,6 +212,14 @@ class PostgresQueueDriver implements QueueDriver {
63
212
 
64
213
  }
65
214
 
215
+ private async deleteCustomQueue(queueName: string, messageID: number) {
216
+ await this.connection.query(`
217
+ DELETE FROM ${queueName}
218
+ WHERE id = $1;`, [messageID]
219
+ )
220
+ return { error: null };
221
+ }
222
+
66
223
  /**
67
224
  * Delete the message
68
225
  * @param queueName The name of the queue
@@ -71,12 +228,16 @@ class PostgresQueueDriver implements QueueDriver {
71
228
  */
72
229
  async delete(queueName: string, messageID: number): Promise<{ error: any; }> {
73
230
  try {
74
- await this.connection.raw(`
231
+ if (this.isCustomQueueImplementation) {
232
+ return this.deleteCustomQueue(queueName, messageID)
233
+ }
234
+ await this.connection.query(`
75
235
  SELECT * FROM ${this.schema}.delete(
76
- queue_name => ?,
77
- msg_id => ?
236
+ queue_name => $1,
237
+ msg_id => $2
78
238
  );
79
- `, [queueName, messageID]
239
+ `, [queueName, messageID],
240
+
80
241
  )
81
242
 
82
243
  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
@@ -52,6 +67,14 @@ class SupabaseQueueDriver implements QueueDriver {
52
67
  return { error };
53
68
  }
54
69
 
70
+ allocateConsumer(): Promise<{ id: string; }> {
71
+ throw new Error("method logic no implemented")
72
+ }
73
+
74
+ freeConsumer(id: string): Promise<void> {
75
+ throw new Error("method logic no implemented")
76
+ }
77
+
55
78
  }
56
79
 
57
80
  export default SupabaseQueueDriver;
package/src/type.ts CHANGED
@@ -30,6 +30,21 @@ 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;
43
+
44
+ /**
45
+ * The enable control consumer. PS: if true, set the allocate a consumer from table workers, because that way you can control the number of consumers(workers)
46
+ */
47
+ enableControlConsumer?: boolean;
33
48
  }
34
49
 
35
50
  /**
@@ -65,6 +80,18 @@ interface Message {
65
80
 
66
81
  interface QueueDriver {
67
82
 
83
+ /**
84
+ * Send the message
85
+ * @param queueName The name of the queue
86
+ * @param message The message
87
+ * @returns Promise<{ error: any }>
88
+ */
89
+ send(
90
+ queueName: string,
91
+ message: { [key: string]: any },
92
+ signal: AbortSignal
93
+ ): Promise<{ error: any }>;
94
+
68
95
  /**
69
96
  * Get the message
70
97
  * @param queueName The name of the queue
@@ -97,6 +124,10 @@ interface QueueDriver {
97
124
  queueName: string,
98
125
  messageID: number
99
126
  ): Promise<{ error: any }>;
127
+
128
+ allocateConsumer(): Promise<{ id: string }>;
129
+
130
+ freeConsumer(id: string): Promise<void>;
100
131
  }
101
132
 
102
133
  export type { Options, HandlerCallback, Message, QueueDriver }