queen-mq 1.0.6 → 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 +47 -0
- package/client-v2/Queen.js +50 -0
- package/client-v2/admin/Admin.js +15 -1
- package/client-v2/buffer/BufferManager.js +18 -6
- package/client-v2/buffer/MessageBuffer.js +17 -1
- package/client-v2/buffer/sinks.js +89 -0
- package/client-v2/builders/QueueBuilder.js +71 -2
- 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 +8 -1
- package/package.json +3 -3
- 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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable sink, pinned to the byte.
|
|
3
|
+
*
|
|
4
|
+
* EPHEMERAL_QUEUES.md §4.1 buys the ephemeral buffered push by PARAMETRIZING
|
|
5
|
+
* the drain rather than duplicating it, and §7.3 names the price of that
|
|
6
|
+
* bargain: "a pin that the DURABLE sink's bodies are byte-identical before and
|
|
7
|
+
* after the sink refactor". This file is that pin.
|
|
8
|
+
*
|
|
9
|
+
* It is written against the durable path only. Nothing here mentions an
|
|
10
|
+
* ephemeral queue, and that is deliberate: the question it answers is not
|
|
11
|
+
* "does the new feature work" but "did the refactor that made the new feature
|
|
12
|
+
* possible move a single byte on the path that was already in production". A
|
|
13
|
+
* `{queue, partition, payload, transactionId}` item whose key ORDER changed, an
|
|
14
|
+
* envelope that grew a `queue` field because the ephemeral wire has one, a path
|
|
15
|
+
* that became `/api/v1/push/batch` -- none of those would fail a deepEqual on a
|
|
16
|
+
* parsed body, and all of them are a broken 1.0.6 producer.
|
|
17
|
+
*
|
|
18
|
+
* The literal below is the request the buffered durable push made before sinks
|
|
19
|
+
* existed. It is not derived from the code under test: derive it and the pin
|
|
20
|
+
* pins nothing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it } from 'node:test'
|
|
24
|
+
import assert from 'node:assert/strict'
|
|
25
|
+
|
|
26
|
+
import { Queen } from '../../client-v2/index.js'
|
|
27
|
+
import { BufferManager } from '../../client-v2/buffer/BufferManager.js'
|
|
28
|
+
import { MessageBuffer } from '../../client-v2/buffer/MessageBuffer.js'
|
|
29
|
+
import { DURABLE_SINK, DURABLE_DESTINATION, durableAddress } from '../../client-v2/buffer/sinks.js'
|
|
30
|
+
import { withPlanServer, ok } from './_planServer.js'
|
|
31
|
+
|
|
32
|
+
const tick = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms))
|
|
33
|
+
|
|
34
|
+
async function until(predicate, what, timeoutMs = 2000) {
|
|
35
|
+
const deadline = Date.now() + timeoutMs
|
|
36
|
+
while (Date.now() < deadline) {
|
|
37
|
+
if (predicate()) return
|
|
38
|
+
await tick(2)
|
|
39
|
+
}
|
|
40
|
+
assert.fail(`timed out waiting for: ${what}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The exact bytes a buffered durable push of two items has always produced. */
|
|
44
|
+
const PINNED_BODY =
|
|
45
|
+
'{"items":[' +
|
|
46
|
+
'{"queue":"orders","partition":"Default","payload":{"n":1},"transactionId":"fixed-1"},' +
|
|
47
|
+
'{"queue":"orders","partition":"Default","payload":{"n":2},"transactionId":"fixed-2"}' +
|
|
48
|
+
']}'
|
|
49
|
+
|
|
50
|
+
describe('durable sink — byte identity', () => {
|
|
51
|
+
it('drains the buffered durable push to the same path, with the same bytes', async () => {
|
|
52
|
+
await withPlanServer([ok([])], ok([]), async (url, hits) => {
|
|
53
|
+
const queen = new Queen({ url, handleSignals: false })
|
|
54
|
+
try {
|
|
55
|
+
await queen
|
|
56
|
+
.queue('orders')
|
|
57
|
+
.partition('Default')
|
|
58
|
+
.buffer({ messageCount: 2, timeMillis: 60000 })
|
|
59
|
+
.push([
|
|
60
|
+
{ data: { n: 1 }, transactionId: 'fixed-1' },
|
|
61
|
+
{ data: { n: 2 }, transactionId: 'fixed-2' }
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
await until(() => hits.length === 1, 'the count threshold to drain the batch')
|
|
65
|
+
assert.equal(hits[0].method, 'POST')
|
|
66
|
+
assert.equal(hits[0].url, '/api/v1/push')
|
|
67
|
+
assert.equal(hits[0].raw, PINNED_BODY, 'the durable buffered push must be byte-identical to 1.0.6')
|
|
68
|
+
} finally {
|
|
69
|
+
await queen.close()
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('formats the envelope from the batch alone — the queue and partition arguments do not leak into it', () => {
|
|
75
|
+
// The durable wire repeats the identity on every ITEM, so the sink is handed
|
|
76
|
+
// a queue and a partition it must ignore. An implementation that hoisted
|
|
77
|
+
// them to the envelope "for symmetry" with the ephemeral sink would produce
|
|
78
|
+
// a body no broker of any version has ever parsed.
|
|
79
|
+
const batch = [{ queue: 'orders', partition: 'Default', payload: { n: 1 }, transactionId: 'fixed-1' }]
|
|
80
|
+
assert.equal(DURABLE_SINK.path, '/api/v1/push')
|
|
81
|
+
assert.equal(JSON.stringify(DURABLE_SINK.format('ignored', 'ignored', batch)), JSON.stringify({ items: batch }))
|
|
82
|
+
assert.equal(JSON.stringify(DURABLE_SINK.format(null, null, batch)), JSON.stringify({ items: batch }))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('is what a buffer gets when nobody names a destination', async () => {
|
|
86
|
+
// Every caller written before sinks existed passes three arguments to
|
|
87
|
+
// addMessage. They must keep draining where they always did, without
|
|
88
|
+
// knowing the parameter exists.
|
|
89
|
+
const posts = []
|
|
90
|
+
const httpClient = { async post(path, body) { posts.push({ path, body }); return [] } }
|
|
91
|
+
const manager = new BufferManager(httpClient)
|
|
92
|
+
|
|
93
|
+
const address = durableAddress('orders', 'Default')
|
|
94
|
+
assert.equal(address, 'orders/Default', 'the durable address is unchanged: no namespace prefix')
|
|
95
|
+
|
|
96
|
+
await manager.addMessage(address, { queue: 'orders', partition: 'Default', payload: { n: 1 }, transactionId: 'fixed-1' }, { messageCount: 1, timeMillis: 60000 })
|
|
97
|
+
await until(() => posts.length === 1, 'the drain')
|
|
98
|
+
|
|
99
|
+
assert.equal(posts[0].path, '/api/v1/push')
|
|
100
|
+
assert.deepEqual(Object.keys(posts[0].body), ['items'], 'the durable envelope is {items} and only {items}')
|
|
101
|
+
assert.deepEqual(posts[0].body.items, [{ queue: 'orders', partition: 'Default', payload: { n: 1 }, transactionId: 'fixed-1' }])
|
|
102
|
+
|
|
103
|
+
manager.cleanup()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('defaults a MessageBuffer built without one to the durable destination', () => {
|
|
107
|
+
const buffer = new MessageBuffer('orders/Default', { messageCount: 1 }, () => {})
|
|
108
|
+
assert.equal(buffer.destination, DURABLE_DESTINATION)
|
|
109
|
+
assert.equal(buffer.destination.sink.path, '/api/v1/push')
|
|
110
|
+
buffer.cleanup()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -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
|
+
})
|