queen-mq 2.1.0 → 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.
- package/README.md +57 -2
- package/client-v2/Queen.js +9 -3
- package/client-v2/README.md +66 -1
- 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/utils/defaults.js +17 -1
- package/package.json +2 -2
- 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
|
@@ -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
|
+
})
|