queen-mq 0.12.3 → 0.13.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.
@@ -0,0 +1,507 @@
1
+ /**
2
+ * Watermark Bug Tests
3
+ *
4
+ * These tests verify that consumer_watermarks are properly reset when:
5
+ * 1. Seeking a consumer group backwards to a timestamp
6
+ * 2. Deleting a consumer group (watermark should be cleaned up)
7
+ *
8
+ * The consumer_watermarks table optimizes POP by tracking when a consumer
9
+ * last found the queue empty. However, if the watermark isn't reset when
10
+ * the cursor moves backwards, old messages become invisible.
11
+ *
12
+ * BUG: Currently, seek and delete operations don't touch consumer_watermarks,
13
+ * causing re-consumption to fail silently.
14
+ *
15
+ * IMPORTANT: The watermark filter has a 2-minute buffer:
16
+ * pl.updated_at >= (v_watermark - interval '2 minutes')
17
+ *
18
+ * So the bug only manifests after 2+ minutes have passed. To test this without
19
+ * waiting, we directly manipulate the watermark timestamp in the database.
20
+ *
21
+ * Run: node run.js seekBackwardsAllowsReconsume
22
+ * node run.js deleteConsumerGroupAllowsReconsume
23
+ */
24
+
25
+ import { dbPool } from './run.js'
26
+
27
+ const QUEUE_NAME_SEEK = 'test-queue-v2-watermark-seek'
28
+ const QUEUE_NAME_DELETE = 'test-queue-v2-watermark-delete'
29
+
30
+ /**
31
+ * Helper: Move the watermark FORWARD in time to simulate that partitions
32
+ * are "old" relative to the watermark.
33
+ *
34
+ * The filter is: pl.updated_at >= (v_watermark - interval '2 minutes')
35
+ *
36
+ * If watermark = NOW + 10 minutes, then:
37
+ * filter = pl.updated_at >= (NOW + 10 - 2) = pl.updated_at >= NOW + 8
38
+ *
39
+ * Partitions updated at NOW will fail this filter (NOW < NOW + 8)
40
+ */
41
+ async function advanceWatermark(queueName, consumerGroup, minutesForward = 10) {
42
+ const result = await dbPool.query(`
43
+ UPDATE queen.consumer_watermarks
44
+ SET last_empty_scan_at = NOW() + interval '${minutesForward} minutes',
45
+ updated_at = NOW() + interval '${minutesForward} minutes'
46
+ WHERE queue_name = $1 AND consumer_group = $2
47
+ RETURNING *
48
+ `, [queueName, consumerGroup])
49
+ return result.rows[0]
50
+ }
51
+
52
+ /**
53
+ * Helper: Check if watermark exists for a queue/consumer_group
54
+ */
55
+ async function getWatermark(queueName, consumerGroup) {
56
+ const result = await dbPool.query(`
57
+ SELECT * FROM queen.consumer_watermarks
58
+ WHERE queue_name = $1 AND consumer_group = $2
59
+ `, [queueName, consumerGroup])
60
+ return result.rows[0]
61
+ }
62
+
63
+ /**
64
+ * Test: Seeking backwards should allow re-consumption of messages
65
+ *
66
+ * Steps:
67
+ * 1. Push messages to queue
68
+ * 2. Consume all messages with a consumer group (watermark advances)
69
+ * 3. Artificially age the watermark (simulate 10 minutes passing)
70
+ * 4. Seek the consumer group backwards to before the messages
71
+ * 5. Try to consume again - should get all messages
72
+ *
73
+ * Expected: All messages are re-consumed after seek
74
+ * Actual (bug): Watermark blocks re-consumption, 0 messages returned
75
+ */
76
+ export async function seekBackwardsAllowsReconsume(client) {
77
+ const consumerGroup = 'watermark-seek-test-cg'
78
+
79
+ // Cleanup
80
+ try {
81
+ await client.deleteConsumerGroup(consumerGroup, true)
82
+ // Also clean up any orphaned watermark from previous runs
83
+ await dbPool.query(
84
+ 'DELETE FROM queen.consumer_watermarks WHERE consumer_group = $1',
85
+ [consumerGroup]
86
+ )
87
+ } catch (e) {
88
+ // Ignore
89
+ }
90
+
91
+ // 1. Create queue
92
+ const queue = await client.queue(QUEUE_NAME_SEEK).create()
93
+ if (!queue.configured) {
94
+ return { success: false, message: 'Queue not created' }
95
+ }
96
+
97
+ // 2. Record timestamp BEFORE pushing messages
98
+ const timestampBeforeMessages = new Date().toISOString()
99
+
100
+ // Small delay to ensure timestamp ordering
101
+ await new Promise(resolve => setTimeout(resolve, 100))
102
+
103
+ // 3. Push messages
104
+ const messageCount = 10
105
+ for (let i = 0; i < messageCount; i++) {
106
+ await client
107
+ .queue(QUEUE_NAME_SEEK)
108
+ .partition(`p-${i}`)
109
+ .push([{ data: { id: i, batch: 'original' } }])
110
+ }
111
+
112
+ console.log(` Pushed ${messageCount} messages`)
113
+
114
+ // 4. Consume all messages with consumer group
115
+ let firstConsumeCount = 0
116
+ await client
117
+ .queue(QUEUE_NAME_SEEK)
118
+ .group(consumerGroup)
119
+ .subscriptionMode('all')
120
+ .concurrency(5)
121
+ .batch(1)
122
+ .wait(false)
123
+ .idleMillis(3000)
124
+ .consume(async msgs => {
125
+ firstConsumeCount += msgs.length
126
+ })
127
+
128
+ console.log(` First consume: ${firstConsumeCount} messages`)
129
+
130
+ if (firstConsumeCount !== messageCount) {
131
+ return {
132
+ success: false,
133
+ message: `First consume should get ${messageCount} messages, got ${firstConsumeCount}`
134
+ }
135
+ }
136
+
137
+ // 5. Verify queue is empty for this consumer (triggers watermark advancement)
138
+ const emptyCheck = await client
139
+ .queue(QUEUE_NAME_SEEK)
140
+ .group(consumerGroup)
141
+ .batch(10)
142
+ .wait(false)
143
+ .pop()
144
+
145
+ if (emptyCheck.length !== 0) {
146
+ return {
147
+ success: false,
148
+ message: `Queue should be empty after first consume, got ${emptyCheck.length} messages`
149
+ }
150
+ }
151
+
152
+ console.log(` Verified queue is empty (watermark should be set)`)
153
+
154
+ // 6. Check watermark was created
155
+ const watermarkBefore = await getWatermark(QUEUE_NAME_SEEK, consumerGroup)
156
+ if (!watermarkBefore) {
157
+ return {
158
+ success: false,
159
+ message: 'Watermark was not created after empty queue check'
160
+ }
161
+ }
162
+ console.log(` Watermark exists: last_empty_scan_at = ${watermarkBefore.last_empty_scan_at}`)
163
+
164
+ // 7. CRITICAL: Advance the watermark forward to simulate that partitions are "old"
165
+ // relative to the watermark. This triggers the bug where old partitions are filtered out.
166
+ const advancedWatermark = await advanceWatermark(QUEUE_NAME_SEEK, consumerGroup, 10)
167
+ console.log(` Advanced watermark to: ${advancedWatermark.last_empty_scan_at} (10 minutes in future)`)
168
+
169
+ // 8. Seek backwards to BEFORE the messages were pushed
170
+ console.log(` Seeking to timestamp: ${timestampBeforeMessages}`)
171
+
172
+ const seekResult = await client.admin.seekConsumerGroup(
173
+ consumerGroup,
174
+ QUEUE_NAME_SEEK,
175
+ { timestamp: timestampBeforeMessages }
176
+ )
177
+
178
+ console.log(` Seek result: ${JSON.stringify(seekResult)}`)
179
+
180
+ // 9. Check if watermark was reset (it should be, but currently isn't - that's the bug)
181
+ const watermarkAfterSeek = await getWatermark(QUEUE_NAME_SEEK, consumerGroup)
182
+ if (watermarkAfterSeek) {
183
+ console.log(` WARNING: Watermark still exists after seek: ${watermarkAfterSeek.last_empty_scan_at}`)
184
+ } else {
185
+ console.log(` Watermark was properly deleted after seek`)
186
+ }
187
+
188
+ // Small delay after seek
189
+ await new Promise(resolve => setTimeout(resolve, 500))
190
+
191
+ // 10. Try to consume again - should get all messages back
192
+ let secondConsumeCount = 0
193
+ await client
194
+ .queue(QUEUE_NAME_SEEK)
195
+ .group(consumerGroup)
196
+ .concurrency(5)
197
+ .batch(1)
198
+ .wait(false)
199
+ .idleMillis(3000)
200
+ .consume(async msgs => {
201
+ secondConsumeCount += msgs.length
202
+ })
203
+
204
+ console.log(` Second consume (after seek): ${secondConsumeCount} messages`)
205
+
206
+ // 11. Verify we got all messages back
207
+ if (secondConsumeCount !== messageCount) {
208
+ return {
209
+ success: false,
210
+ message: `After seek backwards, should re-consume ${messageCount} messages, but got ${secondConsumeCount}. ` +
211
+ `This is because consumer_watermarks was not reset on seek (watermark still at ${watermarkAfterSeek?.last_empty_scan_at}).`
212
+ }
213
+ }
214
+
215
+ return {
216
+ success: true,
217
+ message: `Seek backwards allowed re-consumption of ${secondConsumeCount} messages`
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Test: Deleting consumer group should allow fresh consumption
223
+ *
224
+ * Steps:
225
+ * 1. Push messages to queue
226
+ * 2. Consume all messages with a consumer group (watermark advances)
227
+ * 3. Artificially age the watermark (simulate 10 minutes passing)
228
+ * 4. Delete the consumer group
229
+ * 5. Create a new consumer group with the SAME name
230
+ * 6. Try to consume - should get all messages
231
+ *
232
+ * Expected: All messages are consumed by the "new" consumer group
233
+ * Actual (bug): Orphaned watermark blocks consumption, 0 messages returned
234
+ */
235
+ export async function deleteConsumerGroupAllowsReconsume(client) {
236
+ const consumerGroup = 'watermark-delete-test-cg'
237
+
238
+ // Cleanup
239
+ try {
240
+ await client.deleteConsumerGroup(consumerGroup, true)
241
+ // Also clean up any orphaned watermark from previous runs
242
+ await dbPool.query(
243
+ 'DELETE FROM queen.consumer_watermarks WHERE consumer_group = $1',
244
+ [consumerGroup]
245
+ )
246
+ } catch (e) {
247
+ // Ignore
248
+ }
249
+
250
+ // 1. Create queue
251
+ const queue = await client.queue(QUEUE_NAME_DELETE).create()
252
+ if (!queue.configured) {
253
+ return { success: false, message: 'Queue not created' }
254
+ }
255
+
256
+ // 2. Push messages
257
+ const messageCount = 10
258
+ for (let i = 0; i < messageCount; i++) {
259
+ await client
260
+ .queue(QUEUE_NAME_DELETE)
261
+ .partition(`p-${i}`)
262
+ .push([{ data: { id: i, batch: 'original' } }])
263
+ }
264
+
265
+ console.log(` Pushed ${messageCount} messages`)
266
+
267
+ // 3. Consume all messages with consumer group
268
+ let firstConsumeCount = 0
269
+ await client
270
+ .queue(QUEUE_NAME_DELETE)
271
+ .group(consumerGroup)
272
+ .subscriptionMode('all')
273
+ .concurrency(5)
274
+ .batch(1)
275
+ .wait(false)
276
+ .idleMillis(3000)
277
+ .consume(async msgs => {
278
+ firstConsumeCount += msgs.length
279
+ })
280
+
281
+ console.log(` First consume: ${firstConsumeCount} messages`)
282
+
283
+ if (firstConsumeCount !== messageCount) {
284
+ return {
285
+ success: false,
286
+ message: `First consume should get ${messageCount} messages, got ${firstConsumeCount}`
287
+ }
288
+ }
289
+
290
+ // 4. Verify queue is empty for this consumer (triggers watermark advancement)
291
+ const emptyCheck = await client
292
+ .queue(QUEUE_NAME_DELETE)
293
+ .group(consumerGroup)
294
+ .batch(10)
295
+ .wait(false)
296
+ .pop()
297
+
298
+ if (emptyCheck.length !== 0) {
299
+ return {
300
+ success: false,
301
+ message: `Queue should be empty after first consume, got ${emptyCheck.length} messages`
302
+ }
303
+ }
304
+
305
+ console.log(` Verified queue is empty (watermark should be set)`)
306
+
307
+ // 5. Check watermark was created
308
+ const watermarkBefore = await getWatermark(QUEUE_NAME_DELETE, consumerGroup)
309
+ if (!watermarkBefore) {
310
+ return {
311
+ success: false,
312
+ message: 'Watermark was not created after empty queue check'
313
+ }
314
+ }
315
+ console.log(` Watermark exists: last_empty_scan_at = ${watermarkBefore.last_empty_scan_at}`)
316
+
317
+ // 6. CRITICAL: Advance the watermark forward to simulate that partitions are "old"
318
+ const advancedWatermark = await advanceWatermark(QUEUE_NAME_DELETE, consumerGroup, 10)
319
+ console.log(` Advanced watermark to: ${advancedWatermark.last_empty_scan_at} (10 minutes in future)`)
320
+
321
+ // 7. Delete the consumer group
322
+ console.log(` Deleting consumer group: ${consumerGroup}`)
323
+
324
+ const deleteResult = await client.deleteConsumerGroup(consumerGroup, true)
325
+ console.log(` Delete result: ${JSON.stringify(deleteResult)}`)
326
+
327
+ // 8. Check if watermark was deleted (it should be, but currently isn't - that's the bug)
328
+ const watermarkAfterDelete = await getWatermark(QUEUE_NAME_DELETE, consumerGroup)
329
+ if (watermarkAfterDelete) {
330
+ console.log(` WARNING: Orphaned watermark still exists: ${watermarkAfterDelete.last_empty_scan_at}`)
331
+ } else {
332
+ console.log(` Watermark was properly deleted`)
333
+ }
334
+
335
+ // Small delay after delete
336
+ await new Promise(resolve => setTimeout(resolve, 500))
337
+
338
+ // 9. Create a "new" consumer group with the same name and try to consume
339
+ // Since partition_consumers was deleted, this should start fresh
340
+ // But if consumer_watermarks wasn't deleted, it will skip all messages
341
+ let secondConsumeCount = 0
342
+ await client
343
+ .queue(QUEUE_NAME_DELETE)
344
+ .group(consumerGroup)
345
+ .subscriptionMode('all')
346
+ .concurrency(5)
347
+ .batch(1)
348
+ .wait(false)
349
+ .idleMillis(3000)
350
+ .consume(async msgs => {
351
+ secondConsumeCount += msgs.length
352
+ })
353
+
354
+ console.log(` Second consume (after delete + recreate): ${secondConsumeCount} messages`)
355
+
356
+ // 10. Verify we got all messages
357
+ if (secondConsumeCount !== messageCount) {
358
+ return {
359
+ success: false,
360
+ message: `After deleting CG, new CG with same name should consume ${messageCount} messages, ` +
361
+ `but got ${secondConsumeCount}. Orphaned watermark at ${watermarkAfterDelete?.last_empty_scan_at}.`
362
+ }
363
+ }
364
+
365
+ return {
366
+ success: true,
367
+ message: `Delete + recreate allowed fresh consumption of ${secondConsumeCount} messages`
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Test: Deleting consumer group for specific queue should allow fresh consumption
373
+ *
374
+ * Similar to above but uses deleteConsumerGroupForQueue instead of deleteConsumerGroup
375
+ */
376
+ export async function deleteConsumerGroupForQueueAllowsReconsume(client) {
377
+ const consumerGroup = 'watermark-delete-queue-test-cg'
378
+ const queueName = 'test-queue-v2-watermark-delete-queue'
379
+
380
+ // Cleanup
381
+ try {
382
+ await client.admin.deleteConsumerGroupForQueue(consumerGroup, queueName, true)
383
+ // Also clean up any orphaned watermark from previous runs
384
+ await dbPool.query(
385
+ 'DELETE FROM queen.consumer_watermarks WHERE queue_name = $1 AND consumer_group = $2',
386
+ [queueName, consumerGroup]
387
+ )
388
+ } catch (e) {
389
+ // Ignore
390
+ }
391
+
392
+ // 1. Create queue
393
+ const queue = await client.queue(queueName).create()
394
+ if (!queue.configured) {
395
+ return { success: false, message: 'Queue not created' }
396
+ }
397
+
398
+ // 2. Push messages
399
+ const messageCount = 10
400
+ for (let i = 0; i < messageCount; i++) {
401
+ await client
402
+ .queue(queueName)
403
+ .partition(`p-${i}`)
404
+ .push([{ data: { id: i, batch: 'original' } }])
405
+ }
406
+
407
+ console.log(` Pushed ${messageCount} messages`)
408
+
409
+ // 3. Consume all messages with consumer group
410
+ let firstConsumeCount = 0
411
+ await client
412
+ .queue(queueName)
413
+ .group(consumerGroup)
414
+ .subscriptionMode('all')
415
+ .concurrency(5)
416
+ .batch(1)
417
+ .wait(false)
418
+ .idleMillis(3000)
419
+ .consume(async msgs => {
420
+ firstConsumeCount += msgs.length
421
+ })
422
+
423
+ console.log(` First consume: ${firstConsumeCount} messages`)
424
+
425
+ if (firstConsumeCount !== messageCount) {
426
+ return {
427
+ success: false,
428
+ message: `First consume should get ${messageCount} messages, got ${firstConsumeCount}`
429
+ }
430
+ }
431
+
432
+ // 4. Verify queue is empty (triggers watermark)
433
+ const emptyCheck = await client
434
+ .queue(queueName)
435
+ .group(consumerGroup)
436
+ .batch(10)
437
+ .wait(false)
438
+ .pop()
439
+
440
+ if (emptyCheck.length !== 0) {
441
+ return {
442
+ success: false,
443
+ message: `Queue should be empty after first consume, got ${emptyCheck.length} messages`
444
+ }
445
+ }
446
+
447
+ console.log(` Verified queue is empty (watermark should be set)`)
448
+
449
+ // 5. Check watermark was created
450
+ const watermarkBefore = await getWatermark(queueName, consumerGroup)
451
+ if (!watermarkBefore) {
452
+ return {
453
+ success: false,
454
+ message: 'Watermark was not created after empty queue check'
455
+ }
456
+ }
457
+ console.log(` Watermark exists: last_empty_scan_at = ${watermarkBefore.last_empty_scan_at}`)
458
+
459
+ // 6. CRITICAL: Advance the watermark forward
460
+ const advancedWatermark = await advanceWatermark(queueName, consumerGroup, 10)
461
+ console.log(` Advanced watermark to: ${advancedWatermark.last_empty_scan_at} (10 minutes in future)`)
462
+
463
+ // 7. Delete consumer group for this specific queue
464
+ console.log(` Deleting consumer group for queue: ${consumerGroup} / ${queueName}`)
465
+
466
+ const deleteResult = await client.admin.deleteConsumerGroupForQueue(consumerGroup, queueName, true)
467
+ console.log(` Delete result: ${JSON.stringify(deleteResult)}`)
468
+
469
+ // 8. Check if watermark was deleted
470
+ const watermarkAfterDelete = await getWatermark(queueName, consumerGroup)
471
+ if (watermarkAfterDelete) {
472
+ console.log(` WARNING: Orphaned watermark still exists: ${watermarkAfterDelete.last_empty_scan_at}`)
473
+ } else {
474
+ console.log(` Watermark was properly deleted`)
475
+ }
476
+
477
+ await new Promise(resolve => setTimeout(resolve, 500))
478
+
479
+ // 9. Try to consume again
480
+ let secondConsumeCount = 0
481
+ await client
482
+ .queue(queueName)
483
+ .group(consumerGroup)
484
+ .subscriptionMode('all')
485
+ .concurrency(5)
486
+ .batch(1)
487
+ .wait(false)
488
+ .idleMillis(3000)
489
+ .consume(async msgs => {
490
+ secondConsumeCount += msgs.length
491
+ })
492
+
493
+ console.log(` Second consume (after delete): ${secondConsumeCount} messages`)
494
+
495
+ if (secondConsumeCount !== messageCount) {
496
+ return {
497
+ success: false,
498
+ message: `After deleting CG for queue, should consume ${messageCount} messages, ` +
499
+ `but got ${secondConsumeCount}. Orphaned watermark at ${watermarkAfterDelete?.last_empty_scan_at}.`
500
+ }
501
+ }
502
+
503
+ return {
504
+ success: true,
505
+ message: `Delete CG for queue allowed fresh consumption of ${secondConsumeCount} messages`
506
+ }
507
+ }