queen-mq 1.3.0 → 2.0.2

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.
Files changed (56) hide show
  1. package/README.md +99 -21
  2. package/client-v2/Queen.js +47 -15
  3. package/client-v2/README.md +34 -13
  4. package/client-v2/admin/Admin.js +30 -55
  5. package/client-v2/builders/QueueBuilder.js +106 -34
  6. package/client-v2/builders/TimerBuilder.js +2 -2
  7. package/client-v2/builders/TransactionBuilder.js +35 -9
  8. package/client-v2/consumer/ConsumerManager.js +79 -53
  9. package/client-v2/ephemeral/Ephemeral.js +47 -17
  10. package/client-v2/kv/Kv.js +4 -4
  11. package/client-v2/kv/expiry.js +1 -1
  12. package/client-v2/streams/Stream.js +8 -3
  13. package/client-v2/streams/helpers/rateLimiter.js +4 -4
  14. package/client-v2/streams/operators/GateOperator.js +9 -2
  15. package/client-v2/streams/operators/ReduceOperator.js +2 -2
  16. package/client-v2/streams/operators/WindowSessionOperator.js +3 -3
  17. package/client-v2/streams/runtime/Runner.js +111 -54
  18. package/client-v2/streams/runtime/cycle.js +4 -4
  19. package/client-v2/streams/runtime/register.js +1 -1
  20. package/client-v2/utils/conflation.js +0 -6
  21. package/client-v2/utils/consumerGroup.js +54 -0
  22. package/client-v2/utils/defaults.js +3 -2
  23. package/package.json +5 -8
  24. package/test-v2/_kvtimers.js +12 -13
  25. package/test-v2/ackwindow.js +12 -192
  26. package/test-v2/admin-unit/removedRoutes.test.js +63 -0
  27. package/test-v2/bootstrap.js +2 -2
  28. package/test-v2/conflation-unit/conflationWire.test.js +0 -12
  29. package/test-v2/consume.js +41 -1
  30. package/test-v2/consumer-unit/handlerError.test.js +161 -0
  31. package/test-v2/consumer-unit/nackScope.test.js +88 -0
  32. package/test-v2/docs.js +5 -4
  33. package/test-v2/ephemeral-unit/ephemeralWire.test.js +24 -1
  34. package/test-v2/http-unit/pushStatus.test.js +142 -0
  35. package/test-v2/http-unit/renew.test.js +96 -0
  36. package/test-v2/kv-unit/timerWire.test.js +1 -1
  37. package/test-v2/kv-unit/txnWire.test.js +101 -2
  38. package/test-v2/kv.js +6 -5
  39. package/test-v2/pop-unit/popDefaults.test.js +236 -0
  40. package/test-v2/pop.js +96 -1
  41. package/test-v2/run.js +50 -114
  42. package/test-v2/runner-unit/fatalExit.test.js +64 -0
  43. package/test-v2/semantics.js +20 -34
  44. package/test-v2/stream/_helpers.js +10 -19
  45. package/test-v2/stream/cron.js +1 -1
  46. package/test-v2/stream/gate.js +54 -0
  47. package/test-v2/stream/index.js +2 -0
  48. package/test-v2/stream/tumbling.js +3 -3
  49. package/test-v2/streams-unit/ack.test.js +61 -0
  50. package/test-v2/streams-unit/cycle.test.js +1 -1
  51. package/test-v2/streams-unit/e2e.test.js +6 -10
  52. package/test-v2/streams-unit/gate.test.js +189 -0
  53. package/test-v2/timers.js +6 -5
  54. package/test-v2/transaction.js +169 -0
  55. package/test-v2/watermark.js +38 -176
  56. package/test-v2/maintenance.js +0 -277
@@ -16,6 +16,20 @@ import { checkConflationResponse, CONFLATION_UNSUPPORTED } from '../utils/confla
16
16
  import { popSizing, parseAutopilotDecision } from '../utils/autopilot.js'
17
17
  import * as logger from '../utils/logger.js'
18
18
 
19
+ // Per-item push statuses that mean the broker took the message: `queued`
20
+ // (stored), and `buffered` (a 1.x broker wrote it to its failover file and
21
+ // replays it into storage itself). Every other status is a rejection: `error`
22
+ // from a 2.x broker, which carries no per-item message, and `failed` from a
23
+ // 1.x broker, which does. Unknown statuses count as rejections too: a status
24
+ // this client cannot read is not a message it can report as stored.
25
+ const ACCEPTED_PUSH_STATUSES = new Set(['queued', 'buffered'])
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
+
19
33
  export class QueueBuilder {
20
34
  #queen
21
35
  #httpClient
@@ -37,8 +51,14 @@ export class QueueBuilder {
37
51
  #batch = null
38
52
  #limit = CONSUME_DEFAULTS.limit
39
53
  #idleMillis = CONSUME_DEFAULTS.idleMillis
40
- #autoAck = CONSUME_DEFAULTS.autoAck
41
- #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
42
62
  #timeoutMillis = CONSUME_DEFAULTS.timeoutMillis
43
63
  #renewLease = CONSUME_DEFAULTS.renewLease
44
64
  #renewLeaseIntervalMillis = CONSUME_DEFAULTS.renewLeaseIntervalMillis
@@ -293,6 +313,12 @@ export class QueueBuilder {
293
313
  return this
294
314
  }
295
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
+ */
296
322
  autoAck(enabled) {
297
323
  this.#autoAck = enabled
298
324
  return this
@@ -336,8 +362,9 @@ export class QueueBuilder {
336
362
  * that does not echo the flag rather than draining it silently.
337
363
  *
338
364
  * Refused by the broker (400) when combined with queue mode (no consumer
339
- * group) or with autoAck, which commits at delivery and would turn the
340
- * "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 [].
341
368
  */
342
369
  conflation(enabled = true) {
343
370
  this.#conflation = !!enabled
@@ -354,6 +381,10 @@ export class QueueBuilder {
354
381
  // ===========================
355
382
 
356
383
  consume(handler, options = {}) {
384
+ if (this.#commitOnDelivery) {
385
+ throw new Error(COMMIT_ON_DELIVERY_NOT_FOR_CONSUME)
386
+ }
387
+
357
388
  const consumeOptions = {
358
389
  queue: this.#queueName,
359
390
  partition: this.#partition !== 'Default' ? this.#partition : null,
@@ -364,8 +395,8 @@ export class QueueBuilder {
364
395
  batch: this.#batch,
365
396
  limit: this.#limit,
366
397
  idleMillis: this.#idleMillis,
367
- autoAck: this.#autoAck,
368
- wait: this.#wait,
398
+ autoAck: this.#autoAck ?? CONSUME_DEFAULTS.autoAck,
399
+ wait: this.#wait ?? CONSUME_DEFAULTS.wait,
369
400
  timeoutMillis: this.#timeoutMillis,
370
401
  renewLease: this.#renewLease,
371
402
  renewLeaseIntervalMillis: this.#renewLeaseIntervalMillis,
@@ -389,6 +420,10 @@ export class QueueBuilder {
389
420
  // Pop Methods
390
421
  // ===========================
391
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
+ */
392
427
  wait(enabled) {
393
428
  this.#wait = enabled
394
429
  return this
@@ -408,6 +443,24 @@ export class QueueBuilder {
408
443
  return this
409
444
  }
410
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
+
411
464
  /**
412
465
  * Claim messages and report what the broker chose for this pop.
413
466
  *
@@ -429,15 +482,17 @@ export class QueueBuilder {
429
482
  }
430
483
 
431
484
  async #popWithDecision() {
432
- 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 })
433
492
 
434
493
  try {
435
494
  const path = this.#buildPopPath()
436
495
 
437
- // For pop(), use POP defaults (not CONSUME defaults)
438
- // Override autoAck to false unless explicitly set
439
- const effectiveAutoAck = this.#autoAck !== CONSUME_DEFAULTS.autoAck ? this.#autoAck : POP_DEFAULTS.autoAck
440
-
441
496
  // Batch, partitions and with them the autopilot flag. The RULE for which
442
497
  // of the three travel lives in one place (utils/autopilot.js) because
443
498
  // consume() builds its query string separately; only the PLACEMENT is
@@ -450,17 +505,18 @@ export class QueueBuilder {
450
505
  autopilot: this.#autopilotEnabled()
451
506
  })
452
507
 
453
- // Build params with correct autoAck for pop
454
508
  const params = new URLSearchParams()
455
509
  if (sizing.autopilot) params.append('autopilot', 'true')
456
510
  if (sizing.batch !== null) params.append('batch', sizing.batch)
457
- params.append('wait', this.#wait.toString())
511
+ params.append('wait', effectiveWait.toString())
458
512
  params.append('timeout', this.#timeoutMillis.toString())
459
513
 
460
514
  if (this.#group) params.append('consumerGroup', this.#group)
461
515
  if (this.#namespace) params.append('namespace', this.#namespace)
462
516
  if (this.#task) params.append('task', this.#task)
463
- 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')
464
520
  if (this.#subscriptionMode) params.append('subscriptionMode', this.#subscriptionMode)
465
521
  if (this.#subscriptionFrom) params.append('subscriptionFrom', this.#subscriptionFrom)
466
522
  if (sizing.partitions !== null) params.append('partitions', sizing.partitions)
@@ -475,7 +531,7 @@ export class QueueBuilder {
475
531
 
476
532
  // wait=true is a long-poll: on 429 it should back off and keep waiting
477
533
  // rather than give up after a handful of tries (retryKind: 'pop').
478
- 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)
479
535
 
480
536
  // Degrade-loudly (PLAN_CONFLATION §4), BEFORE the empty-response return:
481
537
  // an old broker's empty pop is a bodiless 204 (result === null), and that
@@ -509,8 +565,8 @@ export class QueueBuilder {
509
565
  // messages right now"; for a declared conflation it would mean "your
510
566
  // last-value policy is not in force and you will never be told", which is
511
567
  // the silent failure the feature is not allowed to have (§4). Both the
512
- // missing-echo error and the broker's 400 refusals (queue mode / autoAck)
513
- // 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.
514
570
  if (error.code === CONFLATION_UNSUPPORTED || (this.#conflation && error.status === 400)) {
515
571
  logger.error('QueueBuilder.pop', { error: error.message, status: error.status, code: error.code, conflation: true })
516
572
  throw error
@@ -542,17 +598,18 @@ export class QueueBuilder {
542
598
 
543
599
  // NOTE: a second, DEAD copy of the pop parameter builder lived here and was
544
600
  // deleted with the kv/timers work (PLAN_KV_TIMERS.md §10.4). pop() builds its
545
- // own params inline, above, because it has to override autoAck with the POP
546
- // defaults; the dead copy did not. Anyone adding a parameter by looking for
547
- // the method whose name says "build pop params" would have added it to the
548
- // copy nobody calls: the pop would keep working and the parameter would
549
- // simply never arrive, which reads as a server-side mystery and not as a
550
- // 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.
551
607
  //
552
608
  // The pair that is still live and MUST be kept in sync is pop()'s inline
553
609
  // params above and ConsumerManager#buildParams: every pop query parameter
554
610
  // (subscriptionMode, subscriptionFrom, partitions, conflation, ...) has to be
555
- // 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.
556
613
 
557
614
  // ===========================
558
615
  // Buffer Management Methods
@@ -849,24 +906,40 @@ class PushBuilder {
849
906
  for (let i = 0; i < results.length; i++) {
850
907
  const result = results[i]
851
908
  const originalItem = this.#formattedItems[i]
909
+ const status = result && typeof result === 'object' ? result.status : undefined
852
910
 
853
- if (result.status === 'duplicate') {
911
+ if (status === 'duplicate') {
854
912
  duplicates.push({ ...originalItem, result })
855
- } else if (result.status === 'failed') {
856
- failed.push({ ...originalItem, result, error: result.error })
857
- } else if (result.status === 'queued') {
913
+ } else if (ACCEPTED_PUSH_STATUSES.has(status)) {
858
914
  successful.push({ ...originalItem, result })
915
+ } else {
916
+ const error = result && typeof result.error === 'string' && result.error.length > 0
917
+ ? result.error
918
+ : `push rejected by the broker (item status: ${status === undefined ? 'missing' : JSON.stringify(status)})`
919
+ failed.push({ ...originalItem, result, error })
859
920
  }
860
921
  }
861
922
 
923
+ // One error for the whole call, built once so the callback and the
924
+ // throw describe the failure the same way. `results` is the broker's
925
+ // per-item answer in input order: the items that did go through are
926
+ // in it too, which is what a caller needs to retry only the rest.
927
+ let failure = null
928
+ if (failed.length > 0) {
929
+ failure = new Error(failed.length === 1
930
+ ? failed[0].error
931
+ : `${failed.length} of ${results.length} pushed items rejected: ${failed[0].error}`)
932
+ failure.results = results
933
+ logger.error('PushBuilder.execute', { status: 'failed', count: failed.length, total: results.length, error: failed[0].error })
934
+ }
935
+
862
936
  // Call appropriate callbacks
863
937
  if (duplicates.length > 0 && this.#onDuplicateCallback) {
864
938
  await this.#onDuplicateCallback(duplicates, new Error('Duplicate transaction IDs detected'))
865
939
  }
866
940
 
867
- if (failed.length > 0 && this.#onErrorCallback) {
868
- const error = new Error(failed[0].error || 'Push failed')
869
- await this.#onErrorCallback(failed, error)
941
+ if (failure && this.#onErrorCallback) {
942
+ await this.#onErrorCallback(failed, failure)
870
943
  }
871
944
 
872
945
  if (successful.length > 0 && this.#onSuccessCallback) {
@@ -874,9 +947,8 @@ class PushBuilder {
874
947
  }
875
948
 
876
949
  // Only throw if no error callback is defined
877
- if (failed.length > 0 && !this.#onErrorCallback) {
878
- logger.error('PushBuilder.execute', { status: 'failed', count: failed.length })
879
- throw new Error(failed[0].error || 'Push failed')
950
+ if (failure && !this.#onErrorCallback) {
951
+ throw failure
880
952
  }
881
953
 
882
954
  logger.log('PushBuilder.execute', { status: 'success', successful: successful.length, duplicates: duplicates.length, failed: failed.length })
@@ -24,8 +24,8 @@
24
24
  * bundle's fate; see TransactionBuilder, which says so where it happens.)
25
25
  *
26
26
  * 2. ONLY RELATIVE DURATIONS, IN MILLISECONDS (§4.2, §20.6). `delayMs`, never
27
- * `delaySeconds` and never an absolute instant: `deliver_at` is computed in
28
- * Postgres, so there is ONE clock and no broker's skew can enter. The rule
27
+ * `delaySeconds` and never an absolute instant: `deliver_at` is computed on
28
+ * the broker's clock, so there is ONE clock and no client's skew can enter. The rule
29
29
  * of the product is "durations that can be sub-second are in milliseconds,
30
30
  * the ones that cannot are in seconds" -- a 250 ms retry backoff is a real
31
31
  * and central use of timers, which is why this wire is the millisecond one.
@@ -30,6 +30,7 @@ import { generateUUID } from './QueueBuilder.js'
30
30
  import { isValidUUID } from '../utils/validation.js'
31
31
  import { kvOp, materializeKvOp } from '../kv/Kv.js'
32
32
  import { TimerBuilder } from './TimerBuilder.js'
33
+ import { consumerGroupOf } from '../utils/consumerGroup.js'
33
34
 
34
35
  export class TransactionBuilder {
35
36
  #httpClient
@@ -46,10 +47,21 @@ export class TransactionBuilder {
46
47
  this.#httpClient = httpClient
47
48
  }
48
49
 
50
+ /**
51
+ * Ack popped messages as part of this transaction.
52
+ *
53
+ * The consumer group defaults to the one each message was popped under
54
+ * (`message.consumerGroup`, which every pop answers with), because the lease
55
+ * an ack has to match belongs to that group: an ack that names no group is
56
+ * judged in queue mode, and a message popped by a group then fails with
57
+ * `rejected_ack`. Pass `{ consumerGroup }` (or `{ group }`, the key
58
+ * `queen.ack()` takes) to override it.
59
+ */
49
60
  ack(messages, status = 'completed', context = {}) {
50
61
  const msgs = Array.isArray(messages) ? messages : [messages]
51
-
52
- logger.log('TransactionBuilder.ack', { count: msgs.length, status, consumerGroup: context.consumerGroup })
62
+ const explicitGroup = context.consumerGroup || context.group || null
63
+
64
+ logger.log('TransactionBuilder.ack', { count: msgs.length, status, consumerGroup: explicitGroup })
53
65
 
54
66
  msgs.forEach(msg => {
55
67
  const transactionId = typeof msg === 'string' ? msg : (msg.transactionId || msg.id)
@@ -72,9 +84,18 @@ export class TransactionBuilder {
72
84
  status
73
85
  }
74
86
 
75
- // Add consumerGroup if provided in context
76
- if (context.consumerGroup) {
77
- operation.consumerGroup = context.consumerGroup
87
+ const consumerGroup = explicitGroup || consumerGroupOf(msg)
88
+ if (consumerGroup) {
89
+ operation.consumerGroup = consumerGroup
90
+ }
91
+
92
+ // Each ack names its own lease. A Queen 2 broker fences an ack with the
93
+ // lease the operation carries, and lends it one from requiredLeases only
94
+ // when the bundle names a single lease: in a bundle spanning two leases,
95
+ // an ack without its own would be applied whoever holds its partition
96
+ // now. 1.x brokers read the operation's lease before requiredLeases too.
97
+ if (leaseId) {
98
+ operation.leaseId = leaseId
78
99
  }
79
100
 
80
101
  this.#operations.push(operation)
@@ -204,7 +225,7 @@ export class TransactionBuilder {
204
225
  *
205
226
  * `putIfAbsent` with `required:true`, so a marker that already exists ABORTS
206
227
  * the transaction: the push and the ack roll back together with it. That is
207
- * what makes "the email is sent exactly once" a property of the database
228
+ * what makes "the email is sent exactly once" a property of the broker
208
229
  * rather than a hope about redelivery.
209
230
  *
210
231
  * The verdict is RETURNED by `commit()`, not thrown -- see there.
@@ -303,9 +324,14 @@ export class TransactionBuilder {
303
324
 
304
325
  logger.error('TransactionBuilder.commit', { error: result.error, reason: result.reason })
305
326
  const error = new Error(result.error || 'Transaction failed')
306
- // The closed-taxonomy code travels ON the error, so no caller ever has
307
- // to match the message: bad_request | duplicate | ack_rejected |
308
- // timer_horizon_exceeded | payload_too_large | misaligned | db_error.
327
+ // The broker's reason code travels ON the error, so no caller ever has
328
+ // to match the message. The ones worth branching on from a 2.x broker:
329
+ // bad_request, duplicate (a pushed transactionId is already stored),
330
+ // rejected_ack (an acked message is no longer leased by this worker, or
331
+ // was popped under a different consumer group than the ack names), and
332
+ // too_large. A 1.x broker spells the ack one ack_rejected, and also
333
+ // sends timer_horizon_exceeded, payload_too_large, misaligned and
334
+ // db_error.
309
335
  if (result.reason) error.reason = result.reason
310
336
  error.result = result
311
337
  throw error
@@ -211,20 +211,25 @@ export class ConsumerManager {
211
211
  try {
212
212
  // Process messages
213
213
  if (each) {
214
- // Process one at a time
214
+ // Process one at a time. A nack releases the failed message's
215
+ // partition and clamps that partition's cursor at it: the later
216
+ // messages of THAT partition will be redelivered, so handling them
217
+ // now would only produce duplicates and rejected acks. The other
218
+ // partitions of a multi-partition pop are still leased to this
219
+ // worker, so their messages are handled now, not after the lease.
220
+ const nackedPartitions = new Set()
215
221
  for (const message of messages) {
216
222
  if (signal && signal.aborted) break
217
223
 
224
+ const partition = message.partitionId ?? message.partition
225
+ if (nackedPartitions.has(partition)) continue
226
+
218
227
  const ok = await this.#processMessage(message, handler, autoAck, group)
219
228
  processedCount++
220
229
 
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
- if (autoAck && !ok) {
226
- logger.warn('ConsumerManager.worker', { workerId, status: 'batch-abandoned-after-nack', remaining: messages.length - messages.indexOf(message) - 1 })
227
- break
230
+ if (!ok) {
231
+ nackedPartitions.add(partition)
232
+ logger.warn('ConsumerManager.worker', { workerId, status: 'partition-abandoned-after-nack', partition })
228
233
  }
229
234
 
230
235
  if (limit && processedCount >= limit) break
@@ -316,62 +321,83 @@ export class ConsumerManager {
316
321
  async #processMessage(message, handler, autoAck, group) {
317
322
  try {
318
323
  await handler(message)
319
-
320
- // Auto-ack on success if enabled
321
- if (autoAck) {
322
- const context = group ? { group } : {}
323
- const res = await this.#queen.ack(message, true, context)
324
- if (res && res.success === false) {
325
- logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'ack-rejected', error: res.error })
326
- } else {
327
- logger.log('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'acked' })
328
- }
329
- }
330
- return true
331
324
  } catch (error) {
332
- // Auto-nack on error if enabled
333
- if (autoAck) {
334
- const context = group ? { group, error: error.message } : { error: error.message }
335
- const res = await this.#queen.ack(message, false, context)
336
- if (res && res.success === false) {
337
- logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'nack-rejected', error: res.error })
338
- }
339
- logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message, status: 'nacked' })
340
- // Don't rethrow when autoAck is enabled - NACK was already sent
341
- // This allows the consumer to continue and retry
342
- return false
325
+ await this.#nackFailed(message, error, autoAck, group, 'ConsumerManager.processMessage')
326
+ return false
327
+ }
328
+
329
+ // Auto-ack on success if enabled
330
+ if (autoAck) {
331
+ const context = group ? { group } : {}
332
+ const res = await this.#queen.ack(message, true, context)
333
+ if (res && res.success === false) {
334
+ logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'ack-rejected', error: res.error })
335
+ } else {
336
+ logger.log('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'acked' })
343
337
  }
344
- logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message })
345
- throw error
346
338
  }
339
+ return true
347
340
  }
348
341
 
349
342
  async #processBatch(messages, handler, autoAck, group) {
350
343
  try {
351
344
  await handler(messages)
345
+ } catch (error) {
346
+ await this.#nackFailed(messages, error, autoAck, group, 'ConsumerManager.processBatch')
347
+ return
348
+ }
352
349
 
353
- // Auto-ack on success if enabled
354
- if (autoAck) {
355
- const context = group ? { group } : {}
356
- const res = await this.#queen.ack(messages, true, context)
357
- if (res && res.success === false) {
358
- logger.error('ConsumerManager.processBatch', { count: messages.length, status: 'ack-rejected', error: res.error })
359
- } else {
360
- logger.log('ConsumerManager.processBatch', { count: messages.length, status: 'acked' })
361
- }
350
+ // Auto-ack on success if enabled
351
+ if (autoAck) {
352
+ const context = group ? { group } : {}
353
+ const res = await this.#queen.ack(messages, true, context)
354
+ if (res && res.success === false) {
355
+ logger.error('ConsumerManager.processBatch', { count: messages.length, status: 'ack-rejected', error: res.error })
356
+ } else {
357
+ logger.log('ConsumerManager.processBatch', { count: messages.length, status: 'acked' })
362
358
  }
363
- } catch (error) {
364
- // Auto-nack on error if enabled
365
- if (autoAck) {
366
- const context = group ? { group, error: error.message } : { error: error.message }
367
- await this.#queen.ack(messages, false, context)
368
- logger.error('ConsumerManager.processBatch', { count: messages.length, error: error.message, status: 'nacked' })
369
- // Don't rethrow when autoAck is enabled - NACK was already sent
370
- // This allows the consumer to continue and retry
371
- return
359
+ }
360
+ }
361
+
362
+ /**
363
+ * A handler threw: nack what it was given, and let the worker keep
364
+ * consuming. The same with `autoAck(false)`, which hands the SUCCESS path to
365
+ * the handler and not the failure path: a handler that threw did not get to
366
+ * settle its messages, and leaving them leased would hold the partition
367
+ * until the lease expires. Before this, a throw under autoAck(false) left
368
+ * the worker loop and stopped the consumer (the others kept running behind a
369
+ * consume() promise that had already rejected).
370
+ *
371
+ * The nack goes through the broker's retry budget like any other: the
372
+ * message is redelivered, and lands in the DLQ once the queue's retryLimit
373
+ * is spent. A message the handler already acked before throwing is already
374
+ * settled, so the broker refuses its nack and nothing changes for it. A
375
+ * handler that wants to decide for itself (ack, nack, DLQ, or stop)
376
+ * declares `.onError()`, which catches the error before it gets here.
377
+ */
378
+ async #nackFailed(messageOrMessages, error, autoAck, group, where) {
379
+ const batch = Array.isArray(messageOrMessages)
380
+ const subject = batch ? { count: messageOrMessages.length } : { transactionId: messageOrMessages.transactionId }
381
+ // A handler can throw anything, not only an Error.
382
+ const reason = error instanceof Error ? error.message : String(error)
383
+ logger.error(where, { ...subject, error: reason, status: 'handler-failed', autoAck })
384
+
385
+ const context = group ? { group, error: reason } : { error: reason }
386
+ try {
387
+ const res = await this.#queen.ack(messageOrMessages, false, context)
388
+ if (res && res.success === false) {
389
+ // Under autoAck(false) this is usually a handler that acked before it
390
+ // threw; with autoAck it means the lease ran out under the handler.
391
+ const log = autoAck ? logger.error : logger.warn
392
+ log(where, { ...subject, status: 'nack-rejected', error: res.error })
393
+ } else {
394
+ logger.log(where, { ...subject, status: 'nacked' })
372
395
  }
373
- logger.error('ConsumerManager.processBatch', { count: messages.length, error: error.message })
374
- throw error
396
+ } catch (nackError) {
397
+ // A message this client cannot even address (no partitionId) cannot be
398
+ // nacked; its lease expires and the broker redelivers it. Never a reason
399
+ // to stop the consumer.
400
+ logger.error(where, { ...subject, status: 'nack-failed', error: nackError.message })
375
401
  }
376
402
  }
377
403
 
@@ -11,18 +11,19 @@
11
11
  * WHAT THIS CLASS IS ABOUT, BEFORE ANY SIGNATURE: contents survive NOTHING
12
12
  * (§1.2). Not a restart, not a crash, not a deploy, not the ownership move that
13
13
  * a membership change causes. Treat a failover like a Redis restart. Declared
14
- * CONFIGURATION is durable -- it lives in PG and comes back after a restart, as
15
- * configured and EMPTY. There is no replay, no history, no subscriptionMode and
14
+ * CONFIGURATION is durable -- it lives in the broker's replicated log and comes
15
+ * back after a restart, as configured and EMPTY. There is no replay, no history, no subscriptionMode and
16
16
  * no DLQ, because none of those concepts has a referent when there is no
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:
@@ -252,9 +253,9 @@ export class Ephemeral {
252
253
  // ------------------------------------------------------------ declaration
253
254
 
254
255
  /**
255
- * Declare a queue and its bounds. Persists the OPTIONS in PG (§1.1): the
256
- * configuration survives a restart, the contents never do, and the queue
257
- * comes back declared and empty.
256
+ * Declare a queue and its bounds. Persists the OPTIONS in the broker's
257
+ * replicated log (§1.1): the configuration survives a restart, the contents
258
+ * never do, and the queue comes back declared and empty.
258
259
  *
259
260
  * Optional in every sense -- a push or a pop that names an unknown queue
260
261
  * creates it implicitly with the tenant defaults (§1.1). Declare when you
@@ -289,7 +290,7 @@ export class Ephemeral {
289
290
  return this.#call('POST', '/api/v1/ephemeral/reset', { queue }, { queue })
290
291
  }
291
292
 
292
- /** Delete the queue: contents, cursors, and the declared configuration in PG. */
293
+ /** Delete the queue: contents, cursors, and the declared configuration. */
293
294
  async delete(queue) {
294
295
  requireQueue(queue)
295
296
  logger.log('Ephemeral.delete', { queue })
@@ -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
 
@@ -482,9 +500,7 @@ export class Ephemeral {
482
500
  /**
483
501
  * Every ephemeral queue this tenant currently has, declared and implicit.
484
502
  *
485
- * Free to poll: the gauges are read out of the broker's own memory, with no
486
- * database behind them -- unlike the durable meter, whose 1s poll is
487
- * load-bearing on PG.
503
+ * Free to poll: the gauges are read out of the broker's own memory.
488
504
  */
489
505
  async queues() {
490
506
  logger.log('Ephemeral.queues', {})
@@ -549,3 +565,17 @@ function resolveTimeout(opts) {
549
565
  if (hasMillis) return opts.timeoutMillis
550
566
  return DEFAULT_WAIT_TIMEOUT_MILLIS
551
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
+ }