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 +15 -0
- package/client-v2/Queen.js +12 -2
- package/client-v2/admin/Admin.js +18 -0
- package/client-v2/buffer/BufferManager.js +186 -129
- package/client-v2/buffer/MessageBuffer.js +257 -53
- package/client-v2/builders/QueueBuilder.js +47 -10
- package/client-v2/utils/defaults.js +15 -1
- package/package.json +3 -3
- package/test-v2/buffer-unit/buffer.test.js +404 -0
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
|
package/client-v2/Queen.js
CHANGED
|
@@ -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' })
|
package/client-v2/admin/Admin.js
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
this
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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.#
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 #
|
|
169
|
-
if (this.#
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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()
|