queen-mq 1.0.3 → 1.0.6

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 CHANGED
@@ -324,6 +324,21 @@ await queen.flushAllBuffers()
324
324
  // Result: 10x-100x faster than individual pushes
325
325
  ```
326
326
 
327
+ The buffer is **bounded and lossless**, and both properties are why `push()` must
328
+ be awaited:
329
+
330
+ | Option | Default | Meaning |
331
+ | --- | --- | --- |
332
+ | `messageCount` | `100` | Flush once this many messages are waiting |
333
+ | `timeMillis` | `1000` | Or this long after the first message arrives |
334
+ | `maxSize` | `4 x messageCount` | Backpressure bound: past this many buffered messages, `push()` WAITS for the flusher instead of growing the heap. There is no unbounded setting |
335
+ | `retryDelayMillis` | `250` | Delay before retrying a batch whose POST failed. Failed batches go back to the front of the buffer, in order, and are retried — never dropped |
336
+
337
+ A producer that outruns the flush pipeline is therefore paced down to the drain
338
+ rate, and a broker outage shows up as slow pushes with bounded memory rather
339
+ than as messages that quietly disappeared. `close()` flushes with a 30 second
340
+ deadline and logs how many messages were left unsent if it expires.
341
+
327
342
  ### Dead Letter Queue
328
343
 
329
344
  ```javascript
@@ -17,6 +17,12 @@ import { CLIENT_DEFAULTS } from './utils/defaults.js'
17
17
  import { validateUrl, validateUrls } from './utils/validation.js'
18
18
  import * as logger from './utils/logger.js'
19
19
 
20
+ // How long close() keeps retrying a push batch the broker will not take before
21
+ // it gives up, logs how many messages were never sent, and lets the process
22
+ // exit. Matches CLIENT_DEFAULTS.timeoutMillis and the usual 30s SIGTERM grace:
23
+ // long enough to ride out a broker restart, short enough that shutdown ends.
24
+ const CLOSE_FLUSH_DEADLINE_MILLIS = 30000
25
+
20
26
  // Both /api/v1/ack and /api/v1/ack/batch respond with a top-level JSON array,
21
27
  // one item per acknowledgment in request order:
22
28
  // [{index, transactionId, success, error, queueName, partitionName, leaseReleased, dlq}]
@@ -596,9 +602,13 @@ export class Queen {
596
602
  async close() {
597
603
  logger.log('Queen.close', 'Starting shutdown')
598
604
 
599
- // Flush all buffers
605
+ // Flush all buffers, with a deadline. The flusher retries a failed batch
606
+ // forever rather than dropping it, which is right while the process is
607
+ // running and wrong on the way out: a SIGTERM grace period is finite, so
608
+ // shutdown stops retrying after CLOSE_FLUSH_DEADLINE_MILLIS and reports
609
+ // what is left instead of hanging until the runtime is killed.
600
610
  try {
601
- await this.#bufferManager.flushAllBuffers()
611
+ await this.#bufferManager.flushAllBuffers({ deadlineMillis: CLOSE_FLUSH_DEADLINE_MILLIS })
602
612
  logger.log('Queen.close', 'All buffers flushed')
603
613
  } catch (error) {
604
614
  logger.error('Queen.close', { error: error.message, phase: 'buffer-flush' })
@@ -68,6 +68,24 @@ export class Admin {
68
68
  return this.#httpClient.get(`/api/v1/resources/queues/${encodeURIComponent(name)}`)
69
69
  }
70
70
 
71
+ /**
72
+ * Per-partition backlog for a queue — the cheap sibling of getQueue:
73
+ * watermark arithmetic only, no segments, no timestamps. Shape:
74
+ * {queue, group, pending, partitions: [{partition, pending}]}.
75
+ * Omitting group gives queue-level pending under the same worst-cursor
76
+ * precedence the dashboard publishes; a named group is that group's own
77
+ * backlog per partition. Requires broker >= 1.0.4 — an older broker
78
+ * answers 404 no_such_route, so fall back to getQueue there.
79
+ * @param {string} name - Queue name
80
+ * @param {string|null} [group] - Consumer group (optional)
81
+ * @returns {Promise<object>}
82
+ */
83
+ async getQueueDepth(name, group = null) {
84
+ logger.log('Admin.getQueueDepth', { name, group })
85
+ const queryString = group ? `?group=${encodeURIComponent(group)}` : ''
86
+ return this.#httpClient.get(`/api/v1/resources/queues/${encodeURIComponent(name)}/depth${queryString}`)
87
+ }
88
+
71
89
  /**
72
90
  * Clear all messages from a queue
73
91
  * @param {string} name - Queue name
@@ -1,176 +1,220 @@
1
1
  /**
2
- * Buffer manager for client-side message buffering across queues
2
+ * Buffer manager for client-side message buffering across queues.
3
+ *
4
+ * One MessageBuffer per `queue/partition` address (the granularity the broker
5
+ * fuses writes on), and exactly ONE drain loop per buffer. The drain is the
6
+ * only thing that sends: it takes `messageCount`-sized batches off the front,
7
+ * POSTs them, and wakes producers parked on the buffer's maxSize bound after
8
+ * each batch that is definitively gone.
9
+ *
10
+ * A batch whose POST fails goes straight back to the front of the buffer, in
11
+ * order, and is retried after `retryDelayMillis` -- indefinitely, until it
12
+ * lands or the buffer is stopped. That is the half of the 2026-08-20 fix that
13
+ * removes loss on flush error; MessageBuffer's docs carry the other half
14
+ * (blocking backpressure) and the measurements behind both.
15
+ *
16
+ * Deadlines: an explicit flush (`flushBuffer`, `flushAllBuffers`) may pass
17
+ * `deadlineMillis` to bound how long it is willing to keep retrying, because
18
+ * "retry forever" is right for a background flusher and wrong for a shutdown
19
+ * path that has a SIGTERM grace period to respect. When the deadline expires
20
+ * the messages are still in the buffer -- the error says how many -- so the
21
+ * failure is loud rather than silent.
3
22
  */
4
23
 
5
24
  import { MessageBuffer } from './MessageBuffer.js'
6
- import { BUFFER_DEFAULTS } from '../utils/defaults.js'
7
25
  import * as logger from '../utils/logger.js'
8
26
 
9
27
  export class BufferManager {
10
28
  #httpClient
11
29
  #buffers = new Map() // queueAddress -> MessageBuffer
12
- #pendingFlushes = new Set() // Track in-flight flush promises
30
+ #drains = new Map() // queueAddress -> { promise, ctl } for the in-flight drain
13
31
  #flushCount = 0
32
+ #stopped = false
14
33
 
15
34
  constructor(httpClient) {
16
35
  this.#httpClient = httpClient
17
36
  }
18
37
 
19
- addMessage(queueAddress, formattedMessage, bufferOptions) {
20
- const options = { ...BUFFER_DEFAULTS, ...bufferOptions }
38
+ /**
39
+ * Buffer one message, waiting for room if the buffer is at its bound.
40
+ *
41
+ * Returns a promise: the add path is where backpressure is applied, so
42
+ * callers MUST await it. PushBuilder does; anything that forgets would be
43
+ * back to the unbounded behaviour this replaced.
44
+ *
45
+ * @param {string} queueAddress
46
+ * @param {object} formattedMessage
47
+ * @param {object} bufferOptions
48
+ * @param {{ signal?: AbortSignal }} [opts]
49
+ */
50
+ async addMessage(queueAddress, formattedMessage, bufferOptions, { signal } = {}) {
51
+ // A push after cleanup() would otherwise create a fresh buffer that nothing
52
+ // will ever flush -- messages accepted into a client that is already shut
53
+ // down, which is the same false success the bound exists to remove.
54
+ if (this.#stopped) {
55
+ throw new Error(`Queen client is closed: message not buffered for ${queueAddress}`)
56
+ }
21
57
 
22
58
  if (!this.#buffers.has(queueAddress)) {
23
- logger.log('BufferManager.createBuffer', { queueAddress, options })
24
- this.#buffers.set(queueAddress, new MessageBuffer(
25
- queueAddress,
26
- options,
27
- (addr) => this.#flushBuffer(addr)
28
- ))
59
+ // The raw options go to the buffer, which fills in the defaults itself:
60
+ // maxSize is derived from the messageCount this caller asked for, and
61
+ // merging defaults here first would hide the difference between "not set"
62
+ // and "set to the default".
63
+ const created = new MessageBuffer(queueAddress, bufferOptions, (addr) => { this.#startDrain(addr) })
64
+ logger.log('BufferManager.createBuffer', { queueAddress, options: created.options })
65
+ this.#buffers.set(queueAddress, created)
29
66
  }
30
67
 
31
68
  const buffer = this.#buffers.get(queueAddress)
32
- buffer.add(formattedMessage)
69
+ await buffer.add(formattedMessage, { signal })
33
70
  logger.log('BufferManager.addMessage', { queueAddress, messageCount: buffer.messageCount })
34
71
  }
35
72
 
36
- async #flushBuffer(queueAddress) {
37
- const buffer = this.#buffers.get(queueAddress)
38
- if (!buffer || buffer.messageCount === 0) {
39
- logger.debug('BufferManager.flushBuffer', { queueAddress, status: 'empty' })
40
- return
73
+ /**
74
+ * Start the drain loop for an address, or join the one already running.
75
+ *
76
+ * Joining rather than starting a second loop is what keeps batches in order:
77
+ * two concurrent senders on the same partition would interleave their POSTs.
78
+ * Returns null when there is nothing to send.
79
+ *
80
+ * @param {string} queueAddress
81
+ * @param {number|null} deadlineMillis - how long a caller is willing to keep
82
+ * retrying a failing batch; null (the default, and what background flushes
83
+ * use) means "until it lands or the buffer stops".
84
+ */
85
+ #startDrain(queueAddress, deadlineMillis = null) {
86
+ const deadline = deadlineMillis === null || deadlineMillis === undefined
87
+ ? Number.POSITIVE_INFINITY
88
+ : Date.now() + deadlineMillis
89
+
90
+ const running = this.#drains.get(queueAddress)
91
+ if (running) {
92
+ // A caller with a deadline joining a background drain tightens it: the
93
+ // shortest patience wins, otherwise a shutdown could be held open by a
94
+ // retry loop that was started with none.
95
+ running.ctl.deadline = Math.min(running.ctl.deadline, deadline)
96
+ return running.promise
41
97
  }
42
98
 
43
- logger.log('BufferManager.flushBuffer', { queueAddress, messageCount: buffer.messageCount })
44
- buffer.setFlushing(true)
45
-
46
- // Create a promise for this flush and track it
47
- const flushPromise = (async () => {
48
- try {
49
- const messages = buffer.extractMessages()
50
-
51
- logger.debug('BufferManager.flushBuffer', { queueAddress, extracted: messages.length })
52
-
53
- if (messages.length === 0) return
54
-
55
- // Send to server
56
- const result = await this.#httpClient.post('/api/v1/push', { items: messages })
57
- logger.debug('BufferManager.flushBuffer', { queueAddress, serverResponse: result ? `${result.length || 'N/A'} items` : 'null' })
58
-
59
- this.#flushCount++
60
- logger.log('BufferManager.flushBuffer', { queueAddress, status: 'success', messagesSent: messages.length })
61
-
62
- // Remove empty buffer
63
- this.#buffers.delete(queueAddress)
64
-
65
- } catch (error) {
66
- logger.error('BufferManager.flushBuffer', { queueAddress, error: error.message })
67
- buffer.setFlushing(false)
68
- throw error
69
- } finally {
70
- // Remove from pending flushes
71
- this.#pendingFlushes.delete(flushPromise)
72
- }
73
- })()
74
-
75
- // Track this flush
76
- this.#pendingFlushes.add(flushPromise)
77
-
78
- return flushPromise
79
- }
80
-
81
- async #flushBufferBatch(queueAddress, batchSize) {
82
99
  const buffer = this.#buffers.get(queueAddress)
83
- if (!buffer || buffer.messageCount === 0) {
84
- return
85
- }
86
-
87
- buffer.setFlushing(true)
88
-
89
- // Create a promise for this flush and track it
90
- const flushPromise = (async () => {
91
- try {
92
- const messages = buffer.extractMessages(batchSize)
93
-
94
- logger.debug('BufferManager.flushBufferBatch', { queueAddress, extracted: messages.length })
95
-
96
- if (messages.length === 0) return
97
-
98
- // Send to server
99
- const result = await this.#httpClient.post('/api/v1/push', { items: messages })
100
- logger.debug('BufferManager.flushBufferBatch', { queueAddress, serverResponse: result ? `${result.length || 'N/A'} items` : 'null' })
101
-
102
- this.#flushCount++
100
+ if (!buffer || !buffer.beginFlush()) return null
101
+
102
+ const ctl = { deadline }
103
+ const promise = this.#drain(queueAddress, buffer, ctl)
104
+ this.#drains.set(queueAddress, { promise, ctl })
105
+ // The count-threshold and timer triggers do not await this promise, so give
106
+ // it a handler of its own: a deadline tightened by a concurrent explicit
107
+ // flush would otherwise surface as an unhandled rejection.
108
+ promise.catch(() => {})
109
+ return promise
110
+ }
103
111
 
104
- // Remove empty buffer if no more messages
105
- if (buffer.messageCount === 0) {
106
- this.#buffers.delete(queueAddress)
107
- } else {
108
- buffer.setFlushing(false)
112
+ async #drain(queueAddress, buffer, ctl) {
113
+ const { messageCount, retryDelayMillis } = buffer.options
114
+
115
+ try {
116
+ while (buffer.messageCount > 0 && !buffer.isStopped) {
117
+ const batch = buffer.takeBatch(messageCount)
118
+ if (batch.length === 0) break
119
+
120
+ try {
121
+ const result = await this.#httpClient.post('/api/v1/push', { items: batch })
122
+ logger.debug('BufferManager.drain', { queueAddress, sent: batch.length, serverResponse: result ? `${result.length || 'N/A'} items` : 'null' })
123
+
124
+ this.#flushCount++
125
+ // Capacity freed. Woken here rather than at takeBatch, because a
126
+ // batch that fails to send goes straight back: waking on extraction
127
+ // would let producers refill against room that never freed.
128
+ buffer.wakeWaiters()
129
+ } catch (error) {
130
+ // NOT dropped. The batch goes back at the front, in order, and this
131
+ // loop retries it. Before 2026-08-20 this branch logged and moved on,
132
+ // losing up to messageCount messages per failed POST.
133
+ buffer.restoreBatch(batch)
134
+ logger.error('BufferManager.drain', { queueAddress, error: error.message, requeued: batch.length })
135
+
136
+ const remaining = ctl.deadline - Date.now()
137
+ if (remaining <= 0) {
138
+ error.queenUnflushedCount = buffer.messageCount
139
+ error.message = `${error.message} (${buffer.messageCount} message(s) still buffered for ${queueAddress}, not sent)`
140
+ throw error
141
+ }
142
+
143
+ await buffer.sleepUnlessStopped(Math.min(retryDelayMillis, remaining))
109
144
  }
110
-
111
- } catch (error) {
112
- logger.error('BufferManager.flushBufferBatch', { queueAddress, error: error.message })
113
- buffer.setFlushing(false)
114
- throw error
115
- } finally {
116
- // Remove from pending flushes
117
- this.#pendingFlushes.delete(flushPromise)
118
145
  }
119
- })()
120
-
121
- // Track this flush
122
- this.#pendingFlushes.add(flushPromise)
123
-
124
- return flushPromise
146
+ } finally {
147
+ buffer.endFlush()
148
+ this.#drains.delete(queueAddress)
149
+ // Drop the entry only when nothing can still be pointed at it: a parked
150
+ // add holds this exact object, and deleting it here would leave that add
151
+ // appending into an orphan no drain would ever visit.
152
+ if (buffer.messageCount === 0 && !buffer.hasParkedAdds && !buffer.isStopped) {
153
+ this.#buffers.delete(queueAddress)
154
+ }
155
+ buffer.wakeWaiters()
156
+ }
125
157
  }
126
158
 
127
- async flushBuffer(queueAddress) {
128
- logger.log('BufferManager.flushBuffer', { queueAddress, activeBuffers: this.#buffers.size, pendingFlushes: this.#pendingFlushes.size })
129
-
159
+ /**
160
+ * Send everything buffered for one address.
161
+ *
162
+ * @param {string} queueAddress
163
+ * @param {{ deadlineMillis?: number }} [opts] - stop retrying a failing batch
164
+ * after this long and throw (the messages stay in the buffer). Omit to
165
+ * retry until the batch lands.
166
+ */
167
+ async flushBuffer(queueAddress, { deadlineMillis = null } = {}) {
168
+ logger.log('BufferManager.flushBuffer', { queueAddress, activeBuffers: this.#buffers.size, pendingFlushes: this.#drains.size })
169
+
130
170
  const buffer = this.#buffers.get(queueAddress)
131
171
  if (!buffer) {
132
172
  logger.debug('BufferManager.flushBuffer', { queueAddress, status: 'not-found' })
133
- await this.#waitForPendingFlushes()
173
+ await this.#waitForDrains()
134
174
  return
135
175
  }
136
176
 
137
- // Cancel timer to prevent time-based flush
177
+ // Cancel the timer to prevent a time-based flush racing this one.
138
178
  buffer.cancelTimer()
139
-
140
- // Get the batch size from buffer options
141
- const batchSize = buffer.options.messageCount
142
-
143
- // Flush all messages in batches
144
- while (buffer.messageCount > 0) {
145
- logger.debug('BufferManager.flushBuffer', { queueAddress, batchSize, remaining: buffer.messageCount })
146
- await this.#flushBufferBatch(queueAddress, batchSize)
147
- }
148
-
149
- // Wait for all pending flushes to complete
150
- await this.#waitForPendingFlushes()
151
-
179
+
180
+ const drain = this.#startDrain(queueAddress, deadlineMillis)
181
+ if (drain) await drain
182
+
152
183
  logger.debug('BufferManager.flushBuffer', { queueAddress, status: 'completed' })
153
184
  }
154
185
 
155
- async flushAllBuffers() {
156
- // Get all queue addresses that have buffers
157
- const queueAddresses = Array.from(this.#buffers.keys())
158
- logger.log('BufferManager.flushAllBuffers', { bufferCount: queueAddresses.length, pendingFlushes: this.#pendingFlushes.size })
159
-
160
- // Flush each buffer in batches
186
+ /**
187
+ * Send everything buffered, for every address.
188
+ *
189
+ * Drains run concurrently across addresses (they are independent buffers, and
190
+ * a shutdown should not pay for them one at a time) and every one is awaited
191
+ * before the first error is rethrown: an unreachable queue must not strand
192
+ * the others' messages.
193
+ */
194
+ async flushAllBuffers({ deadlineMillis = null } = {}) {
195
+ const queueAddresses = new Set([...this.#buffers.keys(), ...this.#drains.keys()])
196
+ logger.log('BufferManager.flushAllBuffers', { bufferCount: queueAddresses.size, pendingFlushes: this.#drains.size })
197
+
198
+ const drains = []
161
199
  for (const queueAddress of queueAddresses) {
162
- await this.flushBuffer(queueAddress)
200
+ const buffer = this.#buffers.get(queueAddress)
201
+ if (buffer) buffer.cancelTimer()
202
+ const drain = this.#startDrain(queueAddress, deadlineMillis)
203
+ if (drain) drains.push(drain)
163
204
  }
164
-
165
- logger.log('BufferManager.flushAllBuffers', { status: 'completed' })
205
+
206
+ const outcomes = await Promise.allSettled(drains)
207
+ const failure = outcomes.find(outcome => outcome.status === 'rejected')
208
+
209
+ logger.log('BufferManager.flushAllBuffers', { status: failure ? 'failed' : 'completed' })
210
+ if (failure) throw failure.reason
166
211
  }
167
212
 
168
- async #waitForPendingFlushes() {
169
- if (this.#pendingFlushes.size === 0) return
170
-
171
- logger.debug('BufferManager.waitForPendingFlushes', { count: this.#pendingFlushes.size })
172
- await Promise.all(Array.from(this.#pendingFlushes))
173
- logger.debug('BufferManager.waitForPendingFlushes', { status: 'completed' })
213
+ async #waitForDrains() {
214
+ if (this.#drains.size === 0) return
215
+ logger.debug('BufferManager.waitForDrains', { count: this.#drains.size })
216
+ await Promise.allSettled([...this.#drains.values()].map(entry => entry.promise))
217
+ logger.debug('BufferManager.waitForDrains', { status: 'completed' })
174
218
  }
175
219
 
176
220
  getStats() {
@@ -189,12 +233,25 @@ export class BufferManager {
189
233
  oldestBufferAge,
190
234
  flushesPerformed: this.#flushCount
191
235
  }
192
-
236
+
193
237
  logger.log('BufferManager.getStats', stats)
194
238
  return stats
195
239
  }
196
240
 
241
+ /**
242
+ * Stop every buffer and discard what is left.
243
+ *
244
+ * Stopping wakes parked adds (they reject: their message was never buffered)
245
+ * and ends any retry loop, so this also unhangs a drain that was waiting out
246
+ * a broker outage. Anything still buffered here is lost -- which is why
247
+ * Queen.close() flushes with a deadline first and logs what remains.
248
+ */
197
249
  cleanup() {
250
+ this.#stopped = true
251
+ const unflushed = this.getStats().totalBufferedMessages
252
+ if (unflushed > 0) {
253
+ logger.error('BufferManager.cleanup', { unflushedMessages: unflushed, status: 'discarded' })
254
+ }
198
255
  logger.log('BufferManager.cleanup', { bufferCount: this.#buffers.size })
199
256
  for (const buffer of this.#buffers.values()) {
200
257
  buffer.cleanup()