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.
- package/README.md +62 -0
- package/client-v2/Queen.js +62 -2
- package/client-v2/admin/Admin.js +15 -1
- package/client-v2/buffer/BufferManager.js +198 -129
- package/client-v2/buffer/MessageBuffer.js +274 -54
- package/client-v2/buffer/sinks.js +89 -0
- package/client-v2/builders/QueueBuilder.js +117 -11
- package/client-v2/consumer/ConsumerManager.js +39 -4
- package/client-v2/ephemeral/Ephemeral.js +551 -0
- package/client-v2/index.js +12 -0
- package/client-v2/streams/Stream.js +3 -0
- package/client-v2/streams/runtime/Runner.js +18 -0
- package/client-v2/utils/conflation.js +118 -0
- package/client-v2/utils/defaults.js +23 -2
- package/package.json +3 -3
- package/test-v2/buffer-unit/buffer.test.js +404 -0
- package/test-v2/conflation-unit/conflationWire.test.js +398 -0
- package/test-v2/ephemeral-unit/_planServer.js +73 -0
- package/test-v2/ephemeral-unit/durableSinkPin.test.js +112 -0
- package/test-v2/ephemeral-unit/ephemeralBuffer.test.js +305 -0
- package/test-v2/ephemeral-unit/ephemeralWire.test.js +398 -0
|
@@ -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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|