queen-mq 1.0.5 → 1.1.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 CHANGED
@@ -154,6 +154,53 @@ await queen.queue('events')
154
154
  .consume(async (message) => { /* from timestamp */ })
155
155
  ```
156
156
 
157
+ ### Conflation (Last-Value Delivery)
158
+
159
+ For command-style queues where one partition is one logical task key — "recompute
160
+ customer 42", "this entity is dirty" — only the newest pending message matters.
161
+ `.conflation(true)` makes a pop of a partition deliver exactly one message, the
162
+ newest visible one, and commit past everything it skipped:
163
+
164
+ ```javascript
165
+ // A backlog of 4 000 recompute requests across 12 entities becomes
166
+ // 12 handler calls, each with the freshest input.
167
+ await queen.queue('recompute')
168
+ .group('workers')
169
+ .conflation(true)
170
+ .partitions(64)
171
+ .consume(async (message) => {
172
+ await recompute(message.data.entityId)
173
+ })
174
+ ```
175
+
176
+ The guarantee: **after the last push to a partition, at least one handler run
177
+ starts after that push committed.** Nothing is deleted — conflation is a delivery
178
+ policy, not compaction; retention still governs what is stored, and a
179
+ non-conflating group on the same queue still sees every message.
180
+
181
+ Notes:
182
+
183
+ - It is a property of the **consumer group**, fixed when that group first
184
+ registers on the queue. A later consumer declaring the opposite does not flip
185
+ it — the stored value wins, that consumer keeps working, and the SDK warns
186
+ once per (queue, group).
187
+ - Skipping is per **partition**, so partitioning is the key: one partition = one
188
+ logical key is the contract this workload has to hold up.
189
+ - A conflating pop returns at most one message per partition, so **partitions**
190
+ size the round-trip, not `batch`. Left unset, the broker uses `.batch(N)` as
191
+ the partition cap; either way it is clamped to 64, so a conflating pop returns
192
+ at most 64 messages per round-trip whatever `batch` says.
193
+ - Refused with 400 by the broker without a `.group(...)`, and together with
194
+ `.autoAck(true)` (auto-ack commits at delivery, which would turn the guarantee
195
+ above into at-most-once).
196
+ - Requires broker **>= 1.1.0**. An older broker ignores the flag and would
197
+ quietly deliver the whole backlog, so the SDK raises
198
+ `conflation was requested but this broker did not apply it` on the first
199
+ response that does not echo it — before any message is processed.
200
+ - `admin.getQueueDepth(queue, group)` reports `effectivePending` (handler calls
201
+ still owed) next to `pending` (log positions still to retire). For a
202
+ conflating group `pending: 4000000, effectivePending: 12` is healthy.
203
+
157
204
  ---
158
205
 
159
206
  ## Connection Options
@@ -324,6 +371,21 @@ await queen.flushAllBuffers()
324
371
  // Result: 10x-100x faster than individual pushes
325
372
  ```
326
373
 
374
+ The buffer is **bounded and lossless**, and both properties are why `push()` must
375
+ be awaited:
376
+
377
+ | Option | Default | Meaning |
378
+ | --- | --- | --- |
379
+ | `messageCount` | `100` | Flush once this many messages are waiting |
380
+ | `timeMillis` | `1000` | Or this long after the first message arrives |
381
+ | `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 |
382
+ | `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 |
383
+
384
+ A producer that outruns the flush pipeline is therefore paced down to the drain
385
+ rate, and a broker outage shows up as slow pushes with bounded memory rather
386
+ than as messages that quietly disappeared. `close()` flushes with a 30 second
387
+ deadline and logs how many messages were left unsent if it expires.
388
+
327
389
  ### Dead Letter Queue
328
390
 
329
391
  ```javascript
@@ -10,6 +10,7 @@ import { QueueBuilder } from './builders/QueueBuilder.js'
10
10
  import { TransactionBuilder } from './builders/TransactionBuilder.js'
11
11
  import { TimerBuilder } from './builders/TimerBuilder.js'
12
12
  import { Kv } from './kv/Kv.js'
13
+ import { Ephemeral } from './ephemeral/Ephemeral.js'
13
14
  import { StreamBuilder } from './stream/StreamBuilder.js'
14
15
  import { StreamConsumer } from './stream/StreamConsumer.js'
15
16
  import { Admin } from './admin/Admin.js'
@@ -17,6 +18,12 @@ import { CLIENT_DEFAULTS } from './utils/defaults.js'
17
18
  import { validateUrl, validateUrls } from './utils/validation.js'
18
19
  import * as logger from './utils/logger.js'
19
20
 
21
+ // How long close() keeps retrying a push batch the broker will not take before
22
+ // it gives up, logs how many messages were never sent, and lets the process
23
+ // exit. Matches CLIENT_DEFAULTS.timeoutMillis and the usual 30s SIGTERM grace:
24
+ // long enough to ride out a broker restart, short enough that shutdown ends.
25
+ const CLOSE_FLUSH_DEADLINE_MILLIS = 30000
26
+
20
27
  // Both /api/v1/ack and /api/v1/ack/batch respond with a top-level JSON array,
21
28
  // one item per acknowledgment in request order:
22
29
  // [{index, transactionId, success, error, queueName, partitionName, leaseReleased, dlq}]
@@ -60,6 +67,7 @@ export class Queen {
60
67
  #shutdownHandlers = []
61
68
  #admin = null
62
69
  #kv = null
70
+ #ephemeral = null
63
71
 
64
72
  constructor(config = {}) {
65
73
  // Configure custom logger before anything else.
@@ -247,6 +255,54 @@ export class Queen {
247
255
  return this.#kv
248
256
  }
249
257
 
258
+ // ===========================
259
+ // Ephemeral API Entry Point
260
+ // ===========================
261
+
262
+ /**
263
+ * RAM-class queues: `/api/v1/ephemeral/*` (EPHEMERAL_QUEUES.md §1, §4).
264
+ *
265
+ * await queen.ephemeral.push('inbox:7', [{ hello: 'world' }])
266
+ * const { messages } = await queen.ephemeral.pop('inbox:7', { wait: true })
267
+ * await queen.ephemeral.ack('inbox:7', messages, { group: 'workers' })
268
+ *
269
+ * A different STORAGE CLASS, not a different API style. What changes:
270
+ *
271
+ * * CONTENTS SURVIVE NOTHING (§1.2) -- restart, crash, deploy, or the
272
+ * ownership move a membership change causes. Treat a failover like a
273
+ * Redis restart. A declared queue's OPTIONS are durable; it comes back
274
+ * configured and EMPTY.
275
+ * * a queue does not have to exist: the first push or pop that names one
276
+ * creates it, which is what makes thousands of short-lived req/reply
277
+ * inboxes cheap (§1.1).
278
+ * * delivery is at-least-once while the owning broker lives, at-most-once
279
+ * with `autoAck` (§1.3) -- NOT "at most once" as a class. Consumers still
280
+ * need idempotency.
281
+ * * consumption semantics are the pop's `group`, exactly as on durable
282
+ * queues (§1.5): same group competes, own group fans out, no group is
283
+ * queue mode. There is no queue-level mode to set.
284
+ * * there is no replay, no subscriptionMode, no DLQ, no transactions -- the
285
+ * verbs are absent because the concepts have no referent (§9).
286
+ *
287
+ * `push(..., {buffered})` shares the durable buffer machinery, so
288
+ * `queen.close()` drains it on the same deadline (§4.1).
289
+ *
290
+ * Requires broker/proxy >= 1.1; an older one 404s the whole family and every
291
+ * verb here maps that to `.code === EPHEMERAL_UNSUPPORTED`. Not to be
292
+ * confused with the OTHER 404: `depth` on a queue that does not exist raises
293
+ * `.code === EPHEMERAL_QUEUE_NOT_FOUND`, which is a missing queue and not a
294
+ * missing feature.
295
+ *
296
+ * Lazily initialized, singleton, like `admin` and `kv`.
297
+ * @returns {Ephemeral}
298
+ */
299
+ get ephemeral() {
300
+ if (!this.#ephemeral) {
301
+ this.#ephemeral = new Ephemeral(this.#httpClient, this.#bufferManager)
302
+ }
303
+ return this.#ephemeral
304
+ }
305
+
250
306
  // ===========================
251
307
  // Timers API Entry Point
252
308
  // ===========================
@@ -596,9 +652,13 @@ export class Queen {
596
652
  async close() {
597
653
  logger.log('Queen.close', 'Starting shutdown')
598
654
 
599
- // Flush all buffers
655
+ // Flush all buffers, with a deadline. The flusher retries a failed batch
656
+ // forever rather than dropping it, which is right while the process is
657
+ // running and wrong on the way out: a SIGTERM grace period is finite, so
658
+ // shutdown stops retrying after CLOSE_FLUSH_DEADLINE_MILLIS and reports
659
+ // what is left instead of hanging until the runtime is killed.
600
660
  try {
601
- await this.#bufferManager.flushAllBuffers()
661
+ await this.#bufferManager.flushAllBuffers({ deadlineMillis: CLOSE_FLUSH_DEADLINE_MILLIS })
602
662
  logger.log('Queen.close', 'All buffers flushed')
603
663
  } catch (error) {
604
664
  logger.error('Queen.close', { error: error.message, phase: 'buffer-flush' })
@@ -71,11 +71,25 @@ export class Admin {
71
71
  /**
72
72
  * Per-partition backlog for a queue — the cheap sibling of getQueue:
73
73
  * watermark arithmetic only, no segments, no timestamps. Shape:
74
- * {queue, group, pending, partitions: [{partition, pending}]}.
74
+ * {queue, group, pending, partitionsPending, conflation, effectivePending,
75
+ * partitions: [{partition, pending}]}.
75
76
  * Omitting group gives queue-level pending under the same worst-cursor
76
77
  * precedence the dashboard publishes; a named group is that group's own
77
78
  * backlog per partition. Requires broker >= 1.0.4 — an older broker
78
79
  * answers 404 no_such_route, so fall back to getQueue there.
80
+ *
81
+ * The three fields added in 1.1.0 (PLAN_CONFLATION §2.5/§5.3):
82
+ * - `partitionsPending`: how many partitions owe work (pending > 0). Useful
83
+ * for every group; it is what queenctl used to compute client-side.
84
+ * - `conflation`: the group's stored last-value delivery policy.
85
+ * - `effectivePending`: WORK depth — handler invocations still owed. For a
86
+ * conflating group that is `partitionsPending` (one call per partition,
87
+ * newest message only); otherwise it equals `pending`.
88
+ *
89
+ * Read them together: for a conflating group `pending` is LOG depth (log
90
+ * positions still to retire), so `pending: 4000000, effectivePending: 12` is
91
+ * healthy — the same two numbers on a non-conflating group are an incident.
92
+ * Absent on brokers older than 1.1.0.
79
93
  * @param {string} name - Queue name
80
94
  * @param {string|null} [group] - Consumer group (optional)
81
95
  * @returns {Promise<object>}
@@ -1,176 +1,232 @@
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.
22
+ *
23
+ * WHERE a batch goes is the buffer's DESTINATION (buffer/sinks.js), not
24
+ * something this loop knows: durable pushes and ephemeral pushes are two routes
25
+ * with two body shapes and exactly one set of ordering, backpressure and retry
26
+ * semantics, so the drain is parametrized instead of copied. Addresses are
27
+ * namespaced per family (`eph:` prefix), so the two never share a buffer, a
28
+ * drain, or a retry queue. A buffer created without a destination drains to the
29
+ * durable push exactly as before.
3
30
  */
4
31
 
5
32
  import { MessageBuffer } from './MessageBuffer.js'
6
- import { BUFFER_DEFAULTS } from '../utils/defaults.js'
7
33
  import * as logger from '../utils/logger.js'
8
34
 
9
35
  export class BufferManager {
10
36
  #httpClient
11
37
  #buffers = new Map() // queueAddress -> MessageBuffer
12
- #pendingFlushes = new Set() // Track in-flight flush promises
38
+ #drains = new Map() // queueAddress -> { promise, ctl } for the in-flight drain
13
39
  #flushCount = 0
40
+ #stopped = false
14
41
 
15
42
  constructor(httpClient) {
16
43
  this.#httpClient = httpClient
17
44
  }
18
45
 
19
- addMessage(queueAddress, formattedMessage, bufferOptions) {
20
- const options = { ...BUFFER_DEFAULTS, ...bufferOptions }
46
+ /**
47
+ * Buffer one message, waiting for room if the buffer is at its bound.
48
+ *
49
+ * Returns a promise: the add path is where backpressure is applied, so
50
+ * callers MUST await it. PushBuilder does; anything that forgets would be
51
+ * back to the unbounded behaviour this replaced.
52
+ *
53
+ * @param {string} queueAddress
54
+ * @param {object} formattedMessage
55
+ * @param {object} bufferOptions
56
+ * @param {{ signal?: AbortSignal, destination?: object }} [opts] - `destination`
57
+ * is `{ sink, queue, partition }` (buffer/sinks.js) and is read ONLY when
58
+ * this address's buffer is created: an address belongs to one queue of one
59
+ * storage class, so its route cannot change under an in-flight retry.
60
+ */
61
+ async addMessage(queueAddress, formattedMessage, bufferOptions, { signal, destination = null } = {}) {
62
+ // A push after cleanup() would otherwise create a fresh buffer that nothing
63
+ // will ever flush -- messages accepted into a client that is already shut
64
+ // down, which is the same false success the bound exists to remove.
65
+ if (this.#stopped) {
66
+ throw new Error(`Queen client is closed: message not buffered for ${queueAddress}`)
67
+ }
21
68
 
22
69
  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
- ))
70
+ // The raw options go to the buffer, which fills in the defaults itself:
71
+ // maxSize is derived from the messageCount this caller asked for, and
72
+ // merging defaults here first would hide the difference between "not set"
73
+ // and "set to the default".
74
+ const created = new MessageBuffer(queueAddress, bufferOptions, (addr) => { this.#startDrain(addr) }, destination)
75
+ logger.log('BufferManager.createBuffer', { queueAddress, options: created.options, sink: created.destination.sink.name })
76
+ this.#buffers.set(queueAddress, created)
29
77
  }
30
78
 
31
79
  const buffer = this.#buffers.get(queueAddress)
32
- buffer.add(formattedMessage)
80
+ await buffer.add(formattedMessage, { signal })
33
81
  logger.log('BufferManager.addMessage', { queueAddress, messageCount: buffer.messageCount })
34
82
  }
35
83
 
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
84
+ /**
85
+ * Start the drain loop for an address, or join the one already running.
86
+ *
87
+ * Joining rather than starting a second loop is what keeps batches in order:
88
+ * two concurrent senders on the same partition would interleave their POSTs.
89
+ * Returns null when there is nothing to send.
90
+ *
91
+ * @param {string} queueAddress
92
+ * @param {number|null} deadlineMillis - how long a caller is willing to keep
93
+ * retrying a failing batch; null (the default, and what background flushes
94
+ * use) means "until it lands or the buffer stops".
95
+ */
96
+ #startDrain(queueAddress, deadlineMillis = null) {
97
+ const deadline = deadlineMillis === null || deadlineMillis === undefined
98
+ ? Number.POSITIVE_INFINITY
99
+ : Date.now() + deadlineMillis
100
+
101
+ const running = this.#drains.get(queueAddress)
102
+ if (running) {
103
+ // A caller with a deadline joining a background drain tightens it: the
104
+ // shortest patience wins, otherwise a shutdown could be held open by a
105
+ // retry loop that was started with none.
106
+ running.ctl.deadline = Math.min(running.ctl.deadline, deadline)
107
+ return running.promise
41
108
  }
42
109
 
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
110
  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++
111
+ if (!buffer || !buffer.beginFlush()) return null
112
+
113
+ const ctl = { deadline }
114
+ const promise = this.#drain(queueAddress, buffer, ctl)
115
+ this.#drains.set(queueAddress, { promise, ctl })
116
+ // The count-threshold and timer triggers do not await this promise, so give
117
+ // it a handler of its own: a deadline tightened by a concurrent explicit
118
+ // flush would otherwise surface as an unhandled rejection.
119
+ promise.catch(() => {})
120
+ return promise
121
+ }
103
122
 
104
- // Remove empty buffer if no more messages
105
- if (buffer.messageCount === 0) {
106
- this.#buffers.delete(queueAddress)
107
- } else {
108
- buffer.setFlushing(false)
123
+ async #drain(queueAddress, buffer, ctl) {
124
+ const { messageCount, retryDelayMillis } = buffer.options
125
+ const { sink, queue, partition } = buffer.destination
126
+
127
+ try {
128
+ while (buffer.messageCount > 0 && !buffer.isStopped) {
129
+ const batch = buffer.takeBatch(messageCount)
130
+ if (batch.length === 0) break
131
+
132
+ try {
133
+ const result = await this.#httpClient.post(sink.path, sink.format(queue, partition, batch))
134
+ logger.debug('BufferManager.drain', { queueAddress, sink: sink.name, sent: batch.length, serverResponse: result ? `${result.length || 'N/A'} items` : 'null' })
135
+
136
+ this.#flushCount++
137
+ // Capacity freed. Woken here rather than at takeBatch, because a
138
+ // batch that fails to send goes straight back: waking on extraction
139
+ // would let producers refill against room that never freed.
140
+ buffer.wakeWaiters()
141
+ } catch (error) {
142
+ // NOT dropped. The batch goes back at the front, in order, and this
143
+ // loop retries it. Before 2026-08-20 this branch logged and moved on,
144
+ // losing up to messageCount messages per failed POST.
145
+ buffer.restoreBatch(batch)
146
+ logger.error('BufferManager.drain', { queueAddress, error: error.message, requeued: batch.length })
147
+
148
+ const remaining = ctl.deadline - Date.now()
149
+ if (remaining <= 0) {
150
+ error.queenUnflushedCount = buffer.messageCount
151
+ error.message = `${error.message} (${buffer.messageCount} message(s) still buffered for ${queueAddress}, not sent)`
152
+ throw error
153
+ }
154
+
155
+ await buffer.sleepUnlessStopped(Math.min(retryDelayMillis, remaining))
109
156
  }
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
157
  }
119
- })()
120
-
121
- // Track this flush
122
- this.#pendingFlushes.add(flushPromise)
123
-
124
- return flushPromise
158
+ } finally {
159
+ buffer.endFlush()
160
+ this.#drains.delete(queueAddress)
161
+ // Drop the entry only when nothing can still be pointed at it: a parked
162
+ // add holds this exact object, and deleting it here would leave that add
163
+ // appending into an orphan no drain would ever visit.
164
+ if (buffer.messageCount === 0 && !buffer.hasParkedAdds && !buffer.isStopped) {
165
+ this.#buffers.delete(queueAddress)
166
+ }
167
+ buffer.wakeWaiters()
168
+ }
125
169
  }
126
170
 
127
- async flushBuffer(queueAddress) {
128
- logger.log('BufferManager.flushBuffer', { queueAddress, activeBuffers: this.#buffers.size, pendingFlushes: this.#pendingFlushes.size })
129
-
171
+ /**
172
+ * Send everything buffered for one address.
173
+ *
174
+ * @param {string} queueAddress
175
+ * @param {{ deadlineMillis?: number }} [opts] - stop retrying a failing batch
176
+ * after this long and throw (the messages stay in the buffer). Omit to
177
+ * retry until the batch lands.
178
+ */
179
+ async flushBuffer(queueAddress, { deadlineMillis = null } = {}) {
180
+ logger.log('BufferManager.flushBuffer', { queueAddress, activeBuffers: this.#buffers.size, pendingFlushes: this.#drains.size })
181
+
130
182
  const buffer = this.#buffers.get(queueAddress)
131
183
  if (!buffer) {
132
184
  logger.debug('BufferManager.flushBuffer', { queueAddress, status: 'not-found' })
133
- await this.#waitForPendingFlushes()
185
+ await this.#waitForDrains()
134
186
  return
135
187
  }
136
188
 
137
- // Cancel timer to prevent time-based flush
189
+ // Cancel the timer to prevent a time-based flush racing this one.
138
190
  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
-
191
+
192
+ const drain = this.#startDrain(queueAddress, deadlineMillis)
193
+ if (drain) await drain
194
+
152
195
  logger.debug('BufferManager.flushBuffer', { queueAddress, status: 'completed' })
153
196
  }
154
197
 
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
198
+ /**
199
+ * Send everything buffered, for every address.
200
+ *
201
+ * Drains run concurrently across addresses (they are independent buffers, and
202
+ * a shutdown should not pay for them one at a time) and every one is awaited
203
+ * before the first error is rethrown: an unreachable queue must not strand
204
+ * the others' messages.
205
+ */
206
+ async flushAllBuffers({ deadlineMillis = null } = {}) {
207
+ const queueAddresses = new Set([...this.#buffers.keys(), ...this.#drains.keys()])
208
+ logger.log('BufferManager.flushAllBuffers', { bufferCount: queueAddresses.size, pendingFlushes: this.#drains.size })
209
+
210
+ const drains = []
161
211
  for (const queueAddress of queueAddresses) {
162
- await this.flushBuffer(queueAddress)
212
+ const buffer = this.#buffers.get(queueAddress)
213
+ if (buffer) buffer.cancelTimer()
214
+ const drain = this.#startDrain(queueAddress, deadlineMillis)
215
+ if (drain) drains.push(drain)
163
216
  }
164
-
165
- logger.log('BufferManager.flushAllBuffers', { status: 'completed' })
217
+
218
+ const outcomes = await Promise.allSettled(drains)
219
+ const failure = outcomes.find(outcome => outcome.status === 'rejected')
220
+
221
+ logger.log('BufferManager.flushAllBuffers', { status: failure ? 'failed' : 'completed' })
222
+ if (failure) throw failure.reason
166
223
  }
167
224
 
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' })
225
+ async #waitForDrains() {
226
+ if (this.#drains.size === 0) return
227
+ logger.debug('BufferManager.waitForDrains', { count: this.#drains.size })
228
+ await Promise.allSettled([...this.#drains.values()].map(entry => entry.promise))
229
+ logger.debug('BufferManager.waitForDrains', { status: 'completed' })
174
230
  }
175
231
 
176
232
  getStats() {
@@ -189,12 +245,25 @@ export class BufferManager {
189
245
  oldestBufferAge,
190
246
  flushesPerformed: this.#flushCount
191
247
  }
192
-
248
+
193
249
  logger.log('BufferManager.getStats', stats)
194
250
  return stats
195
251
  }
196
252
 
253
+ /**
254
+ * Stop every buffer and discard what is left.
255
+ *
256
+ * Stopping wakes parked adds (they reject: their message was never buffered)
257
+ * and ends any retry loop, so this also unhangs a drain that was waiting out
258
+ * a broker outage. Anything still buffered here is lost -- which is why
259
+ * Queen.close() flushes with a deadline first and logs what remains.
260
+ */
197
261
  cleanup() {
262
+ this.#stopped = true
263
+ const unflushed = this.getStats().totalBufferedMessages
264
+ if (unflushed > 0) {
265
+ logger.error('BufferManager.cleanup', { unflushedMessages: unflushed, status: 'discarded' })
266
+ }
198
267
  logger.log('BufferManager.cleanup', { bufferCount: this.#buffers.size })
199
268
  for (const buffer of this.#buffers.values()) {
200
269
  buffer.cleanup()