queen-mq 0.13.0 → 0.14.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 CHANGED
@@ -250,6 +250,47 @@ for (const message of messages) {
250
250
  }
251
251
  ```
252
252
 
253
+ ### Multi-Partition Pop (Drain Many Partitions Per Call)
254
+
255
+ ```javascript
256
+ // One round-trip drains up to 200 messages spread across up to 50 partitions.
257
+ // batch(200) is the GLOBAL cap on total messages; partitions(50) is the
258
+ // hard cap on partitions claimed. All claimed partitions share one leaseId
259
+ // — a single renew() call extends every partition's lease atomically.
260
+ const messages = await queen.queue('events')
261
+ .batch(200)
262
+ .partitions(50)
263
+ .wait(true)
264
+ .pop()
265
+
266
+ // Each message carries its own partition info (per-message partitionId,
267
+ // partition name, leaseId, consumerGroup) — ACK and renew always work
268
+ // message-by-message regardless of how many partitions the batch spans.
269
+ for (const m of messages) {
270
+ console.log(`from ${m.partition}:`, m.data)
271
+ }
272
+
273
+ // Same builder works on .consume() for long-running workers
274
+ await queen.queue('events')
275
+ .batch(100)
276
+ .partitions(8)
277
+ .consume(async (msgs) => {
278
+ for (const m of msgs) await process(m.data)
279
+ })
280
+ ```
281
+
282
+ **When to use:** queues with many partitions where each partition only has
283
+ a handful of new messages per polling interval (per-customer event streams,
284
+ per-tenant work queues, per-device telemetry). Reduces network round-trips
285
+ from O(P) to O(P / N) while preserving per-partition FIFO ordering.
286
+
287
+ **When not to use:** few partitions, or each one busy enough to fill
288
+ `batch(B)` on its own. Default is `partitions(1)` which preserves the
289
+ legacy single-partition behaviour.
290
+
291
+ `.partitions(N)` only applies to **wildcard** pops; specifying
292
+ `.partition('name')` ignores the cap.
293
+
253
294
  ### Transactions (Atomic Operations)
254
295
 
255
296
  ```javascript
@@ -451,6 +492,7 @@ await queen.queue('q').buffer({ messageCount: 100, timeMillis: 1000 }).push([...
451
492
  const msgs = await queen.queue('q').pop()
452
493
  const msgs = await queen.queue('q').batch(10).pop()
453
494
  const msgs = await queen.queue('q').batch(10).wait(true).pop()
495
+ const msgs = await queen.queue('q').batch(200).partitions(50).pop() // multi-partition pop
454
496
  ```
455
497
 
456
498
  ### Consume
@@ -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' }
@@ -1605,6 +1605,11 @@ const msgs = await queen.queue('q').batch(10).wait(true).pop()
1605
1605
 
1606
1606
  // Pop from partition
1607
1607
  const msgs = await queen.queue('q').partition('p1').pop()
1608
+
1609
+ // Multi-partition pop: drain up to 50 partitions in one round-trip,
1610
+ // capped at 200 total messages. All partitions share one leaseId.
1611
+ // Each returned message carries its own partitionId / partition / leaseId.
1612
+ const msgs = await queen.queue('q').batch(200).partitions(50).pop()
1608
1613
  ```
1609
1614
 
1610
1615
  ### Consume
@@ -1625,6 +1630,12 @@ await queen.queue('q').concurrency(5).consume(async (msg) => { /* 5 parallel wor
1625
1630
  // Consume from partition
1626
1631
  await queen.queue('q').partition('p1').consume(async (msg) => { /* process */ })
1627
1632
 
1633
+ // Multi-partition consume: drain up to 8 partitions per poll.
1634
+ // batch(100) is a global cap on total messages across all claimed partitions.
1635
+ await queen.queue('q').batch(100).partitions(8).consume(async (msgs) => {
1636
+ for (const m of msgs) await process(m.data) // m.partitionId / m.partition baked in
1637
+ })
1638
+
1628
1639
  // Consume with consumer group
1629
1640
  await queen.queue('q').group('my-group').consume(async (msg) => { /* process */ })
1630
1641
 
@@ -1863,7 +1874,7 @@ Example log output:
1863
1874
  Here's a complete example showing many features together:
1864
1875
 
1865
1876
  ```javascript
1866
- import { Queen } from './client-js/client-v2/index.js'
1877
+ import { Queen } from './index.js'
1867
1878
 
1868
1879
  const queen = new Queen('http://localhost:6632')
1869
1880
 
@@ -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 // No subscription start point
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "queen-mq",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "High-performance C++ message queue backed by PostgreSQL",
6
6
  "main": "client-v2/index.js",
@@ -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
  }
@@ -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
- const messages = await queen.queue(QUEUE_NAME).batch(10).wait(false).limit(1).each().pop();
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(false)
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(false)
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(false)
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(false)
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(false)
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(false)
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
- const popped = await client.queue(queueName).batch(1).wait(false).pop()
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(false).pop()
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
  }