queen-mq 1.0.6 → 1.2.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.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Buffered ephemeral push: the sink refactor, from both ends
3
+ * (EPHEMERAL_QUEUES.md §4.1).
4
+ *
5
+ * The claim being tested is "the machinery is REUSED, not duplicated". That
6
+ * splits into two questions and this file answers them with two different
7
+ * instruments:
8
+ *
9
+ * 1. does a buffered ephemeral push produce the ephemeral REQUEST? -- asked
10
+ * against a real socket through a real Queen, because the answer is bytes;
11
+ * 2. does it keep the buffer's ORDERING, RETRY and DEADLINE behaviour? --
12
+ * asked against a fake http client the test drives by hand, because the
13
+ * answer is timing, and a real server would only add flakiness to a
14
+ * question that has nothing to do with the network.
15
+ *
16
+ * The second half deliberately mirrors buffer-unit/buffer.test.js: the same
17
+ * controlled sink, the same `until` polling, the same assertions -- pointed at
18
+ * the ephemeral destination. If the two files ever disagree, the sink parameter
19
+ * has leaked into semantics it was never supposed to touch.
20
+ */
21
+
22
+ import { describe, it } from 'node:test'
23
+ import assert from 'node:assert/strict'
24
+
25
+ import { Queen } from '../../client-v2/index.js'
26
+ import { BufferManager } from '../../client-v2/buffer/BufferManager.js'
27
+ import { ephemeralAddress, ephemeralDestination, EPHEMERAL_SINK } from '../../client-v2/buffer/sinks.js'
28
+ import { withPlanServer, ok, pushed } from './_planServer.js'
29
+
30
+ const QUEUE = 'presence'
31
+ const ADDRESS = ephemeralAddress(QUEUE, 'room-7')
32
+ const DESTINATION = ephemeralDestination(QUEUE, 'room-7')
33
+
34
+ const message = n => ({ payload: { n } })
35
+ const payloadNumbers = items => items.map(i => i.payload.n)
36
+
37
+ const tick = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms))
38
+
39
+ async function until(predicate, what, timeoutMs = 2000) {
40
+ const deadline = Date.now() + timeoutMs
41
+ while (Date.now() < deadline) {
42
+ if (predicate()) return
43
+ await tick(2)
44
+ }
45
+ assert.fail(`timed out waiting for: ${what}`)
46
+ }
47
+
48
+ /** A fake http client whose every POST hangs until the test releases or fails it. */
49
+ function controlledSink() {
50
+ const sent = []
51
+ const attempts = []
52
+ const inflight = []
53
+
54
+ return {
55
+ sent,
56
+ attempts,
57
+ get inflightCount() { return inflight.length },
58
+ post(path, body) {
59
+ attempts.push({ path, body })
60
+ return new Promise((resolve, reject) => {
61
+ inflight.push({ messages: body.messages, resolve, reject })
62
+ })
63
+ },
64
+ release() {
65
+ const call = inflight.shift()
66
+ assert.ok(call, 'no POST in flight to release')
67
+ sent.push(...call.messages)
68
+ call.resolve({ pushed: call.messages.length })
69
+ },
70
+ reject(reason = 'connect ECONNREFUSED') {
71
+ const call = inflight.shift()
72
+ assert.ok(call, 'no POST in flight to reject')
73
+ call.reject(new Error(reason))
74
+ }
75
+ }
76
+ }
77
+
78
+ /** A fake http client that answers immediately, failing whenever asked to. */
79
+ function flakySink(shouldFail) {
80
+ const sent = []
81
+ let attempt = 0
82
+ return {
83
+ sent,
84
+ get attempts() { return attempt },
85
+ async post(_path, body) {
86
+ const index = attempt++
87
+ if (shouldFail(index)) throw new Error(`sink refused attempt ${index}`)
88
+ sent.push(...body.messages)
89
+ return { pushed: body.messages.length }
90
+ }
91
+ }
92
+ }
93
+
94
+ describe('buffered ephemeral push — the request it drains into', () => {
95
+ it('drains one batch to the ephemeral route, with the identity on the envelope', async () => {
96
+ await withPlanServer([pushed(3)], ok({ pushed: 0 }), async (url, hits) => {
97
+ const queen = new Queen({ url, handleSignals: false })
98
+ try {
99
+ const res = await queen.ephemeral.push(
100
+ QUEUE,
101
+ [{ n: 1 }, { n: 2 }, { n: 3 }],
102
+ { partition: 'room-7', buffered: { messageCount: 3, timeMillis: 60000 } }
103
+ )
104
+ // Resolves once the messages are IN the buffer, not once they landed.
105
+ assert.deepEqual(res, { buffered: true, count: 3 })
106
+
107
+ await until(() => hits.length === 1, 'the count threshold to drain one batch')
108
+ assert.equal(hits[0].method, 'POST')
109
+ assert.equal(hits[0].url, '/api/v1/ephemeral/push')
110
+ assert.deepEqual(hits[0].body, {
111
+ queue: QUEUE,
112
+ partition: 'room-7',
113
+ messages: [{ payload: { n: 1 } }, { payload: { n: 2 } }, { payload: { n: 3 } }]
114
+ })
115
+ } finally {
116
+ await queen.close()
117
+ }
118
+ })
119
+ })
120
+
121
+ it('omits `partition` on the drained batch when the push named none', async () => {
122
+ await withPlanServer([pushed(2)], ok({ pushed: 0 }), async (url, hits) => {
123
+ const queen = new Queen({ url, handleSignals: false })
124
+ try {
125
+ await queen.ephemeral.push(QUEUE, [{ n: 1 }, { n: 2 }], { buffered: { messageCount: 2, timeMillis: 60000 } })
126
+ await until(() => hits.length === 1, 'the batch')
127
+ assert.deepEqual(hits[0].body, { queue: QUEUE, messages: [{ payload: { n: 1 } }, { payload: { n: 2 } }] })
128
+ } finally {
129
+ await queen.close()
130
+ }
131
+ })
132
+ })
133
+
134
+ it('close() drains an ephemeral buffer that never reached its threshold', async () => {
135
+ // §4.1: the same flushAllBuffers deadline path the durable buffers use.
136
+ // Without it, a short-lived producer's last partial batch dies with the
137
+ // process — which the class contract permits and nobody wants.
138
+ await withPlanServer([pushed(1)], ok({ pushed: 0 }), async (url, hits) => {
139
+ const queen = new Queen({ url, handleSignals: false })
140
+ await queen.ephemeral.push(QUEUE, [{ n: 1 }], { buffered: { messageCount: 1000, timeMillis: 600000 } })
141
+ assert.equal(hits.length, 0, 'precondition: nothing has been sent yet')
142
+
143
+ await queen.close()
144
+
145
+ assert.equal(hits.length, 1, 'close() must flush what is buffered')
146
+ assert.equal(hits[0].url, '/api/v1/ephemeral/push')
147
+ assert.deepEqual(hits[0].body.messages, [{ payload: { n: 1 } }])
148
+ })
149
+ })
150
+
151
+ it('never shares a buffer with the durable queue of the same name', async () => {
152
+ // The two are unrelated objects (§10 Q8). A shared address would post one
153
+ // family's messages to the other family's route.
154
+ await withPlanServer([], ok({ pushed: 0 }), async (url, hits) => {
155
+ const queen = new Queen({ url, handleSignals: false })
156
+ try {
157
+ await queen.ephemeral.push(QUEUE, [{ n: 1 }], { partition: 'Default', buffered: { messageCount: 1000, timeMillis: 600000 } })
158
+ await queen.queue(QUEUE).partition('Default').buffer({ messageCount: 1000, timeMillis: 600000 }).push([{ data: { n: 2 } }])
159
+
160
+ assert.equal(queen.getBufferStats().activeBuffers, 2, 'one buffer per family, never one shared')
161
+ assert.equal(queen.getBufferStats().totalBufferedMessages, 2)
162
+
163
+ await queen.flushAllBuffers()
164
+ assert.equal(hits.length, 2)
165
+
166
+ const byPath = Object.fromEntries(hits.map(h => [h.url, h.body]))
167
+ assert.deepEqual(byPath['/api/v1/ephemeral/push'].messages, [{ payload: { n: 1 } }])
168
+ assert.equal(byPath['/api/v1/push'].items.length, 1)
169
+ assert.equal(byPath['/api/v1/push'].items[0].queue, QUEUE, 'the durable item still repeats its own identity')
170
+ } finally {
171
+ await queen.close()
172
+ }
173
+ })
174
+ })
175
+
176
+ it('addresses one buffer per queue/partition, namespaced under `eph:`', () => {
177
+ assert.equal(ephemeralAddress('presence', 'room-7'), 'eph:presence/room-7')
178
+ assert.equal(ephemeralAddress('presence'), 'eph:presence', 'no partition is its own destination, not `Default`')
179
+ assert.equal(EPHEMERAL_SINK.path, '/api/v1/ephemeral/push')
180
+ })
181
+ })
182
+
183
+ describe('buffered ephemeral push — the semantics it inherits', () => {
184
+ it('parks an add at the bound and resumes it when the flusher drains', async () => {
185
+ const sink = controlledSink()
186
+ const manager = new BufferManager(sink)
187
+ const options = { messageCount: 2, timeMillis: 60000, maxSize: 4, retryDelayMillis: 5 }
188
+
189
+ for (let n = 0; n < 6; n++) {
190
+ await manager.addMessage(ADDRESS, message(n), options, { destination: DESTINATION })
191
+ }
192
+ await until(() => sink.inflightCount === 1, 'the first batch to reach the sink')
193
+ assert.equal(manager.getStats().totalBufferedMessages, 4, 'the buffer should be sitting exactly at its bound')
194
+
195
+ let settled = false
196
+ const parked = manager.addMessage(ADDRESS, message(6), options, { destination: DESTINATION })
197
+ parked.then(() => { settled = true }, () => { settled = true })
198
+ await tick(20)
199
+ assert.equal(settled, false, 'add returned while the buffer was full: that is the unbounded defect')
200
+
201
+ sink.release()
202
+ await until(() => settled, 'the parked add to resume once capacity freed')
203
+ assert.deepEqual(payloadNumbers(sink.sent), [0, 1], 'the drained batch went out in order')
204
+ assert.equal(sink.attempts[0].path, '/api/v1/ephemeral/push')
205
+
206
+ manager.cleanup()
207
+ })
208
+
209
+ it('puts a failed batch back at the FRONT and retries the same messages, in order', async () => {
210
+ const sink = controlledSink()
211
+ const manager = new BufferManager(sink)
212
+ const options = { messageCount: 3, timeMillis: 60000, maxSize: 12, retryDelayMillis: 40 }
213
+
214
+ for (let n = 0; n < 3; n++) {
215
+ await manager.addMessage(ADDRESS, message(n), options, { destination: DESTINATION })
216
+ }
217
+ await until(() => sink.inflightCount === 1, 'the first attempt')
218
+
219
+ const failedAt = Date.now()
220
+ sink.reject()
221
+
222
+ await until(() => manager.getStats().totalBufferedMessages === 3, 'the failed batch to be re-queued')
223
+ assert.equal(manager.getStats().flushesPerformed, 0, 'a POST that never landed must not count as a flush')
224
+
225
+ await until(() => sink.inflightCount === 1, 'the retry')
226
+ assert.ok(Date.now() - failedAt >= 35, 'the retry must wait out retryDelayMillis, not spin')
227
+ assert.deepEqual(payloadNumbers(sink.attempts[1].body.messages), [0, 1, 2], 'the retry resends the same batch, in the same order')
228
+ assert.equal(sink.attempts[1].path, '/api/v1/ephemeral/push', 'and to the same route')
229
+
230
+ sink.release()
231
+ await until(() => manager.getStats().totalBufferedMessages === 0, 'the retry to drain the buffer')
232
+ assert.deepEqual(payloadNumbers(sink.sent), [0, 1, 2])
233
+
234
+ manager.cleanup()
235
+ })
236
+
237
+ it('loses nothing across a run with intermittent failures', async () => {
238
+ const total = 200
239
+ const sink = flakySink(attempt => attempt % 3 === 1)
240
+ const manager = new BufferManager(sink)
241
+ const options = { messageCount: 7, timeMillis: 50, maxSize: 21, retryDelayMillis: 1 }
242
+
243
+ for (let n = 0; n < total; n++) {
244
+ await manager.addMessage(ADDRESS, message(n), options, { destination: DESTINATION })
245
+ }
246
+ await manager.flushAllBuffers()
247
+
248
+ assert.equal(sink.sent.length, total, `sent ${sink.sent.length} of ${total}: messages were dropped`)
249
+ assert.deepEqual(payloadNumbers(sink.sent), Array.from({ length: total }, (_, n) => n), 'order was not preserved')
250
+ assert.ok(sink.attempts > total / options.messageCount, 'precondition: the sink actually failed some attempts')
251
+
252
+ manager.cleanup()
253
+ })
254
+
255
+ it('bounds an explicit flush by its deadline and says how much is still buffered', async () => {
256
+ const sink = flakySink(() => true)
257
+ const manager = new BufferManager(sink)
258
+ const options = { messageCount: 2, timeMillis: 60000, maxSize: 8, retryDelayMillis: 5 }
259
+
260
+ for (let n = 0; n < 4; n++) {
261
+ await manager.addMessage(ADDRESS, message(n), options, { destination: DESTINATION })
262
+ }
263
+
264
+ await assert.rejects(
265
+ () => manager.flushAllBuffers({ deadlineMillis: 30 }),
266
+ error => {
267
+ assert.equal(error.queenUnflushedCount, 4, 'the error must report what was left unsent')
268
+ assert.match(error.message, /still buffered/)
269
+ return true
270
+ }
271
+ )
272
+ assert.equal(manager.getStats().totalBufferedMessages, 4, 'a flush that gave up must leave the messages in the buffer, not drop them')
273
+
274
+ manager.cleanup()
275
+ })
276
+
277
+ it('reports the messages a stopped buffer refused instead of counting them as pushed', async () => {
278
+ await withPlanServer([], ok({ pushed: 0 }), async (url, hits) => {
279
+ const queen = new Queen({ url, handleSignals: false })
280
+ await queen.close() // client already closing
281
+
282
+ await assert.rejects(
283
+ () => queen.ephemeral.push(QUEUE, [{ n: 1 }], { buffered: { messageCount: 1000, timeMillis: 60000 } }),
284
+ /not buffered/
285
+ )
286
+ assert.equal(hits.length, 0)
287
+ })
288
+ })
289
+
290
+ it('translates `intervalMillis` into the linger the buffer actually reads', async () => {
291
+ // MessageBuffer carries unknown option keys through untouched, so an
292
+ // un-translated `intervalMillis` would leave the buffer on its 1s default
293
+ // and silently ignore what the caller asked for.
294
+ await withPlanServer([pushed(1)], ok({ pushed: 0 }), async (url, hits) => {
295
+ const queen = new Queen({ url, handleSignals: false })
296
+ try {
297
+ await queen.ephemeral.push(QUEUE, [{ n: 1 }], { buffered: { messageCount: 1000, intervalMillis: 20 } })
298
+ await until(() => hits.length === 1, 'the time-based flush to fire on the interval it was given')
299
+ assert.deepEqual(hits[0].body.messages, [{ payload: { n: 1 } }])
300
+ } finally {
301
+ await queen.close()
302
+ }
303
+ })
304
+ })
305
+ })