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
|
@@ -1,7 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Message buffer for a single queue
|
|
2
|
+
* Message buffer for a single queue/partition.
|
|
3
|
+
*
|
|
4
|
+
* This is the client-side linger: single pushes accumulate here and leave as
|
|
5
|
+
* one request once `messageCount` messages are waiting, or `timeMillis` has
|
|
6
|
+
* passed since the first one arrived. Two properties beyond that batching are
|
|
7
|
+
* load-bearing, and neither existed before 2026-08-20:
|
|
8
|
+
*
|
|
9
|
+
* - `maxSize` is a BLOCKING bound, not a hint. `add()` returns a promise that
|
|
10
|
+
* does not resolve while the buffer is full, so a producer that outruns the
|
|
11
|
+
* flush pipeline is paced down to the drain rate instead of growing the
|
|
12
|
+
* heap. Measured on the Go client, whose buffer had exactly this shape:
|
|
13
|
+
* filling at 1.46M msg/s against a 1.0M msg/s flush pipeline accumulated
|
|
14
|
+
* 20.9M messages (11.7 GB of RSS) in 45 seconds and lost every one of them
|
|
15
|
+
* at process exit, with ZERO client-side errors reported anywhere. The
|
|
16
|
+
* bounded version sustained 881,148 msg/s with exact send/receive parity
|
|
17
|
+
* (39,655,787 = 39,655,787) and 71 MB of RSS.
|
|
18
|
+
*
|
|
19
|
+
* - A batch that fails to send is put BACK at the front of the buffer, in
|
|
20
|
+
* order, and retried after `retryDelayMillis`. It is never dropped. Before
|
|
21
|
+
* this, the flusher took the batch out before the POST and only logged the
|
|
22
|
+
* failure, so up to `messageCount` messages vanished per failed request.
|
|
23
|
+
*
|
|
24
|
+
* Together those two turn a broker outage into blocked producers with bounded
|
|
25
|
+
* memory, instead of silent loss.
|
|
26
|
+
*
|
|
27
|
+
* BLOCKING IDIOM: JavaScript is single-threaded, so "block" cannot mean
|
|
28
|
+
* "occupy the thread" -- that would starve the very flush that frees the
|
|
29
|
+
* capacity being waited for. It means an awaitable gate: parked adds hold a
|
|
30
|
+
* promise that the flusher resolves after each drained batch (and `stop()`
|
|
31
|
+
* rejects). No spin loop, no setInterval poll: the event loop is free to run
|
|
32
|
+
* the flush while producers wait.
|
|
33
|
+
*
|
|
34
|
+
* Buffered messages still live only in this process's memory. A crash, or a
|
|
35
|
+
* `process.exit()` that skips `close()`, loses them -- buffering belongs on
|
|
36
|
+
* telemetry-shaped traffic, not on anything that must not be lost.
|
|
3
37
|
*/
|
|
4
38
|
|
|
39
|
+
import { BUFFER_DEFAULTS } from '../utils/defaults.js'
|
|
40
|
+
|
|
5
41
|
export class MessageBuffer {
|
|
6
42
|
#queueAddress
|
|
7
43
|
#messages = []
|
|
@@ -10,15 +46,93 @@ export class MessageBuffer {
|
|
|
10
46
|
#timer = null
|
|
11
47
|
#firstMessageTime = null
|
|
12
48
|
#flushing = false
|
|
49
|
+
#stopped = false
|
|
50
|
+
// Adds parked on the maxSize bound. Each entry can be woken (capacity freed)
|
|
51
|
+
// or failed (stop, or the caller's AbortSignal).
|
|
52
|
+
#waiters = []
|
|
53
|
+
// Counts adds that have been woken but have not resumed yet. JS resumes an
|
|
54
|
+
// awaiting function on a later microtask, so the waiter list is already empty
|
|
55
|
+
// while those adds are still in flight; without this counter BufferManager
|
|
56
|
+
// could drop the buffer entry out of its map in that window and the resumed
|
|
57
|
+
// add would append to an orphan nobody ever flushes.
|
|
58
|
+
#parked = 0
|
|
59
|
+
#stopWaiters = []
|
|
13
60
|
|
|
14
61
|
constructor(queueAddress, options, flushCallback) {
|
|
15
62
|
this.#queueAddress = queueAddress
|
|
16
|
-
this.#options = options
|
|
63
|
+
this.#options = MessageBuffer.normalizeOptions(options)
|
|
17
64
|
this.#flushCallback = flushCallback
|
|
18
65
|
}
|
|
19
66
|
|
|
20
|
-
|
|
21
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Fill in defaults and enforce the bound's invariants.
|
|
69
|
+
*
|
|
70
|
+
* `maxSize: 0` (or absent) means the DEFAULT bound, never "unbounded":
|
|
71
|
+
* unbounded is the defect this knob exists to close, so opting out of
|
|
72
|
+
* backpressure is deliberately not expressible. The floor keeps the bound
|
|
73
|
+
* sane when a caller sets a `messageCount` larger than their `maxSize` --
|
|
74
|
+
* a buffer that must block before it can even assemble one batch would
|
|
75
|
+
* deadlock against its own flush threshold.
|
|
76
|
+
*/
|
|
77
|
+
static normalizeOptions(options) {
|
|
78
|
+
// The caller's raw options, NOT BUFFER_DEFAULTS spread over them: the bound
|
|
79
|
+
// is derived from whatever messageCount this buffer ended up with, so
|
|
80
|
+
// `buffer({ messageCount: 10 })` gets a bound of 40, not the 400 that suits
|
|
81
|
+
// the default batch of 100. Unknown keys are carried through untouched.
|
|
82
|
+
const provided = options || {}
|
|
83
|
+
|
|
84
|
+
const messageCount = provided.messageCount > 0 ? provided.messageCount : BUFFER_DEFAULTS.messageCount
|
|
85
|
+
const timeMillis = provided.timeMillis > 0 ? provided.timeMillis : BUFFER_DEFAULTS.timeMillis
|
|
86
|
+
|
|
87
|
+
let maxSize = provided.maxSize > 0 ? provided.maxSize : 4 * messageCount
|
|
88
|
+
if (maxSize < messageCount) maxSize = messageCount
|
|
89
|
+
|
|
90
|
+
const retryDelayMillis = provided.retryDelayMillis > 0
|
|
91
|
+
? provided.retryDelayMillis
|
|
92
|
+
: BUFFER_DEFAULTS.retryDelayMillis
|
|
93
|
+
|
|
94
|
+
return { ...provided, messageCount, timeMillis, maxSize, retryDelayMillis }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Append one message, waiting for room if the buffer is at its bound.
|
|
99
|
+
*
|
|
100
|
+
* Resolves once the message is in the buffer. Rejects if the buffer is
|
|
101
|
+
* stopped while parked, or if `signal` aborts -- an add that could not be
|
|
102
|
+
* buffered must never look like a successful push.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} formattedMessage
|
|
105
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
106
|
+
*/
|
|
107
|
+
async add(formattedMessage, { signal } = {}) {
|
|
108
|
+
if (this.#stopped) {
|
|
109
|
+
throw new Error(`Queen buffer ${this.#queueAddress} is stopped: message not buffered`)
|
|
110
|
+
}
|
|
111
|
+
if (signal?.aborted) throw abortReason(signal)
|
|
112
|
+
|
|
113
|
+
// BACKPRESSURE. Re-checked in a loop, not once: a broadcast wakes every
|
|
114
|
+
// parked add, and the first ones to resume can fill the room that was
|
|
115
|
+
// freed, so the rest have to park again.
|
|
116
|
+
while (this.#messages.length >= this.#options.maxSize && !this.#stopped) {
|
|
117
|
+
// Being at the bound means producers outran the flusher. Make sure one is
|
|
118
|
+
// actually running before parking -- the time-based flush may be a full
|
|
119
|
+
// `timeMillis` away, and nothing else will start it.
|
|
120
|
+
this.#triggerFlush()
|
|
121
|
+
// The counter is held across the resumption itself, not just the wait:
|
|
122
|
+
// see #parked for why the window between "woken" and "resumed" matters.
|
|
123
|
+
this.#parked++
|
|
124
|
+
try {
|
|
125
|
+
await this.#waitForCapacity(signal)
|
|
126
|
+
} finally {
|
|
127
|
+
this.#parked--
|
|
128
|
+
}
|
|
129
|
+
if (signal?.aborted) throw abortReason(signal)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.#stopped) {
|
|
133
|
+
throw new Error(`Queen buffer ${this.#queueAddress} stopped while waiting for capacity: message not buffered`)
|
|
134
|
+
}
|
|
135
|
+
|
|
22
136
|
if (this.#messages.length === 0) {
|
|
23
137
|
this.#firstMessageTime = Date.now()
|
|
24
138
|
this.#startTimer()
|
|
@@ -26,77 +140,146 @@ export class MessageBuffer {
|
|
|
26
140
|
|
|
27
141
|
this.#messages.push(formattedMessage)
|
|
28
142
|
|
|
29
|
-
// Check if we should flush based on size
|
|
30
143
|
if (this.#messages.length >= this.#options.messageCount) {
|
|
31
144
|
this.#triggerFlush()
|
|
32
145
|
}
|
|
33
146
|
}
|
|
34
147
|
|
|
148
|
+
#waitForCapacity(signal) {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const waiter = { resolve, reject, signal, onAbort: null }
|
|
151
|
+
|
|
152
|
+
waiter.settle = (fn, arg) => {
|
|
153
|
+
const index = this.#waiters.indexOf(waiter)
|
|
154
|
+
if (index !== -1) this.#waiters.splice(index, 1)
|
|
155
|
+
if (waiter.onAbort) signal.removeEventListener('abort', waiter.onAbort)
|
|
156
|
+
fn(arg)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (signal) {
|
|
160
|
+
waiter.onAbort = () => waiter.settle(reject, abortReason(signal))
|
|
161
|
+
signal.addEventListener('abort', waiter.onAbort, { once: true })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.#waiters.push(waiter)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Wake every parked add. Called by the flusher after a batch is definitively
|
|
170
|
+
* gone (the POST succeeded), not when the batch is merely taken out of the
|
|
171
|
+
* buffer: a batch that fails goes straight back, and waking on extraction
|
|
172
|
+
* would let producers refill against room that never actually freed.
|
|
173
|
+
*/
|
|
174
|
+
wakeWaiters() {
|
|
175
|
+
const waiters = this.#waiters.slice()
|
|
176
|
+
for (const waiter of waiters) waiter.settle(waiter.resolve)
|
|
177
|
+
}
|
|
178
|
+
|
|
35
179
|
#startTimer() {
|
|
36
180
|
if (this.#timer) return // Timer already running
|
|
37
181
|
|
|
182
|
+
// Deliberately NOT unref()'d: a short script that pushes and returns must
|
|
183
|
+
// stay alive long enough for the time-based flush to fire.
|
|
38
184
|
this.#timer = setTimeout(() => {
|
|
185
|
+
this.#timer = null
|
|
39
186
|
this.#triggerFlush()
|
|
40
187
|
}, this.#options.timeMillis)
|
|
41
188
|
}
|
|
42
189
|
|
|
43
190
|
#triggerFlush() {
|
|
44
|
-
if (this.#flushing || this.#messages.length === 0) return
|
|
45
|
-
|
|
46
|
-
// Clear timer
|
|
47
|
-
if (this.#timer) {
|
|
48
|
-
clearTimeout(this.#timer)
|
|
49
|
-
this.#timer = null
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Trigger flush via callback
|
|
191
|
+
if (this.#flushing || this.#stopped || this.#messages.length === 0) return
|
|
53
192
|
this.#flushCallback(this.#queueAddress)
|
|
54
193
|
}
|
|
55
194
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
195
|
+
/**
|
|
196
|
+
* Claim the right to flush this buffer. Returns false when a flush is already
|
|
197
|
+
* running: one drain loop per buffer, so a burst of adds past the threshold
|
|
198
|
+
* cannot start a second sender that would interleave batches out of order.
|
|
199
|
+
*/
|
|
200
|
+
beginFlush() {
|
|
201
|
+
if (this.#flushing || this.#stopped || this.#messages.length === 0) return false
|
|
202
|
+
this.#flushing = true
|
|
203
|
+
this.cancelTimer()
|
|
204
|
+
return true
|
|
205
|
+
}
|
|
68
206
|
|
|
69
|
-
|
|
70
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Release the flush claim. Anything still buffered (a batch put back by a
|
|
209
|
+
* failed send, or messages added while the drain was stopping) gets a fresh
|
|
210
|
+
* timer, so it cannot sit there unnoticed until the next add.
|
|
211
|
+
*/
|
|
212
|
+
endFlush() {
|
|
213
|
+
this.#flushing = false
|
|
214
|
+
if (this.#messages.length > 0 && !this.#stopped) this.#startTimer()
|
|
215
|
+
}
|
|
71
216
|
|
|
72
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Take up to `batchSize` messages off the front.
|
|
219
|
+
*
|
|
220
|
+
* `splice` returns a fresh array, so the batch does not alias the buffer's
|
|
221
|
+
* storage and putting it back cannot corrupt what is left behind. (The Go
|
|
222
|
+
* reference has to copy explicitly there -- its slices do alias.)
|
|
223
|
+
*/
|
|
224
|
+
takeBatch(batchSize) {
|
|
73
225
|
const messages = this.#messages.splice(0, batchSize)
|
|
74
|
-
|
|
75
|
-
// If buffer is now empty, reset state
|
|
76
|
-
if (this.#messages.length === 0) {
|
|
77
|
-
this.#firstMessageTime = null
|
|
78
|
-
this.#flushing = false
|
|
79
|
-
|
|
80
|
-
if (this.#timer) {
|
|
81
|
-
clearTimeout(this.#timer)
|
|
82
|
-
this.#timer = null
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
226
|
+
if (this.#messages.length === 0) this.#firstMessageTime = null
|
|
86
227
|
return messages
|
|
87
228
|
}
|
|
88
229
|
|
|
89
|
-
|
|
90
|
-
|
|
230
|
+
/**
|
|
231
|
+
* Put a failed batch back at the FRONT, preserving order: these messages were
|
|
232
|
+
* queued before everything still in the buffer, and a retry must not reorder
|
|
233
|
+
* a partition's lane. Occupancy can overshoot maxSize by this one batch --
|
|
234
|
+
* documented on BUFFER_DEFAULTS.maxSize.
|
|
235
|
+
*/
|
|
236
|
+
restoreBatch(messages) {
|
|
237
|
+
if (messages.length === 0) return
|
|
238
|
+
this.#messages.unshift(...messages)
|
|
239
|
+
if (this.#firstMessageTime === null) this.#firstMessageTime = Date.now()
|
|
91
240
|
}
|
|
92
241
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Sleep, but return early if the buffer is stopped. Used for the retry delay
|
|
244
|
+
* between attempts at a failed batch: a shutdown must not wait out a delay
|
|
245
|
+
* that only exists to pace a broker that is not answering.
|
|
246
|
+
*/
|
|
247
|
+
sleepUnlessStopped(millis) {
|
|
248
|
+
if (this.#stopped || millis <= 0) return Promise.resolve()
|
|
249
|
+
return new Promise(resolve => {
|
|
250
|
+
let timer = null
|
|
251
|
+
const wake = () => {
|
|
252
|
+
if (timer) clearTimeout(timer)
|
|
253
|
+
const index = this.#stopWaiters.indexOf(wake)
|
|
254
|
+
if (index !== -1) this.#stopWaiters.splice(index, 1)
|
|
255
|
+
resolve()
|
|
256
|
+
}
|
|
257
|
+
timer = setTimeout(wake, millis)
|
|
258
|
+
this.#stopWaiters.push(wake)
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Stop accepting messages and wake everything parked, so shutdown cannot
|
|
264
|
+
* hang. Parked adds REJECT rather than resolve: their message was never
|
|
265
|
+
* buffered, and reporting success for a message that was dropped on the floor
|
|
266
|
+
* is the failure mode this whole change exists to remove.
|
|
267
|
+
*/
|
|
268
|
+
stop() {
|
|
269
|
+
if (this.#stopped) return
|
|
270
|
+
this.#stopped = true
|
|
271
|
+
this.cancelTimer()
|
|
272
|
+
|
|
273
|
+
const waiters = this.#waiters.slice()
|
|
274
|
+
for (const waiter of waiters) {
|
|
275
|
+
waiter.settle(
|
|
276
|
+
waiter.reject,
|
|
277
|
+
new Error(`Queen buffer ${this.#queueAddress} stopped while waiting for capacity: message not buffered`)
|
|
278
|
+
)
|
|
98
279
|
}
|
|
99
|
-
|
|
280
|
+
|
|
281
|
+
const sleepers = this.#stopWaiters.splice(0, this.#stopWaiters.length)
|
|
282
|
+
for (const wake of sleepers) wake()
|
|
100
283
|
}
|
|
101
284
|
|
|
102
285
|
cancelTimer() {
|
|
@@ -115,18 +298,39 @@ export class MessageBuffer {
|
|
|
115
298
|
return this.#options
|
|
116
299
|
}
|
|
117
300
|
|
|
301
|
+
get isFlushing() {
|
|
302
|
+
return this.#flushing
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
get isStopped() {
|
|
306
|
+
return this.#stopped
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** True while any add is parked on the bound, or woken but not yet resumed. */
|
|
310
|
+
get hasParkedAdds() {
|
|
311
|
+
return this.#waiters.length > 0 || this.#parked > 0
|
|
312
|
+
}
|
|
313
|
+
|
|
118
314
|
get firstMessageAge() {
|
|
119
315
|
return this.#firstMessageTime ? Date.now() - this.#firstMessageTime : 0
|
|
120
316
|
}
|
|
121
317
|
|
|
122
318
|
cleanup() {
|
|
123
|
-
|
|
124
|
-
clearTimeout(this.#timer)
|
|
125
|
-
this.#timer = null
|
|
126
|
-
}
|
|
319
|
+
this.stop()
|
|
127
320
|
this.#messages = []
|
|
128
321
|
this.#firstMessageTime = null
|
|
129
322
|
this.#flushing = false
|
|
130
323
|
}
|
|
131
324
|
}
|
|
132
325
|
|
|
326
|
+
/**
|
|
327
|
+
* The reason an AbortSignal carries, or a plain AbortError when the runtime
|
|
328
|
+
* (or the caller's controller) did not set one.
|
|
329
|
+
*/
|
|
330
|
+
function abortReason(signal) {
|
|
331
|
+
if (signal.reason instanceof Error) return signal.reason
|
|
332
|
+
const error = new Error('Queen buffered push aborted while waiting for buffer capacity')
|
|
333
|
+
error.name = 'AbortError'
|
|
334
|
+
if (signal.reason !== undefined) error.cause = signal.reason
|
|
335
|
+
return error
|
|
336
|
+
}
|
|
@@ -138,6 +138,21 @@ export class QueueBuilder {
|
|
|
138
138
|
return this
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Batch pushes client-side instead of sending each one.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} options
|
|
145
|
+
* @param {number} [options.messageCount=100] - flush once this many messages are waiting
|
|
146
|
+
* @param {number} [options.timeMillis=1000] - flush this long after the first message arrives
|
|
147
|
+
* @param {number} [options.maxSize] - backpressure bound: past this many buffered
|
|
148
|
+
* messages `push()` WAITS for the flusher instead of growing the heap.
|
|
149
|
+
* Defaults to 4 x messageCount, floored at messageCount. There is no
|
|
150
|
+
* unbounded setting: unbounded is what lost 20.9M messages in the
|
|
151
|
+
* 2026-08-20 measurement.
|
|
152
|
+
* @param {number} [options.retryDelayMillis=250] - delay before retrying a batch
|
|
153
|
+
* whose POST failed. Failed batches are re-queued at the front of the
|
|
154
|
+
* buffer and retried, never dropped.
|
|
155
|
+
*/
|
|
141
156
|
buffer(options) {
|
|
142
157
|
this.#bufferOptions = options
|
|
143
158
|
return this
|
|
@@ -622,20 +637,42 @@ class PushBuilder {
|
|
|
622
637
|
async #execute() {
|
|
623
638
|
logger.log('PushBuilder.execute', { queue: this.#queueName, partition: this.#partition, count: this.#formattedItems.length, buffered: !!this.#bufferOptions })
|
|
624
639
|
|
|
625
|
-
// Client-side buffering
|
|
640
|
+
// Client-side buffering. Awaited, one message at a time: addMessage is
|
|
641
|
+
// where the maxSize backpressure bound is applied, so a buffered push
|
|
642
|
+
// resolves only once every item is actually IN the buffer. Firing these
|
|
643
|
+
// off without awaiting would report success for messages the buffer never
|
|
644
|
+
// accepted -- the exact failure this bound exists to remove.
|
|
626
645
|
if (this.#bufferOptions) {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
646
|
+
const queueAddress = `${this.#queueName}/${this.#partition}`
|
|
647
|
+
const accepted = []
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
for (const item of this.#formattedItems) {
|
|
651
|
+
await this.#bufferManager.addMessage(queueAddress, item, this.#bufferOptions)
|
|
652
|
+
accepted.push(item)
|
|
653
|
+
}
|
|
654
|
+
} catch (error) {
|
|
655
|
+
// The buffer refused this message (the client is closing, or the wait
|
|
656
|
+
// for capacity was aborted). Report the items that did NOT make it, the
|
|
657
|
+
// same way the immediate push below reports rejected items -- counting
|
|
658
|
+
// them as buffered would be the false success this bound removes.
|
|
659
|
+
const unbuffered = this.#formattedItems.slice(accepted.length)
|
|
660
|
+
logger.error('PushBuilder.execute', { status: 'not-buffered', count: unbuffered.length, error: error.message })
|
|
661
|
+
if (this.#onErrorCallback) {
|
|
662
|
+
await this.#onErrorCallback(unbuffered, error)
|
|
663
|
+
return null
|
|
664
|
+
}
|
|
665
|
+
throw error
|
|
630
666
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
667
|
+
|
|
668
|
+
const result = { buffered: true, count: accepted.length }
|
|
669
|
+
|
|
670
|
+
logger.log('PushBuilder.execute', { status: 'buffered', count: accepted.length })
|
|
671
|
+
|
|
635
672
|
if (this.#onSuccessCallback) {
|
|
636
|
-
await this.#onSuccessCallback(
|
|
673
|
+
await this.#onSuccessCallback(accepted)
|
|
637
674
|
}
|
|
638
|
-
|
|
675
|
+
|
|
639
676
|
return result
|
|
640
677
|
}
|
|
641
678
|
|
|
@@ -75,6 +75,20 @@ export const POP_DEFAULTS = {
|
|
|
75
75
|
|
|
76
76
|
export const BUFFER_DEFAULTS = {
|
|
77
77
|
messageCount: 100, // Flush after 100 messages
|
|
78
|
-
timeMillis: 1000
|
|
78
|
+
timeMillis: 1000, // Or flush after 1 second
|
|
79
|
+
// Backpressure bound: once this many messages are waiting, a buffered push
|
|
80
|
+
// WAITS for the flusher to drain below it instead of growing the heap. 0 (or
|
|
81
|
+
// absent) means 4 x messageCount -- "unbounded" is deliberately not
|
|
82
|
+
// expressible, because unbounded was the defect. Measured motivation
|
|
83
|
+
// (2026-08-20): without a bound, a producer filling at 1.46M msg/s against a
|
|
84
|
+
// 1.0M msg/s flush pipeline accumulated 20.9M messages (11.7 GB) in 45
|
|
85
|
+
// seconds and lost every one of them at process exit, with zero client-side
|
|
86
|
+
// errors. The bound is approximate: a batch that fails to send is put back,
|
|
87
|
+
// so occupancy can briefly overshoot by up to one messageCount.
|
|
88
|
+
maxSize: 400,
|
|
89
|
+
// How long the flusher waits before retrying a batch whose POST failed. The
|
|
90
|
+
// batch is re-queued at the front of the buffer and retried until it lands
|
|
91
|
+
// (or the buffer is stopped) -- never dropped. 0 means 250.
|
|
92
|
+
retryDelayMillis: 250
|
|
79
93
|
}
|
|
80
94
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "queen-mq",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Partitioned message queue on PostgreSQL — broker client + fluent streaming SDK (windows, joins, gates) in one package",
|
|
6
6
|
"main": "client-v2/index.js",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js && node test-v2/run.js human",
|
|
9
|
-
"test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js",
|
|
8
|
+
"test": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/buffer-unit/buffer.test.js && node test-v2/run.js human",
|
|
9
|
+
"test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/buffer-unit/buffer.test.js",
|
|
10
10
|
"test:unit:e2e": "node --test test-v2/streams-unit/e2e.test.js",
|
|
11
11
|
"test:integration": "node test-v2/run.js human",
|
|
12
12
|
"test:streams": "node test-v2/run.js stream",
|