queen-mq 0.13.0 → 0.14.1
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/client-v2/Queen.js +6 -0
- package/client-v2/builders/QueueBuilder.js +33 -0
- package/client-v2/consumer/ConsumerManager.js +5 -2
- package/client-v2/utils/defaults.js +2 -1
- package/package.json +1 -1
- package/test-v2/consume.js +96 -0
- package/test-v2/maintenance.js +16 -2
- package/test-v2/pop.js +225 -3
- package/test-v2/push.js +14 -5
package/client-v2/Queen.js
CHANGED
|
@@ -350,6 +350,12 @@ export class Queen {
|
|
|
350
350
|
}
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
// Dedupe: with v4 multi-partition pop, all messages in one batch share
|
|
354
|
+
// the same leaseId (one renew_lease_v2 call extends every claimed
|
|
355
|
+
// partition_consumers row). Without this, callers passing the full
|
|
356
|
+
// messages array would issue N redundant identical HTTP calls.
|
|
357
|
+
leaseIds = [...new Set(leaseIds)]
|
|
358
|
+
|
|
353
359
|
if (leaseIds.length === 0) {
|
|
354
360
|
logger.warn('Queen.renew', 'No valid lease IDs found for renewal')
|
|
355
361
|
return { success: false, error: 'No valid lease IDs found for renewal' }
|
|
@@ -37,6 +37,7 @@ export class QueueBuilder {
|
|
|
37
37
|
#subscriptionMode = CONSUME_DEFAULTS.subscriptionMode
|
|
38
38
|
#subscriptionFrom = CONSUME_DEFAULTS.subscriptionFrom
|
|
39
39
|
#each = false
|
|
40
|
+
#maxPartitions = 1
|
|
40
41
|
|
|
41
42
|
// Buffer options
|
|
42
43
|
#bufferOptions = null
|
|
@@ -190,6 +191,22 @@ export class QueueBuilder {
|
|
|
190
191
|
return this
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Pop messages from up to N partitions in a single call (v4 multi-partition pop).
|
|
196
|
+
*
|
|
197
|
+
* Use this to drain many sparsely-loaded partitions efficiently. With
|
|
198
|
+
* `partitions(N)`, the global `batch(B)` budget is shared across all
|
|
199
|
+
* claimed partitions: at most B total messages, drawn from up to N
|
|
200
|
+
* partitions, in a single network round-trip. All N share one leaseId
|
|
201
|
+
* (renewing once extends them all).
|
|
202
|
+
*
|
|
203
|
+
* Default 1 = legacy single-partition behavior.
|
|
204
|
+
*/
|
|
205
|
+
partitions(n) {
|
|
206
|
+
this.#maxPartitions = Math.max(1, n)
|
|
207
|
+
return this
|
|
208
|
+
}
|
|
209
|
+
|
|
193
210
|
limit(count) {
|
|
194
211
|
this.#limit = count
|
|
195
212
|
return this
|
|
@@ -251,6 +268,7 @@ export class QueueBuilder {
|
|
|
251
268
|
subscriptionMode: this.#subscriptionMode,
|
|
252
269
|
subscriptionFrom: this.#subscriptionFrom,
|
|
253
270
|
each: this.#each,
|
|
271
|
+
maxPartitions: this.#maxPartitions,
|
|
254
272
|
signal: options.signal
|
|
255
273
|
}
|
|
256
274
|
|
|
@@ -266,6 +284,20 @@ export class QueueBuilder {
|
|
|
266
284
|
return this
|
|
267
285
|
}
|
|
268
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Set the long-poll timeout in milliseconds. Only meaningful when wait(true)
|
|
289
|
+
* is also set. Default is CONSUME_DEFAULTS.timeoutMillis (30000ms).
|
|
290
|
+
*
|
|
291
|
+
* Use a short timeout in tight consumer loops where you want the long-poll
|
|
292
|
+
* to release the request frequently (e.g. so a shutdown flag is observed
|
|
293
|
+
* promptly). The HTTP layer adds 5s of slack so the server-side timeout
|
|
294
|
+
* always fires first.
|
|
295
|
+
*/
|
|
296
|
+
timeoutMillis(millis) {
|
|
297
|
+
this.#timeoutMillis = Math.max(1, millis)
|
|
298
|
+
return this
|
|
299
|
+
}
|
|
300
|
+
|
|
269
301
|
async pop() {
|
|
270
302
|
logger.log('QueueBuilder.pop', { queue: this.#queueName, partition: this.#partition, namespace: this.#namespace, task: this.#task, batch: this.#batch, wait: this.#wait, group: this.#group })
|
|
271
303
|
|
|
@@ -289,6 +321,7 @@ export class QueueBuilder {
|
|
|
289
321
|
if (effectiveAutoAck) params.append('autoAck', 'true')
|
|
290
322
|
if (this.#subscriptionMode) params.append('subscriptionMode', this.#subscriptionMode)
|
|
291
323
|
if (this.#subscriptionFrom) params.append('subscriptionFrom', this.#subscriptionFrom)
|
|
324
|
+
if (this.#maxPartitions > 1) params.append('partitions', this.#maxPartitions.toString())
|
|
292
325
|
|
|
293
326
|
// Generate affinity key for consistent routing to same backend
|
|
294
327
|
const affinityKey = this.#getAffinityKey()
|
|
@@ -48,6 +48,7 @@ export class ConsumerManager {
|
|
|
48
48
|
subscriptionMode,
|
|
49
49
|
subscriptionFrom,
|
|
50
50
|
each,
|
|
51
|
+
maxPartitions,
|
|
51
52
|
signal
|
|
52
53
|
} = options
|
|
53
54
|
|
|
@@ -67,7 +68,7 @@ export class ConsumerManager {
|
|
|
67
68
|
|
|
68
69
|
// Build the path and params for pop requests
|
|
69
70
|
const path = this.#buildPath(queue, partition, namespace, task)
|
|
70
|
-
const baseParams = this.#buildParams(batch, wait, timeoutMillis, group, subscriptionMode, subscriptionFrom, namespace, task, autoAck)
|
|
71
|
+
const baseParams = this.#buildParams(batch, wait, timeoutMillis, group, subscriptionMode, subscriptionFrom, namespace, task, autoAck, maxPartitions)
|
|
71
72
|
|
|
72
73
|
// Generate affinity key for consistent routing to same backend
|
|
73
74
|
const affinityKey = this.#getAffinityKey(queue, partition, namespace, task, group)
|
|
@@ -389,7 +390,7 @@ export class ConsumerManager {
|
|
|
389
390
|
throw new Error('Must specify queue, namespace, or task')
|
|
390
391
|
}
|
|
391
392
|
|
|
392
|
-
#buildParams(batch, wait, timeoutMillis, group, subscriptionMode, subscriptionFrom, namespace, task, autoAck) {
|
|
393
|
+
#buildParams(batch, wait, timeoutMillis, group, subscriptionMode, subscriptionFrom, namespace, task, autoAck, maxPartitions) {
|
|
393
394
|
const params = new URLSearchParams({
|
|
394
395
|
batch: batch.toString(),
|
|
395
396
|
wait: wait.toString(),
|
|
@@ -401,6 +402,8 @@ export class ConsumerManager {
|
|
|
401
402
|
if (subscriptionFrom) params.append('subscriptionFrom', subscriptionFrom)
|
|
402
403
|
if (namespace) params.append('namespace', namespace)
|
|
403
404
|
if (task) params.append('task', task)
|
|
405
|
+
// v4 multi-partition pop: drain up to N sparse partitions per call.
|
|
406
|
+
if (maxPartitions && maxPartitions > 1) params.append('partitions', maxPartitions.toString())
|
|
404
407
|
// NEVER send autoAck for consume - client always manages acking
|
|
405
408
|
// autoAck is only for pop() where server auto-acks immediately
|
|
406
409
|
|
|
@@ -43,7 +43,8 @@ export const CONSUME_DEFAULTS = {
|
|
|
43
43
|
renewLease: false, // No auto-renewal
|
|
44
44
|
renewLeaseIntervalMillis: null, // Auto-renewal interval when enabled
|
|
45
45
|
subscriptionMode: null, // No subscription mode (standard queue mode)
|
|
46
|
-
subscriptionFrom: null
|
|
46
|
+
subscriptionFrom: null, // No subscription start point
|
|
47
|
+
maxPartitions: 1 // v4 multi-partition pop cap (1 = legacy single-partition)
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export const POP_DEFAULTS = {
|
package/package.json
CHANGED
package/test-v2/consume.js
CHANGED
|
@@ -612,4 +612,100 @@ export async function autoRenewLease(client) {
|
|
|
612
612
|
})
|
|
613
613
|
|
|
614
614
|
return { success: shouldFailHasFailed === false && shouldPassHasPass === true, message: 'Auto renew lease test completed successfully' }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
// ============================================================================
|
|
619
|
+
// v4 Multi-Partition Consume Tests
|
|
620
|
+
// ============================================================================
|
|
621
|
+
//
|
|
622
|
+
// Exercises the .partitions(N) builder method against the high-level
|
|
623
|
+
// consume() API (which calls pop_unified_batch_v4 under the hood).
|
|
624
|
+
|
|
625
|
+
export async function testConsumerMultiPartition(client) {
|
|
626
|
+
const queueName = 'test-queue-v2-v4-consume-multi'
|
|
627
|
+
const queue = await client.queue(queueName).create()
|
|
628
|
+
if (!queue.configured) {
|
|
629
|
+
return { success: false, message: 'Queue not created' }
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Seed 3 partitions × 4 messages each = 12 total.
|
|
633
|
+
for (let p = 0; p < 3; p++) {
|
|
634
|
+
const payloads = []
|
|
635
|
+
for (let m = 0; m < 4; m++) payloads.push({ data: { p, m } })
|
|
636
|
+
await client.queue(queueName).partition(`p${p}`).push(payloads)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Allow the post-push partition_lookup follow-up to settle.
|
|
640
|
+
await new Promise(r => setTimeout(r, 500))
|
|
641
|
+
|
|
642
|
+
let received = []
|
|
643
|
+
|
|
644
|
+
// .partitions(3).batch(100) → up to 100 msgs from up to 3 partitions
|
|
645
|
+
// in a single pop. .limit(1) ends the consume after one batch.
|
|
646
|
+
await client
|
|
647
|
+
.queue(queueName)
|
|
648
|
+
.batch(100)
|
|
649
|
+
.partitions(3)
|
|
650
|
+
.wait(false)
|
|
651
|
+
.limit(1)
|
|
652
|
+
.consume(async msgs => {
|
|
653
|
+
received = msgs
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
if (received.length !== 12) {
|
|
657
|
+
return { success: false, message: `Expected 12 messages across 3 partitions, got ${received.length}` }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const distinctPartitionIds = new Set(received.map(m => m.partitionId))
|
|
661
|
+
const distinctLeaseIds = new Set(received.map(m => m.leaseId).filter(Boolean))
|
|
662
|
+
|
|
663
|
+
if (distinctPartitionIds.size !== 3) {
|
|
664
|
+
return { success: false, message: `Expected 3 distinct partitionIds, got ${distinctPartitionIds.size}` }
|
|
665
|
+
}
|
|
666
|
+
// consume() uses client-side auto-ack which means leaseId may be
|
|
667
|
+
// empty if server-side autoAck is used. Accept either: shared lease
|
|
668
|
+
// id (1 distinct value) or all-empty (0).
|
|
669
|
+
if (distinctLeaseIds.size > 1) {
|
|
670
|
+
return { success: false, message: `All messages must share at most one leaseId, got ${distinctLeaseIds.size}` }
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return { success: true, message: 'Consumer with .partitions(3) drained 3 partitions in one batch' }
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export async function testConsumerMultiPartitionGlobalCap(client) {
|
|
677
|
+
const queueName = 'test-queue-v2-v4-consume-cap'
|
|
678
|
+
const queue = await client.queue(queueName).create()
|
|
679
|
+
if (!queue.configured) {
|
|
680
|
+
return { success: false, message: 'Queue not created' }
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Seed 5 partitions × 50 messages each = 250 total available.
|
|
684
|
+
for (let p = 0; p < 5; p++) {
|
|
685
|
+
const payloads = []
|
|
686
|
+
for (let m = 0; m < 50; m++) payloads.push({ data: { p, m } })
|
|
687
|
+
await client.queue(queueName).partition(`p${p}`).push(payloads)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
await new Promise(r => setTimeout(r, 500))
|
|
691
|
+
|
|
692
|
+
let received = []
|
|
693
|
+
|
|
694
|
+
// batch(20) is the GLOBAL cap. With partitions(5) we can claim up to
|
|
695
|
+
// 5 partitions, but the total returned is bounded at 20 messages.
|
|
696
|
+
await client
|
|
697
|
+
.queue(queueName)
|
|
698
|
+
.batch(20)
|
|
699
|
+
.partitions(5)
|
|
700
|
+
.wait(false)
|
|
701
|
+
.limit(1)
|
|
702
|
+
.consume(async msgs => {
|
|
703
|
+
received = msgs
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
if (received.length !== 20) {
|
|
707
|
+
return { success: false, message: `batch(20) must be a hard global cap, got ${received.length}` }
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return { success: true, message: 'Global batch cap respected across multi-partition consume' }
|
|
615
711
|
}
|
package/test-v2/maintenance.js
CHANGED
|
@@ -63,8 +63,22 @@ export async function test_maintenance_mode(client = null) {
|
|
|
63
63
|
const consumePromise = (async () => {
|
|
64
64
|
while (consumerActive) {
|
|
65
65
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
// wait(true) long-polls on the server. This is important: pushes
|
|
67
|
+
// commit before partition_lookup is refreshed (PUSHPOPLOOKUPSOL
|
|
68
|
+
// is async), so a wait(false) pop can race past freshly-pushed
|
|
69
|
+
// messages and return empty. Long-poll re-runs the candidate
|
|
70
|
+
// scan internally and picks up messages once the lookup row
|
|
71
|
+
// commits — typically within milliseconds.
|
|
72
|
+
// Use a short timeout (1s) so the consumer notices
|
|
73
|
+
// consumerActive=false promptly when the test winds down.
|
|
74
|
+
const messages = await queen.queue(QUEUE_NAME)
|
|
75
|
+
.batch(10)
|
|
76
|
+
.wait(true)
|
|
77
|
+
.timeoutMillis(1000)
|
|
78
|
+
.limit(1)
|
|
79
|
+
.each()
|
|
80
|
+
.pop();
|
|
81
|
+
|
|
68
82
|
if (messages && messages.length > 0) {
|
|
69
83
|
receivedCount += messages.length;
|
|
70
84
|
console.log(` 📨 Received ${messages.length} messages (total: ${receivedCount})`);
|
package/test-v2/pop.js
CHANGED
|
@@ -20,10 +20,15 @@ export async function popNonEmptyQueue(client) {
|
|
|
20
20
|
.queue('test-queue-v2-pop-non-empty')
|
|
21
21
|
.push([{ data: { message: 'Hello, world!' } }])
|
|
22
22
|
|
|
23
|
+
// wait(true) rides out the PUSHPOPLOOKUPSOL race: partition_lookup is
|
|
24
|
+
// updated asynchronously after push commits, so an immediate
|
|
25
|
+
// wait(false) wildcard pop can race past the not-yet-committed lookup
|
|
26
|
+
// row. Long-poll re-runs the candidate scan and picks up the row
|
|
27
|
+
// once it commits (typically within ms).
|
|
23
28
|
const res = await client
|
|
24
29
|
.queue('test-queue-v2-pop-non-empty')
|
|
25
30
|
.batch(1)
|
|
26
|
-
.wait(
|
|
31
|
+
.wait(true)
|
|
27
32
|
.pop()
|
|
28
33
|
return { success: res.length === 1 }
|
|
29
34
|
}
|
|
@@ -58,10 +63,11 @@ export async function popWithAck(client) {
|
|
|
58
63
|
.push([{ data: { message: 'Hello, world!' } }])
|
|
59
64
|
|
|
60
65
|
|
|
66
|
+
// wait(true): ride out the PUSHPOPLOOKUPSOL race (see popNonEmptyQueue).
|
|
61
67
|
const res = await client
|
|
62
68
|
.queue('test-queue-v2-pop-with-ack')
|
|
63
69
|
.batch(1)
|
|
64
|
-
.wait(
|
|
70
|
+
.wait(true)
|
|
65
71
|
.pop()
|
|
66
72
|
|
|
67
73
|
const resAck = await client.ack(res[0])
|
|
@@ -88,10 +94,11 @@ export async function popWithAckReconsume(client) {
|
|
|
88
94
|
.queue('test-queue-v2-pop-with-ack-reconsume')
|
|
89
95
|
.push([{ data: { message: 'Hello, world!' } }])
|
|
90
96
|
|
|
97
|
+
// wait(true): ride out the PUSHPOPLOOKUPSOL race (see popNonEmptyQueue).
|
|
91
98
|
const res = await client
|
|
92
99
|
.queue('test-queue-v2-pop-with-ack-reconsume')
|
|
93
100
|
.batch(1)
|
|
94
|
-
.wait(
|
|
101
|
+
.wait(true)
|
|
95
102
|
.pop()
|
|
96
103
|
|
|
97
104
|
if (res.length !== 1) {
|
|
@@ -112,3 +119,218 @@ export async function popWithAckReconsume(client) {
|
|
|
112
119
|
return { success: false, message: `Expected 1 message, got ${res2.length}` }
|
|
113
120
|
}
|
|
114
121
|
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// v4 Multi-Partition Pop Tests
|
|
124
|
+
// ============================================================================
|
|
125
|
+
//
|
|
126
|
+
// Exercises the .partitions(N) builder method end-to-end against the
|
|
127
|
+
// pop_unified_batch_v4 procedure:
|
|
128
|
+
// * Server drains up to N partitions per call
|
|
129
|
+
// * Each message carries its own partitionId / leaseId / partition
|
|
130
|
+
// * batch(B) is a global cap on TOTAL messages across all partitions
|
|
131
|
+
// * Default (no .partitions() call) preserves single-partition behaviour
|
|
132
|
+
|
|
133
|
+
export async function popV4MultiPartitionBasic(client) {
|
|
134
|
+
const queueName = 'test-queue-v2-v4-pop-basic'
|
|
135
|
+
const queue = await client.queue(queueName).create()
|
|
136
|
+
if (!queue.configured) {
|
|
137
|
+
return { success: false, message: 'Queue not created' }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Seed 3 partitions × 2 messages each = 6 total.
|
|
141
|
+
for (let p = 0; p < 3; p++) {
|
|
142
|
+
await client
|
|
143
|
+
.queue(queueName)
|
|
144
|
+
.partition(`p${p}`)
|
|
145
|
+
.push([
|
|
146
|
+
{ data: { p, m: 0 } },
|
|
147
|
+
{ data: { p, m: 1 } },
|
|
148
|
+
])
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Allow the post-push partition_lookup follow-up (PUSHPOPLOOKUPSOL)
|
|
152
|
+
// to settle before the wildcard scan.
|
|
153
|
+
await new Promise(r => setTimeout(r, 500))
|
|
154
|
+
|
|
155
|
+
const res = await client
|
|
156
|
+
.queue(queueName)
|
|
157
|
+
.batch(100)
|
|
158
|
+
.partitions(3)
|
|
159
|
+
.wait(false)
|
|
160
|
+
.pop()
|
|
161
|
+
|
|
162
|
+
if (res.length !== 6) {
|
|
163
|
+
return { success: false, message: `Expected 6 messages across 3 partitions, got ${res.length}` }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Each message must carry its own partitionId; partitionIds must
|
|
167
|
+
// span all 3 partitions; leaseIds must collapse to a single value.
|
|
168
|
+
const distinctPartitionIds = new Set(res.map(m => m.partitionId))
|
|
169
|
+
const distinctLeaseIds = new Set(res.map(m => m.leaseId).filter(Boolean))
|
|
170
|
+
|
|
171
|
+
if (distinctPartitionIds.size !== 3) {
|
|
172
|
+
return { success: false, message: `Expected 3 distinct partitionIds, got ${distinctPartitionIds.size}` }
|
|
173
|
+
}
|
|
174
|
+
if (distinctLeaseIds.size !== 1) {
|
|
175
|
+
return { success: false, message: `All messages must share a single leaseId, got ${distinctLeaseIds.size} distinct` }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { success: true, message: 'Multi-partition pop returned messages spanning 3 partitions with shared leaseId' }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function popV4GlobalCap(client) {
|
|
182
|
+
const queueName = 'test-queue-v2-v4-pop-cap'
|
|
183
|
+
const queue = await client.queue(queueName).create()
|
|
184
|
+
if (!queue.configured) {
|
|
185
|
+
return { success: false, message: 'Queue not created' }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Seed 5 partitions × 100 messages each = 500 total available.
|
|
189
|
+
for (let p = 0; p < 5; p++) {
|
|
190
|
+
const payloads = []
|
|
191
|
+
for (let m = 0; m < 100; m++) payloads.push({ data: { p, m } })
|
|
192
|
+
await client.queue(queueName).partition(`p${p}`).push(payloads)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await new Promise(r => setTimeout(r, 500))
|
|
196
|
+
|
|
197
|
+
// batch=10 is the GLOBAL cap, partitions=5 lets us span partitions.
|
|
198
|
+
// We must receive EXACTLY 10 messages, not 5*10 or 5*100.
|
|
199
|
+
const res = await client
|
|
200
|
+
.queue(queueName)
|
|
201
|
+
.batch(10)
|
|
202
|
+
.partitions(5)
|
|
203
|
+
.wait(false)
|
|
204
|
+
.pop()
|
|
205
|
+
|
|
206
|
+
if (res.length !== 10) {
|
|
207
|
+
return { success: false, message: `batch(10) must be a hard global cap, got ${res.length}` }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { success: true, message: 'Global batch cap respected across multi-partition pop' }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function popV4DefaultOne(client) {
|
|
214
|
+
const queueName = 'test-queue-v2-v4-pop-default'
|
|
215
|
+
const queue = await client.queue(queueName).create()
|
|
216
|
+
if (!queue.configured) {
|
|
217
|
+
return { success: false, message: 'Queue not created' }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Seed 4 partitions × 5 messages each.
|
|
221
|
+
for (let p = 0; p < 4; p++) {
|
|
222
|
+
const payloads = []
|
|
223
|
+
for (let m = 0; m < 5; m++) payloads.push({ data: { p, m } })
|
|
224
|
+
await client.queue(queueName).partition(`p${p}`).push(payloads)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await new Promise(r => setTimeout(r, 500))
|
|
228
|
+
|
|
229
|
+
// No .partitions() → max_partitions defaults to 1 → single partition.
|
|
230
|
+
const res = await client
|
|
231
|
+
.queue(queueName)
|
|
232
|
+
.batch(100)
|
|
233
|
+
.wait(false)
|
|
234
|
+
.pop()
|
|
235
|
+
|
|
236
|
+
if (res.length !== 5) {
|
|
237
|
+
return { success: false, message: `Default max_partitions=1 should drain only one partition (5 msgs), got ${res.length}` }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const distinctPartitionIds = new Set(res.map(m => m.partitionId))
|
|
241
|
+
if (distinctPartitionIds.size !== 1) {
|
|
242
|
+
return { success: false, message: `All messages should come from a single partition, got ${distinctPartitionIds.size}` }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { success: true, message: 'Default pop preserves legacy single-partition behaviour' }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function popV4MultiPartitionAck(client) {
|
|
249
|
+
const queueName = 'test-queue-v2-v4-pop-ack'
|
|
250
|
+
const queue = await client.queue(queueName).create()
|
|
251
|
+
if (!queue.configured) {
|
|
252
|
+
return { success: false, message: 'Queue not created' }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Seed 2 partitions × 3 messages each.
|
|
256
|
+
for (let p = 0; p < 2; p++) {
|
|
257
|
+
await client.queue(queueName).partition(`p${p}`).push([
|
|
258
|
+
{ data: { p, m: 0 } },
|
|
259
|
+
{ data: { p, m: 1 } },
|
|
260
|
+
{ data: { p, m: 2 } },
|
|
261
|
+
])
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await new Promise(r => setTimeout(r, 500))
|
|
265
|
+
|
|
266
|
+
const res = await client
|
|
267
|
+
.queue(queueName)
|
|
268
|
+
.batch(100)
|
|
269
|
+
.partitions(2)
|
|
270
|
+
.wait(false)
|
|
271
|
+
.pop()
|
|
272
|
+
|
|
273
|
+
if (res.length !== 6) {
|
|
274
|
+
return { success: false, message: `Expected 6 messages across 2 partitions, got ${res.length}` }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ACK each message individually using its own per-message partitionId
|
|
278
|
+
// and the shared leaseId. The v4 procedure has set each partition's
|
|
279
|
+
// batch_size to its actual contribution (3), so once 3 ACKs land for
|
|
280
|
+
// a given partition_id, that partition's lease auto-releases.
|
|
281
|
+
const ackResult = await client.ack(res, true)
|
|
282
|
+
if (!ackResult.success) {
|
|
283
|
+
return { success: false, message: `Batch ACK failed: ${ackResult.error || 'unknown'}` }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { success: true, message: 'Per-message ACK across multi-partition pop succeeded' }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function popV4SharedLeaseRenew(client) {
|
|
290
|
+
const queueName = 'test-queue-v2-v4-pop-renew'
|
|
291
|
+
const queue = await client
|
|
292
|
+
.queue(queueName)
|
|
293
|
+
.config({ leaseTime: 60 })
|
|
294
|
+
.create()
|
|
295
|
+
if (!queue.configured) {
|
|
296
|
+
return { success: false, message: 'Queue not created' }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Seed 3 partitions × 1 message each.
|
|
300
|
+
for (let p = 0; p < 3; p++) {
|
|
301
|
+
await client.queue(queueName).partition(`p${p}`).push([{ data: { p } }])
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await new Promise(r => setTimeout(r, 500))
|
|
305
|
+
|
|
306
|
+
const res = await client
|
|
307
|
+
.queue(queueName)
|
|
308
|
+
.batch(100)
|
|
309
|
+
.partitions(3)
|
|
310
|
+
.wait(false)
|
|
311
|
+
.pop()
|
|
312
|
+
|
|
313
|
+
if (res.length !== 3) {
|
|
314
|
+
return { success: false, message: `Expected 3 messages from 3 partitions, got ${res.length}` }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// All 3 messages share one leaseId. Passing res to renew() exercises
|
|
318
|
+
// both the dedup logic AND the server-side multi-row lease extension
|
|
319
|
+
// (renew_lease_v2 filters on worker_id alone, which matches all 3
|
|
320
|
+
// partition_consumers rows).
|
|
321
|
+
const renewResult = await client.renew(res)
|
|
322
|
+
// renew() returns array when input is array. After dedup, it's a
|
|
323
|
+
// single result for the shared leaseId.
|
|
324
|
+
if (!Array.isArray(renewResult) || renewResult.length !== 1) {
|
|
325
|
+
return { success: false, message: `Expected dedup to collapse to 1 renew call, got ${Array.isArray(renewResult) ? renewResult.length : 'non-array'}` }
|
|
326
|
+
}
|
|
327
|
+
if (!renewResult[0].success) {
|
|
328
|
+
return { success: false, message: `Renew failed: ${renewResult[0].error}` }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Cleanup: release leases by acking everything.
|
|
332
|
+
await client.ack(res, true)
|
|
333
|
+
|
|
334
|
+
return { success: true, message: 'Shared leaseId renewed once and dedup confirmed' }
|
|
335
|
+
}
|
|
336
|
+
|
package/test-v2/push.js
CHANGED
|
@@ -221,10 +221,15 @@ export async function pushLargePayload(client) {
|
|
|
221
221
|
.queue('test-queue-large-payload')
|
|
222
222
|
.push([{ data: largePayload }])
|
|
223
223
|
|
|
224
|
+
// wait(true) rides out the PUSHPOPLOOKUPSOL race: partition_lookup is
|
|
225
|
+
// updated asynchronously after push commits, so an immediate
|
|
226
|
+
// wait(false) wildcard pop can race past the not-yet-committed lookup
|
|
227
|
+
// row. Long-poll re-runs the candidate scan and picks up the row
|
|
228
|
+
// once it commits (typically within ms).
|
|
224
229
|
const received = await client
|
|
225
230
|
.queue('test-queue-large-payload')
|
|
226
231
|
.batch(1)
|
|
227
|
-
.wait(
|
|
232
|
+
.wait(true)
|
|
228
233
|
.pop()
|
|
229
234
|
|
|
230
235
|
if (!received || !received[0].data || !received[0].data.array || received[0].data.array.length !== 10000) {
|
|
@@ -243,10 +248,11 @@ export async function pushNullPayload(client) {
|
|
|
243
248
|
.queue('test-queue-null-payload')
|
|
244
249
|
.push([{ data: null }])
|
|
245
250
|
|
|
251
|
+
// wait(true): ride out the PUSHPOPLOOKUPSOL race (see pushLargePayload).
|
|
246
252
|
const received = await client
|
|
247
253
|
.queue('test-queue-null-payload')
|
|
248
254
|
.batch(1)
|
|
249
|
-
.wait(
|
|
255
|
+
.wait(true)
|
|
250
256
|
.pop()
|
|
251
257
|
|
|
252
258
|
if (!received || received[0].data !== null) {
|
|
@@ -265,10 +271,11 @@ export async function pushEmptyPayload(client) {
|
|
|
265
271
|
.queue('test-queue-empty-payload')
|
|
266
272
|
.push([{ data: {} }])
|
|
267
273
|
|
|
274
|
+
// wait(true): ride out the PUSHPOPLOOKUPSOL race (see pushLargePayload).
|
|
268
275
|
const received = await client
|
|
269
276
|
.queue('test-queue-empty-payload')
|
|
270
277
|
.batch(1)
|
|
271
|
-
.wait(
|
|
278
|
+
.wait(true)
|
|
272
279
|
.pop()
|
|
273
280
|
|
|
274
281
|
if (!received || Object.keys(received[0].data).length !== 0) {
|
|
@@ -300,7 +307,8 @@ export async function testSimplePushRoundTrip(client) {
|
|
|
300
307
|
if (!res[0] || res[0].status !== 'queued') {
|
|
301
308
|
return { success: false, message: `Expected queued, got ${JSON.stringify(res[0])}` }
|
|
302
309
|
}
|
|
303
|
-
|
|
310
|
+
// wait(true): ride out the PUSHPOPLOOKUPSOL race (see pushLargePayload).
|
|
311
|
+
const popped = await client.queue(queueName).batch(1).wait(true).pop()
|
|
304
312
|
if (!popped || popped.length !== 1 || popped[0].data.n !== 1) {
|
|
305
313
|
return { success: false, message: 'Round-trip pop failed' }
|
|
306
314
|
}
|
|
@@ -316,8 +324,9 @@ export async function testPushAutoCreatesQueueAndPartition(client) {
|
|
|
316
324
|
if (!res[0] || res[0].status !== 'queued') {
|
|
317
325
|
return { success: false, message: `Expected queued, got ${JSON.stringify(res[0])}` }
|
|
318
326
|
}
|
|
327
|
+
// wait(true): ride out the PUSHPOPLOOKUPSOL race (see pushLargePayload).
|
|
319
328
|
const popped = await client.queue(queueName).partition(partitionName)
|
|
320
|
-
.batch(1).wait(
|
|
329
|
+
.batch(1).wait(true).pop()
|
|
321
330
|
if (!popped || popped.length !== 1) {
|
|
322
331
|
return { success: false, message: 'Auto-created partition did not receive the message' }
|
|
323
332
|
}
|