queen-mq 2.0.0 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -5
- package/client-v2/Queen.js +2 -2
- package/client-v2/README.md +8 -2
- package/client-v2/admin/Admin.js +30 -8
- package/client-v2/builders/QueueBuilder.js +73 -24
- package/client-v2/consumer/ConsumerManager.js +71 -15
- package/client-v2/ephemeral/Ephemeral.js +40 -8
- package/client-v2/http/HttpClient.js +82 -13
- package/client-v2/utils/defaults.js +3 -2
- package/package.json +2 -2
- package/test-v2/admin-unit/removedRoutes.test.js +63 -0
- package/test-v2/consumer-unit/nackScope.test.js +88 -0
- package/test-v2/consumer-unit/stopOnAbort.test.js +537 -0
- package/test-v2/ephemeral-unit/ephemeralWire.test.js +24 -1
- package/test-v2/pop-unit/popDefaults.test.js +236 -0
- package/test-v2/pop.js +68 -0
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** —
|
|
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
|
-
`.
|
|
196
|
-
|
|
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
|
|
285
|
-
broker redelivers
|
|
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
|
package/client-v2/Queen.js
CHANGED
|
@@ -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 `
|
|
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.
|
package/client-v2/README.md
CHANGED
|
@@ -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:
|
|
1919
|
-
autoAck: false
|
|
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
|
|
package/client-v2/admin/Admin.js
CHANGED
|
@@ -101,15 +101,26 @@ export class Admin {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
*
|
|
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] -
|
|
107
|
-
* @returns {Promise<
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
*
|
|
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<
|
|
197
|
+
* @returns {Promise<never>}
|
|
179
198
|
*/
|
|
180
199
|
async moveMessageToDLQ(partitionId, transactionId) {
|
|
181
200
|
logger.log('Admin.moveMessageToDLQ', { partitionId, transactionId })
|
|
182
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
348
|
-
* "the newest state is definitely processed" guarantee into
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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,
|
|
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 /
|
|
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
|
|
554
|
-
//
|
|
555
|
-
// the method whose name says "build pop params" would have added
|
|
556
|
-
// copy nobody calls: the pop would keep working and the parameter
|
|
557
|
-
// simply never arrive, which reads as a server-side mystery and not as
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
|
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
|
|
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.
|
|
21
|
-
* advances the cursor at delivery and is at-most-once. The
|
|
22
|
-
* ack -- is at-least-once for as long as the owning broker
|
|
23
|
-
* an unacked message redelivers when its lease expires,
|
|
24
|
-
* incremented, until `retryLimit`, after which it is DROPPED
|
|
25
|
-
* DLQ, §9). Consumers still need idempotency, exactly as on
|
|
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
|
-
*
|
|
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 (
|
|
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
|
+
}
|