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.
- package/client-v2/Queen.js +71 -15
- package/client-v2/README.md +37 -0
- package/client-v2/builders/QueueBuilder.js +10 -4
- package/client-v2/builders/TransactionBuilder.js +14 -2
- package/client-v2/consumer/ConsumerManager.js +64 -12
- package/client-v2/http/HttpClient.js +382 -32
- package/client-v2/streams/runtime/Runner.js +45 -0
- package/client-v2/utils/defaults.js +20 -1
- package/package.json +4 -3
- package/test-v2/ackwindow.js +258 -0
- package/test-v2/auth.js +65 -149
- package/test-v2/consume.js +2 -0
- package/test-v2/http-unit/hostHeader.test.js +411 -0
- package/test-v2/http-unit/retry429.test.js +314 -0
- package/test-v2/load.js +37 -41
- package/test-v2/maintenance.js +2 -2
- package/test-v2/pop.js +2 -0
- package/test-v2/push.js +29 -35
- package/test-v2/run.js +37 -5
- package/test-v2/semantics.js +792 -0
- package/test-v2/stream/_helpers.js +1 -1
- package/test-v2/stream/tumbling.js +10 -6
- package/test-v2/streams-unit/ack.test.js +203 -0
- package/test-v2/streams-unit/e2e.test.js +4 -1
- package/test-v2/transaction.js +2 -0
- package/test-v2/watermark.js +78 -62
|
@@ -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
|
|
253
|
-
// stay open forever.
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
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
|
package/test-v2/transaction.js
CHANGED
|
@@ -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()
|
package/test-v2/watermark.js
CHANGED
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Seek / delete re-consumption tests (behaviour, not bookkeeping)
|
|
3
3
|
*
|
|
4
|
-
* These tests verify
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
58
|
-
|
|
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.
|
|
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 (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
`
|
|
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.
|
|
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 (
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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}.
|
|
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
|
|
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.
|
|
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 (
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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}.
|
|
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
|
|