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/client-v2/LOGGING.md +63 -30
- package/client-v2/Queen.js +8 -7
- package/client-v2/buffer/BufferManager.js +12 -23
- package/client-v2/builders/QueueBuilder.js +0 -2
- package/client-v2/consumer/ConsumerManager.js +2 -3
- package/client-v2/http/HttpClient.js +0 -1
- package/client-v2/utils/defaults.js +2 -1
- package/client-v2/utils/logger.js +46 -14
- package/package.json +1 -1
- package/test-v2/auth.js +340 -0
- package/test-v2/logger.js +312 -0
- package/test-v2/push.js +275 -0
- package/test-v2/run.js +7 -1
- package/test-v2/watermark.js +507 -0
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 = [
|