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.
Files changed (135) hide show
  1. package/README.md +467 -1775
  2. package/package.json +12 -14
  3. package/src/client/client.js +664 -5
  4. package/src/managers/queueManagerOptimized.js +250 -47
  5. package/src/routes/ack.js +3 -3
  6. package/src/server.js +131 -3
  7. package/src/test/advanced-client-tests.js +761 -0
  8. package/src/test/advanced-pattern-tests.js +2 -2
  9. package/src/test/bus-mode-tests.js +6 -6
  10. package/src/test/core-tests.js +1 -1
  11. package/src/test/edge-case-tests.js +2 -2
  12. package/src/test/test-new.js +36 -1
  13. package/src/webapp-dist/assets/Analytics-DJrE4Rz2.css +1 -0
  14. package/src/webapp-dist/assets/Analytics-HEMXKmfJ.js +1 -0
  15. package/src/webapp-dist/assets/ConfirmDialog-YEoCdE7x.js +1 -0
  16. package/src/webapp-dist/assets/ConsumerGroups-CC5VJywp.css +1 -0
  17. package/src/webapp-dist/assets/ConsumerGroups-D8Z20HhZ.js +1 -0
  18. package/src/webapp-dist/assets/Dashboard-BRFOz6it.css +1 -0
  19. package/src/webapp-dist/assets/Dashboard-Bpx9I32h.js +1 -0
  20. package/src/webapp-dist/assets/LoadingSpinner-BPttXcbs.js +1 -0
  21. package/src/webapp-dist/assets/Messages-BOfVHKma.css +1 -0
  22. package/src/webapp-dist/assets/Messages-EKURCbLl.js +1 -0
  23. package/src/webapp-dist/assets/QueueDetail-BXXA7xoZ.css +1 -0
  24. package/src/webapp-dist/assets/QueueDetail-Bmxg0KiA.js +1 -0
  25. package/src/webapp-dist/assets/Queues-CvrJkrZT.css +1 -0
  26. package/src/webapp-dist/assets/Queues-yUx9_3-9.js +1 -0
  27. package/src/webapp-dist/assets/StatusBadge-C1bBtXHh.css +1 -0
  28. package/src/webapp-dist/assets/StatusBadge-C6N84XmO.js +1 -0
  29. package/src/webapp-dist/assets/analytics-BapTekLM.js +1 -0
  30. package/src/webapp-dist/assets/colors-5z6Q_kUr.js +18 -0
  31. package/src/webapp-dist/assets/index-B7w2bKT6.css +1 -0
  32. package/src/webapp-dist/assets/index-CUSnwYTH.js +31 -0
  33. package/src/webapp-dist/assets/messages-CRV6E-xU.js +1 -0
  34. package/src/webapp-dist/assets/queues-BdUbxfBC.js +1 -0
  35. package/src/webapp-dist/assets/resources-Bs9U-ceF.js +1 -0
  36. package/{webapp → src/webapp-dist}/index.html +2 -1
  37. package/API.md +0 -1226
  38. package/AUTH.md +0 -2044
  39. package/CHANGELOG_STATIC_SERVING.md +0 -213
  40. package/DEPLOYMENT.md +0 -425
  41. package/PROGRAMMATIC_SERVER.md +0 -239
  42. package/WEBAPP.md +0 -1889
  43. package/assets/dashboard-01.png +0 -0
  44. package/assets/dashboard.png +0 -0
  45. package/examples/batch-processing.js +0 -58
  46. package/examples/bus-mode.js +0 -239
  47. package/examples/continuous-consumer-optimized.js +0 -215
  48. package/examples/continuous-consumer.js +0 -159
  49. package/examples/continuous-producer.js +0 -343
  50. package/examples/mixed-mode.js +0 -277
  51. package/examples/multi-server-test.js +0 -305
  52. package/examples/programmatic-server.js +0 -58
  53. package/examples/server-with-frontend.js +0 -46
  54. package/examples/single.js +0 -64
  55. package/examples/smartchat-dealyed.js +0 -42
  56. package/examples/smartchat.js +0 -52
  57. package/examples/test-cache-invalidation.js +0 -119
  58. package/examples/test-cache-multi-server.js +0 -245
  59. package/examples/test-complete-client.js +0 -260
  60. package/examples/test-connection-recovery.js +0 -66
  61. package/examples/test-dashboard-api.js +0 -200
  62. package/examples/test-minimal-client.js +0 -112
  63. package/examples/test-queue-creation-policy.js +0 -137
  64. package/examples/test-traceid.js +0 -147
  65. package/server.log +0 -1
  66. package/test-keepalive-v2.sh +0 -22
  67. package/webapp/COLOR_GUIDE.md +0 -118
  68. package/webapp/README.md +0 -143
  69. package/webapp/package-lock.json +0 -3184
  70. package/webapp/package.json +0 -25
  71. package/webapp/postcss.config.js +0 -7
  72. package/webapp/public/assets/queen-logo-blue.svg +0 -210
  73. package/webapp/public/assets/queen-logo-cyan.svg +0 -210
  74. package/webapp/public/assets/queen-logo-indigo.svg +0 -210
  75. package/webapp/public/assets/queen-logo-orange.svg +0 -210
  76. package/webapp/public/assets/queen-logo-pink.svg +0 -210
  77. package/webapp/public/assets/queen-logo-purple.svg +0 -210
  78. package/webapp/public/assets/queen-logo-rose.svg +0 -239
  79. package/webapp/public/assets/queen-logo.svg +0 -263
  80. package/webapp/src/App.vue +0 -19
  81. package/webapp/src/api/analytics.js +0 -10
  82. package/webapp/src/api/client.js +0 -34
  83. package/webapp/src/api/consumers.js +0 -52
  84. package/webapp/src/api/health.js +0 -7
  85. package/webapp/src/api/messages.js +0 -26
  86. package/webapp/src/api/queues.js +0 -14
  87. package/webapp/src/api/resources.js +0 -8
  88. package/webapp/src/assets/styles/main.css +0 -357
  89. package/webapp/src/components/analytics/AnalyticsFilters.vue +0 -87
  90. package/webapp/src/components/analytics/AnalyticsMetrics.vue +0 -57
  91. package/webapp/src/components/analytics/MessageDistributionChart.vue +0 -111
  92. package/webapp/src/components/analytics/MessageFlowChart.vue +0 -173
  93. package/webapp/src/components/analytics/TimeRangeSelector.vue +0 -27
  94. package/webapp/src/components/analytics/TopQueuesChart.vue +0 -132
  95. package/webapp/src/components/common/ConfirmDialog.vue +0 -56
  96. package/webapp/src/components/common/LoadingSpinner.vue +0 -6
  97. package/webapp/src/components/common/MetricCard.vue +0 -43
  98. package/webapp/src/components/common/StatusBadge.vue +0 -45
  99. package/webapp/src/components/dashboard/MessageStatusCard.vue +0 -50
  100. package/webapp/src/components/dashboard/PerformanceCard.vue +0 -38
  101. package/webapp/src/components/dashboard/ThroughputChart.vue +0 -182
  102. package/webapp/src/components/dashboard/TopQueuesTable.vue +0 -53
  103. package/webapp/src/components/layout/AppLayout.vue +0 -110
  104. package/webapp/src/components/layout/AppSidebar.vue +0 -304
  105. package/webapp/src/components/messages/MessageDetailPanel.vue +0 -242
  106. package/webapp/src/components/messages/MessageFilters.vue +0 -101
  107. package/webapp/src/components/queue-detail/PartitionList.vue +0 -79
  108. package/webapp/src/components/queue-detail/PushMessageModal.vue +0 -175
  109. package/webapp/src/components/queue-detail/QueueConfig.vue +0 -63
  110. package/webapp/src/components/queue-detail/QueueDetailHeader.vue +0 -53
  111. package/webapp/src/components/queue-detail/RecentMessages.vue +0 -76
  112. package/webapp/src/components/queues/CreateQueueModal.vue +0 -193
  113. package/webapp/src/components/queues/QueueFilters.vue +0 -90
  114. package/webapp/src/composables/useApi.js +0 -34
  115. package/webapp/src/composables/useTheme.js +0 -36
  116. package/webapp/src/main.js +0 -11
  117. package/webapp/src/router/index.js +0 -42
  118. package/webapp/src/utils/colors.js +0 -96
  119. package/webapp/src/utils/formatters.js +0 -49
  120. package/webapp/src/views/Analytics.vue +0 -377
  121. package/webapp/src/views/ConsumerGroups.vue +0 -433
  122. package/webapp/src/views/Dashboard.vue +0 -418
  123. package/webapp/src/views/Messages.vue +0 -363
  124. package/webapp/src/views/QueueDetail.vue +0 -582
  125. package/webapp/src/views/Queues.vue +0 -496
  126. package/webapp/tailwind.config.js +0 -25
  127. package/webapp/vite.config.js +0 -10
  128. /package/{assets → src/webapp-dist/assets}/queen-logo-blue.svg +0 -0
  129. /package/{assets → src/webapp-dist/assets}/queen-logo-cyan.svg +0 -0
  130. /package/{assets → src/webapp-dist/assets}/queen-logo-indigo.svg +0 -0
  131. /package/{assets → src/webapp-dist/assets}/queen-logo-orange.svg +0 -0
  132. /package/{assets → src/webapp-dist/assets}/queen-logo-pink.svg +0 -0
  133. /package/{assets → src/webapp-dist/assets}/queen-logo-purple.svg +0 -0
  134. /package/{assets → src/webapp-dist/assets}/queen-logo-rose.svg +0 -0
  135. /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
- // Optimized batch insert for high throughput (simplified - no status)
205
- const pushMessagesBatch = async (items) => {
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
- return batchResults;
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
- // Batch acknowledge messages with cursor-based advancement
467
- const acknowledgeMessages = async (acknowledgments, consumerGroup = null) => {
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
- // Only treat as "total batch failure" if it's a real batch (>1 message)
490
- // Single-message failures always go to DLQ
491
- const isTotalBatchFailure = failedCount === totalMessages && totalMessages > 1;
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: Don't advance cursor, allow retry
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
- // Just release the lease - next POP will get same batch
538
+ // Get queue retry limit and current batch retry count
507
539
  const allIds = acknowledgments.map(a => a.transactionId);
508
- await client.query(`
509
- WITH affected_partitions AS (
510
- SELECT DISTINCT m.partition_id
511
- FROM queen.messages m
512
- WHERE m.transaction_id = ANY($1::varchar[])
513
- )
514
- UPDATE queen.partition_consumers pc
515
- SET lease_expires_at = NULL,
516
- lease_acquired_at = NULL,
517
- message_batch = NULL,
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
- grouped.failed.forEach(ack => {
526
- results.push({ transactionId: ack.transactionId, status: 'failed_retry' });
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 > 50) {
1004
- log(`⚠️ : ${connectionWaitTime}ms`);
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
- const WEBAPP_PATH = options.webappPath || path.join(__dirname, '..', 'webapp', 'dist');
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.writeStatus(config.HTTP_STATUS.INTERNAL_SERVER_ERROR.toString()).end(JSON.stringify({ error: error.message }));
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