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.
@@ -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
- #batch = CONSUME_DEFAULTS.batch
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 = 1
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 = Math.max(1, size)
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
- * Default 1 = legacy single-partition behavior.
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 = Math.max(1, n)
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
- // Build params with correct autoAck for pop
337
- const params = new URLSearchParams({
338
- batch: this.#batch.toString(),
339
- wait: this.#wait.toString(),
340
- timeout: this.#timeoutMillis.toString()
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 (this.#maxPartitions > 1) params.append('partitions', this.#maxPartitions.toString())
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 = `${this.#queueName}/${this.#partition}`
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
- const queueAddress = `${this.#queueName}/${this.#partition}`
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
- await new Promise(resolve => setTimeout(resolve, 100))
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
- #buildParams(batch, wait, timeoutMillis, group, subscriptionMode, subscriptionFrom, namespace, task, autoAck, maxPartitions) {
446
- const params = new URLSearchParams({
447
- batch: batch.toString(),
448
- wait: wait.toString(),
449
- timeout: timeoutMillis.toString() // Server expects 'timeout', not 'timeoutMillis'
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
- if (maxPartitions && maxPartitions > 1) params.append('partitions', maxPartitions.toString())
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