queen-mq 2.0.2 → 2.0.4

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
@@ -1049,3 +1079,25 @@ const messages: Message<OrderData>[] = await queen.queue('orders').pop()
1049
1079
  ## License
1050
1080
 
1051
1081
  Apache 2.0 - See [LICENSE.md](../LICENSE.md)
1082
+
1083
+ ### Optional consumer supervision
1084
+
1085
+ ```js
1086
+ await queen.queue('orders').group('billing')
1087
+ .supervision({ group: 'billing-production' })
1088
+ .concurrency(4).each().consume(async message => { /* process message */ })
1089
+ ```
1090
+
1091
+ Supervision defaults to off; `.supervision(false)` disables it. The group names
1092
+ an application/deployment in the dashboard, independently of the consumer group.
1093
+ Each consume invocation publishes its own instance into the broker's
1094
+ `queen-supervisor` KV namespace every 10 seconds (30-second heartbeat timeout,
1095
+ 60-second TTL), plus a final stopped observation. The credential needs KV write
1096
+ access. Publication is serialized and best effort with a two-second deadline.
1097
+
1098
+ The Supervisors page supporting `queen.consumer.status/v1` shows live async
1099
+ consumer loops, busy handlers, successful/failed handler calls and progress times.
1100
+ A batch is one handler call; completion does not imply ACK success. No payloads or
1101
+ error text are published. Event-loop starvation can stop heartbeats. Reporting
1102
+ does not restart processes or tasks, change ACK/lease policies, or enable remote
1103
+ control. With reporting off there is no additional timer or network traffic.
@@ -42,6 +42,7 @@ export class QueueBuilder {
42
42
  #config = {}
43
43
 
44
44
  // Consume options
45
+ #supervision = false
45
46
  #concurrency = CONSUME_DEFAULTS.concurrency
46
47
  // batch / maxPartitions hold the USER's value, and null means the setter was
47
48
  // never called -- which is the dimension pop autopilot gets to choose. The
@@ -238,6 +239,12 @@ export class QueueBuilder {
238
239
  return this
239
240
  }
240
241
 
242
+ /** Opt-in consumer observations in the broker dashboard; false by default. */
243
+ supervision(config = false) {
244
+ this.#supervision = config
245
+ return this
246
+ }
247
+
241
248
  concurrency(count) {
242
249
  this.#concurrency = Math.max(1, count)
243
250
  return this
@@ -410,6 +417,7 @@ export class QueueBuilder {
410
417
  // has to survive all the way to #buildParams: it is the ONLY record that
411
418
  // the user said nothing about that dimension.
412
419
  autopilot: this.#autopilotEnabled(),
420
+ supervision: options.supervision ?? this.#supervision,
413
421
  signal: options.signal
414
422
  }
415
423
 
@@ -3,10 +3,25 @@
3
3
  */
4
4
 
5
5
  import * as logger from '../utils/logger.js'
6
+ import { Supervision } from './Supervision.js'
6
7
  import { checkConflationResponse, CONFLATION_UNSUPPORTED } from '../utils/conflation.js'
7
8
  import { popSizing, parseAutopilotDecision, emptyPollDelayMillis } from '../utils/autopilot.js'
8
9
  import { CONSUME_DEFAULTS } from '../utils/defaults.js'
9
10
 
11
+ /** Wait `ms`, or less if the consumer is stopped meanwhile: the loop checks the signal next. */
12
+ function pause(ms, signal) {
13
+ if (signal?.aborted) return Promise.resolve()
14
+ return new Promise(resolve => {
15
+ const done = () => {
16
+ clearTimeout(timer)
17
+ signal?.removeEventListener('abort', done)
18
+ resolve()
19
+ }
20
+ const timer = setTimeout(done, ms)
21
+ signal?.addEventListener('abort', done, { once: true })
22
+ })
23
+ }
24
+
10
25
  export class ConsumerManager {
11
26
  #httpClient
12
27
  #queen
@@ -78,9 +93,13 @@ export class ConsumerManager {
78
93
  // Generate affinity key for consistent routing to same backend
79
94
  const affinityKey = this.#getAffinityKey(queue, partition, namespace, task, group)
80
95
 
96
+ const supervision = options.supervision ? new Supervision(this.#httpClient, options.supervision, options) : null
97
+ if (supervision) handler = supervision.wrap(handler)
98
+
81
99
  // Start workers
82
100
  const workers = []
83
101
  for (let i = 0; i < concurrency; i++) {
102
+ if (supervision) supervision.running++
84
103
  workers.push(this.#worker(i, handler, path, baseParams, {
85
104
  batch,
86
105
  limit,
@@ -99,9 +118,12 @@ export class ConsumerManager {
99
118
  // the pop target to key the once-per-(queue,group) conflict warning.
100
119
  conflation,
101
120
  conflationCtx: { queue, namespace, task, group }
121
+ }).finally(async () => {
122
+ if (supervision && --supervision.running === 0) await supervision.stop()
102
123
  }))
103
124
  }
104
125
 
126
+ supervision?.start()
105
127
  logger.log('ConsumerManager.start', { status: 'workers-started', count: concurrency })
106
128
 
107
129
  // Wait for all workers to complete
@@ -160,7 +182,10 @@ export class ConsumerManager {
160
182
  // a long-poll: mark it 'pop' so a 429 backs off and keeps waiting
161
183
  // instead of giving up after the bounded push-like attempt budget.
162
184
  const clientTimeout = wait ? timeoutMillis + 5000 : timeoutMillis
163
- const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey, wait ? 'pop' : null)
185
+ // The signal closes the poll as well: a broker hands nothing to a poll
186
+ // whose caller is gone, so a stopped consumer is never given a message
187
+ // it would only sit on until the lease expires.
188
+ const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey, wait ? 'pop' : null, signal)
164
189
 
165
190
  // Degrade-loudly (PLAN_CONFLATION §4). Checked BEFORE the empty-response
166
191
  // branch on purpose: a pre-1.1.0 broker answers an empty pop with a
@@ -181,7 +206,7 @@ export class ConsumerManager {
181
206
  // pop engaged autopilot and the broker had an opinion (it knows the
182
207
  // arrival rate on this queue and this client does not), otherwise
183
208
  // the historical 100ms.
184
- await new Promise(resolve => setTimeout(resolve, emptyPollDelayMillis(parseAutopilotDecision(result))))
209
+ await pause(emptyPollDelayMillis(parseAutopilotDecision(result)), signal)
185
210
  continue
186
211
  }
187
212
  }
@@ -218,8 +243,17 @@ export class ConsumerManager {
218
243
  // partitions of a multi-partition pop are still leased to this
219
244
  // worker, so their messages are handled now, not after the lease.
220
245
  const nackedPartitions = new Set()
221
- for (const message of messages) {
222
- if (signal && signal.aborted) break
246
+ for (const [i, message] of messages.entries()) {
247
+ // Stopped, or the limit reached, with messages still in hand:
248
+ // give them back rather than leave them leased. Those of a
249
+ // nacked partition were already given back by the nack.
250
+ if ((signal && signal.aborted) || (limit && processedCount >= limit)) {
251
+ const unstarted = messages.slice(i).filter(m => !nackedPartitions.has(m.partitionId ?? m.partition))
252
+ if (unstarted.length > 0) {
253
+ await this.#releaseUnstarted(unstarted, group, signal && signal.aborted ? 'aborted' : 'limit-reached')
254
+ }
255
+ break
256
+ }
223
257
 
224
258
  const partition = message.partitionId ?? message.partition
225
259
  if (nackedPartitions.has(partition)) continue
@@ -231,8 +265,6 @@ export class ConsumerManager {
231
265
  nackedPartitions.add(partition)
232
266
  logger.warn('ConsumerManager.worker', { workerId, status: 'partition-abandoned-after-nack', partition })
233
267
  }
234
-
235
- if (limit && processedCount >= limit) break
236
268
  }
237
269
  } else {
238
270
  // Process as batch
@@ -249,6 +281,13 @@ export class ConsumerManager {
249
281
  }
250
282
 
251
283
  } catch (error) {
284
+ // Stopped while a poll was open: the poll was closed, nothing was
285
+ // taken, the worker is done. Not an error, whatever the wait mode.
286
+ if (error.aborted || (signal && signal.aborted)) {
287
+ logger.log('ConsumerManager.worker', { workerId, status: 'aborted', processedCount })
288
+ break
289
+ }
290
+
252
291
  // Conflation faults are terminal and are classified FIRST, ahead of the
253
292
  // message-substring heuristics below: a consumer that asked for
254
293
  // last-value delivery and is not getting it must stop, not retry
@@ -278,7 +317,7 @@ export class ConsumerManager {
278
317
  ? error.retryAfterSeconds * 1000
279
318
  : 1000
280
319
  logger.warn('ConsumerManager.worker', { workerId, status: 'rate-limited', code: error.code, retryAfterMs })
281
- await new Promise(resolve => setTimeout(resolve, retryAfterMs))
320
+ await pause(retryAfterMs, signal)
282
321
  continue
283
322
  }
284
323
 
@@ -290,7 +329,7 @@ export class ConsumerManager {
290
329
  if (isNetworkError) {
291
330
  logger.warn('ConsumerManager.worker', { workerId, error: 'network', message: error.message })
292
331
  // Wait before retry
293
- await new Promise(resolve => setTimeout(resolve, 1000))
332
+ await pause(1000, signal)
294
333
  continue
295
334
  }
296
335
 
@@ -339,6 +378,26 @@ export class ConsumerManager {
339
378
  return true
340
379
  }
341
380
 
381
+ /**
382
+ * Hand back messages this worker holds but will not process (it was stopped,
383
+ * or reached its limit): a `retry` ack releases their lease, the broker
384
+ * redelivers them first, in order, and charges no retry. Best effort -- a
385
+ * release that fails leaves the message to its lease, as before.
386
+ */
387
+ async #releaseUnstarted(messages, group, reason) {
388
+ const subject = { count: messages.length, transactionIds: messages.map(m => m.transactionId) }
389
+ try {
390
+ const res = await this.#queen.ack(messages, 'retry', group ? { group } : {})
391
+ if (res && res.success === false) {
392
+ logger.warn('ConsumerManager.release', { ...subject, reason, status: 'release-rejected', error: res.error })
393
+ } else {
394
+ logger.log('ConsumerManager.release', { ...subject, reason, status: 'released' })
395
+ }
396
+ } catch (error) {
397
+ logger.warn('ConsumerManager.release', { ...subject, reason, status: 'release-failed', error: error.message })
398
+ }
399
+ }
400
+
342
401
  async #processBatch(messages, handler, autoAck, group) {
343
402
  try {
344
403
  await handler(messages)
@@ -0,0 +1,78 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { hostname } from 'node:os'
3
+ import * as logger from '../utils/logger.js'
4
+
5
+ // One opt-in reporter per consume invocation. Handler calls are not ACKs.
6
+ export class Supervision {
7
+ constructor(http, config, options) {
8
+ if (!config || typeof config !== 'object' || typeof config.group !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/.test(config.group) || config.group === 'coordination' || /[\r\n]/.test(config.group)) throw new Error('supervision.group must be a valid application/deployment name')
9
+ if (!Number.isInteger(options.concurrency) || options.concurrency < 1 || options.concurrency > 4096) throw new Error('supervision requires concurrency between 1 and 4096')
10
+ this.http = http
11
+ this.options = options
12
+ this.group = config.group
13
+ this.id = randomBytes(16).toString('hex')
14
+ this.started = Date.now()
15
+ this.monotonic = performance.now()
16
+ this.running = this.completed = this.failed = this.sequence = 0
17
+ this.last = this.pending = this.timer = null
18
+ this.active = new Map()
19
+ this.state = 'running'
20
+ this.warned = false
21
+ }
22
+ wrap(handler) {
23
+ return async (...args) => {
24
+ const id = this.sequence++
25
+ this.active.set(id, performance.now())
26
+ try { const result = await handler(...args); this.completed++; return result }
27
+ catch (error) { this.failed++; throw error }
28
+ finally { this.active.delete(id); this.last = Math.floor(Date.now() / 1000) }
29
+ }
30
+ }
31
+ start() {
32
+ this.publish()
33
+ this.timer = setInterval(() => this.publish(), 10_000)
34
+ this.timer.unref()
35
+ }
36
+ document() {
37
+ const now = performance.now()
38
+ return {
39
+ schema: 'queen.consumer.status/v1', instance_id: this.id, engine: 'js', execution_model: 'async-tasks',
40
+ hostname: hostname(), pid: process.pid, state: this.state, updated_at_epoch: Math.floor(Date.now() / 1000),
41
+ started_at_epoch: Math.floor(this.started / 1000), uptime_seconds: Math.floor((now - this.monotonic) / 1000),
42
+ configuration: { heartbeat_timeout: 30 },
43
+ pool_status: [{ name: 'consumer', queue: this.options.queue || null, namespace: this.options.namespace || null,
44
+ task: this.options.task || null, consumer_group: this.options.group || '__QUEUE_MODE__',
45
+ desired: this.options.concurrency, running: this.running, busy: this.active.size,
46
+ completed: this.completed, failed: this.failed, last_completed_at_epoch: this.last,
47
+ oldest_inflight_seconds: this.active.size ? Math.floor((now - Math.min(...this.active.values())) / 1000) : null }],
48
+ }
49
+ }
50
+ publish() {
51
+ if (this.pending) return this.pending
52
+ this.pending = this.send().finally(() => { this.pending = null })
53
+ return this.pending
54
+ }
55
+ async send() {
56
+ try {
57
+ const bytes = Buffer.from(JSON.stringify(this.document()))
58
+ if (bytes.length > 45_000) throw new Error('Consumer status exceeds one chunk')
59
+ const write = randomBytes(16).toString('hex'), slot = `${this.group}/${this.id}`
60
+ const ops = [
61
+ { op: 'put', ns: 'queen-supervisor', key: `${slot}/head`, value: { format: 'queen.supervisor.remote-status/v1', write, chunks: 1, bytes: bytes.length }, ttlSeconds: 60 },
62
+ { op: 'put', ns: 'queen-supervisor', key: `${slot}/chunk/0000`, value: { write, index: 0, data: bytes.toString('base64') }, ttlSeconds: 60 },
63
+ ]
64
+ const result = await this.http.post('/api/v1/kv', { operations: ops }, 2000, null, null, AbortSignal.timeout(2000))
65
+ if (!Array.isArray(result?.results) || result.results.length !== 2 || result.results.some(r => r.applied !== true)) throw new Error('Consumer status publication was not applied')
66
+ this.warned = false
67
+ } catch {
68
+ if (!this.warned) logger.warn('Consumer.supervision', 'Status publication failed; consumption continues')
69
+ this.warned = true
70
+ }
71
+ }
72
+ async stop() {
73
+ clearInterval(this.timer)
74
+ if (this.pending) await this.pending
75
+ this.state = 'stopped'
76
+ await this.publish()
77
+ }
78
+ }
@@ -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,12 +605,14 @@ 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
- async post(path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null) {
546
- return this.#requestWithFailover('POST', path, body, requestTimeoutMillis, affinityKey, retryKind)
614
+ async post(path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null, signal = null) {
615
+ return this.#requestWithFailover('POST', path, body, requestTimeoutMillis, affinityKey, retryKind, signal)
547
616
  }
548
617
 
549
618
  async put(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.2",
3
+ "version": "2.0.4",
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 test-v2/consumer-unit/supervision.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
+ })
@@ -0,0 +1,97 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { ConsumerManager } from '../../client-v2/consumer/ConsumerManager.js'
4
+ import { Supervision } from '../../client-v2/consumer/Supervision.js'
5
+
6
+ const options = { queue: 'orders', concurrency: 2, timeoutMillis: 30000, limit: 1, batch: 1, each: true, autoAck: true, wait: false }
7
+ const decode = body => {
8
+ assert.equal(body.operations.length, 2)
9
+ const [head, chunk] = body.operations
10
+ assert.equal(head.ns, 'queen-supervisor')
11
+ assert.equal(head.ttlSeconds, 60)
12
+ assert.equal(chunk.ttlSeconds, 60)
13
+ assert.equal(head.value.write, chunk.value.write)
14
+ const bytes = Buffer.from(chunk.value.data, 'base64')
15
+ assert.equal(bytes.length, head.value.bytes)
16
+ return JSON.parse(bytes)
17
+ }
18
+ function rig(fail = false) {
19
+ const docs = [], acks = []
20
+ const http = { get: async () => ({ messages: [{ transactionId: 't', partitionId: 'p', data: { secret: 42 } }] }),
21
+ post: async (path, body) => { assert.equal(path, '/api/v1/kv'); docs.push(decode(body)); if (fail) throw new Error('offline'); return { results: [{ applied: true }, { applied: true }] } } }
22
+ const queen = { ack: async (_, success) => { acks.push(success); return { success: true } } }
23
+ return { docs, acks, http, manager: new ConsumerManager(http, queen) }
24
+ }
25
+ test('default and explicit off create no publications and preserve acknowledgement', async () => {
26
+ for (const supervision of [undefined, false]) {
27
+ const r = rig(); await r.manager.start(async () => {}, { ...options, supervision })
28
+ assert.equal(r.docs.length, 0); assert.deepEqual(r.acks, [true, true])
29
+ }
30
+ })
31
+ test('enabled consumers count actual exits, handler failures and final state without changing ACKs', async () => {
32
+ const r = rig()
33
+ let n = 0
34
+ await r.manager.start(async () => { if (++n === 1) throw new Error('private error') }, { ...options, supervision: { group: 'billing-production' } })
35
+ const last = r.docs.at(-1)
36
+ assert.equal(last.state, 'stopped'); assert.equal(last.pool_status[0].running, 0)
37
+ assert.equal(last.pool_status[0].busy, 0); assert.equal(last.pool_status[0].completed, 1)
38
+ assert.equal(last.pool_status[0].failed, 1); assert.deepEqual(r.acks.sort(), [false, true])
39
+ assert.equal(JSON.stringify(r.docs).includes('private error'), false)
40
+ assert.equal(JSON.stringify(r.docs).includes('secret'), false)
41
+ })
42
+ test('publication errors do not affect consumption; instances are unique', async () => {
43
+ const r = rig(true)
44
+ await r.manager.start(async () => {}, { ...options, supervision: { group: 'billing' } })
45
+ const first = r.docs[0].instance_id
46
+ await r.manager.start(async () => {}, { ...options, supervision: { group: 'billing' } })
47
+ assert.notEqual(first, r.docs.at(-1).instance_id); assert.equal(r.acks.length, 4)
48
+ })
49
+ test('busy handlers remain observable and publication is serialized', async () => {
50
+ const r = rig(); const reporter = new Supervision(r.http, { group: 'billing' }, options)
51
+ let release
52
+ const work = reporter.wrap(() => new Promise(resolve => { release = resolve }))()
53
+ reporter.running = 1
54
+ await Promise.all([reporter.publish(), reporter.publish()])
55
+ assert.equal(r.docs.length, 1); assert.equal(r.docs[0].pool_status[0].busy, 1)
56
+ assert.equal(r.docs[0].pool_status[0].completed, 0)
57
+ release(); await work
58
+ assert.equal(reporter.document().pool_status[0].completed, 1)
59
+ assert.equal(reporter.document().pool_status[0].oldest_inflight_seconds, null)
60
+ })
61
+ test('invalid opt-in groups fail before polling', async () => {
62
+ for (const group of ['', 'coordination', 'a/b', 'billing\n', undefined]) {
63
+ const r = rig(); await assert.rejects(r.manager.start(async () => {}, { ...options, supervision: { group } }), /supervision.group/)
64
+ assert.equal(r.docs.length, 0)
65
+ }
66
+ })
67
+
68
+ test('real HTTP publishing preserves authentication and has a total deadline', async () => {
69
+ const { createServer } = await import('node:http')
70
+ const { Queen } = await import('../../client-v2/index.js')
71
+ let kvCalls = 0, polls = 0
72
+ const docs = []
73
+ const server = createServer((req, res) => {
74
+ if (req.url === '/api/v1/kv') {
75
+ kvCalls++
76
+ assert.equal(req.headers.authorization, 'Bearer test-token')
77
+ let bytes = ''
78
+ req.on('data', chunk => { bytes += chunk })
79
+ req.on('end', () => { docs.push(decode(JSON.parse(bytes))) })
80
+ return // Deliberately never answer. Consumption and shutdown stay bounded.
81
+ }
82
+ polls++
83
+ res.setHeader('Content-Type', 'application/json')
84
+ res.end(JSON.stringify({ messages: [{ transactionId: 't', partitionId: 'p', data: {} }] }))
85
+ })
86
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
87
+ const queen = new Queen({ url: `http://127.0.0.1:${server.address().port}`, bearerToken: 'test-token', handleSignals: false })
88
+ const start = performance.now()
89
+ try {
90
+ await queen.queue('orders').supervision({ group: 'wire' }).wait(false).autoAck(false).limit(1).consume(async () => {})
91
+ assert.equal(polls, 1); assert.equal(kvCalls, 2)
92
+ assert.equal(docs.at(-1).state, 'stopped')
93
+ assert.ok(performance.now() - start < 6000, 'two publications are bounded by two seconds each')
94
+ } finally {
95
+ await queen.close(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve))
96
+ }
97
+ })