queen-mq 0.16.0 → 1.0.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.
@@ -15,6 +15,42 @@ import { CLIENT_DEFAULTS } from './utils/defaults.js'
15
15
  import { validateUrl, validateUrls } from './utils/validation.js'
16
16
  import * as logger from './utils/logger.js'
17
17
 
18
+ // Both /api/v1/ack and /api/v1/ack/batch respond with a top-level JSON array,
19
+ // one item per acknowledgment in request order:
20
+ // [{index, transactionId, success, error, queueName, partitionName, leaseReleased, dlq}]
21
+ // (see queen.ack_messages_v2 / routes/ack.cpp). A rejected ack/nack (e.g.
22
+ // "Invalid or expired lease") still arrives as HTTP 200 with success=false on
23
+ // the item, so the per-item flag is the only signal that the broker accepted it.
24
+
25
+ function normalizeAckItem(item, index) {
26
+ if (item === null || typeof item !== 'object') {
27
+ throw new Error(`Unexpected ack result item at index ${index}`)
28
+ }
29
+ let success = typeof item.success === 'boolean' ? item.success : true
30
+ let error = typeof item.error === 'string' && item.error.length > 0 ? item.error : null
31
+ if (error) success = false
32
+ if (!success && !error) error = 'Acknowledgment rejected by server'
33
+ return { ...item, success, error }
34
+ }
35
+
36
+ // expected is the number of acknowledgments sent, so a truncated or misaligned
37
+ // response fails loudly instead of being misattributed to the wrong message.
38
+ function parseAckResults(result, expected) {
39
+ if (Array.isArray(result)) {
40
+ if (result.length !== expected) {
41
+ throw new Error(`Ack response has ${result.length} results, expected ${expected}`)
42
+ }
43
+ return result.map(normalizeAckItem)
44
+ }
45
+
46
+ // Top-level error envelope: the whole request was rejected.
47
+ if (result && typeof result === 'object' && typeof result.error === 'string' && result.error.length > 0) {
48
+ return Array.from({ length: expected }, () => ({ success: false, error: result.error }))
49
+ }
50
+
51
+ throw new Error('Unexpected ack response format: missing per-item result array')
52
+ }
53
+
18
54
  export class Queen {
19
55
  #httpClient
20
56
  #bufferManager
@@ -87,7 +123,7 @@ export class Queen {
87
123
  }
88
124
 
89
125
  #createHttpClient() {
90
- const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken, headers } = this.#config
126
+ const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken, headers, hostHeader, retry429 } = this.#config
91
127
 
92
128
  if (urls.length === 1) {
93
129
  // Single server
@@ -97,7 +133,9 @@ export class Queen {
97
133
  retryAttempts,
98
134
  retryDelayMillis,
99
135
  bearerToken,
100
- headers
136
+ headers,
137
+ hostHeader,
138
+ retry429
101
139
  })
102
140
  }
103
141
 
@@ -113,7 +151,9 @@ export class Queen {
113
151
  retryDelayMillis,
114
152
  enableFailover,
115
153
  bearerToken,
116
- headers
154
+ headers,
155
+ hostHeader,
156
+ retry429
117
157
  })
118
158
  }
119
159
 
@@ -195,7 +235,7 @@ export class Queen {
195
235
  // Handle batch acknowledgment
196
236
  if (Array.isArray(message)) {
197
237
  if (message.length === 0) {
198
- return { processed: 0, results: [] }
238
+ return { success: true, processed: 0, results: [] }
199
239
  }
200
240
 
201
241
  // Check if messages have individual status
@@ -277,13 +317,19 @@ export class Queen {
277
317
  consumerGroup: context.group || null
278
318
  })
279
319
 
280
- if (result && result.error) {
281
- logger.error('Queen.ack', { type: 'batch', error: result.error })
282
- return { success: false, error: result.error }
320
+ const results = parseAckResults(result, acknowledgments.length)
321
+ const failed = results.filter(r => !r.success)
322
+
323
+ if (failed.length > 0) {
324
+ const error = failed.length === 1
325
+ ? failed[0].error
326
+ : `${failed.length} of ${results.length} acknowledgments rejected: ${failed[0].error}`
327
+ logger.error('Queen.ack', { type: 'batch', error, failed: failed.length, count: results.length })
328
+ return { success: false, error, results }
283
329
  }
284
330
 
285
- logger.log('Queen.ack', { type: 'batch', success: true, count: acknowledgments.length })
286
- return { success: true, ...result }
331
+ logger.log('Queen.ack', { type: 'batch', success: true, count: results.length })
332
+ return { success: true, processed: results.length, results }
287
333
  } catch (error) {
288
334
  logger.error('Queen.ack', { type: 'batch', error: error.message })
289
335
  return { success: false, error: error.message }
@@ -321,13 +367,14 @@ export class Queen {
321
367
  try {
322
368
  const result = await this.#httpClient.post('/api/v1/ack', body)
323
369
 
324
- if (result && result.error) {
325
- logger.error('Queen.ack', { type: 'single', transactionId, error: result.error })
326
- return { success: false, error: result.error }
327
- }
370
+ const [ackResult] = parseAckResults(result, 1)
328
371
 
329
- logger.log('Queen.ack', { type: 'single', transactionId, success: true })
330
- return { success: true, ...result }
372
+ if (!ackResult.success) {
373
+ logger.error('Queen.ack', { type: 'single', transactionId, error: ackResult.error })
374
+ } else {
375
+ logger.log('Queen.ack', { type: 'single', transactionId, success: true })
376
+ }
377
+ return ackResult
331
378
  } catch (error) {
332
379
  logger.error('Queen.ack', { type: 'single', transactionId, error: error.message })
333
380
  return { success: false, error: error.message }
@@ -512,6 +559,15 @@ export class Queen {
512
559
  }
513
560
  this.#shutdownHandlers = []
514
561
 
562
+ // Release the HTTP agent's keep-alive sockets — without this the Node
563
+ // event loop stays pinned by open sockets and the process never exits
564
+ // naturally after close().
565
+ try {
566
+ await this.#httpClient.destroy()
567
+ } catch (error) {
568
+ logger.warn('Queen.close', { error: error.message, phase: 'http-destroy' })
569
+ }
570
+
515
571
  logger.log('Queen.close', 'Client closed successfully')
516
572
  }
517
573
  }
@@ -104,6 +104,43 @@ const queen = new Queen({
104
104
  })
105
105
  ```
106
106
 
107
+ ### Connecting Through a Hosted Proxy (Cluster Selection)
108
+
109
+ A Queen proxy deployment routes each request to a tenant cluster using the
110
+ **Host header** (its first DNS label is the cluster slug). Normally you don't
111
+ have to think about it — every cluster has its own hostname, so pointing the
112
+ client at that hostname is all it takes:
113
+
114
+ ```javascript
115
+ const queen = new Queen({
116
+ url: 'https://acme.eu1.queenmq.cloud', // Host follows from the URL
117
+ bearerToken: process.env.QUEEN_API_KEY
118
+ })
119
+ ```
120
+
121
+ When the base URL can't be the cluster's hostname — an IP or a shared cell
122
+ endpoint, a local rig, a staging box, split-horizon DNS — use `hostHeader` to
123
+ advertise the cluster host while the connection still goes to the configured
124
+ address (the same thing `curl --resolve` does; TLS SNI and certificate
125
+ validation follow `hostHeader`, not the address):
126
+
127
+ ```javascript
128
+ const queen = new Queen({
129
+ url: 'https://10.0.0.7:6711', // where we connect
130
+ hostHeader: 'acme.eu1.queenmq.cloud', // which cluster we ask for
131
+ bearerToken: process.env.QUEEN_API_KEY
132
+ })
133
+ ```
134
+
135
+ `hostHeader` takes a bare authority (`'acme'`, `'acme.eu1.queenmq.cloud'`,
136
+ `'acme.local:6711'`) — not a URL. It works with `urls: [...]` too: every
137
+ backend is dialed as usual, and all of them get the same Host.
138
+
139
+ > ⚠️ `headers: { Host: '...' }` **cannot** work in JavaScript: `Host` is a
140
+ > WHATWG forbidden header name and `fetch()` drops it. Rather than let that
141
+ > silently route you to the wrong cluster, the client maps it onto
142
+ > `hostHeader` and warns once. Set `hostHeader` directly.
143
+
107
144
  ---
108
145
 
109
146
  ## Part 1: Hello Queue!
@@ -335,8 +335,10 @@ export class QueueBuilder {
335
335
 
336
336
  // Generate affinity key for consistent routing to same backend
337
337
  const affinityKey = this.#getAffinityKey()
338
-
339
- const result = await this.#httpClient.get(`${path}?${params}`, this.#timeoutMillis + 5000, affinityKey)
338
+
339
+ // wait=true is a long-poll: on 429 it should back off and keep waiting
340
+ // rather than give up after a handful of tries (retryKind: 'pop').
341
+ const result = await this.#httpClient.get(`${path}?${params}`, this.#timeoutMillis + 5000, affinityKey, this.#wait ? 'pop' : null)
340
342
 
341
343
  if (!result || !result.messages) {
342
344
  logger.log('QueueBuilder.pop', { status: 'no-messages' })
@@ -347,8 +349,12 @@ export class QueueBuilder {
347
349
  logger.log('QueueBuilder.pop', { status: 'success', count: messages.length })
348
350
  return messages
349
351
  } catch (error) {
350
- // Return empty array on error instead of throwing
351
- logger.error('QueueBuilder.pop', { error: error.message })
352
+ // Return empty array on error instead of throwing. This also covers a
353
+ // 429 whose retry429 policy was exhausted (bounded pop, or an explicit
354
+ // maxAttempts override) and a terminal 403 (e.g. cluster_suspended) --
355
+ // both are logged with their `.code` rather than raising, matching this
356
+ // method's existing swallow-to-[] contract.
357
+ logger.error('QueueBuilder.pop', { error: error.message, status: error.status, code: error.code })
352
358
  return []
353
359
  }
354
360
  }
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  import * as logger from '../utils/logger.js'
6
+ import { generateUUID } from './QueueBuilder.js'
7
+ import { isValidUUID } from '../utils/validation.js'
6
8
 
7
9
  export class TransactionBuilder {
8
10
  #httpClient
@@ -82,16 +84,26 @@ export class TransactionBuilder {
82
84
  payloadValue = item
83
85
  }
84
86
 
87
+ // Same contract as QueueBuilder.push: the caller's transactionId is
88
+ // what makes a retried transaction idempotent inside the dedup
89
+ // window, so it has to reach the wire. Absent, mint one here rather
90
+ // than leaving the broker to do it, so the id is knowable client
91
+ // side either way.
85
92
  const result = {
86
93
  queue: queueName,
87
- payload: payloadValue
94
+ payload: payloadValue,
95
+ transactionId: item.transactionId || generateUUID()
88
96
  }
89
-
97
+
90
98
  // Add partition if set
91
99
  if (partition !== null) {
92
100
  result.partition = partition
93
101
  }
94
102
 
103
+ if (item.traceId && isValidUUID(item.traceId)) {
104
+ result.traceId = item.traceId
105
+ }
106
+
95
107
  return result
96
108
  })
97
109
  })
@@ -144,9 +144,11 @@ export class ConsumerManager {
144
144
  }
145
145
 
146
146
  try {
147
- // Pop messages with affinity key for consistent routing
147
+ // Pop messages with affinity key for consistent routing. wait=true is
148
+ // a long-poll: mark it 'pop' so a 429 backs off and keeps waiting
149
+ // instead of giving up after the bounded push-like attempt budget.
148
150
  const clientTimeout = wait ? timeoutMillis + 5000 : timeoutMillis
149
- const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey)
151
+ const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey, wait ? 'pop' : null)
150
152
 
151
153
  // Handle empty response
152
154
  if (!result || !result.messages || result.messages.length === 0) {
@@ -188,9 +190,18 @@ export class ConsumerManager {
188
190
  for (const message of messages) {
189
191
  if (signal && signal.aborted) break
190
192
 
191
- await this.#processMessage(message, handler, autoAck, group)
193
+ const ok = await this.#processMessage(message, handler, autoAck, group)
192
194
  processedCount++
193
195
 
196
+ // A nack releases the lease and clamps the server cursor at the
197
+ // failed message: everything after it in this popped batch WILL
198
+ // be redelivered. Processing it now would only produce duplicates
199
+ // and rejected acks — abandon the rest of the batch.
200
+ if (autoAck && !ok) {
201
+ logger.warn('ConsumerManager.worker', { workerId, status: 'batch-abandoned-after-nack', remaining: messages.length - messages.indexOf(message) - 1 })
202
+ break
203
+ }
204
+
194
205
  if (limit && processedCount >= limit) break
195
206
  }
196
207
  } else {
@@ -216,6 +227,20 @@ export class ConsumerManager {
216
227
  continue // Retry on timeout
217
228
  }
218
229
 
230
+ // 429 (rate limited): HttpClient already retries this internally
231
+ // with backoff (unbounded for wait=true pop, per retry429 policy) --
232
+ // this branch is a defensive fallback for the case where an explicit
233
+ // retry429.maxAttempts override got exhausted. Back off and keep
234
+ // polling instead of hot-looping or rethrowing/dying.
235
+ if (error.status === 429) {
236
+ const retryAfterMs = typeof error.retryAfterSeconds === 'number' && error.retryAfterSeconds >= 0
237
+ ? error.retryAfterSeconds * 1000
238
+ : 1000
239
+ logger.warn('ConsumerManager.worker', { workerId, status: 'rate-limited', code: error.code, retryAfterMs })
240
+ await new Promise(resolve => setTimeout(resolve, retryAfterMs))
241
+ continue
242
+ }
243
+
219
244
  // Check if network error
220
245
  const isNetworkError = error.message?.includes('fetch failed') ||
221
246
  error.message?.includes('ECONNREFUSED') ||
@@ -228,8 +253,18 @@ export class ConsumerManager {
228
253
  continue
229
254
  }
230
255
 
256
+ // 403 (forbidden): terminal. cluster_suspended in particular can
257
+ // never resolve itself, and none of the other proxy codes
258
+ // (storage_quota_exceeded / feature_gated / forbidden) are worth
259
+ // hot-looping either -- stop this worker and surface the error
260
+ // (with .code) to the caller instead of retrying.
261
+ if (error.status === 403) {
262
+ logger.error('ConsumerManager.worker', { workerId, status: 'forbidden', code: error.code, error: error.message })
263
+ throw error
264
+ }
265
+
231
266
  // Other errors - rethrow
232
- logger.error('ConsumerManager.worker', { workerId, error: error.message })
267
+ logger.error('ConsumerManager.worker', { workerId, error: error.message, code: error.code })
233
268
  throw error
234
269
  }
235
270
  }
@@ -237,6 +272,11 @@ export class ConsumerManager {
237
272
  logger.log('ConsumerManager.worker', { workerId, status: 'stopped', processedCount })
238
273
  }
239
274
 
275
+ /**
276
+ * Returns true when the message was handled (and acked) successfully,
277
+ * false when it was nacked — the caller must abandon the rest of the
278
+ * popped batch (the nack released the lease server-side).
279
+ */
240
280
  async #processMessage(message, handler, autoAck, group) {
241
281
  try {
242
282
  await handler(message)
@@ -244,18 +284,26 @@ export class ConsumerManager {
244
284
  // Auto-ack on success if enabled
245
285
  if (autoAck) {
246
286
  const context = group ? { group } : {}
247
- await this.#queen.ack(message, true, context)
248
- logger.log('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'acked' })
287
+ const res = await this.#queen.ack(message, true, context)
288
+ if (res && res.success === false) {
289
+ logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'ack-rejected', error: res.error })
290
+ } else {
291
+ logger.log('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'acked' })
292
+ }
249
293
  }
294
+ return true
250
295
  } catch (error) {
251
296
  // Auto-nack on error if enabled
252
297
  if (autoAck) {
253
- const context = group ? { group } : {}
254
- await this.#queen.ack(message, false, context)
298
+ const context = group ? { group, error: error.message } : { error: error.message }
299
+ const res = await this.#queen.ack(message, false, context)
300
+ if (res && res.success === false) {
301
+ logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'nack-rejected', error: res.error })
302
+ }
255
303
  logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message, status: 'nacked' })
256
304
  // Don't rethrow when autoAck is enabled - NACK was already sent
257
305
  // This allows the consumer to continue and retry
258
- return
306
+ return false
259
307
  }
260
308
  logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message })
261
309
  throw error
@@ -269,13 +317,17 @@ export class ConsumerManager {
269
317
  // Auto-ack on success if enabled
270
318
  if (autoAck) {
271
319
  const context = group ? { group } : {}
272
- await this.#queen.ack(messages, true, context)
273
- logger.log('ConsumerManager.processBatch', { count: messages.length, status: 'acked' })
320
+ const res = await this.#queen.ack(messages, true, context)
321
+ if (res && res.success === false) {
322
+ logger.error('ConsumerManager.processBatch', { count: messages.length, status: 'ack-rejected', error: res.error })
323
+ } else {
324
+ logger.log('ConsumerManager.processBatch', { count: messages.length, status: 'acked' })
325
+ }
274
326
  }
275
327
  } catch (error) {
276
328
  // Auto-nack on error if enabled
277
329
  if (autoAck) {
278
- const context = group ? { group } : {}
330
+ const context = group ? { group, error: error.message } : { error: error.message }
279
331
  await this.#queen.ack(messages, false, context)
280
332
  logger.error('ConsumerManager.processBatch', { count: messages.length, error: error.message, status: 'nacked' })
281
333
  // Don't rethrow when autoAck is enabled - NACK was already sent