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.
@@ -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 new Promise(resolve => setTimeout(resolve, delay))
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
- return response.json()
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 new Promise(resolve => setTimeout(resolve, delay))
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
- async get(path, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
542
- return this.#requestWithFailover('GET', path, null, requestTimeoutMillis, affinityKey, retryKind)
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: false, // No long polling (immediate return)
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 // Server-side auto-ack (false = manual ack required)
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.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
+ })