queen-mq 1.0.6 → 1.2.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,410 @@
1
+ /**
2
+ * Pop autopilot, client side.
3
+ *
4
+ * The four things a client can be wrong about here, and why each is asserted
5
+ * against the WHOLE query string rather than against one parameter:
6
+ *
7
+ * 1. BOTH BUILDERS MUST AGREE. pop() and consume() assemble their query
8
+ * strings separately (QueueBuilder.pop's inline params vs
9
+ * ConsumerManager#buildParams) — the hazard the standing comment in
10
+ * QueueBuilder.js already names. Every case below is run through both, and
11
+ * both are compared to the same expected string, so a rule implemented in
12
+ * one and not the other cannot pass.
13
+ *
14
+ * 2. NOT ENGAGING AUTOPILOT MUST BE BYTE-IDENTICAL TO THE OLD SDK. The escape
15
+ * hatch is only worth having if it is exact, and "exact" is not something a
16
+ * test of one parameter can show: a stray autopilot=true, or a batch that
17
+ * stopped being emitted, is a different request. Hence full-string equality
18
+ * including the parameters this feature never touches, and including their
19
+ * ORDER — this SDK appends rather than sorting, so order is part of the
20
+ * bytes.
21
+ *
22
+ * 3. AN EXPLICIT VALUE IS SACRED, PER DIMENSION. partitions(1) and "never
23
+ * called partitions" both used to reach the wire as nothing at all; they are
24
+ * now different requests, and the pinned one must survive autopilot.
25
+ *
26
+ * 4. THE ADDITIVE RESPONSE FIELD MUST NOT BE LOAD-BEARING. A broker that does
27
+ * not send it, sends it half-filled, or sends it with fields this SDK has
28
+ * never heard of, all have to work.
29
+ *
30
+ * Same style as conflation-unit/conflationWire.test.js: a real node:http server
31
+ * playing a canned plan, real fetch, real JSON, and every assertion is about
32
+ * bytes that actually crossed a socket.
33
+ */
34
+
35
+ import { describe, it, afterEach } from 'node:test'
36
+ import assert from 'node:assert/strict'
37
+
38
+ import { Queen } from '../../client-v2/index.js'
39
+ import { Runner } from '../../client-v2/streams/runtime/Runner.js'
40
+ import {
41
+ ENV_POP_AUTOPILOT,
42
+ popAutopilotDisabledByEnv,
43
+ popSizing,
44
+ parseAutopilotDecision,
45
+ emptyPollDelayMillis,
46
+ EMPTY_POLL_BACKOFF_MILLIS
47
+ } from '../../client-v2/utils/autopilot.js'
48
+ import { withPlanServer, ok } from '../kv-unit/_planServer.js'
49
+
50
+ const QUEUE = 'q'
51
+ const GROUP = 'g'
52
+
53
+ /** One delivered frame, shaped like a real pop response element. */
54
+ function frame(n = 1) {
55
+ return {
56
+ transactionId: `txn-${n}`,
57
+ partitionId: `part-${n}`,
58
+ partition: 'Default',
59
+ payload: { n },
60
+ leaseId: 'lease-1',
61
+ consumerGroup: GROUP
62
+ }
63
+ }
64
+
65
+ /** A 200 pop body; `extra` carries the additive autopilot echo under test. */
66
+ function popBody(extra = {}, frames = [frame()]) {
67
+ return ok({ messages: frames, partitionsClaimed: frames.length, ...extra })
68
+ }
69
+
70
+ /** Query string of a recorded hit, exactly as it crossed the socket. */
71
+ function rawQuery(url) {
72
+ const i = url.indexOf('?')
73
+ return i < 0 ? '' : url.slice(i + 1)
74
+ }
75
+
76
+ /** Only the pop hits — consume also acks, and the ack is not under test here. */
77
+ function popHits(hits) {
78
+ return hits.filter(h => h.url.startsWith('/api/v1/pop'))
79
+ }
80
+
81
+ async function withQueen(plan, defaultResponse, run) {
82
+ await withPlanServer(plan, defaultResponse, async (url, hits) => {
83
+ const queen = new Queen({ url, handleSignals: false })
84
+ try {
85
+ await run(queen, hits)
86
+ } finally {
87
+ await queen.close()
88
+ }
89
+ })
90
+ }
91
+
92
+ afterEach(() => { delete process.env[ENV_POP_AUTOPILOT] })
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // 1. Param assembly, from both builders.
96
+ // ---------------------------------------------------------------------------
97
+
98
+ // The shared spine of every case: a named queue and group, no long poll,
99
+ // default timeout. Everything that varies below is sizing.
100
+ const GROUP_AND_TAIL = `wait=false&timeout=30000&consumerGroup=${GROUP}`
101
+
102
+ const CASES = [
103
+ {
104
+ // (a) nothing set: both knobs go to the broker, neither travels.
105
+ name: 'nothing set',
106
+ build: qb => qb,
107
+ want: `autopilot=true&${GROUP_AND_TAIL}`
108
+ },
109
+ {
110
+ // (b) partitions pinned, batch left to the broker.
111
+ name: 'partitions only',
112
+ build: qb => qb.partitions(4),
113
+ want: `autopilot=true&${GROUP_AND_TAIL}&partitions=4`
114
+ },
115
+ {
116
+ // (b') the pin that used to be indistinguishable from unset. partitions(1)
117
+ // is a decision — hold this consumer to one partition — and the broker has
118
+ // to be told, or autopilot would widen it.
119
+ name: 'partitions pinned to one',
120
+ build: qb => qb.partitions(1),
121
+ want: `autopilot=true&${GROUP_AND_TAIL}&partitions=1`
122
+ },
123
+ {
124
+ // (c) batch pinned, sweep width left to the broker.
125
+ name: 'batch only',
126
+ build: qb => qb.batch(50),
127
+ want: `autopilot=true&batch=50&${GROUP_AND_TAIL}`
128
+ },
129
+ {
130
+ // (d) both set: nothing left to decide, so no autopilot parameter and the
131
+ // exact request the pre-autopilot SDK sent.
132
+ name: 'both set',
133
+ build: qb => qb.batch(50).partitions(4),
134
+ want: `batch=50&${GROUP_AND_TAIL}&partitions=4`
135
+ },
136
+ {
137
+ // (d') both set with partitions at 1: still byte-identical to the old SDK,
138
+ // which never emitted partitions=1.
139
+ name: 'both set, partitions one',
140
+ build: qb => qb.batch(50).partitions(1),
141
+ want: `batch=50&${GROUP_AND_TAIL}`
142
+ },
143
+ {
144
+ // (e) escape hatch, nothing set: the client-side defaults are back.
145
+ name: 'autopilot off, nothing set',
146
+ build: qb => qb.autopilot(false),
147
+ want: `batch=1&${GROUP_AND_TAIL}`
148
+ },
149
+ {
150
+ // (e') escape hatch with a pin: partitions=1 stays off the wire, exactly as
151
+ // before autopilot existed.
152
+ name: 'autopilot off, partitions pinned to one',
153
+ build: qb => qb.autopilot(false).partitions(1),
154
+ want: `batch=1&${GROUP_AND_TAIL}`
155
+ },
156
+ {
157
+ name: 'autopilot off, both set',
158
+ build: qb => qb.autopilot(false).batch(50).partitions(4),
159
+ want: `batch=50&${GROUP_AND_TAIL}&partitions=4`
160
+ },
161
+ {
162
+ // autopilot(true) is the default, spelled out. It must not change anything,
163
+ // including for a caller who set both knobs.
164
+ name: 'autopilot explicitly on, both set',
165
+ build: qb => qb.autopilot(true).batch(50).partitions(4),
166
+ want: `batch=50&${GROUP_AND_TAIL}&partitions=4`
167
+ },
168
+ {
169
+ // batch(0) is not "a batch of zero" and never was: it is the absence of an
170
+ // opinion, which now means the broker decides.
171
+ name: 'batch zero is unset',
172
+ build: qb => qb.batch(0),
173
+ want: `autopilot=true&${GROUP_AND_TAIL}`
174
+ }
175
+ ]
176
+
177
+ describe('pop autopilot — param assembly (pop)', () => {
178
+ for (const tc of CASES) {
179
+ it(tc.name, async () => {
180
+ await withQueen([popBody()], popBody(), async (queen, hits) => {
181
+ await tc.build(queen.queue(QUEUE).group(GROUP).wait(false)).pop()
182
+
183
+ assert.equal(popHits(hits).length, 1)
184
+ assert.equal(rawQuery(popHits(hits)[0].url), tc.want)
185
+ })
186
+ })
187
+ }
188
+ })
189
+
190
+ describe('pop autopilot — param assembly (consume)', () => {
191
+ for (const tc of CASES) {
192
+ it(tc.name, async () => {
193
+ await withQueen([popBody()], popBody(), async (queen, hits) => {
194
+ let handled = 0
195
+ await tc.build(queen.queue(QUEUE).group(GROUP).wait(false))
196
+ .limit(1)
197
+ .consume(() => { handled++ })
198
+
199
+ assert.equal(handled, 1)
200
+ const pops = popHits(hits)
201
+ assert.ok(pops.length >= 1, 'consume made no pop request')
202
+ assert.equal(rawQuery(pops[0].url), tc.want)
203
+ })
204
+ })
205
+ }
206
+ })
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // 2. The process-wide rollback.
210
+ // ---------------------------------------------------------------------------
211
+
212
+ describe('pop autopilot — QUEEN_SDK_POP_AUTOPILOT', () => {
213
+ it('a client built while the variable is set sends the pre-autopilot request', async () => {
214
+ process.env[ENV_POP_AUTOPILOT] = 'off'
215
+ await withQueen([popBody()], popBody(), async (queen, hits) => {
216
+ await queen.queue(QUEUE).group(GROUP).wait(false).pop()
217
+
218
+ assert.equal(rawQuery(popHits(hits)[0].url), `batch=1&${GROUP_AND_TAIL}`)
219
+ })
220
+ })
221
+
222
+ it('is read ONCE, at construction — changing it later does not move a live client', async () => {
223
+ await withQueen([popBody()], popBody(), async (queen, hits) => {
224
+ process.env[ENV_POP_AUTOPILOT] = 'off'
225
+ await queen.queue(QUEUE).group(GROUP).wait(false).pop()
226
+
227
+ assert.equal(rawQuery(popHits(hits)[0].url), `autopilot=true&${GROUP_AND_TAIL}`)
228
+ })
229
+ })
230
+
231
+ it('an explicit .autopilot(true) outranks the environment', async () => {
232
+ process.env[ENV_POP_AUTOPILOT] = 'off'
233
+ await withQueen([popBody()], popBody(), async (queen, hits) => {
234
+ await queen.queue(QUEUE).group(GROUP).wait(false).autopilot(true).pop()
235
+
236
+ assert.equal(rawQuery(popHits(hits)[0].url), `autopilot=true&${GROUP_AND_TAIL}`)
237
+ })
238
+ })
239
+
240
+ it('accepts the whole vocabulary, and nothing else', () => {
241
+ for (const v of ['off', 'OFF', ' off ', 'false', '0', 'no', 'disabled']) {
242
+ process.env[ENV_POP_AUTOPILOT] = v
243
+ assert.equal(popAutopilotDisabledByEnv(), true, `${v} should disable autopilot`)
244
+ }
245
+ for (const v of ['', 'on', 'true', '1', 'yes', 'nonsense']) {
246
+ process.env[ENV_POP_AUTOPILOT] = v
247
+ assert.equal(popAutopilotDisabledByEnv(), false, `${v} should leave autopilot on`)
248
+ }
249
+ delete process.env[ENV_POP_AUTOPILOT]
250
+ assert.equal(popAutopilotDisabledByEnv(), false, 'unset leaves autopilot on')
251
+ })
252
+ })
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // 3. The additive response field.
256
+ // ---------------------------------------------------------------------------
257
+
258
+ describe('pop autopilot — the echo', () => {
259
+ it('parses what the broker chose and ignores what it did not', () => {
260
+ assert.equal(parseAutopilotDecision(null), null)
261
+ assert.equal(parseAutopilotDecision({ messages: [] }), null, 'absent')
262
+ assert.equal(parseAutopilotDecision({ autopilot: null }), null, 'null')
263
+ assert.equal(parseAutopilotDecision({ autopilot: true }), null, 'not an object')
264
+ assert.equal(parseAutopilotDecision({ autopilot: [] }), null, 'an array is not an object')
265
+
266
+ assert.deepEqual(
267
+ parseAutopilotDecision({ autopilot: { partitions: 8, batch: 200, waitMs: 25 } }),
268
+ { partitions: 8, batch: 200, waitMillis: 25 }
269
+ )
270
+ // waitMs is optional: the broker sends it only when it has an opinion.
271
+ assert.deepEqual(
272
+ parseAutopilotDecision({ autopilot: { partitions: 4, batch: 64 } }),
273
+ { partitions: 4, batch: 64, waitMillis: 0 }
274
+ )
275
+ // Forward compatibility: a newer broker growing a field must not cost this
276
+ // client the fields it does understand.
277
+ assert.deepEqual(
278
+ parseAutopilotDecision({
279
+ autopilot: { partitions: 2, batch: 10, waitMs: 5, reason: 'ready_age', confidence: 0.9 }
280
+ }),
281
+ { partitions: 2, batch: 10, waitMillis: 5 }
282
+ )
283
+ // A field of the wrong type is dropped, not fatal.
284
+ assert.deepEqual(
285
+ parseAutopilotDecision({ autopilot: { partitions: 'eight', batch: 10 } }),
286
+ { partitions: 0, batch: 10, waitMillis: 0 }
287
+ )
288
+ })
289
+
290
+ it('popResult() hands the caller what the broker chose', async () => {
291
+ const body = popBody({ autopilot: { partitions: 8, batch: 200, waitMs: 25 } })
292
+ await withQueen([body], body, async (queen) => {
293
+ const res = await queen.queue(QUEUE).group(GROUP).wait(false).popResult()
294
+
295
+ assert.equal(res.messages.length, 1)
296
+ assert.deepEqual(res.autopilot, { partitions: 8, batch: 200, waitMillis: 25 })
297
+ })
298
+ })
299
+
300
+ it('popResult() reports null when the broker said nothing — a 1.1 broker, say', async () => {
301
+ await withQueen([popBody()], popBody(), async (queen) => {
302
+ const res = await queen.queue(QUEUE).group(GROUP).wait(false).popResult()
303
+
304
+ assert.equal(res.messages.length, 1)
305
+ assert.equal(res.autopilot, null)
306
+ })
307
+ })
308
+
309
+ it('pop() still returns a bare array of messages', async () => {
310
+ const body = popBody({ autopilot: { partitions: 8, batch: 200 } })
311
+ await withQueen([body], body, async (queen) => {
312
+ const messages = await queen.queue(QUEUE).group(GROUP).wait(false).pop()
313
+
314
+ assert.ok(Array.isArray(messages))
315
+ assert.equal(messages.length, 1)
316
+ })
317
+ })
318
+ })
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // 4. Empty-poll pacing.
322
+ // ---------------------------------------------------------------------------
323
+
324
+ describe('pop autopilot — empty-poll pacing', () => {
325
+ it('honours the broker advice, and falls back to the historical delay', () => {
326
+ assert.equal(emptyPollDelayMillis(null), EMPTY_POLL_BACKOFF_MILLIS)
327
+ assert.equal(emptyPollDelayMillis({ partitions: 1, batch: 1, waitMillis: 0 }), EMPTY_POLL_BACKOFF_MILLIS)
328
+ assert.equal(emptyPollDelayMillis({ partitions: 1, batch: 1, waitMillis: 250 }), 250)
329
+ })
330
+ })
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // 5. The rule itself, in isolation.
334
+ // ---------------------------------------------------------------------------
335
+
336
+ describe('pop autopilot — popSizing', () => {
337
+ it('leaves a pinned dimension alone and delegates only the unset one', () => {
338
+ assert.deepEqual(
339
+ popSizing({ batch: null, maxPartitions: null, fallbackBatch: 1, autopilot: true }),
340
+ { autopilot: true, batch: null, partitions: null }
341
+ )
342
+ assert.deepEqual(
343
+ popSizing({ batch: 50, maxPartitions: null, fallbackBatch: 1, autopilot: true }),
344
+ { autopilot: true, batch: '50', partitions: null }
345
+ )
346
+ assert.deepEqual(
347
+ popSizing({ batch: null, maxPartitions: 1, fallbackBatch: 1, autopilot: true }),
348
+ { autopilot: true, batch: null, partitions: '1' }
349
+ )
350
+ // Both set: nothing to decide, so the flag does not travel either.
351
+ assert.deepEqual(
352
+ popSizing({ batch: 50, maxPartitions: 4, fallbackBatch: 1, autopilot: true }),
353
+ { autopilot: false, batch: '50', partitions: '4' }
354
+ )
355
+ // Off: the client-side default comes back and partitions keeps its >1 gate.
356
+ assert.deepEqual(
357
+ popSizing({ batch: null, maxPartitions: null, fallbackBatch: 1, autopilot: false }),
358
+ { autopilot: false, batch: '1', partitions: null }
359
+ )
360
+ assert.deepEqual(
361
+ popSizing({ batch: null, maxPartitions: 1, fallbackBatch: 1, autopilot: false }),
362
+ { autopilot: false, batch: '1', partitions: null }
363
+ )
364
+ })
365
+ })
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // 6. The streams runtime pins its width.
369
+ // ---------------------------------------------------------------------------
370
+
371
+ describe('pop autopilot — streams runtime', () => {
372
+ /** A QueueBuilder stand-in that records the chain the runner builds. */
373
+ function recordingSource(calls) {
374
+ const qb = {
375
+ batch(v) { calls.push(['batch', v]); return qb },
376
+ wait(v) { calls.push(['wait', v]); return qb },
377
+ timeoutMillis(v) { calls.push(['timeoutMillis', v]); return qb },
378
+ group(v) { calls.push(['group', v]); return qb },
379
+ partitions(v) { calls.push(['partitions', v]); return qb },
380
+ subscriptionMode(v) { calls.push(['subscriptionMode', v]); return qb },
381
+ subscriptionFrom(v) { calls.push(['subscriptionFrom', v]); return qb },
382
+ conflation(v) { calls.push(['conflation', v]); return qb },
383
+ async pop() { return [] }
384
+ }
385
+ return qb
386
+ }
387
+
388
+ it('pins maxPartitions even at 1, so autopilot cannot widen a stream cycle', async () => {
389
+ const calls = []
390
+ const runner = Object.create(Runner.prototype)
391
+ Object.assign(runner, {
392
+ stream: { source: recordingSource(calls) },
393
+ batchSize: 100,
394
+ maxWaitMillis: 1000,
395
+ consumerGroup: 'stream-g',
396
+ maxPartitions: 1,
397
+ subscriptionMode: null,
398
+ subscriptionFrom: null,
399
+ conflation: false
400
+ })
401
+
402
+ await runner._popMessages()
403
+
404
+ assert.deepEqual(
405
+ calls.filter(([k]) => k === 'partitions'),
406
+ [['partitions', 1]],
407
+ 'the streams layer always decided its own width; it must keep saying so'
408
+ )
409
+ })
410
+ })