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.
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Client-side push buffer: backpressure and loss-free retry.
3
+ *
4
+ * These pin the 2026-08-20 fix, ported from the Go reference
5
+ * (clients/client-go/message_buffer.go). Two defects were measured there and
6
+ * were present in this SDK too:
7
+ *
8
+ * 1. add() appended to an unbounded array and returned. A producer filling at
9
+ * 1.46M msg/s against a 1.0M msg/s flush pipeline accumulated 20.9M
10
+ * messages (11.7 GB of RSS) in 45 seconds and lost every one at process
11
+ * exit, with zero client-side errors reported.
12
+ * 2. The flusher took a batch out of the buffer before the POST and dropped
13
+ * it on error, losing up to messageCount messages per failed request.
14
+ *
15
+ * No broker and no network: the sink is a fake object with a post() the test
16
+ * drives, which is the whole point -- the buffer's contract is about ordering,
17
+ * occupancy and retries, none of which need a server to observe.
18
+ */
19
+
20
+ import { describe, it } from 'node:test'
21
+ import assert from 'node:assert/strict'
22
+
23
+ import { QueueBuilder } from '../../client-v2/builders/QueueBuilder.js'
24
+ import { BufferManager } from '../../client-v2/buffer/BufferManager.js'
25
+ import { MessageBuffer } from '../../client-v2/buffer/MessageBuffer.js'
26
+ import { BUFFER_DEFAULTS } from '../../client-v2/utils/defaults.js'
27
+
28
+ const ADDRESS = 'orders/Default'
29
+
30
+ const item = n => ({ queue: 'orders', partition: 'Default', payload: { n }, transactionId: `t-${n}` })
31
+ const payloadNumbers = items => items.map(i => i.payload.n)
32
+
33
+ /** A sink whose every POST hangs until the test releases or fails it. */
34
+ function controlledSink() {
35
+ const sent = []
36
+ const attempts = []
37
+ const inflight = []
38
+
39
+ return {
40
+ sent,
41
+ attempts,
42
+ get inflightCount() { return inflight.length },
43
+ post(_path, body) {
44
+ const items = body.items
45
+ attempts.push(items)
46
+ return new Promise((resolve, reject) => {
47
+ inflight.push({ items, resolve, reject })
48
+ })
49
+ },
50
+ /** Complete the oldest in-flight POST successfully. */
51
+ release() {
52
+ const call = inflight.shift()
53
+ assert.ok(call, 'no POST in flight to release')
54
+ sent.push(...call.items)
55
+ call.resolve([])
56
+ },
57
+ /** Fail the oldest in-flight POST. */
58
+ reject(message = 'connect ECONNREFUSED') {
59
+ const call = inflight.shift()
60
+ assert.ok(call, 'no POST in flight to reject')
61
+ call.reject(new Error(message))
62
+ }
63
+ }
64
+ }
65
+
66
+ /**
67
+ * A sink that answers immediately, failing whenever `shouldFail(attemptIndex)`
68
+ * says so. Used for the parity runs, where the interesting question is what
69
+ * comes out the far end after N failures, not when each one happened.
70
+ */
71
+ function flakySink(shouldFail) {
72
+ const sent = []
73
+ let attempt = 0
74
+ return {
75
+ sent,
76
+ get attempts() { return attempt },
77
+ async post(_path, body) {
78
+ const index = attempt++
79
+ if (shouldFail(index)) throw new Error(`sink refused attempt ${index}`)
80
+ sent.push(...body.items)
81
+ return []
82
+ }
83
+ }
84
+ }
85
+
86
+ const tick = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms))
87
+
88
+ /** Poll until `predicate()` holds, or fail the test. Cheaper than a fixed sleep
89
+ * on an idle machine and less flaky than one on a loaded machine. */
90
+ async function until(predicate, what, timeoutMs = 2000) {
91
+ const deadline = Date.now() + timeoutMs
92
+ while (Date.now() < deadline) {
93
+ if (predicate()) return
94
+ await tick(2)
95
+ }
96
+ assert.fail(`timed out waiting for: ${what}`)
97
+ }
98
+
99
+ /** Track whether a promise has settled, without awaiting it. */
100
+ function watch(promise) {
101
+ const state = { settled: false, value: undefined, error: undefined }
102
+ promise.then(
103
+ value => { state.settled = true; state.value = value },
104
+ error => { state.settled = true; state.error = error }
105
+ )
106
+ return state
107
+ }
108
+
109
+ describe('buffer options', () => {
110
+ it('resolves an absent or zero maxSize to a BOUND, never to unbounded', () => {
111
+ // Unbounded is the defect this knob closes, so it is deliberately not
112
+ // expressible: 0 means the default bound, not infinity.
113
+ assert.equal(MessageBuffer.normalizeOptions({ messageCount: 10 }).maxSize, 40)
114
+ assert.equal(MessageBuffer.normalizeOptions({ messageCount: 10, maxSize: 0 }).maxSize, 40)
115
+ assert.equal(MessageBuffer.normalizeOptions({ messageCount: 10, maxSize: -1 }).maxSize, 40)
116
+ assert.equal(MessageBuffer.normalizeOptions({}).maxSize, BUFFER_DEFAULTS.maxSize)
117
+ })
118
+
119
+ it('floors maxSize at messageCount', () => {
120
+ // A bound below the flush threshold would block the producer before the
121
+ // buffer could assemble the batch that unblocks it.
122
+ assert.equal(MessageBuffer.normalizeOptions({ messageCount: 100, maxSize: 10 }).maxSize, 100)
123
+ })
124
+
125
+ it('defaults retryDelayMillis rather than retrying in a hot loop', () => {
126
+ assert.equal(MessageBuffer.normalizeOptions({ messageCount: 10 }).retryDelayMillis, 250)
127
+ assert.equal(MessageBuffer.normalizeOptions({ retryDelayMillis: 0 }).retryDelayMillis, 250)
128
+ assert.equal(MessageBuffer.normalizeOptions({ retryDelayMillis: 5 }).retryDelayMillis, 5)
129
+ })
130
+
131
+ it('ships a bounded default', () => {
132
+ assert.equal(BUFFER_DEFAULTS.maxSize, 4 * BUFFER_DEFAULTS.messageCount)
133
+ })
134
+ })
135
+
136
+ describe('backpressure', () => {
137
+ it('parks an add at the bound and resumes it when the flusher drains', async () => {
138
+ const sink = controlledSink()
139
+ const manager = new BufferManager(sink)
140
+ const options = { messageCount: 2, timeMillis: 60000, maxSize: 4, retryDelayMillis: 5 }
141
+
142
+ // The first two adds trip the count threshold and start the drain, which
143
+ // takes them and then hangs on the sink. The next four pile up behind it.
144
+ for (let n = 0; n < 6; n++) {
145
+ await manager.addMessage(ADDRESS, item(n), options)
146
+ }
147
+ await until(() => sink.inflightCount === 1, 'the first batch to reach the sink')
148
+ assert.equal(manager.getStats().totalBufferedMessages, 4, 'the buffer should be sitting exactly at its bound')
149
+
150
+ // The seventh add has nowhere to go: it must WAIT, not grow the buffer.
151
+ const parked = watch(manager.addMessage(ADDRESS, item(6), options))
152
+ await tick(20)
153
+ assert.equal(parked.settled, false, 'add returned while the buffer was full: that is the unbounded defect')
154
+ assert.equal(manager.getStats().totalBufferedMessages, 4, 'buffer grew past maxSize while an add was parked')
155
+
156
+ // Draining a batch frees room, which is what wakes the parked add.
157
+ sink.release()
158
+ await until(() => parked.settled, 'the parked add to resume once capacity freed')
159
+ assert.equal(parked.error, undefined, 'the resumed add must succeed, not error')
160
+ assert.deepEqual(payloadNumbers(sink.sent), [0, 1], 'the drained batch went out in order')
161
+
162
+ manager.cleanup()
163
+ })
164
+
165
+ it('wakes parked adds on cleanup, and tells them the message was NOT buffered', async () => {
166
+ const sink = controlledSink()
167
+ const manager = new BufferManager(sink)
168
+ const options = { messageCount: 2, timeMillis: 60000, maxSize: 2, retryDelayMillis: 5 }
169
+
170
+ for (let n = 0; n < 4; n++) {
171
+ await manager.addMessage(ADDRESS, item(n), options)
172
+ }
173
+ await until(() => manager.getStats().totalBufferedMessages === 2, 'the buffer to reach its bound')
174
+
175
+ const parked = watch(manager.addMessage(ADDRESS, item(99), options))
176
+ await tick(20)
177
+ assert.equal(parked.settled, false, 'precondition: the add is parked')
178
+
179
+ // Shutdown must not leave a producer waiting forever...
180
+ manager.cleanup()
181
+ await until(() => parked.settled, 'cleanup() to wake the parked add')
182
+
183
+ // ...and must not tell it the message went somewhere. It did not.
184
+ assert.ok(parked.error instanceof Error, 'a parked add woken by cleanup must reject, not resolve')
185
+ assert.match(parked.error.message, /not buffered/)
186
+ })
187
+
188
+ it('fails a parked add when its AbortSignal fires, instead of reporting success', async () => {
189
+ const sink = controlledSink()
190
+ const manager = new BufferManager(sink)
191
+ const options = { messageCount: 2, timeMillis: 60000, maxSize: 2, retryDelayMillis: 5 }
192
+
193
+ for (let n = 0; n < 4; n++) {
194
+ await manager.addMessage(ADDRESS, item(n), options)
195
+ }
196
+ await until(() => manager.getStats().totalBufferedMessages === 2, 'the buffer to reach its bound')
197
+
198
+ const controller = new AbortController()
199
+ const parked = watch(manager.addMessage(ADDRESS, item(99), options, { signal: controller.signal }))
200
+ await tick(20)
201
+ assert.equal(parked.settled, false, 'precondition: the add is parked')
202
+
203
+ controller.abort()
204
+ await until(() => parked.settled, 'the abort to release the parked add')
205
+ assert.ok(parked.error instanceof Error, 'an aborted add must reject')
206
+ assert.equal(parked.error.name, 'AbortError')
207
+ assert.equal(manager.getStats().totalBufferedMessages, 2, 'an aborted add must not leave its message behind')
208
+
209
+ manager.cleanup()
210
+ })
211
+ })
212
+
213
+ describe('failed flushes', () => {
214
+ it('puts a failed batch back at the FRONT, in order, and retries it after retryDelayMillis', async () => {
215
+ const sink = controlledSink()
216
+ const manager = new BufferManager(sink)
217
+ const options = { messageCount: 3, timeMillis: 60000, maxSize: 12, retryDelayMillis: 40 }
218
+
219
+ for (let n = 0; n < 3; n++) {
220
+ await manager.addMessage(ADDRESS, item(n), options)
221
+ }
222
+ await until(() => sink.inflightCount === 1, 'the first attempt')
223
+
224
+ const failedAt = Date.now()
225
+ sink.reject()
226
+
227
+ // The batch is back in the buffer -- not logged and forgotten.
228
+ await until(() => manager.getStats().totalBufferedMessages === 3, 'the failed batch to be re-queued')
229
+ assert.equal(manager.getStats().flushesPerformed, 0, 'a POST that never landed must not count as a flush')
230
+
231
+ await until(() => sink.inflightCount === 1, 'the retry')
232
+ assert.ok(Date.now() - failedAt >= 35, 'the retry must wait out retryDelayMillis, not spin')
233
+ assert.deepEqual(payloadNumbers(sink.attempts[1]), [0, 1, 2], 'the retry must resend the same batch, in the same order')
234
+
235
+ sink.release()
236
+ await until(() => manager.getStats().totalBufferedMessages === 0, 'the retry to drain the buffer')
237
+ assert.deepEqual(payloadNumbers(sink.sent), [0, 1, 2])
238
+ assert.equal(manager.getStats().flushesPerformed, 1)
239
+
240
+ manager.cleanup()
241
+ })
242
+
243
+ it('keeps a re-queued batch ahead of messages added while it was failing', async () => {
244
+ // Ordering within a partition is the product's headline promise: a retry
245
+ // that landed behind newer messages would reorder the lane.
246
+ const sink = controlledSink()
247
+ const manager = new BufferManager(sink)
248
+ const options = { messageCount: 2, timeMillis: 60000, maxSize: 20, retryDelayMillis: 10 }
249
+
250
+ for (let n = 0; n < 2; n++) {
251
+ await manager.addMessage(ADDRESS, item(n), options)
252
+ }
253
+ await until(() => sink.inflightCount === 1, 'the first attempt')
254
+ sink.reject()
255
+ await until(() => manager.getStats().totalBufferedMessages === 2, 're-queue')
256
+
257
+ for (let n = 2; n < 6; n++) {
258
+ await manager.addMessage(ADDRESS, item(n), options)
259
+ }
260
+
261
+ for (let batch = 0; batch < 3; batch++) {
262
+ await until(() => sink.inflightCount === 1, `attempt for batch ${batch}`)
263
+ sink.release()
264
+ }
265
+ await until(() => manager.getStats().totalBufferedMessages === 0, 'the buffer to drain')
266
+ assert.deepEqual(payloadNumbers(sink.sent), [0, 1, 2, 3, 4, 5])
267
+
268
+ manager.cleanup()
269
+ })
270
+
271
+ it('loses nothing across a run with intermittent failures', async () => {
272
+ // The whole point, stated as count parity: every message a caller was told
273
+ // was buffered comes out of the sink exactly once, in order, however many
274
+ // POSTs failed on the way.
275
+ const total = 500
276
+ const sink = flakySink(attempt => attempt % 3 === 1)
277
+ const manager = new BufferManager(sink)
278
+ const options = { messageCount: 7, timeMillis: 50, maxSize: 21, retryDelayMillis: 1 }
279
+
280
+ for (let n = 0; n < total; n++) {
281
+ await manager.addMessage(ADDRESS, item(n), options)
282
+ }
283
+ await manager.flushAllBuffers()
284
+
285
+ assert.equal(sink.sent.length, total, `sent ${sink.sent.length} of ${total}: messages were dropped`)
286
+ assert.deepEqual(payloadNumbers(sink.sent), Array.from({ length: total }, (_, n) => n), 'order was not preserved')
287
+ assert.equal(manager.getStats().totalBufferedMessages, 0)
288
+ assert.ok(sink.attempts > total / options.messageCount, 'precondition: the sink actually failed some attempts')
289
+
290
+ manager.cleanup()
291
+ })
292
+
293
+ it('bounds an explicit flush by its deadline and says how much is still buffered', async () => {
294
+ // Background flushes retry forever rather than drop; a shutdown cannot.
295
+ const sink = flakySink(() => true)
296
+ const manager = new BufferManager(sink)
297
+ const options = { messageCount: 2, timeMillis: 60000, maxSize: 8, retryDelayMillis: 5 }
298
+
299
+ for (let n = 0; n < 4; n++) {
300
+ await manager.addMessage(ADDRESS, item(n), options)
301
+ }
302
+
303
+ await assert.rejects(
304
+ () => manager.flushAllBuffers({ deadlineMillis: 30 }),
305
+ error => {
306
+ assert.equal(error.queenUnflushedCount, 4, 'the error must report what was left unsent')
307
+ assert.match(error.message, /still buffered/)
308
+ return true
309
+ }
310
+ )
311
+ assert.equal(manager.getStats().totalBufferedMessages, 4, 'a flush that gave up must leave the messages in the buffer, not drop them')
312
+
313
+ manager.cleanup()
314
+ })
315
+ })
316
+
317
+ describe('drain loop', () => {
318
+ it('runs one drain per buffer, no matter how many adds trip the threshold', async () => {
319
+ // Two senders on one partition would interleave batches and reorder the
320
+ // lane; the flushing flag is what prevents that.
321
+ const sink = controlledSink()
322
+ const manager = new BufferManager(sink)
323
+ const options = { messageCount: 2, timeMillis: 60000, maxSize: 100, retryDelayMillis: 5 }
324
+
325
+ for (let n = 0; n < 10; n++) {
326
+ await manager.addMessage(ADDRESS, item(n), options)
327
+ }
328
+ await until(() => sink.inflightCount === 1, 'the first batch')
329
+ await tick(20)
330
+ assert.equal(sink.inflightCount, 1, 'a second drain started: batches can now interleave')
331
+ assert.equal(sink.attempts.length, 1)
332
+
333
+ manager.cleanup()
334
+ })
335
+
336
+ it('keeps separate buffers per queue/partition address', async () => {
337
+ const sink = controlledSink()
338
+ const manager = new BufferManager(sink)
339
+ const options = { messageCount: 100, timeMillis: 60000, maxSize: 400, retryDelayMillis: 5 }
340
+
341
+ await manager.addMessage('orders/eu', item(1), options)
342
+ await manager.addMessage('orders/us', item(2), options)
343
+ await manager.addMessage('other/eu', item(3), options)
344
+
345
+ const stats = manager.getStats()
346
+ assert.equal(stats.activeBuffers, 3)
347
+ assert.equal(stats.totalBufferedMessages, 3)
348
+
349
+ manager.cleanup()
350
+ })
351
+
352
+ it('keeps the public push API shape now that the add path can wait', async () => {
353
+ // addMessage became awaitable so backpressure could exist at all. The
354
+ // builder is what most callers actually touch, so pin its shape: a buffered
355
+ // push still resolves to { buffered, count } and still lands in the buffer.
356
+ // The builder is driven over the fake sink rather than a Queen, so the
357
+ // test owns the teardown: a real client's pending flush timer would keep
358
+ // the runner alive for the full timeMillis.
359
+ const sink = controlledSink()
360
+ const manager = new BufferManager(sink)
361
+ const builder = new QueueBuilder(null, sink, manager, 'orders')
362
+
363
+ const result = await builder
364
+ .partition('eu')
365
+ .buffer({ messageCount: 1000, timeMillis: 60000 })
366
+ .push([{ data: { n: 1 } }, { data: { n: 2 } }])
367
+
368
+ assert.deepEqual(result, { buffered: true, count: 2 })
369
+ const stats = manager.getStats()
370
+ assert.equal(stats.totalBufferedMessages, 2)
371
+ assert.equal(stats.activeBuffers, 1, 'one buffer per queue/partition, not one per push')
372
+
373
+ manager.cleanup()
374
+ })
375
+
376
+ it('reports the items a stopped buffer refused instead of counting them as pushed', async () => {
377
+ const sink = controlledSink()
378
+ const manager = new BufferManager(sink)
379
+ const builder = new QueueBuilder(null, sink, manager, 'orders')
380
+ manager.cleanup() // client already closing
381
+
382
+ // push() returns a thenable builder, not a Promise, so await it inside.
383
+ await assert.rejects(
384
+ async () => { await builder.buffer({ messageCount: 1000, timeMillis: 60000 }).push([{ data: { n: 1 } }]) },
385
+ /not buffered/
386
+ )
387
+ assert.equal(manager.getStats().totalBufferedMessages, 0, 'a closed client must not accumulate messages nothing will flush')
388
+ })
389
+
390
+ it('flushes a buffer that never reaches its count, on the timer', async () => {
391
+ // The branch every other test here avoids with a 60s timer, and the one a
392
+ // low-volume producer actually lives on.
393
+ const sink = flakySink(() => false)
394
+ const manager = new BufferManager(sink)
395
+ const options = { messageCount: 1000, timeMillis: 30, maxSize: 4000, retryDelayMillis: 5 }
396
+
397
+ await manager.addMessage(ADDRESS, item(1), options)
398
+ await manager.addMessage(ADDRESS, item(2), options)
399
+ assert.equal(manager.getStats().totalBufferedMessages, 2, 'two messages are nowhere near the threshold')
400
+
401
+ await until(() => sink.sent.length === 2, 'the time-based flush to fire')
402
+ manager.cleanup()
403
+ })
404
+ })