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.
- package/README.md +99 -21
- package/client-v2/Queen.js +47 -15
- package/client-v2/README.md +34 -13
- package/client-v2/admin/Admin.js +30 -55
- package/client-v2/builders/QueueBuilder.js +106 -34
- package/client-v2/builders/TimerBuilder.js +2 -2
- package/client-v2/builders/TransactionBuilder.js +35 -9
- package/client-v2/consumer/ConsumerManager.js +79 -53
- package/client-v2/ephemeral/Ephemeral.js +47 -17
- package/client-v2/kv/Kv.js +4 -4
- package/client-v2/kv/expiry.js +1 -1
- package/client-v2/streams/Stream.js +8 -3
- package/client-v2/streams/helpers/rateLimiter.js +4 -4
- package/client-v2/streams/operators/GateOperator.js +9 -2
- package/client-v2/streams/operators/ReduceOperator.js +2 -2
- package/client-v2/streams/operators/WindowSessionOperator.js +3 -3
- package/client-v2/streams/runtime/Runner.js +111 -54
- package/client-v2/streams/runtime/cycle.js +4 -4
- package/client-v2/streams/runtime/register.js +1 -1
- package/client-v2/utils/conflation.js +0 -6
- package/client-v2/utils/consumerGroup.js +54 -0
- package/client-v2/utils/defaults.js +3 -2
- package/package.json +5 -8
- package/test-v2/_kvtimers.js +12 -13
- package/test-v2/ackwindow.js +12 -192
- package/test-v2/admin-unit/removedRoutes.test.js +63 -0
- package/test-v2/bootstrap.js +2 -2
- package/test-v2/conflation-unit/conflationWire.test.js +0 -12
- package/test-v2/consume.js +41 -1
- package/test-v2/consumer-unit/handlerError.test.js +161 -0
- package/test-v2/consumer-unit/nackScope.test.js +88 -0
- package/test-v2/docs.js +5 -4
- package/test-v2/ephemeral-unit/ephemeralWire.test.js +24 -1
- package/test-v2/http-unit/pushStatus.test.js +142 -0
- package/test-v2/http-unit/renew.test.js +96 -0
- package/test-v2/kv-unit/timerWire.test.js +1 -1
- package/test-v2/kv-unit/txnWire.test.js +101 -2
- package/test-v2/kv.js +6 -5
- package/test-v2/pop-unit/popDefaults.test.js +236 -0
- package/test-v2/pop.js +96 -1
- package/test-v2/run.js +50 -114
- package/test-v2/runner-unit/fatalExit.test.js +64 -0
- package/test-v2/semantics.js +20 -34
- package/test-v2/stream/_helpers.js +10 -19
- package/test-v2/stream/cron.js +1 -1
- package/test-v2/stream/gate.js +54 -0
- package/test-v2/stream/index.js +2 -0
- package/test-v2/stream/tumbling.js +3 -3
- package/test-v2/streams-unit/ack.test.js +61 -0
- package/test-v2/streams-unit/cycle.test.js +1 -1
- package/test-v2/streams-unit/e2e.test.js +6 -10
- package/test-v2/streams-unit/gate.test.js +189 -0
- package/test-v2/timers.js +6 -5
- package/test-v2/transaction.js +169 -0
- package/test-v2/watermark.js +38 -176
- 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
|
-
|
|
41
|
-
|
|
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
|
|
340
|
-
* "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 [].
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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,
|
|
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 /
|
|
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
|
|
546
|
-
//
|
|
547
|
-
// the method whose name says "build pop params" would have added
|
|
548
|
-
// copy nobody calls: the pop would keep working and the parameter
|
|
549
|
-
// simply never arrive, which reads as a server-side mystery and not as
|
|
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 (
|
|
911
|
+
if (status === 'duplicate') {
|
|
854
912
|
duplicates.push({ ...originalItem, result })
|
|
855
|
-
} else if (
|
|
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 (
|
|
868
|
-
|
|
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 (
|
|
878
|
-
|
|
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
|
|
28
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
if (
|
|
77
|
-
operation.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
|
|
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
|
|
307
|
-
// to match the message
|
|
308
|
-
//
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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
|
|
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.
|
|
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:
|
|
@@ -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
|
|
256
|
-
* configuration survives a restart, the contents
|
|
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
|
|
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
|
-
*
|
|
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
|
|
|
@@ -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
|
|
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
|
+
}
|