queen-mq 2.0.4 → 2.3.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,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
+ })
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The binary transaction body (client-v2/http/pushBinary.js), asserted against
3
+ * a server that records the bytes. What is at stake is the default (a client
4
+ * that does not set both binaryPush and pushCompression sends the JSON bundle
5
+ * every released broker knows) and, with the options, the order of the
6
+ * results: the broker lists the head's acks before the pushes, and the caller
7
+ * must get them back in the order of its own operations.
8
+ */
9
+
10
+ import { describe, it } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import { zstdDecompressSync } from 'node:zlib'
13
+
14
+ import { Queen } from '../../client-v2/index.js'
15
+ import { encodeTransactionFrames } from '../../client-v2/http/pushBinary.js'
16
+ import { withWireServer } from './_wireServer.js'
17
+
18
+ const MESSAGE = { transactionId: 'in-1', partitionId: '7', leaseId: 'lease-1' }
19
+
20
+ const answer = (results) => () => ({ body: JSON.stringify({ transactionId: 'tx', success: true, results }) })
21
+ const transactions = (hits) => hits.filter(h => h.method === 'POST' && h.url === '/api/v1/transaction')
22
+
23
+ async function withQueen(config, respond, run) {
24
+ await withWireServer(respond, 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
+ const framed = { binaryPush: true, pushCompression: true }
35
+
36
+ /** The head and the groups of a binary transaction body. */
37
+ function decode(raw) {
38
+ assert.equal(raw.subarray(0, 4).toString('latin1'), 'QTB1')
39
+ const headLength = raw.readUInt32LE(5)
40
+ const head = raw.subarray(9, 9 + headLength).toString()
41
+ return { flags: raw[4], head: head === '' ? null : JSON.parse(head), groups: frameGroups(raw.subarray(9 + headLength)) }
42
+ }
43
+
44
+ /**
45
+ * Decodes `u32 groups | per group: u16 len, queue | u16 len, partition | u32 n
46
+ * | u32 z_len | z` and every frame of every group.
47
+ */
48
+ function frameGroups(bytes) {
49
+ let at = 0
50
+ const u16 = () => { at += 2; return bytes.readUInt16LE(at - 2) }
51
+ const u32 = () => { at += 4; return bytes.readUInt32LE(at - 4) }
52
+ const take = (n) => { at += n; return bytes.subarray(at - n, at) }
53
+ const groups = []
54
+ for (let g = u32(); g > 0; g--) {
55
+ const group = { queue: take(u16()).toString(), partition: take(u16()).toString(), txns: [], payloads: [] }
56
+ const count = u32()
57
+ const frames = zstdDecompressSync(take(u32()))
58
+ let p = 0
59
+ for (let i = 0; i < count; i++) {
60
+ // u32 body_len | u8 0 | id[16] | u16 len, txn | payload
61
+ const frame = frames.subarray(p + 4, p + 4 + frames.readUInt32LE(p))
62
+ p += 4 + frame.length
63
+ assert.equal(frame[0], 0, 'a frame carries no flags')
64
+ assert.equal(frame[7] >> 4, 7, 'the message id is a version-7 UUID')
65
+ assert.equal(frame[9] & 0xc0, 0x80)
66
+ const txnLength = frame.readUInt16LE(17)
67
+ group.txns.push(frame.subarray(19, 19 + txnLength).toString())
68
+ group.payloads.push(frame.subarray(19 + txnLength).toString())
69
+ }
70
+ assert.equal(p, frames.length, 'bytes after the frames of a group')
71
+ groups.push(group)
72
+ }
73
+ assert.equal(at, bytes.length, 'bytes after the last group')
74
+ return groups
75
+ }
76
+
77
+ describe('transaction — the default', () => {
78
+ const results = [{ index: 0, type: 'ack', success: true }, { index: 1, type: 'push', success: true }]
79
+
80
+ for (const [label, config] of [
81
+ ['no option set', {}],
82
+ ['binaryPush alone', { binaryPush: true }],
83
+ ['pushCompression alone', { pushCompression: true }]
84
+ ]) {
85
+ it(`is the JSON bundle with ${label}`, async () => {
86
+ await withQueen(config, answer(results), async (queen, hits) => {
87
+ await queen.transaction().ack(MESSAGE).queue('out').partition('p1').push([{ data: { n: 1 } }]).commit()
88
+
89
+ const sent = transactions(hits)[0]
90
+ assert.equal(sent.headers['content-type'], 'application/json')
91
+ const body = JSON.parse(sent.raw.toString())
92
+ assert.deepEqual(body.operations.map(op => op.type), ['ack', 'push'])
93
+ })
94
+ })
95
+ }
96
+ })
97
+
98
+ describe('transaction — binaryPush with pushCompression', () => {
99
+ it('sends the pushes as frames, the rest of the bundle as the JSON head', async () => {
100
+ const results = [
101
+ { index: 0, type: 'ack', success: true, transactionId: 'in-1' },
102
+ { index: 1, type: 'push', success: true, transactionId: 'a' },
103
+ { index: 2, type: 'push', success: true, transactionId: 'tx-b' },
104
+ { index: 3, type: 'push', success: true, transactionId: 'c' }
105
+ ]
106
+ await withQueen(framed, answer(results), async (queen, hits) => {
107
+ const result = await queen.transaction()
108
+ .ack(MESSAGE)
109
+ .queue('out').partition('p1').push([{ data: { n: 1 } }])
110
+ .queue('out').partition('p1').push([{ data: { n: 2 }, transactionId: 'tx-b' }])
111
+ .queue('out').partition('p2').push([{ data: 'text' }])
112
+ .commit()
113
+
114
+ assert.equal(result.success, true)
115
+ assert.equal(result.results.length, 4)
116
+ const sent = transactions(hits)[0]
117
+ assert.equal(sent.headers['content-type'], 'application/x-queen-push')
118
+ const { flags, head, groups } = decode(sent.raw)
119
+ assert.equal(flags, 0)
120
+ // The head: the ack and the lease, and no push.
121
+ assert.deepEqual(head, {
122
+ operations: [{ type: 'ack', transactionId: 'in-1', partitionId: '7', status: 'completed', leaseId: 'lease-1' }],
123
+ requiredLeases: ['lease-1']
124
+ })
125
+ // The pushes: consecutive items of one partition share a group.
126
+ assert.equal(groups.length, 2)
127
+ assert.deepEqual([groups[0].queue, groups[0].partition, groups[0].payloads], ['out', 'p1', ['{"n":1}', '{"n":2}']])
128
+ assert.equal(groups[0].txns[0].length, 36)
129
+ assert.equal(groups[0].txns[1], 'tx-b')
130
+ assert.deepEqual([groups[1].queue, groups[1].partition, groups[1].payloads], ['out', 'p2', ['"text"']])
131
+ })
132
+ })
133
+
134
+ it('hands the results back in the order of the caller\'s operations', async () => {
135
+ // The caller pushes, acks, pushes. The broker answers the ack first (it is
136
+ // in the head), then the two pushes, then the rider.
137
+ const results = [
138
+ { index: 0, type: 'ack', success: true, transactionId: 'in-1' },
139
+ { index: 1, type: 'push', success: true, transactionId: 'first' },
140
+ { index: 2, type: 'push', success: true, transactionId: 'second' },
141
+ { index: 3, type: 'kv', opIndex: 0, applied: true }
142
+ ]
143
+ await withQueen(framed, answer(results), async (queen, hits) => {
144
+ const result = await queen.transaction()
145
+ .queue('out').push([{ data: 1, transactionId: 'first' }])
146
+ .ack(MESSAGE)
147
+ .queue('out').push([{ data: 2, transactionId: 'second' }])
148
+ .kv.put('saga', 'k1', { step: 1 }, { ttl: '24h' })
149
+ .commit()
150
+
151
+ assert.deepEqual(result.results.map(r => [r.index, r.type, r.transactionId ?? null]), [
152
+ [0, 'push', 'first'], [1, 'ack', 'in-1'], [2, 'push', 'second'], [3, 'kv', null]
153
+ ])
154
+ // On the wire the two pushes are one group, in the caller's order, and
155
+ // the rider travels in the head.
156
+ const { head, groups } = decode(transactions(hits)[0].raw)
157
+ assert.deepEqual(head.operations.map(op => op.type), ['ack'])
158
+ assert.equal(head.kv.length, 1)
159
+ assert.equal(groups.length, 1)
160
+ assert.deepEqual(groups[0].txns, ['first', 'second'])
161
+ assert.equal(groups[0].partition, 'Default')
162
+ })
163
+ })
164
+
165
+ it('leaves the results alone when the acks come first anyway', async () => {
166
+ const results = [
167
+ { index: 0, type: 'ack', success: true, transactionId: 'in-1' },
168
+ { index: 1, type: 'push', success: true, transactionId: 'a' }
169
+ ]
170
+ await withQueen(framed, answer(results), async (queen) => {
171
+ const result = await queen.transaction().ack(MESSAGE).queue('out').push([{ data: 1, transactionId: 'a' }]).commit()
172
+
173
+ assert.deepEqual(result.results, results)
174
+ })
175
+ })
176
+
177
+ it('returns a rolled-back bundle as the broker answered it', async () => {
178
+ const lost = {
179
+ transactionId: 'tx', success: false, reason: 'kv_precondition', results: [], failedIndex: 2, kvReason: 'exists'
180
+ }
181
+ await withQueen(framed, () => ({ body: JSON.stringify(lost) }), async (queen) => {
182
+ const result = await queen.transaction()
183
+ .queue('out').push([{ data: 1 }])
184
+ .ack(MESSAGE)
185
+ .once('idem', 'order-1', { ttl: '24h' })
186
+ .commit()
187
+
188
+ assert.deepEqual(result, lost)
189
+ })
190
+ })
191
+
192
+ it('leaves a bundle whose push carries a trace id to JSON', async () => {
193
+ await withQueen(framed, answer([{ index: 0, type: 'push', success: true }]), async (queen, hits) => {
194
+ await queen.transaction().queue('out')
195
+ .push([{ data: 1, traceId: '0190a0a0-0000-4000-8000-000000000001' }]).commit()
196
+
197
+ const sent = transactions(hits)[0]
198
+ assert.equal(sent.headers['content-type'], 'application/json')
199
+ assert.equal(JSON.parse(sent.raw.toString()).operations[0].items[0].traceId, '0190a0a0-0000-4000-8000-000000000001')
200
+ })
201
+ })
202
+
203
+ it('leaves a bundle without pushes to JSON: there is nothing to frame', async () => {
204
+ await withQueen(framed, answer([{ index: 0, type: 'ack', success: true }]), async (queen, hits) => {
205
+ await queen.transaction().ack(MESSAGE).commit()
206
+
207
+ assert.equal(transactions(hits)[0].headers['content-type'], 'application/json')
208
+ assert.equal(transactions(hits)[0].raw[0], '{'.charCodeAt(0))
209
+ })
210
+ })
211
+ })
212
+
213
+ describe('encodeTransactionFrames', () => {
214
+ it('asks for the counts answer with flag 2, and takes an empty head', async () => {
215
+ const body = Buffer.from(await encodeTransactionFrames(null, [{ queue: 'q', partition: 'p', payloads: ['1'] }], { counts: true }))
216
+
217
+ assert.equal(body.subarray(0, 9).toString('latin1'), 'QTB1\x02\x00\x00\x00\x00')
218
+ const { head, groups } = decode(body)
219
+ assert.equal(head, null)
220
+ assert.deepEqual(groups.map(g => g.payloads), [['1']])
221
+ })
222
+ })