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.
@@ -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
- add(formattedMessage) {
21
- // Set first message time if this is the first message
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
- extractMessages(batchSize = null) {
57
- // If no batch size specified, extract all messages
58
- if (batchSize === null || batchSize >= this.#messages.length) {
59
- const messages = [...this.#messages]
60
- this.#messages = []
61
- this.#firstMessageTime = null
62
- this.#flushing = false
63
-
64
- if (this.#timer) {
65
- clearTimeout(this.#timer)
66
- this.#timer = null
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
- return messages
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
- // Extract a batch of messages
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
- setFlushing(value) {
90
- this.#flushing = value
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
- forceFlush() {
94
- // Immediately trigger flush, ignoring timers
95
- if (this.#timer) {
96
- clearTimeout(this.#timer)
97
- this.#timer = null
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
- this.#triggerFlush()
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
- if (this.#timer) {
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
- for (const item of this.#formattedItems) {
628
- const queueAddress = `${this.#queueName}/${this.#partition}`
629
- this.#bufferManager.addMessage(queueAddress, item, this.#bufferOptions)
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
- const result = { buffered: true, count: this.#formattedItems.length }
632
-
633
- logger.log('PushBuilder.execute', { status: 'buffered', count: this.#formattedItems.length })
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(this.#formattedItems)
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 // Or flush after 1 second
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",
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",