queen-mq 1.0.6 → 1.2.0
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 +110 -4
- package/client-v2/Queen.js +68 -0
- package/client-v2/admin/Admin.js +15 -1
- package/client-v2/buffer/BufferManager.js +18 -6
- package/client-v2/buffer/MessageBuffer.js +17 -1
- package/client-v2/buffer/sinks.js +89 -0
- package/client-v2/builders/QueueBuilder.js +175 -16
- package/client-v2/consumer/ConsumerManager.js +79 -12
- package/client-v2/ephemeral/Ephemeral.js +551 -0
- package/client-v2/index.js +12 -0
- package/client-v2/streams/Stream.js +3 -0
- package/client-v2/streams/runtime/Runner.js +26 -3
- package/client-v2/utils/autopilot.js +168 -0
- package/client-v2/utils/conflation.js +118 -0
- package/client-v2/utils/defaults.js +16 -3
- package/package.json +3 -3
- package/test-v2/autopilot-unit/autopilotWire.test.js +410 -0
- package/test-v2/conflation-unit/conflationWire.test.js +398 -0
- package/test-v2/ephemeral-unit/_planServer.js +73 -0
- package/test-v2/ephemeral-unit/durableSinkPin.test.js +112 -0
- package/test-v2/ephemeral-unit/ephemeralBuffer.test.js +305 -0
- package/test-v2/ephemeral-unit/ephemeralWire.test.js +398 -0
|
@@ -10,7 +10,10 @@ export const generateUUID = () => {
|
|
|
10
10
|
|
|
11
11
|
//import { generateUUID } from '../../utils/uuid.js'
|
|
12
12
|
import { isValidUUID } from '../utils/validation.js'
|
|
13
|
+
import { durableAddress } from '../buffer/sinks.js'
|
|
13
14
|
import { QUEUE_DEFAULTS, CONSUME_DEFAULTS, POP_DEFAULTS } from '../utils/defaults.js'
|
|
15
|
+
import { checkConflationResponse, CONFLATION_UNSUPPORTED } from '../utils/conflation.js'
|
|
16
|
+
import { popSizing, parseAutopilotDecision } from '../utils/autopilot.js'
|
|
14
17
|
import * as logger from '../utils/logger.js'
|
|
15
18
|
|
|
16
19
|
export class QueueBuilder {
|
|
@@ -26,7 +29,12 @@ export class QueueBuilder {
|
|
|
26
29
|
|
|
27
30
|
// Consume options
|
|
28
31
|
#concurrency = CONSUME_DEFAULTS.concurrency
|
|
29
|
-
|
|
32
|
+
// batch / maxPartitions hold the USER's value, and null means the setter was
|
|
33
|
+
// never called -- which is the dimension pop autopilot gets to choose. The
|
|
34
|
+
// client-side defaults are applied at emission time (utils/autopilot.js), not
|
|
35
|
+
// here, because filling them in here would erase the difference between
|
|
36
|
+
// "never called batch()" and "called batch(1)".
|
|
37
|
+
#batch = null
|
|
30
38
|
#limit = CONSUME_DEFAULTS.limit
|
|
31
39
|
#idleMillis = CONSUME_DEFAULTS.idleMillis
|
|
32
40
|
#autoAck = CONSUME_DEFAULTS.autoAck
|
|
@@ -36,8 +44,12 @@ export class QueueBuilder {
|
|
|
36
44
|
#renewLeaseIntervalMillis = CONSUME_DEFAULTS.renewLeaseIntervalMillis
|
|
37
45
|
#subscriptionMode = CONSUME_DEFAULTS.subscriptionMode
|
|
38
46
|
#subscriptionFrom = CONSUME_DEFAULTS.subscriptionFrom
|
|
47
|
+
#conflation = CONSUME_DEFAULTS.conflation
|
|
39
48
|
#each = false
|
|
40
|
-
#maxPartitions =
|
|
49
|
+
#maxPartitions = null
|
|
50
|
+
// Per-call override for pop autopilot: null = the client default (on unless
|
|
51
|
+
// QUEEN_SDK_POP_AUTOPILOT turned it off).
|
|
52
|
+
#autopilot = null
|
|
41
53
|
|
|
42
54
|
// Buffer options
|
|
43
55
|
#bufferOptions = null
|
|
@@ -211,8 +223,15 @@ export class QueueBuilder {
|
|
|
211
223
|
return this
|
|
212
224
|
}
|
|
213
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Pin the message budget for one pop. Leave it unset and the broker sizes it
|
|
228
|
+
* (see `autopilot`), where it used to mean the client-side default of 1.
|
|
229
|
+
*
|
|
230
|
+
* `batch(0)` is not "a batch of zero" and never was: it is the absence of an
|
|
231
|
+
* opinion, so it reads as unset.
|
|
232
|
+
*/
|
|
214
233
|
batch(size) {
|
|
215
|
-
this.#batch =
|
|
234
|
+
this.#batch = size > 0 ? size : null
|
|
216
235
|
return this
|
|
217
236
|
}
|
|
218
237
|
|
|
@@ -225,13 +244,45 @@ export class QueueBuilder {
|
|
|
225
244
|
* partitions, in a single network round-trip. All N share one leaseId
|
|
226
245
|
* (renewing once extends them all).
|
|
227
246
|
*
|
|
228
|
-
*
|
|
247
|
+
* Leave it unset and the broker chooses the sweep width (see `autopilot`);
|
|
248
|
+
* `partitions(1)` pins the legacy single-partition behaviour, which is a
|
|
249
|
+
* decision the broker is told about and never overrides.
|
|
229
250
|
*/
|
|
230
251
|
partitions(n) {
|
|
231
|
-
this.#maxPartitions =
|
|
252
|
+
this.#maxPartitions = n > 0 ? n : null
|
|
253
|
+
return this
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Turn broker-side pop sizing on or off for this builder.
|
|
258
|
+
*
|
|
259
|
+
* On (the default) the broker chooses `batch` and `partitions` for the pops
|
|
260
|
+
* of this builder. Even then, a `batch` or `partitions` set explicitly
|
|
261
|
+
* travels on the wire as it always did and is never second-guessed: autopilot
|
|
262
|
+
* only ever fills the knobs left unset.
|
|
263
|
+
*
|
|
264
|
+
* `autopilot(false)` restores this SDK's pre-1.2 behaviour byte for byte: the
|
|
265
|
+
* client-side defaults come back (batch 1, partitions 1) and no autopilot
|
|
266
|
+
* parameter is sent. QUEEN_SDK_POP_AUTOPILOT=off does the same for a whole
|
|
267
|
+
* process; an explicit call here outranks the environment in both directions.
|
|
268
|
+
*
|
|
269
|
+
* Setting BOTH batch and partitions leaves autopilot nothing to decide, so no
|
|
270
|
+
* autopilot parameter is sent in that case either, whatever this flag says.
|
|
271
|
+
*/
|
|
272
|
+
autopilot(enabled = true) {
|
|
273
|
+
this.#autopilot = !!enabled
|
|
232
274
|
return this
|
|
233
275
|
}
|
|
234
276
|
|
|
277
|
+
/**
|
|
278
|
+
* This builder's resolved autopilot decision: its own flag when set,
|
|
279
|
+
* otherwise the client-wide default settled in the Queen constructor.
|
|
280
|
+
*/
|
|
281
|
+
#autopilotEnabled() {
|
|
282
|
+
if (this.#autopilot !== null) return this.#autopilot
|
|
283
|
+
return !this.#queen || !this.#queen.autopilotOff
|
|
284
|
+
}
|
|
285
|
+
|
|
235
286
|
limit(count) {
|
|
236
287
|
this.#limit = count
|
|
237
288
|
return this
|
|
@@ -265,6 +316,34 @@ export class QueueBuilder {
|
|
|
265
316
|
return this
|
|
266
317
|
}
|
|
267
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Last-value delivery for this consumer group (PLAN_CONFLATION §1.1).
|
|
321
|
+
*
|
|
322
|
+
* A pop of a partition delivers exactly ONE message — the newest visible one
|
|
323
|
+
* — and commits past everything it skipped. For command-style queues where
|
|
324
|
+
* one partition is one logical task key ("recompute entity X"), a consumer
|
|
325
|
+
* behind a backlog then does the work once with the latest input instead of
|
|
326
|
+
* replaying every stale intermediate.
|
|
327
|
+
*
|
|
328
|
+
* It is a property of the GROUP, not of the call: it is persisted when the
|
|
329
|
+
* group first registers on the queue, and from then on the stored value wins
|
|
330
|
+
* for every consumer of that group. Declaring the opposite later does not
|
|
331
|
+
* flip it — the SDK warns once and keeps working. Default off; a group
|
|
332
|
+
* created without it behaves exactly as before.
|
|
333
|
+
*
|
|
334
|
+
* Requires broker >= 1.1.0. An older broker ignores the parameter and would
|
|
335
|
+
* quietly deliver the whole backlog, so the SDK raises on the first response
|
|
336
|
+
* that does not echo the flag rather than draining it silently.
|
|
337
|
+
*
|
|
338
|
+
* 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.
|
|
341
|
+
*/
|
|
342
|
+
conflation(enabled = true) {
|
|
343
|
+
this.#conflation = !!enabled
|
|
344
|
+
return this
|
|
345
|
+
}
|
|
346
|
+
|
|
268
347
|
each() {
|
|
269
348
|
this.#each = true
|
|
270
349
|
return this
|
|
@@ -292,8 +371,14 @@ export class QueueBuilder {
|
|
|
292
371
|
renewLeaseIntervalMillis: this.#renewLeaseIntervalMillis,
|
|
293
372
|
subscriptionMode: this.#subscriptionMode,
|
|
294
373
|
subscriptionFrom: this.#subscriptionFrom,
|
|
374
|
+
conflation: this.#conflation,
|
|
295
375
|
each: this.#each,
|
|
296
376
|
maxPartitions: this.#maxPartitions,
|
|
377
|
+
// Resolved here so ConsumerManager sees a decision and not a null. batch
|
|
378
|
+
// and maxPartitions keep their null when autopilot is on, and that null
|
|
379
|
+
// has to survive all the way to #buildParams: it is the ONLY record that
|
|
380
|
+
// the user said nothing about that dimension.
|
|
381
|
+
autopilot: this.#autopilotEnabled(),
|
|
297
382
|
signal: options.signal
|
|
298
383
|
}
|
|
299
384
|
|
|
@@ -323,7 +408,27 @@ export class QueueBuilder {
|
|
|
323
408
|
return this
|
|
324
409
|
}
|
|
325
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Claim messages and report what the broker chose for this pop.
|
|
413
|
+
*
|
|
414
|
+
* Same call as `pop()` — this is the shape that also carries the additive
|
|
415
|
+
* `autopilot` echo, which is null when this pop did not engage autopilot or
|
|
416
|
+
* the broker is older than 1.2.
|
|
417
|
+
*
|
|
418
|
+
* const { messages, autopilot } = await client.queue('events').group('w').popResult()
|
|
419
|
+
* if (autopilot) console.log(autopilot.partitions, autopilot.batch, autopilot.waitMillis)
|
|
420
|
+
*
|
|
421
|
+
* @returns {Promise<{messages: object[], autopilot: {partitions: number, batch: number, waitMillis: number}|null}>}
|
|
422
|
+
*/
|
|
423
|
+
async popResult() {
|
|
424
|
+
return this.#popWithDecision()
|
|
425
|
+
}
|
|
426
|
+
|
|
326
427
|
async pop() {
|
|
428
|
+
return (await this.#popWithDecision()).messages
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async #popWithDecision() {
|
|
327
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 })
|
|
328
433
|
|
|
329
434
|
try {
|
|
@@ -333,20 +438,37 @@ export class QueueBuilder {
|
|
|
333
438
|
// Override autoAck to false unless explicitly set
|
|
334
439
|
const effectiveAutoAck = this.#autoAck !== CONSUME_DEFAULTS.autoAck ? this.#autoAck : POP_DEFAULTS.autoAck
|
|
335
440
|
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
441
|
+
// Batch, partitions and with them the autopilot flag. The RULE for which
|
|
442
|
+
// of the three travel lives in one place (utils/autopilot.js) because
|
|
443
|
+
// consume() builds its query string separately; only the PLACEMENT is
|
|
444
|
+
// here, and it is the pre-autopilot placement so an autopilot-off request
|
|
445
|
+
// is byte-identical to the one this SDK used to send.
|
|
446
|
+
const sizing = popSizing({
|
|
447
|
+
batch: this.#batch,
|
|
448
|
+
maxPartitions: this.#maxPartitions,
|
|
449
|
+
fallbackBatch: POP_DEFAULTS.batch,
|
|
450
|
+
autopilot: this.#autopilotEnabled()
|
|
341
451
|
})
|
|
342
452
|
|
|
453
|
+
// Build params with correct autoAck for pop
|
|
454
|
+
const params = new URLSearchParams()
|
|
455
|
+
if (sizing.autopilot) params.append('autopilot', 'true')
|
|
456
|
+
if (sizing.batch !== null) params.append('batch', sizing.batch)
|
|
457
|
+
params.append('wait', this.#wait.toString())
|
|
458
|
+
params.append('timeout', this.#timeoutMillis.toString())
|
|
459
|
+
|
|
343
460
|
if (this.#group) params.append('consumerGroup', this.#group)
|
|
344
461
|
if (this.#namespace) params.append('namespace', this.#namespace)
|
|
345
462
|
if (this.#task) params.append('task', this.#task)
|
|
346
463
|
if (effectiveAutoAck) params.append('autoAck', 'true')
|
|
347
464
|
if (this.#subscriptionMode) params.append('subscriptionMode', this.#subscriptionMode)
|
|
348
465
|
if (this.#subscriptionFrom) params.append('subscriptionFrom', this.#subscriptionFrom)
|
|
349
|
-
if (
|
|
466
|
+
if (sizing.partitions !== null) params.append('partitions', sizing.partitions)
|
|
467
|
+
// Conflation (PLAN_CONFLATION §3.1): sent ONLY when true, so an
|
|
468
|
+
// undeclared pop is byte-identical to today. NOTE: this is the pop
|
|
469
|
+
// builder; consume() builds its params in ConsumerManager#buildParams —
|
|
470
|
+
// see the comment below #buildPopPath about exactly this hazard.
|
|
471
|
+
if (this.#conflation) params.append('conflation', 'true')
|
|
350
472
|
|
|
351
473
|
// Generate affinity key for consistent routing to same backend
|
|
352
474
|
const affinityKey = this.#getAffinityKey()
|
|
@@ -355,22 +477,51 @@ export class QueueBuilder {
|
|
|
355
477
|
// rather than give up after a handful of tries (retryKind: 'pop').
|
|
356
478
|
const result = await this.#httpClient.get(`${path}?${params}`, this.#timeoutMillis + 5000, affinityKey, this.#wait ? 'pop' : null)
|
|
357
479
|
|
|
480
|
+
// Degrade-loudly (PLAN_CONFLATION §4), BEFORE the empty-response return:
|
|
481
|
+
// an old broker's empty pop is a bodiless 204 (result === null), and that
|
|
482
|
+
// is precisely the first thing a consumer on an idle queue sees. Also
|
|
483
|
+
// where a declaration conflict is warned about, exactly once.
|
|
484
|
+
if (this.#conflation) {
|
|
485
|
+
checkConflationResponse(result, {
|
|
486
|
+
queue: this.#queueName,
|
|
487
|
+
namespace: this.#namespace,
|
|
488
|
+
task: this.#task,
|
|
489
|
+
group: this.#group
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// The broker's own account of how it sized this pop, when the request
|
|
494
|
+
// engaged autopilot and the answer had a body to carry it (a bodiless 204
|
|
495
|
+
// cannot, so an empty short pop reports null).
|
|
496
|
+
const autopilot = parseAutopilotDecision(result)
|
|
497
|
+
|
|
358
498
|
if (!result || !result.messages) {
|
|
359
499
|
logger.log('QueueBuilder.pop', { status: 'no-messages' })
|
|
360
|
-
return []
|
|
500
|
+
return { messages: [], autopilot }
|
|
361
501
|
}
|
|
362
502
|
|
|
363
503
|
const messages = result.messages.filter(msg => msg != null)
|
|
364
504
|
logger.log('QueueBuilder.pop', { status: 'success', count: messages.length })
|
|
365
|
-
return messages
|
|
505
|
+
return { messages, autopilot }
|
|
366
506
|
} catch (error) {
|
|
507
|
+
// Conflation is the one thing this method does NOT swallow. The
|
|
508
|
+
// swallow-to-[] contract exists for transport faults, where [] means "no
|
|
509
|
+
// messages right now"; for a declared conflation it would mean "your
|
|
510
|
+
// last-value policy is not in force and you will never be told", which is
|
|
511
|
+
// 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.
|
|
514
|
+
if (error.code === CONFLATION_UNSUPPORTED || (this.#conflation && error.status === 400)) {
|
|
515
|
+
logger.error('QueueBuilder.pop', { error: error.message, status: error.status, code: error.code, conflation: true })
|
|
516
|
+
throw error
|
|
517
|
+
}
|
|
367
518
|
// Return empty array on error instead of throwing. This also covers a
|
|
368
519
|
// 429 whose retry429 policy was exhausted (bounded pop, or an explicit
|
|
369
520
|
// maxAttempts override) and a terminal 403 (e.g. cluster_suspended) --
|
|
370
521
|
// both are logged with their `.code` rather than raising, matching this
|
|
371
522
|
// method's existing swallow-to-[] contract.
|
|
372
523
|
logger.error('QueueBuilder.pop', { error: error.message, status: error.status, code: error.code })
|
|
373
|
-
return []
|
|
524
|
+
return { messages: [], autopilot: null }
|
|
374
525
|
}
|
|
375
526
|
}
|
|
376
527
|
|
|
@@ -397,6 +548,11 @@ export class QueueBuilder {
|
|
|
397
548
|
// copy nobody calls: the pop would keep working and the parameter would
|
|
398
549
|
// simply never arrive, which reads as a server-side mystery and not as a
|
|
399
550
|
// client bug.
|
|
551
|
+
//
|
|
552
|
+
// The pair that is still live and MUST be kept in sync is pop()'s inline
|
|
553
|
+
// params above and ConsumerManager#buildParams: every pop query parameter
|
|
554
|
+
// (subscriptionMode, subscriptionFrom, partitions, conflation, ...) has to be
|
|
555
|
+
// appended in BOTH, because pop() and consume() share no builder.
|
|
400
556
|
|
|
401
557
|
// ===========================
|
|
402
558
|
// Buffer Management Methods
|
|
@@ -406,7 +562,7 @@ export class QueueBuilder {
|
|
|
406
562
|
if (!this.#queueName) {
|
|
407
563
|
throw new Error('Queue name is required for buffer flush')
|
|
408
564
|
}
|
|
409
|
-
const queueAddress =
|
|
565
|
+
const queueAddress = durableAddress(this.#queueName, this.#partition)
|
|
410
566
|
logger.log('QueueBuilder.flushBuffer', { queueAddress })
|
|
411
567
|
await this.#bufferManager.flushBuffer(queueAddress)
|
|
412
568
|
}
|
|
@@ -643,7 +799,10 @@ class PushBuilder {
|
|
|
643
799
|
// off without awaiting would report success for messages the buffer never
|
|
644
800
|
// accepted -- the exact failure this bound exists to remove.
|
|
645
801
|
if (this.#bufferOptions) {
|
|
646
|
-
|
|
802
|
+
// No destination: the durable push is the default sink, so this address's
|
|
803
|
+
// buffer drains to POST /api/v1/push with a `{items}` body exactly as it
|
|
804
|
+
// did before sinks existed (buffer/sinks.js).
|
|
805
|
+
const queueAddress = durableAddress(this.#queueName, this.#partition)
|
|
647
806
|
const accepted = []
|
|
648
807
|
|
|
649
808
|
try {
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import * as logger from '../utils/logger.js'
|
|
6
|
+
import { checkConflationResponse, CONFLATION_UNSUPPORTED } from '../utils/conflation.js'
|
|
7
|
+
import { popSizing, parseAutopilotDecision, emptyPollDelayMillis } from '../utils/autopilot.js'
|
|
8
|
+
import { CONSUME_DEFAULTS } from '../utils/defaults.js'
|
|
6
9
|
|
|
7
10
|
export class ConsumerManager {
|
|
8
11
|
#httpClient
|
|
@@ -47,8 +50,10 @@ export class ConsumerManager {
|
|
|
47
50
|
renewLeaseIntervalMillis,
|
|
48
51
|
subscriptionMode,
|
|
49
52
|
subscriptionFrom,
|
|
53
|
+
conflation,
|
|
50
54
|
each,
|
|
51
55
|
maxPartitions,
|
|
56
|
+
autopilot,
|
|
52
57
|
signal
|
|
53
58
|
} = options
|
|
54
59
|
|
|
@@ -68,7 +73,7 @@ export class ConsumerManager {
|
|
|
68
73
|
|
|
69
74
|
// Build the path and params for pop requests
|
|
70
75
|
const path = this.#buildPath(queue, partition, namespace, task)
|
|
71
|
-
const baseParams = this.#buildParams(batch, wait, timeoutMillis, group, subscriptionMode, subscriptionFrom, namespace, task, autoAck, maxPartitions)
|
|
76
|
+
const baseParams = this.#buildParams(batch, wait, timeoutMillis, group, subscriptionMode, subscriptionFrom, namespace, task, autoAck, maxPartitions, conflation, this.#autopilotEnabled(autopilot))
|
|
72
77
|
|
|
73
78
|
// Generate affinity key for consistent routing to same backend
|
|
74
79
|
const affinityKey = this.#getAffinityKey(queue, partition, namespace, task, group)
|
|
@@ -88,7 +93,12 @@ export class ConsumerManager {
|
|
|
88
93
|
each,
|
|
89
94
|
signal,
|
|
90
95
|
group, // Pass consumer group to workers
|
|
91
|
-
affinityKey // Pass affinity key to workers
|
|
96
|
+
affinityKey, // Pass affinity key to workers
|
|
97
|
+
// Conflation was REQUESTED by this consumer: the worker has to check
|
|
98
|
+
// every response for the broker's echo (PLAN_CONFLATION §4) and needs
|
|
99
|
+
// the pop target to key the once-per-(queue,group) conflict warning.
|
|
100
|
+
conflation,
|
|
101
|
+
conflationCtx: { queue, namespace, task, group }
|
|
92
102
|
}))
|
|
93
103
|
}
|
|
94
104
|
|
|
@@ -113,7 +123,9 @@ export class ConsumerManager {
|
|
|
113
123
|
each,
|
|
114
124
|
signal,
|
|
115
125
|
group,
|
|
116
|
-
affinityKey
|
|
126
|
+
affinityKey,
|
|
127
|
+
conflation,
|
|
128
|
+
conflationCtx
|
|
117
129
|
} = options
|
|
118
130
|
|
|
119
131
|
logger.log('ConsumerManager.worker', { workerId, status: 'started', limit, idleMillis })
|
|
@@ -150,13 +162,26 @@ export class ConsumerManager {
|
|
|
150
162
|
const clientTimeout = wait ? timeoutMillis + 5000 : timeoutMillis
|
|
151
163
|
const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey, wait ? 'pop' : null)
|
|
152
164
|
|
|
165
|
+
// Degrade-loudly (PLAN_CONFLATION §4). Checked BEFORE the empty-response
|
|
166
|
+
// branch on purpose: a pre-1.1.0 broker answers an empty pop with a
|
|
167
|
+
// bodiless 204 (result === null), which is the first thing a consumer
|
|
168
|
+
// on an idle queue sees — and the whole point is to raise before a
|
|
169
|
+
// single message of a backlog is processed one-by-one. Throwing here
|
|
170
|
+
// leaves the loop through the catch below, which stops this worker.
|
|
171
|
+
if (conflation) {
|
|
172
|
+
checkConflationResponse(result, conflationCtx)
|
|
173
|
+
}
|
|
174
|
+
|
|
153
175
|
// Handle empty response
|
|
154
176
|
if (!result || !result.messages || result.messages.length === 0) {
|
|
155
177
|
if (wait) {
|
|
156
178
|
continue // Long polling timeout, retry
|
|
157
179
|
} else {
|
|
158
|
-
// Short delay before retry
|
|
159
|
-
|
|
180
|
+
// Short delay before retry -- the broker's advised pacing when this
|
|
181
|
+
// pop engaged autopilot and the broker had an opinion (it knows the
|
|
182
|
+
// arrival rate on this queue and this client does not), otherwise
|
|
183
|
+
// the historical 100ms.
|
|
184
|
+
await new Promise(resolve => setTimeout(resolve, emptyPollDelayMillis(parseAutopilotDecision(result))))
|
|
160
185
|
continue
|
|
161
186
|
}
|
|
162
187
|
}
|
|
@@ -219,6 +244,17 @@ export class ConsumerManager {
|
|
|
219
244
|
}
|
|
220
245
|
|
|
221
246
|
} catch (error) {
|
|
247
|
+
// Conflation faults are terminal and are classified FIRST, ahead of the
|
|
248
|
+
// message-substring heuristics below: a consumer that asked for
|
|
249
|
+
// last-value delivery and is not getting it must stop, not retry
|
|
250
|
+
// (PLAN_CONFLATION §4). The broker's 400 refusals (queue mode /
|
|
251
|
+
// autoAck) are permanent config faults and stop the loop for the same
|
|
252
|
+
// reason — retrying them forever would be the silent version.
|
|
253
|
+
if (error.code === CONFLATION_UNSUPPORTED || (conflation && error.status === 400)) {
|
|
254
|
+
logger.error('ConsumerManager.worker', { workerId, status: 'conflation-unavailable', code: error.code, httpStatus: error.status, error: error.message })
|
|
255
|
+
throw error
|
|
256
|
+
}
|
|
257
|
+
|
|
222
258
|
// Check if this is a timeout error (expected for long polling)
|
|
223
259
|
const isTimeoutError = error.name === 'AbortError' ||
|
|
224
260
|
error.message?.includes('timeout')
|
|
@@ -442,20 +478,51 @@ export class ConsumerManager {
|
|
|
442
478
|
throw new Error('Must specify queue, namespace, or task')
|
|
443
479
|
}
|
|
444
480
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
481
|
+
/**
|
|
482
|
+
* The autopilot decision for one consume: the caller's explicit option if
|
|
483
|
+
* there is one, otherwise the client-wide default settled in the Queen
|
|
484
|
+
* constructor. The builder path has already resolved it; the undefined case
|
|
485
|
+
* is for callers that drive ConsumerManager with options of their own.
|
|
486
|
+
*/
|
|
487
|
+
#autopilotEnabled(autopilot) {
|
|
488
|
+
if (typeof autopilot === 'boolean') return autopilot
|
|
489
|
+
return !this.#queen || !this.#queen.autopilotOff
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#buildParams(batch, wait, timeoutMillis, group, subscriptionMode, subscriptionFrom, namespace, task, autoAck, maxPartitions, conflation, autopilot = true) {
|
|
493
|
+
// Batch, partitions and with them the autopilot flag. null/0 means the user
|
|
494
|
+
// set nothing (QueueBuilder leaves it that way on purpose), which is the
|
|
495
|
+
// dimension the broker gets to choose. THE RULE lives in one place
|
|
496
|
+
// (utils/autopilot.js) precisely because this is the SECOND parameter
|
|
497
|
+
// builder; only the placement of the keys is here, and it is the
|
|
498
|
+
// pre-autopilot placement so an autopilot-off request is byte-identical.
|
|
499
|
+
const sizing = popSizing({
|
|
500
|
+
batch,
|
|
501
|
+
maxPartitions,
|
|
502
|
+
fallbackBatch: CONSUME_DEFAULTS.batch,
|
|
503
|
+
autopilot
|
|
450
504
|
})
|
|
451
505
|
|
|
506
|
+
const params = new URLSearchParams()
|
|
507
|
+
if (sizing.autopilot) params.append('autopilot', 'true')
|
|
508
|
+
if (sizing.batch !== null) params.append('batch', sizing.batch)
|
|
509
|
+
params.append('wait', wait.toString())
|
|
510
|
+
params.append('timeout', timeoutMillis.toString()) // Server expects 'timeout', not 'timeoutMillis'
|
|
511
|
+
|
|
452
512
|
if (group) params.append('consumerGroup', group)
|
|
453
513
|
if (subscriptionMode) params.append('subscriptionMode', subscriptionMode)
|
|
454
514
|
if (subscriptionFrom) params.append('subscriptionFrom', subscriptionFrom)
|
|
455
515
|
if (namespace) params.append('namespace', namespace)
|
|
456
516
|
if (task) params.append('task', task)
|
|
457
|
-
// v4 multi-partition pop: drain up to N sparse partitions per call.
|
|
458
|
-
|
|
517
|
+
// v4 multi-partition pop: drain up to N sparse partitions per call. Under
|
|
518
|
+
// autopilot a pinned width travels even when it is 1, because 1 is then a
|
|
519
|
+
// decision and not the absence of one.
|
|
520
|
+
if (sizing.partitions !== null) params.append('partitions', sizing.partitions)
|
|
521
|
+
// Conflation (PLAN_CONFLATION §3.1): last-value delivery for this group.
|
|
522
|
+
// Sent ONLY when true, so a consumer that never declares it puts no new
|
|
523
|
+
// bytes on the wire. THIS IS THE SECOND PARAMETER BUILDER — the pop() one
|
|
524
|
+
// lives inline in QueueBuilder.pop and must gain every parameter too.
|
|
525
|
+
if (conflation) params.append('conflation', 'true')
|
|
459
526
|
// NEVER send autoAck for consume - client always manages acking
|
|
460
527
|
// autoAck is only for pop() where server auto-acks immediately
|
|
461
528
|
|