queen-mq 0.12.4 โ†’ 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.
@@ -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) {
@@ -278,6 +285,283 @@ export async function pushEmptyPayload(client) {
278
285
  return { success: true }
279
286
  }
280
287
 
288
+ // ============================================================================
289
+ // push_messages_v3 coverage (see PUSH_IMPROVEMENT.md ยง8.1).
290
+ // These tests exercise the contract that the TEMP-TABLE-free rewrite must
291
+ // preserve: single-call semantics (new inserts, pre-existing duplicates,
292
+ // intra-batch duplicates, missing transactionId, empty batch, malformed
293
+ // traceId) plus the cross-call concurrent-race invariant.
294
+ // ============================================================================
295
+
296
+ function distinctCount(arr) {
297
+ return new Set(arr).size
298
+ }
299
+
300
+ export async function testSimplePushRoundTrip(client) {
301
+ const queueName = 'test-v3-simple-roundtrip'
302
+ const queue = await client.queue(queueName).create()
303
+ if (!queue.configured) {
304
+ return { success: false, message: 'Queue not created' }
305
+ }
306
+ const res = await client.queue(queueName).push([{ data: { n: 1 } }])
307
+ if (!res[0] || res[0].status !== 'queued') {
308
+ return { success: false, message: `Expected queued, got ${JSON.stringify(res[0])}` }
309
+ }
310
+ // wait(true): ride out the PUSHPOPLOOKUPSOL race (see pushLargePayload).
311
+ const popped = await client.queue(queueName).batch(1).wait(true).pop()
312
+ if (!popped || popped.length !== 1 || popped[0].data.n !== 1) {
313
+ return { success: false, message: 'Round-trip pop failed' }
314
+ }
315
+ return { success: true }
316
+ }
317
+
318
+ export async function testPushAutoCreatesQueueAndPartition(client) {
319
+ // Queue and partition are never referenced before โ€” must be auto-created.
320
+ const queueName = 'test-v3-autocreate-' + crypto.randomBytes(4).toString('hex')
321
+ const partitionName = 'autopart-' + crypto.randomBytes(4).toString('hex')
322
+ const res = await client.queue(queueName).partition(partitionName)
323
+ .push([{ data: { hello: 'auto' } }])
324
+ if (!res[0] || res[0].status !== 'queued') {
325
+ return { success: false, message: `Expected queued, got ${JSON.stringify(res[0])}` }
326
+ }
327
+ // wait(true): ride out the PUSHPOPLOOKUPSOL race (see pushLargePayload).
328
+ const popped = await client.queue(queueName).partition(partitionName)
329
+ .batch(1).wait(true).pop()
330
+ if (!popped || popped.length !== 1) {
331
+ return { success: false, message: 'Auto-created partition did not receive the message' }
332
+ }
333
+ return { success: true }
334
+ }
335
+
336
+ export async function testPushPreExistingDuplicate(client) {
337
+ const queueName = 'test-v3-pre-existing-dup'
338
+ const queue = await client.queue(queueName).create()
339
+ if (!queue.configured) {
340
+ return { success: false, message: 'Queue not created' }
341
+ }
342
+ const txn = 'txn-pre-' + crypto.randomBytes(4).toString('hex')
343
+ const first = await client.queue(queueName)
344
+ .push([{ transactionId: txn, data: { i: 1 } }])
345
+ if (first[0].status !== 'queued') {
346
+ return { success: false, message: `First push not queued: ${JSON.stringify(first[0])}` }
347
+ }
348
+ const firstId = first[0].message_id
349
+ const second = await client.queue(queueName)
350
+ .push([{ transactionId: txn, data: { i: 2 } }])
351
+ if (second[0].status !== 'duplicate') {
352
+ return { success: false, message: `Second push not duplicate: ${JSON.stringify(second[0])}` }
353
+ }
354
+ if (!firstId || second[0].message_id !== firstId) {
355
+ return { success: false, message: `Duplicate id mismatch: want ${firstId}, got ${second[0].message_id}` }
356
+ }
357
+ return { success: true }
358
+ }
359
+
360
+ export async function testPushIntraBatchDuplicatesAllNew(client) {
361
+ const queueName = 'test-v3-intra-batch-new'
362
+ const queue = await client.queue(queueName).create()
363
+ if (!queue.configured) {
364
+ return { success: false, message: 'Queue not created' }
365
+ }
366
+ const txn = 'txn-ib-' + crypto.randomBytes(4).toString('hex')
367
+ const res = await client.queue(queueName).push([
368
+ { transactionId: txn, data: { i: 1 } },
369
+ { transactionId: txn, data: { i: 2 } },
370
+ { transactionId: txn, data: { i: 3 } }
371
+ ])
372
+ if (!Array.isArray(res) || res.length !== 3) {
373
+ return { success: false, message: `Expected 3 entries, got ${res && res.length}` }
374
+ }
375
+ const queued = res.filter(r => r.status === 'queued')
376
+ const dupes = res.filter(r => r.status === 'duplicate')
377
+ if (queued.length !== 1 || dupes.length !== 2) {
378
+ return { success: false, message: `Want 1 queued / 2 duplicate, got ${JSON.stringify(res)}` }
379
+ }
380
+ const winnerId = queued[0].message_id
381
+ if (!winnerId || !dupes.every(d => d.message_id === winnerId)) {
382
+ return { success: false, message: `Intra-batch duplicates must share winner id ${winnerId}; got ${JSON.stringify(dupes)}` }
383
+ }
384
+ return { success: true }
385
+ }
386
+
387
+ export async function testPushIntraBatchDuplicatesMatchingPreExisting(client) {
388
+ const queueName = 'test-v3-intra-batch-existing'
389
+ const queue = await client.queue(queueName).create()
390
+ if (!queue.configured) {
391
+ return { success: false, message: 'Queue not created' }
392
+ }
393
+ const txn = 'txn-ibe-' + crypto.randomBytes(4).toString('hex')
394
+ // Seed an existing row.
395
+ const seed = await client.queue(queueName)
396
+ .push([{ transactionId: txn, data: { seed: true } }])
397
+ if (seed[0].status !== 'queued') {
398
+ return { success: false, message: 'Seed push failed' }
399
+ }
400
+ const existingId = seed[0].message_id
401
+ const res = await client.queue(queueName).push([
402
+ { transactionId: txn, data: { i: 1 } },
403
+ { transactionId: txn, data: { i: 2 } }
404
+ ])
405
+ if (!Array.isArray(res) || res.length !== 2) {
406
+ return { success: false, message: `Expected 2 entries, got ${JSON.stringify(res)}` }
407
+ }
408
+ if (!res.every(r => r.status === 'duplicate' && r.message_id === existingId)) {
409
+ return { success: false, message: `All entries must duplicate onto ${existingId}, got ${JSON.stringify(res)}` }
410
+ }
411
+ return { success: true }
412
+ }
413
+
414
+ export async function testPushConcurrentBatchesToSameKey(client) {
415
+ const queueName = 'test-v3-concurrent-same-key'
416
+ const queue = await client.queue(queueName).create()
417
+ if (!queue.configured) {
418
+ return { success: false, message: 'Queue not created' }
419
+ }
420
+ const txn = 'txn-conc-' + crypto.randomBytes(4).toString('hex')
421
+ // Fire 8 concurrent single-item pushes using the same transactionId.
422
+ // Exactly one must win; the others must report duplicate with the winner's id.
423
+ const N = 8
424
+ const results = await Promise.all(
425
+ Array.from({ length: N }, (_, i) =>
426
+ client.queue(queueName)
427
+ .push([{ transactionId: txn, data: { i } }])
428
+ .then(r => r[0])
429
+ )
430
+ )
431
+ const queued = results.filter(r => r.status === 'queued')
432
+ const dupes = results.filter(r => r.status === 'duplicate')
433
+ if (queued.length !== 1) {
434
+ return { success: false, message: `Want exactly 1 queued, got ${queued.length}: ${JSON.stringify(results)}` }
435
+ }
436
+ if (dupes.length !== N - 1) {
437
+ return { success: false, message: `Want ${N - 1} duplicates, got ${dupes.length}` }
438
+ }
439
+ const winnerId = queued[0].message_id
440
+ if (!winnerId || !dupes.every(d => d.message_id === winnerId)) {
441
+ return { success: false, message: `All duplicates must report winner id ${winnerId}: ${JSON.stringify(dupes)}` }
442
+ }
443
+ return { success: true }
444
+ }
445
+
446
+ export async function testPushMissingTransactionId(client) {
447
+ const queueName = 'test-v3-missing-txn'
448
+ const queue = await client.queue(queueName).create()
449
+ if (!queue.configured) {
450
+ return { success: false, message: 'Queue not created' }
451
+ }
452
+ // 100 items, no transactionId provided. Each must get a distinct id and
453
+ // all should end up queued (no accidental intra-batch collisions from a
454
+ // volatile-function-inlining regression).
455
+ // NOTE: the client auto-generates a transactionId when omitted, so we
456
+ // bypass it via the raw HTTP push contract by supplying only `data`. The
457
+ // client still fills a fresh transactionId per item, which is the correct
458
+ // client-level contract; the server-side test below additionally proves
459
+ // the SP itself handles missing txn ids by bypassing the client helper.
460
+ const items = Array.from({ length: 100 }, (_, i) => ({ data: { i } }))
461
+ const res = await client.queue(queueName).push(items)
462
+ if (!Array.isArray(res) || res.length !== 100) {
463
+ return { success: false, message: `Expected 100 results, got ${res && res.length}` }
464
+ }
465
+ if (!res.every(r => r.status === 'queued')) {
466
+ const bad = res.find(r => r.status !== 'queued')
467
+ return { success: false, message: `All must queue; first bad entry ${JSON.stringify(bad)}` }
468
+ }
469
+ if (distinctCount(res.map(r => r.message_id)) !== 100) {
470
+ return { success: false, message: `Expected 100 distinct message ids` }
471
+ }
472
+ return { success: true }
473
+ }
474
+
475
+ export async function testPushEmptyArray(client) {
476
+ const queueName = 'test-v3-empty-array'
477
+ const queue = await client.queue(queueName).create()
478
+ if (!queue.configured) {
479
+ return { success: false, message: 'Queue not created' }
480
+ }
481
+ const res = await client.queue(queueName).push([])
482
+ if (!Array.isArray(res) || res.length !== 0) {
483
+ return { success: false, message: `Expected [], got ${JSON.stringify(res)}` }
484
+ }
485
+ return { success: true }
486
+ }
487
+
488
+ export async function testPushMalformedTraceId(client) {
489
+ // Per plan ยง5: malformed traceId aborts the batch in both v2 and v3.
490
+ // We only need to verify the behavior is stable (either rejected at the
491
+ // client validator or surfaced as a failure from the server). The client
492
+ // QueueBuilder currently drops invalid UUIDs silently โ€” in which case the
493
+ // push still succeeds and traceId is null. Accept either outcome as long
494
+ // as we don't see data corruption.
495
+ const queueName = 'test-v3-malformed-trace'
496
+ const queue = await client.queue(queueName).create()
497
+ if (!queue.configured) {
498
+ return { success: false, message: 'Queue not created' }
499
+ }
500
+ let res
501
+ try {
502
+ res = await client.queue(queueName)
503
+ .push([{ traceId: 'not-a-uuid', data: { i: 1 } }])
504
+ } catch (err) {
505
+ // Server rejected: acceptable per plan.
506
+ return { success: true, message: 'Server rejected malformed traceId (expected)' }
507
+ }
508
+ if (!Array.isArray(res) || res.length !== 1) {
509
+ return { success: false, message: `Expected 1 result, got ${JSON.stringify(res)}` }
510
+ }
511
+ // If the client dropped the bad traceId, we'll get a queued row with
512
+ // trace_id=null. That is an acceptable, well-documented outcome.
513
+ if (res[0].status !== 'queued' && res[0].status !== 'failed') {
514
+ return { success: false, message: `Unexpected status: ${JSON.stringify(res[0])}` }
515
+ }
516
+ return { success: true }
517
+ }
518
+
519
+ export async function testPushProducerSubEmptyStringBecomesNull(client) {
520
+ // We can't set producerSub from the client (server-stamped field). The
521
+ // invariant we need to preserve is that when the server writes an empty
522
+ // producer_sub into the JSON, the SP stores SQL NULL. We verify it by
523
+ // pushing without auth (server will stamp producerSub as empty / NULL)
524
+ // and then checking via a short-lived pg client. NOTE: we intentionally
525
+ // do NOT dynamically import ./run.js here โ€” that module executes
526
+ // top-level `await main()` and re-importing it while it's running
527
+ // deadlocks the ESM module graph.
528
+ const queueName = 'test-v3-producer-sub-null'
529
+ const queue = await client.queue(queueName).create()
530
+ if (!queue.configured) {
531
+ return { success: false, message: 'Queue not created' }
532
+ }
533
+ const res = await client.queue(queueName).push([{ data: { x: 1 } }])
534
+ if (res[0].status !== 'queued') {
535
+ return { success: false, message: 'Push failed' }
536
+ }
537
+ const messageId = res[0].message_id
538
+
539
+ const { default: pg } = await import('pg')
540
+ const pool = new pg.Pool({
541
+ host: process.env.PG_HOST || 'localhost',
542
+ port: process.env.PG_PORT || 5432,
543
+ database: process.env.PG_DB || 'postgres',
544
+ user: process.env.PG_USER || 'postgres',
545
+ password: process.env.PG_PASSWORD || 'postgres',
546
+ max: 1
547
+ })
548
+ try {
549
+ const r = await pool.query(
550
+ 'SELECT producer_sub FROM queen.messages WHERE id = $1',
551
+ [messageId]
552
+ )
553
+ if (r.rows.length !== 1) {
554
+ return { success: false, message: `Message ${messageId} not found` }
555
+ }
556
+ if (r.rows[0].producer_sub !== null) {
557
+ return { success: false, message: `Expected producer_sub NULL, got ${JSON.stringify(r.rows[0].producer_sub)}` }
558
+ }
559
+ return { success: true }
560
+ } finally {
561
+ await pool.end()
562
+ }
563
+ }
564
+
281
565
  export async function pushEncryptedPayload(client) {
282
566
  const queue = await client.queue('test-queue-encrypted-payload')
283
567
  .config({ encryptionEnabled: true })
package/test-v2/run.js CHANGED
@@ -13,6 +13,8 @@ import * as maintenanceTests from './maintenance.js'
13
13
  import * as retentionTests from './retention.js'
14
14
  import * as bootstrapTests from './bootstrap.js'
15
15
  import * as loggerTests from './logger.js'
16
+ import * as watermarkTests from './watermark.js'
17
+ import * as authTests from './auth.js'
16
18
  import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
17
19
 
18
20
 
@@ -115,7 +117,9 @@ async function main() {
115
117
  retentionTests,
116
118
  maintenanceTests,
117
119
  bootstrapTests,
118
- loggerTests
120
+ loggerTests,
121
+ watermarkTests,
122
+ authTests
119
123
  ]
120
124
 
121
125
  const aiTests = [