queen-mq 2.0.2 → 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
CHANGED
|
@@ -325,6 +325,36 @@ await queen.queue('tasks')
|
|
|
325
325
|
Earlier versions stopped the consumer on a throw under `.autoAck(false)`: `consume()` rejected after
|
|
326
326
|
the first failure, and the message stayed leased until its lease expired.
|
|
327
327
|
|
|
328
|
+
**Stopping a consumer** (a graceful shutdown) is aborting its `signal`. The handler call in progress
|
|
329
|
+
finishes, with its ack, and `consume()` resolves. Nothing is left leased:
|
|
330
|
+
|
|
331
|
+
- The long poll in flight is closed at once. The broker hands nothing to a poll whose caller is gone,
|
|
332
|
+
so a message that arrives during the shutdown goes to another consumer straight away.
|
|
333
|
+
- A pop answer that has already started arriving is read to the end: the broker leased its messages
|
|
334
|
+
when it sent it, and the body says which ones they are.
|
|
335
|
+
- With `.each()`, messages already popped but not yet handed to the handler go back with a `retry`
|
|
336
|
+
ack. The broker releases their lease and redelivers them first, in order, without charging a
|
|
337
|
+
retry. The same happens to messages popped beyond `.limit()`.
|
|
338
|
+
- A wait between attempts (a 429 backoff, a retry after a 5xx or a network error) ends at once.
|
|
339
|
+
|
|
340
|
+
```javascript
|
|
341
|
+
const stop = new AbortController()
|
|
342
|
+
// consume() starts when awaited: Promise.resolve() starts it now and keeps the
|
|
343
|
+
// promise that settles once the consumer has stopped.
|
|
344
|
+
const consuming = Promise.resolve(queen.queue('tasks').group('workers').each()
|
|
345
|
+
.consume(async (message) => { await processTask(message.data) }, { signal: stop.signal }))
|
|
346
|
+
|
|
347
|
+
process.once('SIGTERM', async () => {
|
|
348
|
+
stop.abort()
|
|
349
|
+
await consuming // the message in the handler is finished and acked
|
|
350
|
+
await queen.close()
|
|
351
|
+
})
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Earlier versions checked the signal only between polls. A poll open at the abort stayed open for up
|
|
355
|
+
to its timeout, and `.each()` dropped what it brought back without settling it, so its partition
|
|
356
|
+
waited out the whole lease.
|
|
357
|
+
|
|
328
358
|
### Pop Messages (On-Demand Processing)
|
|
329
359
|
|
|
330
360
|
```javascript
|
|
@@ -7,6 +7,20 @@ import { checkConflationResponse, CONFLATION_UNSUPPORTED } from '../utils/confla
|
|
|
7
7
|
import { popSizing, parseAutopilotDecision, emptyPollDelayMillis } from '../utils/autopilot.js'
|
|
8
8
|
import { CONSUME_DEFAULTS } from '../utils/defaults.js'
|
|
9
9
|
|
|
10
|
+
/** Wait `ms`, or less if the consumer is stopped meanwhile: the loop checks the signal next. */
|
|
11
|
+
function pause(ms, signal) {
|
|
12
|
+
if (signal?.aborted) return Promise.resolve()
|
|
13
|
+
return new Promise(resolve => {
|
|
14
|
+
const done = () => {
|
|
15
|
+
clearTimeout(timer)
|
|
16
|
+
signal?.removeEventListener('abort', done)
|
|
17
|
+
resolve()
|
|
18
|
+
}
|
|
19
|
+
const timer = setTimeout(done, ms)
|
|
20
|
+
signal?.addEventListener('abort', done, { once: true })
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
export class ConsumerManager {
|
|
11
25
|
#httpClient
|
|
12
26
|
#queen
|
|
@@ -160,7 +174,10 @@ export class ConsumerManager {
|
|
|
160
174
|
// a long-poll: mark it 'pop' so a 429 backs off and keeps waiting
|
|
161
175
|
// instead of giving up after the bounded push-like attempt budget.
|
|
162
176
|
const clientTimeout = wait ? timeoutMillis + 5000 : timeoutMillis
|
|
163
|
-
|
|
177
|
+
// The signal closes the poll as well: a broker hands nothing to a poll
|
|
178
|
+
// whose caller is gone, so a stopped consumer is never given a message
|
|
179
|
+
// it would only sit on until the lease expires.
|
|
180
|
+
const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey, wait ? 'pop' : null, signal)
|
|
164
181
|
|
|
165
182
|
// Degrade-loudly (PLAN_CONFLATION §4). Checked BEFORE the empty-response
|
|
166
183
|
// branch on purpose: a pre-1.1.0 broker answers an empty pop with a
|
|
@@ -181,7 +198,7 @@ export class ConsumerManager {
|
|
|
181
198
|
// pop engaged autopilot and the broker had an opinion (it knows the
|
|
182
199
|
// arrival rate on this queue and this client does not), otherwise
|
|
183
200
|
// the historical 100ms.
|
|
184
|
-
await
|
|
201
|
+
await pause(emptyPollDelayMillis(parseAutopilotDecision(result)), signal)
|
|
185
202
|
continue
|
|
186
203
|
}
|
|
187
204
|
}
|
|
@@ -218,8 +235,17 @@ export class ConsumerManager {
|
|
|
218
235
|
// partitions of a multi-partition pop are still leased to this
|
|
219
236
|
// worker, so their messages are handled now, not after the lease.
|
|
220
237
|
const nackedPartitions = new Set()
|
|
221
|
-
for (const message of messages) {
|
|
222
|
-
|
|
238
|
+
for (const [i, message] of messages.entries()) {
|
|
239
|
+
// Stopped, or the limit reached, with messages still in hand:
|
|
240
|
+
// give them back rather than leave them leased. Those of a
|
|
241
|
+
// nacked partition were already given back by the nack.
|
|
242
|
+
if ((signal && signal.aborted) || (limit && processedCount >= limit)) {
|
|
243
|
+
const unstarted = messages.slice(i).filter(m => !nackedPartitions.has(m.partitionId ?? m.partition))
|
|
244
|
+
if (unstarted.length > 0) {
|
|
245
|
+
await this.#releaseUnstarted(unstarted, group, signal && signal.aborted ? 'aborted' : 'limit-reached')
|
|
246
|
+
}
|
|
247
|
+
break
|
|
248
|
+
}
|
|
223
249
|
|
|
224
250
|
const partition = message.partitionId ?? message.partition
|
|
225
251
|
if (nackedPartitions.has(partition)) continue
|
|
@@ -231,8 +257,6 @@ export class ConsumerManager {
|
|
|
231
257
|
nackedPartitions.add(partition)
|
|
232
258
|
logger.warn('ConsumerManager.worker', { workerId, status: 'partition-abandoned-after-nack', partition })
|
|
233
259
|
}
|
|
234
|
-
|
|
235
|
-
if (limit && processedCount >= limit) break
|
|
236
260
|
}
|
|
237
261
|
} else {
|
|
238
262
|
// Process as batch
|
|
@@ -249,6 +273,13 @@ export class ConsumerManager {
|
|
|
249
273
|
}
|
|
250
274
|
|
|
251
275
|
} catch (error) {
|
|
276
|
+
// Stopped while a poll was open: the poll was closed, nothing was
|
|
277
|
+
// taken, the worker is done. Not an error, whatever the wait mode.
|
|
278
|
+
if (error.aborted || (signal && signal.aborted)) {
|
|
279
|
+
logger.log('ConsumerManager.worker', { workerId, status: 'aborted', processedCount })
|
|
280
|
+
break
|
|
281
|
+
}
|
|
282
|
+
|
|
252
283
|
// Conflation faults are terminal and are classified FIRST, ahead of the
|
|
253
284
|
// message-substring heuristics below: a consumer that asked for
|
|
254
285
|
// last-value delivery and is not getting it must stop, not retry
|
|
@@ -278,7 +309,7 @@ export class ConsumerManager {
|
|
|
278
309
|
? error.retryAfterSeconds * 1000
|
|
279
310
|
: 1000
|
|
280
311
|
logger.warn('ConsumerManager.worker', { workerId, status: 'rate-limited', code: error.code, retryAfterMs })
|
|
281
|
-
await
|
|
312
|
+
await pause(retryAfterMs, signal)
|
|
282
313
|
continue
|
|
283
314
|
}
|
|
284
315
|
|
|
@@ -290,7 +321,7 @@ export class ConsumerManager {
|
|
|
290
321
|
if (isNetworkError) {
|
|
291
322
|
logger.warn('ConsumerManager.worker', { workerId, error: 'network', message: error.message })
|
|
292
323
|
// Wait before retry
|
|
293
|
-
await
|
|
324
|
+
await pause(1000, signal)
|
|
294
325
|
continue
|
|
295
326
|
}
|
|
296
327
|
|
|
@@ -339,6 +370,26 @@ export class ConsumerManager {
|
|
|
339
370
|
return true
|
|
340
371
|
}
|
|
341
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Hand back messages this worker holds but will not process (it was stopped,
|
|
375
|
+
* or reached its limit): a `retry` ack releases their lease, the broker
|
|
376
|
+
* redelivers them first, in order, and charges no retry. Best effort -- a
|
|
377
|
+
* release that fails leaves the message to its lease, as before.
|
|
378
|
+
*/
|
|
379
|
+
async #releaseUnstarted(messages, group, reason) {
|
|
380
|
+
const subject = { count: messages.length, transactionIds: messages.map(m => m.transactionId) }
|
|
381
|
+
try {
|
|
382
|
+
const res = await this.#queen.ack(messages, 'retry', group ? { group } : {})
|
|
383
|
+
if (res && res.success === false) {
|
|
384
|
+
logger.warn('ConsumerManager.release', { ...subject, reason, status: 'release-rejected', error: res.error })
|
|
385
|
+
} else {
|
|
386
|
+
logger.log('ConsumerManager.release', { ...subject, reason, status: 'released' })
|
|
387
|
+
}
|
|
388
|
+
} catch (error) {
|
|
389
|
+
logger.warn('ConsumerManager.release', { ...subject, reason, status: 'release-failed', error: error.message })
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
342
393
|
async #processBatch(messages, handler, autoAck, group) {
|
|
343
394
|
try {
|
|
344
395
|
await handler(messages)
|
|
@@ -4,6 +4,47 @@
|
|
|
4
4
|
|
|
5
5
|
import * as logger from '../utils/logger.js'
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* The error a request ends with when its caller aborted it (a consumer being
|
|
9
|
+
* stopped). It says nothing about the backend: it is never retried, never
|
|
10
|
+
* fails over to another node, and never marks a node unhealthy.
|
|
11
|
+
*/
|
|
12
|
+
function callerAborted(method, url) {
|
|
13
|
+
const error = new Error(`${method} ${url} aborted by the caller`)
|
|
14
|
+
error.name = 'AbortError'
|
|
15
|
+
error.aborted = true
|
|
16
|
+
return error
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Wait `delay` ms before another attempt, unless the caller aborts first: then
|
|
21
|
+
* reject at once with the caller-aborted error instead of sitting out the wait.
|
|
22
|
+
*/
|
|
23
|
+
function waitBeforeRetry(delay, signal, method, url) {
|
|
24
|
+
if (signal?.aborted) return Promise.reject(callerAborted(method, url))
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const onAbort = () => {
|
|
27
|
+
clearTimeout(timer)
|
|
28
|
+
reject(callerAborted(method, url))
|
|
29
|
+
}
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
signal?.removeEventListener('abort', onAbort)
|
|
32
|
+
resolve()
|
|
33
|
+
}, delay)
|
|
34
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A pop answer carries leases: once it is arriving, it is read to the end. */
|
|
39
|
+
function isPop(method, url) {
|
|
40
|
+
if (method !== 'GET') return false
|
|
41
|
+
try {
|
|
42
|
+
return new URL(url).pathname.startsWith('/api/v1/pop')
|
|
43
|
+
} catch {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
7
48
|
// --------------------------------------------------------------------------
|
|
8
49
|
// Host-routed proxy support (queen_proxy selects the tenant cluster from the
|
|
9
50
|
// first DNS label of the Host header -- proxy/src/cache.rs
|
|
@@ -211,14 +252,15 @@ export class HttpClient {
|
|
|
211
252
|
* only status this layer treats as retryable; 5xx/network retry and
|
|
212
253
|
* cross-backend failover are handled by the caller.
|
|
213
254
|
*/
|
|
214
|
-
async #executeWithRetry429(url, method, body, requestTimeoutMillis, retryKind) {
|
|
255
|
+
async #executeWithRetry429(url, method, body, requestTimeoutMillis, retryKind, signal = null) {
|
|
215
256
|
const { maxAttempts, baseMs, capMs } = this.#retry429PolicyFor(retryKind)
|
|
216
257
|
let tries = 0
|
|
217
258
|
// eslint-disable-next-line no-constant-condition
|
|
218
259
|
while (true) {
|
|
260
|
+
if (signal?.aborted) throw callerAborted(method, url)
|
|
219
261
|
tries++
|
|
220
262
|
try {
|
|
221
|
-
return await this.#executeRequest(url, method, body, requestTimeoutMillis)
|
|
263
|
+
return await this.#executeRequest(url, method, body, requestTimeoutMillis, signal)
|
|
222
264
|
} catch (error) {
|
|
223
265
|
if (error.status !== 429) throw error
|
|
224
266
|
|
|
@@ -229,7 +271,7 @@ export class HttpClient {
|
|
|
229
271
|
|
|
230
272
|
const delay = this.#computeRetry429DelayMs(tries - 1, error.retryAfterSeconds, baseMs, capMs)
|
|
231
273
|
logger.warn('HttpClient.retry429', { method, url, attempt: tries, retryKind: retryKind || 'default', nextDelayMs: delay, retryAfterSeconds: error.retryAfterSeconds ?? null, code: error.code ?? null })
|
|
232
|
-
await
|
|
274
|
+
await waitBeforeRetry(delay, signal, method, url)
|
|
233
275
|
}
|
|
234
276
|
}
|
|
235
277
|
}
|
|
@@ -323,12 +365,17 @@ export class HttpClient {
|
|
|
323
365
|
}
|
|
324
366
|
}
|
|
325
367
|
|
|
326
|
-
async #executeRequest(url, method, body = null, requestTimeoutMillis = null) {
|
|
368
|
+
async #executeRequest(url, method, body = null, requestTimeoutMillis = null, signal = null) {
|
|
369
|
+
if (signal?.aborted) throw callerAborted(method, url)
|
|
327
370
|
const effectiveTimeout = requestTimeoutMillis || this.#timeoutMillis
|
|
328
371
|
logger.log('HttpClient.request', { method, url, hasBody: !!body, timeout: effectiveTimeout, host: this.#hostOverride ? this.#hostOverride.authority : undefined })
|
|
329
372
|
|
|
330
373
|
const controller = new AbortController()
|
|
331
374
|
const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout)
|
|
375
|
+
// The caller's signal closes the request too: a long poll its consumer no
|
|
376
|
+
// longer wants must not stay open for the broker to hand it a message.
|
|
377
|
+
const abortFromCaller = () => controller.abort()
|
|
378
|
+
signal?.addEventListener('abort', abortFromCaller, { once: true })
|
|
332
379
|
|
|
333
380
|
try {
|
|
334
381
|
const headers = { 'Content-Type': 'application/json' }
|
|
@@ -367,6 +414,14 @@ export class HttpClient {
|
|
|
367
414
|
|
|
368
415
|
const response = await fetch(requestUrl, options)
|
|
369
416
|
|
|
417
|
+
// The broker granted the leases of a pop when it sent these headers. From
|
|
418
|
+
// here the caller's abort no longer cuts the read: the body says which
|
|
419
|
+
// messages this consumer holds, and it needs them to give them back. The
|
|
420
|
+
// timeout still bounds the read.
|
|
421
|
+
if (response.ok && isPop(method, url)) {
|
|
422
|
+
signal?.removeEventListener('abort', abortFromCaller)
|
|
423
|
+
}
|
|
424
|
+
|
|
370
425
|
logger.log('HttpClient.response', { method, url, status: response.status })
|
|
371
426
|
|
|
372
427
|
// Handle 204 No Content
|
|
@@ -421,9 +476,16 @@ export class HttpClient {
|
|
|
421
476
|
}
|
|
422
477
|
}
|
|
423
478
|
|
|
424
|
-
|
|
479
|
+
// Awaited here, not returned: the body is still being read, and both the
|
|
480
|
+
// timeout (cleared in `finally`) and the abort classification below must
|
|
481
|
+
// cover that read too.
|
|
482
|
+
return await response.json()
|
|
425
483
|
|
|
426
484
|
} catch (error) {
|
|
485
|
+
if (signal?.aborted) {
|
|
486
|
+
logger.log('HttpClient.request', { method, url, status: 'aborted-by-caller' })
|
|
487
|
+
throw callerAborted(method, url)
|
|
488
|
+
}
|
|
427
489
|
if (error.name === 'AbortError') {
|
|
428
490
|
const timeoutError = new Error(`Request timeout after ${effectiveTimeout}ms`)
|
|
429
491
|
timeoutError.name = 'AbortError'
|
|
@@ -441,18 +503,20 @@ export class HttpClient {
|
|
|
441
503
|
logger.error('HttpClient.request', { method, url, error: error.message })
|
|
442
504
|
throw error
|
|
443
505
|
} finally {
|
|
506
|
+
signal?.removeEventListener('abort', abortFromCaller)
|
|
444
507
|
clearTimeout(timeoutId)
|
|
445
508
|
}
|
|
446
509
|
}
|
|
447
510
|
|
|
448
|
-
async #requestWithRetry(method, path, body = null, requestTimeoutMillis = null, retryKind = null) {
|
|
511
|
+
async #requestWithRetry(method, path, body = null, requestTimeoutMillis = null, retryKind = null, signal = null) {
|
|
449
512
|
let lastError = null
|
|
450
513
|
|
|
451
514
|
for (let attempt = 0; attempt < this.#retryAttempts; attempt++) {
|
|
452
515
|
try {
|
|
453
516
|
const url = this.#getUrl() + path
|
|
454
|
-
return await this.#executeWithRetry429(url, method, body, requestTimeoutMillis, retryKind)
|
|
517
|
+
return await this.#executeWithRetry429(url, method, body, requestTimeoutMillis, retryKind, signal)
|
|
455
518
|
} catch (error) {
|
|
519
|
+
if (error.aborted) throw error
|
|
456
520
|
lastError = error
|
|
457
521
|
|
|
458
522
|
// Don't retry on client errors (4xx)
|
|
@@ -464,7 +528,7 @@ export class HttpClient {
|
|
|
464
528
|
if (attempt < this.#retryAttempts - 1) {
|
|
465
529
|
const delay = this.#retryDelayMillis * Math.pow(2, attempt)
|
|
466
530
|
logger.warn('HttpClient.retry', { method, path, attempt: attempt + 1, delay, error: error.message })
|
|
467
|
-
await
|
|
531
|
+
await waitBeforeRetry(delay, signal, method, path)
|
|
468
532
|
}
|
|
469
533
|
}
|
|
470
534
|
}
|
|
@@ -473,9 +537,9 @@ export class HttpClient {
|
|
|
473
537
|
throw lastError
|
|
474
538
|
}
|
|
475
539
|
|
|
476
|
-
async #requestWithFailover(method, path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
|
|
540
|
+
async #requestWithFailover(method, path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null, signal = null) {
|
|
477
541
|
if (!this.#loadBalancer || !this.#enableFailover) {
|
|
478
|
-
return this.#requestWithRetry(method, path, body, requestTimeoutMillis, retryKind)
|
|
542
|
+
return this.#requestWithRetry(method, path, body, requestTimeoutMillis, retryKind, signal)
|
|
479
543
|
}
|
|
480
544
|
|
|
481
545
|
const urls = this.#loadBalancer.getAllUrls()
|
|
@@ -498,13 +562,16 @@ export class HttpClient {
|
|
|
498
562
|
// 429s are retried in place (same backend, backoff-paced) inside
|
|
499
563
|
// #executeWithRetry429 -- they are not a backend-health signal, so
|
|
500
564
|
// they must not trigger failover to a different server.
|
|
501
|
-
const result = await this.#executeWithRetry429(url + path, method, body, requestTimeoutMillis, retryKind)
|
|
565
|
+
const result = await this.#executeWithRetry429(url + path, method, body, requestTimeoutMillis, retryKind, signal)
|
|
502
566
|
|
|
503
567
|
// Mark backend as healthy on success
|
|
504
568
|
this.#loadBalancer.markHealthy(url)
|
|
505
569
|
|
|
506
570
|
return result
|
|
507
571
|
} catch (error) {
|
|
572
|
+
// Stopped by its caller: this node did nothing wrong, and another
|
|
573
|
+
// node must not get the request instead.
|
|
574
|
+
if (error.aborted) throw error
|
|
508
575
|
lastError = error
|
|
509
576
|
|
|
510
577
|
// Mark backend as unhealthy on failure (5xx or network errors)
|
|
@@ -538,8 +605,10 @@ export class HttpClient {
|
|
|
538
605
|
// `retryKind`: pass 'pop' for long-poll (wait=true) pop requests to get the
|
|
539
606
|
// unbounded-with-backoff 429 policy; omit for everything else (push, admin
|
|
540
607
|
// calls, non-waiting pop), which get the bounded default (10 attempts).
|
|
541
|
-
|
|
542
|
-
|
|
608
|
+
// `signal`: aborting it closes the request, which then rejects with an
|
|
609
|
+
// error carrying `aborted: true` -- never retried and never failed over.
|
|
610
|
+
async get(path, requestTimeoutMillis = null, affinityKey = null, retryKind = null, signal = null) {
|
|
611
|
+
return this.#requestWithFailover('GET', path, null, requestTimeoutMillis, affinityKey, retryKind, signal)
|
|
543
612
|
}
|
|
544
613
|
|
|
545
614
|
async post(path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "queen-mq",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Partitioned message queue on a replicated broker log — broker client + fluent streaming SDK (windows, joins, gates) in one package",
|
|
6
6
|
"main": "client-v2/index.js",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "npm run test:unit && node test-v2/run.js human",
|
|
9
|
-
"test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/streams-unit/gate.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/http-unit/pushStatus.test.js test-v2/http-unit/renew.test.js test-v2/consumer-unit/handlerError.test.js test-v2/consumer-unit/nackScope.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.test.js test-v2/runner-unit/fatalExit.test.js test-v2/pop-unit/popDefaults.test.js test-v2/admin-unit/removedRoutes.test.js",
|
|
9
|
+
"test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/streams-unit/gate.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/http-unit/pushStatus.test.js test-v2/http-unit/renew.test.js test-v2/consumer-unit/handlerError.test.js test-v2/consumer-unit/nackScope.test.js test-v2/consumer-unit/stopOnAbort.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.test.js test-v2/runner-unit/fatalExit.test.js test-v2/pop-unit/popDefaults.test.js test-v2/admin-unit/removedRoutes.test.js",
|
|
10
10
|
"test:unit:e2e": "node --test test-v2/streams-unit/e2e.test.js",
|
|
11
11
|
"test:integration": "node test-v2/run.js human",
|
|
12
12
|
"test:streams": "node test-v2/run.js stream",
|
|
@@ -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
|
+
})
|