queen-mq 0.1.5 → 0.2.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 +467 -1775
- package/package.json +12 -14
- package/src/client/client.js +664 -5
- package/src/managers/queueManagerOptimized.js +250 -47
- package/src/routes/ack.js +3 -3
- package/src/server.js +131 -3
- package/src/test/advanced-client-tests.js +761 -0
- package/src/test/advanced-pattern-tests.js +2 -2
- package/src/test/bus-mode-tests.js +6 -6
- package/src/test/core-tests.js +1 -1
- package/src/test/edge-case-tests.js +2 -2
- package/src/test/test-new.js +36 -1
- package/src/webapp-dist/assets/Analytics-DJrE4Rz2.css +1 -0
- package/src/webapp-dist/assets/Analytics-HEMXKmfJ.js +1 -0
- package/src/webapp-dist/assets/ConfirmDialog-YEoCdE7x.js +1 -0
- package/src/webapp-dist/assets/ConsumerGroups-CC5VJywp.css +1 -0
- package/src/webapp-dist/assets/ConsumerGroups-D8Z20HhZ.js +1 -0
- package/src/webapp-dist/assets/Dashboard-BRFOz6it.css +1 -0
- package/src/webapp-dist/assets/Dashboard-Bpx9I32h.js +1 -0
- package/src/webapp-dist/assets/LoadingSpinner-BPttXcbs.js +1 -0
- package/src/webapp-dist/assets/Messages-BOfVHKma.css +1 -0
- package/src/webapp-dist/assets/Messages-EKURCbLl.js +1 -0
- package/src/webapp-dist/assets/QueueDetail-BXXA7xoZ.css +1 -0
- package/src/webapp-dist/assets/QueueDetail-Bmxg0KiA.js +1 -0
- package/src/webapp-dist/assets/Queues-CvrJkrZT.css +1 -0
- package/src/webapp-dist/assets/Queues-yUx9_3-9.js +1 -0
- package/src/webapp-dist/assets/StatusBadge-C1bBtXHh.css +1 -0
- package/src/webapp-dist/assets/StatusBadge-C6N84XmO.js +1 -0
- package/src/webapp-dist/assets/analytics-BapTekLM.js +1 -0
- package/src/webapp-dist/assets/colors-5z6Q_kUr.js +18 -0
- package/src/webapp-dist/assets/index-B7w2bKT6.css +1 -0
- package/src/webapp-dist/assets/index-CUSnwYTH.js +31 -0
- package/src/webapp-dist/assets/messages-CRV6E-xU.js +1 -0
- package/src/webapp-dist/assets/queues-BdUbxfBC.js +1 -0
- package/src/webapp-dist/assets/resources-Bs9U-ceF.js +1 -0
- package/{webapp → src/webapp-dist}/index.html +2 -1
- package/API.md +0 -1226
- package/AUTH.md +0 -2044
- package/CHANGELOG_STATIC_SERVING.md +0 -213
- package/DEPLOYMENT.md +0 -425
- package/PROGRAMMATIC_SERVER.md +0 -239
- package/WEBAPP.md +0 -1889
- package/assets/dashboard-01.png +0 -0
- package/assets/dashboard.png +0 -0
- package/examples/batch-processing.js +0 -58
- package/examples/bus-mode.js +0 -239
- package/examples/continuous-consumer-optimized.js +0 -215
- package/examples/continuous-consumer.js +0 -159
- package/examples/continuous-producer.js +0 -343
- package/examples/mixed-mode.js +0 -277
- package/examples/multi-server-test.js +0 -305
- package/examples/programmatic-server.js +0 -58
- package/examples/server-with-frontend.js +0 -46
- package/examples/single.js +0 -64
- package/examples/smartchat-dealyed.js +0 -42
- package/examples/smartchat.js +0 -52
- package/examples/test-cache-invalidation.js +0 -119
- package/examples/test-cache-multi-server.js +0 -245
- package/examples/test-complete-client.js +0 -260
- package/examples/test-connection-recovery.js +0 -66
- package/examples/test-dashboard-api.js +0 -200
- package/examples/test-minimal-client.js +0 -112
- package/examples/test-queue-creation-policy.js +0 -137
- package/examples/test-traceid.js +0 -147
- package/server.log +0 -1
- package/test-keepalive-v2.sh +0 -22
- package/webapp/COLOR_GUIDE.md +0 -118
- package/webapp/README.md +0 -143
- package/webapp/package-lock.json +0 -3184
- package/webapp/package.json +0 -25
- package/webapp/postcss.config.js +0 -7
- package/webapp/public/assets/queen-logo-blue.svg +0 -210
- package/webapp/public/assets/queen-logo-cyan.svg +0 -210
- package/webapp/public/assets/queen-logo-indigo.svg +0 -210
- package/webapp/public/assets/queen-logo-orange.svg +0 -210
- package/webapp/public/assets/queen-logo-pink.svg +0 -210
- package/webapp/public/assets/queen-logo-purple.svg +0 -210
- package/webapp/public/assets/queen-logo-rose.svg +0 -239
- package/webapp/public/assets/queen-logo.svg +0 -263
- package/webapp/src/App.vue +0 -19
- package/webapp/src/api/analytics.js +0 -10
- package/webapp/src/api/client.js +0 -34
- package/webapp/src/api/consumers.js +0 -52
- package/webapp/src/api/health.js +0 -7
- package/webapp/src/api/messages.js +0 -26
- package/webapp/src/api/queues.js +0 -14
- package/webapp/src/api/resources.js +0 -8
- package/webapp/src/assets/styles/main.css +0 -357
- package/webapp/src/components/analytics/AnalyticsFilters.vue +0 -87
- package/webapp/src/components/analytics/AnalyticsMetrics.vue +0 -57
- package/webapp/src/components/analytics/MessageDistributionChart.vue +0 -111
- package/webapp/src/components/analytics/MessageFlowChart.vue +0 -173
- package/webapp/src/components/analytics/TimeRangeSelector.vue +0 -27
- package/webapp/src/components/analytics/TopQueuesChart.vue +0 -132
- package/webapp/src/components/common/ConfirmDialog.vue +0 -56
- package/webapp/src/components/common/LoadingSpinner.vue +0 -6
- package/webapp/src/components/common/MetricCard.vue +0 -43
- package/webapp/src/components/common/StatusBadge.vue +0 -45
- package/webapp/src/components/dashboard/MessageStatusCard.vue +0 -50
- package/webapp/src/components/dashboard/PerformanceCard.vue +0 -38
- package/webapp/src/components/dashboard/ThroughputChart.vue +0 -182
- package/webapp/src/components/dashboard/TopQueuesTable.vue +0 -53
- package/webapp/src/components/layout/AppLayout.vue +0 -110
- package/webapp/src/components/layout/AppSidebar.vue +0 -304
- package/webapp/src/components/messages/MessageDetailPanel.vue +0 -242
- package/webapp/src/components/messages/MessageFilters.vue +0 -101
- package/webapp/src/components/queue-detail/PartitionList.vue +0 -79
- package/webapp/src/components/queue-detail/PushMessageModal.vue +0 -175
- package/webapp/src/components/queue-detail/QueueConfig.vue +0 -63
- package/webapp/src/components/queue-detail/QueueDetailHeader.vue +0 -53
- package/webapp/src/components/queue-detail/RecentMessages.vue +0 -76
- package/webapp/src/components/queues/CreateQueueModal.vue +0 -193
- package/webapp/src/components/queues/QueueFilters.vue +0 -90
- package/webapp/src/composables/useApi.js +0 -34
- package/webapp/src/composables/useTheme.js +0 -36
- package/webapp/src/main.js +0 -11
- package/webapp/src/router/index.js +0 -42
- package/webapp/src/utils/colors.js +0 -96
- package/webapp/src/utils/formatters.js +0 -49
- package/webapp/src/views/Analytics.vue +0 -377
- package/webapp/src/views/ConsumerGroups.vue +0 -433
- package/webapp/src/views/Dashboard.vue +0 -418
- package/webapp/src/views/Messages.vue +0 -363
- package/webapp/src/views/QueueDetail.vue +0 -582
- package/webapp/src/views/Queues.vue +0 -496
- package/webapp/tailwind.config.js +0 -25
- package/webapp/vite.config.js +0 -10
- /package/{assets → src/webapp-dist/assets}/queen-logo-blue.svg +0 -0
- /package/{assets → src/webapp-dist/assets}/queen-logo-cyan.svg +0 -0
- /package/{assets → src/webapp-dist/assets}/queen-logo-indigo.svg +0 -0
- /package/{assets → src/webapp-dist/assets}/queen-logo-orange.svg +0 -0
- /package/{assets → src/webapp-dist/assets}/queen-logo-pink.svg +0 -0
- /package/{assets → src/webapp-dist/assets}/queen-logo-purple.svg +0 -0
- /package/{assets → src/webapp-dist/assets}/queen-logo-rose.svg +0 -0
- /package/{assets → src/webapp-dist/assets}/queen-logo.svg +0 -0
|
@@ -201,8 +201,8 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
201
201
|
return result;
|
|
202
202
|
};
|
|
203
203
|
|
|
204
|
-
//
|
|
205
|
-
const
|
|
204
|
+
// Internal push operation that accepts a client
|
|
205
|
+
const pushMessagesInternal = async (client, items) => {
|
|
206
206
|
const pushStartTime = Date.now();
|
|
207
207
|
|
|
208
208
|
// Group items by queue and partition for efficient resource lookup
|
|
@@ -229,7 +229,6 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
229
229
|
// Check capacity for all queues with max_queue_size set (single query)
|
|
230
230
|
const queuesToCheck = Object.keys(queueBatchSizes);
|
|
231
231
|
if (queuesToCheck.length > 0) {
|
|
232
|
-
await withTransaction(pool, async (client) => {
|
|
233
232
|
const capacityCheck = await client.query(
|
|
234
233
|
`SELECT
|
|
235
234
|
q.name,
|
|
@@ -254,7 +253,6 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
254
253
|
throw new Error(`Queue '${row.name}' would exceed max capacity (${maxSize}). Current: ${currentDepth}, Batch: ${batchSize}`);
|
|
255
254
|
}
|
|
256
255
|
}
|
|
257
|
-
});
|
|
258
256
|
}
|
|
259
257
|
|
|
260
258
|
// Process each partition group
|
|
@@ -267,7 +265,6 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
267
265
|
|
|
268
266
|
while (retryCount <= maxRetries) {
|
|
269
267
|
try {
|
|
270
|
-
const results = await withTransaction(pool, async (client) => {
|
|
271
268
|
// Ensure resources exist (cached after first call)
|
|
272
269
|
const resources = await ensureResources(client, queueName, partitionName);
|
|
273
270
|
const { partitionId, encryptionEnabled, queueConfig } = resources;
|
|
@@ -433,10 +430,7 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
433
430
|
const queuePath = `${queueName}/${partitionName}`;
|
|
434
431
|
eventManager.notifyMessageAvailable(queuePath);
|
|
435
432
|
|
|
436
|
-
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
allResults.push(...results);
|
|
433
|
+
allResults.push(...batchResults);
|
|
440
434
|
break; // Success, exit retry loop
|
|
441
435
|
} catch (error) {
|
|
442
436
|
// Check if it's a stale cache error
|
|
@@ -454,17 +448,42 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
454
448
|
log(`[PUSH] Pushed ${items.length} items → ${allResults.length} results in ${duration}ms (${(items.length / (duration / 1000)).toFixed(0)} msg/s)`);
|
|
455
449
|
return allResults;
|
|
456
450
|
};
|
|
451
|
+
|
|
452
|
+
// Public wrapper for push that creates its own transaction
|
|
453
|
+
const pushMessagesBatch = async (items) => {
|
|
454
|
+
return withTransaction(pool, async (client) => {
|
|
455
|
+
return pushMessagesInternal(client, items);
|
|
456
|
+
});
|
|
457
|
+
};
|
|
457
458
|
|
|
458
459
|
|
|
459
460
|
// Acknowledge messages (now per consumer group)
|
|
460
461
|
// CURSOR-BASED: Single ACK now routes through batch ACK to advance cursor properly
|
|
461
|
-
const acknowledgeMessage = async (transactionId, status = 'completed', error = null, consumerGroup = null) => {
|
|
462
|
+
const acknowledgeMessage = async (transactionId, status = 'completed', error = null, consumerGroup = null, leaseId = null) => {
|
|
463
|
+
// If lease ID provided, validate it first
|
|
464
|
+
if (leaseId) {
|
|
465
|
+
log(`Validating lease ${leaseId} for transaction ${transactionId}`);
|
|
466
|
+
const valid = await pool.query(`
|
|
467
|
+
SELECT 1 FROM queen.partition_consumers pc
|
|
468
|
+
JOIN queen.messages m ON m.partition_id = pc.partition_id
|
|
469
|
+
WHERE m.transaction_id = $1
|
|
470
|
+
AND pc.worker_id = $2
|
|
471
|
+
AND pc.lease_expires_at > NOW()
|
|
472
|
+
`, [transactionId, leaseId]);
|
|
473
|
+
|
|
474
|
+
if (valid.rows.length === 0) {
|
|
475
|
+
log(`Invalid lease ${leaseId} for transaction ${transactionId} - validation failed`);
|
|
476
|
+
throw new Error('Invalid lease - cannot ACK');
|
|
477
|
+
}
|
|
478
|
+
log(`Lease ${leaseId} validated successfully`);
|
|
479
|
+
}
|
|
480
|
+
|
|
462
481
|
const batchResult = await acknowledgeMessages([{ transactionId, status, error }], consumerGroup);
|
|
463
482
|
return batchResult[0] || { status: 'not_found', transaction_id: transactionId };
|
|
464
483
|
};
|
|
465
484
|
|
|
466
|
-
//
|
|
467
|
-
const
|
|
485
|
+
// Internal acknowledge operation that accepts a client
|
|
486
|
+
const acknowledgeMessagesInternal = async (client, acknowledgments, consumerGroup = null) => {
|
|
468
487
|
const ackStartTime = Date.now();
|
|
469
488
|
const results = [];
|
|
470
489
|
const actualConsumerGroup = consumerGroup || '__QUEUE_MODE__';
|
|
@@ -486,45 +505,154 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
486
505
|
const successCount = grouped.completed.length;
|
|
487
506
|
|
|
488
507
|
// Determine if this is a TOTAL batch failure or partial failure
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
const
|
|
508
|
+
// Total batch failure: all messages being ACKed right now are failures AND
|
|
509
|
+
// we're ACKing the complete leased batch
|
|
510
|
+
const allIds = acknowledgments.map(a => a.transactionId);
|
|
511
|
+
const batchSizeInfo = await client.query(`
|
|
512
|
+
SELECT pc.batch_size, pc.partition_id
|
|
513
|
+
FROM queen.messages m
|
|
514
|
+
JOIN queen.partition_consumers pc ON pc.partition_id = m.partition_id
|
|
515
|
+
AND pc.consumer_group = $2
|
|
516
|
+
WHERE m.transaction_id = ANY($1::varchar[])
|
|
517
|
+
LIMIT 1
|
|
518
|
+
`, [allIds, actualConsumerGroup]);
|
|
519
|
+
|
|
520
|
+
if (batchSizeInfo.rows.length === 0) {
|
|
521
|
+
throw new Error('Cannot find leased batch size for acknowledgment');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const leasedBatchSize = batchSizeInfo.rows[0].batch_size;
|
|
525
|
+
|
|
526
|
+
// Total batch failure:
|
|
527
|
+
// 1. All messages in this ACK call are failures (failedCount === totalMessages)
|
|
528
|
+
// 2. This ACK call covers the complete leased batch (totalMessages === leasedBatchSize)
|
|
529
|
+
const isTotalBatchFailure = (failedCount === totalMessages) && (totalMessages === leasedBatchSize);
|
|
492
530
|
|
|
493
|
-
await withTransaction(pool, async (client) => {
|
|
494
531
|
// CURSOR-BASED APPROACH: Handle total batch failure vs partial failure differently
|
|
495
532
|
|
|
496
533
|
if (isTotalBatchFailure) {
|
|
497
534
|
// ═══════════════════════════════════════════════════════════════
|
|
498
|
-
// TOTAL BATCH FAILURE:
|
|
535
|
+
// TOTAL BATCH FAILURE: Check retry limit before retrying
|
|
499
536
|
// ═══════════════════════════════════════════════════════════════
|
|
500
|
-
log(`[CURSOR] Total batch failure - cursor NOT advanced, same batch will retry on next POP`);
|
|
501
|
-
const ackTime = Date.now() - ackStartTime;
|
|
502
|
-
const throughput = ackTime > 0 ? (totalMessages / (ackTime / 1000)).toFixed(0) : 0;
|
|
503
|
-
|
|
504
|
-
log(`[${LogTypes.ACK_BATCH}] Total batch failure: ${failedCount} msgs | ${throughput} msg/s | ${ackTime}ms | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'}`);
|
|
505
537
|
|
|
506
|
-
//
|
|
538
|
+
// Get queue retry limit and current batch retry count
|
|
507
539
|
const allIds = acknowledgments.map(a => a.transactionId);
|
|
508
|
-
await client.query(`
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
batch_size = 0,
|
|
519
|
-
acked_count = 0
|
|
520
|
-
FROM affected_partitions ap
|
|
521
|
-
WHERE pc.partition_id = ap.partition_id
|
|
540
|
+
const batchRetryInfo = await client.query(`
|
|
541
|
+
SELECT
|
|
542
|
+
q.retry_limit,
|
|
543
|
+
pc.batch_retry_count,
|
|
544
|
+
pc.partition_id,
|
|
545
|
+
p.name as partition_name
|
|
546
|
+
FROM queen.messages m
|
|
547
|
+
JOIN queen.partitions p ON p.id = m.partition_id
|
|
548
|
+
JOIN queen.queues q ON q.id = p.queue_id
|
|
549
|
+
JOIN queen.partition_consumers pc ON pc.partition_id = m.partition_id
|
|
522
550
|
AND pc.consumer_group = $2
|
|
551
|
+
WHERE m.transaction_id = ANY($1::varchar[])
|
|
552
|
+
LIMIT 1
|
|
523
553
|
`, [allIds, actualConsumerGroup]);
|
|
524
554
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
555
|
+
if (batchRetryInfo.rows.length === 0) {
|
|
556
|
+
throw new Error('Cannot find queue retry limit for batch failure');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const { retry_limit, batch_retry_count, partition_id, partition_name } = batchRetryInfo.rows[0];
|
|
560
|
+
const currentRetryCount = batch_retry_count || 0;
|
|
561
|
+
|
|
562
|
+
const ackTime = Date.now() - ackStartTime;
|
|
563
|
+
const throughput = ackTime > 0 ? (totalMessages / (ackTime / 1000)).toFixed(0) : 0;
|
|
564
|
+
|
|
565
|
+
if (currentRetryCount >= retry_limit) {
|
|
566
|
+
// ═══════════════════════════════════════════════════════════════
|
|
567
|
+
// BATCH RETRY LIMIT EXCEEDED: Move to DLQ and advance cursor
|
|
568
|
+
// ═══════════════════════════════════════════════════════════════
|
|
569
|
+
log(`[CURSOR] Batch retry limit exceeded (${currentRetryCount}/${retry_limit}) - moving batch to DLQ and advancing cursor`);
|
|
570
|
+
log(`[${LogTypes.ACK_BATCH}] Batch retry limit exceeded: ${failedCount} msgs → DLQ | ${throughput} msg/s | ${ackTime}ms | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'}`);
|
|
571
|
+
|
|
572
|
+
// Move all messages to DLQ
|
|
573
|
+
const failedErrors = grouped.failed.map(a => a.error || `Batch retry limit exceeded (${currentRetryCount}/${retry_limit})`);
|
|
574
|
+
await client.query(`
|
|
575
|
+
INSERT INTO queen.dead_letter_queue (message_id, partition_id, consumer_group, error_message, original_created_at)
|
|
576
|
+
SELECT
|
|
577
|
+
m.id,
|
|
578
|
+
m.partition_id,
|
|
579
|
+
$2,
|
|
580
|
+
e.error_message,
|
|
581
|
+
m.created_at
|
|
582
|
+
FROM queen.messages m
|
|
583
|
+
CROSS JOIN LATERAL UNNEST($1::varchar[], $3::text[]) AS e(txn_id, error_message)
|
|
584
|
+
WHERE m.transaction_id = e.txn_id
|
|
585
|
+
AND m.transaction_id = ANY($1::varchar[])
|
|
586
|
+
ON CONFLICT DO NOTHING
|
|
587
|
+
`, [allIds, actualConsumerGroup, failedErrors]);
|
|
588
|
+
|
|
589
|
+
// Get last message for cursor advancement
|
|
590
|
+
const lastMessageInfo = await client.query(`
|
|
591
|
+
SELECT id, created_at
|
|
592
|
+
FROM queen.messages m
|
|
593
|
+
WHERE m.transaction_id = ANY($1::varchar[])
|
|
594
|
+
ORDER BY m.created_at DESC, m.id DESC
|
|
595
|
+
LIMIT 1
|
|
596
|
+
`, [allIds]);
|
|
597
|
+
|
|
598
|
+
const lastMessage = lastMessageInfo.rows[0];
|
|
599
|
+
|
|
600
|
+
// Advance cursor and reset batch retry count
|
|
601
|
+
await client.query(`
|
|
602
|
+
UPDATE queen.partition_consumers
|
|
603
|
+
SET
|
|
604
|
+
-- Advance cursor past failed batch
|
|
605
|
+
last_consumed_created_at = $1,
|
|
606
|
+
last_consumed_id = $2,
|
|
607
|
+
total_messages_consumed = total_messages_consumed + $3,
|
|
608
|
+
total_batches_consumed = total_batches_consumed + 1,
|
|
609
|
+
last_consumed_at = NOW(),
|
|
610
|
+
|
|
611
|
+
-- Reset batch retry count
|
|
612
|
+
batch_retry_count = 0,
|
|
613
|
+
|
|
614
|
+
-- Update pending estimate
|
|
615
|
+
pending_estimate = GREATEST(0, pending_estimate - $3),
|
|
616
|
+
last_stats_update = NOW(),
|
|
617
|
+
|
|
618
|
+
-- Release lease
|
|
619
|
+
lease_expires_at = NULL,
|
|
620
|
+
lease_acquired_at = NULL,
|
|
621
|
+
message_batch = NULL,
|
|
622
|
+
batch_size = 0,
|
|
623
|
+
acked_count = 0
|
|
624
|
+
WHERE partition_id = $4
|
|
625
|
+
AND consumer_group = $5
|
|
626
|
+
`, [lastMessage.created_at, lastMessage.id, totalMessages, partition_id, actualConsumerGroup]);
|
|
627
|
+
|
|
628
|
+
grouped.failed.forEach(ack => {
|
|
629
|
+
results.push({ transactionId: ack.transactionId, status: 'failed_dlq' });
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
} else {
|
|
633
|
+
// ═══════════════════════════════════════════════════════════════
|
|
634
|
+
// BATCH RETRY: Increment retry count and retry same batch
|
|
635
|
+
// ═══════════════════════════════════════════════════════════════
|
|
636
|
+
log(`[CURSOR] Total batch failure - cursor NOT advanced, same batch will retry (attempt ${currentRetryCount + 1}/${retry_limit})`);
|
|
637
|
+
log(`[${LogTypes.ACK_BATCH}] Total batch failure: ${failedCount} msgs | Retry ${currentRetryCount + 1}/${retry_limit} | ${throughput} msg/s | ${ackTime}ms | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'}`);
|
|
638
|
+
|
|
639
|
+
// Increment batch retry count and release lease
|
|
640
|
+
await client.query(`
|
|
641
|
+
UPDATE queen.partition_consumers pc
|
|
642
|
+
SET lease_expires_at = NULL,
|
|
643
|
+
lease_acquired_at = NULL,
|
|
644
|
+
message_batch = NULL,
|
|
645
|
+
batch_size = 0,
|
|
646
|
+
acked_count = 0,
|
|
647
|
+
batch_retry_count = COALESCE(batch_retry_count, 0) + 1
|
|
648
|
+
WHERE pc.partition_id = $1
|
|
649
|
+
AND pc.consumer_group = $2
|
|
650
|
+
`, [partition_id, actualConsumerGroup]);
|
|
651
|
+
|
|
652
|
+
grouped.failed.forEach(ack => {
|
|
653
|
+
results.push({ transactionId: ack.transactionId, status: 'failed_retry' });
|
|
654
|
+
});
|
|
655
|
+
}
|
|
528
656
|
|
|
529
657
|
} else {
|
|
530
658
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -566,6 +694,9 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
566
694
|
total_batches_consumed = total_batches_consumed + 1,
|
|
567
695
|
last_consumed_at = NOW(),
|
|
568
696
|
|
|
697
|
+
-- Reset batch retry count on successful processing
|
|
698
|
+
batch_retry_count = 0,
|
|
699
|
+
|
|
569
700
|
-- Update pending estimate
|
|
570
701
|
pending_estimate = GREATEST(0, pending_estimate - $3),
|
|
571
702
|
last_stats_update = NOW(),
|
|
@@ -645,11 +776,17 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
645
776
|
results.push({ transactionId: ack.transactionId, status: 'failed_dlq' });
|
|
646
777
|
});
|
|
647
778
|
}
|
|
648
|
-
});
|
|
649
779
|
|
|
650
780
|
return results;
|
|
651
781
|
};
|
|
652
782
|
|
|
783
|
+
// Public wrapper for acknowledge that creates its own transaction
|
|
784
|
+
const acknowledgeMessages = async (acknowledgments, consumerGroup = null) => {
|
|
785
|
+
return withTransaction(pool, async (client) => {
|
|
786
|
+
return acknowledgeMessagesInternal(client, acknowledgments, consumerGroup);
|
|
787
|
+
});
|
|
788
|
+
};
|
|
789
|
+
|
|
653
790
|
// Reclaim expired leases from partition_consumers
|
|
654
791
|
const reclaimExpiredLeases = async () => {
|
|
655
792
|
return withTransaction(pool, async (client) => {
|
|
@@ -1000,8 +1137,8 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
1000
1137
|
|
|
1001
1138
|
return withTransaction(pool, async (client) => {
|
|
1002
1139
|
const connectionWaitTime = Date.now() - startTime;
|
|
1003
|
-
if (connectionWaitTime >
|
|
1004
|
-
log(`⚠️
|
|
1140
|
+
if (connectionWaitTime > 1000) {
|
|
1141
|
+
log(`⚠️ Warning connection wait time: ${connectionWaitTime}ms`);
|
|
1005
1142
|
}
|
|
1006
1143
|
|
|
1007
1144
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -1232,11 +1369,15 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
1232
1369
|
ON CONFLICT (partition_id, consumer_group) DO NOTHING
|
|
1233
1370
|
`, [candidate.partition_id, actualConsumerGroup, initialCursorId, initialCursorTimestamp]);
|
|
1234
1371
|
|
|
1372
|
+
// Generate unique lease ID for this POP operation
|
|
1373
|
+
const leaseId = generateUUID();
|
|
1374
|
+
|
|
1235
1375
|
// Step 2: Try to acquire lease and get cursor position
|
|
1236
1376
|
const leaseResult = await client.query(`
|
|
1237
1377
|
UPDATE queen.partition_consumers
|
|
1238
1378
|
SET lease_expires_at = NOW() + INTERVAL '1 second' * $3,
|
|
1239
1379
|
lease_acquired_at = NOW(),
|
|
1380
|
+
worker_id = $4, -- Store lease ID in worker_id field
|
|
1240
1381
|
message_batch = NULL,
|
|
1241
1382
|
batch_size = 0,
|
|
1242
1383
|
acked_count = 0
|
|
@@ -1244,8 +1385,8 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
1244
1385
|
AND consumer_group = $2
|
|
1245
1386
|
AND (lease_expires_at IS NULL OR lease_expires_at <= NOW())
|
|
1246
1387
|
RETURNING partition_id, last_consumed_id, last_consumed_created_at,
|
|
1247
|
-
total_messages_consumed, lease_expires_at
|
|
1248
|
-
`, [candidate.partition_id, actualConsumerGroup, candidate.lease_time]);
|
|
1388
|
+
total_messages_consumed, lease_expires_at, worker_id as lease_id
|
|
1389
|
+
`, [candidate.partition_id, actualConsumerGroup, candidate.lease_time, leaseId]);
|
|
1249
1390
|
|
|
1250
1391
|
// Check if we acquired the lease
|
|
1251
1392
|
const acquired = leaseResult.rows.length > 0;
|
|
@@ -1333,6 +1474,7 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
1333
1474
|
|
|
1334
1475
|
// Success! We have messages from this partition
|
|
1335
1476
|
acquiredPartition = candidate;
|
|
1477
|
+
acquiredPartition.leaseId = leaseId; // Store lease ID for later use
|
|
1336
1478
|
|
|
1337
1479
|
// ─────────────────────────────────────────────────────────────────────
|
|
1338
1480
|
// PHASE 4: Update Partition Lease with Message Batch
|
|
@@ -1377,7 +1519,8 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
1377
1519
|
retryCount: msg.retry_count || 0,
|
|
1378
1520
|
priority: msg.priority || 0,
|
|
1379
1521
|
createdAt: msg.created_at,
|
|
1380
|
-
consumerGroup: consumerGroup || null
|
|
1522
|
+
consumerGroup: consumerGroup || null,
|
|
1523
|
+
leaseId: acquiredPartition.leaseId // Add lease ID to each message
|
|
1381
1524
|
};
|
|
1382
1525
|
}));
|
|
1383
1526
|
const decryptTime = Date.now() - decryptStart;
|
|
@@ -1414,6 +1557,65 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
1414
1557
|
}
|
|
1415
1558
|
});
|
|
1416
1559
|
};
|
|
1560
|
+
|
|
1561
|
+
// Execute multiple operations in a single transaction
|
|
1562
|
+
const executeTransaction = async (operations, requiredLeases = []) => {
|
|
1563
|
+
return withTransaction(pool, async (client) => {
|
|
1564
|
+
// 1. Validate all required leases upfront
|
|
1565
|
+
if (requiredLeases.length > 0) {
|
|
1566
|
+
const leaseCheck = await client.query(`
|
|
1567
|
+
SELECT COUNT(*) = $2 as all_valid
|
|
1568
|
+
FROM queen.partition_consumers
|
|
1569
|
+
WHERE worker_id = ANY($1::varchar[])
|
|
1570
|
+
AND lease_expires_at > NOW()
|
|
1571
|
+
`, [requiredLeases, requiredLeases.length]);
|
|
1572
|
+
|
|
1573
|
+
if (!leaseCheck.rows[0].all_valid) {
|
|
1574
|
+
throw new Error('One or more leases invalid or expired');
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// 2. Execute operations in order
|
|
1579
|
+
const results = [];
|
|
1580
|
+
for (const op of operations) {
|
|
1581
|
+
switch (op.type) {
|
|
1582
|
+
case 'push':
|
|
1583
|
+
const pushResult = await pushMessagesInternal(client, op.items);
|
|
1584
|
+
results.push({ type: 'push', result: pushResult });
|
|
1585
|
+
break;
|
|
1586
|
+
|
|
1587
|
+
case 'ack':
|
|
1588
|
+
const acks = [{
|
|
1589
|
+
transactionId: op.transactionId,
|
|
1590
|
+
status: op.status || 'completed',
|
|
1591
|
+
error: op.error
|
|
1592
|
+
}];
|
|
1593
|
+
const ackResult = await acknowledgeMessagesInternal(client, acks, op.consumerGroup);
|
|
1594
|
+
results.push({ type: 'ack', result: ackResult });
|
|
1595
|
+
break;
|
|
1596
|
+
|
|
1597
|
+
case 'extend':
|
|
1598
|
+
const extendResult = await client.query(`
|
|
1599
|
+
UPDATE queen.partition_consumers
|
|
1600
|
+
SET lease_expires_at = GREATEST(lease_expires_at, NOW() + INTERVAL '1 second' * $1)
|
|
1601
|
+
WHERE worker_id = $2 AND lease_expires_at > NOW()
|
|
1602
|
+
RETURNING lease_expires_at
|
|
1603
|
+
`, [op.seconds || 60, op.leaseId]);
|
|
1604
|
+
|
|
1605
|
+
if (extendResult.rows.length === 0) {
|
|
1606
|
+
throw new Error(`Cannot extend lease ${op.leaseId} - not found or expired`);
|
|
1607
|
+
}
|
|
1608
|
+
results.push({ type: 'extend', result: extendResult.rows[0] });
|
|
1609
|
+
break;
|
|
1610
|
+
|
|
1611
|
+
default:
|
|
1612
|
+
throw new Error(`Unknown operation type: ${op.type}`);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
return results;
|
|
1617
|
+
});
|
|
1618
|
+
};
|
|
1417
1619
|
|
|
1418
1620
|
// Export all functions
|
|
1419
1621
|
return {
|
|
@@ -1426,6 +1628,7 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
|
|
|
1426
1628
|
getQueueStats,
|
|
1427
1629
|
getQueueLag,
|
|
1428
1630
|
uniquePop, // ← New unified function
|
|
1429
|
-
configureQueue
|
|
1631
|
+
configureQueue,
|
|
1632
|
+
executeTransaction // ← New transaction support
|
|
1430
1633
|
};
|
|
1431
1634
|
};
|
package/src/routes/ack.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const createAckRoute = (queueManager) => {
|
|
2
2
|
return async (body) => {
|
|
3
|
-
const { transactionId, status, error, consumerGroup } = body;
|
|
3
|
+
const { transactionId, status, error, consumerGroup, leaseId } = body;
|
|
4
4
|
|
|
5
5
|
if (!transactionId) {
|
|
6
6
|
throw new Error('transactionId is required');
|
|
@@ -10,14 +10,14 @@ export const createAckRoute = (queueManager) => {
|
|
|
10
10
|
throw new Error('status must be "completed" or "failed"');
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
const result = await queueManager.acknowledgeMessage(transactionId, status, error, consumerGroup);
|
|
13
|
+
const result = await queueManager.acknowledgeMessage(transactionId, status, error, consumerGroup, leaseId);
|
|
14
14
|
|
|
15
15
|
if (!result) {
|
|
16
16
|
throw new Error('Message not found or not in processing state');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
return {
|
|
20
|
-
transactionId: result.transaction_id,
|
|
20
|
+
transactionId: result.transactionId || result.transaction_id,
|
|
21
21
|
status: result.status,
|
|
22
22
|
consumerGroup: consumerGroup || null,
|
|
23
23
|
acknowledgedAt: new Date().toISOString()
|
package/src/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { startRetentionJob } from './services/retentionService.js';
|
|
|
23
23
|
import { startEvictionJob } from './services/evictionService.js';
|
|
24
24
|
import { log } from './utils/logger.js';
|
|
25
25
|
import { streamJSON, sendJSON, sendError } from './utils/streaming.js';
|
|
26
|
+
import { generateUUID } from './utils/uuid.js';
|
|
26
27
|
|
|
27
28
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
29
|
|
|
@@ -40,7 +41,13 @@ export async function QueenServer(options = {}) {
|
|
|
40
41
|
const PORT = options.port || config.SERVER.PORT;
|
|
41
42
|
const HOST = options.host || config.SERVER.HOST;
|
|
42
43
|
const SERVE_WEBAPP = options.serveWebapp || false;
|
|
43
|
-
|
|
44
|
+
|
|
45
|
+
// Default webapp path: try src/webapp-dist (npm package), fall back to ../webapp/dist (development)
|
|
46
|
+
let defaultWebappPath = path.join(__dirname, 'webapp-dist');
|
|
47
|
+
if (!fs.existsSync(defaultWebappPath)) {
|
|
48
|
+
defaultWebappPath = path.join(__dirname, '..', 'webapp', 'dist');
|
|
49
|
+
}
|
|
50
|
+
const WEBAPP_PATH = options.webappPath || defaultWebappPath;
|
|
44
51
|
|
|
45
52
|
// Use WORKER_ID from config as the server instance ID for consumer groups
|
|
46
53
|
// This ensures the same server (identified by WORKER_ID) maintains its position in the event stream
|
|
@@ -554,10 +561,11 @@ export async function QueenServer(options = {}) {
|
|
|
554
561
|
});
|
|
555
562
|
} catch (error) {
|
|
556
563
|
if (abortedRef.aborted) return;
|
|
557
|
-
log('ACK error:', error);
|
|
564
|
+
log('ACK error:', error.message || error);
|
|
558
565
|
res.cork(() => {
|
|
566
|
+
res.writeStatus('500');
|
|
559
567
|
setCorsHeaders(res);
|
|
560
|
-
res.
|
|
568
|
+
res.end(JSON.stringify({ error: error.message || 'Unknown error' }));
|
|
561
569
|
});
|
|
562
570
|
}
|
|
563
571
|
}, abortedRef);
|
|
@@ -609,6 +617,109 @@ export async function QueenServer(options = {}) {
|
|
|
609
617
|
}, abortedRef);
|
|
610
618
|
});
|
|
611
619
|
|
|
620
|
+
// Lease extension endpoint
|
|
621
|
+
app.post('/api/v1/lease/:leaseId/extend', (res, req) => {
|
|
622
|
+
const abortedRef = { aborted: false };
|
|
623
|
+
res.onAborted(() => {
|
|
624
|
+
abortedRef.aborted = true;
|
|
625
|
+
log('Lease extension request aborted');
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const leaseId = req.getParameter(0); // In uWebSockets.js, parameters are accessed by index
|
|
629
|
+
|
|
630
|
+
readJson(res, async (body) => {
|
|
631
|
+
if (abortedRef.aborted) return;
|
|
632
|
+
|
|
633
|
+
const { seconds = 60 } = body || {};
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
const result = await pool.query(`
|
|
637
|
+
UPDATE queen.partition_consumers
|
|
638
|
+
SET lease_expires_at = GREATEST(lease_expires_at, NOW() + INTERVAL '1 second' * $1)
|
|
639
|
+
WHERE worker_id = $2 AND lease_expires_at > NOW()
|
|
640
|
+
RETURNING lease_expires_at, partition_id
|
|
641
|
+
`, [seconds, leaseId]);
|
|
642
|
+
|
|
643
|
+
if (result.rows.length === 0) {
|
|
644
|
+
if (!abortedRef.aborted) {
|
|
645
|
+
res.cork(() => {
|
|
646
|
+
setCorsHeaders(res);
|
|
647
|
+
res.writeStatus('404').end(JSON.stringify({ error: 'Lease not found or expired' }));
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (!abortedRef.aborted) {
|
|
654
|
+
res.cork(() => {
|
|
655
|
+
setCorsHeaders(res);
|
|
656
|
+
res.writeStatus('200').end(JSON.stringify({
|
|
657
|
+
leaseId,
|
|
658
|
+
newExpiresAt: result.rows[0].lease_expires_at
|
|
659
|
+
}));
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (!abortedRef.aborted) {
|
|
664
|
+
log('Lease extension error:', error);
|
|
665
|
+
res.cork(() => {
|
|
666
|
+
setCorsHeaders(res);
|
|
667
|
+
res.writeStatus('500').end(JSON.stringify({ error: error.message }));
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}, abortedRef);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Transaction endpoint
|
|
675
|
+
app.post('/api/v1/transaction', (res, req) => {
|
|
676
|
+
const abortedRef = { aborted: false };
|
|
677
|
+
res.onAborted(() => {
|
|
678
|
+
abortedRef.aborted = true;
|
|
679
|
+
log('Transaction request aborted');
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
readJson(res, async (body) => {
|
|
683
|
+
if (abortedRef.aborted) return;
|
|
684
|
+
|
|
685
|
+
const { operations = [], requiredLeases = [] } = body;
|
|
686
|
+
|
|
687
|
+
if (!Array.isArray(operations) || operations.length === 0) {
|
|
688
|
+
if (!abortedRef.aborted) {
|
|
689
|
+
res.cork(() => {
|
|
690
|
+
setCorsHeaders(res);
|
|
691
|
+
res.writeStatus('400').end(JSON.stringify({ error: 'Operations array required' }));
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
try {
|
|
698
|
+
const results = await queueManager.executeTransaction(operations, requiredLeases);
|
|
699
|
+
|
|
700
|
+
if (!abortedRef.aborted) {
|
|
701
|
+
const transactionId = generateUUID();
|
|
702
|
+
res.cork(() => {
|
|
703
|
+
setCorsHeaders(res);
|
|
704
|
+
res.writeStatus('200').end(JSON.stringify({
|
|
705
|
+
success: true,
|
|
706
|
+
results,
|
|
707
|
+
transactionId
|
|
708
|
+
}));
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
} catch (error) {
|
|
712
|
+
if (!abortedRef.aborted) {
|
|
713
|
+
log('Transaction error:', error);
|
|
714
|
+
res.cork(() => {
|
|
715
|
+
setCorsHeaders(res);
|
|
716
|
+
res.writeStatus('400').end(JSON.stringify({ error: error.message }));
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}, abortedRef);
|
|
721
|
+
});
|
|
722
|
+
|
|
612
723
|
// Messages routes
|
|
613
724
|
app.get('/api/v1/messages', (res, req) => {
|
|
614
725
|
const query = new URLSearchParams(req.getQuery());
|
|
@@ -1234,6 +1345,23 @@ export async function QueenServer(options = {}) {
|
|
|
1234
1345
|
// ============================================================================
|
|
1235
1346
|
|
|
1236
1347
|
if (SERVE_WEBAPP) {
|
|
1348
|
+
// Check if webapp directory exists
|
|
1349
|
+
if (!fs.existsSync(WEBAPP_PATH)) {
|
|
1350
|
+
log(`❌ ERROR: Webapp directory not found at: ${WEBAPP_PATH}`);
|
|
1351
|
+
log(`💡 To enable frontend serving:`);
|
|
1352
|
+
log(` 1. Build the webapp: cd webapp && npm install && npm run build`);
|
|
1353
|
+
log(` 2. Or specify a custom path: QueenServer({ webappPath: '/path/to/dist' })`);
|
|
1354
|
+
log(` 3. Or disable frontend: QueenServer({ serveWebapp: false })`);
|
|
1355
|
+
throw new Error(`Webapp directory not found: ${WEBAPP_PATH}`);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const indexPath = path.join(WEBAPP_PATH, 'index.html');
|
|
1359
|
+
if (!fs.existsSync(indexPath)) {
|
|
1360
|
+
log(`❌ ERROR: index.html not found in webapp directory: ${WEBAPP_PATH}`);
|
|
1361
|
+
log(`💡 Make sure the webapp is built: cd webapp && npm run build`);
|
|
1362
|
+
throw new Error(`index.html not found in: ${WEBAPP_PATH}`);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1237
1365
|
log(`📁 Enabling static file serving from: ${WEBAPP_PATH}`);
|
|
1238
1366
|
|
|
1239
1367
|
// MIME type mapping
|