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,277 @@
1
+ /**
2
+ * Locks integration suite: `queen.lock`, `queen.semaphore`, `queen.locks`,
3
+ * `kv.check` and `transaction().guard(lock)` against a real broker.
4
+ *
5
+ * Repeatable for the reason kv.js gives: every run starts on a fresh broker.
6
+ * Every lock here is named under `test-` and carries a lifetime, so a run that
7
+ * goes wrong leaves nothing that does not expire by itself.
8
+ *
9
+ * What only a real broker can show, and what these are for:
10
+ * * one holder: two handles, one permit;
11
+ * * the retry of a call whose answer was lost comes back with the SAME
12
+ * permit (the owner);
13
+ * * an expired lock goes to the next handle with a HIGHER token, and the
14
+ * old handle's guarded step commits nothing — the message is not pushed;
15
+ * * a guarded step survives the lock's own renewal;
16
+ * * a semaphore never grants more than its limit to a crowd.
17
+ */
18
+
19
+ import { sleep } from './_kvtimers.js'
20
+
21
+ const fail = (message) => ({ success: false, message })
22
+
23
+ /** A broker alone raises its cluster version to 5 on its first tick; `check` needs it. */
24
+ async function checksAreServed(client) {
25
+ for (let i = 0; i < 100; i++) {
26
+ try {
27
+ await client.kv.check('test-locks-probe', 'k', { expect: 0 })
28
+ return
29
+ } catch (error) {
30
+ if (error.status !== 503) throw error
31
+ await sleep(100)
32
+ }
33
+ }
34
+ throw new Error('the broker never served a check (cluster version below 5?)')
35
+ }
36
+
37
+ async function drain(client, queue) {
38
+ const seen = []
39
+ for (;;) {
40
+ const msgs = await client.queue(queue).batch(50).wait(false).pop()
41
+ if (!msgs || msgs.length === 0) return seen
42
+ seen.push(...msgs.map(m => m.data))
43
+ await client.ack(msgs, true)
44
+ }
45
+ }
46
+
47
+ export async function lockHasOneHolderAndAGuardedStepCommits(client) {
48
+ await checksAreServed(client)
49
+ const name = 'test-lock/one-holder'
50
+ const a = client.lock(name, { ttl: '30s' })
51
+ const b = client.lock(name, { ttl: '30s' })
52
+ try {
53
+ if ((await a.acquire()) !== true) return fail('the first handle did not acquire')
54
+ if ((await b.acquire()) !== false) return fail('a second handle acquired a held lock')
55
+ if (a.token === null || a.slot !== 0) return fail(`the holder has no token or slot: ${a.token} ${a.slot}`)
56
+
57
+ const who = await client.locks.get(name)
58
+ if (who.held !== true || who.holders.length !== 1 || who.holders[0].owner !== a.owner ||
59
+ who.holders[0].token !== a.token || !who.holders[0].expiresAt) {
60
+ return fail(`get does not show the holder: ${JSON.stringify(who)}`)
61
+ }
62
+
63
+ // The permit is a KV row and nothing else.
64
+ const row = await client.kv.get('queen-locks', `${name}#0`)
65
+ if (row.found !== true || row.version !== a.token || row.value.owner !== a.owner) {
66
+ return fail(`the permit's row is not what the lock says: ${JSON.stringify(row)}`)
67
+ }
68
+
69
+ const queue = 'test-lock-one-holder'
70
+ const res = await client.transaction()
71
+ .guard(a)
72
+ .queue(queue).push([{ data: { step: 1 } }])
73
+ .commit()
74
+ if (res.success !== true) return fail(`the holder's guarded step did not commit: ${JSON.stringify(res)}`)
75
+ const got = await drain(client, queue)
76
+ if (got.length !== 1 || got[0].step !== 1) return fail(`expected the one guarded message, got ${JSON.stringify(got)}`)
77
+
78
+ if ((await a.release()) !== true) return fail('release did not remove the permit')
79
+ if ((await b.acquire()) !== true) return fail('the lock was not free after its release')
80
+ return { success: true, message: 'one holder, get and the KV row agree, a guarded step commits, release frees it' }
81
+ } finally {
82
+ await a.release().catch(() => {})
83
+ await b.release().catch(() => {})
84
+ }
85
+ }
86
+
87
+ export async function lockRetryWithTheSameOwnerIsTheSamePermit(client) {
88
+ const name = 'test-lock/retry-owner'
89
+ const owner = `test-owner-${Date.now()}`
90
+ // What a client does when the answer to its acquire never arrived: the same
91
+ // call again.
92
+ const first = await client.locks.acquire(name, { ttl: '30s', owner })
93
+ const again = await client.locks.acquire(name, { ttl: '30s', owner })
94
+ if (first.acquired !== true || again.acquired !== true) {
95
+ return fail(`both attempts must answer the permit: ${JSON.stringify([first, again])}`)
96
+ }
97
+ if (again.already !== true || again.token !== first.token) {
98
+ return fail(`the retry must answer the SAME permit, marked already: ${JSON.stringify(again)}`)
99
+ }
100
+ // The same for a renew: its answer is lost, the old token is sent again.
101
+ const renewed = await client.locks.renew(name, { token: first.token, ttl: '30s', owner })
102
+ const resent = await client.locks.renew(name, { token: first.token, ttl: '30s', owner })
103
+ if (renewed.renewed !== true || resent.renewed !== true || !(resent.token > renewed.token)) {
104
+ return fail(`a renew sent again by its owner must be carried through: ${JSON.stringify([renewed, resent])}`)
105
+ }
106
+ // Somebody else's stale token is not.
107
+ const stranger = await client.locks.renew(name, { token: first.token, ttl: '30s', owner: 'somebody-else' })
108
+ if (stranger.renewed !== false || stranger.reason !== 'lost' || stranger.holders[0].owner !== owner) {
109
+ return fail(`a stranger's stale token must be lost: ${JSON.stringify(stranger)}`)
110
+ }
111
+ const done = await client.locks.release(name, { token: resent.token })
112
+ return {
113
+ success: done.released === true,
114
+ message: 'acquire and renew sent again by their owner answer the permit; a stranger\'s stale token is lost'
115
+ }
116
+ }
117
+
118
+ export async function lockExpiredIsTakenOverAndTheOldHolderCommitsNothing(client) {
119
+ await checksAreServed(client)
120
+ const name = 'test-lock/expiry'
121
+ const queue = 'test-lock-expiry'
122
+ // The old holder does not renew: it is "paused" for longer than its lease.
123
+ const old = client.lock(name, { ttl: '1s', autoRenew: false })
124
+ const next = client.lock(name, { ttl: '30s' })
125
+ try {
126
+ if (!(await old.acquire())) return fail('the first holder did not acquire')
127
+ const oldToken = old.token
128
+ const staleGuard = old.guard()
129
+ await sleep(1300)
130
+
131
+ if (!(await next.acquire())) return fail('an expired lock was not free for the next holder')
132
+ if (!(next.token > oldToken)) return fail(`the next holder's token ${next.token} is not above ${oldToken}`)
133
+
134
+ // The old holder wakes up and sends the step it was about to send.
135
+ const stale = await client.transaction()
136
+ .kv.check(staleGuard.ns, staleGuard.key, { expect: staleGuard.expect, required: true })
137
+ .queue(queue).push([{ data: { from: 'old' } }])
138
+ .commit()
139
+ if (stale.success !== false || stale.reason !== 'kv_precondition') {
140
+ return fail(`the old holder's step must roll back: ${JSON.stringify(stale)}`)
141
+ }
142
+ if (old.held !== false) return fail('a handle past its lifetime must not claim to hold')
143
+
144
+ const ok = await client.transaction().guard(next).queue(queue).push([{ data: { from: 'next' } }]).commit()
145
+ if (ok.success !== true) return fail(`the new holder's step did not commit: ${JSON.stringify(ok)}`)
146
+
147
+ const got = await drain(client, queue)
148
+ if (got.length !== 1 || got[0].from !== 'next') {
149
+ return fail(`only the new holder's message may exist, got ${JSON.stringify(got)}`)
150
+ }
151
+ return { success: true, message: 'takeover with a higher token; the old holder\'s guarded step pushed nothing' }
152
+ } finally {
153
+ await next.release().catch(() => {})
154
+ }
155
+ }
156
+
157
+ export async function lockGuardedStepSurvivesItsOwnRenewal(client) {
158
+ await checksAreServed(client)
159
+ const name = 'test-lock/renew-under-load'
160
+ const queue = 'test-lock-renew'
161
+ // A short lease renewed every 100 ms, and steps committed as fast as they
162
+ // go: some of them are judged after a renew moved the token.
163
+ const lock = client.lock(name, { ttl: '2s', renewEvery: '100ms' })
164
+ try {
165
+ if (!(await lock.acquire())) return fail('did not acquire')
166
+ const first = lock.token
167
+ let committed = 0
168
+ const end = Date.now() + 1500
169
+ while (Date.now() < end) {
170
+ const res = await client.transaction()
171
+ .guard(lock)
172
+ .queue(queue).push([{ data: { n: committed } }])
173
+ .commit()
174
+ if (res.success !== true) return fail(`step ${committed} did not commit while the lock was held: ${JSON.stringify(res)}`)
175
+ committed++
176
+ }
177
+ if (!(lock.token > first)) return fail('the lock never renewed during the run; the test proved nothing')
178
+ if (lock.held !== true) return fail('the lock was lost under its own renewal')
179
+ const got = await drain(client, queue)
180
+ if (got.length !== committed) return fail(`${committed} steps committed, ${got.length} messages exist`)
181
+ return { success: true, message: `${committed} guarded steps across ${lock.token - first > 0 ? 'several' : 'no'} renewals, each committed once` }
182
+ } finally {
183
+ await lock.release().catch(() => {})
184
+ }
185
+ }
186
+
187
+ export async function lockRunHoldsForTheWorkAndWaitsItsTurn(client) {
188
+ const name = 'test-lock/run'
189
+ const order = []
190
+ const work = (id, ms) => async () => {
191
+ order.push(`${id}:in`)
192
+ await sleep(ms)
193
+ order.push(`${id}:out`)
194
+ return id
195
+ }
196
+ const a = client.lock(name, { ttl: '10s' })
197
+ const b = client.lock(name, { ttl: '10s' })
198
+ const [ra, rb] = await Promise.all([
199
+ a.run(work('a', 300), { wait: '10s' }),
200
+ b.run(work('b', 50), { wait: '10s' })
201
+ ])
202
+ if (!ra.acquired || !rb.acquired) return fail(`both must get their turn: ${JSON.stringify([ra, rb])}`)
203
+ // Never interleaved: in, out, in, out.
204
+ const interleaved = order[0].split(':')[0] !== order[1].split(':')[0]
205
+ if (interleaved || order.length !== 4) return fail(`the two runs overlapped: ${order.join(' ')}`)
206
+ const none = await client.lock(name, { ttl: '10s' }).run(async () => {
207
+ return client.lock(name, { ttl: '10s' }).run(async () => 'inner')
208
+ })
209
+ if (none.value.acquired !== false) return fail('a second handle ran inside the first one\'s hold')
210
+ const free = await client.locks.get(name)
211
+ return {
212
+ success: free.held === false,
213
+ message: `two runs took turns (${order.join(' ')}), and the lock is free afterwards`
214
+ }
215
+ }
216
+
217
+ export async function semaphoreNeverGrantsMoreThanItsLimit(client) {
218
+ const name = 'test-sem/crowd'
219
+ const LIMIT = 3
220
+ const permits = Array.from({ length: 12 }, () => client.semaphore(name, LIMIT, { ttl: '30s' }))
221
+ try {
222
+ const got = await Promise.all(permits.map(p => p.acquire()))
223
+ let holders = permits.filter((_, i) => got[i])
224
+ if (holders.length < 1 || holders.length > LIMIT) return fail(`${holders.length} permits granted of ${LIMIT}`)
225
+ // A crowd can leave a permit free (each gives up after a few lost races):
226
+ // one at a time, it fills.
227
+ for (const p of permits) {
228
+ if (holders.length === LIMIT) break
229
+ if (!p.held && await p.acquire()) holders.push(p)
230
+ }
231
+ if (holders.length !== LIMIT) return fail(`the semaphore did not fill: ${holders.length} of ${LIMIT}`)
232
+ const slots = new Set(holders.map(p => p.slot))
233
+ if (slots.size !== LIMIT) return fail(`two holders share a slot: ${[...slots]}`)
234
+
235
+ const extra = client.semaphore(name, LIMIT, { ttl: '30s' })
236
+ if (await extra.acquire()) return fail('a permit past the limit was granted')
237
+ const who = await client.locks.get(name)
238
+ if (who.holders.length !== LIMIT) return fail(`get shows ${who.holders.length} holders of ${LIMIT}`)
239
+
240
+ // One leaves, the waiter gets exactly that slot.
241
+ const freed = holders[0].slot
242
+ const waiting = extra.acquire({ wait: '5s' })
243
+ await sleep(200)
244
+ await holders[0].release()
245
+ if (!(await waiting)) return fail('a waiter did not get the freed permit')
246
+ if (extra.slot !== freed) return fail(`the waiter got slot ${extra.slot}, not the freed ${freed}`)
247
+ await extra.release()
248
+ return { success: true, message: `a crowd of 12 on ${LIMIT} permits: never more than ${LIMIT}, a freed slot goes to the waiter` }
249
+ } finally {
250
+ await Promise.all(permits.map(p => p.release().catch(() => {})))
251
+ }
252
+ }
253
+
254
+ export async function kvCheckLooksAndGatesWithoutWriting(client) {
255
+ await checksAreServed(client)
256
+ const ns = 'test-kv-check'
257
+ const put = await client.kv.put(ns, 'state', { n: 1 }, { ttl: '10m' })
258
+ const held = await client.kv.check(ns, 'state', { expect: put.version })
259
+ if (held.applied !== true || held.version !== put.version || 'value' in held) {
260
+ return fail(`a held check answers its version and no value: ${JSON.stringify(held)}`)
261
+ }
262
+ const stale = await client.kv.check(ns, 'state', { expect: put.version + 1000 })
263
+ if (stale.applied !== false || stale.reason !== 'version' || stale.value.n !== 1) {
264
+ return fail(`a lost check answers what a reader sees: ${JSON.stringify(stale)}`)
265
+ }
266
+ const absent = await client.kv.check(ns, 'nobody', { expect: 0 })
267
+ if (absent.applied !== true) return fail(`expect: 0 on an absent key must hold: ${JSON.stringify(absent)}`)
268
+ const row = await client.kv.get(ns, 'state')
269
+ if (row.version !== put.version) return fail('a check moved the version it read')
270
+
271
+ // The gate: a batch-wide precondition on a key the batch does not write.
272
+ const gated = await client.kv.check(ns, 'state', { expect: put.version + 1000, required: true })
273
+ if (gated.applied !== false || gated.precondition !== true) {
274
+ return fail(`a required check that loses is a precondition verdict: ${JSON.stringify(gated)}`)
275
+ }
276
+ return { success: true, message: 'check holds, loses with the current row, takes expect:0, and never writes' }
277
+ }
package/test-v2/run.js CHANGED
@@ -17,6 +17,7 @@ import * as semanticsTests from './semantics.js'
17
17
  import * as ackWindowTests from './ackwindow.js'
18
18
  import * as kvTests from './kv.js'
19
19
  import * as timerTests from './timers.js'
20
+ import * as lockTests from './locks.js'
20
21
  import * as streamTests from './stream/index.js'
21
22
  import * as docsTests from './docs.js'
22
23
  import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
@@ -115,6 +116,7 @@ async function main() {
115
116
  ackWindowTests,
116
117
  kvTests,
117
118
  timerTests,
119
+ lockTests,
118
120
  docsTests
119
121
  ]
120
122
 
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A real node:http server for the binary wire forms: it records every request
3
+ * with its body as bytes (kv-unit/_planServer.js reads bodies as text, which a
4
+ * binary push body does not survive) and answers what `answer(hit)` returns:
5
+ * `{ status, contentType, body }`, body a Buffer or a string.
6
+ */
7
+
8
+ import { createServer } from 'node:http'
9
+
10
+ export function withWireServer(answer, run) {
11
+ const hits = []
12
+
13
+ const server = createServer((req, res) => {
14
+ const chunks = []
15
+ req.on('data', chunk => chunks.push(chunk))
16
+ req.on('end', () => {
17
+ const hit = { method: req.method, url: req.url, headers: req.headers, raw: Buffer.concat(chunks) }
18
+ hits.push(hit)
19
+ const { status = 200, contentType = 'application/json', body = '' } = answer(hit) || {}
20
+ if (status === 204) {
21
+ res.writeHead(204)
22
+ res.end()
23
+ return
24
+ }
25
+ res.writeHead(status, { 'Content-Type': contentType })
26
+ res.end(body)
27
+ })
28
+ })
29
+
30
+ return new Promise((resolve, reject) => {
31
+ server.listen(0, '127.0.0.1', () => {
32
+ const { port } = server.address()
33
+ const teardown = () => new Promise(r => server.close(r))
34
+ Promise.resolve()
35
+ .then(() => run(`http://127.0.0.1:${port}`, hits))
36
+ .then(
37
+ async (value) => { await teardown(); resolve(value) },
38
+ async (err) => { await teardown(); reject(err) }
39
+ )
40
+ })
41
+ })
42
+ }
43
+
44
+ export function query(url) {
45
+ const i = url.indexOf('?')
46
+ return new URLSearchParams(i < 0 ? '' : url.slice(i + 1))
47
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The framed pop answer (client-v2/http/popFrames.js), against hand-built
3
+ * answers and a server that plays them. What is at stake: a consumer that
4
+ * sets framedPop must be handed exactly the messages the JSON answer gives it,
5
+ * and one that does not set it must send the request every released broker
6
+ * knows.
7
+ */
8
+
9
+ import { describe, it } from 'node:test'
10
+ import assert from 'node:assert/strict'
11
+ import { zstdCompressSync } from 'node:zlib'
12
+
13
+ import { Queen, CONFLATION_UNSUPPORTED } from '../../client-v2/index.js'
14
+ import { decodePopFrames, isPopFrames } from '../../client-v2/http/popFrames.js'
15
+ import { withWireServer, query } from './_wireServer.js'
16
+
17
+ const u16 = (n) => { const b = Buffer.alloc(2); b.writeUInt16LE(n); return b }
18
+ const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32LE(n); return b }
19
+ const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b }
20
+ const text = (s) => Buffer.concat([u16(Buffer.byteLength(s)), Buffer.from(s)])
21
+
22
+ /** One stored frame: u32 body_len | flags | id | [trace] | txn | [producer] | payload. */
23
+ function storedFrame(id, txn, payload, { producer = null, trace = null } = {}) {
24
+ const idBytes = Buffer.alloc(16)
25
+ idBytes[0] = id
26
+ idBytes[6] = 0x70
27
+ idBytes[8] = 0x80
28
+ const body = Buffer.concat([
29
+ Buffer.of((trace ? 1 : 0) | (producer ? 2 : 0)),
30
+ idBytes,
31
+ trace ? Buffer.from(trace.replaceAll('-', ''), 'hex') : Buffer.alloc(0),
32
+ text(txn),
33
+ producer ? text(producer) : Buffer.alloc(0),
34
+ Buffer.from(payload)
35
+ ])
36
+ return Buffer.concat([u32(body.length), body])
37
+ }
38
+
39
+ /**
40
+ * A framed pop answer of one claim on partition 7 ("p7") of "orders", lease
41
+ * "lease-1": one record at base offset 10 holding `frames`, created at
42
+ * 2023-11-14T22:13:20Z, of which the claim covers start..=end.
43
+ */
44
+ function framedAnswer(own, start, end, compress, frames) {
45
+ let raw = Buffer.concat(frames)
46
+ if (compress) raw = zstdCompressSync(raw)
47
+ return Buffer.concat([
48
+ Buffer.from('QPF1'), Buffer.of(1), u32(Buffer.byteLength(own)), Buffer.from(own),
49
+ text('orders'), text('lease-1'), text('__QUEUE_MODE__'),
50
+ u32(1), // claims
51
+ u64(7), text('p7'), u64(start), u64(end),
52
+ u32(3), // delivery attempt
53
+ u32(1), // records
54
+ u64(10), u64(1_700_000_000_000_000n), u32(frames.length), Buffer.of(compress ? 1 : 0), u32(raw.length), raw
55
+ ])
56
+ }
57
+
58
+ const twoFrames = () => [storedFrame(1, 't-10', '{"n":10}'), storedFrame(2, 't-11', '{"n":11,"s":"x"}', { producer: 'svc' })]
59
+
60
+ // The JSON answer of the same two messages, as the broker renders it.
61
+ const TWO_MESSAGES_JSON = '{"success":true,"queue":"orders","partition":"p7","partitionId":"7","leaseId":"lease-1","consumerGroup":"__QUEUE_MODE__","messages":[' +
62
+ '{"id":"01000000-0000-7000-8000-000000000000","transactionId":"t-10","traceId":null,"data":{"n":10},"producerSub":null,"createdAt":"2023-11-14T22:13:20.000Z","partitionId":"7","partition":"p7","leaseId":"lease-1","consumerGroup":"__QUEUE_MODE__","deliveryAttempt":3,"offset":10},' +
63
+ '{"id":"02000000-0000-7000-8000-000000000000","transactionId":"t-11","traceId":null,"data":{"n":11,"s":"x"},"producerSub":"svc","createdAt":"2023-11-14T22:13:20.000Z","partitionId":"7","partition":"p7","leaseId":"lease-1","consumerGroup":"__QUEUE_MODE__","deliveryAttempt":3,"offset":11}' +
64
+ '],"partitionsClaimed":1}'
65
+
66
+ const json = (body) => ({ body })
67
+ const framed = (body) => ({ contentType: 'application/x-queen-pop', body })
68
+ const pops = (hits) => hits.filter(h => h.method === 'GET' && h.url.startsWith('/api/v1/pop'))
69
+
70
+ async function withQueen(config, answer, run) {
71
+ await withWireServer(answer, async (url, hits) => {
72
+ const queen = new Queen({ url, handleSignals: false, retryAttempts: 1, ...config })
73
+ try {
74
+ await run(queen, hits)
75
+ } finally {
76
+ await queen.close()
77
+ }
78
+ })
79
+ }
80
+
81
+ describe('decodePopFrames', () => {
82
+ for (const compress of [false, true]) {
83
+ it(`unpacks ${compress ? 'a zstd' : 'a raw'} record into the JSON answer's object`, async () => {
84
+ const got = await decodePopFrames(framedAnswer('{"frames":true}', 10, 11, compress, twoFrames()))
85
+
86
+ assert.deepEqual(got, JSON.parse(TWO_MESSAGES_JSON))
87
+ assert.deepEqual(Object.keys(got.messages[0]), Object.keys(JSON.parse(TWO_MESSAGES_JSON).messages[0]))
88
+ })
89
+
90
+ it(`keeps only the messages inside the claim (${compress ? 'zstd' : 'raw'})`, async () => {
91
+ const got = await decodePopFrames(framedAnswer('{"frames":true}', 11, 11, compress, twoFrames()))
92
+
93
+ assert.equal(got.messages.length, 1)
94
+ assert.equal(got.messages[0].offset, 11)
95
+ assert.equal(got.messages[0].transactionId, 't-11')
96
+ assert.equal(got.messages[0].producerSub, 'svc')
97
+ assert.equal(got.messages[0].id, '02000000-0000-7000-8000-000000000000')
98
+ })
99
+ }
100
+
101
+ it('reads a trace id, an empty payload as null, and the answer without its own keys', async () => {
102
+ const trace = '0190aaaa-0000-4000-8000-000000000001'
103
+ const answer = framedAnswer('', 10, 11, false, [storedFrame(1, 't-10', '', { trace }), storedFrame(2, 't-11', '[1,"due"]')])
104
+ const got = await decodePopFrames(answer)
105
+
106
+ assert.equal(got.messages[0].traceId, trace)
107
+ assert.equal(got.messages[0].data, null)
108
+ assert.deepEqual(got.messages[1].data, [1, 'due'])
109
+
110
+ // Version 0 has no JSON object in front of the names.
111
+ const v0 = Buffer.concat([Buffer.from('QPF1'), Buffer.of(0), answer.subarray(9)])
112
+ assert.deepEqual(await decodePopFrames(v0), got)
113
+ })
114
+
115
+ it('adds the answer\'s own keys, without `frames`', async () => {
116
+ const own = '{"frames":true,"conflation":true,"autopilot":{"partitions":4,"batch":50}}'
117
+ const got = await decodePopFrames(framedAnswer(own, 10, 11, false, twoFrames()))
118
+
119
+ assert.equal('frames' in got, false)
120
+ assert.equal(got.conflation, true)
121
+ assert.deepEqual(got.autopilot, { partitions: 4, batch: 50 })
122
+ })
123
+
124
+ it('rejects damage', async () => {
125
+ const answer = framedAnswer('{"frames":true}', 10, 11, false, twoFrames())
126
+
127
+ assert.equal(isPopFrames(answer), true)
128
+ assert.equal(isPopFrames(Buffer.from(TWO_MESSAGES_JSON)), false)
129
+ await assert.rejects(decodePopFrames(answer.subarray(0, answer.length - 3)), /framed pop answer/)
130
+ await assert.rejects(decodePopFrames(Buffer.from(TWO_MESSAGES_JSON)), /not a framed pop answer/)
131
+
132
+ const future = Buffer.from(answer)
133
+ future[4] = 9
134
+ await assert.rejects(decodePopFrames(future), /unknown version 9/)
135
+
136
+ // A record that declares more frames than it holds.
137
+ const short = Buffer.from(answer)
138
+ const frames = twoFrames()
139
+ short.writeUInt32LE(3, short.length - frames[0].length - frames[1].length - 9)
140
+ await assert.rejects(decodePopFrames(short), /a record is shorter than its 3 frames/)
141
+
142
+ const badCodec = Buffer.from(answer)
143
+ badCodec[badCodec.length - frames[0].length - frames[1].length - 5] = 7
144
+ await assert.rejects(decodePopFrames(badCodec), /unknown codec 7/)
145
+ })
146
+ })
147
+
148
+ describe('pop — the default', () => {
149
+ it('sends no frames parameter', async () => {
150
+ await withQueen({}, () => json(TWO_MESSAGES_JSON), async (queen, hits) => {
151
+ const messages = await queen.queue('orders').batch(2).wait(false).pop()
152
+
153
+ assert.equal(messages.length, 2)
154
+ assert.equal(query(pops(hits)[0].url).has('frames'), false)
155
+ })
156
+ })
157
+ })
158
+
159
+ describe('pop — framedPop', () => {
160
+ for (const compress of [false, true]) {
161
+ it(`gives the messages of the JSON answer (${compress ? 'zstd' : 'raw'} record)`, async () => {
162
+ let want = null
163
+ await withQueen({}, () => json(TWO_MESSAGES_JSON), async (queen) => {
164
+ want = await queen.queue('orders').batch(2).wait(false).pop()
165
+ })
166
+
167
+ const answer = framedAnswer('{"frames":true}', 10, 11, compress, twoFrames())
168
+ await withQueen({ framedPop: true }, () => framed(answer), async (queen, hits) => {
169
+ const got = await queen.queue('orders').batch(2).wait(false).pop()
170
+
171
+ assert.equal(query(pops(hits)[0].url).get('frames'), 'true')
172
+ assert.equal(got.length, 2)
173
+ assert.deepEqual(got, want)
174
+ })
175
+ })
176
+ }
177
+
178
+ it('asks for frames on the partition and the namespace routes too', async () => {
179
+ await withQueen({ framedPop: true }, () => ({ status: 204 }), async (queen, hits) => {
180
+ await queen.queue('orders').partition('p7').wait(false).pop()
181
+ await queen.queue().namespace('ns').wait(false).pop()
182
+
183
+ const [byPartition, byNamespace] = pops(hits)
184
+ assert.ok(byPartition.url.startsWith('/api/v1/pop/queue/orders/partition/p7?'))
185
+ assert.equal(query(byPartition.url).get('frames'), 'true')
186
+ assert.ok(byNamespace.url.startsWith('/api/v1/pop?'))
187
+ assert.equal(query(byNamespace.url).get('frames'), 'true')
188
+ })
189
+ })
190
+
191
+ it('knows a framed answer by its first bytes, whatever its content type', async () => {
192
+ const answer = framedAnswer('{"frames":true}', 10, 11, false, twoFrames())
193
+ for (const contentType of ['application/octet-stream', 'application/json']) {
194
+ await withQueen({ framedPop: true }, () => ({ contentType, body: answer }), async (queen) => {
195
+ const got = await queen.queue('orders').batch(2).wait(false).pop()
196
+
197
+ assert.deepEqual(got.map(m => m.transactionId), ['t-10', 't-11'], contentType)
198
+ })
199
+ }
200
+ })
201
+
202
+ it('reads a JSON answer as always: a broker that does not know the parameter', async () => {
203
+ await withQueen({ framedPop: true }, () => json(TWO_MESSAGES_JSON), async (queen) => {
204
+ const got = await queen.queue('orders').batch(2).wait(false).pop()
205
+
206
+ assert.equal(got.length, 2)
207
+ assert.equal(got[1].transactionId, 't-11')
208
+ assert.equal(got[1].data.s, 'x')
209
+ })
210
+ })
211
+
212
+ it('reads an empty pop', async () => {
213
+ await withQueen({ framedPop: true }, () => ({ status: 204 }), async (queen) => {
214
+ assert.deepEqual(await queen.queue('orders').wait(false).pop(), [])
215
+ })
216
+ })
217
+
218
+ it('carries the autopilot\'s choice in the answer\'s own keys', async () => {
219
+ const own = '{"frames":true,"autopilot":{"partitions":4,"batch":50}}'
220
+ await withQueen({ framedPop: true }, () => framed(framedAnswer(own, 10, 11, false, twoFrames())), async (queen) => {
221
+ const { messages, autopilot } = await queen.queue('orders').wait(false).popResult()
222
+
223
+ assert.equal(messages.length, 2)
224
+ assert.deepEqual(autopilot, { partitions: 4, batch: 50, waitMillis: 0 })
225
+ })
226
+ })
227
+
228
+ it('carries the conflation echo, and still raises when the broker sent none', async () => {
229
+ const echoed = framedAnswer('{"frames":true,"conflation":true}', 10, 11, false, twoFrames())
230
+ await withQueen({ framedPop: true }, () => framed(echoed), async (queen) => {
231
+ const got = await queen.queue('orders').group('g').conflation(true).wait(false).pop()
232
+ assert.equal(got.length, 2)
233
+ })
234
+
235
+ const silent = framedAnswer('{"frames":true}', 10, 11, false, twoFrames())
236
+ await withQueen({ framedPop: true }, () => framed(silent), async (queen) => {
237
+ await assert.rejects(
238
+ queen.queue('orders').group('g2').conflation(true).wait(false).pop(),
239
+ { code: CONFLATION_UNSUPPORTED }
240
+ )
241
+ })
242
+ })
243
+ })
244
+
245
+ describe('consume — framedPop', () => {
246
+ // The consumer loop on framed answers: the handler gets every message of
247
+ // the claim, in order, and the loop stops at its limit.
248
+ it('hands the handler the messages of the claim, in order', async () => {
249
+ const answer = framedAnswer('{"frames":true}', 10, 11, true, twoFrames())
250
+ await withQueen({ framedPop: true }, () => framed(answer), async (queen, hits) => {
251
+ const seen = []
252
+ await queen.queue('orders').batch(2).wait(false).autoAck(false).each().limit(2)
253
+ .consume(async (message) => {
254
+ seen.push(`${message.transactionId}@${message.partitionId}/${message.leaseId}`)
255
+ })
256
+
257
+ assert.deepEqual(seen, ['t-10@7/lease-1', 't-11@7/lease-1'])
258
+ assert.ok(pops(hits).length >= 1)
259
+ for (const hit of pops(hits)) {
260
+ assert.equal(query(hit.url).get('frames'), 'true')
261
+ }
262
+ })
263
+ })
264
+ })