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 CHANGED
@@ -22,7 +22,7 @@ Queen MQ is a partitioned message queue broker that keeps its state in its own r
22
22
  - **Consumer Groups** - Kafka-style consumer groups for scalability
23
23
  - **Flexible Semantics** - Exactly-once, at-least-once, and at-most-once delivery
24
24
  - **Transactions** - Atomic operations across push and ack
25
- - **High Performance** — 104K msg/s push, 165K msg/s fan-out with consumer groups on a single 32-core node ([benchmarks](https://github.com/queen-mq/queen/tree/master/benchmark-queen/2026-04-26))
25
+ - **High Performance** — 1M msg/s in and out of one queue of 10M partitions on three nodes ([benchmarks](https://queenmq.com/benchmarks/))
26
26
  - **Subscription Modes** - Process from beginning, new messages only, or from timestamp
27
27
  - **Dead Letter Queue** - Automatic failure handling and monitoring
28
28
  - **Message Tracing** - Debug distributed workflows with trace timelines
@@ -192,8 +192,8 @@ Notes:
192
192
  the partition cap; either way it is clamped to 64, so a conflating pop returns
193
193
  at most 64 messages per round-trip whatever `batch` says.
194
194
  - Refused with 400 by the broker without a `.group(...)`, and together with
195
- `.autoAck(true)` (auto-ack commits at delivery, which would turn the guarantee
196
- above into at-most-once).
195
+ `.commitOnDelivery()` (a commit at delivery would turn the guarantee above
196
+ into at-most-once). `pop()` raises that 400 instead of returning `[]`.
197
197
  - Requires broker **>= 1.1.0**. An older broker ignores the flag and would
198
198
  quietly deliver the whole backlog, so the SDK raises
199
199
  `conflation was requested but this broker did not apply it` on the first
@@ -281,8 +281,9 @@ await queen.queue('tasks')
281
281
 
282
282
  **When the handler throws**, the consumer nacks what it was given (the message, or the whole batch)
283
283
  and keeps consuming. The broker redelivers it, and moves it to the dead letter queue once the queue's
284
- `retryLimit` is spent. With `.each()`, the rest of the popped batch is dropped after a nack: the
285
- broker redelivers it too.
284
+ `retryLimit` is spent. With `.each()`, the later messages of the failed message's partition are
285
+ skipped after a nack (the broker redelivers them too); the other partitions of the same pop are
286
+ still handled.
286
287
 
287
288
  This is the same with `.autoAck(false)`. That setting hands the *success* path to your handler (it
288
289
  acks), not the failure path: a handler that threw never got to settle its messages. A message your
@@ -324,6 +325,36 @@ await queen.queue('tasks')
324
325
  Earlier versions stopped the consumer on a throw under `.autoAck(false)`: `consume()` rejected after
325
326
  the first failure, and the message stayed leased until its lease expired.
326
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
+
327
358
  ### Pop Messages (On-Demand Processing)
328
359
 
329
360
  ```javascript
@@ -794,8 +825,20 @@ const msgs = await queen.queue('q').batch(10).pop()
794
825
  const msgs = await queen.queue('q').batch(10).wait(true).pop()
795
826
  const msgs = await queen.queue('q').batch(200).partitions(50).pop() // multi-partition pop
796
827
  const { messages, autopilot } = await queen.queue('q').popResult() // + what the broker chose
828
+ const msgs = await queen.queue('q').group('g').commitOnDelivery().pop() // committed at delivery, nothing to ack
797
829
  ```
798
830
 
831
+ A pop long-polls, waiting up to `timeoutMillis` (30 s) for a message, unless you call
832
+ `.wait(false)`. Its messages come back leased, and the ack is yours.
833
+
834
+ `.commitOnDelivery()` changes that for `pop()` and `popResult()`. The broker moves the group's
835
+ cursor past the messages as it hands them out: there is no lease (`leaseId` is empty) and nothing
836
+ to ack. This is at-most-once delivery: a crash after the pop loses the messages. The broker
837
+ refuses it together with `.conflation()` (400). `consume()` always leases its messages, so it
838
+ throws before any request when the builder has `.commitOnDelivery()`.
839
+
840
+ `.autoAck()` is `consume()`'s ack after your handler and has no effect on a pop.
841
+
799
842
  ### Consume
800
843
 
801
844
  ```javascript
@@ -950,6 +993,18 @@ await queen.close() // Flush buffers and close connections
950
993
  the broker. These values are what comes back with `.autopilot(false)` or
951
994
  `QUEEN_SDK_POP_AUTOPILOT=off`.
952
995
 
996
+ ### Pop Defaults
997
+
998
+ ```javascript
999
+ {
1000
+ batch: 1, // autopilot OFF only, as for consume
1001
+ wait: true, // long-polls; .wait(false) returns at once
1002
+ timeoutMillis: 30000, // the long-poll limit
1003
+ autoAck: false, // consume()'s ack after the handler; no effect on pop()
1004
+ commitOnDelivery: false // leased; .commitOnDelivery() commits at delivery (at-most-once)
1005
+ }
1006
+ ```
1007
+
953
1008
  ---
954
1009
 
955
1010
  ## Logging
@@ -319,8 +319,8 @@ export class Queen {
319
319
  * creates it, which is what makes thousands of short-lived req/reply
320
320
  * inboxes cheap (§1.1).
321
321
  * * delivery is at-least-once while the owning broker lives, at-most-once
322
- * with `autoAck` (§1.3) -- NOT "at most once" as a class. Consumers still
323
- * need idempotency.
322
+ * with `commitOnDelivery` (§1.3) -- NOT "at most once" as a class.
323
+ * Consumers still need idempotency.
324
324
  * * consumption semantics are the pop's `group`, exactly as on durable
325
325
  * queues (§1.5): same group competes, own group fans out, no group is
326
326
  * queue mode. There is no queue-level mode to set.
@@ -1662,6 +1662,11 @@ const msgs = await queen.queue('q').partition('p1').pop()
1662
1662
  // capped at 200 total messages. All partitions share one leaseId.
1663
1663
  // Each returned message carries its own partitionId / partition / leaseId.
1664
1664
  const msgs = await queen.queue('q').batch(200).partitions(50).pop()
1665
+
1666
+ // Commit at delivery: the broker moves the group's cursor past the messages
1667
+ // as it hands them out. No lease, nothing to ack, at-most-once: a crash after
1668
+ // the pop loses them. pop() and popResult() only; consume() throws.
1669
+ const msgs = await queen.queue('q').group('g').commitOnDelivery().pop()
1665
1670
  ```
1666
1671
 
1667
1672
  ### Consume
@@ -1915,8 +1920,9 @@ await queen.close()
1915
1920
  ```javascript
1916
1921
  {
1917
1922
  batch: 1,
1918
- wait: false, // No long polling
1919
- autoAck: false // Manual ack required
1923
+ wait: true, // Long polling; .wait(false) returns at once
1924
+ autoAck: false, // consume()'s ack after the handler; no effect on pop()
1925
+ commitOnDelivery: false // Manual ack required; .commitOnDelivery() commits at delivery
1920
1926
  }
1921
1927
  ```
1922
1928
 
@@ -101,15 +101,26 @@ export class Admin {
101
101
  }
102
102
 
103
103
  /**
104
- * Clear all messages from a queue
104
+ * @deprecated The 2.x broker has no route that clears a queue, so this
105
+ * always rejects, before any request. Seek each consumer group
106
+ * to the end instead.
107
+ *
108
+ * The old DELETE /api/v1/queues/:name/clear answers 404 no_such_route on
109
+ * 2.x. A seek to the end moves one group's cursor past every queued message
110
+ * (and releases its live leases); the group of a pop without one is
111
+ * '__QUEUE_MODE__'. Deleting the queue is not a clear: it removes the queue
112
+ * itself, with its configuration.
105
113
  * @param {string} name - Queue name
106
- * @param {string} [partition] - Optional partition to clear
107
- * @returns {Promise<object>}
114
+ * @param {string} [partition] - Ignored
115
+ * @returns {Promise<never>}
108
116
  */
109
117
  async clearQueue(name, partition = null) {
110
118
  logger.log('Admin.clearQueue', { name, partition })
111
- const queryString = partition ? `?partition=${encodeURIComponent(partition)}` : ''
112
- return this.#httpClient.delete(`/api/v1/queues/${encodeURIComponent(name)}/clear${queryString}`)
119
+ throw new Error(
120
+ 'Queen 2.x has no route that clears a queue. Move each consumer group past what is queued ' +
121
+ `with queen.admin.seekConsumerGroup(group, '${name}', { toEnd: true }); ` +
122
+ "a pop without a group reads as the group '__QUEUE_MODE__'."
123
+ )
113
124
  }
114
125
 
115
126
  /**
@@ -172,14 +183,25 @@ export class Admin {
172
183
  }
173
184
 
174
185
  /**
175
- * Move a message to the Dead Letter Queue
186
+ * @deprecated The 2.x broker has no route that moves a message to the DLQ
187
+ * by its address, so this always rejects, before any request.
188
+ * Ack the leased message with the `dlq` status instead.
189
+ *
190
+ * The old POST /api/v1/messages/:partitionId/:transactionId/dlq answers 404
191
+ * no_such_route on 2.x. The broker files a dead letter when the consumer
192
+ * group acks the message with the `dlq` status while it holds the lease (an
193
+ * address alone cannot name the lease), or when a `failed` ack spends the
194
+ * queue's last retry.
176
195
  * @param {string} partitionId - Partition ID
177
196
  * @param {string} transactionId - Transaction ID
178
- * @returns {Promise<object>}
197
+ * @returns {Promise<never>}
179
198
  */
180
199
  async moveMessageToDLQ(partitionId, transactionId) {
181
200
  logger.log('Admin.moveMessageToDLQ', { partitionId, transactionId })
182
- return this.#httpClient.post(`/api/v1/messages/${partitionId}/${transactionId}/dlq`, {})
201
+ throw new Error(
202
+ 'Queen 2.x has no route that moves a message to the DLQ by its address. Pop the message for ' +
203
+ "its consumer group and call queen.ack(message, 'dlq', { group }) while you hold its lease."
204
+ )
183
205
  }
184
206
 
185
207
  // ===========================
@@ -24,6 +24,12 @@ import * as logger from '../utils/logger.js'
24
24
  // this client cannot read is not a message it can report as stored.
25
25
  const ACCEPTED_PUSH_STATUSES = new Set(['queued', 'buffered'])
26
26
 
27
+ // What consume() throws when the builder carries commitOnDelivery(): the loop
28
+ // always pops leased and acks after the handler, so it cannot honour a commit
29
+ // at delivery, and silently leasing would not be what the caller asked for.
30
+ const COMMIT_ON_DELIVERY_NOT_FOR_CONSUME =
31
+ 'commitOnDelivery() is a pop() option; consume() always leases its messages'
32
+
27
33
  export class QueueBuilder {
28
34
  #queen
29
35
  #httpClient
@@ -45,8 +51,14 @@ export class QueueBuilder {
45
51
  #batch = null
46
52
  #limit = CONSUME_DEFAULTS.limit
47
53
  #idleMillis = CONSUME_DEFAULTS.idleMillis
48
- #autoAck = CONSUME_DEFAULTS.autoAck
49
- #wait = CONSUME_DEFAULTS.wait
54
+ // autoAck and wait hold the USER's value, and null means the setter was never
55
+ // called: consume() applies CONSUME_DEFAULTS and pop() POP_DEFAULTS at
56
+ // emission time. autoAck is consume()'s alone; pop() never sends it.
57
+ #autoAck = null
58
+ #wait = null
59
+ // The pop's own option for the broker's commit at delivery: pop() and
60
+ // popResult() send it as autoAck=true, and consume() refuses it.
61
+ #commitOnDelivery = POP_DEFAULTS.commitOnDelivery
50
62
  #timeoutMillis = CONSUME_DEFAULTS.timeoutMillis
51
63
  #renewLease = CONSUME_DEFAULTS.renewLease
52
64
  #renewLeaseIntervalMillis = CONSUME_DEFAULTS.renewLeaseIntervalMillis
@@ -301,6 +313,12 @@ export class QueueBuilder {
301
313
  return this
302
314
  }
303
315
 
316
+ /**
317
+ * consume(): ack each message after the handler returns (default true; the
318
+ * client acks, nothing is sent with the pop). No effect on pop(): it never
319
+ * reaches the wire. For the broker's at-most-once commit at delivery on a
320
+ * pop, use commitOnDelivery().
321
+ */
304
322
  autoAck(enabled) {
305
323
  this.#autoAck = enabled
306
324
  return this
@@ -344,8 +362,9 @@ export class QueueBuilder {
344
362
  * that does not echo the flag rather than draining it silently.
345
363
  *
346
364
  * Refused by the broker (400) when combined with queue mode (no consumer
347
- * group) or with autoAck, which commits at delivery and would turn the
348
- * "the newest state is definitely processed" guarantee into at-most-once.
365
+ * group) or with commitOnDelivery(), which commits at delivery and would
366
+ * turn the "the newest state is definitely processed" guarantee into
367
+ * at-most-once. pop() raises that 400 instead of returning [].
349
368
  */
350
369
  conflation(enabled = true) {
351
370
  this.#conflation = !!enabled
@@ -362,6 +381,10 @@ export class QueueBuilder {
362
381
  // ===========================
363
382
 
364
383
  consume(handler, options = {}) {
384
+ if (this.#commitOnDelivery) {
385
+ throw new Error(COMMIT_ON_DELIVERY_NOT_FOR_CONSUME)
386
+ }
387
+
365
388
  const consumeOptions = {
366
389
  queue: this.#queueName,
367
390
  partition: this.#partition !== 'Default' ? this.#partition : null,
@@ -372,8 +395,8 @@ export class QueueBuilder {
372
395
  batch: this.#batch,
373
396
  limit: this.#limit,
374
397
  idleMillis: this.#idleMillis,
375
- autoAck: this.#autoAck,
376
- wait: this.#wait,
398
+ autoAck: this.#autoAck ?? CONSUME_DEFAULTS.autoAck,
399
+ wait: this.#wait ?? CONSUME_DEFAULTS.wait,
377
400
  timeoutMillis: this.#timeoutMillis,
378
401
  renewLease: this.#renewLease,
379
402
  renewLeaseIntervalMillis: this.#renewLeaseIntervalMillis,
@@ -397,6 +420,10 @@ export class QueueBuilder {
397
420
  // Pop Methods
398
421
  // ===========================
399
422
 
423
+ /**
424
+ * Long-poll: wait up to timeoutMillis for a message instead of returning
425
+ * empty at once. Default true for both pop() and consume().
426
+ */
400
427
  wait(enabled) {
401
428
  this.#wait = enabled
402
429
  return this
@@ -416,6 +443,24 @@ export class QueueBuilder {
416
443
  return this
417
444
  }
418
445
 
446
+ /**
447
+ * Commit the messages of pop() and popResult() at delivery.
448
+ *
449
+ * The broker moves the consumer group's cursor past the messages as it hands
450
+ * them out: no lease, nothing to ack (the messages come back with an empty
451
+ * leaseId). That is at-most-once: a crash after the pop loses them. The
452
+ * request carries autoAck=true, the parameter every 2.x broker reads.
453
+ *
454
+ * A pop option only: consume() always leases its messages, so it throws
455
+ * when this is set. The broker refuses it together with conflation() (400).
456
+ *
457
+ * @param {boolean} [enabled=true]
458
+ */
459
+ commitOnDelivery(enabled = true) {
460
+ this.#commitOnDelivery = !!enabled
461
+ return this
462
+ }
463
+
419
464
  /**
420
465
  * Claim messages and report what the broker chose for this pop.
421
466
  *
@@ -437,15 +482,17 @@ export class QueueBuilder {
437
482
  }
438
483
 
439
484
  async #popWithDecision() {
440
- logger.log('QueueBuilder.pop', { queue: this.#queueName, partition: this.#partition, namespace: this.#namespace, task: this.#task, batch: this.#batch, wait: this.#wait, group: this.#group })
485
+ // For pop(), use POP defaults (not CONSUME defaults) for what the caller
486
+ // did not set. autoAck() is consume()'s ack after the handler and never
487
+ // reaches the wire; the broker's at-most-once autoAck travels only from
488
+ // commitOnDelivery(). Without it a pop comes back leased.
489
+ const effectiveWait = this.#wait ?? POP_DEFAULTS.wait
490
+
491
+ logger.log('QueueBuilder.pop', { queue: this.#queueName, partition: this.#partition, namespace: this.#namespace, task: this.#task, batch: this.#batch, wait: effectiveWait, group: this.#group })
441
492
 
442
493
  try {
443
494
  const path = this.#buildPopPath()
444
495
 
445
- // For pop(), use POP defaults (not CONSUME defaults)
446
- // Override autoAck to false unless explicitly set
447
- const effectiveAutoAck = this.#autoAck !== CONSUME_DEFAULTS.autoAck ? this.#autoAck : POP_DEFAULTS.autoAck
448
-
449
496
  // Batch, partitions and with them the autopilot flag. The RULE for which
450
497
  // of the three travel lives in one place (utils/autopilot.js) because
451
498
  // consume() builds its query string separately; only the PLACEMENT is
@@ -458,17 +505,18 @@ export class QueueBuilder {
458
505
  autopilot: this.#autopilotEnabled()
459
506
  })
460
507
 
461
- // Build params with correct autoAck for pop
462
508
  const params = new URLSearchParams()
463
509
  if (sizing.autopilot) params.append('autopilot', 'true')
464
510
  if (sizing.batch !== null) params.append('batch', sizing.batch)
465
- params.append('wait', this.#wait.toString())
511
+ params.append('wait', effectiveWait.toString())
466
512
  params.append('timeout', this.#timeoutMillis.toString())
467
513
 
468
514
  if (this.#group) params.append('consumerGroup', this.#group)
469
515
  if (this.#namespace) params.append('namespace', this.#namespace)
470
516
  if (this.#task) params.append('task', this.#task)
471
- if (effectiveAutoAck) params.append('autoAck', 'true')
517
+ // Sent only when true, where earlier SDKs placed it: an absent autoAck
518
+ // is the broker's leased default.
519
+ if (this.#commitOnDelivery) params.append('autoAck', 'true')
472
520
  if (this.#subscriptionMode) params.append('subscriptionMode', this.#subscriptionMode)
473
521
  if (this.#subscriptionFrom) params.append('subscriptionFrom', this.#subscriptionFrom)
474
522
  if (sizing.partitions !== null) params.append('partitions', sizing.partitions)
@@ -483,7 +531,7 @@ export class QueueBuilder {
483
531
 
484
532
  // wait=true is a long-poll: on 429 it should back off and keep waiting
485
533
  // rather than give up after a handful of tries (retryKind: 'pop').
486
- const result = await this.#httpClient.get(`${path}?${params}`, this.#timeoutMillis + 5000, affinityKey, this.#wait ? 'pop' : null)
534
+ const result = await this.#httpClient.get(`${path}?${params}`, this.#timeoutMillis + 5000, affinityKey, effectiveWait ? 'pop' : null)
487
535
 
488
536
  // Degrade-loudly (PLAN_CONFLATION §4), BEFORE the empty-response return:
489
537
  // an old broker's empty pop is a bodiless 204 (result === null), and that
@@ -517,8 +565,8 @@ export class QueueBuilder {
517
565
  // messages right now"; for a declared conflation it would mean "your
518
566
  // last-value policy is not in force and you will never be told", which is
519
567
  // the silent failure the feature is not allowed to have (§4). Both the
520
- // missing-echo error and the broker's 400 refusals (queue mode / autoAck)
521
- // are permanent config faults, so they raise.
568
+ // missing-echo error and the broker's 400 refusals (queue mode /
569
+ // commitOnDelivery) are permanent config faults, so they raise.
522
570
  if (error.code === CONFLATION_UNSUPPORTED || (this.#conflation && error.status === 400)) {
523
571
  logger.error('QueueBuilder.pop', { error: error.message, status: error.status, code: error.code, conflation: true })
524
572
  throw error
@@ -550,17 +598,18 @@ export class QueueBuilder {
550
598
 
551
599
  // NOTE: a second, DEAD copy of the pop parameter builder lived here and was
552
600
  // deleted with the kv/timers work (PLAN_KV_TIMERS.md §10.4). pop() builds its
553
- // own params inline, above, because it has to override autoAck with the POP
554
- // defaults; the dead copy did not. Anyone adding a parameter by looking for
555
- // the method whose name says "build pop params" would have added it to the
556
- // copy nobody calls: the pop would keep working and the parameter would
557
- // simply never arrive, which reads as a server-side mystery and not as a
558
- // client bug.
601
+ // own params inline, above, because it uses the POP defaults and carries
602
+ // commitOnDelivery, which consume() refuses. Anyone adding a parameter by
603
+ // looking for the method whose name says "build pop params" would have added
604
+ // it to the copy nobody calls: the pop would keep working and the parameter
605
+ // would simply never arrive, which reads as a server-side mystery and not as
606
+ // a client bug.
559
607
  //
560
608
  // The pair that is still live and MUST be kept in sync is pop()'s inline
561
609
  // params above and ConsumerManager#buildParams: every pop query parameter
562
610
  // (subscriptionMode, subscriptionFrom, partitions, conflation, ...) has to be
563
- // appended in BOTH, because pop() and consume() share no builder.
611
+ // appended in BOTH, because pop() and consume() share no builder. The one
612
+ // exception is commitOnDelivery's autoAck=true: consume() throws instead.
564
613
 
565
614
  // ===========================
566
615
  // Buffer Management Methods
@@ -7,6 +7,20 @@ import { checkConflationResponse, CONFLATION_UNSUPPORTED } from '../utils/confla
7
7
  import { popSizing, parseAutopilotDecision, emptyPollDelayMillis } from '../utils/autopilot.js'
8
8
  import { CONSUME_DEFAULTS } from '../utils/defaults.js'
9
9
 
10
+ /** Wait `ms`, or less if the consumer is stopped meanwhile: the loop checks the signal next. */
11
+ function pause(ms, signal) {
12
+ if (signal?.aborted) return Promise.resolve()
13
+ return new Promise(resolve => {
14
+ const done = () => {
15
+ clearTimeout(timer)
16
+ signal?.removeEventListener('abort', done)
17
+ resolve()
18
+ }
19
+ const timer = setTimeout(done, ms)
20
+ signal?.addEventListener('abort', done, { once: true })
21
+ })
22
+ }
23
+
10
24
  export class ConsumerManager {
11
25
  #httpClient
12
26
  #queen
@@ -160,7 +174,10 @@ export class ConsumerManager {
160
174
  // a long-poll: mark it 'pop' so a 429 backs off and keeps waiting
161
175
  // instead of giving up after the bounded push-like attempt budget.
162
176
  const clientTimeout = wait ? timeoutMillis + 5000 : timeoutMillis
163
- const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey, wait ? 'pop' : null)
177
+ // The signal closes the poll as well: a broker hands nothing to a poll
178
+ // whose caller is gone, so a stopped consumer is never given a message
179
+ // it would only sit on until the lease expires.
180
+ const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey, wait ? 'pop' : null, signal)
164
181
 
165
182
  // Degrade-loudly (PLAN_CONFLATION §4). Checked BEFORE the empty-response
166
183
  // branch on purpose: a pre-1.1.0 broker answers an empty pop with a
@@ -181,7 +198,7 @@ export class ConsumerManager {
181
198
  // pop engaged autopilot and the broker had an opinion (it knows the
182
199
  // arrival rate on this queue and this client does not), otherwise
183
200
  // the historical 100ms.
184
- await new Promise(resolve => setTimeout(resolve, emptyPollDelayMillis(parseAutopilotDecision(result))))
201
+ await pause(emptyPollDelayMillis(parseAutopilotDecision(result)), signal)
185
202
  continue
186
203
  }
187
204
  }
@@ -211,23 +228,35 @@ export class ConsumerManager {
211
228
  try {
212
229
  // Process messages
213
230
  if (each) {
214
- // Process one at a time
215
- for (const message of messages) {
216
- if (signal && signal.aborted) break
231
+ // Process one at a time. A nack releases the failed message's
232
+ // partition and clamps that partition's cursor at it: the later
233
+ // messages of THAT partition will be redelivered, so handling them
234
+ // now would only produce duplicates and rejected acks. The other
235
+ // partitions of a multi-partition pop are still leased to this
236
+ // worker, so their messages are handled now, not after the lease.
237
+ const nackedPartitions = new Set()
238
+ for (const [i, message] of messages.entries()) {
239
+ // Stopped, or the limit reached, with messages still in hand:
240
+ // give them back rather than leave them leased. Those of a
241
+ // nacked partition were already given back by the nack.
242
+ if ((signal && signal.aborted) || (limit && processedCount >= limit)) {
243
+ const unstarted = messages.slice(i).filter(m => !nackedPartitions.has(m.partitionId ?? m.partition))
244
+ if (unstarted.length > 0) {
245
+ await this.#releaseUnstarted(unstarted, group, signal && signal.aborted ? 'aborted' : 'limit-reached')
246
+ }
247
+ break
248
+ }
249
+
250
+ const partition = message.partitionId ?? message.partition
251
+ if (nackedPartitions.has(partition)) continue
217
252
 
218
253
  const ok = await this.#processMessage(message, handler, autoAck, group)
219
254
  processedCount++
220
255
 
221
- // A nack releases the lease and clamps the server cursor at the
222
- // failed message: everything after it in this popped batch WILL
223
- // be redelivered. Processing it now would only produce duplicates
224
- // and rejected acks — abandon the rest of the batch.
225
256
  if (!ok) {
226
- logger.warn('ConsumerManager.worker', { workerId, status: 'batch-abandoned-after-nack', remaining: messages.length - messages.indexOf(message) - 1 })
227
- break
257
+ nackedPartitions.add(partition)
258
+ logger.warn('ConsumerManager.worker', { workerId, status: 'partition-abandoned-after-nack', partition })
228
259
  }
229
-
230
- if (limit && processedCount >= limit) break
231
260
  }
232
261
  } else {
233
262
  // Process as batch
@@ -244,6 +273,13 @@ export class ConsumerManager {
244
273
  }
245
274
 
246
275
  } catch (error) {
276
+ // Stopped while a poll was open: the poll was closed, nothing was
277
+ // taken, the worker is done. Not an error, whatever the wait mode.
278
+ if (error.aborted || (signal && signal.aborted)) {
279
+ logger.log('ConsumerManager.worker', { workerId, status: 'aborted', processedCount })
280
+ break
281
+ }
282
+
247
283
  // Conflation faults are terminal and are classified FIRST, ahead of the
248
284
  // message-substring heuristics below: a consumer that asked for
249
285
  // last-value delivery and is not getting it must stop, not retry
@@ -273,7 +309,7 @@ export class ConsumerManager {
273
309
  ? error.retryAfterSeconds * 1000
274
310
  : 1000
275
311
  logger.warn('ConsumerManager.worker', { workerId, status: 'rate-limited', code: error.code, retryAfterMs })
276
- await new Promise(resolve => setTimeout(resolve, retryAfterMs))
312
+ await pause(retryAfterMs, signal)
277
313
  continue
278
314
  }
279
315
 
@@ -285,7 +321,7 @@ export class ConsumerManager {
285
321
  if (isNetworkError) {
286
322
  logger.warn('ConsumerManager.worker', { workerId, error: 'network', message: error.message })
287
323
  // Wait before retry
288
- await new Promise(resolve => setTimeout(resolve, 1000))
324
+ await pause(1000, signal)
289
325
  continue
290
326
  }
291
327
 
@@ -334,6 +370,26 @@ export class ConsumerManager {
334
370
  return true
335
371
  }
336
372
 
373
+ /**
374
+ * Hand back messages this worker holds but will not process (it was stopped,
375
+ * or reached its limit): a `retry` ack releases their lease, the broker
376
+ * redelivers them first, in order, and charges no retry. Best effort -- a
377
+ * release that fails leaves the message to its lease, as before.
378
+ */
379
+ async #releaseUnstarted(messages, group, reason) {
380
+ const subject = { count: messages.length, transactionIds: messages.map(m => m.transactionId) }
381
+ try {
382
+ const res = await this.#queen.ack(messages, 'retry', group ? { group } : {})
383
+ if (res && res.success === false) {
384
+ logger.warn('ConsumerManager.release', { ...subject, reason, status: 'release-rejected', error: res.error })
385
+ } else {
386
+ logger.log('ConsumerManager.release', { ...subject, reason, status: 'released' })
387
+ }
388
+ } catch (error) {
389
+ logger.warn('ConsumerManager.release', { ...subject, reason, status: 'release-failed', error: error.message })
390
+ }
391
+ }
392
+
337
393
  async #processBatch(messages, handler, autoAck, group) {
338
394
  try {
339
395
  await handler(messages)
@@ -17,12 +17,13 @@
17
17
  * history to have.
18
18
  *
19
19
  * DELIVERY IS NOT "AT MOST ONCE" (§1.3), and the docs must not say it is. The
20
- * class picks what can be LOST; the ack mode picks the guarantee. `autoAck`
21
- * advances the cursor at delivery and is at-most-once. The default -- explicit
22
- * ack -- is at-least-once for as long as the owning broker incarnation lives:
23
- * an unacked message redelivers when its lease expires, with `attempts`
24
- * incremented, until `retryLimit`, after which it is DROPPED and counted (no
25
- * DLQ, §9). Consumers still need idempotency, exactly as on durable queues.
20
+ * class picks what can be LOST; the ack mode picks the guarantee.
21
+ * `commitOnDelivery` advances the cursor at delivery and is at-most-once. The
22
+ * default -- explicit ack -- is at-least-once for as long as the owning broker
23
+ * incarnation lives: an unacked message redelivers when its lease expires,
24
+ * with `attempts` incremented, until `retryLimit`, after which it is DROPPED
25
+ * and counted (no DLQ, §9). Consumers still need idempotency, exactly as on
26
+ * durable queues.
26
27
  *
27
28
  * CONSUMPTION SEMANTICS COME FROM THE GROUP, EXACTLY AS ON THE DURABLE ENGINE
28
29
  * (§1.5). There is no queue-level mode to choose:
@@ -404,7 +405,23 @@ export class Ephemeral {
404
405
  *
405
406
  * `group` is the whole of the consumption semantics (§1.5): same group =
406
407
  * competing consumers, own group = fan-out, no group = queue mode.
407
- * `autoAck:true` commits at delivery and is at-most-once.
408
+ *
409
+ * `commitOnDelivery:true` commits at delivery: the broker moves the group's
410
+ * cursor past the messages as it hands them out, with no lease and nothing
411
+ * to ack. That is at-most-once: a crash after the pop loses them. It
412
+ * travels as `autoAck=true`, the parameter the broker reads.
413
+ *
414
+ * @param {string} queue
415
+ * @param {object} [opts]
416
+ * @param {string} [opts.partition]
417
+ * @param {number} [opts.batch]
418
+ * @param {boolean} [opts.wait]
419
+ * @param {number} [opts.timeout] Milliseconds; `timeoutMillis` is the same.
420
+ * @param {string} [opts.group]
421
+ * @param {boolean} [opts.commitOnDelivery] Commit at delivery (at-most-once).
422
+ * @param {boolean} [opts.autoAck] Deprecated: the old name of
423
+ * `commitOnDelivery`, still read with the same meaning. Pass one or the
424
+ * other, not both.
408
425
  */
409
426
  async pop(queue, opts = {}) {
410
427
  requireQueue(queue)
@@ -413,6 +430,7 @@ export class Ephemeral {
413
430
  const group = opts.group ?? null
414
431
  const wait = opts.wait === true
415
432
  const timeoutMillis = resolveTimeout(opts)
433
+ const commitOnDelivery = resolveCommitOnDelivery(opts)
416
434
 
417
435
  const params = new URLSearchParams({ queue })
418
436
  if (partition !== null) params.append('partition', partition)
@@ -424,7 +442,7 @@ export class Ephemeral {
424
442
  params.append('timeout', String(timeoutMillis))
425
443
  }
426
444
  if (group !== null) params.append('group', group)
427
- if (opts.autoAck === true) params.append('autoAck', 'true')
445
+ if (commitOnDelivery) params.append('autoAck', 'true')
428
446
 
429
447
  logger.log('Ephemeral.pop', { queue, partition, group, batch: opts.batch ?? null, wait })
430
448
 
@@ -547,3 +565,17 @@ function resolveTimeout(opts) {
547
565
  if (hasMillis) return opts.timeoutMillis
548
566
  return DEFAULT_WAIT_TIMEOUT_MILLIS
549
567
  }
568
+
569
+ /**
570
+ * The pop's commit at delivery, from `commitOnDelivery` or its deprecated
571
+ * alias `autoAck`. Both spellings at once are refused, as for the timeout: two
572
+ * values for one setting would have to be resolved by a rule nobody reads.
573
+ */
574
+ function resolveCommitOnDelivery(opts) {
575
+ const hasName = opts.commitOnDelivery !== undefined && opts.commitOnDelivery !== null
576
+ const hasAlias = opts.autoAck !== undefined && opts.autoAck !== null
577
+ if (hasName && hasAlias) {
578
+ throw new Error('ephemeral: pass either `commitOnDelivery` or its deprecated alias `autoAck`, not both')
579
+ }
580
+ return (hasName ? opts.commitOnDelivery : opts.autoAck) === true
581
+ }