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