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
package/README.md
CHANGED
|
@@ -229,7 +229,11 @@ const queen = new Queen({
|
|
|
229
229
|
timeoutMillis: 30000,
|
|
230
230
|
retryAttempts: 3,
|
|
231
231
|
loadBalancingStrategy: 'affinity', // or 'round-robin', 'session'
|
|
232
|
-
enableFailover: true
|
|
232
|
+
enableFailover: true,
|
|
233
|
+
// Opt-in wire forms, off by default (see Binary Push, Push Compression and Framed Pop)
|
|
234
|
+
binaryPush: false,
|
|
235
|
+
pushCompression: false,
|
|
236
|
+
framedPop: false
|
|
233
237
|
})
|
|
234
238
|
```
|
|
235
239
|
|
|
@@ -515,6 +519,54 @@ rate, and a broker outage shows up as slow pushes with bounded memory rather
|
|
|
515
519
|
than as messages that quietly disappeared. `close()` flushes with a 30 second
|
|
516
520
|
deadline and logs how many messages were left unsent if it expires.
|
|
517
521
|
|
|
522
|
+
### Binary Push, Push Compression and Framed Pop
|
|
523
|
+
|
|
524
|
+
Three opt-in forms of the push, pop and transaction requests. They change how
|
|
525
|
+
the messages travel between the client and the broker and nothing else: the
|
|
526
|
+
calls, their results, deduplication and the delivery guarantees stay the same.
|
|
527
|
+
All three are off by default, and with them off the client sends and reads the
|
|
528
|
+
JSON every broker knows.
|
|
529
|
+
|
|
530
|
+
```javascript
|
|
531
|
+
const queen = new Queen({
|
|
532
|
+
url: 'http://localhost:6632',
|
|
533
|
+
binaryPush: true, // a push travels as a binary body
|
|
534
|
+
pushCompression: true, // with binaryPush: framed and compressed here
|
|
535
|
+
framedPop: true // a pop returns the stored records, unpacked here
|
|
536
|
+
})
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
| Option | What it does |
|
|
540
|
+
| --- | --- |
|
|
541
|
+
| `binaryPush` | A push, direct or buffered, sends its items as one length-prefixed binary body instead of a JSON document. The answer is the same list, one entry per item. |
|
|
542
|
+
| `pushCompression` | Needs `binaryPush`. The client frames each partition's batch the way the broker stores it, mints the message ids (UUIDv7) and compresses the batch with zstd, once. The broker checks the frames and, when it has nothing to add to them, stores those bytes without compressing them again. A transaction that pushes sends its pushes the same way, with the rest of the bundle as JSON in front of them. |
|
|
543
|
+
| `framedPop` | `pop()` and `consume()` ask for the stored records (`frames=true`) and unpack them here into the same message objects, instead of a JSON answer that the broker renders message by message. |
|
|
544
|
+
|
|
545
|
+
Turn them on when the broker's CPU or the network limits a high message rate:
|
|
546
|
+
the framing, the compression and the unpacking then run in the clients, and the
|
|
547
|
+
batches cross the network compressed. zstd is the one of Node.js (`node:zlib`),
|
|
548
|
+
so there is nothing to install.
|
|
549
|
+
|
|
550
|
+
**What stays JSON**, with no action from you:
|
|
551
|
+
|
|
552
|
+
- A push with a `traceId` on one of its items.
|
|
553
|
+
- A transaction with no push, or with a `traceId` on one of its pushes.
|
|
554
|
+
- A pop the broker cannot answer in frames: a pop of a conflating group, and
|
|
555
|
+
every pop on a broker that runs with an encryption key
|
|
556
|
+
(`QUEEN_ENCRYPTION_KEY`). The client reads the JSON answer as always.
|
|
557
|
+
- Acks, KV, timers, locks and the admin calls. Ephemeral queues have their own
|
|
558
|
+
routes and do not change either.
|
|
559
|
+
|
|
560
|
+
A transaction's `results` come back in the order you added the operations, as
|
|
561
|
+
with JSON. The broker answers a binary transaction with the acks first and the
|
|
562
|
+
pushes after them, and the client puts them back.
|
|
563
|
+
|
|
564
|
+
**Requires broker 2.3.0 or later** for `binaryPush` and `pushCompression`.
|
|
565
|
+
A broker up to 2.2.0 answers 400 to the binary bodies and the client does not
|
|
566
|
+
fall back to JSON, so turn the two on after every broker of the cluster is
|
|
567
|
+
upgraded. `framedPop` is safe against any broker: an older one ignores the
|
|
568
|
+
parameter and answers JSON.
|
|
569
|
+
|
|
518
570
|
### Dead Letter Queue
|
|
519
571
|
|
|
520
572
|
```javascript
|
|
@@ -997,7 +1049,10 @@ await queen.close() // Flush buffers and close connections
|
|
|
997
1049
|
loadBalancingStrategy: 'affinity', // 'affinity' | 'round-robin' | 'session'
|
|
998
1050
|
affinityHashRing: 128,
|
|
999
1051
|
enableFailover: true,
|
|
1000
|
-
healthRetryAfterMillis: 5000
|
|
1052
|
+
healthRetryAfterMillis: 5000,
|
|
1053
|
+
binaryPush: false, // a push as a binary body
|
|
1054
|
+
pushCompression: false, // with binaryPush: frames compressed in the client
|
|
1055
|
+
framedPop: false // a pop as the stored records
|
|
1001
1056
|
}
|
|
1002
1057
|
```
|
|
1003
1058
|
|
package/client-v2/Queen.js
CHANGED
|
@@ -179,7 +179,7 @@ export class Queen {
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
#createHttpClient() {
|
|
182
|
-
const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken, headers, hostHeader, retry429 } = this.#config
|
|
182
|
+
const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken, headers, hostHeader, retry429, binaryPush, pushCompression, framedPop } = this.#config
|
|
183
183
|
|
|
184
184
|
if (urls.length === 1) {
|
|
185
185
|
// Single server
|
|
@@ -191,7 +191,10 @@ export class Queen {
|
|
|
191
191
|
bearerToken,
|
|
192
192
|
headers,
|
|
193
193
|
hostHeader,
|
|
194
|
-
retry429
|
|
194
|
+
retry429,
|
|
195
|
+
binaryPush,
|
|
196
|
+
pushCompression,
|
|
197
|
+
framedPop
|
|
195
198
|
})
|
|
196
199
|
}
|
|
197
200
|
|
|
@@ -209,7 +212,10 @@ export class Queen {
|
|
|
209
212
|
bearerToken,
|
|
210
213
|
headers,
|
|
211
214
|
hostHeader,
|
|
212
|
-
retry429
|
|
215
|
+
retry429,
|
|
216
|
+
binaryPush,
|
|
217
|
+
pushCompression,
|
|
218
|
+
framedPop
|
|
213
219
|
})
|
|
214
220
|
}
|
|
215
221
|
|
package/client-v2/README.md
CHANGED
|
@@ -20,6 +20,7 @@ Welcome to Queen client! This is your friendly guide to mastering message queues
|
|
|
20
20
|
- [Part 12: Message Tracing - Debug Your Workflows](#part-12-message-tracing---debug-your-workflows)
|
|
21
21
|
- [Part 13: Callbacks & Error Handling](#part-13-callbacks--error-handling)
|
|
22
22
|
- [Part 14: Graceful Shutdown](#part-14-graceful-shutdown)
|
|
23
|
+
- [Part 15: Binary Push, Compression & Framed Pop](#part-15-binary-push-compression--framed-pop)
|
|
23
24
|
- [Cheat Sheet](#cheat-sheet)
|
|
24
25
|
|
|
25
26
|
---
|
|
@@ -1607,6 +1608,67 @@ await queen.close()
|
|
|
1607
1608
|
|
|
1608
1609
|
---
|
|
1609
1610
|
|
|
1611
|
+
## Part 15: Binary Push, Compression & Framed Pop
|
|
1612
|
+
|
|
1613
|
+
Everything so far travels as JSON. For a high message rate, the client has three
|
|
1614
|
+
opt-in forms of the push, pop and transaction requests. They change how the
|
|
1615
|
+
messages travel and nothing else: your calls, their results, deduplication and
|
|
1616
|
+
the delivery guarantees stay the same. All three are off by default.
|
|
1617
|
+
|
|
1618
|
+
```javascript
|
|
1619
|
+
const queen = new Queen({
|
|
1620
|
+
url: 'http://localhost:6632',
|
|
1621
|
+
binaryPush: true, // a push travels as a binary body
|
|
1622
|
+
pushCompression: true, // with binaryPush: framed and compressed here
|
|
1623
|
+
framedPop: true // a pop returns the stored records, unpacked here
|
|
1624
|
+
})
|
|
1625
|
+
|
|
1626
|
+
// The rest of your code does not change.
|
|
1627
|
+
await queen.queue('events').partition('device-7').push([{ data: { n: 1 } }])
|
|
1628
|
+
await queen.queue('events').group('workers').consume(async (message) => {
|
|
1629
|
+
console.log(message.data)
|
|
1630
|
+
})
|
|
1631
|
+
```
|
|
1632
|
+
|
|
1633
|
+
### What Each Option Does
|
|
1634
|
+
|
|
1635
|
+
| Option | What it does |
|
|
1636
|
+
| --- | --- |
|
|
1637
|
+
| `binaryPush` | A push, direct or buffered, sends its items as one length-prefixed binary body instead of a JSON document. The answer is the same list, one entry per item. |
|
|
1638
|
+
| `pushCompression` | Needs `binaryPush`. The client frames each partition's batch the way the broker stores it, mints the message ids (UUIDv7) and compresses the batch with zstd, once. The broker checks the frames and, when it has nothing to add to them, stores those bytes without compressing them again. A transaction that pushes sends its pushes the same way, with the rest of the bundle as JSON in front of them. |
|
|
1639
|
+
| `framedPop` | `pop()` and `consume()` ask for the stored records (`frames=true`) and unpack them here into the same message objects, instead of a JSON answer that the broker renders message by message. |
|
|
1640
|
+
|
|
1641
|
+
Turn them on when the broker's CPU or the network limits a high message rate:
|
|
1642
|
+
the framing, the compression and the unpacking then run in the clients, and the
|
|
1643
|
+
batches cross the network compressed. zstd is the one of Node.js (`node:zlib`),
|
|
1644
|
+
so there is nothing to install.
|
|
1645
|
+
|
|
1646
|
+
### What Stays JSON
|
|
1647
|
+
|
|
1648
|
+
The client falls back by itself in these cases:
|
|
1649
|
+
|
|
1650
|
+
- A push with a `traceId` on one of its items.
|
|
1651
|
+
- A transaction with no push, or with a `traceId` on one of its pushes.
|
|
1652
|
+
- A pop the broker cannot answer in frames: a pop of a conflating group, and
|
|
1653
|
+
every pop on a broker that runs with an encryption key
|
|
1654
|
+
(`QUEEN_ENCRYPTION_KEY`). The client reads the JSON answer as always.
|
|
1655
|
+
- Acks, KV, timers, locks and the admin calls. Ephemeral queues have their own
|
|
1656
|
+
routes and do not change either.
|
|
1657
|
+
|
|
1658
|
+
A transaction's `results` come back in the order you added the operations, as
|
|
1659
|
+
with JSON. The broker answers a binary transaction with the acks first and the
|
|
1660
|
+
pushes after them, and the client puts them back.
|
|
1661
|
+
|
|
1662
|
+
### Which Broker
|
|
1663
|
+
|
|
1664
|
+
`binaryPush` and `pushCompression` need broker 2.3.0 or later. A broker up
|
|
1665
|
+
to 2.2.0 answers 400 to the binary bodies and the client does not fall back to
|
|
1666
|
+
JSON, so turn the two on after every broker of the cluster is upgraded.
|
|
1667
|
+
`framedPop` is safe against any broker: an older one ignores the parameter and
|
|
1668
|
+
answers JSON.
|
|
1669
|
+
|
|
1670
|
+
---
|
|
1671
|
+
|
|
1610
1672
|
## Cheat Sheet
|
|
1611
1673
|
|
|
1612
1674
|
### Connection
|
|
@@ -1883,7 +1945,10 @@ await queen.close()
|
|
|
1883
1945
|
loadBalancingStrategy: 'affinity', // 'affinity' | 'round-robin' | 'session'
|
|
1884
1946
|
affinityHashRing: 128,
|
|
1885
1947
|
enableFailover: true,
|
|
1886
|
-
healthRetryAfterMillis: 5000
|
|
1948
|
+
healthRetryAfterMillis: 5000,
|
|
1949
|
+
binaryPush: false, // a push as a binary body
|
|
1950
|
+
pushCompression: false, // with binaryPush: frames compressed in the client
|
|
1951
|
+
framedPop: false // a pop as the stored records
|
|
1887
1952
|
}
|
|
1888
1953
|
```
|
|
1889
1954
|
|
|
@@ -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
|
+
}
|