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
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side 429/403 handling tests (PLAN_QUEEN_PROXY_CLOUD.md §4/§9,
|
|
3
|
+
* blocker B4 -- "client 429/backoff work ... mandatory pre-enforcement").
|
|
4
|
+
*
|
|
5
|
+
* The proxy error contract under test:
|
|
6
|
+
* 429 Retry-After: <seconds> { "error": "...", "code": "rate_limited" | "quota_exceeded" }
|
|
7
|
+
* 403 { "error": "...", "code": "cluster_suspended" | "storage_quota_exceeded"
|
|
8
|
+
* | "feature_gated" | "forbidden" }
|
|
9
|
+
*
|
|
10
|
+
* These tests run against a real local HTTP server (no broker) that plays
|
|
11
|
+
* back a canned response script, mirroring the style of ack.test.js
|
|
12
|
+
* (withAckServer) and streams-unit/fakeServer.js -- no mocking framework,
|
|
13
|
+
* just node:http.
|
|
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
|
+
import { HttpClient } from '../../client-v2/http/HttpClient.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Spin up a real HTTP server driven by a canned response `plan` (array of
|
|
25
|
+
* {status, body, retryAfter, headers}, consumed in request order; any
|
|
26
|
+
* request beyond the plan gets `defaultResponse`). Records each hit's
|
|
27
|
+
* arrival time so tests can sanity-check backoff pacing.
|
|
28
|
+
*/
|
|
29
|
+
function withPlanServer(plan, defaultResponse, run) {
|
|
30
|
+
const hits = []
|
|
31
|
+
const start = Date.now()
|
|
32
|
+
let index = 0
|
|
33
|
+
|
|
34
|
+
const server = createServer((req, res) => {
|
|
35
|
+
let raw = ''
|
|
36
|
+
req.on('data', chunk => { raw += chunk })
|
|
37
|
+
req.on('end', () => {
|
|
38
|
+
hits.push({ method: req.method, url: req.url, atMs: Date.now() - start })
|
|
39
|
+
|
|
40
|
+
const descriptor = index < plan.length ? plan[index] : defaultResponse
|
|
41
|
+
index++
|
|
42
|
+
|
|
43
|
+
const status = descriptor.status ?? 200
|
|
44
|
+
const headers = { 'Content-Type': 'application/json', ...(descriptor.headers || {}) }
|
|
45
|
+
if (descriptor.retryAfter != null) headers['Retry-After'] = String(descriptor.retryAfter)
|
|
46
|
+
|
|
47
|
+
res.writeHead(status, headers)
|
|
48
|
+
res.end(JSON.stringify(descriptor.body ?? { ok: true }))
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
server.listen(0, '127.0.0.1', () => {
|
|
54
|
+
const { port } = server.address()
|
|
55
|
+
const teardown = () => new Promise(r => server.close(r))
|
|
56
|
+
Promise.resolve()
|
|
57
|
+
.then(() => run(`http://127.0.0.1:${port}`, hits))
|
|
58
|
+
.then(
|
|
59
|
+
async (value) => { await teardown(); resolve(value) },
|
|
60
|
+
async (err) => { await teardown(); reject(err) }
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rateLimited(retryAfter) {
|
|
67
|
+
const descriptor = { status: 429, body: { error: 'slow down', code: 'rate_limited' } }
|
|
68
|
+
if (retryAfter != null) descriptor.retryAfter = retryAfter
|
|
69
|
+
return descriptor
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function repeat(descriptor, n) {
|
|
73
|
+
return Array.from({ length: n }, () => ({ ...descriptor }))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// HttpClient (direct) -- the centralized retry429 mechanism itself.
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
describe('HttpClient — 429 retry policy', () => {
|
|
81
|
+
it('honors Retry-After (seconds) and succeeds once the header-specified wait elapses', async () => {
|
|
82
|
+
const plan = [rateLimited('0')]
|
|
83
|
+
await withPlanServer(plan, { status: 200, body: { ok: true } }, async (url, hits) => {
|
|
84
|
+
const client = new HttpClient({ baseUrl: url, retry429: { baseMs: 5, capMs: 50 } })
|
|
85
|
+
try {
|
|
86
|
+
const result = await client.get('/x')
|
|
87
|
+
assert.deepEqual(result, { ok: true })
|
|
88
|
+
assert.equal(hits.length, 2, 'one 429 then one success')
|
|
89
|
+
} finally {
|
|
90
|
+
await client.destroy()
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('falls back to exponential backoff when Retry-After is absent, and the gap grows', async () => {
|
|
96
|
+
const plan = repeat(rateLimited(), 2)
|
|
97
|
+
await withPlanServer(plan, { status: 200, body: { ok: true } }, async (url, hits) => {
|
|
98
|
+
const client = new HttpClient({ baseUrl: url, retry429: { baseMs: 20, capMs: 2000 } })
|
|
99
|
+
try {
|
|
100
|
+
const result = await client.get('/x')
|
|
101
|
+
assert.deepEqual(result, { ok: true })
|
|
102
|
+
assert.equal(hits.length, 3)
|
|
103
|
+
const gap1 = hits[1].atMs - hits[0].atMs
|
|
104
|
+
const gap2 = hits[2].atMs - hits[1].atMs
|
|
105
|
+
// Exponential (base=20ms -> ~20ms then ~40ms), jittered +-20%: allow
|
|
106
|
+
// generous slack for CI timer jitter while still proving growth.
|
|
107
|
+
assert.ok(gap2 > gap1 * 1.2, `expected gap2 (${gap2}ms) to exceed gap1 (${gap1}ms) by backoff growth`)
|
|
108
|
+
} finally {
|
|
109
|
+
await client.destroy()
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('gives up after retry429.maxAttempts on a default (push-like) call and throws rate_limited', async () => {
|
|
115
|
+
await withPlanServer([], rateLimited(), async (url, hits) => {
|
|
116
|
+
const client = new HttpClient({ baseUrl: url, retry429: { maxAttempts: 3, baseMs: 1, capMs: 5 } })
|
|
117
|
+
try {
|
|
118
|
+
await assert.rejects(
|
|
119
|
+
client.post('/api/v1/push', { items: [] }),
|
|
120
|
+
(err) => {
|
|
121
|
+
assert.equal(err.status, 429)
|
|
122
|
+
assert.equal(err.code, 'rate_limited')
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
assert.equal(hits.length, 3, 'exactly maxAttempts tries, no more')
|
|
127
|
+
} finally {
|
|
128
|
+
await client.destroy()
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('defaults to 10 attempts for a default (non-pop) call when retry429 is not configured', async () => {
|
|
134
|
+
await withPlanServer([], rateLimited(), async (url, hits) => {
|
|
135
|
+
const client = new HttpClient({ baseUrl: url, retry429: { baseMs: 1, capMs: 2 } })
|
|
136
|
+
try {
|
|
137
|
+
await assert.rejects(client.post('/api/v1/push', { items: [] }))
|
|
138
|
+
assert.equal(hits.length, 10)
|
|
139
|
+
} finally {
|
|
140
|
+
await client.destroy()
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('retries a long-poll pop (retryKind "pop") past the push default of 10 attempts', async () => {
|
|
146
|
+
const plan = repeat(rateLimited('0'), 14)
|
|
147
|
+
await withPlanServer(plan, { status: 200, body: { messages: [{ id: 'm1' }] } }, async (url, hits) => {
|
|
148
|
+
const client = new HttpClient({ baseUrl: url, retry429: { baseMs: 1, capMs: 5 } })
|
|
149
|
+
try {
|
|
150
|
+
const result = await client.get('/api/v1/pop', null, null, 'pop')
|
|
151
|
+
assert.deepEqual(result, { messages: [{ id: 'm1' }] })
|
|
152
|
+
assert.equal(hits.length, 15, 'pop must not give up at the push default of 10 attempts')
|
|
153
|
+
} finally {
|
|
154
|
+
await client.destroy()
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('an explicit maxAttempts override applies to pop too (not just push)', async () => {
|
|
160
|
+
await withPlanServer([], rateLimited(), async (url, hits) => {
|
|
161
|
+
const client = new HttpClient({ baseUrl: url, retry429: { maxAttempts: 2, baseMs: 1, capMs: 5 } })
|
|
162
|
+
try {
|
|
163
|
+
await assert.rejects(client.get('/api/v1/pop', null, null, 'pop'))
|
|
164
|
+
assert.equal(hits.length, 2, 'explicit maxAttempts bounds pop as well as push')
|
|
165
|
+
} finally {
|
|
166
|
+
await client.destroy()
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('never retries a 403 and preserves its code (terminal)', async () => {
|
|
172
|
+
await withPlanServer([], { status: 403, body: { error: 'cluster suspended', code: 'cluster_suspended' } }, async (url, hits) => {
|
|
173
|
+
const client = new HttpClient({ baseUrl: url })
|
|
174
|
+
try {
|
|
175
|
+
await assert.rejects(
|
|
176
|
+
client.post('/api/v1/push', { items: [] }),
|
|
177
|
+
(err) => {
|
|
178
|
+
assert.equal(err.status, 403)
|
|
179
|
+
assert.equal(err.code, 'cluster_suspended')
|
|
180
|
+
return true
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
assert.equal(hits.length, 1, '403 must not be retried')
|
|
184
|
+
} finally {
|
|
185
|
+
await client.destroy()
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('still does not retry an ordinary 4xx like 400 (unaffected by the 429 changes)', async () => {
|
|
191
|
+
await withPlanServer([], { status: 400, body: { error: 'bad request' } }, async (url, hits) => {
|
|
192
|
+
const client = new HttpClient({ baseUrl: url })
|
|
193
|
+
try {
|
|
194
|
+
await assert.rejects(client.get('/x'), (err) => {
|
|
195
|
+
assert.equal(err.status, 400)
|
|
196
|
+
return true
|
|
197
|
+
})
|
|
198
|
+
assert.equal(hits.length, 1)
|
|
199
|
+
} finally {
|
|
200
|
+
await client.destroy()
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Queen public API wiring: config plumbing (retry429) + call-site marking
|
|
208
|
+
// (push vs. wait=true pop) actually reach HttpClient.
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
describe('Queen — push()/pop() wired to retry429', () => {
|
|
212
|
+
it('push() retries a 429 (Retry-After) then succeeds', async () => {
|
|
213
|
+
const plan = [rateLimited('0')]
|
|
214
|
+
await withPlanServer(plan, { status: 200, body: [{ status: 'queued', transactionId: 'tx-1' }] }, async (url, hits) => {
|
|
215
|
+
const queen = new Queen({ url, handleSignals: false, retry429: { baseMs: 5, capMs: 50 } })
|
|
216
|
+
try {
|
|
217
|
+
const result = await queen.queue('q1').push({ hello: 'world' })
|
|
218
|
+
assert.equal(hits.length, 2)
|
|
219
|
+
assert.equal(result[0].status, 'queued')
|
|
220
|
+
} finally {
|
|
221
|
+
await queen.close()
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('push() surfaces a terminal 403 without retrying', async () => {
|
|
227
|
+
await withPlanServer([], { status: 403, body: { error: 'over quota', code: 'storage_quota_exceeded' } }, async (url, hits) => {
|
|
228
|
+
const queen = new Queen({ url, handleSignals: false })
|
|
229
|
+
try {
|
|
230
|
+
await assert.rejects(
|
|
231
|
+
async () => { await queen.queue('q1').push({ hello: 'world' }) },
|
|
232
|
+
(err) => { assert.equal(err.code, 'storage_quota_exceeded'); return true }
|
|
233
|
+
)
|
|
234
|
+
assert.equal(hits.length, 1)
|
|
235
|
+
} finally {
|
|
236
|
+
await queen.close()
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('a long-poll pop() rides out more 429s than the push default before returning', async () => {
|
|
242
|
+
const plan = repeat(rateLimited('0'), 12)
|
|
243
|
+
await withPlanServer(plan, { status: 200, body: { messages: [{ transactionId: 'tx-1', data: { x: 1 } }] } }, async (url, hits) => {
|
|
244
|
+
const queen = new Queen({ url, handleSignals: false, retry429: { baseMs: 1, capMs: 5 } })
|
|
245
|
+
try {
|
|
246
|
+
const messages = await queen.queue('q1').wait(true).pop()
|
|
247
|
+
assert.equal(messages.length, 1)
|
|
248
|
+
assert.equal(hits.length, 13)
|
|
249
|
+
} finally {
|
|
250
|
+
await queen.close()
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('a non-waiting pop() uses the bounded default and swallows an exhausted 429 to []', async () => {
|
|
256
|
+
await withPlanServer([], rateLimited(), async (url, hits) => {
|
|
257
|
+
const queen = new Queen({ url, handleSignals: false, retry429: { maxAttempts: 2, baseMs: 1, capMs: 5 } })
|
|
258
|
+
try {
|
|
259
|
+
const messages = await queen.queue('q1').wait(false).pop()
|
|
260
|
+
assert.deepEqual(messages, [], 'pop() keeps its swallow-errors-to-[] contract')
|
|
261
|
+
assert.equal(hits.length, 2)
|
|
262
|
+
} finally {
|
|
263
|
+
await queen.close()
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// ConsumerManager worker loop: the actual hot-loop/die bug (B4) this task
|
|
271
|
+
// fixes -- consume() must back off through 429s and stop cleanly on a
|
|
272
|
+
// terminal 403 instead of spinning or crashing uncontrolled.
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
describe('Queen — consume() loop backs off on 429 and stops on terminal 403', () => {
|
|
276
|
+
it('backs off through repeated 429s and still delivers the message', async () => {
|
|
277
|
+
const plan = repeat(rateLimited('0'), 3)
|
|
278
|
+
await withPlanServer(
|
|
279
|
+
plan,
|
|
280
|
+
{ status: 200, body: { messages: [{ transactionId: 'tx-1', partitionId: 'p-1', data: { x: 1 } }] } },
|
|
281
|
+
async (url, hits) => {
|
|
282
|
+
const queen = new Queen({ url, handleSignals: false, retry429: { baseMs: 1, capMs: 5 } })
|
|
283
|
+
try {
|
|
284
|
+
const received = []
|
|
285
|
+
// .each() delivers one message per handler call (default batches
|
|
286
|
+
// the whole array) -- simplest shape to assert on here.
|
|
287
|
+
await queen.queue('q1').wait(true).limit(1).autoAck(false).each().consume(async (msg) => {
|
|
288
|
+
received.push(msg)
|
|
289
|
+
})
|
|
290
|
+
assert.equal(received.length, 1)
|
|
291
|
+
assert.equal(received[0].transactionId, 'tx-1')
|
|
292
|
+
assert.ok(hits.length >= 4, 'the 3 rate-limited attempts plus the final success must all have hit the server')
|
|
293
|
+
} finally {
|
|
294
|
+
await queen.close()
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('stops the worker on a terminal 403 (cluster_suspended) instead of hot-looping or hanging', async () => {
|
|
301
|
+
await withPlanServer([], { status: 403, body: { error: 'cluster suspended', code: 'cluster_suspended' } }, async (url, hits) => {
|
|
302
|
+
const queen = new Queen({ url, handleSignals: false })
|
|
303
|
+
try {
|
|
304
|
+
await assert.rejects(
|
|
305
|
+
async () => { await queen.queue('q1').wait(true).consume(async () => {}) },
|
|
306
|
+
(err) => { assert.equal(err.code, 'cluster_suspended'); return true }
|
|
307
|
+
)
|
|
308
|
+
assert.equal(hits.length, 1, 'must stop after the first 403, not hot-loop')
|
|
309
|
+
} finally {
|
|
310
|
+
await queen.close()
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
})
|
package/test-v2/load.js
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
// Ordering assertion that tolerates REDELIVERY (at-least-once is the broker's
|
|
2
|
+
// contract: a lease expiry / ack timeout on a slow machine legally replays a
|
|
3
|
+
// batch). What it still catches is the real bug class — SKIPPED messages: the
|
|
4
|
+
// stream must never jump PAST an id it hasn't delivered yet. Throwing on a
|
|
5
|
+
// redelivered (already-seen) id instead would nack the batch, and four nacks
|
|
6
|
+
// dead-letter the head (v0.16.0 retry semantics) — turning one benign
|
|
7
|
+
// redelivery into message loss and a failed test.
|
|
8
|
+
function makeOrderingTracker() {
|
|
9
|
+
let maxId = -1
|
|
10
|
+
return {
|
|
11
|
+
observe(id) {
|
|
12
|
+
if (id > maxId + 1) {
|
|
13
|
+
throw new Error(`Message ordering violation: jumped from ${maxId} to ${id} (skipped ${id - maxId - 1})`)
|
|
14
|
+
}
|
|
15
|
+
if (id > maxId) maxId = id
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
1
20
|
export async function testLoad(client) {
|
|
2
21
|
const queue = await client
|
|
3
22
|
.queue('test-queue-v2-load')
|
|
@@ -19,24 +38,22 @@ export async function testLoad(client) {
|
|
|
19
38
|
await client.queue('test-queue-v2-load').push(messages)
|
|
20
39
|
|
|
21
40
|
let uniqueIds = new Set();
|
|
22
|
-
|
|
41
|
+
const order = makeOrderingTracker();
|
|
23
42
|
|
|
43
|
+
// idleMillis instead of limit(10000): a hard per-worker delivery budget
|
|
44
|
+
// counts redeliveries too, so one replayed batch makes workers return
|
|
45
|
+
// "complete" with messages still queued (or spin forever waiting to hit an
|
|
46
|
+
// exact share). Draining until idle is redelivery-proof — same pattern as
|
|
47
|
+
// the bootstrap tests.
|
|
24
48
|
await client
|
|
25
49
|
.queue('test-queue-v2-load')
|
|
26
50
|
.concurrency(10)
|
|
27
51
|
.batch(100)
|
|
28
52
|
.wait(false)
|
|
29
|
-
.
|
|
53
|
+
.idleMillis(5000)
|
|
30
54
|
.consume(async msgs => {
|
|
31
55
|
for (const msg of msgs) {
|
|
32
|
-
|
|
33
|
-
lastId = msg.data.id
|
|
34
|
-
} else {
|
|
35
|
-
if (msg.data.id !== lastId + 1) {
|
|
36
|
-
throw new Error('Message ordering violation')
|
|
37
|
-
}
|
|
38
|
-
lastId = msg.data.id
|
|
39
|
-
}
|
|
56
|
+
order.observe(msg.data.id)
|
|
40
57
|
uniqueIds.add(msg.data.id)
|
|
41
58
|
}
|
|
42
59
|
})
|
|
@@ -76,24 +93,16 @@ export async function testLoadPartition(client) {
|
|
|
76
93
|
|
|
77
94
|
|
|
78
95
|
for (let i = 0; i < 10; i++) {
|
|
79
|
-
|
|
96
|
+
const order = makeOrderingTracker();
|
|
80
97
|
await client
|
|
81
98
|
.queue('test-queue-v2-load-partition')
|
|
82
99
|
.partition(i.toString())
|
|
83
100
|
.batch(100)
|
|
84
101
|
.wait(false)
|
|
85
|
-
.
|
|
102
|
+
.idleMillis(5000)
|
|
86
103
|
.consume(async msgs => {
|
|
87
104
|
for (const msg of msgs) {
|
|
88
|
-
|
|
89
|
-
lastId = msg.data.id
|
|
90
|
-
} else {
|
|
91
|
-
if (msg.data.id !== lastId + 1) {
|
|
92
|
-
console.log(`Message ordering violation: ${msg.data.id} !== ${lastId + 1}`)
|
|
93
|
-
throw new Error('Message ordering violation')
|
|
94
|
-
}
|
|
95
|
-
lastId = msg.data.id
|
|
96
|
-
}
|
|
105
|
+
order.observe(msg.data.id)
|
|
97
106
|
uniqueIds.add(msg.data.id)
|
|
98
107
|
}
|
|
99
108
|
})
|
|
@@ -126,7 +135,7 @@ export async function testLoadConsumerGroup(client) {
|
|
|
126
135
|
await client.queue('test-queue-v2-load-consumer-group').push(messages)
|
|
127
136
|
|
|
128
137
|
let uniqueIdsA = new Set();
|
|
129
|
-
|
|
138
|
+
const orderA = makeOrderingTracker();
|
|
130
139
|
|
|
131
140
|
await client
|
|
132
141
|
.queue('test-queue-v2-load-consumer-group')
|
|
@@ -135,23 +144,16 @@ export async function testLoadConsumerGroup(client) {
|
|
|
135
144
|
.concurrency(10)
|
|
136
145
|
.batch(100)
|
|
137
146
|
.wait(false)
|
|
138
|
-
.
|
|
147
|
+
.idleMillis(5000)
|
|
139
148
|
.consume(async msgs => {
|
|
140
149
|
for (const msg of msgs) {
|
|
141
|
-
|
|
142
|
-
lastIdA = msg.data.id
|
|
143
|
-
} else {
|
|
144
|
-
if (msg.data.id !== lastIdA + 1) {
|
|
145
|
-
throw new Error('Message ordering violation')
|
|
146
|
-
}
|
|
147
|
-
lastIdA = msg.data.id
|
|
148
|
-
}
|
|
150
|
+
orderA.observe(msg.data.id)
|
|
149
151
|
uniqueIdsA.add(msg.data.id)
|
|
150
152
|
}
|
|
151
153
|
})
|
|
152
154
|
|
|
153
155
|
let uniqueIdsB = new Set();
|
|
154
|
-
|
|
156
|
+
const orderB = makeOrderingTracker();
|
|
155
157
|
|
|
156
158
|
await client
|
|
157
159
|
.queue('test-queue-v2-load-consumer-group')
|
|
@@ -160,22 +162,16 @@ export async function testLoadConsumerGroup(client) {
|
|
|
160
162
|
.concurrency(10)
|
|
161
163
|
.batch(100)
|
|
162
164
|
.wait(false)
|
|
163
|
-
.
|
|
165
|
+
.idleMillis(5000)
|
|
164
166
|
.consume(async msgs => {
|
|
165
167
|
for (const msg of msgs) {
|
|
166
|
-
|
|
167
|
-
lastIdB = msg.data.id
|
|
168
|
-
} else {
|
|
169
|
-
if (msg.data.id !== lastIdB + 1) {
|
|
170
|
-
throw new Error('Message ordering violation')
|
|
171
|
-
}
|
|
172
|
-
lastIdB = msg.data.id
|
|
173
|
-
}
|
|
168
|
+
orderB.observe(msg.data.id)
|
|
174
169
|
uniqueIdsB.add(msg.data.id)
|
|
175
170
|
}
|
|
176
171
|
})
|
|
177
172
|
|
|
178
173
|
const uniqueIdsCountA = uniqueIdsA.size;
|
|
179
174
|
const uniqueIdsCountB = uniqueIdsB.size;
|
|
175
|
+
console.log(`Unique IDs: groupA=${uniqueIdsCountA} groupB=${uniqueIdsCountB}`)
|
|
180
176
|
return { success: uniqueIdsCountA === messagesToPush && uniqueIdsCountB === messagesToPush, message: 'Load test completed successfully' }
|
|
181
177
|
}
|
package/test-v2/maintenance.js
CHANGED
|
@@ -6,8 +6,8 @@ export async function test_maintenance_mode(client = null) {
|
|
|
6
6
|
|
|
7
7
|
const queen = client
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
const API_URL = 'http://localhost:6632';
|
|
9
|
+
// Default Queen port; override with QUEEN_SERVER_URL (same as run.js).
|
|
10
|
+
const API_URL = process.env.QUEEN_SERVER_URL || 'http://localhost:6632';
|
|
11
11
|
const QUEUE_NAME = 'test-maintenance-queue';
|
|
12
12
|
const MESSAGES_PER_SECOND = 10;
|
|
13
13
|
const MAINTENANCE_DURATION_SECONDS = 10;
|
package/test-v2/pop.js
CHANGED
|
@@ -25,11 +25,13 @@ export async function popNonEmptyQueue(client) {
|
|
|
25
25
|
// wait(false) wildcard pop can race past the not-yet-committed lookup
|
|
26
26
|
// row. Long-poll re-runs the candidate scan and picks up the row
|
|
27
27
|
// once it commits (typically within ms).
|
|
28
|
+
// docs:start(js-pop)
|
|
28
29
|
const res = await client
|
|
29
30
|
.queue('test-queue-v2-pop-non-empty')
|
|
30
31
|
.batch(1)
|
|
31
32
|
.wait(true)
|
|
32
33
|
.pop()
|
|
34
|
+
// docs:end
|
|
33
35
|
return { success: res.length === 1 }
|
|
34
36
|
}
|
|
35
37
|
|
package/test-v2/push.js
CHANGED
|
@@ -5,9 +5,11 @@ export async function pushMessage(client) {
|
|
|
5
5
|
if (!queue.configured) {
|
|
6
6
|
return { success: false, message: 'Queue not created' }
|
|
7
7
|
}
|
|
8
|
+
// docs:start(js-push)
|
|
8
9
|
const res = await client
|
|
9
10
|
.queue('test-queue-v2')
|
|
10
11
|
.push([{ data: { message: 'Hello, world!' } }])
|
|
12
|
+
// docs:end
|
|
11
13
|
|
|
12
14
|
return { success: res[0].status === 'queued' }
|
|
13
15
|
}
|
|
@@ -17,14 +19,17 @@ export async function pushDuplicateMessage(client) {
|
|
|
17
19
|
if (!queue.configured) {
|
|
18
20
|
return { success: false, message: 'Queue not created' }
|
|
19
21
|
}
|
|
22
|
+
// docs:start(js-push-dedup)
|
|
20
23
|
const res1 = await client
|
|
21
24
|
.queue('test-queue-v2')
|
|
22
25
|
.push([{ transactionId: 'test-transaction-id', data: { message: 'Hello, world!' } }])
|
|
23
|
-
|
|
26
|
+
|
|
24
27
|
const res2 = await client
|
|
25
28
|
.queue('test-queue-v2')
|
|
26
29
|
.push([{ transactionId: 'test-transaction-id', data: { message: 'Hello, world!' } }])
|
|
27
|
-
|
|
30
|
+
// res1[0].status === 'queued', res2[0].status === 'duplicate'
|
|
31
|
+
// docs:end
|
|
32
|
+
|
|
28
33
|
return { success: res1[0].status === 'queued' && res2[0].status === 'duplicate' }
|
|
29
34
|
}
|
|
30
35
|
|
|
@@ -518,48 +523,37 @@ export async function testPushMalformedTraceId(client) {
|
|
|
518
523
|
|
|
519
524
|
export async function testPushProducerSubEmptyStringBecomesNull(client) {
|
|
520
525
|
// We can't set producerSub from the client (server-stamped field). The
|
|
521
|
-
// invariant we need to preserve is that
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
// top-level `await main()` and re-importing it while it's running
|
|
527
|
-
// deadlocks the ESM module graph.
|
|
526
|
+
// invariant we need to preserve is that a message pushed without auth has a
|
|
527
|
+
// producerSub of null (never a literal empty string). Black-box port: the
|
|
528
|
+
// segments engine stores messages in queen.seg_segments and never populates
|
|
529
|
+
// queen.messages, so we observe the field via the pop API — the seg-native
|
|
530
|
+
// ground truth — instead of a direct SQL read of queen.messages.
|
|
528
531
|
const queueName = 'test-v3-producer-sub-null'
|
|
529
532
|
const queue = await client.queue(queueName).create()
|
|
530
533
|
if (!queue.configured) {
|
|
531
534
|
return { success: false, message: 'Queue not created' }
|
|
532
535
|
}
|
|
533
|
-
const
|
|
536
|
+
const tx = 'tx-psn-' + crypto.randomBytes(4).toString('hex')
|
|
537
|
+
const res = await client.queue(queueName).push([{ transactionId: tx, data: { x: 1 } }])
|
|
534
538
|
if (res[0].status !== 'queued') {
|
|
535
539
|
return { success: false, message: 'Push failed' }
|
|
536
540
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
password: process.env.PG_PASSWORD || 'postgres',
|
|
546
|
-
max: 1
|
|
547
|
-
})
|
|
548
|
-
try {
|
|
549
|
-
const r = await pool.query(
|
|
550
|
-
'SELECT producer_sub FROM queen.messages WHERE id = $1',
|
|
551
|
-
[messageId]
|
|
552
|
-
)
|
|
553
|
-
if (r.rows.length !== 1) {
|
|
554
|
-
return { success: false, message: `Message ${messageId} not found` }
|
|
555
|
-
}
|
|
556
|
-
if (r.rows[0].producer_sub !== null) {
|
|
557
|
-
return { success: false, message: `Expected producer_sub NULL, got ${JSON.stringify(r.rows[0].producer_sub)}` }
|
|
558
|
-
}
|
|
559
|
-
return { success: true }
|
|
560
|
-
} finally {
|
|
561
|
-
await pool.end()
|
|
541
|
+
|
|
542
|
+
// wait(true): ride out any push→visibility race (see pushLargePayload).
|
|
543
|
+
const deadline = Date.now() + 6000
|
|
544
|
+
let hit = null
|
|
545
|
+
while (Date.now() < deadline && !hit) {
|
|
546
|
+
const msgs = await client.queue(queueName).batch(20).wait(true).pop()
|
|
547
|
+
hit = (msgs || []).find(m => m && m.transactionId === tx)
|
|
548
|
+
if (!hit) await new Promise(r => setTimeout(r, 150))
|
|
562
549
|
}
|
|
550
|
+
if (!hit) {
|
|
551
|
+
return { success: false, message: `Pushed message ${tx} not observed via pop` }
|
|
552
|
+
}
|
|
553
|
+
if (hit.producerSub != null) {
|
|
554
|
+
return { success: false, message: `Expected producerSub null, got ${JSON.stringify(hit.producerSub)}` }
|
|
555
|
+
}
|
|
556
|
+
return { success: true }
|
|
563
557
|
}
|
|
564
558
|
|
|
565
559
|
export async function pushEncryptedPayload(client) {
|
package/test-v2/run.js
CHANGED
|
@@ -15,6 +15,8 @@ import * as bootstrapTests from './bootstrap.js'
|
|
|
15
15
|
import * as loggerTests from './logger.js'
|
|
16
16
|
import * as watermarkTests from './watermark.js'
|
|
17
17
|
import * as authTests from './auth.js'
|
|
18
|
+
import * as semanticsTests from './semantics.js'
|
|
19
|
+
import * as ackWindowTests from './ackwindow.js'
|
|
18
20
|
import * as streamTests from './stream/index.js'
|
|
19
21
|
import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
|
|
20
22
|
|
|
@@ -22,7 +24,7 @@ import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
|
|
|
22
24
|
// Test configuration
|
|
23
25
|
|
|
24
26
|
export const TEST_CONFIG_SINGLE = {
|
|
25
|
-
baseUrls: ['http://localhost:6632'],
|
|
27
|
+
baseUrls: [process.env.QUEEN_SERVER_URL || 'http://localhost:6632'],
|
|
26
28
|
loadBalancingStrategy: 'affinity',
|
|
27
29
|
dbConfig: {
|
|
28
30
|
host: process.env.PG_HOST || 'localhost',
|
|
@@ -88,6 +90,8 @@ function printResults() {
|
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
export const cleanupTestData = async () => {
|
|
93
|
+
// All the LIKE patterns test queues use.
|
|
94
|
+
const patterns = ['test-%', 'edge-%', 'pattern-%', 'workflow-%'];
|
|
91
95
|
try {
|
|
92
96
|
// Drop streaming queries first (CASCADE removes their state rows).
|
|
93
97
|
// Safe even when queen_streams isn't installed yet — we swallow the
|
|
@@ -98,11 +102,32 @@ export const cleanupTestData = async () => {
|
|
|
98
102
|
// queen_streams schema not installed — ignore.
|
|
99
103
|
}
|
|
100
104
|
|
|
101
|
-
|
|
105
|
+
// Queue identity is now the queen.queues id (log_queues was merged
|
|
106
|
+
// away): log_partitions, consumer_watermarks, consumer_groups_metadata
|
|
107
|
+
// and queue_lag_metrics all cascade from the queues row. Only log_txns
|
|
108
|
+
// and log_dlq have NO foreign key by design, so they get an explicit
|
|
109
|
+
// purge keyed via log_partitions first. Without it, every suite run
|
|
110
|
+
// inherits the previous run's messages and dedup window entries —
|
|
111
|
+
// fixed-transactionId tests report 'duplicate' on their FIRST push.
|
|
112
|
+
try {
|
|
113
|
+
await dbPool.query(`
|
|
114
|
+
WITH parts AS (
|
|
115
|
+
SELECT lp.id FROM queen.log_partitions lp
|
|
116
|
+
JOIN queen.queues q ON q.id = lp.queue_id
|
|
117
|
+
WHERE q.name LIKE ANY($1::text[])
|
|
118
|
+
),
|
|
119
|
+
d1 AS (DELETE FROM queen.log_txns WHERE partition_id IN (SELECT id FROM parts)),
|
|
120
|
+
d2 AS (DELETE FROM queen.log_dlq WHERE partition_id IN (SELECT id FROM parts))
|
|
121
|
+
SELECT 1`, [patterns]);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
// Log-engine schema not installed (rows-only server) — ignore.
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await dbPool.query(`DELETE FROM queen.queues WHERE name LIKE ANY($1::text[])`, [patterns]);
|
|
102
127
|
|
|
103
|
-
log('Test data cleaned up');
|
|
128
|
+
log(true, 'Test data cleaned up (rows + segments)');
|
|
104
129
|
} catch (error) {
|
|
105
|
-
log(`Cleanup error: ${error.message}
|
|
130
|
+
log(false, `Cleanup error: ${error.message}`);
|
|
106
131
|
}
|
|
107
132
|
};
|
|
108
133
|
|
|
@@ -129,7 +154,9 @@ async function main() {
|
|
|
129
154
|
bootstrapTests,
|
|
130
155
|
loggerTests,
|
|
131
156
|
watermarkTests,
|
|
132
|
-
authTests
|
|
157
|
+
authTests,
|
|
158
|
+
semanticsTests,
|
|
159
|
+
ackWindowTests
|
|
133
160
|
]
|
|
134
161
|
|
|
135
162
|
const aiTests = [
|
|
@@ -224,6 +251,11 @@ async function main() {
|
|
|
224
251
|
|
|
225
252
|
//await cleanupTestData()
|
|
226
253
|
await closeDb()
|
|
254
|
+
// Queen.close() destroys the per-client HTTP agent, releasing keep-alive
|
|
255
|
+
// sockets so the loop can drain; the explicit exit code is for CI.
|
|
256
|
+
await client.close()
|
|
257
|
+
const failedCount = testResults.filter(x => !x.success).length
|
|
258
|
+
process.exit(failedCount > 0 ? 1 : 0)
|
|
227
259
|
}
|
|
228
260
|
|
|
229
261
|
try {
|