queen-mq 2.1.0 → 2.6.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 +109 -2
- package/client-v2/Queen.js +9 -3
- package/client-v2/README.md +116 -1
- package/client-v2/builders/QueueBuilder.js +34 -0
- package/client-v2/http/HttpClient.js +67 -3
- package/client-v2/http/popFrames.js +211 -0
- package/client-v2/http/pushBinary.js +367 -0
- package/client-v2/http/zstd.js +41 -0
- package/client-v2/reads/offsetReads.js +252 -0
- package/client-v2/utils/defaults.js +17 -1
- package/package.json +2 -2
- package/test-v2/reads-unit/offsetReads.test.js +380 -0
- package/test-v2/reads.js +109 -0
- package/test-v2/run.js +2 -0
- package/test-v2/wire-unit/_wireServer.js +47 -0
- package/test-v2/wire-unit/popFrames.test.js +264 -0
- package/test-v2/wire-unit/pushBinary.test.js +235 -0
- package/test-v2/wire-unit/transactionFrames.test.js +222 -0
package/test-v2/reads.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads by offset (queue.fetch) and partition discovery (queue.changedSince)
|
|
3
|
+
* against a live broker.
|
|
4
|
+
*
|
|
5
|
+
* Run: node run.js fetchReadsEveryRecordByOffset
|
|
6
|
+
* node run.js changedSinceListsTheWrittenPartitions
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const QUEUE_FETCH = 'test-queue-v2-reads-fetch'
|
|
10
|
+
const QUEUE_CHANGED = 'test-queue-v2-reads-changed'
|
|
11
|
+
|
|
12
|
+
export async function fetchReadsEveryRecordByOffset(client) {
|
|
13
|
+
// 1,200 partitions: more than one fetch call (1000 entries each).
|
|
14
|
+
const partitions = Array.from({ length: 1200 }, (_, i) => `entity-${i}`)
|
|
15
|
+
const perPartition = 3
|
|
16
|
+
for (let n = 0; n < perPartition; n++) {
|
|
17
|
+
const items = partitions.map(p => ({ partition: p, transactionId: `${p}-${n}`, data: { p, n } }))
|
|
18
|
+
for (let i = 0; i < items.length; i += 500) {
|
|
19
|
+
const chunk = items.slice(i, i + 500)
|
|
20
|
+
// One push per partition group keeps the per-partition order of n.
|
|
21
|
+
await Promise.all(chunk.map(x => client.queue(QUEUE_FETCH).partition(x.partition).push([{ transactionId: x.transactionId, data: x.data }])))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const read = await client.queue(QUEUE_FETCH).fetch(partitions)
|
|
26
|
+
if (read.size !== partitions.length) {
|
|
27
|
+
return { success: false, message: `fetch answered ${read.size} partitions for ${partitions.length}` }
|
|
28
|
+
}
|
|
29
|
+
for (const p of partitions) {
|
|
30
|
+
const part = read.get(p)
|
|
31
|
+
const got = part.records.map(r => `${r.offset}:${r.payload.p}:${r.payload.n}:${r.transactionId}`)
|
|
32
|
+
const want = Array.from({ length: perPartition }, (_, n) => `${n}:${p}:${n}:${p}-${n}`)
|
|
33
|
+
if (got.join(',') !== want.join(',')) {
|
|
34
|
+
return { success: false, message: `${p}: read ${got.join(',')}, expected ${want.join(',')}` }
|
|
35
|
+
}
|
|
36
|
+
if (part.highWatermark !== perPartition || part.nextOffset !== perPartition || part.logStartOffset !== 0) {
|
|
37
|
+
return { success: false, message: `${p}: bounds ${JSON.stringify(part)}` }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A later read from nextOffset answers only what came after.
|
|
42
|
+
await client.queue(QUEUE_FETCH).partition('entity-7').push([{ transactionId: 'entity-7-late', data: { late: true } }])
|
|
43
|
+
const later = await client.queue(QUEUE_FETCH).fetch(['entity-7', 'entity-8'], {
|
|
44
|
+
offsets: { 'entity-7': read.get('entity-7').nextOffset, 'entity-8': read.get('entity-8').nextOffset }
|
|
45
|
+
})
|
|
46
|
+
const late = later.get('entity-7').records
|
|
47
|
+
if (late.length !== 1 || late[0].offset !== perPartition || late[0].payload.late !== true) {
|
|
48
|
+
return { success: false, message: `read from nextOffset answered ${JSON.stringify(late)}` }
|
|
49
|
+
}
|
|
50
|
+
if (later.get('entity-8').records.length !== 0) {
|
|
51
|
+
return { success: false, message: 'a partition with nothing new answered records' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The reads took no lease and moved no cursor: a pop still gets the message.
|
|
55
|
+
const popped = await client.queue(QUEUE_FETCH).partition('entity-9').batch(10).wait(false).pop()
|
|
56
|
+
if (popped.length !== perPartition) {
|
|
57
|
+
return { success: false, message: `a pop after the fetch got ${popped.length} messages, expected ${perPartition}` }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const missing = await client.queue('test-queue-v2-reads-does-not-exist').fetch(['x'])
|
|
61
|
+
if (missing.get('x').records.length !== 0) {
|
|
62
|
+
return { success: false, message: 'a queue that does not exist answered records' }
|
|
63
|
+
}
|
|
64
|
+
return { success: true, message: `read ${partitions.length} partitions by offset` }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function changedSinceListsTheWrittenPartitions(client) {
|
|
68
|
+
const before = new Date(Date.now() - 60_000)
|
|
69
|
+
const names = Array.from({ length: 1100 }, (_, i) => `c-${i}`)
|
|
70
|
+
for (let i = 0; i < names.length; i += 100) {
|
|
71
|
+
await Promise.all(names.slice(i, i + 100).map(p => client.queue(QUEUE_CHANGED).partition(p).push([{ data: { p } }])))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 1,100 partitions: two pages.
|
|
75
|
+
const first = await client.queue(QUEUE_CHANGED).changedSince(before)
|
|
76
|
+
const listed = new Set(first.partitions.map(p => p.name))
|
|
77
|
+
const missing = names.filter(n => !listed.has(n))
|
|
78
|
+
if (missing.length > 0) {
|
|
79
|
+
return { success: false, message: `changedSince missed ${missing.length} partitions, e.g. ${missing.slice(0, 3)}` }
|
|
80
|
+
}
|
|
81
|
+
if (first.partitions.length !== names.length || typeof first.safeTime !== 'string') {
|
|
82
|
+
return { success: false, message: `changedSince answered ${first.partitions.length} partitions, safeTime ${first.safeTime}` }
|
|
83
|
+
}
|
|
84
|
+
const one = first.partitions.find(p => p.name === 'c-5')
|
|
85
|
+
if (one.lastOffset !== 0 || one.logStart !== 0 || typeof one.lastWriteAt !== 'string' || typeof one.id !== 'string') {
|
|
86
|
+
return { success: false, message: `partition fields ${JSON.stringify(one)}` }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// From the safeTime of that pass, a write to one partition lists it, and
|
|
90
|
+
// nothing written before that safeTime comes back.
|
|
91
|
+
await client.queue(QUEUE_CHANGED).partition('c-42').push([{ data: { again: true } }])
|
|
92
|
+
const next = await client.queue(QUEUE_CHANGED).changedSince(first.safeTime)
|
|
93
|
+
if (!next.partitions.some(p => p.name === 'c-42' && p.lastOffset === 1)) {
|
|
94
|
+
return { success: false, message: `c-42 not listed after its write: ${JSON.stringify(next.partitions.slice(0, 5))}` }
|
|
95
|
+
}
|
|
96
|
+
if (next.partitions.some(p => p.lastWriteAt < first.safeTime)) {
|
|
97
|
+
return { success: false, message: 'a partition written before safeTime was listed' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const all = await client.queue(QUEUE_CHANGED).changedSince()
|
|
101
|
+
if (all.partitions.length !== names.length) {
|
|
102
|
+
return { success: false, message: `changedSince() without since answered ${all.partitions.length}` }
|
|
103
|
+
}
|
|
104
|
+
const none = await client.queue('test-queue-v2-reads-does-not-exist').changedSince(before)
|
|
105
|
+
if (none.partitions.length !== 0) {
|
|
106
|
+
return { success: false, message: 'a queue that does not exist listed partitions' }
|
|
107
|
+
}
|
|
108
|
+
return { success: true, message: `listed ${names.length} partitions over two pages` }
|
|
109
|
+
}
|
package/test-v2/run.js
CHANGED
|
@@ -20,6 +20,7 @@ import * as timerTests from './timers.js'
|
|
|
20
20
|
import * as lockTests from './locks.js'
|
|
21
21
|
import * as streamTests from './stream/index.js'
|
|
22
22
|
import * as docsTests from './docs.js'
|
|
23
|
+
import * as readsTests from './reads.js'
|
|
23
24
|
import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
|
|
24
25
|
|
|
25
26
|
|
|
@@ -117,6 +118,7 @@ async function main() {
|
|
|
117
118
|
kvTests,
|
|
118
119
|
timerTests,
|
|
119
120
|
lockTests,
|
|
121
|
+
readsTests,
|
|
120
122
|
docsTests
|
|
121
123
|
]
|
|
122
124
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A real node:http server for the binary wire forms: it records every request
|
|
3
|
+
* with its body as bytes (kv-unit/_planServer.js reads bodies as text, which a
|
|
4
|
+
* binary push body does not survive) and answers what `answer(hit)` returns:
|
|
5
|
+
* `{ status, contentType, body }`, body a Buffer or a string.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createServer } from 'node:http'
|
|
9
|
+
|
|
10
|
+
export function withWireServer(answer, run) {
|
|
11
|
+
const hits = []
|
|
12
|
+
|
|
13
|
+
const server = createServer((req, res) => {
|
|
14
|
+
const chunks = []
|
|
15
|
+
req.on('data', chunk => chunks.push(chunk))
|
|
16
|
+
req.on('end', () => {
|
|
17
|
+
const hit = { method: req.method, url: req.url, headers: req.headers, raw: Buffer.concat(chunks) }
|
|
18
|
+
hits.push(hit)
|
|
19
|
+
const { status = 200, contentType = 'application/json', body = '' } = answer(hit) || {}
|
|
20
|
+
if (status === 204) {
|
|
21
|
+
res.writeHead(204)
|
|
22
|
+
res.end()
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
res.writeHead(status, { 'Content-Type': contentType })
|
|
26
|
+
res.end(body)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
server.listen(0, '127.0.0.1', () => {
|
|
32
|
+
const { port } = server.address()
|
|
33
|
+
const teardown = () => new Promise(r => server.close(r))
|
|
34
|
+
Promise.resolve()
|
|
35
|
+
.then(() => run(`http://127.0.0.1:${port}`, hits))
|
|
36
|
+
.then(
|
|
37
|
+
async (value) => { await teardown(); resolve(value) },
|
|
38
|
+
async (err) => { await teardown(); reject(err) }
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function query(url) {
|
|
45
|
+
const i = url.indexOf('?')
|
|
46
|
+
return new URLSearchParams(i < 0 ? '' : url.slice(i + 1))
|
|
47
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framed pop answer (client-v2/http/popFrames.js), against hand-built
|
|
3
|
+
* answers and a server that plays them. What is at stake: a consumer that
|
|
4
|
+
* sets framedPop must be handed exactly the messages the JSON answer gives it,
|
|
5
|
+
* and one that does not set it must send the request every released broker
|
|
6
|
+
* knows.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it } from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import { zstdCompressSync } from 'node:zlib'
|
|
12
|
+
|
|
13
|
+
import { Queen, CONFLATION_UNSUPPORTED } from '../../client-v2/index.js'
|
|
14
|
+
import { decodePopFrames, isPopFrames } from '../../client-v2/http/popFrames.js'
|
|
15
|
+
import { withWireServer, query } from './_wireServer.js'
|
|
16
|
+
|
|
17
|
+
const u16 = (n) => { const b = Buffer.alloc(2); b.writeUInt16LE(n); return b }
|
|
18
|
+
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32LE(n); return b }
|
|
19
|
+
const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b }
|
|
20
|
+
const text = (s) => Buffer.concat([u16(Buffer.byteLength(s)), Buffer.from(s)])
|
|
21
|
+
|
|
22
|
+
/** One stored frame: u32 body_len | flags | id | [trace] | txn | [producer] | payload. */
|
|
23
|
+
function storedFrame(id, txn, payload, { producer = null, trace = null } = {}) {
|
|
24
|
+
const idBytes = Buffer.alloc(16)
|
|
25
|
+
idBytes[0] = id
|
|
26
|
+
idBytes[6] = 0x70
|
|
27
|
+
idBytes[8] = 0x80
|
|
28
|
+
const body = Buffer.concat([
|
|
29
|
+
Buffer.of((trace ? 1 : 0) | (producer ? 2 : 0)),
|
|
30
|
+
idBytes,
|
|
31
|
+
trace ? Buffer.from(trace.replaceAll('-', ''), 'hex') : Buffer.alloc(0),
|
|
32
|
+
text(txn),
|
|
33
|
+
producer ? text(producer) : Buffer.alloc(0),
|
|
34
|
+
Buffer.from(payload)
|
|
35
|
+
])
|
|
36
|
+
return Buffer.concat([u32(body.length), body])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A framed pop answer of one claim on partition 7 ("p7") of "orders", lease
|
|
41
|
+
* "lease-1": one record at base offset 10 holding `frames`, created at
|
|
42
|
+
* 2023-11-14T22:13:20Z, of which the claim covers start..=end.
|
|
43
|
+
*/
|
|
44
|
+
function framedAnswer(own, start, end, compress, frames) {
|
|
45
|
+
let raw = Buffer.concat(frames)
|
|
46
|
+
if (compress) raw = zstdCompressSync(raw)
|
|
47
|
+
return Buffer.concat([
|
|
48
|
+
Buffer.from('QPF1'), Buffer.of(1), u32(Buffer.byteLength(own)), Buffer.from(own),
|
|
49
|
+
text('orders'), text('lease-1'), text('__QUEUE_MODE__'),
|
|
50
|
+
u32(1), // claims
|
|
51
|
+
u64(7), text('p7'), u64(start), u64(end),
|
|
52
|
+
u32(3), // delivery attempt
|
|
53
|
+
u32(1), // records
|
|
54
|
+
u64(10), u64(1_700_000_000_000_000n), u32(frames.length), Buffer.of(compress ? 1 : 0), u32(raw.length), raw
|
|
55
|
+
])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const twoFrames = () => [storedFrame(1, 't-10', '{"n":10}'), storedFrame(2, 't-11', '{"n":11,"s":"x"}', { producer: 'svc' })]
|
|
59
|
+
|
|
60
|
+
// The JSON answer of the same two messages, as the broker renders it.
|
|
61
|
+
const TWO_MESSAGES_JSON = '{"success":true,"queue":"orders","partition":"p7","partitionId":"7","leaseId":"lease-1","consumerGroup":"__QUEUE_MODE__","messages":[' +
|
|
62
|
+
'{"id":"01000000-0000-7000-8000-000000000000","transactionId":"t-10","traceId":null,"data":{"n":10},"producerSub":null,"createdAt":"2023-11-14T22:13:20.000Z","partitionId":"7","partition":"p7","leaseId":"lease-1","consumerGroup":"__QUEUE_MODE__","deliveryAttempt":3,"offset":10},' +
|
|
63
|
+
'{"id":"02000000-0000-7000-8000-000000000000","transactionId":"t-11","traceId":null,"data":{"n":11,"s":"x"},"producerSub":"svc","createdAt":"2023-11-14T22:13:20.000Z","partitionId":"7","partition":"p7","leaseId":"lease-1","consumerGroup":"__QUEUE_MODE__","deliveryAttempt":3,"offset":11}' +
|
|
64
|
+
'],"partitionsClaimed":1}'
|
|
65
|
+
|
|
66
|
+
const json = (body) => ({ body })
|
|
67
|
+
const framed = (body) => ({ contentType: 'application/x-queen-pop', body })
|
|
68
|
+
const pops = (hits) => hits.filter(h => h.method === 'GET' && h.url.startsWith('/api/v1/pop'))
|
|
69
|
+
|
|
70
|
+
async function withQueen(config, answer, run) {
|
|
71
|
+
await withWireServer(answer, async (url, hits) => {
|
|
72
|
+
const queen = new Queen({ url, handleSignals: false, retryAttempts: 1, ...config })
|
|
73
|
+
try {
|
|
74
|
+
await run(queen, hits)
|
|
75
|
+
} finally {
|
|
76
|
+
await queen.close()
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('decodePopFrames', () => {
|
|
82
|
+
for (const compress of [false, true]) {
|
|
83
|
+
it(`unpacks ${compress ? 'a zstd' : 'a raw'} record into the JSON answer's object`, async () => {
|
|
84
|
+
const got = await decodePopFrames(framedAnswer('{"frames":true}', 10, 11, compress, twoFrames()))
|
|
85
|
+
|
|
86
|
+
assert.deepEqual(got, JSON.parse(TWO_MESSAGES_JSON))
|
|
87
|
+
assert.deepEqual(Object.keys(got.messages[0]), Object.keys(JSON.parse(TWO_MESSAGES_JSON).messages[0]))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it(`keeps only the messages inside the claim (${compress ? 'zstd' : 'raw'})`, async () => {
|
|
91
|
+
const got = await decodePopFrames(framedAnswer('{"frames":true}', 11, 11, compress, twoFrames()))
|
|
92
|
+
|
|
93
|
+
assert.equal(got.messages.length, 1)
|
|
94
|
+
assert.equal(got.messages[0].offset, 11)
|
|
95
|
+
assert.equal(got.messages[0].transactionId, 't-11')
|
|
96
|
+
assert.equal(got.messages[0].producerSub, 'svc')
|
|
97
|
+
assert.equal(got.messages[0].id, '02000000-0000-7000-8000-000000000000')
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
it('reads a trace id, an empty payload as null, and the answer without its own keys', async () => {
|
|
102
|
+
const trace = '0190aaaa-0000-4000-8000-000000000001'
|
|
103
|
+
const answer = framedAnswer('', 10, 11, false, [storedFrame(1, 't-10', '', { trace }), storedFrame(2, 't-11', '[1,"due"]')])
|
|
104
|
+
const got = await decodePopFrames(answer)
|
|
105
|
+
|
|
106
|
+
assert.equal(got.messages[0].traceId, trace)
|
|
107
|
+
assert.equal(got.messages[0].data, null)
|
|
108
|
+
assert.deepEqual(got.messages[1].data, [1, 'due'])
|
|
109
|
+
|
|
110
|
+
// Version 0 has no JSON object in front of the names.
|
|
111
|
+
const v0 = Buffer.concat([Buffer.from('QPF1'), Buffer.of(0), answer.subarray(9)])
|
|
112
|
+
assert.deepEqual(await decodePopFrames(v0), got)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('adds the answer\'s own keys, without `frames`', async () => {
|
|
116
|
+
const own = '{"frames":true,"conflation":true,"autopilot":{"partitions":4,"batch":50}}'
|
|
117
|
+
const got = await decodePopFrames(framedAnswer(own, 10, 11, false, twoFrames()))
|
|
118
|
+
|
|
119
|
+
assert.equal('frames' in got, false)
|
|
120
|
+
assert.equal(got.conflation, true)
|
|
121
|
+
assert.deepEqual(got.autopilot, { partitions: 4, batch: 50 })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('rejects damage', async () => {
|
|
125
|
+
const answer = framedAnswer('{"frames":true}', 10, 11, false, twoFrames())
|
|
126
|
+
|
|
127
|
+
assert.equal(isPopFrames(answer), true)
|
|
128
|
+
assert.equal(isPopFrames(Buffer.from(TWO_MESSAGES_JSON)), false)
|
|
129
|
+
await assert.rejects(decodePopFrames(answer.subarray(0, answer.length - 3)), /framed pop answer/)
|
|
130
|
+
await assert.rejects(decodePopFrames(Buffer.from(TWO_MESSAGES_JSON)), /not a framed pop answer/)
|
|
131
|
+
|
|
132
|
+
const future = Buffer.from(answer)
|
|
133
|
+
future[4] = 9
|
|
134
|
+
await assert.rejects(decodePopFrames(future), /unknown version 9/)
|
|
135
|
+
|
|
136
|
+
// A record that declares more frames than it holds.
|
|
137
|
+
const short = Buffer.from(answer)
|
|
138
|
+
const frames = twoFrames()
|
|
139
|
+
short.writeUInt32LE(3, short.length - frames[0].length - frames[1].length - 9)
|
|
140
|
+
await assert.rejects(decodePopFrames(short), /a record is shorter than its 3 frames/)
|
|
141
|
+
|
|
142
|
+
const badCodec = Buffer.from(answer)
|
|
143
|
+
badCodec[badCodec.length - frames[0].length - frames[1].length - 5] = 7
|
|
144
|
+
await assert.rejects(decodePopFrames(badCodec), /unknown codec 7/)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('pop — the default', () => {
|
|
149
|
+
it('sends no frames parameter', async () => {
|
|
150
|
+
await withQueen({}, () => json(TWO_MESSAGES_JSON), async (queen, hits) => {
|
|
151
|
+
const messages = await queen.queue('orders').batch(2).wait(false).pop()
|
|
152
|
+
|
|
153
|
+
assert.equal(messages.length, 2)
|
|
154
|
+
assert.equal(query(pops(hits)[0].url).has('frames'), false)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('pop — framedPop', () => {
|
|
160
|
+
for (const compress of [false, true]) {
|
|
161
|
+
it(`gives the messages of the JSON answer (${compress ? 'zstd' : 'raw'} record)`, async () => {
|
|
162
|
+
let want = null
|
|
163
|
+
await withQueen({}, () => json(TWO_MESSAGES_JSON), async (queen) => {
|
|
164
|
+
want = await queen.queue('orders').batch(2).wait(false).pop()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const answer = framedAnswer('{"frames":true}', 10, 11, compress, twoFrames())
|
|
168
|
+
await withQueen({ framedPop: true }, () => framed(answer), async (queen, hits) => {
|
|
169
|
+
const got = await queen.queue('orders').batch(2).wait(false).pop()
|
|
170
|
+
|
|
171
|
+
assert.equal(query(pops(hits)[0].url).get('frames'), 'true')
|
|
172
|
+
assert.equal(got.length, 2)
|
|
173
|
+
assert.deepEqual(got, want)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
it('asks for frames on the partition and the namespace routes too', async () => {
|
|
179
|
+
await withQueen({ framedPop: true }, () => ({ status: 204 }), async (queen, hits) => {
|
|
180
|
+
await queen.queue('orders').partition('p7').wait(false).pop()
|
|
181
|
+
await queen.queue().namespace('ns').wait(false).pop()
|
|
182
|
+
|
|
183
|
+
const [byPartition, byNamespace] = pops(hits)
|
|
184
|
+
assert.ok(byPartition.url.startsWith('/api/v1/pop/queue/orders/partition/p7?'))
|
|
185
|
+
assert.equal(query(byPartition.url).get('frames'), 'true')
|
|
186
|
+
assert.ok(byNamespace.url.startsWith('/api/v1/pop?'))
|
|
187
|
+
assert.equal(query(byNamespace.url).get('frames'), 'true')
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('knows a framed answer by its first bytes, whatever its content type', async () => {
|
|
192
|
+
const answer = framedAnswer('{"frames":true}', 10, 11, false, twoFrames())
|
|
193
|
+
for (const contentType of ['application/octet-stream', 'application/json']) {
|
|
194
|
+
await withQueen({ framedPop: true }, () => ({ contentType, body: answer }), async (queen) => {
|
|
195
|
+
const got = await queen.queue('orders').batch(2).wait(false).pop()
|
|
196
|
+
|
|
197
|
+
assert.deepEqual(got.map(m => m.transactionId), ['t-10', 't-11'], contentType)
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('reads a JSON answer as always: a broker that does not know the parameter', async () => {
|
|
203
|
+
await withQueen({ framedPop: true }, () => json(TWO_MESSAGES_JSON), async (queen) => {
|
|
204
|
+
const got = await queen.queue('orders').batch(2).wait(false).pop()
|
|
205
|
+
|
|
206
|
+
assert.equal(got.length, 2)
|
|
207
|
+
assert.equal(got[1].transactionId, 't-11')
|
|
208
|
+
assert.equal(got[1].data.s, 'x')
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('reads an empty pop', async () => {
|
|
213
|
+
await withQueen({ framedPop: true }, () => ({ status: 204 }), async (queen) => {
|
|
214
|
+
assert.deepEqual(await queen.queue('orders').wait(false).pop(), [])
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('carries the autopilot\'s choice in the answer\'s own keys', async () => {
|
|
219
|
+
const own = '{"frames":true,"autopilot":{"partitions":4,"batch":50}}'
|
|
220
|
+
await withQueen({ framedPop: true }, () => framed(framedAnswer(own, 10, 11, false, twoFrames())), async (queen) => {
|
|
221
|
+
const { messages, autopilot } = await queen.queue('orders').wait(false).popResult()
|
|
222
|
+
|
|
223
|
+
assert.equal(messages.length, 2)
|
|
224
|
+
assert.deepEqual(autopilot, { partitions: 4, batch: 50, waitMillis: 0 })
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('carries the conflation echo, and still raises when the broker sent none', async () => {
|
|
229
|
+
const echoed = framedAnswer('{"frames":true,"conflation":true}', 10, 11, false, twoFrames())
|
|
230
|
+
await withQueen({ framedPop: true }, () => framed(echoed), async (queen) => {
|
|
231
|
+
const got = await queen.queue('orders').group('g').conflation(true).wait(false).pop()
|
|
232
|
+
assert.equal(got.length, 2)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const silent = framedAnswer('{"frames":true}', 10, 11, false, twoFrames())
|
|
236
|
+
await withQueen({ framedPop: true }, () => framed(silent), async (queen) => {
|
|
237
|
+
await assert.rejects(
|
|
238
|
+
queen.queue('orders').group('g2').conflation(true).wait(false).pop(),
|
|
239
|
+
{ code: CONFLATION_UNSUPPORTED }
|
|
240
|
+
)
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('consume — framedPop', () => {
|
|
246
|
+
// The consumer loop on framed answers: the handler gets every message of
|
|
247
|
+
// the claim, in order, and the loop stops at its limit.
|
|
248
|
+
it('hands the handler the messages of the claim, in order', async () => {
|
|
249
|
+
const answer = framedAnswer('{"frames":true}', 10, 11, true, twoFrames())
|
|
250
|
+
await withQueen({ framedPop: true }, () => framed(answer), async (queen, hits) => {
|
|
251
|
+
const seen = []
|
|
252
|
+
await queen.queue('orders').batch(2).wait(false).autoAck(false).each().limit(2)
|
|
253
|
+
.consume(async (message) => {
|
|
254
|
+
seen.push(`${message.transactionId}@${message.partitionId}/${message.leaseId}`)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
assert.deepEqual(seen, ['t-10@7/lease-1', 't-11@7/lease-1'])
|
|
258
|
+
assert.ok(pops(hits).length >= 1)
|
|
259
|
+
for (const hit of pops(hits)) {
|
|
260
|
+
assert.equal(query(hit.url).get('frames'), 'true')
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
})
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The binary push body (client-v2/http/pushBinary.js), asserted against a
|
|
3
|
+
* server that records the bytes. What is at stake is the default: a client
|
|
4
|
+
* that does not set binaryPush must send the JSON request every released
|
|
5
|
+
* broker knows.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
11
|
+
|
|
12
|
+
import { Queen } from '../../client-v2/index.js'
|
|
13
|
+
import { binaryPushBody, encodePushBinary, encodePushFrames } from '../../client-v2/http/pushBinary.js'
|
|
14
|
+
import { withWireServer } from './_wireServer.js'
|
|
15
|
+
|
|
16
|
+
const TXN = '0190aaaa-0000-7000-8000-000000000001'
|
|
17
|
+
// The client sends a trace id only when it is a version-4 UUID.
|
|
18
|
+
const TRACE = '0190aaaa-0000-4000-8000-000000000001'
|
|
19
|
+
|
|
20
|
+
const queued = (n = 1) => ({ body: JSON.stringify(Array.from({ length: n }, () => ({ status: 'queued' }))) })
|
|
21
|
+
const pushes = (hits) => hits.filter(h => h.method === 'POST' && h.url === '/api/v1/push')
|
|
22
|
+
|
|
23
|
+
async function withQueen(config, answer, run) {
|
|
24
|
+
await withWireServer(answer, async (url, hits) => {
|
|
25
|
+
const queen = new Queen({ url, handleSignals: false, retryAttempts: 1, ...config })
|
|
26
|
+
try {
|
|
27
|
+
await run(queen, hits)
|
|
28
|
+
} finally {
|
|
29
|
+
await queen.close()
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Reads the fields of a binary push body in order. */
|
|
35
|
+
function reader(bytes) {
|
|
36
|
+
let at = 0
|
|
37
|
+
const r = {
|
|
38
|
+
u16() { at += 2; return bytes.readUInt16LE(at - 2) },
|
|
39
|
+
u32() { at += 4; return bytes.readUInt32LE(at - 4) },
|
|
40
|
+
take(n) { at += n; return bytes.subarray(at - n, at) },
|
|
41
|
+
text16() { return r.take(r.u16()).toString() },
|
|
42
|
+
text32() { return r.take(r.u32()).toString() },
|
|
43
|
+
left() { return bytes.length - at }
|
|
44
|
+
}
|
|
45
|
+
return r
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The groups of a plain binary push body (after magic and flags). */
|
|
49
|
+
function plainGroups(bytes, withTxn) {
|
|
50
|
+
const r = reader(bytes)
|
|
51
|
+
const groups = []
|
|
52
|
+
for (let g = r.u32(); g > 0; g--) {
|
|
53
|
+
const group = { queue: r.text16(), partition: r.text16(), items: [] }
|
|
54
|
+
for (let n = r.u32(); n > 0; n--) {
|
|
55
|
+
const txn = withTxn ? r.text16() : null
|
|
56
|
+
group.items.push({ txn, payload: r.text32() })
|
|
57
|
+
}
|
|
58
|
+
groups.push(group)
|
|
59
|
+
}
|
|
60
|
+
assert.equal(r.left(), 0, 'bytes left after the last payload')
|
|
61
|
+
return groups
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The groups of a framed binary push body, each with its frames unpacked. */
|
|
65
|
+
function framedGroups(bytes) {
|
|
66
|
+
const r = reader(bytes)
|
|
67
|
+
const groups = []
|
|
68
|
+
for (let g = r.u32(); g > 0; g--) {
|
|
69
|
+
const group = { queue: r.text16(), partition: r.text16(), count: r.u32(), frames: [] }
|
|
70
|
+
const f = reader(zstdDecompressSync(r.take(r.u32())))
|
|
71
|
+
while (f.left() > 0) {
|
|
72
|
+
const body = reader(f.take(f.u32()))
|
|
73
|
+
const flags = body.take(1)[0]
|
|
74
|
+
const id = Buffer.from(body.take(16))
|
|
75
|
+
group.frames.push({ flags, id, txn: body.text16(), payload: body.take(body.left()).toString() })
|
|
76
|
+
}
|
|
77
|
+
groups.push(group)
|
|
78
|
+
}
|
|
79
|
+
assert.equal(r.left(), 0, 'bytes left after the last group')
|
|
80
|
+
return groups
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('push — the default', () => {
|
|
84
|
+
it('is the JSON request', async () => {
|
|
85
|
+
await withQueen({}, () => queued(), async (queen, hits) => {
|
|
86
|
+
const res = await queen.queue('orders').partition('p1').push({ data: { a: 1 } })
|
|
87
|
+
|
|
88
|
+
assert.equal(res[0].status, 'queued')
|
|
89
|
+
const hit = pushes(hits)[0]
|
|
90
|
+
assert.equal(hit.headers['content-type'], 'application/json')
|
|
91
|
+
const body = JSON.parse(hit.raw.toString())
|
|
92
|
+
assert.deepEqual(Object.keys(body), ['items'])
|
|
93
|
+
assert.deepEqual(body.items[0].payload, { a: 1 })
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('push — binaryPush', () => {
|
|
99
|
+
it('sends the binary body, each item with its transaction id', async () => {
|
|
100
|
+
await withQueen({ binaryPush: true }, () => queued(), async (queen, hits) => {
|
|
101
|
+
const res = await queen.queue('orders').partition('p1').push({ data: { a: 1 }, transactionId: TXN })
|
|
102
|
+
|
|
103
|
+
assert.equal(res[0].status, 'queued')
|
|
104
|
+
const hit = pushes(hits)[0]
|
|
105
|
+
assert.equal(hit.headers['content-type'], 'application/x-queen-push')
|
|
106
|
+
assert.equal(hit.raw.subarray(0, 5).toString('latin1'), 'QPB1\x04')
|
|
107
|
+
assert.deepEqual(plainGroups(hit.raw.subarray(5), true), [
|
|
108
|
+
{ queue: 'orders', partition: 'p1', items: [{ txn: TXN, payload: '{"a":1}' }] }
|
|
109
|
+
])
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('keeps the order of the items: consecutive items of one partition share a group', async () => {
|
|
114
|
+
const item = (partition, payload) => ({ queue: 'q', partition, payload, transactionId: `t${payload}` })
|
|
115
|
+
const bytes = Buffer.from(await binaryPushBody([item('a', 1), item('a', 2), item('b', 3), item('a', 4)], false))
|
|
116
|
+
|
|
117
|
+
assert.equal(bytes.subarray(0, 5).toString('latin1'), 'QPB1\x04')
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
plainGroups(bytes.subarray(5), true).map(g => [g.partition, g.items.map(i => i.txn + '=' + i.payload)]),
|
|
120
|
+
[['a', ['t1=1', 't2=2']], ['b', ['t3=3']], ['a', ['t4=4']]]
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('carries payloads that are not objects, and non-ASCII text', async () => {
|
|
125
|
+
await withQueen({ binaryPush: true }, () => queued(5), async (queen, hits) => {
|
|
126
|
+
await queen.queue('ordini').partition('città').push([
|
|
127
|
+
{ data: 'testo "con" virgolette\n' }, { data: [1, 2] }, { data: 7 }, { data: null }, { data: { è: '€' } }
|
|
128
|
+
])
|
|
129
|
+
|
|
130
|
+
const [group] = plainGroups(pushes(hits)[0].raw.subarray(5), true)
|
|
131
|
+
assert.equal(group.partition, 'città')
|
|
132
|
+
assert.deepEqual(group.items.map(i => JSON.parse(i.payload)), ['testo "con" virgolette\n', [1, 2], 7, null, { è: '€' }])
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('leaves an item with a trace id to the JSON request', async () => {
|
|
137
|
+
await withQueen({ binaryPush: true }, () => queued(), async (queen, hits) => {
|
|
138
|
+
await queen.queue('orders').push({ data: { a: 1 }, traceId: TRACE })
|
|
139
|
+
|
|
140
|
+
const hit = pushes(hits)[0]
|
|
141
|
+
assert.equal(hit.headers['content-type'], 'application/json')
|
|
142
|
+
assert.equal(JSON.parse(hit.raw.toString()).items[0].traceId, TRACE)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('sends a buffered push as the binary body too', async () => {
|
|
147
|
+
const { flags, body } = await bodyOf(async (queen) => {
|
|
148
|
+
await queen.queue('orders').partition('p1').buffer({ messageCount: 10, timeMillis: 60000 }).push({ data: { a: 1 } })
|
|
149
|
+
await queen.flushAllBuffers()
|
|
150
|
+
})
|
|
151
|
+
assert.equal(flags, 4)
|
|
152
|
+
assert.equal(body[0].items[0].payload, '{"a":1}')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
/** Runs `use` on a binaryPush client and returns the one push it sent, decoded. */
|
|
156
|
+
async function bodyOf(use) {
|
|
157
|
+
let sent = null
|
|
158
|
+
await withQueen({ binaryPush: true }, () => queued(2), async (queen, hits) => {
|
|
159
|
+
await use(queen)
|
|
160
|
+
assert.equal(pushes(hits).length, 1)
|
|
161
|
+
sent = pushes(hits)[0]
|
|
162
|
+
})
|
|
163
|
+
assert.equal(sent.raw.subarray(0, 4).toString('latin1'), 'QPB1')
|
|
164
|
+
return { flags: sent.raw[4], body: plainGroups(sent.raw.subarray(5), true) }
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('push — binaryPush with pushCompression', () => {
|
|
169
|
+
it('sends each partition as one zstd frame of stored frames, ids minted here', async () => {
|
|
170
|
+
await withQueen({ binaryPush: true, pushCompression: true }, () => queued(3), async (queen, hits) => {
|
|
171
|
+
await queen.queue('orders').partition('p1').push([{ data: { a: 1 }, transactionId: TXN }, { data: { a: 2 } }, { data: 'x' }])
|
|
172
|
+
|
|
173
|
+
const hit = pushes(hits)[0]
|
|
174
|
+
assert.equal(hit.headers['content-type'], 'application/x-queen-push')
|
|
175
|
+
assert.equal(hit.raw.subarray(0, 5).toString('latin1'), 'QPB1\x08')
|
|
176
|
+
const [group, ...rest] = framedGroups(hit.raw.subarray(5))
|
|
177
|
+
assert.equal(rest.length, 0)
|
|
178
|
+
assert.equal(group.queue, 'orders')
|
|
179
|
+
assert.equal(group.partition, 'p1')
|
|
180
|
+
assert.equal(group.count, 3)
|
|
181
|
+
assert.deepEqual(group.frames.map(f => f.payload), ['{"a":1}', '{"a":2}', '"x"'])
|
|
182
|
+
assert.equal(group.frames[0].txn, TXN)
|
|
183
|
+
for (const frame of group.frames) {
|
|
184
|
+
assert.equal(frame.flags, 0)
|
|
185
|
+
assert.equal(frame.id[6] >> 4, 7, 'the message id is a version-7 UUID')
|
|
186
|
+
assert.equal(frame.id[8] & 0xc0, 0x80)
|
|
187
|
+
assert.equal(frame.txn.length, 36)
|
|
188
|
+
}
|
|
189
|
+
assert.equal(new Set(group.frames.map(f => f.id.toString('hex'))).size, 3)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('gives a message without a transaction id the text of its id', async () => {
|
|
194
|
+
const bytes = Buffer.from(await encodePushFrames([{ queue: 'q', partition: 'p', payloads: ['1'] }]))
|
|
195
|
+
const [{ frames: [frame] }] = framedGroups(bytes.subarray(5))
|
|
196
|
+
const hex = frame.id.toString('hex')
|
|
197
|
+
assert.equal(frame.txn, `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('writes the content size in each zstd frame: the broker asks for it', async () => {
|
|
201
|
+
const bytes = Buffer.from(await encodePushFrames([{ queue: 'q', partition: 'p', payloads: ['{"a":1}'] }]))
|
|
202
|
+
const r = reader(bytes.subarray(5))
|
|
203
|
+
r.u32(); r.text16(); r.text16(); r.u32()
|
|
204
|
+
const frame = r.take(r.u32())
|
|
205
|
+
assert.equal(frame.readUInt32LE(0), 0xfd2fb528, 'a zstd frame')
|
|
206
|
+
// Frame header descriptor: the single-segment bit, or a content-size
|
|
207
|
+
// field of 2 bytes or more, says the frame carries its content size.
|
|
208
|
+
assert.ok((frame[4] & 0x20) !== 0 || (frame[4] >> 6) !== 0)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('encodePushBinary — flags', () => {
|
|
213
|
+
const batches = [{ queue: 'q', partition: 'p', payloads: ['1'] }]
|
|
214
|
+
|
|
215
|
+
it('asks for the counts answer with flag 2', async () => {
|
|
216
|
+
const bytes = Buffer.from(await encodePushBinary(batches, { counts: true }))
|
|
217
|
+
assert.equal(bytes.subarray(0, 5).toString('latin1'), 'QPB1\x02')
|
|
218
|
+
assert.equal(Buffer.from(await encodePushFrames(batches, { counts: true }))[4], 8 | 2)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('compresses the whole body with flag 1, its raw length in front', async () => {
|
|
222
|
+
const plain = Buffer.from(await encodePushBinary(batches))
|
|
223
|
+
const packed = Buffer.from(await encodePushBinary(batches, { compress: true }))
|
|
224
|
+
assert.equal(packed.subarray(0, 5).toString('latin1'), 'QPB1\x01')
|
|
225
|
+
assert.equal(packed.readUInt32LE(5), plain.length - 5)
|
|
226
|
+
assert.deepEqual(zstdDecompressSync(packed.subarray(9)), plain.subarray(5))
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('refuses transaction ids that do not match the payloads', async () => {
|
|
230
|
+
await assert.rejects(
|
|
231
|
+
encodePushBinary([{ queue: 'q', partition: 'p', payloads: ['1', '2'], transactionIds: ['t'] }]),
|
|
232
|
+
{ message: '1 transaction ids for 2 payloads' }
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
})
|