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.
@@ -1,24 +1,149 @@
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.
37
+ *
38
+ * A buffer also carries its DESTINATION (buffer/sinks.js): the route its
39
+ * batches are posted to and the shape they are posted in. It is fixed at
40
+ * creation and never changes, because it is a property of the address -- one
41
+ * address is one queue of one storage class -- and because a drain that could
42
+ * change route mid-retry would post a re-queued batch somewhere its earlier
43
+ * attempt did not go. Absent, it is the durable push, which is what every
44
+ * caller that predates ephemeral queues gets without knowing sinks exist.
3
45
  */
4
46
 
47
+ import { BUFFER_DEFAULTS } from '../utils/defaults.js'
48
+ import { DURABLE_DESTINATION } from './sinks.js'
49
+
5
50
  export class MessageBuffer {
6
51
  #queueAddress
7
52
  #messages = []
8
53
  #options
54
+ #destination
9
55
  #flushCallback
10
56
  #timer = null
11
57
  #firstMessageTime = null
12
58
  #flushing = false
59
+ #stopped = false
60
+ // Adds parked on the maxSize bound. Each entry can be woken (capacity freed)
61
+ // or failed (stop, or the caller's AbortSignal).
62
+ #waiters = []
63
+ // Counts adds that have been woken but have not resumed yet. JS resumes an
64
+ // awaiting function on a later microtask, so the waiter list is already empty
65
+ // while those adds are still in flight; without this counter BufferManager
66
+ // could drop the buffer entry out of its map in that window and the resumed
67
+ // add would append to an orphan nobody ever flushes.
68
+ #parked = 0
69
+ #stopWaiters = []
13
70
 
14
- constructor(queueAddress, options, flushCallback) {
71
+ constructor(queueAddress, options, flushCallback, destination = null) {
15
72
  this.#queueAddress = queueAddress
16
- this.#options = options
73
+ this.#options = MessageBuffer.normalizeOptions(options)
17
74
  this.#flushCallback = flushCallback
75
+ this.#destination = destination || DURABLE_DESTINATION
76
+ }
77
+
78
+ /**
79
+ * Fill in defaults and enforce the bound's invariants.
80
+ *
81
+ * `maxSize: 0` (or absent) means the DEFAULT bound, never "unbounded":
82
+ * unbounded is the defect this knob exists to close, so opting out of
83
+ * backpressure is deliberately not expressible. The floor keeps the bound
84
+ * sane when a caller sets a `messageCount` larger than their `maxSize` --
85
+ * a buffer that must block before it can even assemble one batch would
86
+ * deadlock against its own flush threshold.
87
+ */
88
+ static normalizeOptions(options) {
89
+ // The caller's raw options, NOT BUFFER_DEFAULTS spread over them: the bound
90
+ // is derived from whatever messageCount this buffer ended up with, so
91
+ // `buffer({ messageCount: 10 })` gets a bound of 40, not the 400 that suits
92
+ // the default batch of 100. Unknown keys are carried through untouched.
93
+ const provided = options || {}
94
+
95
+ const messageCount = provided.messageCount > 0 ? provided.messageCount : BUFFER_DEFAULTS.messageCount
96
+ const timeMillis = provided.timeMillis > 0 ? provided.timeMillis : BUFFER_DEFAULTS.timeMillis
97
+
98
+ let maxSize = provided.maxSize > 0 ? provided.maxSize : 4 * messageCount
99
+ if (maxSize < messageCount) maxSize = messageCount
100
+
101
+ const retryDelayMillis = provided.retryDelayMillis > 0
102
+ ? provided.retryDelayMillis
103
+ : BUFFER_DEFAULTS.retryDelayMillis
104
+
105
+ return { ...provided, messageCount, timeMillis, maxSize, retryDelayMillis }
18
106
  }
19
107
 
20
- add(formattedMessage) {
21
- // Set first message time if this is the first message
108
+ /**
109
+ * Append one message, waiting for room if the buffer is at its bound.
110
+ *
111
+ * Resolves once the message is in the buffer. Rejects if the buffer is
112
+ * stopped while parked, or if `signal` aborts -- an add that could not be
113
+ * buffered must never look like a successful push.
114
+ *
115
+ * @param {object} formattedMessage
116
+ * @param {{ signal?: AbortSignal }} [opts]
117
+ */
118
+ async add(formattedMessage, { signal } = {}) {
119
+ if (this.#stopped) {
120
+ throw new Error(`Queen buffer ${this.#queueAddress} is stopped: message not buffered`)
121
+ }
122
+ if (signal?.aborted) throw abortReason(signal)
123
+
124
+ // BACKPRESSURE. Re-checked in a loop, not once: a broadcast wakes every
125
+ // parked add, and the first ones to resume can fill the room that was
126
+ // freed, so the rest have to park again.
127
+ while (this.#messages.length >= this.#options.maxSize && !this.#stopped) {
128
+ // Being at the bound means producers outran the flusher. Make sure one is
129
+ // actually running before parking -- the time-based flush may be a full
130
+ // `timeMillis` away, and nothing else will start it.
131
+ this.#triggerFlush()
132
+ // The counter is held across the resumption itself, not just the wait:
133
+ // see #parked for why the window between "woken" and "resumed" matters.
134
+ this.#parked++
135
+ try {
136
+ await this.#waitForCapacity(signal)
137
+ } finally {
138
+ this.#parked--
139
+ }
140
+ if (signal?.aborted) throw abortReason(signal)
141
+ }
142
+
143
+ if (this.#stopped) {
144
+ throw new Error(`Queen buffer ${this.#queueAddress} stopped while waiting for capacity: message not buffered`)
145
+ }
146
+
22
147
  if (this.#messages.length === 0) {
23
148
  this.#firstMessageTime = Date.now()
24
149
  this.#startTimer()
@@ -26,77 +151,146 @@ export class MessageBuffer {
26
151
 
27
152
  this.#messages.push(formattedMessage)
28
153
 
29
- // Check if we should flush based on size
30
154
  if (this.#messages.length >= this.#options.messageCount) {
31
155
  this.#triggerFlush()
32
156
  }
33
157
  }
34
158
 
159
+ #waitForCapacity(signal) {
160
+ return new Promise((resolve, reject) => {
161
+ const waiter = { resolve, reject, signal, onAbort: null }
162
+
163
+ waiter.settle = (fn, arg) => {
164
+ const index = this.#waiters.indexOf(waiter)
165
+ if (index !== -1) this.#waiters.splice(index, 1)
166
+ if (waiter.onAbort) signal.removeEventListener('abort', waiter.onAbort)
167
+ fn(arg)
168
+ }
169
+
170
+ if (signal) {
171
+ waiter.onAbort = () => waiter.settle(reject, abortReason(signal))
172
+ signal.addEventListener('abort', waiter.onAbort, { once: true })
173
+ }
174
+
175
+ this.#waiters.push(waiter)
176
+ })
177
+ }
178
+
179
+ /**
180
+ * Wake every parked add. Called by the flusher after a batch is definitively
181
+ * gone (the POST succeeded), not when the batch is merely taken out of the
182
+ * buffer: a batch that fails goes straight back, and waking on extraction
183
+ * would let producers refill against room that never actually freed.
184
+ */
185
+ wakeWaiters() {
186
+ const waiters = this.#waiters.slice()
187
+ for (const waiter of waiters) waiter.settle(waiter.resolve)
188
+ }
189
+
35
190
  #startTimer() {
36
191
  if (this.#timer) return // Timer already running
37
192
 
193
+ // Deliberately NOT unref()'d: a short script that pushes and returns must
194
+ // stay alive long enough for the time-based flush to fire.
38
195
  this.#timer = setTimeout(() => {
196
+ this.#timer = null
39
197
  this.#triggerFlush()
40
198
  }, this.#options.timeMillis)
41
199
  }
42
200
 
43
201
  #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
202
+ if (this.#flushing || this.#stopped || this.#messages.length === 0) return
53
203
  this.#flushCallback(this.#queueAddress)
54
204
  }
55
205
 
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
- }
206
+ /**
207
+ * Claim the right to flush this buffer. Returns false when a flush is already
208
+ * running: one drain loop per buffer, so a burst of adds past the threshold
209
+ * cannot start a second sender that would interleave batches out of order.
210
+ */
211
+ beginFlush() {
212
+ if (this.#flushing || this.#stopped || this.#messages.length === 0) return false
213
+ this.#flushing = true
214
+ this.cancelTimer()
215
+ return true
216
+ }
68
217
 
69
- return messages
70
- }
218
+ /**
219
+ * Release the flush claim. Anything still buffered (a batch put back by a
220
+ * failed send, or messages added while the drain was stopping) gets a fresh
221
+ * timer, so it cannot sit there unnoticed until the next add.
222
+ */
223
+ endFlush() {
224
+ this.#flushing = false
225
+ if (this.#messages.length > 0 && !this.#stopped) this.#startTimer()
226
+ }
71
227
 
72
- // Extract a batch of messages
228
+ /**
229
+ * Take up to `batchSize` messages off the front.
230
+ *
231
+ * `splice` returns a fresh array, so the batch does not alias the buffer's
232
+ * storage and putting it back cannot corrupt what is left behind. (The Go
233
+ * reference has to copy explicitly there -- its slices do alias.)
234
+ */
235
+ takeBatch(batchSize) {
73
236
  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
-
237
+ if (this.#messages.length === 0) this.#firstMessageTime = null
86
238
  return messages
87
239
  }
88
240
 
89
- setFlushing(value) {
90
- this.#flushing = value
241
+ /**
242
+ * Put a failed batch back at the FRONT, preserving order: these messages were
243
+ * queued before everything still in the buffer, and a retry must not reorder
244
+ * a partition's lane. Occupancy can overshoot maxSize by this one batch --
245
+ * documented on BUFFER_DEFAULTS.maxSize.
246
+ */
247
+ restoreBatch(messages) {
248
+ if (messages.length === 0) return
249
+ this.#messages.unshift(...messages)
250
+ if (this.#firstMessageTime === null) this.#firstMessageTime = Date.now()
91
251
  }
92
252
 
93
- forceFlush() {
94
- // Immediately trigger flush, ignoring timers
95
- if (this.#timer) {
96
- clearTimeout(this.#timer)
97
- this.#timer = null
253
+ /**
254
+ * Sleep, but return early if the buffer is stopped. Used for the retry delay
255
+ * between attempts at a failed batch: a shutdown must not wait out a delay
256
+ * that only exists to pace a broker that is not answering.
257
+ */
258
+ sleepUnlessStopped(millis) {
259
+ if (this.#stopped || millis <= 0) return Promise.resolve()
260
+ return new Promise(resolve => {
261
+ let timer = null
262
+ const wake = () => {
263
+ if (timer) clearTimeout(timer)
264
+ const index = this.#stopWaiters.indexOf(wake)
265
+ if (index !== -1) this.#stopWaiters.splice(index, 1)
266
+ resolve()
267
+ }
268
+ timer = setTimeout(wake, millis)
269
+ this.#stopWaiters.push(wake)
270
+ })
271
+ }
272
+
273
+ /**
274
+ * Stop accepting messages and wake everything parked, so shutdown cannot
275
+ * hang. Parked adds REJECT rather than resolve: their message was never
276
+ * buffered, and reporting success for a message that was dropped on the floor
277
+ * is the failure mode this whole change exists to remove.
278
+ */
279
+ stop() {
280
+ if (this.#stopped) return
281
+ this.#stopped = true
282
+ this.cancelTimer()
283
+
284
+ const waiters = this.#waiters.slice()
285
+ for (const waiter of waiters) {
286
+ waiter.settle(
287
+ waiter.reject,
288
+ new Error(`Queen buffer ${this.#queueAddress} stopped while waiting for capacity: message not buffered`)
289
+ )
98
290
  }
99
- this.#triggerFlush()
291
+
292
+ const sleepers = this.#stopWaiters.splice(0, this.#stopWaiters.length)
293
+ for (const wake of sleepers) wake()
100
294
  }
101
295
 
102
296
  cancelTimer() {
@@ -115,18 +309,44 @@ export class MessageBuffer {
115
309
  return this.#options
116
310
  }
117
311
 
312
+ /** `{ sink, queue, partition }` -- where this buffer's batches are posted. */
313
+ get destination() {
314
+ return this.#destination
315
+ }
316
+
317
+ get isFlushing() {
318
+ return this.#flushing
319
+ }
320
+
321
+ get isStopped() {
322
+ return this.#stopped
323
+ }
324
+
325
+ /** True while any add is parked on the bound, or woken but not yet resumed. */
326
+ get hasParkedAdds() {
327
+ return this.#waiters.length > 0 || this.#parked > 0
328
+ }
329
+
118
330
  get firstMessageAge() {
119
331
  return this.#firstMessageTime ? Date.now() - this.#firstMessageTime : 0
120
332
  }
121
333
 
122
334
  cleanup() {
123
- if (this.#timer) {
124
- clearTimeout(this.#timer)
125
- this.#timer = null
126
- }
335
+ this.stop()
127
336
  this.#messages = []
128
337
  this.#firstMessageTime = null
129
338
  this.#flushing = false
130
339
  }
131
340
  }
132
341
 
342
+ /**
343
+ * The reason an AbortSignal carries, or a plain AbortError when the runtime
344
+ * (or the caller's controller) did not set one.
345
+ */
346
+ function abortReason(signal) {
347
+ if (signal.reason instanceof Error) return signal.reason
348
+ const error = new Error('Queen buffered push aborted while waiting for buffer capacity')
349
+ error.name = 'AbortError'
350
+ if (signal.reason !== undefined) error.cause = signal.reason
351
+ return error
352
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Drain sinks: WHERE a buffered batch goes, and in WHAT shape.
3
+ *
4
+ * The buffer machinery -- blocking backpressure at `maxSize`, one drain loop
5
+ * per address, a failed batch put back at the FRONT and retried until it lands
6
+ * or a flush deadline expires -- is about ordering, occupancy and loss. None of
7
+ * that is durable-specific, and none of it is worth writing twice. So the drain
8
+ * takes a SINK instead of a hardcoded POST:
9
+ *
10
+ * { path, format(queue, partition, batch) -> body }
11
+ *
12
+ * `format` receives the queue and partition because the two storage classes
13
+ * disagree about where that identity lives on the wire, and that disagreement
14
+ * is the entire reason this parameter exists:
15
+ *
16
+ * * the DURABLE push wire repeats `{queue, partition}` on EVERY item, so the
17
+ * envelope is just `{items}` and the sink ignores both arguments;
18
+ * * the EPHEMERAL push wire hoists them to the envelope --
19
+ * `{queue, partition?, messages:[{payload}...]}` -- so the batch elements
20
+ * carry nothing but their payload.
21
+ *
22
+ * DURABLE_SINK IS TODAY'S REQUEST, BYTE FOR BYTE. It is the default for a
23
+ * buffer created without a destination, which is every caller that existed
24
+ * before ephemeral queues did, and test-v2/ephemeral-unit/durableSinkPin.test.js
25
+ * exists for no other reason than to fail if that ever stops being true.
26
+ *
27
+ * ADDRESSES ARE NAMESPACED. A buffer address is the key of the one-buffer-one-
28
+ * drain map, so an ephemeral `orders` and a durable `orders` must not hash to
29
+ * the same entry -- they are unrelated objects (EPHEMERAL_QUEUES.md §10 Q8) and
30
+ * a shared buffer would post one family's messages to the other family's route.
31
+ * The `eph:` prefix is the same namespacing the broker applies to its own queue
32
+ * keys (§3.2), for the same reason.
33
+ */
34
+
35
+ /** The durable push wire: identity per item, envelope carries only the batch. */
36
+ export const DURABLE_SINK = {
37
+ name: 'durable',
38
+ path: '/api/v1/push',
39
+ format(_queue, _partition, batch) {
40
+ return { items: batch }
41
+ }
42
+ }
43
+
44
+ /** The ephemeral push wire (EPHEMERAL_QUEUES.md §3.1): identity on the envelope. */
45
+ export const EPHEMERAL_SINK = {
46
+ name: 'ephemeral',
47
+ path: '/api/v1/ephemeral/push',
48
+ format(queue, partition, batch) {
49
+ const body = { queue }
50
+ // Omitted, never defaulted client-side: which partition an ephemeral push
51
+ // without one lands on is the broker's rule, and inventing a 'Default' here
52
+ // would take that decision away from it in a way the caller never asked for.
53
+ if (partition !== null && partition !== undefined) body.partition = partition
54
+ body.messages = batch
55
+ return body
56
+ }
57
+ }
58
+
59
+ /**
60
+ * What a buffer drains into: the sink, plus the identity that sink formats for.
61
+ * The default is the durable push, so a buffer created without one behaves
62
+ * exactly as buffers did before sinks existed.
63
+ */
64
+ export const DURABLE_DESTINATION = { sink: DURABLE_SINK, queue: null, partition: null }
65
+
66
+ /** The ephemeral counterpart, bound to one (queue, partition). */
67
+ export function ephemeralDestination(queue, partition = null) {
68
+ return { sink: EPHEMERAL_SINK, queue, partition }
69
+ }
70
+
71
+ /**
72
+ * The durable buffer address, unchanged: `queue/partition`. Kept here next to
73
+ * its ephemeral sibling so the two can be compared at a glance.
74
+ */
75
+ export function durableAddress(queue, partition) {
76
+ return `${queue}/${partition}`
77
+ }
78
+
79
+ /**
80
+ * The ephemeral buffer address: `eph:queue/partition`, or `eph:queue` when the
81
+ * caller named no partition (which is a different destination from any named
82
+ * one, because the broker picks, and a buffer must not merge the two).
83
+ *
84
+ * Same ambiguity as the durable address -- a queue named `a/b` collides with
85
+ * (`a`, `b`) -- inherited deliberately rather than fixed on one side only.
86
+ */
87
+ export function ephemeralAddress(queue, partition = null) {
88
+ return partition === null || partition === undefined ? `eph:${queue}` : `eph:${queue}/${partition}`
89
+ }