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
|
@@ -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) {
|
|
@@ -81,9 +81,10 @@ export const CONSUME_DEFAULTS = {
|
|
|
81
81
|
// As in CONSUME_DEFAULTS, batch is the autopilot-OFF default.
|
|
82
82
|
export const POP_DEFAULTS = {
|
|
83
83
|
batch: 1, // One message (autopilot off only)
|
|
84
|
-
wait:
|
|
84
|
+
wait: true, // Long polling, as every pop has done; .wait(false) returns at once
|
|
85
85
|
timeoutMillis: 30000, // 30 seconds if wait=true
|
|
86
|
-
autoAck: false
|
|
86
|
+
autoAck: false, // Never sent: autoAck() is consume()'s ack after the handler
|
|
87
|
+
commitOnDelivery: false // Leased; true sends autoAck=true, the broker's at-most-once commit at delivery
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
export const BUFFER_DEFAULTS = {
|
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/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",
|
|
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,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin methods whose route the 2.x broker does not have.
|
|
3
|
+
*
|
|
4
|
+
* clearQueue() sent DELETE /api/v1/queues/:name/clear and moveMessageToDLQ()
|
|
5
|
+
* sent POST /api/v1/messages/:partitionId/:transactionId/dlq. Neither route is
|
|
6
|
+
* registered (server/src/rsm/facade/real/phase2/reads.rs: the messages family
|
|
7
|
+
* is GET, DELETE and POST .../retry; there is no /api/v1/queues/... family), so
|
|
8
|
+
* a 2.x broker answers both with 404 no_such_route, and the caller saw
|
|
9
|
+
* "not found". Both now throw before any request, naming the way that works.
|
|
10
|
+
*
|
|
11
|
+
* Same style as conflation-unit/conflationWire.test.js: a real node:http
|
|
12
|
+
* server records every request, so "before any request" is asserted on the
|
|
13
|
+
* socket, not on a mock.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it } from 'node:test'
|
|
17
|
+
import assert from 'node:assert/strict'
|
|
18
|
+
|
|
19
|
+
import { Queen } from '../../client-v2/index.js'
|
|
20
|
+
import { withPlanServer } from '../kv-unit/_planServer.js'
|
|
21
|
+
|
|
22
|
+
const notFound = { status: 404, body: { code: 'no_such_route', error: 'not found' } }
|
|
23
|
+
|
|
24
|
+
async function withQueen(run) {
|
|
25
|
+
await withPlanServer([], notFound, async (url, hits) => {
|
|
26
|
+
const queen = new Queen({ url, handleSignals: false })
|
|
27
|
+
try {
|
|
28
|
+
await run(queen, hits)
|
|
29
|
+
} finally {
|
|
30
|
+
await queen.close()
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('Admin — routes the 2.x broker does not have', () => {
|
|
36
|
+
it('moveMessageToDLQ() throws before any request and names the dlq ack', async () => {
|
|
37
|
+
await withQueen(async (queen, hits) => {
|
|
38
|
+
await assert.rejects(
|
|
39
|
+
queen.admin.moveMessageToDLQ('7', 'tx-1'),
|
|
40
|
+
(err) => {
|
|
41
|
+
assert.match(err.message, /no route/)
|
|
42
|
+
assert.match(err.message, /queen\.ack\(message, 'dlq', \{ group \}\)/)
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
assert.equal(hits.length, 0, 'no request was sent')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('clearQueue() throws before any request and names the seek to the end', async () => {
|
|
51
|
+
await withQueen(async (queen, hits) => {
|
|
52
|
+
await assert.rejects(
|
|
53
|
+
queen.admin.clearQueue('orders', 'p1'),
|
|
54
|
+
(err) => {
|
|
55
|
+
assert.match(err.message, /no route/)
|
|
56
|
+
assert.match(err.message, /seekConsumerGroup\(group, 'orders', \{ toEnd: true \}\)/)
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
assert.equal(hits.length, 0, 'no request was sent')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* each(): a nack releases ONE partition. A multi-partition pop claims several
|
|
3
|
+
* partitions under one lease; when the handler fails a message, the nack
|
|
4
|
+
* releases that message's partition and clamps its cursor, so the later
|
|
5
|
+
* messages of THAT partition come back on the next pop. The other partitions
|
|
6
|
+
* are still leased to this worker: their messages must be handled now.
|
|
7
|
+
*
|
|
8
|
+
* Before: the loop abandoned the whole popped batch after a nack. The other
|
|
9
|
+
* partitions' messages stayed leased and came back only when the lease
|
|
10
|
+
* expired (found live 2026-10-06 against 2.0.1: B1 and B2 waited the whole
|
|
11
|
+
* 6 s lease after A1 failed).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it } from 'node:test'
|
|
15
|
+
import assert from 'node:assert/strict'
|
|
16
|
+
import { createServer } from 'node:http'
|
|
17
|
+
|
|
18
|
+
import { Queen } from '../../client-v2/index.js'
|
|
19
|
+
|
|
20
|
+
const GROUP = 'workers'
|
|
21
|
+
|
|
22
|
+
const message = (partition, n) => ({
|
|
23
|
+
id: `msg-${partition}${n}`,
|
|
24
|
+
transactionId: `tx-${partition}${n}`,
|
|
25
|
+
partitionId: `pid-${partition}`,
|
|
26
|
+
partition,
|
|
27
|
+
leaseId: 'lease-1',
|
|
28
|
+
consumerGroup: GROUP,
|
|
29
|
+
data: { tag: `${partition}${n}` },
|
|
30
|
+
createdAt: '2026-10-06T10:00:00.000Z'
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
async function withBroker(pops, run) {
|
|
34
|
+
const queue = [...pops]
|
|
35
|
+
const requests = []
|
|
36
|
+
const server = createServer((req, res) => {
|
|
37
|
+
let raw = ''
|
|
38
|
+
req.on('data', chunk => { raw += chunk })
|
|
39
|
+
req.on('end', () => {
|
|
40
|
+
const body = raw ? JSON.parse(raw) : null
|
|
41
|
+
const path = req.url.split('?')[0]
|
|
42
|
+
requests.push({ method: req.method, path, body })
|
|
43
|
+
if (req.method === 'GET' && path.startsWith('/api/v1/pop')) {
|
|
44
|
+
const batch = queue.shift()
|
|
45
|
+
if (!batch) { res.writeHead(204); res.end(); return }
|
|
46
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
47
|
+
res.end(JSON.stringify({ success: true, consumerGroup: GROUP, messages: batch }))
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
if (req.method === 'POST' && (path === '/api/v1/ack' || path === '/api/v1/ack/batch')) {
|
|
51
|
+
const acks = body.acknowledgments || [body]
|
|
52
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
53
|
+
res.end(JSON.stringify(acks.map((a, i) => ({ index: i, transactionId: a.transactionId, success: true, error: null }))))
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
57
|
+
res.end('{"error":"not found"}')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
61
|
+
const queen = new Queen({ url: `http://127.0.0.1:${server.address().port}`, handleSignals: false })
|
|
62
|
+
try {
|
|
63
|
+
await run(queen, requests)
|
|
64
|
+
} finally {
|
|
65
|
+
await queen.close()
|
|
66
|
+
await new Promise(resolve => server.close(resolve))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('each(): a nack skips only its own partition', () => {
|
|
71
|
+
it('handles the other partitions of the pop after a failure', async () => {
|
|
72
|
+
const pop = [message('A', 1), message('A', 2), message('B', 1), message('B', 2)]
|
|
73
|
+
await withBroker([pop], async (queen, requests) => {
|
|
74
|
+
const handled = []
|
|
75
|
+
await queen.queue('orders').group(GROUP).each().batch(4).limit(3).idleMillis(500)
|
|
76
|
+
.consume(async (m) => {
|
|
77
|
+
handled.push(m.data.tag)
|
|
78
|
+
if (m.data.tag === 'A1') throw new Error('A1 fails')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
assert.deepEqual(handled, ['A1', 'B1', 'B2'], 'A2 is skipped (it comes back after the nack), B is handled now')
|
|
82
|
+
const settled = requests.filter(r => r.path.startsWith('/api/v1/ack'))
|
|
83
|
+
.flatMap(r => r.body.acknowledgments || [r.body])
|
|
84
|
+
.map(a => `${a.transactionId}:${a.status}`)
|
|
85
|
+
assert.deepEqual(settled, ['tx-A1:failed', 'tx-B1:completed', 'tx-B2:completed'])
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
})
|