queen-mq 0.16.0 → 1.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.
@@ -35,7 +35,7 @@ export function mkName(testName, suffix = '') {
35
35
  return `test-stream-${slug}-${stamp}-${_nameCounter}${tail}`
36
36
  }
37
37
 
38
- export const STREAMS_URL = process.env.QUEEN_URL || 'http://localhost:6632'
38
+ export const STREAMS_URL = process.env.QUEEN_URL || process.env.QUEEN_SERVER_URL || 'http://localhost:6632'
39
39
 
40
40
  /**
41
41
  * Sleep helper.
@@ -249,12 +249,16 @@ export async function tumblingIdleFlushClosesQuietPartitions(client) {
249
249
  await client.queue(src).create()
250
250
  await client.queue(sink).create()
251
251
 
252
- // Push 3 messages then go silent — without idle flush, the window would
253
- // stay open forever. With a 700ms flush cadence and 1-second windows,
254
- // the flush should close the window within ~2 seconds of the last push.
255
- for (let i = 0; i < 3; i++) {
256
- await client.queue(src).partition('quiet').push([{ data: { v: 7 } }])
257
- }
252
+ // Push 3 messages IN ONE BATCH then go silent — without idle flush, the
253
+ // window would stay open forever. One push call = one segment = one
254
+ // timestamp, so the three messages cannot straddle a 1-second window
255
+ // boundary. Three sequential pushes could land at x.98s / x+1.01s, splitting
256
+ // the window: emits[0] then reads {count:2, sum:14} and the FIRST window is
257
+ // closed by the cycle path instead of the idle flush (flushCyclesTotal 0) —
258
+ // observed live, ~1 run in 5 under load.
259
+ await client.queue(src).partition('quiet').push([
260
+ { data: { v: 7 } }, { data: { v: 7 } }, { data: { v: 7 } }
261
+ ])
258
262
 
259
263
  const handle = await Stream
260
264
  .from(client.queue(src))
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Queen.ack() wire-format tests.
3
+ *
4
+ * The server (routes/ack.cpp + queen.ack_messages_v2) responds to both
5
+ * /api/v1/ack and /api/v1/ack/batch with a top-level JSON array, one item per
6
+ * acknowledgment in request order:
7
+ *
8
+ * [{"index":0,"transactionId":"...","success":false,"error":"Invalid or expired lease",
9
+ * "queueName":"q","partitionName":"Default","leaseReleased":false,"dlq":false}]
10
+ *
11
+ * These tests pin that wire format so a rejected ack/nack (e.g. "Invalid or
12
+ * expired lease", "Message not found") is never reported as success: true.
13
+ * Mirror of clients/client-go/ack_test.go.
14
+ */
15
+
16
+ import { describe, it } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+ import { createServer } from 'node:http'
19
+
20
+ import { Queen } from '../../client-v2/index.js'
21
+
22
+ function ackResultItem(index, transactionId, success, error = null) {
23
+ return {
24
+ index,
25
+ transactionId,
26
+ success,
27
+ error,
28
+ queueName: 'test-queue',
29
+ partitionName: 'Default',
30
+ leaseReleased: success,
31
+ dlq: false
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Spin up a real HTTP server, point a Queen client at it, run the test body,
37
+ * then tear both down. respond(body) returns the JSON payload to send back.
38
+ * Requests are recorded as { path, body } for assertions.
39
+ */
40
+ async function withAckServer(respond, run) {
41
+ const requests = []
42
+ const server = createServer((req, res) => {
43
+ let raw = ''
44
+ req.on('data', chunk => { raw += chunk })
45
+ req.on('end', () => {
46
+ const body = raw ? JSON.parse(raw) : {}
47
+ requests.push({ path: req.url, body })
48
+ res.writeHead(200, { 'Content-Type': 'application/json' })
49
+ res.end(JSON.stringify(respond(body)))
50
+ })
51
+ })
52
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
53
+ const { port } = server.address()
54
+
55
+ const client = new Queen({
56
+ url: `http://127.0.0.1:${port}`,
57
+ handleSignals: false,
58
+ retryAttempts: 1
59
+ })
60
+
61
+ try {
62
+ await run(client, requests)
63
+ } finally {
64
+ await client.close()
65
+ await new Promise(resolve => server.close(resolve))
66
+ }
67
+ }
68
+
69
+ const MSG = { transactionId: 'tx-1', partitionId: 'p-1', leaseId: 'lease-1' }
70
+
71
+ describe('Queen.ack single (/api/v1/ack)', () => {
72
+ it('reports success for an accepted ack', async () => {
73
+ await withAckServer(
74
+ () => [ackResultItem(0, 'tx-1', true)],
75
+ async (client, requests) => {
76
+ const result = await client.ack(MSG, true)
77
+ assert.equal(result.success, true)
78
+ assert.equal(result.error, null)
79
+ assert.equal(result.transactionId, 'tx-1')
80
+ assert.equal(requests[0].path, '/api/v1/ack')
81
+ assert.equal(requests[0].body.status, 'completed')
82
+ }
83
+ )
84
+ })
85
+
86
+ it('reports failure when the ack is rejected for an expired lease', async () => {
87
+ await withAckServer(
88
+ () => [ackResultItem(0, 'tx-1', false, 'Invalid or expired lease')],
89
+ async (client) => {
90
+ const result = await client.ack(MSG, true)
91
+ assert.equal(result.success, false)
92
+ assert.equal(result.error, 'Invalid or expired lease')
93
+ }
94
+ )
95
+ })
96
+
97
+ it('reports failure when a nack is rejected for a missing message', async () => {
98
+ await withAckServer(
99
+ () => [ackResultItem(0, 'tx-1', false, 'Message not found')],
100
+ async (client, requests) => {
101
+ const result = await client.ack(MSG, false)
102
+ assert.equal(result.success, false)
103
+ assert.equal(result.error, 'Message not found')
104
+ assert.equal(requests[0].body.status, 'failed')
105
+ }
106
+ )
107
+ })
108
+
109
+ it('still fails when rejected without an error string', async () => {
110
+ await withAckServer(
111
+ () => [ackResultItem(0, 'tx-1', false, null)],
112
+ async (client) => {
113
+ const result = await client.ack(MSG, true)
114
+ assert.equal(result.success, false)
115
+ assert.equal(result.error, 'Acknowledgment rejected by server')
116
+ }
117
+ )
118
+ })
119
+
120
+ it('propagates a top-level error envelope', async () => {
121
+ await withAckServer(
122
+ () => ({ error: 'internal error' }),
123
+ async (client) => {
124
+ const result = await client.ack(MSG, true)
125
+ assert.equal(result.success, false)
126
+ assert.equal(result.error, 'internal error')
127
+ }
128
+ )
129
+ })
130
+
131
+ it('fails on a response that is neither a result array nor an error envelope', async () => {
132
+ await withAckServer(
133
+ () => ({ ok: true }),
134
+ async (client) => {
135
+ const result = await client.ack(MSG, true)
136
+ assert.equal(result.success, false)
137
+ assert.match(result.error, /Unexpected ack response format/)
138
+ }
139
+ )
140
+ })
141
+ })
142
+
143
+ describe('Queen.ack batch (/api/v1/ack/batch)', () => {
144
+ const MSGS = [
145
+ { transactionId: 'tx-1', partitionId: 'p-1', leaseId: 'lease-1' },
146
+ { transactionId: 'tx-2', partitionId: 'p-1', leaseId: 'lease-1' }
147
+ ]
148
+
149
+ it('reports success when every acknowledgment is accepted', async () => {
150
+ await withAckServer(
151
+ () => [ackResultItem(0, 'tx-1', true), ackResultItem(1, 'tx-2', true)],
152
+ async (client, requests) => {
153
+ const result = await client.ack(MSGS, true)
154
+ assert.equal(result.success, true)
155
+ assert.equal(result.processed, 2)
156
+ assert.equal(result.results.length, 2)
157
+ assert.equal(requests[0].path, '/api/v1/ack/batch')
158
+ assert.equal(requests[0].body.acknowledgments.length, 2)
159
+ }
160
+ )
161
+ })
162
+
163
+ it('reports failure with per-item results on mixed outcomes', async () => {
164
+ await withAckServer(
165
+ () => [
166
+ ackResultItem(0, 'tx-1', true),
167
+ ackResultItem(1, 'tx-2', false, 'Invalid or expired lease')
168
+ ],
169
+ async (client) => {
170
+ const result = await client.ack(MSGS, true)
171
+ assert.equal(result.success, false)
172
+ assert.equal(result.error, 'Invalid or expired lease')
173
+ assert.equal(result.results[0].success, true)
174
+ assert.equal(result.results[1].success, false)
175
+ assert.equal(result.results[1].error, 'Invalid or expired lease')
176
+ }
177
+ )
178
+ })
179
+
180
+ it('fails when the server returns fewer results than acknowledgments sent', async () => {
181
+ await withAckServer(
182
+ () => [ackResultItem(0, 'tx-1', true)],
183
+ async (client) => {
184
+ const result = await client.ack(MSGS, true)
185
+ assert.equal(result.success, false)
186
+ assert.match(result.error, /1 results, expected 2/)
187
+ }
188
+ )
189
+ })
190
+
191
+ it('propagates a top-level error envelope to every acknowledgment', async () => {
192
+ await withAckServer(
193
+ () => ({ error: 'internal error' }),
194
+ async (client) => {
195
+ const result = await client.ack(MSGS, true)
196
+ assert.equal(result.success, false)
197
+ assert.equal(result.error, '2 of 2 acknowledgments rejected: internal error')
198
+ assert.equal(result.results.length, 2)
199
+ assert.equal(result.results[0].error, 'internal error')
200
+ }
201
+ )
202
+ })
203
+ })
@@ -12,7 +12,10 @@
12
12
  *
13
13
  * Prerequisites:
14
14
  * - Queen running on QUEEN_URL (default http://localhost:6632)
15
- * - The schema procedures (019..022) applied (auto-loaded on Queen boot)
15
+ * - The streams procedures applied (auto-loaded on Queen boot):
16
+ * 002_streams_schema, 007_log_streams, 008_streams_register_query_v1,
17
+ * 009_streams_state_get_v1 (the retired rows-era streams cycle's role
18
+ * now lives in 007_log_streams' log_streams_cycle_v1)
16
19
  *
17
20
  * Run:
18
21
  * QUEEN_URL=http://localhost:6632 node --test test/e2e.test.js
@@ -14,12 +14,14 @@ export async function transactionBasicPushAck(client) {
14
14
  return { success: false, message: 'No message to consume' }
15
15
  }
16
16
 
17
+ // docs:start(js-transaction)
17
18
  await client
18
19
  .transaction()
19
20
  .queue('test-queue-v2-txn-basic-b')
20
21
  .push([{ data: { value: messages[0].data.value + 1 } }])
21
22
  .ack(messages[0])
22
23
  .commit()
24
+ // docs:end
23
25
 
24
26
  // Verify message moved to B
25
27
  const resultB = await client.queue('test-queue-v2-txn-basic-b').batch(1).wait(false).pop()
@@ -1,25 +1,36 @@
1
1
  /**
2
- * Watermark Bug Tests
2
+ * Seek / delete re-consumption tests (behaviour, not bookkeeping)
3
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)
4
+ * These tests verify the BEHAVIOUR a user cares about:
5
+ * 1. Seeking a consumer group backwards re-exposes the old messages, and the
6
+ * group re-consumes them from the start within a short bound.
7
+ * 2. Deleting a consumer group (all queues, or for one queue) lets a group of
8
+ * the same name consume every message again from the start.
7
9
  *
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.
10
+ * WHY THIS WAS REWRITTEN (2026-07-24)
11
+ * -----------------------------------
12
+ * The consumer_watermarks table is a LEGACY-path optimisation: it records when a
13
+ * consumer last found a queue empty so an indexed empty-scan can be skipped. The
14
+ * original tests asserted the *row* was created after an empty poll and then aged
15
+ * it to exercise the 2-minute buffer:
16
+ * pl.updated_at >= (v_watermark - interval '2 minutes')
11
17
  *
12
- * BUG: Currently, seek and delete operations don't touch consumer_watermarks,
13
- * causing re-consumption to fail silently.
18
+ * With the wildcard hot-list ON (QUEEN_HOTLIST=1, the default) empty polls are
19
+ * answered from the in-memory candidate ring and that bookkeeping row is NEVER
20
+ * written — by design, not a bug: the ring, not the watermark, is the discovery
21
+ * source. So asserting the row exists fails on the hot-list path even though
22
+ * re-consumption works perfectly.
14
23
  *
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.
24
+ * The behaviour is identical on both engines, so these tests now assert the
25
+ * behaviour directly and only exercise the watermark-age scenario on the LEGACY
26
+ * path — detected at runtime by whether the empty poll actually wrote the row
27
+ * (no env flag needed). On the hot-list path the broker's seek/delete handlers
28
+ * re-seed / invalidate the ring so the reconsume is immediate; the idle-bounded
29
+ * consume below (idleMillis 3000) is what proves "within a short bound".
20
30
  *
21
31
  * Run: node run.js seekBackwardsAllowsReconsume
22
32
  * node run.js deleteConsumerGroupAllowsReconsume
33
+ * node run.js deleteConsumerGroupForQueueAllowsReconsume
23
34
  */
24
35
 
25
36
  import { dbPool } from './run.js'
@@ -40,11 +51,12 @@ const QUEUE_NAME_DELETE = 'test-queue-v2-watermark-delete'
40
51
  */
41
52
  async function advanceWatermark(queueName, consumerGroup, minutesForward = 10) {
42
53
  const result = await dbPool.query(`
43
- UPDATE queen.consumer_watermarks
54
+ UPDATE queen.consumer_watermarks w
44
55
  SET last_empty_scan_at = NOW() + interval '${minutesForward} minutes',
45
56
  updated_at = NOW() + interval '${minutesForward} minutes'
46
- WHERE queue_name = $1 AND consumer_group = $2
47
- RETURNING *
57
+ FROM queen.queues q
58
+ WHERE w.queue_id = q.id AND q.name = $1 AND w.consumer_group = $2
59
+ RETURNING w.*
48
60
  `, [queueName, consumerGroup])
49
61
  return result.rows[0]
50
62
  }
@@ -54,8 +66,9 @@ async function advanceWatermark(queueName, consumerGroup, minutesForward = 10) {
54
66
  */
55
67
  async function getWatermark(queueName, consumerGroup) {
56
68
  const result = await dbPool.query(`
57
- SELECT * FROM queen.consumer_watermarks
58
- WHERE queue_name = $1 AND consumer_group = $2
69
+ SELECT w.* FROM queen.consumer_watermarks w
70
+ JOIN queen.queues q ON q.id = w.queue_id
71
+ WHERE q.name = $1 AND w.consumer_group = $2
59
72
  `, [queueName, consumerGroup])
60
73
  return result.rows[0]
61
74
  }
@@ -151,20 +164,20 @@ export async function seekBackwardsAllowsReconsume(client) {
151
164
 
152
165
  console.log(` Verified queue is empty (watermark should be set)`)
153
166
 
154
- // 6. Check watermark was created
167
+ // 6. Detect which discovery path we are on. On the LEGACY path the empty poll
168
+ // wrote a consumer_watermarks row; on the hot-list path it did not (empty
169
+ // polls are served from the ring — by design). We do NOT assert the row: it
170
+ // is a path-specific bookkeeping detail, not the behaviour under test.
155
171
  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
- }
172
+ if (watermarkBefore) {
173
+ // Legacy path: age the watermark 10 minutes into the future so the old
174
+ // partitions fall outside the 2-minute buffer — the original repro that
175
+ // proves seek must reset the watermark.
176
+ const advancedWatermark = await advanceWatermark(QUEUE_NAME_SEEK, consumerGroup, 10)
177
+ console.log(` [legacy] Watermark row exists; advanced to ${advancedWatermark.last_empty_scan_at} (10m future)`)
178
+ } else {
179
+ console.log(` [hotlist] No watermark row (empty polls served from the ring) — skipping the watermark-age step`)
161
180
  }
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
181
 
169
182
  // 8. Seek backwards to BEFORE the messages were pushed
170
183
  console.log(` Seeking to timestamp: ${timestampBeforeMessages}`)
@@ -203,12 +216,17 @@ export async function seekBackwardsAllowsReconsume(client) {
203
216
 
204
217
  console.log(` Second consume (after seek): ${secondConsumeCount} messages`)
205
218
 
206
- // 11. Verify we got all messages back
219
+ // 11. Verify we got all messages back within the idle-bounded consume above.
220
+ // A shortfall means the backward seek did not re-expose the messages
221
+ // promptly: on the legacy path the consumer_watermarks row was not reset
222
+ // (still at ${watermarkAfterSeek?.last_empty_scan_at}); on the hot-list path
223
+ // the ring was not re-seeded, so reconsume would only resume at the ≤30s
224
+ // periodic floor instead of immediately.
207
225
  if (secondConsumeCount !== messageCount) {
208
226
  return {
209
227
  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}).`
228
+ message: `After seek backwards, should re-consume ${messageCount} messages within the idle window, but got ${secondConsumeCount}. ` +
229
+ `(legacy watermark after seek: ${watermarkAfterSeek?.last_empty_scan_at})`
212
230
  }
213
231
  }
214
232
 
@@ -304,19 +322,17 @@ export async function deleteConsumerGroupAllowsReconsume(client) {
304
322
 
305
323
  console.log(` Verified queue is empty (watermark should be set)`)
306
324
 
307
- // 5. Check watermark was created
325
+ // 5. Detect the discovery path (see seekBackwardsAllowsReconsume). The
326
+ // watermark row is a legacy-path artefact, not the behaviour under test, so
327
+ // we do not assert it — we only exercise the watermark-age scenario when the
328
+ // row was actually written (legacy path).
308
329
  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
- }
330
+ if (watermarkBefore) {
331
+ const advancedWatermark = await advanceWatermark(QUEUE_NAME_DELETE, consumerGroup, 10)
332
+ console.log(` [legacy] Watermark row exists; advanced to ${advancedWatermark.last_empty_scan_at} (10m future)`)
333
+ } else {
334
+ console.log(` [hotlist] No watermark row (empty polls served from the ring) — skipping the watermark-age step`)
314
335
  }
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
336
 
321
337
  // 7. Delete the consumer group
322
338
  console.log(` Deleting consumer group: ${consumerGroup}`)
@@ -353,12 +369,16 @@ export async function deleteConsumerGroupAllowsReconsume(client) {
353
369
 
354
370
  console.log(` Second consume (after delete + recreate): ${secondConsumeCount} messages`)
355
371
 
356
- // 10. Verify we got all messages
372
+ // 10. Verify we got all messages within the idle-bounded consume above. A
373
+ // shortfall means the delete left stale discovery state: a legacy orphaned
374
+ // watermark (at ${watermarkAfterDelete?.last_empty_scan_at}), or, on the
375
+ // hot-list path, a surviving group ring / stale seeded-groups cache that
376
+ // suppressed the from-the-start reconsume.
357
377
  if (secondConsumeCount !== messageCount) {
358
378
  return {
359
379
  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}.`
380
+ message: `After deleting CG, new CG with same name should consume ${messageCount} messages within the idle window, ` +
381
+ `but got ${secondConsumeCount}. (legacy watermark after delete: ${watermarkAfterDelete?.last_empty_scan_at})`
362
382
  }
363
383
  }
364
384
 
@@ -382,7 +402,7 @@ export async function deleteConsumerGroupForQueueAllowsReconsume(client) {
382
402
  await client.admin.deleteConsumerGroupForQueue(consumerGroup, queueName, true)
383
403
  // Also clean up any orphaned watermark from previous runs
384
404
  await dbPool.query(
385
- 'DELETE FROM queen.consumer_watermarks WHERE queue_name = $1 AND consumer_group = $2',
405
+ 'DELETE FROM queen.consumer_watermarks w USING queen.queues q WHERE w.queue_id = q.id AND q.name = $1 AND w.consumer_group = $2',
386
406
  [queueName, consumerGroup]
387
407
  )
388
408
  } catch (e) {
@@ -446,19 +466,15 @@ export async function deleteConsumerGroupForQueueAllowsReconsume(client) {
446
466
 
447
467
  console.log(` Verified queue is empty (watermark should be set)`)
448
468
 
449
- // 5. Check watermark was created
469
+ // 5. Detect the discovery path (see seekBackwardsAllowsReconsume). The
470
+ // watermark row is a legacy-path artefact, not the behaviour under test.
450
471
  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
- }
472
+ if (watermarkBefore) {
473
+ const advancedWatermark = await advanceWatermark(queueName, consumerGroup, 10)
474
+ console.log(` [legacy] Watermark row exists; advanced to ${advancedWatermark.last_empty_scan_at} (10m future)`)
475
+ } else {
476
+ console.log(` [hotlist] No watermark row (empty polls served from the ring) — skipping the watermark-age step`)
456
477
  }
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
478
 
463
479
  // 7. Delete consumer group for this specific queue
464
480
  console.log(` Deleting consumer group for queue: ${consumerGroup} / ${queueName}`)
@@ -495,8 +511,8 @@ export async function deleteConsumerGroupForQueueAllowsReconsume(client) {
495
511
  if (secondConsumeCount !== messageCount) {
496
512
  return {
497
513
  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}.`
514
+ message: `After deleting CG for queue, should consume ${messageCount} messages within the idle window, ` +
515
+ `but got ${secondConsumeCount}. (legacy watermark after delete: ${watermarkAfterDelete?.last_empty_scan_at})`
500
516
  }
501
517
  }
502
518