queen-mq 2.0.0 → 2.0.3
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/README.md +60 -5
- package/client-v2/Queen.js +2 -2
- package/client-v2/README.md +8 -2
- package/client-v2/admin/Admin.js +30 -8
- package/client-v2/builders/QueueBuilder.js +73 -24
- package/client-v2/consumer/ConsumerManager.js +71 -15
- package/client-v2/ephemeral/Ephemeral.js +40 -8
- package/client-v2/http/HttpClient.js +82 -13
- package/client-v2/utils/defaults.js +3 -2
- package/package.json +2 -2
- package/test-v2/admin-unit/removedRoutes.test.js +63 -0
- package/test-v2/consumer-unit/nackScope.test.js +88 -0
- package/test-v2/consumer-unit/stopOnAbort.test.js +537 -0
- package/test-v2/ephemeral-unit/ephemeralWire.test.js +24 -1
- package/test-v2/pop-unit/popDefaults.test.js +236 -0
- package/test-v2/pop.js +68 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* consume() — what aborting its signal does.
|
|
3
|
+
*
|
|
4
|
+
* The rule: stopping a consumer never strands a message. The long poll in
|
|
5
|
+
* flight is closed, so the broker stops handing this consumer messages (a
|
|
6
|
+
* broker hands nothing to a poll whose caller is gone, and releases what a
|
|
7
|
+
* forwarded pop claimed for one). A message the client already holds but has
|
|
8
|
+
* not handed to the handler goes back with a `retry` ack: the lease is
|
|
9
|
+
* released, the message is redelivered first, and no retry is charged.
|
|
10
|
+
*
|
|
11
|
+
* Before: the signal was only checked between polls. A poll open at the
|
|
12
|
+
* abort stayed open for up to its timeout, the broker could still hand it a
|
|
13
|
+
* message, and each() then dropped that message without settling it. The
|
|
14
|
+
* partition stayed blocked until the lease expired (measured against 2.0.1:
|
|
15
|
+
* the full lease, 60-180 s on a rolling restart, every restart).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it } from 'node:test'
|
|
19
|
+
import assert from 'node:assert/strict'
|
|
20
|
+
import { createServer } from 'node:http'
|
|
21
|
+
import { readFile } from 'node:fs/promises'
|
|
22
|
+
import vm from 'node:vm'
|
|
23
|
+
|
|
24
|
+
import { Queen } from '../../client-v2/index.js'
|
|
25
|
+
import { HttpClient } from '../../client-v2/http/HttpClient.js'
|
|
26
|
+
import { LoadBalancer } from '../../client-v2/http/LoadBalancer.js'
|
|
27
|
+
|
|
28
|
+
const GROUP = 'workers'
|
|
29
|
+
|
|
30
|
+
const message = (n) => ({
|
|
31
|
+
id: `msg-${n}`,
|
|
32
|
+
transactionId: `tx-${n}`,
|
|
33
|
+
partitionId: '7',
|
|
34
|
+
partition: 'p1',
|
|
35
|
+
leaseId: 'lease-1',
|
|
36
|
+
consumerGroup: GROUP,
|
|
37
|
+
data: { n },
|
|
38
|
+
createdAt: '2026-10-06T10:00:00.000Z'
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A fake broker. A pop answers the next batch of `pops`; once they run out it
|
|
43
|
+
* is held open, like a long poll on an idle queue, and recorded as `closed`
|
|
44
|
+
* when the client goes away. `ackStatus` answers acks (default: accepted).
|
|
45
|
+
*/
|
|
46
|
+
async function startBroker(pops = [], { ackStatus = 200 } = {}) {
|
|
47
|
+
const queue = [...pops]
|
|
48
|
+
const requests = []
|
|
49
|
+
const held = []
|
|
50
|
+
const server = createServer((req, res) => {
|
|
51
|
+
let raw = ''
|
|
52
|
+
req.on('data', chunk => { raw += chunk })
|
|
53
|
+
req.on('end', () => {
|
|
54
|
+
const body = raw ? JSON.parse(raw) : null
|
|
55
|
+
const path = req.url.split('?')[0]
|
|
56
|
+
const record = { method: req.method, path, body, closed: false }
|
|
57
|
+
requests.push(record)
|
|
58
|
+
if (req.method === 'GET' && path.startsWith('/api/v1/pop')) {
|
|
59
|
+
const batch = queue.shift()
|
|
60
|
+
if (batch) {
|
|
61
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
62
|
+
res.end(JSON.stringify({ success: true, consumerGroup: GROUP, messages: batch }))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
res.on('close', () => { if (!res.writableEnded) record.closed = true })
|
|
66
|
+
held.push(res)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (req.method === 'POST' && (path === '/api/v1/ack' || path === '/api/v1/ack/batch')) {
|
|
70
|
+
const acks = body.acknowledgments || [body]
|
|
71
|
+
res.writeHead(ackStatus, { 'Content-Type': 'application/json' })
|
|
72
|
+
res.end(ackStatus === 200
|
|
73
|
+
? JSON.stringify(acks.map((a, i) => ({ index: i, transactionId: a.transactionId, success: true, error: null, leaseReleased: true })))
|
|
74
|
+
: '{"error":"unavailable"}')
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
78
|
+
res.end('{"error":"not found"}')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
82
|
+
return {
|
|
83
|
+
url: `http://127.0.0.1:${server.address().port}`,
|
|
84
|
+
requests,
|
|
85
|
+
pops: () => requests.filter(r => r.method === 'GET' && r.path.startsWith('/api/v1/pop')),
|
|
86
|
+
acks: () => requests.filter(r => r.method === 'POST' && r.path.startsWith('/api/v1/ack')),
|
|
87
|
+
async stop() {
|
|
88
|
+
for (const res of held) if (!res.writableEnded && !res.destroyed) { res.writeHead(204); res.end() }
|
|
89
|
+
server.closeAllConnections()
|
|
90
|
+
await new Promise(resolve => server.close(resolve))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
96
|
+
|
|
97
|
+
/** Resolves with 'resolved' / 'rejected: …', or 'pending' if it took longer than `ms`. */
|
|
98
|
+
const settleWithin = (promise, ms) => Promise.race([
|
|
99
|
+
Promise.resolve(promise).then(() => 'resolved', e => `rejected: ${e.message}`),
|
|
100
|
+
sleep(ms).then(() => 'pending')
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
const settledStatuses = (acks) => acks.flatMap(a => (a.body.acknowledgments || [a.body]).map(x => `${x.transactionId}:${x.status}`))
|
|
104
|
+
|
|
105
|
+
describe('consume() — aborting the signal never strands a message', () => {
|
|
106
|
+
it('closes the long poll in flight, and the consumer ends at once', async () => {
|
|
107
|
+
const broker = await startBroker()
|
|
108
|
+
const queen = new Queen({ url: broker.url, handleSignals: false })
|
|
109
|
+
try {
|
|
110
|
+
const ac = new AbortController()
|
|
111
|
+
const running = queen.queue('orders').group(GROUP).timeoutMillis(30000)
|
|
112
|
+
.consume(async () => {}, { signal: ac.signal })
|
|
113
|
+
const outcome = settleWithin(running, 2000)
|
|
114
|
+
await sleep(150)
|
|
115
|
+
assert.equal(broker.pops().length, 1, 'one long poll is open')
|
|
116
|
+
|
|
117
|
+
ac.abort()
|
|
118
|
+
|
|
119
|
+
assert.equal(await outcome, 'resolved', 'consume() resolves without waiting out the 30 s poll')
|
|
120
|
+
await sleep(50)
|
|
121
|
+
assert.equal(broker.pops()[0].closed, true, 'the client closed the poll it had open')
|
|
122
|
+
assert.equal(broker.pops().length, 1, 'and opened no other')
|
|
123
|
+
} finally {
|
|
124
|
+
await queen.close()
|
|
125
|
+
await broker.stop()
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('is not a backend failure: the poll does not fail over to another node', async () => {
|
|
130
|
+
const a = await startBroker()
|
|
131
|
+
const b = await startBroker()
|
|
132
|
+
const queen = new Queen({ urls: [a.url, b.url], enableFailover: true, loadBalancingStrategy: 'affinity', handleSignals: false })
|
|
133
|
+
try {
|
|
134
|
+
const ac = new AbortController()
|
|
135
|
+
const running = queen.queue('orders').group(GROUP).timeoutMillis(30000)
|
|
136
|
+
.consume(async () => {}, { signal: ac.signal })
|
|
137
|
+
const outcome = settleWithin(running, 2000)
|
|
138
|
+
await sleep(150)
|
|
139
|
+
|
|
140
|
+
ac.abort()
|
|
141
|
+
|
|
142
|
+
assert.equal(await outcome, 'resolved')
|
|
143
|
+
await sleep(50)
|
|
144
|
+
assert.equal(a.pops().length + b.pops().length, 1, 'the abort did not fail over to the other node')
|
|
145
|
+
} finally {
|
|
146
|
+
await queen.close()
|
|
147
|
+
await a.stop()
|
|
148
|
+
await b.stop()
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('HttpClient: an aborted request rejects as aborted, and leaves every node healthy', async () => {
|
|
153
|
+
const a = await startBroker()
|
|
154
|
+
const b = await startBroker()
|
|
155
|
+
const lb = new LoadBalancer([a.url, b.url], 'affinity')
|
|
156
|
+
const http = new HttpClient({ loadBalancer: lb, enableFailover: true, retryAttempts: 3 })
|
|
157
|
+
try {
|
|
158
|
+
const ac = new AbortController()
|
|
159
|
+
const request = http.get('/api/v1/pop/queue/orders?wait=true&timeout=30000', 35000, 'orders:*:workers', 'pop', ac.signal)
|
|
160
|
+
const settled = request.then(() => null, e => e)
|
|
161
|
+
await sleep(150)
|
|
162
|
+
|
|
163
|
+
ac.abort()
|
|
164
|
+
|
|
165
|
+
const error = await settled
|
|
166
|
+
assert.equal(error?.aborted, true, 'the rejection says the caller aborted it')
|
|
167
|
+
assert.equal(a.pops().length + b.pops().length, 1, 'no other node was tried')
|
|
168
|
+
for (const [url, status] of lb.getHealthStatus()) {
|
|
169
|
+
assert.equal(status.healthy, true, `${url} is still healthy`)
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
await a.stop()
|
|
173
|
+
await b.stop()
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('wait(false): an abort while a pop is in flight ends the consumer without an error', async () => {
|
|
178
|
+
const broker = await startBroker()
|
|
179
|
+
const queen = new Queen({ url: broker.url, handleSignals: false })
|
|
180
|
+
try {
|
|
181
|
+
const ac = new AbortController()
|
|
182
|
+
const running = queen.queue('orders').group(GROUP).wait(false).timeoutMillis(30000)
|
|
183
|
+
.consume(async () => {}, { signal: ac.signal })
|
|
184
|
+
const outcome = settleWithin(running, 2000)
|
|
185
|
+
await sleep(150)
|
|
186
|
+
|
|
187
|
+
ac.abort()
|
|
188
|
+
|
|
189
|
+
assert.equal(await outcome, 'resolved', 'consume() resolves, it does not reject')
|
|
190
|
+
} finally {
|
|
191
|
+
await queen.close()
|
|
192
|
+
await broker.stop()
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('each(): the messages not yet handed to the handler go back with a retry ack', async () => {
|
|
197
|
+
const broker = await startBroker([[message(1), message(2), message(3)]])
|
|
198
|
+
const queen = new Queen({ url: broker.url, handleSignals: false })
|
|
199
|
+
try {
|
|
200
|
+
const ac = new AbortController()
|
|
201
|
+
const seen = []
|
|
202
|
+
const running = queen.queue('orders').group(GROUP).batch(3).each()
|
|
203
|
+
.consume(async (msg) => { seen.push(msg.transactionId); ac.abort() }, { signal: ac.signal })
|
|
204
|
+
|
|
205
|
+
assert.equal(await settleWithin(running, 2000), 'resolved')
|
|
206
|
+
assert.deepEqual(seen, ['tx-1'], 'nothing is handed to the handler after the stop')
|
|
207
|
+
assert.deepEqual(settledStatuses(broker.acks()), ['tx-1:completed', 'tx-2:retry', 'tx-3:retry'],
|
|
208
|
+
'the one in the handler finishes; the other two are released, not dropped')
|
|
209
|
+
const release = broker.acks().at(-1).body
|
|
210
|
+
for (const a of release.acknowledgments || [release]) {
|
|
211
|
+
assert.equal(a.leaseId ?? release.leaseId, 'lease-1', 'the release names the lease it gives back')
|
|
212
|
+
}
|
|
213
|
+
} finally {
|
|
214
|
+
await queen.close()
|
|
215
|
+
await broker.stop()
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('each() with a limit: the messages past the limit go back too, instead of staying leased', async () => {
|
|
220
|
+
const broker = await startBroker([[message(1), message(2), message(3)]])
|
|
221
|
+
const queen = new Queen({ url: broker.url, handleSignals: false })
|
|
222
|
+
try {
|
|
223
|
+
const seen = []
|
|
224
|
+
const running = queen.queue('orders').group(GROUP).batch(3).each().limit(1)
|
|
225
|
+
.consume(async (msg) => { seen.push(msg.transactionId) })
|
|
226
|
+
|
|
227
|
+
assert.equal(await settleWithin(running, 2000), 'resolved')
|
|
228
|
+
assert.deepEqual(seen, ['tx-1'])
|
|
229
|
+
assert.deepEqual(settledStatuses(broker.acks()), ['tx-1:completed', 'tx-2:retry', 'tx-3:retry'])
|
|
230
|
+
} finally {
|
|
231
|
+
await queen.close()
|
|
232
|
+
await broker.stop()
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('a release that cannot be delivered does not fail the consumer: the lease is the fallback', async () => {
|
|
237
|
+
const broker = await startBroker([[message(1), message(2)]], { ackStatus: 503 })
|
|
238
|
+
const queen = new Queen({ url: broker.url, handleSignals: false, retryAttempts: 1 })
|
|
239
|
+
try {
|
|
240
|
+
const ac = new AbortController()
|
|
241
|
+
const running = queen.queue('orders').group(GROUP).batch(2).each().autoAck(false)
|
|
242
|
+
.consume(async () => { ac.abort() }, { signal: ac.signal })
|
|
243
|
+
|
|
244
|
+
assert.equal(await settleWithin(running, 3000), 'resolved', 'consume() still resolves')
|
|
245
|
+
assert.ok(settledStatuses(broker.acks()).includes('tx-2:retry'), 'the release was attempted')
|
|
246
|
+
} finally {
|
|
247
|
+
await queen.close()
|
|
248
|
+
await broker.stop()
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A server that sends the headers and half a JSON body, then stalls. Each
|
|
255
|
+
* request is counted; `closeAll` ends the stalled bodies.
|
|
256
|
+
*/
|
|
257
|
+
async function startStallingServer() {
|
|
258
|
+
let hits = 0
|
|
259
|
+
const open = []
|
|
260
|
+
const server = createServer((req, res) => {
|
|
261
|
+
hits++
|
|
262
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
263
|
+
res.write('{"value":')
|
|
264
|
+
open.push(res)
|
|
265
|
+
})
|
|
266
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
267
|
+
return {
|
|
268
|
+
url: `http://127.0.0.1:${server.address().port}`,
|
|
269
|
+
hits: () => hits,
|
|
270
|
+
async stop() {
|
|
271
|
+
for (const res of open) if (!res.destroyed) res.destroy()
|
|
272
|
+
server.closeAllConnections()
|
|
273
|
+
await new Promise(resolve => server.close(resolve))
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Resolves once fetch has handed back the response headers for a URL with `prefix`. */
|
|
279
|
+
function onResponseHeaders(prefix) {
|
|
280
|
+
const original = globalThis.fetch
|
|
281
|
+
let ready
|
|
282
|
+
const promise = new Promise(resolve => { ready = resolve })
|
|
283
|
+
globalThis.fetch = async (...args) => {
|
|
284
|
+
const response = await original(...args)
|
|
285
|
+
if (String(args[0]).startsWith(prefix)) setImmediate(ready)
|
|
286
|
+
return response
|
|
287
|
+
}
|
|
288
|
+
return { ready: promise, restore() { globalThis.fetch = original } }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
describe('HttpClient — a response whose body is still being read', () => {
|
|
292
|
+
it('an abort during the body read is a caller abort: no node is marked unhealthy', async () => {
|
|
293
|
+
const a = await startStallingServer()
|
|
294
|
+
const b = await startStallingServer()
|
|
295
|
+
const lb = new LoadBalancer([a.url, b.url], 'affinity')
|
|
296
|
+
const http = new HttpClient({ loadBalancer: lb, enableFailover: true, retryAttempts: 1 })
|
|
297
|
+
const headers = onResponseHeaders('http://127.0.0.1:')
|
|
298
|
+
try {
|
|
299
|
+
const ac = new AbortController()
|
|
300
|
+
const settled = http.get('/status', 10000, 'key', null, ac.signal).then(() => null, e => e)
|
|
301
|
+
await headers.ready
|
|
302
|
+
|
|
303
|
+
ac.abort()
|
|
304
|
+
|
|
305
|
+
const error = await settled
|
|
306
|
+
assert.equal(error?.aborted, true, 'the rejection says the caller aborted it')
|
|
307
|
+
assert.equal(a.hits() + b.hits(), 1, 'no other node was tried')
|
|
308
|
+
for (const [url, status] of lb.getHealthStatus()) {
|
|
309
|
+
assert.equal(status.healthy, true, `${url} is still healthy`)
|
|
310
|
+
}
|
|
311
|
+
} finally {
|
|
312
|
+
headers.restore()
|
|
313
|
+
await http.destroy()
|
|
314
|
+
await a.stop()
|
|
315
|
+
await b.stop()
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('the request timeout also bounds the body read', async () => {
|
|
320
|
+
const server = await startStallingServer()
|
|
321
|
+
const http = new HttpClient({ baseUrl: server.url, retryAttempts: 1 })
|
|
322
|
+
try {
|
|
323
|
+
const started = Date.now()
|
|
324
|
+
const outcome = await settleWithin(http.get('/status', 300), 3000)
|
|
325
|
+
assert.match(outcome, /^rejected: Request timeout/, 'a stalled body ends as a timeout, not a hang')
|
|
326
|
+
assert.ok(Date.now() - started < 2000)
|
|
327
|
+
} finally {
|
|
328
|
+
await http.destroy()
|
|
329
|
+
await server.stop()
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
describe('consume() — a pop response that has started arriving', () => {
|
|
335
|
+
it('is read to the end after a stop, and its message goes back with a retry ack', async () => {
|
|
336
|
+
// The broker granted the lease when it sent the headers: from then on the
|
|
337
|
+
// message is this consumer's to give back, which needs the body.
|
|
338
|
+
const message1 = message(1)
|
|
339
|
+
const body = JSON.stringify({ success: true, consumerGroup: GROUP, messages: [message1] })
|
|
340
|
+
let finish
|
|
341
|
+
const finished = new Promise(resolve => { finish = resolve })
|
|
342
|
+
let pops = 0
|
|
343
|
+
const acks = []
|
|
344
|
+
const server = createServer((req, res) => {
|
|
345
|
+
let raw = ''
|
|
346
|
+
req.on('data', chunk => { raw += chunk })
|
|
347
|
+
req.on('end', () => {
|
|
348
|
+
if (req.method === 'GET') {
|
|
349
|
+
pops++
|
|
350
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) })
|
|
351
|
+
res.write(body.slice(0, body.length / 2))
|
|
352
|
+
finished.then(() => { if (!res.destroyed) res.end(body.slice(body.length / 2)) })
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
const parsed = JSON.parse(raw)
|
|
356
|
+
const items = parsed.acknowledgments || [parsed]
|
|
357
|
+
acks.push(...items)
|
|
358
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
359
|
+
res.end(JSON.stringify(items.map((a, i) => ({ index: i, transactionId: a.transactionId, success: true, error: null, leaseReleased: true }))))
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
363
|
+
const url = `http://127.0.0.1:${server.address().port}`
|
|
364
|
+
const queen = new Queen({ url, handleSignals: false, retryAttempts: 1 })
|
|
365
|
+
const headers = onResponseHeaders(url)
|
|
366
|
+
try {
|
|
367
|
+
const stop = new AbortController()
|
|
368
|
+
const seen = []
|
|
369
|
+
const running = Promise.resolve(queen.queue('orders').group(GROUP).batch(1).each()
|
|
370
|
+
.consume(async (m) => { seen.push(m.transactionId) }, { signal: stop.signal }))
|
|
371
|
+
await headers.ready
|
|
372
|
+
|
|
373
|
+
stop.abort()
|
|
374
|
+
finish()
|
|
375
|
+
|
|
376
|
+
assert.equal(await settleWithin(running, 2000), 'resolved')
|
|
377
|
+
assert.deepEqual(seen, [], 'nothing is handed to the handler after the stop')
|
|
378
|
+
assert.equal(pops, 1)
|
|
379
|
+
assert.deepEqual(acks.map(a => `${a.transactionId}:${a.status}`), ['tx-1:retry'],
|
|
380
|
+
'the message in the half-read response is given back, not left to its lease')
|
|
381
|
+
} finally {
|
|
382
|
+
finish()
|
|
383
|
+
headers.restore()
|
|
384
|
+
await queen.close()
|
|
385
|
+
server.closeAllConnections()
|
|
386
|
+
await new Promise(resolve => server.close(resolve))
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
/** A server that answers every request with `status` (and Retry-After), counting them. */
|
|
392
|
+
async function startRefusingServer(status, retryAfterSeconds = null) {
|
|
393
|
+
let hits = 0
|
|
394
|
+
let firstHit
|
|
395
|
+
const hit = new Promise(resolve => { firstHit = resolve })
|
|
396
|
+
const server = createServer((req, res) => {
|
|
397
|
+
hits++
|
|
398
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
399
|
+
if (retryAfterSeconds != null) headers['Retry-After'] = String(retryAfterSeconds)
|
|
400
|
+
res.writeHead(status, headers)
|
|
401
|
+
res.end('{"error":"refused"}', () => firstHit())
|
|
402
|
+
})
|
|
403
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
404
|
+
return {
|
|
405
|
+
url: `http://127.0.0.1:${server.address().port}`,
|
|
406
|
+
hits: () => hits,
|
|
407
|
+
firstHit: hit,
|
|
408
|
+
async stop() {
|
|
409
|
+
server.closeAllConnections()
|
|
410
|
+
await new Promise(resolve => server.close(resolve))
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** A server that drops every connection without answering. */
|
|
416
|
+
async function startDroppingServer() {
|
|
417
|
+
let hits = 0
|
|
418
|
+
let firstHit
|
|
419
|
+
const hit = new Promise(resolve => { firstHit = resolve })
|
|
420
|
+
const server = createServer((req) => { hits++; req.socket.destroy(); firstHit() })
|
|
421
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
422
|
+
return {
|
|
423
|
+
url: `http://127.0.0.1:${server.address().port}`,
|
|
424
|
+
hits: () => hits,
|
|
425
|
+
firstHit: hit,
|
|
426
|
+
async stop() {
|
|
427
|
+
server.closeAllConnections()
|
|
428
|
+
await new Promise(resolve => server.close(resolve))
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Aborts once the client is inside its wait, and measures how long stopping takes. */
|
|
434
|
+
async function abortDuringWait(server, running, ac) {
|
|
435
|
+
await server.firstHit
|
|
436
|
+
await sleep(100) // the client has read the answer and is now waiting
|
|
437
|
+
const started = performance.now()
|
|
438
|
+
ac.abort()
|
|
439
|
+
const outcome = await settleWithin(running, 3000)
|
|
440
|
+
return { outcome, elapsed: performance.now() - started }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
describe('a stop during a wait between attempts', () => {
|
|
444
|
+
it('HttpClient: a 429 backoff ends at once', async () => {
|
|
445
|
+
const server = await startRefusingServer(429, 2)
|
|
446
|
+
const http = new HttpClient({ baseUrl: server.url, retryAttempts: 1 })
|
|
447
|
+
try {
|
|
448
|
+
const ac = new AbortController()
|
|
449
|
+
const running = http.get('/api/v1/pop/queue/orders?wait=true', 10000, null, 'pop', ac.signal).then(() => null, e => { throw e })
|
|
450
|
+
const errorP = running.catch(e => e)
|
|
451
|
+
const { elapsed } = await abortDuringWait(server, running, ac)
|
|
452
|
+
const error = await errorP
|
|
453
|
+
assert.equal(error?.aborted, true)
|
|
454
|
+
assert.equal(server.hits(), 1, 'no attempt after the stop')
|
|
455
|
+
assert.ok(elapsed < 250, `stopping took ${Math.round(elapsed)} ms, not the 2 s Retry-After`)
|
|
456
|
+
} finally {
|
|
457
|
+
await http.destroy()
|
|
458
|
+
await server.stop()
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('HttpClient: the backoff before retrying a 5xx ends at once', async () => {
|
|
463
|
+
const server = await startRefusingServer(503)
|
|
464
|
+
const http = new HttpClient({ baseUrl: server.url, retryAttempts: 3, retryDelayMillis: 2000 })
|
|
465
|
+
try {
|
|
466
|
+
const ac = new AbortController()
|
|
467
|
+
const running = http.get('/status', 10000, null, null, ac.signal)
|
|
468
|
+
const errorP = running.catch(e => e)
|
|
469
|
+
const { elapsed } = await abortDuringWait(server, running, ac)
|
|
470
|
+
const error = await errorP
|
|
471
|
+
assert.equal(error?.aborted, true)
|
|
472
|
+
assert.equal(server.hits(), 1, 'no attempt after the stop')
|
|
473
|
+
assert.ok(elapsed < 250, `stopping took ${Math.round(elapsed)} ms, not the 2 s backoff`)
|
|
474
|
+
} finally {
|
|
475
|
+
await http.destroy()
|
|
476
|
+
await server.stop()
|
|
477
|
+
}
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('consume(): the pause after a 429 the client gave up retrying ends at once', async () => {
|
|
481
|
+
const server = await startRefusingServer(429, 2)
|
|
482
|
+
const queen = new Queen({ url: server.url, handleSignals: false, retryAttempts: 1, retry429: { maxAttempts: 1 } })
|
|
483
|
+
try {
|
|
484
|
+
const ac = new AbortController()
|
|
485
|
+
const running = Promise.resolve(queen.queue('orders').group(GROUP).consume(async () => {}, { signal: ac.signal }))
|
|
486
|
+
const { outcome, elapsed } = await abortDuringWait(server, running, ac)
|
|
487
|
+
assert.equal(outcome, 'resolved')
|
|
488
|
+
assert.ok(elapsed < 250, `stopping took ${Math.round(elapsed)} ms, not the 2 s Retry-After`)
|
|
489
|
+
} finally {
|
|
490
|
+
await queen.close()
|
|
491
|
+
await server.stop()
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('consume(): the pause after a network error ends at once', async () => {
|
|
496
|
+
const server = await startDroppingServer()
|
|
497
|
+
const queen = new Queen({ url: server.url, handleSignals: false, retryAttempts: 1 })
|
|
498
|
+
try {
|
|
499
|
+
const ac = new AbortController()
|
|
500
|
+
const running = Promise.resolve(queen.queue('orders').group(GROUP).consume(async () => {}, { signal: ac.signal }))
|
|
501
|
+
const { outcome, elapsed } = await abortDuringWait(server, running, ac)
|
|
502
|
+
assert.equal(outcome, 'resolved')
|
|
503
|
+
assert.ok(elapsed < 250, `stopping took ${Math.round(elapsed)} ms, not the 1 s pause`)
|
|
504
|
+
} finally {
|
|
505
|
+
await queen.close()
|
|
506
|
+
await server.stop()
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
describe('README — the "Stopping a consumer" example', () => {
|
|
512
|
+
it('starts consuming before the stop, not after it', async () => {
|
|
513
|
+
const broker = await startBroker()
|
|
514
|
+
const queen = new Queen({ url: broker.url, handleSignals: false })
|
|
515
|
+
const readme = await readFile(new URL('../../README.md', import.meta.url), 'utf8')
|
|
516
|
+
const section = readme.slice(readme.indexOf('**Stopping a consumer**'))
|
|
517
|
+
const snippet = section.match(/```javascript\n([\s\S]*?)```/)[1]
|
|
518
|
+
let shutdown
|
|
519
|
+
const context = vm.createContext({
|
|
520
|
+
queen, AbortController, Promise,
|
|
521
|
+
processTask: async () => {},
|
|
522
|
+
process: { once(event, fn) { assert.equal(event, 'SIGTERM'); shutdown = fn } }
|
|
523
|
+
})
|
|
524
|
+
try {
|
|
525
|
+
vm.runInContext(snippet, context)
|
|
526
|
+
await sleep(150)
|
|
527
|
+
const popsBeforeStop = broker.pops().length
|
|
528
|
+
|
|
529
|
+
await shutdown()
|
|
530
|
+
|
|
531
|
+
assert.equal(popsBeforeStop, 1, 'the example is consuming before SIGTERM')
|
|
532
|
+
} finally {
|
|
533
|
+
await queen.close().catch(() => {})
|
|
534
|
+
await broker.stop()
|
|
535
|
+
}
|
|
536
|
+
})
|
|
537
|
+
})
|
|
@@ -201,7 +201,7 @@ describe('ephemeral wire — pop', () => {
|
|
|
201
201
|
|
|
202
202
|
it('pop puts every declared parameter on the query string, in order', async () => {
|
|
203
203
|
await withEphemeral([popped(QUEUE, [])], async (eph, hits) => {
|
|
204
|
-
await eph.pop(QUEUE, { partition: 'room-7', batch: 10, wait: true, timeout: 2000, group: 'workers',
|
|
204
|
+
await eph.pop(QUEUE, { partition: 'room-7', batch: 10, wait: true, timeout: 2000, group: 'workers', commitOnDelivery: true })
|
|
205
205
|
assert.equal(
|
|
206
206
|
hits[0].url,
|
|
207
207
|
`/api/v1/ephemeral/pop?queue=${QUEUE}&partition=room-7&batch=10&wait=true&timeout=2000&group=workers&autoAck=true`
|
|
@@ -209,6 +209,29 @@ describe('ephemeral wire — pop', () => {
|
|
|
209
209
|
})
|
|
210
210
|
})
|
|
211
211
|
|
|
212
|
+
it('pop sends `commitOnDelivery` as autoAck=true, and nothing for false', async () => {
|
|
213
|
+
await withEphemeral([popped(QUEUE, []), popped(QUEUE, [])], async (eph, hits) => {
|
|
214
|
+
await eph.pop(QUEUE, { commitOnDelivery: true })
|
|
215
|
+
assert.equal(hits[0].url, `/api/v1/ephemeral/pop?queue=${QUEUE}&autoAck=true`)
|
|
216
|
+
|
|
217
|
+
await eph.pop(QUEUE, { commitOnDelivery: false })
|
|
218
|
+
assert.equal(hits[1].url, `/api/v1/ephemeral/pop?queue=${QUEUE}`)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('pop still reads the deprecated `autoAck` as `commitOnDelivery`, and refuses both spellings', async () => {
|
|
223
|
+
await withEphemeral([popped(QUEUE, [])], async (eph, hits) => {
|
|
224
|
+
await eph.pop(QUEUE, { group: 'workers', autoAck: true })
|
|
225
|
+
assert.equal(hits[0].url, `/api/v1/ephemeral/pop?queue=${QUEUE}&group=workers&autoAck=true`)
|
|
226
|
+
|
|
227
|
+
await assert.rejects(
|
|
228
|
+
() => eph.pop(QUEUE, { commitOnDelivery: true, autoAck: true }),
|
|
229
|
+
/pass either `commitOnDelivery` or its deprecated alias `autoAck`, not both/
|
|
230
|
+
)
|
|
231
|
+
assert.equal(hits.length, 1)
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
212
235
|
it('pop sends an explicit timeout whenever it waits, and none when it does not', async () => {
|
|
213
236
|
// The HTTP deadline is set PAST the server's, so the broker's long poll
|
|
214
237
|
// always ends the request first. Letting the two defaults drift apart is
|