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.
- package/README.md +135 -2
- package/client-v2/Queen.js +93 -3
- package/client-v2/README.md +66 -1
- package/client-v2/builders/TransactionBuilder.js +105 -17
- 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/index.js +5 -0
- package/client-v2/kv/Kv.js +41 -0
- package/client-v2/locks/Locks.js +544 -0
- package/client-v2/utils/defaults.js +17 -1
- package/package.json +2 -2
- package/test-v2/kv-unit/locksWire.test.js +541 -0
- package/test-v2/locks.js +277 -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
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import * as logger from '../utils/logger.js'
|
|
6
|
+
import { binaryPushBody, transactionFrames, restoreResultOrder, PUSH_BINARY_CONTENT_TYPE } from './pushBinary.js'
|
|
7
|
+
import { decodePopFrames, isPopFrames } from './popFrames.js'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* The error a request ends with when its caller aborted it (a consumer being
|
|
@@ -142,6 +144,12 @@ export class HttpClient {
|
|
|
142
144
|
// carries the same virtual Host.
|
|
143
145
|
#hostOverride = null
|
|
144
146
|
#pinnedDispatchers = new Map()
|
|
147
|
+
// The binary push body and the framed pop answer (pushBinary.js,
|
|
148
|
+
// popFrames.js). All three off by default: the client then sends and reads
|
|
149
|
+
// the JSON every broker knows.
|
|
150
|
+
#binaryPush
|
|
151
|
+
#pushCompression
|
|
152
|
+
#framedPop
|
|
145
153
|
|
|
146
154
|
constructor(options = {}) {
|
|
147
155
|
const {
|
|
@@ -169,7 +177,21 @@ export class HttpClient {
|
|
|
169
177
|
// unbounded (retry forever, paced by backoff) unless maxAttempts is
|
|
170
178
|
// explicitly set here, in which case it applies to both.
|
|
171
179
|
// See PLAN_QUEEN_PROXY_CLOUD.md §4/§9 (client 429 backoff, blocker B4).
|
|
172
|
-
retry429 = {}
|
|
180
|
+
retry429 = {},
|
|
181
|
+
// A push to /api/v1/push goes out as the binary push body instead of
|
|
182
|
+
// JSON. It needs a broker that accepts that body. Items with a trace id
|
|
183
|
+
// stay JSON.
|
|
184
|
+
binaryPush = false,
|
|
185
|
+
// With binaryPush: the messages are framed as the broker stores them and
|
|
186
|
+
// each partition's batch is zstd-compressed once, here; the broker keeps
|
|
187
|
+
// those bytes as they are. The client then mints the message ids. A
|
|
188
|
+
// transaction that pushes sends its pushes the same way (the binary
|
|
189
|
+
// transaction body), and the rest of the bundle as JSON in front of them.
|
|
190
|
+
pushCompression = false,
|
|
191
|
+
// Pops ask the broker for the messages as the stored records
|
|
192
|
+
// (frames=true) and unpack them here. A broker that does not know the
|
|
193
|
+
// parameter answers JSON, which is read as always.
|
|
194
|
+
framedPop = false
|
|
173
195
|
} = options
|
|
174
196
|
|
|
175
197
|
this.#baseUrl = baseUrl
|
|
@@ -180,6 +202,9 @@ export class HttpClient {
|
|
|
180
202
|
this.#enableFailover = enableFailover
|
|
181
203
|
this.#bearerToken = bearerToken
|
|
182
204
|
this.#retry429 = retry429 || {}
|
|
205
|
+
this.#binaryPush = binaryPush === true
|
|
206
|
+
this.#pushCompression = pushCompression === true
|
|
207
|
+
this.#framedPop = framedPop === true
|
|
183
208
|
|
|
184
209
|
// A Host in `headers` can never go out over fetch(); rather than let it be
|
|
185
210
|
// dropped, map it onto the supported mechanism and say so out loud.
|
|
@@ -408,11 +433,15 @@ export class HttpClient {
|
|
|
408
433
|
}
|
|
409
434
|
}
|
|
410
435
|
|
|
411
|
-
if (body) {
|
|
436
|
+
if (body instanceof Uint8Array) {
|
|
437
|
+
// Already encoded (the binary push body): sent as it is.
|
|
438
|
+
headers['Content-Type'] = PUSH_BINARY_CONTENT_TYPE
|
|
439
|
+
options.body = body
|
|
440
|
+
} else if (body) {
|
|
412
441
|
options.body = JSON.stringify(body)
|
|
413
442
|
}
|
|
414
443
|
|
|
415
|
-
|
|
444
|
+
let response = await fetch(requestUrl, options)
|
|
416
445
|
|
|
417
446
|
// The broker granted the leases of a pop when it sent these headers. From
|
|
418
447
|
// here the caller's abort no longer cuts the read: the body says which
|
|
@@ -460,6 +489,19 @@ export class HttpClient {
|
|
|
460
489
|
throw error
|
|
461
490
|
}
|
|
462
491
|
|
|
492
|
+
// A pop that asked for frames: its first bytes say which answer came
|
|
493
|
+
// back, whatever a proxy made of the Content-Type on the way. The framed
|
|
494
|
+
// one is unpacked into the object the JSON answer parses to, so its
|
|
495
|
+
// readers do not tell the two apart; a JSON one (a broker that does not
|
|
496
|
+
// know the parameter) goes on to be read as always.
|
|
497
|
+
if (this.#framedPop && isPop(method, url)) {
|
|
498
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
499
|
+
if (isPopFrames(bytes)) {
|
|
500
|
+
return await decodePopFrames(bytes)
|
|
501
|
+
}
|
|
502
|
+
response = new Response(bytes, { status: response.status, headers: response.headers })
|
|
503
|
+
}
|
|
504
|
+
|
|
463
505
|
// Parse successful response
|
|
464
506
|
const contentType = response.headers.get('content-type')
|
|
465
507
|
const contentLength = response.headers.get('content-length')
|
|
@@ -608,10 +650,32 @@ export class HttpClient {
|
|
|
608
650
|
// `signal`: aborting it closes the request, which then rejects with an
|
|
609
651
|
// error carrying `aborted: true` -- never retried and never failed over.
|
|
610
652
|
async get(path, requestTimeoutMillis = null, affinityKey = null, retryKind = null, signal = null) {
|
|
653
|
+
if (this.#framedPop && path.startsWith('/api/v1/pop')) {
|
|
654
|
+
path += `${path.includes('?') ? '&' : '?'}frames=true`
|
|
655
|
+
}
|
|
611
656
|
return this.#requestWithFailover('GET', path, null, requestTimeoutMillis, affinityKey, retryKind, signal)
|
|
612
657
|
}
|
|
613
658
|
|
|
614
659
|
async post(path, body = null, requestTimeoutMillis = null, affinityKey = null, retryKind = null, signal = null) {
|
|
660
|
+
if (this.#binaryPush && path === '/api/v1/push' && body && Array.isArray(body.items)) {
|
|
661
|
+
// Encoded once, before any retry: every attempt sends the same bytes
|
|
662
|
+
// (with pushCompression, the same message ids). null = an item the
|
|
663
|
+
// binary body does not carry, and the push stays JSON.
|
|
664
|
+
body = (await binaryPushBody(body.items, this.#pushCompression)) ?? body
|
|
665
|
+
}
|
|
666
|
+
if (this.#binaryPush && this.#pushCompression && path === '/api/v1/transaction') {
|
|
667
|
+
// The pushes of a bundle as frames built and compressed here, the rest
|
|
668
|
+
// of it as the JSON head in front of them. null = no push, or one the
|
|
669
|
+
// binary body does not carry: the bundle stays JSON.
|
|
670
|
+
const framed = await transactionFrames(body)
|
|
671
|
+
if (framed) {
|
|
672
|
+
const answer = await this.#requestWithFailover('POST', path, framed.body, requestTimeoutMillis, affinityKey, retryKind, signal)
|
|
673
|
+
// The broker answers the head's operations before the pushes: the
|
|
674
|
+
// caller gets the results in the order of its own operations.
|
|
675
|
+
restoreResultOrder(answer, framed.order)
|
|
676
|
+
return answer
|
|
677
|
+
}
|
|
678
|
+
}
|
|
615
679
|
return this.#requestWithFailover('POST', path, body, requestTimeoutMillis, affinityKey, retryKind, signal)
|
|
616
680
|
}
|
|
617
681
|
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framed pop answer: what a broker sends for a pop with `frames=true`.
|
|
3
|
+
* The messages come as the stored records, each still the zstd frame the
|
|
4
|
+
* producer (or the broker) made, and are unpacked here.
|
|
5
|
+
*
|
|
6
|
+
* "QPF1" | u8 1 | u32 len, the answer's own keys as one JSON object
|
|
7
|
+
* u16 len, queue | u16 len, leaseId | u16 len, consumerGroup | u32 claims
|
|
8
|
+
* per claim: u64 partitionId | u16 len, partition | u64 start | u64 end | u32 deliveryAttempt | u32 records
|
|
9
|
+
* per record: u64 base_offset | i64 created_at_us | u32 count | u8 codec (0 raw, 1 zstd) | u32 len | bytes
|
|
10
|
+
*
|
|
11
|
+
* All integers little-endian. A record's bytes are `count` frames:
|
|
12
|
+
*
|
|
13
|
+
* u32 body_len | u8 flags | u8[16] message id | [u8[16] trace id, flag 1]
|
|
14
|
+
* | u16 len, transactionId | [u16 len, producer, flag 2] | payload
|
|
15
|
+
*
|
|
16
|
+
* Message i of a record has offset base_offset+i and belongs to the delivery
|
|
17
|
+
* when that lies in the claim's start..=end. The JSON object in front holds what
|
|
18
|
+
* the JSON answer has beside its messages: the conflation echo and the
|
|
19
|
+
* autopilot's choice.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { uuidText } from './pushBinary.js'
|
|
23
|
+
import { zstdDecompress } from './zstd.js'
|
|
24
|
+
|
|
25
|
+
export const POP_FRAMES_CONTENT_TYPE = 'application/x-queen-pop'
|
|
26
|
+
|
|
27
|
+
const MAGIC = 'QPF1'
|
|
28
|
+
const FLAG_TRACE = 1
|
|
29
|
+
const FLAG_PRODUCER = 2
|
|
30
|
+
const FLAG_ENCRYPTED = 4
|
|
31
|
+
|
|
32
|
+
const utf8 = new TextDecoder()
|
|
33
|
+
|
|
34
|
+
function damaged(what) {
|
|
35
|
+
return new Error(`framed pop answer: ${what}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Reads little-endian fields off a byte array; a read past the end throws. */
|
|
39
|
+
class In {
|
|
40
|
+
constructor(bytes, short = 'truncated') {
|
|
41
|
+
this.bytes = bytes
|
|
42
|
+
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
43
|
+
this.at = 0
|
|
44
|
+
this.short = short
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
take(length) {
|
|
48
|
+
if (this.bytes.length - this.at < length) throw damaged(this.short)
|
|
49
|
+
const slice = this.bytes.subarray(this.at, this.at + length)
|
|
50
|
+
this.at += length
|
|
51
|
+
return slice
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
u8() {
|
|
55
|
+
return this.take(1)[0]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
u16() {
|
|
59
|
+
this.take(2)
|
|
60
|
+
return this.view.getUint16(this.at - 2, true)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
u32() {
|
|
64
|
+
this.take(4)
|
|
65
|
+
return this.view.getUint32(this.at - 4, true)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
u64() {
|
|
69
|
+
this.take(8)
|
|
70
|
+
return this.view.getBigUint64(this.at - 8, true)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
i64() {
|
|
74
|
+
this.take(8)
|
|
75
|
+
return this.view.getBigInt64(this.at - 8, true)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
text() {
|
|
79
|
+
return utf8.decode(this.take(this.u16()))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
rest() {
|
|
83
|
+
return this.bytes.subarray(this.at)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether a pop answer's bytes are the framed one. */
|
|
88
|
+
export function isPopFrames(bytes) {
|
|
89
|
+
if (!bytes || bytes.length < MAGIC.length + 1) return false
|
|
90
|
+
for (let i = 0; i < MAGIC.length; i++) {
|
|
91
|
+
if (bytes[i] !== MAGIC.charCodeAt(i)) return false
|
|
92
|
+
}
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Unpack a framed pop answer into the object the JSON answer parses to:
|
|
98
|
+
*
|
|
99
|
+
* { success, queue, partition, partitionId, leaseId, consumerGroup,
|
|
100
|
+
* messages: [{ id, transactionId, traceId, data, producerSub, createdAt,
|
|
101
|
+
* partitionId, partition, leaseId, consumerGroup,
|
|
102
|
+
* deliveryAttempt, offset }],
|
|
103
|
+
* partitionsClaimed, ...the answer's own keys }
|
|
104
|
+
*
|
|
105
|
+
* The messages are in delivery order. Everything that reads a pop answer
|
|
106
|
+
* (the conflation check, the autopilot's choice, the consumer loop) reads
|
|
107
|
+
* this object as it reads the JSON one.
|
|
108
|
+
*
|
|
109
|
+
* @param {Uint8Array} bytes
|
|
110
|
+
* @returns {Promise<object>}
|
|
111
|
+
*/
|
|
112
|
+
export async function decodePopFrames(bytes) {
|
|
113
|
+
if (!isPopFrames(bytes)) throw damaged('not a framed pop answer')
|
|
114
|
+
const input = new In(bytes)
|
|
115
|
+
input.take(MAGIC.length)
|
|
116
|
+
const version = input.u8()
|
|
117
|
+
let own = null
|
|
118
|
+
if (version === 1) {
|
|
119
|
+
const text = utf8.decode(input.take(input.u32()))
|
|
120
|
+
if (text.length > 0) {
|
|
121
|
+
try {
|
|
122
|
+
own = JSON.parse(text)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
throw damaged(`its own keys: ${error.message}`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} else if (version !== 0) {
|
|
128
|
+
throw damaged(`unknown version ${version}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const queue = input.text()
|
|
132
|
+
const leaseId = input.text()
|
|
133
|
+
const consumerGroup = input.text()
|
|
134
|
+
const claims = input.u32()
|
|
135
|
+
const messages = []
|
|
136
|
+
let firstPartition = ''
|
|
137
|
+
let firstPartitionId = ''
|
|
138
|
+
|
|
139
|
+
for (let c = 0; c < claims; c++) {
|
|
140
|
+
const partitionId = input.u64().toString()
|
|
141
|
+
const partition = input.text()
|
|
142
|
+
const start = input.u64()
|
|
143
|
+
const end = input.u64()
|
|
144
|
+
const deliveryAttempt = input.u32()
|
|
145
|
+
const records = input.u32()
|
|
146
|
+
if (c === 0) {
|
|
147
|
+
firstPartition = partition
|
|
148
|
+
firstPartitionId = partitionId
|
|
149
|
+
}
|
|
150
|
+
for (let r = 0; r < records; r++) {
|
|
151
|
+
const base = input.u64()
|
|
152
|
+
const createdAt = new Date(Number(input.i64() / 1000n)).toISOString()
|
|
153
|
+
const count = input.u32()
|
|
154
|
+
const codec = input.u8()
|
|
155
|
+
let raw = input.take(input.u32())
|
|
156
|
+
if (codec === 1) {
|
|
157
|
+
try {
|
|
158
|
+
raw = await zstdDecompress(raw)
|
|
159
|
+
} catch (error) {
|
|
160
|
+
throw damaged(`zstd: ${error.message}`)
|
|
161
|
+
}
|
|
162
|
+
} else if (codec !== 0) {
|
|
163
|
+
throw damaged(`unknown codec ${codec}`)
|
|
164
|
+
}
|
|
165
|
+
const frames = new In(raw, `a record is shorter than its ${count} frames`)
|
|
166
|
+
for (let i = 0; i < count; i++) {
|
|
167
|
+
const frame = new In(frames.take(frames.u32()), 'a frame is malformed')
|
|
168
|
+
const flags = frame.u8()
|
|
169
|
+
const id = frame.take(16)
|
|
170
|
+
const trace = flags & FLAG_TRACE ? frame.take(16) : null
|
|
171
|
+
const transactionId = frame.text()
|
|
172
|
+
const producerSub = flags & FLAG_PRODUCER ? frame.text() : null
|
|
173
|
+
if (flags & FLAG_ENCRYPTED) throw damaged('a frame is encrypted')
|
|
174
|
+
const offset = base + BigInt(i)
|
|
175
|
+
if (offset < start || offset > end) continue
|
|
176
|
+
const payload = frame.rest()
|
|
177
|
+
messages.push({
|
|
178
|
+
id: uuidText(id),
|
|
179
|
+
transactionId,
|
|
180
|
+
traceId: trace ? uuidText(trace) : null,
|
|
181
|
+
data: payload.length === 0 ? null : JSON.parse(utf8.decode(payload)),
|
|
182
|
+
producerSub,
|
|
183
|
+
createdAt,
|
|
184
|
+
partitionId,
|
|
185
|
+
partition,
|
|
186
|
+
leaseId,
|
|
187
|
+
consumerGroup,
|
|
188
|
+
deliveryAttempt,
|
|
189
|
+
offset: Number(offset)
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const answer = {
|
|
196
|
+
success: true,
|
|
197
|
+
queue,
|
|
198
|
+
partition: firstPartition,
|
|
199
|
+
partitionId: firstPartitionId,
|
|
200
|
+
leaseId,
|
|
201
|
+
consumerGroup,
|
|
202
|
+
messages,
|
|
203
|
+
partitionsClaimed: claims
|
|
204
|
+
}
|
|
205
|
+
if (own && typeof own === 'object' && !Array.isArray(own)) {
|
|
206
|
+
// `frames` only says the answer is this one; the JSON answer has no such key.
|
|
207
|
+
const { frames: _frames, ...keys } = own
|
|
208
|
+
Object.assign(answer, keys)
|
|
209
|
+
}
|
|
210
|
+
return answer
|
|
211
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The binary push body: an alternative to the JSON body of POST /api/v1/push.
|
|
3
|
+
*
|
|
4
|
+
* "QPB1" | u8 flags | [u32 raw_len, when zstd] | body
|
|
5
|
+
* body: u32 groups | per group: u16 len, queue | u16 len, partition | u32 n
|
|
6
|
+
* | n x ([u16 len, transactionId, when flag 4] | u32 len, payload)
|
|
7
|
+
*
|
|
8
|
+
* All integers little-endian. flags: 1 = the body is one zstd frame of raw_len
|
|
9
|
+
* bytes, 2 = answer with the counts only, 4 = every item carries its
|
|
10
|
+
* transaction id (empty = the broker mints it). No trace id. Each payload is
|
|
11
|
+
* one JSON document.
|
|
12
|
+
*
|
|
13
|
+
* With flag 8 (encodePushFrames) a group is the messages as the broker stores
|
|
14
|
+
* them, compressed here once:
|
|
15
|
+
*
|
|
16
|
+
* body: u32 groups | per group: u16 len, queue | u16 len, partition | u32 n | u32 z_len | z
|
|
17
|
+
* z: one zstd frame of n frames: u32 body_len | u8 0 | u8[16] message id
|
|
18
|
+
* | u16 len, transactionId | payload
|
|
19
|
+
*
|
|
20
|
+
* The message id is a version-7 UUID minted by the client; a message without
|
|
21
|
+
* its own transaction id uses the id's text. The broker checks every frame and
|
|
22
|
+
* keeps the compressed bytes as they are, so it does not compress them again.
|
|
23
|
+
*
|
|
24
|
+
* A broker that does not know this body answers 400; a client sends it only
|
|
25
|
+
* when `binaryPush` is set, so by default a push is the JSON request every
|
|
26
|
+
* broker knows.
|
|
27
|
+
*
|
|
28
|
+
* The binary transaction body (encodeTransactionFrames), an alternative to the
|
|
29
|
+
* JSON body of POST /api/v1/transaction, carries the pushes the same way:
|
|
30
|
+
*
|
|
31
|
+
* "QTB1" | u8 flags | u32 len, head | u32 groups | the groups, as with flag 8 above
|
|
32
|
+
*
|
|
33
|
+
* head is the JSON transaction body without its push operations: the acks in
|
|
34
|
+
* `operations`, `requiredLeases`, `kv`, `timers`. flags: 2 = the answer leaves
|
|
35
|
+
* out the pushes' and the acks' results and counts them (`pushed`,
|
|
36
|
+
* `duplicate`, `acked`); the riders' results stay. In the answer the head's
|
|
37
|
+
* operations come first and the groups' messages after them, in the order
|
|
38
|
+
* written; the riders follow, as in the JSON body.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { v7 as uuidv7 } from 'uuid'
|
|
42
|
+
import { zstdCompress } from './zstd.js'
|
|
43
|
+
|
|
44
|
+
export const PUSH_BINARY_CONTENT_TYPE = 'application/x-queen-push'
|
|
45
|
+
|
|
46
|
+
const MAGIC = Uint8Array.of(0x51, 0x50, 0x42, 0x31) // "QPB1"
|
|
47
|
+
const TXN_MAGIC = Uint8Array.of(0x51, 0x54, 0x42, 0x31) // "QTB1"
|
|
48
|
+
const FLAG_ZSTD = 1
|
|
49
|
+
const FLAG_COUNTS = 2
|
|
50
|
+
const FLAG_TXN = 4
|
|
51
|
+
const FLAG_FRAMES = 8
|
|
52
|
+
|
|
53
|
+
const utf8 = new TextEncoder()
|
|
54
|
+
const HEX = '0123456789abcdef'
|
|
55
|
+
|
|
56
|
+
/** A message does not fit the body: a name or transaction id over 65535 bytes. */
|
|
57
|
+
function tooLong(what) {
|
|
58
|
+
const error = new Error(`${what} longer than 65535 bytes`)
|
|
59
|
+
error.tooLong = true
|
|
60
|
+
return error
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A growing byte buffer; strings are encoded straight into it. */
|
|
64
|
+
class Out {
|
|
65
|
+
constructor(size = 1024) {
|
|
66
|
+
this.bytes = new Uint8Array(size)
|
|
67
|
+
this.view = new DataView(this.bytes.buffer)
|
|
68
|
+
this.length = 0
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
need(extra) {
|
|
72
|
+
if (this.length + extra <= this.bytes.length) return
|
|
73
|
+
const grown = new Uint8Array(Math.max(this.bytes.length * 2, this.length + extra))
|
|
74
|
+
grown.set(this.bytes.subarray(0, this.length))
|
|
75
|
+
this.bytes = grown
|
|
76
|
+
this.view = new DataView(grown.buffer)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
u8(value) {
|
|
80
|
+
this.need(1)
|
|
81
|
+
this.bytes[this.length++] = value
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
u32(value) {
|
|
85
|
+
this.need(4)
|
|
86
|
+
this.view.setUint32(this.length, value, true)
|
|
87
|
+
this.length += 4
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Four bytes to fill in later with `setU32`; returns where they are. */
|
|
91
|
+
reserveU32() {
|
|
92
|
+
const at = this.length
|
|
93
|
+
this.u32(0)
|
|
94
|
+
return at
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
setU32(at, value) {
|
|
98
|
+
this.view.setUint32(at, value, true)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
raw(bytes) {
|
|
102
|
+
this.need(bytes.length)
|
|
103
|
+
this.bytes.set(bytes, this.length)
|
|
104
|
+
this.length += bytes.length
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `text` as UTF-8, no length in front; returns its length in bytes. */
|
|
108
|
+
text(text) {
|
|
109
|
+
this.need(text.length * 3)
|
|
110
|
+
const { written } = utf8.encodeInto(text, this.bytes.subarray(this.length))
|
|
111
|
+
this.length += written
|
|
112
|
+
return written
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `text` with its u16 length in front. */
|
|
116
|
+
text16(text, what) {
|
|
117
|
+
this.need(2)
|
|
118
|
+
const at = this.length
|
|
119
|
+
this.length += 2
|
|
120
|
+
const written = this.text(text)
|
|
121
|
+
if (written > 0xffff) throw tooLong(what)
|
|
122
|
+
this.view.setUint16(at, written, true)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `text` with its u32 length in front. */
|
|
126
|
+
text32(text) {
|
|
127
|
+
const at = this.reserveU32()
|
|
128
|
+
this.setU32(at, this.text(text))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
done() {
|
|
132
|
+
return this.bytes.subarray(0, this.length)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function checkBatch(batch) {
|
|
137
|
+
if (batch.transactionIds && batch.transactionIds.length !== batch.payloads.length) {
|
|
138
|
+
throw new Error(`${batch.transactionIds.length} transaction ids for ${batch.payloads.length} payloads`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Encode batches as a binary push body.
|
|
144
|
+
*
|
|
145
|
+
* @param {Array<{queue: string, partition: string, payloads: string[], transactionIds?: string[]}>} batches
|
|
146
|
+
* one partition's messages each; a payload is the text of one JSON document,
|
|
147
|
+
* and transactionIds, when given, holds one per payload ('' = the broker
|
|
148
|
+
* mints it)
|
|
149
|
+
* @param {object} [options]
|
|
150
|
+
* @param {boolean} [options.compress=false] the body is zstd-compressed
|
|
151
|
+
* @param {boolean} [options.counts=false] the broker answers
|
|
152
|
+
* `{count, queued, duplicate, error}` instead of one object per message
|
|
153
|
+
* @returns {Promise<Uint8Array>}
|
|
154
|
+
*/
|
|
155
|
+
export async function encodePushBinary(batches, { compress = false, counts = false } = {}) {
|
|
156
|
+
const withTxn = batches.some(batch => batch.transactionIds)
|
|
157
|
+
const flags = (counts ? FLAG_COUNTS : 0) | (withTxn ? FLAG_TXN : 0)
|
|
158
|
+
const body = new Out()
|
|
159
|
+
body.u32(batches.length)
|
|
160
|
+
for (const batch of batches) {
|
|
161
|
+
checkBatch(batch)
|
|
162
|
+
body.text16(batch.queue, 'queue name')
|
|
163
|
+
body.text16(batch.partition, 'partition name')
|
|
164
|
+
body.u32(batch.payloads.length)
|
|
165
|
+
for (let i = 0; i < batch.payloads.length; i++) {
|
|
166
|
+
if (withTxn) body.text16(batch.transactionIds ? batch.transactionIds[i] : '', 'transaction id')
|
|
167
|
+
body.text32(batch.payloads[i])
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const out = new Out(MAGIC.length + 5 + (compress ? 0 : body.length))
|
|
172
|
+
out.raw(MAGIC)
|
|
173
|
+
if (!compress) {
|
|
174
|
+
out.u8(flags)
|
|
175
|
+
out.raw(body.done())
|
|
176
|
+
return out.done()
|
|
177
|
+
}
|
|
178
|
+
out.u8(flags | FLAG_ZSTD)
|
|
179
|
+
out.u32(body.length)
|
|
180
|
+
out.raw(await zstdCompress(body.done()))
|
|
181
|
+
return out.done()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Encode batches as a binary push body whose groups are the messages framed
|
|
186
|
+
* as the broker stores them and compressed once, here. Same batches as
|
|
187
|
+
* `encodePushBinary`; `counts` asks for the short answer.
|
|
188
|
+
*
|
|
189
|
+
* @returns {Promise<Uint8Array>}
|
|
190
|
+
*/
|
|
191
|
+
export async function encodePushFrames(batches, { counts = false } = {}) {
|
|
192
|
+
const out = new Out()
|
|
193
|
+
out.raw(MAGIC)
|
|
194
|
+
out.u8(FLAG_FRAMES | (counts ? FLAG_COUNTS : 0))
|
|
195
|
+
return appendFrameGroups(out, batches)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Encode a binary transaction body: `head`, the JSON transaction body without
|
|
200
|
+
* its push operations (its text; empty for none), then batches as groups of
|
|
201
|
+
* messages framed as the broker stores them and compressed once, here.
|
|
202
|
+
* `counts`: the broker counts the pushes and the acks in its answer instead of
|
|
203
|
+
* rendering one result for each.
|
|
204
|
+
*
|
|
205
|
+
* @returns {Promise<Uint8Array>}
|
|
206
|
+
*/
|
|
207
|
+
export async function encodeTransactionFrames(head, batches, { counts = false } = {}) {
|
|
208
|
+
const out = new Out()
|
|
209
|
+
out.raw(TXN_MAGIC)
|
|
210
|
+
out.u8(counts ? FLAG_COUNTS : 0)
|
|
211
|
+
out.text32(head || '')
|
|
212
|
+
return appendFrameGroups(out, batches)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Appends `u32 groups | per group: u16 len, queue | u16 len, partition | u32 n
|
|
217
|
+
* | u32 z_len | z` to `out`: each batch's messages framed as the broker stores
|
|
218
|
+
* them, with a version-7 id minted here, as one zstd frame.
|
|
219
|
+
*/
|
|
220
|
+
async function appendFrameGroups(out, batches) {
|
|
221
|
+
out.u32(batches.length)
|
|
222
|
+
const frames = new Out()
|
|
223
|
+
for (const batch of batches) {
|
|
224
|
+
checkBatch(batch)
|
|
225
|
+
frames.length = 0
|
|
226
|
+
for (let i = 0; i < batch.payloads.length; i++) {
|
|
227
|
+
const bodyLength = frames.reserveU32()
|
|
228
|
+
frames.u8(0)
|
|
229
|
+
frames.need(16)
|
|
230
|
+
const id = frames.length
|
|
231
|
+
uuidv7(undefined, frames.bytes, id)
|
|
232
|
+
frames.length += 16
|
|
233
|
+
const txn = batch.transactionIds ? batch.transactionIds[i] : ''
|
|
234
|
+
frames.text16(txn || uuidText(frames.bytes, id), 'transaction id')
|
|
235
|
+
frames.text(batch.payloads[i])
|
|
236
|
+
frames.setU32(bodyLength, frames.length - bodyLength - 4)
|
|
237
|
+
}
|
|
238
|
+
out.text16(batch.queue, 'queue name')
|
|
239
|
+
out.text16(batch.partition, 'partition name')
|
|
240
|
+
out.u32(batch.payloads.length)
|
|
241
|
+
const compressed = await zstdCompress(frames.done())
|
|
242
|
+
out.u32(compressed.length)
|
|
243
|
+
out.raw(compressed)
|
|
244
|
+
}
|
|
245
|
+
return out.done()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The lower-case hyphenated text of the 16 id bytes at `at`. */
|
|
249
|
+
export function uuidText(bytes, at = 0) {
|
|
250
|
+
let text = ''
|
|
251
|
+
for (let i = 0; i < 16; i++) {
|
|
252
|
+
if (i === 4 || i === 6 || i === 8 || i === 10) text += '-'
|
|
253
|
+
const byte = bytes[at + i]
|
|
254
|
+
text += HEX[byte >> 4] + HEX[byte & 0x0f]
|
|
255
|
+
}
|
|
256
|
+
return text
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The items of a JSON push (`{queue, partition, payload, transactionId}`) as a
|
|
261
|
+
* binary push body, or null when one of them cannot travel in it: a trace id,
|
|
262
|
+
* a transaction id that is not a string, a payload JSON does not carry, a name
|
|
263
|
+
* over 65535 bytes. The caller then sends the JSON body, and the broker
|
|
264
|
+
* answers that as it always did. Consecutive items of one partition share a
|
|
265
|
+
* group, so the order of the items is the order on the wire.
|
|
266
|
+
*
|
|
267
|
+
* `compress`: the messages framed and compressed here, kept by the broker as
|
|
268
|
+
* they are.
|
|
269
|
+
*/
|
|
270
|
+
export async function binaryPushBody(items, compress) {
|
|
271
|
+
if (items.length === 0) return null
|
|
272
|
+
const batches = []
|
|
273
|
+
for (const item of items) {
|
|
274
|
+
if (!addItem(batches, item)) return null
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
return compress ? await encodePushFrames(batches) : await encodePushBinary(batches)
|
|
278
|
+
} catch (error) {
|
|
279
|
+
// A name too long for the body: the JSON push says so in the broker's words.
|
|
280
|
+
if (error.tooLong) return null
|
|
281
|
+
throw error
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Adds one pushed item to `batches`, in the last one when it is the same
|
|
287
|
+
* partition's. False when the item cannot travel in a binary body.
|
|
288
|
+
*/
|
|
289
|
+
function addItem(batches, item) {
|
|
290
|
+
if (!item || typeof item !== 'object' || typeof item.queue !== 'string') return false
|
|
291
|
+
if (item.traceId !== undefined && item.traceId !== null) return false
|
|
292
|
+
const transactionId = item.transactionId ?? ''
|
|
293
|
+
if (typeof transactionId !== 'string') return false
|
|
294
|
+
const partition = item.partition ?? 'Default'
|
|
295
|
+
if (typeof partition !== 'string') return false
|
|
296
|
+
const payload = JSON.stringify(item.payload)
|
|
297
|
+
if (payload === undefined) return false
|
|
298
|
+
let last = batches[batches.length - 1]
|
|
299
|
+
if (!last || last.queue !== item.queue || last.partition !== partition) {
|
|
300
|
+
last = { queue: item.queue, partition, payloads: [], transactionIds: [] }
|
|
301
|
+
batches.push(last)
|
|
302
|
+
}
|
|
303
|
+
last.payloads.push(payload)
|
|
304
|
+
last.transactionIds.push(transactionId)
|
|
305
|
+
return true
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A JSON transaction body (`{operations, requiredLeases, kv, timers}`) as the
|
|
310
|
+
* binary one, when its pushes can travel in it: there is at least one, and
|
|
311
|
+
* none carries a trace id. Everything but the pushes stays in the JSON head;
|
|
312
|
+
* consecutive pushed items of one partition share a group, so the pushes keep
|
|
313
|
+
* their order. Null otherwise: the caller then sends the JSON body.
|
|
314
|
+
*
|
|
315
|
+
* `order` is null when the broker's answer lists the operations as the caller
|
|
316
|
+
* added them (every other operation before every push). Otherwise `order[k]`
|
|
317
|
+
* is the place, among the caller's operations, of the k-th result the broker
|
|
318
|
+
* sends: it lists the head's operations first, then the pushes.
|
|
319
|
+
*
|
|
320
|
+
* @returns {Promise<{body: Uint8Array, order: number[]|null}|null>}
|
|
321
|
+
*/
|
|
322
|
+
export async function transactionFrames(request) {
|
|
323
|
+
if (!request || typeof request !== 'object' || !Array.isArray(request.operations)) return null
|
|
324
|
+
const head = { ...request, operations: [] }
|
|
325
|
+
const batches = []
|
|
326
|
+
const headAt = []
|
|
327
|
+
const pushAt = []
|
|
328
|
+
let flat = 0
|
|
329
|
+
for (const operation of request.operations) {
|
|
330
|
+
if (!operation || operation.type !== 'push') {
|
|
331
|
+
head.operations.push(operation)
|
|
332
|
+
headAt.push(flat++)
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
// A push names its items, or is one item itself.
|
|
336
|
+
const items = Array.isArray(operation.items) ? operation.items : [operation]
|
|
337
|
+
for (const item of items) {
|
|
338
|
+
if (!addItem(batches, item)) return null
|
|
339
|
+
pushAt.push(flat++)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (batches.length === 0) return null
|
|
343
|
+
let body
|
|
344
|
+
try {
|
|
345
|
+
body = await encodeTransactionFrames(JSON.stringify(head), batches)
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (error.tooLong) return null
|
|
348
|
+
throw error
|
|
349
|
+
}
|
|
350
|
+
const order = [...headAt, ...pushAt]
|
|
351
|
+
return { body, order: order.every((at, k) => at === k) ? null : order }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Puts the results of a binary transaction back in the order the caller added
|
|
356
|
+
* its operations in, and gives each its own index again. The riders' results,
|
|
357
|
+
* after the operations', are already in place.
|
|
358
|
+
*/
|
|
359
|
+
export function restoreResultOrder(answer, order) {
|
|
360
|
+
if (!order || !answer || !Array.isArray(answer.results) || answer.results.length < order.length) return
|
|
361
|
+
const results = answer.results.slice()
|
|
362
|
+
order.forEach((at, k) => {
|
|
363
|
+
const result = answer.results[k]
|
|
364
|
+
results[at] = result && typeof result === 'object' ? { ...result, index: at } : result
|
|
365
|
+
})
|
|
366
|
+
answer.results = results
|
|
367
|
+
}
|