queen-mq 0.12.4 → 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.
- package/package.json +1 -1
- package/test-v2/auth.js +340 -0
- package/test-v2/push.js +275 -0
- package/test-v2/run.js +5 -1
- package/test-v2/watermark.js +507 -0
package/package.json
CHANGED
package/test-v2/auth.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// Tests for server-stamped producer identity (issue #23, feature A).
|
|
2
|
+
//
|
|
3
|
+
// The goal of feature A is: when JWT auth is enabled, the server attaches the
|
|
4
|
+
// authenticated client's `sub` claim to every pushed message, and a client
|
|
5
|
+
// cannot spoof another producer's identity by supplying `producerSub` in the
|
|
6
|
+
// request body.
|
|
7
|
+
//
|
|
8
|
+
// Three environments are exercised here:
|
|
9
|
+
//
|
|
10
|
+
// 1. No auth (default dev setup): `producer_sub` must be NULL; clients that
|
|
11
|
+
// send `producerSub` in the body must still get NULL (the field is not
|
|
12
|
+
// client-settable).
|
|
13
|
+
// 2. JWT auth enabled (set JWT_SECRET env to match the server's
|
|
14
|
+
// JWT_SECRET): `producer_sub` must equal the `sub` claim.
|
|
15
|
+
// 3. JWT auth enabled + spoofing attempt: body-supplied `producerSub` must
|
|
16
|
+
// be ignored; stored value is the JWT `sub`.
|
|
17
|
+
//
|
|
18
|
+
// Tests gate themselves on the `JWT_SECRET` env var so the suite can run
|
|
19
|
+
// unattended in either server configuration.
|
|
20
|
+
|
|
21
|
+
import crypto from 'crypto'
|
|
22
|
+
import { dbPool } from './run.js'
|
|
23
|
+
|
|
24
|
+
const SERVER_URL = process.env.QUEEN_URL || 'http://localhost:6632'
|
|
25
|
+
const JWT_SECRET = process.env.JWT_SECRET || ''
|
|
26
|
+
const JWT_ENABLED = JWT_SECRET.length > 0
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Minimal HS256 JWT signer (avoids pulling a dependency just for tests)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
function b64url(buf) {
|
|
32
|
+
return Buffer.from(buf).toString('base64')
|
|
33
|
+
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function signHs256Jwt(payload, secret) {
|
|
37
|
+
const header = { alg: 'HS256', typ: 'JWT' }
|
|
38
|
+
const encHeader = b64url(JSON.stringify(header))
|
|
39
|
+
const encPayload = b64url(JSON.stringify(payload))
|
|
40
|
+
const signingInput = `${encHeader}.${encPayload}`
|
|
41
|
+
const sig = crypto.createHmac('sha256', secret).update(signingInput).digest()
|
|
42
|
+
return `${signingInput}.${b64url(sig)}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeToken(sub, role = 'read-write') {
|
|
46
|
+
const now = Math.floor(Date.now() / 1000)
|
|
47
|
+
return signHs256Jwt(
|
|
48
|
+
{ sub, username: sub, role, iat: now, exp: now + 3600 },
|
|
49
|
+
JWT_SECRET
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Ground-truth helper: read producer_sub directly from Postgres, bypassing
|
|
55
|
+
// the API so we can tell the difference between a server that actually stores
|
|
56
|
+
// the value and one that just echoes the client's input.
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
async function getStoredProducerSub(queueName, transactionId) {
|
|
59
|
+
const res = await dbPool.query(
|
|
60
|
+
`SELECT m.producer_sub
|
|
61
|
+
FROM queen.messages m
|
|
62
|
+
JOIN queen.partitions p ON p.id = m.partition_id
|
|
63
|
+
JOIN queen.queues q ON q.id = p.queue_id
|
|
64
|
+
WHERE q.name = $1 AND m.transaction_id = $2`,
|
|
65
|
+
[queueName, transactionId]
|
|
66
|
+
)
|
|
67
|
+
return res.rows.length > 0 ? res.rows[0].producer_sub : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Raw HTTP push so we can control the Authorization header and inject
|
|
71
|
+
// arbitrary extra fields (like a forged `producerSub`) per-test.
|
|
72
|
+
async function httpPush({ queue, partition = 'Default', transactionId, data, bearerToken, extraItemFields = {} }) {
|
|
73
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
74
|
+
if (bearerToken) headers['Authorization'] = `Bearer ${bearerToken}`
|
|
75
|
+
|
|
76
|
+
const body = {
|
|
77
|
+
items: [{
|
|
78
|
+
queue,
|
|
79
|
+
partition,
|
|
80
|
+
transactionId,
|
|
81
|
+
payload: data,
|
|
82
|
+
...extraItemFields
|
|
83
|
+
}]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const res = await fetch(`${SERVER_URL}/api/v1/push`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers,
|
|
89
|
+
body: JSON.stringify(body)
|
|
90
|
+
})
|
|
91
|
+
const text = await res.text()
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
throw new Error(`push failed ${res.status}: ${text}`)
|
|
94
|
+
}
|
|
95
|
+
return text ? JSON.parse(text) : null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Raw HTTP configure (queue creation) with optional bearer.
|
|
99
|
+
async function httpConfigure(queue, bearerToken) {
|
|
100
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
101
|
+
if (bearerToken) headers['Authorization'] = `Bearer ${bearerToken}`
|
|
102
|
+
const res = await fetch(`${SERVER_URL}/api/v1/configure`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers,
|
|
105
|
+
body: JSON.stringify({ queue, options: {} })
|
|
106
|
+
})
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const text = await res.text()
|
|
109
|
+
throw new Error(`configure failed ${res.status}: ${text}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Direct SP call via dbPool — bypasses HTTP and JWT entirely. Used by the
|
|
114
|
+
// SP-level tests below so they work regardless of whether the server has
|
|
115
|
+
// auth enabled or not.
|
|
116
|
+
async function callPushMessagesV3(items) {
|
|
117
|
+
await dbPool.query(
|
|
118
|
+
'SELECT queen.push_messages_v3($1::jsonb)',
|
|
119
|
+
[JSON.stringify(items)]
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Direct SP pop. Returns the `messages` array for the first (only) partition.
|
|
124
|
+
async function callPopSpecificBatch(queueName) {
|
|
125
|
+
const reqs = [{
|
|
126
|
+
idx: 0,
|
|
127
|
+
queue_name: queueName,
|
|
128
|
+
partition_name: 'Default',
|
|
129
|
+
consumer_group: '__QUEUE_MODE__',
|
|
130
|
+
lease_seconds: 60,
|
|
131
|
+
worker_id: `test-worker-${Date.now()}`,
|
|
132
|
+
batch_size: 10,
|
|
133
|
+
sub_mode: 'all',
|
|
134
|
+
sub_from: ''
|
|
135
|
+
}]
|
|
136
|
+
const res = await dbPool.query(
|
|
137
|
+
'SELECT queen.pop_specific_batch($1::jsonb) as out',
|
|
138
|
+
[JSON.stringify(reqs)]
|
|
139
|
+
)
|
|
140
|
+
const out = res.rows[0].out
|
|
141
|
+
return out[0]?.result?.messages || []
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ===========================================================================
|
|
145
|
+
// TEST 1: Stored procedure round-trip (SQL-only, no HTTP, no JWT)
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Exercises the schema + SP contract end-to-end purely via SQL. If this fails,
|
|
148
|
+
// the migration or 001_push.sql / 002_pop_unified.sql is wrong. Runs in any
|
|
149
|
+
// server configuration because it talks to Postgres directly.
|
|
150
|
+
// ===========================================================================
|
|
151
|
+
export async function producerSubRoundTripViaStoredProcedure(client) {
|
|
152
|
+
const q = 'test-auth-sp-roundtrip'
|
|
153
|
+
const tx = `tx-sp-${Date.now()}`
|
|
154
|
+
|
|
155
|
+
await callPushMessagesV3([{
|
|
156
|
+
queue: q,
|
|
157
|
+
partition: 'Default',
|
|
158
|
+
transactionId: tx,
|
|
159
|
+
payload: { x: 1 },
|
|
160
|
+
producerSub: 'sp-test-sub'
|
|
161
|
+
}])
|
|
162
|
+
|
|
163
|
+
const stored = await getStoredProducerSub(q, tx)
|
|
164
|
+
if (stored !== 'sp-test-sub') {
|
|
165
|
+
return { success: false, message: `Expected producer_sub='sp-test-sub' in DB, got ${JSON.stringify(stored)}` }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const popped = await callPopSpecificBatch(q)
|
|
169
|
+
const target = popped.find(m => m.transactionId === tx)
|
|
170
|
+
if (!target) {
|
|
171
|
+
return { success: false, message: `Expected to find tx ${tx} in pop result` }
|
|
172
|
+
}
|
|
173
|
+
if (target.producerSub !== 'sp-test-sub') {
|
|
174
|
+
return { success: false, message: `Pop returned producerSub=${JSON.stringify(target.producerSub)}, expected 'sp-test-sub'` }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { success: true, message: 'producer_sub round-trips through push_messages_v3 and pop exposes it' }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ===========================================================================
|
|
181
|
+
// TEST 2: SP with no producerSub => producer_sub is NULL
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Guards against an accidental "default to empty string" regression in the SP.
|
|
184
|
+
// ===========================================================================
|
|
185
|
+
export async function producerSubNullWhenNotProvided(client) {
|
|
186
|
+
const q = 'test-auth-sp-null'
|
|
187
|
+
const tx = `tx-sp-null-${Date.now()}`
|
|
188
|
+
|
|
189
|
+
await callPushMessagesV3([{
|
|
190
|
+
queue: q,
|
|
191
|
+
partition: 'Default',
|
|
192
|
+
transactionId: tx,
|
|
193
|
+
payload: { x: 1 }
|
|
194
|
+
// no producerSub
|
|
195
|
+
}])
|
|
196
|
+
|
|
197
|
+
const stored = await getStoredProducerSub(q, tx)
|
|
198
|
+
if (stored !== null) {
|
|
199
|
+
return { success: false, message: `Expected NULL producer_sub, got ${JSON.stringify(stored)}` }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const popped = await callPopSpecificBatch(q)
|
|
203
|
+
const target = popped.find(m => m.transactionId === tx)
|
|
204
|
+
if (!target) {
|
|
205
|
+
return { success: false, message: `Expected to find tx ${tx} in pop result` }
|
|
206
|
+
}
|
|
207
|
+
if (target.producerSub != null) {
|
|
208
|
+
return { success: false, message: `Pop returned producerSub=${JSON.stringify(target.producerSub)}, expected null` }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { success: true, message: 'Omitting producerSub yields NULL in DB and null in pop response' }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ===========================================================================
|
|
215
|
+
// TEST 3: SP treats empty string producerSub as NULL
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// The SP uses NULLIF(..., '') to avoid storing a literal empty string when a
|
|
218
|
+
// client accidentally sends "". Verifies that invariant.
|
|
219
|
+
// ===========================================================================
|
|
220
|
+
export async function producerSubEmptyStringStoredAsNull(client) {
|
|
221
|
+
const q = 'test-auth-sp-empty'
|
|
222
|
+
const tx = `tx-sp-empty-${Date.now()}`
|
|
223
|
+
|
|
224
|
+
await callPushMessagesV3([{
|
|
225
|
+
queue: q,
|
|
226
|
+
partition: 'Default',
|
|
227
|
+
transactionId: tx,
|
|
228
|
+
payload: { x: 1 },
|
|
229
|
+
producerSub: ''
|
|
230
|
+
}])
|
|
231
|
+
|
|
232
|
+
const stored = await getStoredProducerSub(q, tx)
|
|
233
|
+
if (stored !== null) {
|
|
234
|
+
return { success: false, message: `Expected NULL for empty-string producerSub, got ${JSON.stringify(stored)}` }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { success: true, message: 'Empty-string producerSub is stored as NULL' }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ===========================================================================
|
|
241
|
+
// TEST 4: HTTP push without JWT => producer_sub is NULL
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Runs only when the server is NOT configured with JWT (typical dev setup).
|
|
244
|
+
// Also checks that a client sending `producerSub` in the push body does NOT
|
|
245
|
+
// end up with it persisted (the field is not client-settable under any mode).
|
|
246
|
+
// ===========================================================================
|
|
247
|
+
export async function producerSubIgnoredFromBodyWithoutAuth(client) {
|
|
248
|
+
if (JWT_ENABLED) {
|
|
249
|
+
return { success: true, message: 'Skipped (JWT_SECRET is set - run producerSubStampedFromJwt instead)' }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const q = 'test-auth-http-no-jwt'
|
|
253
|
+
const tx = `tx-noauth-${Date.now()}`
|
|
254
|
+
|
|
255
|
+
const queue = await client.queue(q).create()
|
|
256
|
+
if (!queue.configured) {
|
|
257
|
+
return { success: false, message: 'Queue not created' }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Spoofing attempt against an unauthenticated server.
|
|
261
|
+
await httpPush({
|
|
262
|
+
queue: q,
|
|
263
|
+
transactionId: tx,
|
|
264
|
+
data: { hello: 'world' },
|
|
265
|
+
extraItemFields: { producerSub: 'attacker-no-jwt' }
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const stored = await getStoredProducerSub(q, tx)
|
|
269
|
+
if (stored !== null) {
|
|
270
|
+
return { success: false, message: `Expected NULL producer_sub (auth disabled), got ${JSON.stringify(stored)} - client was able to set it!` }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { success: true, message: 'Body-supplied producerSub ignored when auth disabled; stored as NULL' }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ===========================================================================
|
|
277
|
+
// TEST 5: HTTP push WITH JWT => producer_sub = sub claim
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Requires:
|
|
280
|
+
// - Server started with JWT_ENABLED=true JWT_ALGORITHM=HS256 JWT_SECRET=<x>
|
|
281
|
+
// - Test run with JWT_SECRET=<x>
|
|
282
|
+
// ===========================================================================
|
|
283
|
+
export async function producerSubStampedFromJwt(client) {
|
|
284
|
+
if (!JWT_ENABLED) {
|
|
285
|
+
return { success: true, message: 'Skipped (set JWT_SECRET env var matching server to run)' }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const q = 'test-auth-http-jwt-stamp'
|
|
289
|
+
const tx = `tx-jwt-${Date.now()}`
|
|
290
|
+
const token = makeToken('alice-producer')
|
|
291
|
+
|
|
292
|
+
await httpConfigure(q, token)
|
|
293
|
+
await httpPush({
|
|
294
|
+
queue: q,
|
|
295
|
+
transactionId: tx,
|
|
296
|
+
data: { hello: 'world' },
|
|
297
|
+
bearerToken: token
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
const stored = await getStoredProducerSub(q, tx)
|
|
301
|
+
if (stored !== 'alice-producer') {
|
|
302
|
+
return { success: false, message: `Expected producer_sub='alice-producer', got ${JSON.stringify(stored)}` }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return { success: true, message: 'producer_sub stamped from authenticated JWT sub claim' }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ===========================================================================
|
|
309
|
+
// TEST 6: Spoofing producerSub in body is ignored even with valid JWT
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// This is the core anti-impersonation invariant of issue #23.
|
|
312
|
+
// ===========================================================================
|
|
313
|
+
export async function producerSubSpoofingIgnoredWithJwt(client) {
|
|
314
|
+
if (!JWT_ENABLED) {
|
|
315
|
+
return { success: true, message: 'Skipped (set JWT_SECRET env var matching server to run)' }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const q = 'test-auth-http-jwt-spoof'
|
|
319
|
+
const tx = `tx-spoof-${Date.now()}`
|
|
320
|
+
const token = makeToken('legit-producer')
|
|
321
|
+
|
|
322
|
+
await httpConfigure(q, token)
|
|
323
|
+
await httpPush({
|
|
324
|
+
queue: q,
|
|
325
|
+
transactionId: tx,
|
|
326
|
+
data: { hello: 'world' },
|
|
327
|
+
bearerToken: token,
|
|
328
|
+
extraItemFields: { producerSub: 'attacker' }
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
const stored = await getStoredProducerSub(q, tx)
|
|
332
|
+
if (stored !== 'legit-producer') {
|
|
333
|
+
return {
|
|
334
|
+
success: false,
|
|
335
|
+
message: `Impersonation not prevented: stored producer_sub=${JSON.stringify(stored)}, expected 'legit-producer'`
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { success: true, message: 'Body-supplied producerSub ignored; stored sub is from validated JWT' }
|
|
340
|
+
}
|
package/test-v2/push.js
CHANGED
|
@@ -278,6 +278,281 @@ export async function pushEmptyPayload(client) {
|
|
|
278
278
|
return { success: true }
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// ============================================================================
|
|
282
|
+
// push_messages_v3 coverage (see PUSH_IMPROVEMENT.md §8.1).
|
|
283
|
+
// These tests exercise the contract that the TEMP-TABLE-free rewrite must
|
|
284
|
+
// preserve: single-call semantics (new inserts, pre-existing duplicates,
|
|
285
|
+
// intra-batch duplicates, missing transactionId, empty batch, malformed
|
|
286
|
+
// traceId) plus the cross-call concurrent-race invariant.
|
|
287
|
+
// ============================================================================
|
|
288
|
+
|
|
289
|
+
function distinctCount(arr) {
|
|
290
|
+
return new Set(arr).size
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function testSimplePushRoundTrip(client) {
|
|
294
|
+
const queueName = 'test-v3-simple-roundtrip'
|
|
295
|
+
const queue = await client.queue(queueName).create()
|
|
296
|
+
if (!queue.configured) {
|
|
297
|
+
return { success: false, message: 'Queue not created' }
|
|
298
|
+
}
|
|
299
|
+
const res = await client.queue(queueName).push([{ data: { n: 1 } }])
|
|
300
|
+
if (!res[0] || res[0].status !== 'queued') {
|
|
301
|
+
return { success: false, message: `Expected queued, got ${JSON.stringify(res[0])}` }
|
|
302
|
+
}
|
|
303
|
+
const popped = await client.queue(queueName).batch(1).wait(false).pop()
|
|
304
|
+
if (!popped || popped.length !== 1 || popped[0].data.n !== 1) {
|
|
305
|
+
return { success: false, message: 'Round-trip pop failed' }
|
|
306
|
+
}
|
|
307
|
+
return { success: true }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function testPushAutoCreatesQueueAndPartition(client) {
|
|
311
|
+
// Queue and partition are never referenced before — must be auto-created.
|
|
312
|
+
const queueName = 'test-v3-autocreate-' + crypto.randomBytes(4).toString('hex')
|
|
313
|
+
const partitionName = 'autopart-' + crypto.randomBytes(4).toString('hex')
|
|
314
|
+
const res = await client.queue(queueName).partition(partitionName)
|
|
315
|
+
.push([{ data: { hello: 'auto' } }])
|
|
316
|
+
if (!res[0] || res[0].status !== 'queued') {
|
|
317
|
+
return { success: false, message: `Expected queued, got ${JSON.stringify(res[0])}` }
|
|
318
|
+
}
|
|
319
|
+
const popped = await client.queue(queueName).partition(partitionName)
|
|
320
|
+
.batch(1).wait(false).pop()
|
|
321
|
+
if (!popped || popped.length !== 1) {
|
|
322
|
+
return { success: false, message: 'Auto-created partition did not receive the message' }
|
|
323
|
+
}
|
|
324
|
+
return { success: true }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function testPushPreExistingDuplicate(client) {
|
|
328
|
+
const queueName = 'test-v3-pre-existing-dup'
|
|
329
|
+
const queue = await client.queue(queueName).create()
|
|
330
|
+
if (!queue.configured) {
|
|
331
|
+
return { success: false, message: 'Queue not created' }
|
|
332
|
+
}
|
|
333
|
+
const txn = 'txn-pre-' + crypto.randomBytes(4).toString('hex')
|
|
334
|
+
const first = await client.queue(queueName)
|
|
335
|
+
.push([{ transactionId: txn, data: { i: 1 } }])
|
|
336
|
+
if (first[0].status !== 'queued') {
|
|
337
|
+
return { success: false, message: `First push not queued: ${JSON.stringify(first[0])}` }
|
|
338
|
+
}
|
|
339
|
+
const firstId = first[0].message_id
|
|
340
|
+
const second = await client.queue(queueName)
|
|
341
|
+
.push([{ transactionId: txn, data: { i: 2 } }])
|
|
342
|
+
if (second[0].status !== 'duplicate') {
|
|
343
|
+
return { success: false, message: `Second push not duplicate: ${JSON.stringify(second[0])}` }
|
|
344
|
+
}
|
|
345
|
+
if (!firstId || second[0].message_id !== firstId) {
|
|
346
|
+
return { success: false, message: `Duplicate id mismatch: want ${firstId}, got ${second[0].message_id}` }
|
|
347
|
+
}
|
|
348
|
+
return { success: true }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function testPushIntraBatchDuplicatesAllNew(client) {
|
|
352
|
+
const queueName = 'test-v3-intra-batch-new'
|
|
353
|
+
const queue = await client.queue(queueName).create()
|
|
354
|
+
if (!queue.configured) {
|
|
355
|
+
return { success: false, message: 'Queue not created' }
|
|
356
|
+
}
|
|
357
|
+
const txn = 'txn-ib-' + crypto.randomBytes(4).toString('hex')
|
|
358
|
+
const res = await client.queue(queueName).push([
|
|
359
|
+
{ transactionId: txn, data: { i: 1 } },
|
|
360
|
+
{ transactionId: txn, data: { i: 2 } },
|
|
361
|
+
{ transactionId: txn, data: { i: 3 } }
|
|
362
|
+
])
|
|
363
|
+
if (!Array.isArray(res) || res.length !== 3) {
|
|
364
|
+
return { success: false, message: `Expected 3 entries, got ${res && res.length}` }
|
|
365
|
+
}
|
|
366
|
+
const queued = res.filter(r => r.status === 'queued')
|
|
367
|
+
const dupes = res.filter(r => r.status === 'duplicate')
|
|
368
|
+
if (queued.length !== 1 || dupes.length !== 2) {
|
|
369
|
+
return { success: false, message: `Want 1 queued / 2 duplicate, got ${JSON.stringify(res)}` }
|
|
370
|
+
}
|
|
371
|
+
const winnerId = queued[0].message_id
|
|
372
|
+
if (!winnerId || !dupes.every(d => d.message_id === winnerId)) {
|
|
373
|
+
return { success: false, message: `Intra-batch duplicates must share winner id ${winnerId}; got ${JSON.stringify(dupes)}` }
|
|
374
|
+
}
|
|
375
|
+
return { success: true }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export async function testPushIntraBatchDuplicatesMatchingPreExisting(client) {
|
|
379
|
+
const queueName = 'test-v3-intra-batch-existing'
|
|
380
|
+
const queue = await client.queue(queueName).create()
|
|
381
|
+
if (!queue.configured) {
|
|
382
|
+
return { success: false, message: 'Queue not created' }
|
|
383
|
+
}
|
|
384
|
+
const txn = 'txn-ibe-' + crypto.randomBytes(4).toString('hex')
|
|
385
|
+
// Seed an existing row.
|
|
386
|
+
const seed = await client.queue(queueName)
|
|
387
|
+
.push([{ transactionId: txn, data: { seed: true } }])
|
|
388
|
+
if (seed[0].status !== 'queued') {
|
|
389
|
+
return { success: false, message: 'Seed push failed' }
|
|
390
|
+
}
|
|
391
|
+
const existingId = seed[0].message_id
|
|
392
|
+
const res = await client.queue(queueName).push([
|
|
393
|
+
{ transactionId: txn, data: { i: 1 } },
|
|
394
|
+
{ transactionId: txn, data: { i: 2 } }
|
|
395
|
+
])
|
|
396
|
+
if (!Array.isArray(res) || res.length !== 2) {
|
|
397
|
+
return { success: false, message: `Expected 2 entries, got ${JSON.stringify(res)}` }
|
|
398
|
+
}
|
|
399
|
+
if (!res.every(r => r.status === 'duplicate' && r.message_id === existingId)) {
|
|
400
|
+
return { success: false, message: `All entries must duplicate onto ${existingId}, got ${JSON.stringify(res)}` }
|
|
401
|
+
}
|
|
402
|
+
return { success: true }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function testPushConcurrentBatchesToSameKey(client) {
|
|
406
|
+
const queueName = 'test-v3-concurrent-same-key'
|
|
407
|
+
const queue = await client.queue(queueName).create()
|
|
408
|
+
if (!queue.configured) {
|
|
409
|
+
return { success: false, message: 'Queue not created' }
|
|
410
|
+
}
|
|
411
|
+
const txn = 'txn-conc-' + crypto.randomBytes(4).toString('hex')
|
|
412
|
+
// Fire 8 concurrent single-item pushes using the same transactionId.
|
|
413
|
+
// Exactly one must win; the others must report duplicate with the winner's id.
|
|
414
|
+
const N = 8
|
|
415
|
+
const results = await Promise.all(
|
|
416
|
+
Array.from({ length: N }, (_, i) =>
|
|
417
|
+
client.queue(queueName)
|
|
418
|
+
.push([{ transactionId: txn, data: { i } }])
|
|
419
|
+
.then(r => r[0])
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
const queued = results.filter(r => r.status === 'queued')
|
|
423
|
+
const dupes = results.filter(r => r.status === 'duplicate')
|
|
424
|
+
if (queued.length !== 1) {
|
|
425
|
+
return { success: false, message: `Want exactly 1 queued, got ${queued.length}: ${JSON.stringify(results)}` }
|
|
426
|
+
}
|
|
427
|
+
if (dupes.length !== N - 1) {
|
|
428
|
+
return { success: false, message: `Want ${N - 1} duplicates, got ${dupes.length}` }
|
|
429
|
+
}
|
|
430
|
+
const winnerId = queued[0].message_id
|
|
431
|
+
if (!winnerId || !dupes.every(d => d.message_id === winnerId)) {
|
|
432
|
+
return { success: false, message: `All duplicates must report winner id ${winnerId}: ${JSON.stringify(dupes)}` }
|
|
433
|
+
}
|
|
434
|
+
return { success: true }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function testPushMissingTransactionId(client) {
|
|
438
|
+
const queueName = 'test-v3-missing-txn'
|
|
439
|
+
const queue = await client.queue(queueName).create()
|
|
440
|
+
if (!queue.configured) {
|
|
441
|
+
return { success: false, message: 'Queue not created' }
|
|
442
|
+
}
|
|
443
|
+
// 100 items, no transactionId provided. Each must get a distinct id and
|
|
444
|
+
// all should end up queued (no accidental intra-batch collisions from a
|
|
445
|
+
// volatile-function-inlining regression).
|
|
446
|
+
// NOTE: the client auto-generates a transactionId when omitted, so we
|
|
447
|
+
// bypass it via the raw HTTP push contract by supplying only `data`. The
|
|
448
|
+
// client still fills a fresh transactionId per item, which is the correct
|
|
449
|
+
// client-level contract; the server-side test below additionally proves
|
|
450
|
+
// the SP itself handles missing txn ids by bypassing the client helper.
|
|
451
|
+
const items = Array.from({ length: 100 }, (_, i) => ({ data: { i } }))
|
|
452
|
+
const res = await client.queue(queueName).push(items)
|
|
453
|
+
if (!Array.isArray(res) || res.length !== 100) {
|
|
454
|
+
return { success: false, message: `Expected 100 results, got ${res && res.length}` }
|
|
455
|
+
}
|
|
456
|
+
if (!res.every(r => r.status === 'queued')) {
|
|
457
|
+
const bad = res.find(r => r.status !== 'queued')
|
|
458
|
+
return { success: false, message: `All must queue; first bad entry ${JSON.stringify(bad)}` }
|
|
459
|
+
}
|
|
460
|
+
if (distinctCount(res.map(r => r.message_id)) !== 100) {
|
|
461
|
+
return { success: false, message: `Expected 100 distinct message ids` }
|
|
462
|
+
}
|
|
463
|
+
return { success: true }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export async function testPushEmptyArray(client) {
|
|
467
|
+
const queueName = 'test-v3-empty-array'
|
|
468
|
+
const queue = await client.queue(queueName).create()
|
|
469
|
+
if (!queue.configured) {
|
|
470
|
+
return { success: false, message: 'Queue not created' }
|
|
471
|
+
}
|
|
472
|
+
const res = await client.queue(queueName).push([])
|
|
473
|
+
if (!Array.isArray(res) || res.length !== 0) {
|
|
474
|
+
return { success: false, message: `Expected [], got ${JSON.stringify(res)}` }
|
|
475
|
+
}
|
|
476
|
+
return { success: true }
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function testPushMalformedTraceId(client) {
|
|
480
|
+
// Per plan §5: malformed traceId aborts the batch in both v2 and v3.
|
|
481
|
+
// We only need to verify the behavior is stable (either rejected at the
|
|
482
|
+
// client validator or surfaced as a failure from the server). The client
|
|
483
|
+
// QueueBuilder currently drops invalid UUIDs silently — in which case the
|
|
484
|
+
// push still succeeds and traceId is null. Accept either outcome as long
|
|
485
|
+
// as we don't see data corruption.
|
|
486
|
+
const queueName = 'test-v3-malformed-trace'
|
|
487
|
+
const queue = await client.queue(queueName).create()
|
|
488
|
+
if (!queue.configured) {
|
|
489
|
+
return { success: false, message: 'Queue not created' }
|
|
490
|
+
}
|
|
491
|
+
let res
|
|
492
|
+
try {
|
|
493
|
+
res = await client.queue(queueName)
|
|
494
|
+
.push([{ traceId: 'not-a-uuid', data: { i: 1 } }])
|
|
495
|
+
} catch (err) {
|
|
496
|
+
// Server rejected: acceptable per plan.
|
|
497
|
+
return { success: true, message: 'Server rejected malformed traceId (expected)' }
|
|
498
|
+
}
|
|
499
|
+
if (!Array.isArray(res) || res.length !== 1) {
|
|
500
|
+
return { success: false, message: `Expected 1 result, got ${JSON.stringify(res)}` }
|
|
501
|
+
}
|
|
502
|
+
// If the client dropped the bad traceId, we'll get a queued row with
|
|
503
|
+
// trace_id=null. That is an acceptable, well-documented outcome.
|
|
504
|
+
if (res[0].status !== 'queued' && res[0].status !== 'failed') {
|
|
505
|
+
return { success: false, message: `Unexpected status: ${JSON.stringify(res[0])}` }
|
|
506
|
+
}
|
|
507
|
+
return { success: true }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export async function testPushProducerSubEmptyStringBecomesNull(client) {
|
|
511
|
+
// We can't set producerSub from the client (server-stamped field). The
|
|
512
|
+
// invariant we need to preserve is that when the server writes an empty
|
|
513
|
+
// producer_sub into the JSON, the SP stores SQL NULL. We verify it by
|
|
514
|
+
// pushing without auth (server will stamp producerSub as empty / NULL)
|
|
515
|
+
// and then checking via a short-lived pg client. NOTE: we intentionally
|
|
516
|
+
// do NOT dynamically import ./run.js here — that module executes
|
|
517
|
+
// top-level `await main()` and re-importing it while it's running
|
|
518
|
+
// deadlocks the ESM module graph.
|
|
519
|
+
const queueName = 'test-v3-producer-sub-null'
|
|
520
|
+
const queue = await client.queue(queueName).create()
|
|
521
|
+
if (!queue.configured) {
|
|
522
|
+
return { success: false, message: 'Queue not created' }
|
|
523
|
+
}
|
|
524
|
+
const res = await client.queue(queueName).push([{ data: { x: 1 } }])
|
|
525
|
+
if (res[0].status !== 'queued') {
|
|
526
|
+
return { success: false, message: 'Push failed' }
|
|
527
|
+
}
|
|
528
|
+
const messageId = res[0].message_id
|
|
529
|
+
|
|
530
|
+
const { default: pg } = await import('pg')
|
|
531
|
+
const pool = new pg.Pool({
|
|
532
|
+
host: process.env.PG_HOST || 'localhost',
|
|
533
|
+
port: process.env.PG_PORT || 5432,
|
|
534
|
+
database: process.env.PG_DB || 'postgres',
|
|
535
|
+
user: process.env.PG_USER || 'postgres',
|
|
536
|
+
password: process.env.PG_PASSWORD || 'postgres',
|
|
537
|
+
max: 1
|
|
538
|
+
})
|
|
539
|
+
try {
|
|
540
|
+
const r = await pool.query(
|
|
541
|
+
'SELECT producer_sub FROM queen.messages WHERE id = $1',
|
|
542
|
+
[messageId]
|
|
543
|
+
)
|
|
544
|
+
if (r.rows.length !== 1) {
|
|
545
|
+
return { success: false, message: `Message ${messageId} not found` }
|
|
546
|
+
}
|
|
547
|
+
if (r.rows[0].producer_sub !== null) {
|
|
548
|
+
return { success: false, message: `Expected producer_sub NULL, got ${JSON.stringify(r.rows[0].producer_sub)}` }
|
|
549
|
+
}
|
|
550
|
+
return { success: true }
|
|
551
|
+
} finally {
|
|
552
|
+
await pool.end()
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
281
556
|
export async function pushEncryptedPayload(client) {
|
|
282
557
|
const queue = await client.queue('test-queue-encrypted-payload')
|
|
283
558
|
.config({ encryptionEnabled: true })
|
package/test-v2/run.js
CHANGED
|
@@ -13,6 +13,8 @@ import * as maintenanceTests from './maintenance.js'
|
|
|
13
13
|
import * as retentionTests from './retention.js'
|
|
14
14
|
import * as bootstrapTests from './bootstrap.js'
|
|
15
15
|
import * as loggerTests from './logger.js'
|
|
16
|
+
import * as watermarkTests from './watermark.js'
|
|
17
|
+
import * as authTests from './auth.js'
|
|
16
18
|
import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
|
|
17
19
|
|
|
18
20
|
|
|
@@ -115,7 +117,9 @@ async function main() {
|
|
|
115
117
|
retentionTests,
|
|
116
118
|
maintenanceTests,
|
|
117
119
|
bootstrapTests,
|
|
118
|
-
loggerTests
|
|
120
|
+
loggerTests,
|
|
121
|
+
watermarkTests,
|
|
122
|
+
authTests
|
|
119
123
|
]
|
|
120
124
|
|
|
121
125
|
const aiTests = [
|
|
@@ -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
|
+
}
|