queen-mq 0.1.1 → 0.1.2

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 (139) hide show
  1. package/API.md +1226 -0
  2. package/AUTH.md +2044 -0
  3. package/LICENSE.md +202 -0
  4. package/README.md +276 -53
  5. package/WEBAPP.md +1889 -0
  6. package/assets/dashboard-01.png +0 -0
  7. package/assets/queen-logo-blue.svg +210 -0
  8. package/assets/queen-logo-cyan.svg +210 -0
  9. package/assets/queen-logo-indigo.svg +210 -0
  10. package/assets/queen-logo-orange.svg +210 -0
  11. package/assets/queen-logo-pink.svg +210 -0
  12. package/assets/queen-logo-purple.svg +210 -0
  13. package/assets/queen-logo-rose.svg +239 -0
  14. package/assets/queen-logo.svg +263 -0
  15. package/examples/batch-processing.js +58 -0
  16. package/examples/test-dashboard-api.js +200 -0
  17. package/package.json +4 -2
  18. package/server.log +1 -0
  19. package/src/benchmark/consumer.js +207 -0
  20. package/src/benchmark/consumer_multi.js +216 -0
  21. package/src/benchmark/producer.js +75 -0
  22. package/src/benchmark/producer_multi.js +115 -0
  23. package/src/client/client.js +192 -16
  24. package/src/client/queenClient.js +5 -0
  25. package/src/cluster-server.js +242 -0
  26. package/src/config.js +19 -5
  27. package/src/database/connection.js +42 -16
  28. package/src/database/poolManager.js +7 -0
  29. package/src/database/schema-v2.sql +194 -130
  30. package/src/managers/queueManagerOptimized.js +823 -933
  31. package/src/managers/systemEventManager.js +8 -3
  32. package/src/routes/messages.js +127 -57
  33. package/src/routes/pop.js +27 -43
  34. package/src/routes/resources.js +61 -27
  35. package/src/routes/status.js +1037 -0
  36. package/src/server.js +308 -272
  37. package/src/services/evictionService.js +57 -28
  38. package/src/services/retentionService.js +44 -11
  39. package/src/test/advanced-pattern-tests.js +5 -5
  40. package/src/test/bus-mode-tests.js +24 -11
  41. package/src/test/edge-case-tests.js +12 -5
  42. package/src/test/enterprise-tests.js +48 -15
  43. package/src/test/utils.js +1 -1
  44. package/src/utils/streaming.js +231 -0
  45. package/src/utils/uuid.js +2 -2
  46. package/src/websocket/wsServer.js +10 -3
  47. package/test-keepalive-v2.sh +22 -0
  48. package/webapp/COLOR_GUIDE.md +118 -0
  49. package/webapp/README.md +143 -0
  50. package/webapp/index.html +14 -0
  51. package/webapp/package-lock.json +3184 -0
  52. package/webapp/package.json +25 -0
  53. package/webapp/postcss.config.js +7 -0
  54. package/webapp/public/assets/queen-logo-blue.svg +210 -0
  55. package/webapp/public/assets/queen-logo-cyan.svg +210 -0
  56. package/webapp/public/assets/queen-logo-indigo.svg +210 -0
  57. package/webapp/public/assets/queen-logo-orange.svg +210 -0
  58. package/webapp/public/assets/queen-logo-pink.svg +210 -0
  59. package/webapp/public/assets/queen-logo-purple.svg +210 -0
  60. package/webapp/public/assets/queen-logo-rose.svg +239 -0
  61. package/webapp/public/assets/queen-logo.svg +263 -0
  62. package/webapp/src/App.vue +19 -0
  63. package/webapp/src/api/analytics.js +10 -0
  64. package/webapp/src/api/client.js +29 -0
  65. package/webapp/src/api/consumers.js +52 -0
  66. package/webapp/src/api/health.js +7 -0
  67. package/webapp/src/api/messages.js +26 -0
  68. package/webapp/src/api/queues.js +14 -0
  69. package/webapp/src/api/resources.js +8 -0
  70. package/webapp/src/assets/styles/main.css +357 -0
  71. package/webapp/src/components/analytics/AnalyticsFilters.vue +87 -0
  72. package/webapp/src/components/analytics/AnalyticsMetrics.vue +57 -0
  73. package/webapp/src/components/analytics/MessageDistributionChart.vue +111 -0
  74. package/webapp/src/components/analytics/MessageFlowChart.vue +173 -0
  75. package/webapp/src/components/analytics/TimeRangeSelector.vue +27 -0
  76. package/webapp/src/components/analytics/TopQueuesChart.vue +132 -0
  77. package/webapp/src/components/common/ConfirmDialog.vue +56 -0
  78. package/webapp/src/components/common/LoadingSpinner.vue +6 -0
  79. package/webapp/src/components/common/MetricCard.vue +43 -0
  80. package/webapp/src/components/common/StatusBadge.vue +45 -0
  81. package/webapp/src/components/dashboard/MessageStatusCard.vue +50 -0
  82. package/webapp/src/components/dashboard/PerformanceCard.vue +38 -0
  83. package/webapp/src/components/dashboard/ThroughputChart.vue +182 -0
  84. package/webapp/src/components/dashboard/TopQueuesTable.vue +53 -0
  85. package/webapp/src/components/layout/AppLayout.vue +110 -0
  86. package/webapp/src/components/layout/AppSidebar.vue +304 -0
  87. package/webapp/src/components/messages/MessageDetailPanel.vue +242 -0
  88. package/webapp/src/components/messages/MessageFilters.vue +114 -0
  89. package/webapp/src/components/queue-detail/PartitionList.vue +79 -0
  90. package/webapp/src/components/queue-detail/PushMessageModal.vue +175 -0
  91. package/webapp/src/components/queue-detail/QueueConfig.vue +63 -0
  92. package/webapp/src/components/queue-detail/QueueDetailHeader.vue +53 -0
  93. package/webapp/src/components/queue-detail/RecentMessages.vue +76 -0
  94. package/webapp/src/components/queues/CreateQueueModal.vue +193 -0
  95. package/webapp/src/components/queues/QueueFilters.vue +90 -0
  96. package/webapp/src/composables/useApi.js +34 -0
  97. package/webapp/src/composables/useTheme.js +36 -0
  98. package/webapp/src/main.js +11 -0
  99. package/webapp/src/router/index.js +42 -0
  100. package/webapp/src/utils/colors.js +96 -0
  101. package/webapp/src/utils/formatters.js +49 -0
  102. package/webapp/src/views/Analytics.vue +377 -0
  103. package/webapp/src/views/ConsumerGroups.vue +433 -0
  104. package/webapp/src/views/Dashboard.vue +418 -0
  105. package/webapp/src/views/Messages.vue +361 -0
  106. package/webapp/src/views/QueueDetail.vue +582 -0
  107. package/webapp/src/views/Queues.vue +496 -0
  108. package/webapp/tailwind.config.js +25 -0
  109. package/webapp/vite.config.js +10 -0
  110. package/dashboard/.vscode/extensions.json +0 -3
  111. package/dashboard/README.md +0 -5
  112. package/dashboard/index.html +0 -14
  113. package/dashboard/package-lock.json +0 -1458
  114. package/dashboard/package.json +0 -25
  115. package/dashboard/public/vite.svg +0 -1
  116. package/dashboard/src/App.vue +0 -29
  117. package/dashboard/src/assets/styles/main.css +0 -908
  118. package/dashboard/src/assets/vue.svg +0 -1
  119. package/dashboard/src/components/cards/MetricCard.vue +0 -298
  120. package/dashboard/src/components/charts/QueueDepthChart.vue +0 -276
  121. package/dashboard/src/components/charts/QueueLagChart.vue +0 -436
  122. package/dashboard/src/components/charts/ThroughputChart.vue +0 -302
  123. package/dashboard/src/components/common/ActivityFeed.vue +0 -251
  124. package/dashboard/src/components/layout/AppHeader.vue +0 -208
  125. package/dashboard/src/components/layout/AppLayout.vue +0 -88
  126. package/dashboard/src/components/layout/AppSidebar.vue +0 -261
  127. package/dashboard/src/main.js +0 -44
  128. package/dashboard/src/router.js +0 -54
  129. package/dashboard/src/services/api.js +0 -187
  130. package/dashboard/src/services/websocket.js +0 -167
  131. package/dashboard/src/utils/constants.js +0 -56
  132. package/dashboard/src/utils/helpers.js +0 -118
  133. package/dashboard/src/views/Analytics.vue +0 -912
  134. package/dashboard/src/views/Dashboard.vue +0 -906
  135. package/dashboard/src/views/Messages.vue +0 -437
  136. package/dashboard/src/views/QueueDetail.vue +0 -501
  137. package/dashboard/src/views/Queues.vue +0 -333
  138. package/dashboard/vite.config.js +0 -30
  139. package/src/routes/analytics.js +0 -812
@@ -5,37 +5,66 @@ import config from '../config.js';
5
5
  const EVICTION_INTERVAL = config.JOBS.EVICTION_INTERVAL;
6
6
 
7
7
  // Evict messages that exceeded max wait time for a queue
8
+ // Note: Eviction is already handled by POP query filtering old messages
9
+ // This service optionally moves them to DLQ for visibility
8
10
  const evictMessages = async (client, queueName, maxWaitTimeSeconds) => {
9
11
  if (!maxWaitTimeSeconds || maxWaitTimeSeconds <= 0) return 0;
10
12
 
11
- // For the new schema, we need to update or create status entries for evicted messages
12
- const result = await client.query(`
13
- WITH queue_partitions AS (
14
- SELECT p.id
15
- FROM queen.partitions p
16
- JOIN queen.queues q ON p.queue_id = q.id
17
- WHERE q.name = $1
18
- ),
19
- eligible_messages AS (
20
- SELECT m.id
21
- FROM queen.messages m
22
- JOIN queue_partitions qp ON m.partition_id = qp.id
23
- LEFT JOIN queen.messages_status ms ON m.id = ms.message_id AND ms.consumer_group IS NULL
24
- WHERE m.created_at < NOW() - INTERVAL '1 second' * $2
25
- AND (ms.status = 'pending' OR ms.status IS NULL)
26
- )
27
- INSERT INTO queen.messages_status (message_id, consumer_group, status, completed_at, error_message)
28
- SELECT id, NULL, 'evicted', NOW(), 'Message exceeded maximum wait time'
29
- FROM eligible_messages
30
- ON CONFLICT (message_id, consumer_group)
31
- DO UPDATE SET
32
- status = 'evicted',
33
- completed_at = NOW(),
34
- error_message = 'Message exceeded maximum wait time'
35
- RETURNING message_id
36
- `, [queueName, maxWaitTimeSeconds]);
37
-
38
- return result.rowCount || 0;
13
+ try {
14
+ // Find old messages that haven't been consumed and move to DLQ
15
+ // Use a WHERE EXISTS check to ensure message still exists before inserting into DLQ
16
+ const result = await client.query(`
17
+ WITH queue_partitions AS (
18
+ SELECT p.id, p.queue_id
19
+ FROM queen.partitions p
20
+ JOIN queen.queues q ON p.queue_id = q.id
21
+ WHERE q.name = $1
22
+ ),
23
+ old_unconsumed_messages AS (
24
+ SELECT m.id, m.partition_id, m.created_at
25
+ FROM queen.messages m
26
+ JOIN queue_partitions qp ON m.partition_id = qp.id
27
+ LEFT JOIN queen.partition_consumers pc ON pc.partition_id = m.partition_id
28
+ AND pc.consumer_group = '__QUEUE_MODE__'
29
+ WHERE m.created_at < NOW() - INTERVAL '1 second' * $2
30
+ -- Message has not been consumed yet
31
+ AND ((m.created_at, m.id) > (COALESCE(pc.last_consumed_created_at, '1970-01-01'::timestamptz),
32
+ COALESCE(pc.last_consumed_id, '00000000-0000-0000-0000-000000000000'::uuid))
33
+ OR pc.last_consumed_id IS NULL)
34
+ )
35
+ INSERT INTO queen.dead_letter_queue (
36
+ message_id,
37
+ partition_id,
38
+ consumer_group,
39
+ error_message,
40
+ original_created_at
41
+ )
42
+ SELECT
43
+ om.id,
44
+ om.partition_id,
45
+ '__QUEUE_MODE__',
46
+ 'Message exceeded maximum wait time',
47
+ om.created_at
48
+ FROM old_unconsumed_messages om
49
+ WHERE EXISTS (
50
+ SELECT 1 FROM queen.messages m WHERE m.id = om.id
51
+ )
52
+ ON CONFLICT DO NOTHING
53
+ RETURNING message_id
54
+ `, [queueName, maxWaitTimeSeconds]);
55
+
56
+ return result.rowCount || 0;
57
+ } catch (error) {
58
+ // Catch foreign key violations (messages deleted between SELECT and INSERT)
59
+ if (error.code === '23503') {
60
+ // Foreign key constraint violation - message was deleted
61
+ // This is a harmless race condition, just log it
62
+ log(`${LogTypes.EVICTION} | Queue: ${queueName} | Warning: Message deleted during eviction (race condition)`);
63
+ return 0;
64
+ }
65
+ // Re-throw other errors
66
+ throw error;
67
+ }
39
68
  };
40
69
 
41
70
  // Evict messages during pop operation (inline eviction)
@@ -16,30 +16,44 @@ const retentionQueue = async (client, queue) => {
16
16
 
17
17
  let totalDeleted = 0;
18
18
 
19
- // Delete old pending messages from all partitions in this queue
19
+ // Delete old unconsumed messages from all partitions in this queue
20
20
  if (retentionSeconds > 0) {
21
21
  const result = await client.query(`
22
- DELETE FROM queen.messages
23
- WHERE partition_id IN (
22
+ DELETE FROM queen.messages m
23
+ WHERE m.partition_id IN (
24
24
  SELECT id FROM queen.partitions WHERE queue_id = $1
25
25
  )
26
- AND status = 'pending'
27
- AND created_at < NOW() - INTERVAL '1 second' * $2
26
+ AND m.created_at < NOW() - INTERVAL '1 second' * $2
27
+ AND NOT EXISTS (
28
+ -- Message has been consumed by at least one consumer
29
+ SELECT 1
30
+ FROM queen.partition_consumers pc
31
+ WHERE pc.partition_id = m.partition_id
32
+ AND (m.created_at, m.id) <= (pc.last_consumed_created_at, pc.last_consumed_id)
33
+ )
28
34
  RETURNING id
29
35
  `, [queue.id, retentionSeconds]);
30
36
 
31
37
  totalDeleted += result.rowCount || 0;
32
38
  }
33
39
 
34
- // Delete completed/failed/evicted messages from all partitions in this queue
40
+ // Delete old consumed messages from all partitions in this queue
41
+ // (messages consumed by ALL consumer groups on that partition)
35
42
  if (completedRetentionSeconds > 0) {
36
43
  const result = await client.query(`
37
- DELETE FROM queen.messages
38
- WHERE partition_id IN (
44
+ DELETE FROM queen.messages m
45
+ WHERE m.partition_id IN (
39
46
  SELECT id FROM queen.partitions WHERE queue_id = $1
40
47
  )
41
- AND status IN ('completed', 'failed', 'evicted')
42
- AND COALESCE(completed_at, failed_at, created_at) < NOW() - INTERVAL '1 second' * $2
48
+ AND m.created_at < NOW() - INTERVAL '1 second' * $2
49
+ AND NOT EXISTS (
50
+ -- No consumer group that hasn't consumed this message yet
51
+ SELECT 1
52
+ FROM queen.partition_consumers pc
53
+ WHERE pc.partition_id = m.partition_id
54
+ AND ((m.created_at, m.id) > (pc.last_consumed_created_at, pc.last_consumed_id)
55
+ OR pc.last_consumed_id IS NULL)
56
+ )
43
57
  RETURNING id
44
58
  `, [queue.id, completedRetentionSeconds]);
45
59
 
@@ -48,7 +62,6 @@ const retentionQueue = async (client, queue) => {
48
62
 
49
63
  // Log retention if messages were deleted
50
64
  if (totalDeleted > 0) {
51
- // Note: We might need to update retention_history table to use queue_id instead
52
65
  log(`${LogTypes.RETENTION} | Queue: ${queue.name} | Count: ${totalDeleted} | RetentionSeconds: ${retentionSeconds} | CompletedRetentionSeconds: ${completedRetentionSeconds}`);
53
66
  }
54
67
 
@@ -76,6 +89,23 @@ const cleanupEmptyPartitions = async (client) => {
76
89
  return result.rowCount;
77
90
  };
78
91
 
92
+ // Clean up old metrics data (messages_consumed table)
93
+ const cleanupOldMetrics = async (client) => {
94
+ const retentionDays = config.JOBS.METRICS_RETENTION_DAYS;
95
+
96
+ // Delete metrics data older than the retention period
97
+ const result = await client.query(`
98
+ DELETE FROM queen.messages_consumed
99
+ WHERE acked_at < NOW() - INTERVAL '1 day' * $1
100
+ `, [retentionDays]);
101
+
102
+ if (result.rowCount > 0) {
103
+ log(`📊 Deleted ${result.rowCount} old metrics records (older than ${retentionDays} days)`);
104
+ }
105
+
106
+ return result.rowCount;
107
+ };
108
+
79
109
  // Main retention function
80
110
  const performRetention = async (pool) => {
81
111
  const client = await pool.connect();
@@ -97,6 +127,9 @@ const performRetention = async (pool) => {
97
127
  // Cleanup empty partitions
98
128
  await cleanupEmptyPartitions(client);
99
129
 
130
+ // Cleanup old metrics data
131
+ await cleanupOldMetrics(client);
132
+
100
133
  return totalDeleted;
101
134
  } catch (error) {
102
135
  log('Retention error:', error);
@@ -217,12 +217,12 @@ export async function testDeadLetterQueuePattern(client) {
217
217
 
218
218
  for (const msg of messages) {
219
219
  if (msg.data.shouldFail) {
220
- // Check retry count
220
+ // Check if message is in DLQ
221
221
  const retryCheck = await dbPool.query(`
222
- SELECT ms.retry_count
223
- FROM queen.messages_status ms
224
- JOIN queen.messages m ON ms.message_id = m.id
225
- WHERE m.transaction_id = $1 AND ms.consumer_group = '__QUEUE_MODE__'
222
+ SELECT dlq.retry_count
223
+ FROM queen.dead_letter_queue dlq
224
+ JOIN queen.messages m ON dlq.message_id = m.id
225
+ WHERE m.transaction_id = $1 AND dlq.consumer_group = '__QUEUE_MODE__'
226
226
  `, [msg.transactionId]);
227
227
 
228
228
  const retryCount = retryCheck.rows[0]?.retry_count || 0;
@@ -320,24 +320,37 @@ export async function testConsumerGroupIsolation(client) {
320
320
  }
321
321
  }
322
322
 
323
- // Verify isolation: Check that group1's first message is completed for group1 but failed for group2
323
+ // Verify isolation: Check that group1's first message is completed (cursor advanced) and group2 failed (in DLQ)
324
324
  const statusCheck = await dbPool.query(`
325
- SELECT ms.status, ms.consumer_group
326
- FROM queen.messages_status ms
327
- JOIN queen.messages m ON ms.message_id = m.id
325
+ SELECT
326
+ m.id as message_id,
327
+ m.transaction_id,
328
+ CASE
329
+ WHEN dlq1.id IS NOT NULL THEN 'failed'
330
+ WHEN m.id <= COALESCE(pc1.last_consumed_id, '00000000-0000-0000-0000-000000000000'::uuid) THEN 'completed'
331
+ ELSE 'pending'
332
+ END as group1_status,
333
+ CASE
334
+ WHEN dlq2.id IS NOT NULL THEN 'failed'
335
+ WHEN m.id <= COALESCE(pc2.last_consumed_id, '00000000-0000-0000-0000-000000000000'::uuid) THEN 'completed'
336
+ ELSE 'pending'
337
+ END as group2_status
338
+ FROM queen.messages m
339
+ LEFT JOIN queen.partition_consumers pc1 ON pc1.partition_id = m.partition_id AND pc1.consumer_group = 'group1'
340
+ LEFT JOIN queen.partition_consumers pc2 ON pc2.partition_id = m.partition_id AND pc2.consumer_group = 'group2'
341
+ LEFT JOIN queen.dead_letter_queue dlq1 ON dlq1.message_id = m.id AND dlq1.consumer_group = 'group1'
342
+ LEFT JOIN queen.dead_letter_queue dlq2 ON dlq2.message_id = m.id AND dlq2.consumer_group = 'group2'
328
343
  WHERE m.transaction_id = $1
329
- ORDER BY ms.consumer_group
330
344
  `, [group1Messages[0].transactionId]);
331
345
 
332
- const group1Status = statusCheck.rows.find(r => r.consumer_group === 'group1');
333
- const group2Status = statusCheck.rows.find(r => r.consumer_group === 'group2');
346
+ const result = statusCheck.rows[0];
334
347
 
335
- if (group1Status?.status !== 'completed') {
336
- throw new Error(`Group1 status should be completed, got ${group1Status?.status}`);
348
+ if (result?.group1_status !== 'completed') {
349
+ throw new Error(`Group1 status should be completed, got ${result?.group1_status}`);
337
350
  }
338
351
 
339
- if (group2Status?.status !== 'failed') {
340
- throw new Error(`Group2 status should be failed, got ${group2Status?.status}`);
352
+ if (result?.group2_status !== 'failed') {
353
+ throw new Error(`Group2 status should be failed, got ${result?.group2_status}`);
341
354
  }
342
355
 
343
356
  passTest('Consumer groups are properly isolated');
@@ -268,12 +268,19 @@ export async function testLeaseExpiration(client) {
268
268
  // Wait for lease to expire (2 seconds + buffer)
269
269
  await sleep(2500);
270
270
 
271
- // Check message status in database
271
+ // Check partition lease status in database
272
272
  const statusCheck = await dbPool.query(`
273
- SELECT status, lease_expires_at
274
- FROM queen.messages_status ms
275
- JOIN queen.messages m ON ms.message_id = m.id
276
- WHERE m.transaction_id = $1 AND ms.consumer_group = '__QUEUE_MODE__'
273
+ SELECT pc.lease_expires_at,
274
+ CASE
275
+ WHEN (m.created_at, m.id) <= (pc.last_consumed_created_at, COALESCE(pc.last_consumed_id, '00000000-0000-0000-0000-000000000000'::uuid))
276
+ THEN 'completed'
277
+ WHEN pc.lease_expires_at IS NOT NULL AND pc.lease_expires_at > NOW()
278
+ THEN 'processing'
279
+ ELSE 'pending'
280
+ END as status
281
+ FROM queen.messages m
282
+ LEFT JOIN queen.partition_consumers pc ON pc.partition_id = m.partition_id AND pc.consumer_group = '__QUEUE_MODE__'
283
+ WHERE m.transaction_id = $1
277
284
  `, [firstTransactionId]);
278
285
 
279
286
  if (statusCheck.rows.length > 0) {
@@ -184,13 +184,16 @@ export async function testMessageEviction(client) {
184
184
  timestamp: Date.now()
185
185
  });
186
186
 
187
- // Verify message exists
187
+ // Verify message exists and is pending
188
188
  const pendingCheck = await dbPool.query(`
189
- SELECT m.id, m.created_at, ms.status
189
+ SELECT m.id, m.created_at,
190
+ pc.last_consumed_id, pc.last_consumed_created_at,
191
+ dlq.id as dlq_id
190
192
  FROM queen.messages m
191
193
  JOIN queen.partitions p ON m.partition_id = p.id
192
194
  JOIN queen.queues q ON p.queue_id = q.id
193
- LEFT JOIN queen.messages_status ms ON m.id = ms.message_id AND ms.consumer_group = '__QUEUE_MODE__'
195
+ LEFT JOIN queen.partition_consumers pc ON pc.partition_id = p.id AND pc.consumer_group = '__QUEUE_MODE__'
196
+ LEFT JOIN queen.dead_letter_queue dlq ON dlq.message_id = m.id AND dlq.consumer_group = '__QUEUE_MODE__'
194
197
  WHERE q.name = $1 AND p.name = 'eviction-partition'
195
198
  ORDER BY m.created_at DESC
196
199
  LIMIT 1
@@ -200,7 +203,14 @@ export async function testMessageEviction(client) {
200
203
  throw new Error('Message not found');
201
204
  }
202
205
 
203
- const status = pendingCheck.rows[0].status || 'pending';
206
+ // Derive status from cursor position
207
+ const msg = pendingCheck.rows[0];
208
+ const isDLQ = msg.dlq_id != null;
209
+ const isConsumed = msg.last_consumed_id &&
210
+ ((msg.created_at < msg.last_consumed_created_at) ||
211
+ (msg.created_at.getTime() === msg.last_consumed_created_at?.getTime() && msg.id <= msg.last_consumed_id));
212
+ const status = isDLQ ? 'dead_letter' : (isConsumed ? 'completed' : 'pending');
213
+
204
214
  if (status !== 'pending') {
205
215
  throw new Error(`Message not in pending status, got: ${status}`);
206
216
  }
@@ -215,13 +225,14 @@ export async function testMessageEviction(client) {
215
225
  gotMessage = true;
216
226
  }
217
227
 
218
- // Check if message was evicted
228
+ // Check if message was evicted (moved to DLQ)
219
229
  const evictionCheck = await dbPool.query(`
220
- SELECT m.id, ms.status, ms.error_message
230
+ SELECT m.id, dlq.error_message,
231
+ CASE WHEN dlq.id IS NOT NULL THEN 'evicted' ELSE 'pending' END as status
221
232
  FROM queen.messages m
222
233
  JOIN queen.partitions p ON m.partition_id = p.id
223
234
  JOIN queen.queues q ON p.queue_id = q.id
224
- LEFT JOIN queen.messages_status ms ON m.id = ms.message_id AND ms.consumer_group = '__QUEUE_MODE__'
235
+ LEFT JOIN queen.dead_letter_queue dlq ON dlq.message_id = m.id AND dlq.consumer_group = '__QUEUE_MODE__'
225
236
  WHERE q.name = $1 AND p.name = 'eviction-partition'
226
237
  ORDER BY m.created_at DESC
227
238
  LIMIT 1
@@ -382,16 +393,32 @@ export async function testRetentionCompletedMessages(client) {
382
393
  throw new Error('Failed to take message for completion');
383
394
  }
384
395
 
385
- // Verify message is marked as completed
396
+ // Verify message is marked as completed (cursor-based check using UUIDv7 ordering)
386
397
  const statusCheck = await dbPool.query(`
387
- SELECT ms.status
388
- FROM queen.messages_status ms
389
- JOIN queen.messages m ON ms.message_id = m.id
390
- WHERE m.transaction_id = $1 AND ms.consumer_group = '__QUEUE_MODE__'
398
+ SELECT
399
+ m.id, m.created_at,
400
+ pc.last_consumed_id, pc.last_consumed_created_at,
401
+ CASE
402
+ WHEN pc.last_consumed_id IS NOT NULL AND m.id <= pc.last_consumed_id
403
+ THEN 'completed'
404
+ ELSE 'pending'
405
+ END as status
406
+ FROM queen.messages m
407
+ LEFT JOIN queen.partition_consumers pc
408
+ ON pc.partition_id = m.partition_id
409
+ AND pc.consumer_group = '__QUEUE_MODE__'
410
+ WHERE m.transaction_id = $1
391
411
  `, [transactionId]);
392
412
 
393
413
  if (!statusCheck.rows[0] || statusCheck.rows[0].status !== 'completed') {
394
- throw new Error('Message not marked as completed');
414
+ const row = statusCheck.rows[0];
415
+ throw new Error(
416
+ `Message not marked as completed.\n` +
417
+ ` Status: ${row?.status}\n` +
418
+ ` Message ID: ${row?.id}\n` +
419
+ ` Cursor ID: ${row?.last_consumed_id}\n` +
420
+ ` Has cursor: ${row?.last_consumed_id != null}`
421
+ );
395
422
  }
396
423
 
397
424
  // Wait for retention period
@@ -405,9 +432,15 @@ export async function testRetentionCompletedMessages(client) {
405
432
 
406
433
  // Check if completed message still exists
407
434
  const retainedCheck = await dbPool.query(`
408
- SELECT m.id, ms.status, ms.completed_at
435
+ SELECT m.id,
436
+ CASE
437
+ WHEN (m.created_at, m.id) <= (pc.last_consumed_created_at, COALESCE(pc.last_consumed_id, '00000000-0000-0000-0000-000000000000'::uuid))
438
+ THEN 'completed'
439
+ ELSE 'pending'
440
+ END as status,
441
+ pc.last_consumed_at as completed_at
409
442
  FROM queen.messages m
410
- LEFT JOIN queen.messages_status ms ON m.id = ms.message_id AND ms.consumer_group = '__QUEUE_MODE__'
443
+ LEFT JOIN queen.partition_consumers pc ON pc.partition_id = m.partition_id AND pc.consumer_group = '__QUEUE_MODE__'
411
444
  WHERE m.transaction_id = $1
412
445
  `, [transactionId]);
413
446
 
package/src/test/utils.js CHANGED
@@ -6,7 +6,7 @@ import pg from 'pg';
6
6
 
7
7
  // Test configuration
8
8
  export const TEST_CONFIG = {
9
- baseUrls: ['http://localhost:6632', 'http://localhost:6633'],
9
+ baseUrls: ['http://localhost:6632'],
10
10
  dbConfig: {
11
11
  host: process.env.PG_HOST || 'localhost',
12
12
  port: process.env.PG_PORT || 5432,
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Streaming utilities for uWebSockets.js with backpressure handling
3
+ *
4
+ * When sending large responses (>1MB), we need to handle backpressure to prevent:
5
+ * - Event loop blocking
6
+ * - Memory buildup
7
+ * - Slow client stalling the server
8
+ *
9
+ * uWebSockets.js provides efficient backpressure handling via:
10
+ * - res.write() - Returns false if buffer is full
11
+ * - res.onWritable() - Called when buffer has space again
12
+ * - res.cork() - Batches syscalls for efficiency
13
+ */
14
+
15
+ import { log } from './logger.js';
16
+
17
+ // Thresholds for streaming decisions
18
+ const SMALL_RESPONSE_THRESHOLD = 128 * 1024; // 128KB - send directly
19
+ const CHUNK_SIZE = 64 * 1024; // 64KB - stream in chunks
20
+
21
+ /**
22
+ * Send JSON response with automatic backpressure handling
23
+ *
24
+ * For small responses (<128KB): Sends directly via res.end()
25
+ * For large responses (>=128KB): Streams with backpressure via res.write() + res.onWritable()
26
+ *
27
+ * @param {Object} res - uWebSockets.js response object
28
+ * @param {Object} data - Data to serialize and send
29
+ * @param {Object} abortedRef - Reference object with 'aborted' flag
30
+ * @param {Object} options - Optional settings
31
+ * @returns {boolean} - True if sent successfully, false if aborted
32
+ */
33
+ export const streamJSON = (res, data, abortedRef = null, options = {}) => {
34
+ // Check if already aborted
35
+ if (abortedRef && abortedRef.aborted) {
36
+ return false;
37
+ }
38
+
39
+ const jsonStr = JSON.stringify(data);
40
+ const dataSize = jsonStr.length;
41
+
42
+ // Small response - send directly (fast path)
43
+ if (dataSize < SMALL_RESPONSE_THRESHOLD) {
44
+ try {
45
+ res.cork(() => {
46
+ res.end(jsonStr);
47
+ });
48
+ return true;
49
+ } catch (error) {
50
+ log(`Error sending small response: ${error.message}`);
51
+ return false;
52
+ }
53
+ }
54
+
55
+ // Large response - stream with backpressure handling
56
+ // uWebSockets.js requires all writes to be synchronous within cork callbacks
57
+ log(`Streaming large response: ${(dataSize / 1024 / 1024).toFixed(2)}MB`);
58
+
59
+ let offset = 0;
60
+
61
+ // Start sending - write as much as possible in first cork
62
+ res.cork(() => {
63
+ // Write chunks until buffer is full or done
64
+ while (offset < dataSize) {
65
+ const chunkEnd = Math.min(offset + CHUNK_SIZE, dataSize);
66
+ const chunk = jsonStr.slice(offset, chunkEnd);
67
+ const isLastChunk = chunkEnd >= dataSize;
68
+
69
+ if (isLastChunk) {
70
+ // Last chunk - end the response
71
+ res.end(chunk);
72
+ log(`Streamed response complete: ${(dataSize / 1024).toFixed(2)}KB sent`);
73
+ return; // All done!
74
+ }
75
+
76
+ const ok = res.write(chunk);
77
+ offset = chunkEnd;
78
+
79
+ if (!ok) {
80
+ // Buffer full - stop and wait for drain
81
+ log(`Backpressure detected at offset ${offset}/${dataSize} (${((offset/dataSize)*100).toFixed(1)}%)`);
82
+ break; // Exit while loop, set up onWritable below
83
+ }
84
+
85
+ // Successfully wrote, continue to next chunk
86
+ }
87
+
88
+ // If we exited due to backpressure (offset < dataSize), set up handler
89
+ if (offset < dataSize) {
90
+ // Set up backpressure handler for remaining chunks
91
+ // onWritable is called when the socket buffer has space again
92
+ res.onWritable((writeOffset) => {
93
+ if (abortedRef && abortedRef.aborted) {
94
+ return true; // true = done, stop calling
95
+ }
96
+
97
+ // CRITICAL: All writes inside onWritable must also be corked!
98
+ let isDone = true; // Will be set to false if we need to continue
99
+
100
+ res.cork(() => {
101
+ // Write as many chunks as possible while buffer has space
102
+ while (offset < dataSize) {
103
+ const chunkEnd = Math.min(offset + CHUNK_SIZE, dataSize);
104
+ const chunk = jsonStr.slice(offset, chunkEnd);
105
+ const isLastChunk = chunkEnd >= dataSize;
106
+
107
+ if (isLastChunk) {
108
+ // Last chunk - end the response
109
+ res.end(chunk);
110
+ log(`Streamed response complete: ${(dataSize / 1024).toFixed(2)}KB sent`);
111
+ isDone = true; // true = done, stop calling onWritable
112
+ return;
113
+ }
114
+
115
+ const ok = res.write(chunk);
116
+ offset = chunkEnd;
117
+
118
+ if (!ok) {
119
+ // Buffer full again - stop and wait for next onWritable call
120
+ log(`Backpressure detected at offset ${offset}/${dataSize} (${((offset/dataSize)*100).toFixed(1)}%)`);
121
+ isDone = false; // false = not done, call me again when writable
122
+ return;
123
+ }
124
+
125
+ // Successfully wrote, try next chunk in this iteration
126
+ }
127
+
128
+ // If we get here, all chunks sent
129
+ isDone = true; // Done
130
+ });
131
+
132
+ return isDone;
133
+ });
134
+ }
135
+ });
136
+
137
+ return true;
138
+ };
139
+
140
+ /**
141
+ * Send a simple JSON response (for small data)
142
+ * Always uses res.cork() for efficiency
143
+ */
144
+ export const sendJSON = (res, data, statusCode = 200) => {
145
+ res.cork(() => {
146
+ res.writeStatus(statusCode.toString());
147
+ res.end(JSON.stringify(data));
148
+ });
149
+ };
150
+
151
+ /**
152
+ * Send error response with proper status code
153
+ * Note: CORS headers should be set by caller before calling this
154
+ */
155
+ export const sendError = (res, error, statusCode = 500) => {
156
+ res.cork(() => {
157
+ res.writeStatus(statusCode.toString());
158
+ res.end(JSON.stringify({
159
+ error: error.message || error,
160
+ code: error.code || 'INTERNAL_ERROR'
161
+ }));
162
+ });
163
+ };
164
+
165
+ /**
166
+ * Stream large array in chunks (alternative approach for very large arrays)
167
+ * Serializes chunks separately to avoid large JSON.stringify() blocking
168
+ */
169
+ export const streamJSONArray = (res, items, abortedRef = null) => {
170
+ if (abortedRef && abortedRef.aborted) return false;
171
+
172
+ const ITEMS_PER_CHUNK = 1000;
173
+ let sent = 0;
174
+
175
+ const sendNextChunk = () => {
176
+ if (abortedRef && abortedRef.aborted) return true;
177
+
178
+ const chunk = items.slice(sent, sent + ITEMS_PER_CHUNK);
179
+ if (chunk.length === 0) {
180
+ // All sent
181
+ res.end(']');
182
+ return true;
183
+ }
184
+
185
+ const isFirst = sent === 0;
186
+ const isLast = sent + chunk.length >= items.length;
187
+
188
+ // Build chunk JSON
189
+ let chunkStr;
190
+ if (isFirst) {
191
+ chunkStr = '[' + chunk.map(item => JSON.stringify(item)).join(',');
192
+ } else {
193
+ chunkStr = ',' + chunk.map(item => JSON.stringify(item)).join(',');
194
+ }
195
+
196
+ const ok = isLast
197
+ ? res.end(chunkStr + ']')
198
+ : res.write(chunkStr);
199
+
200
+ sent += chunk.length;
201
+
202
+ if (!ok) {
203
+ // Backpressure
204
+ res.onWritable(() => sendNextChunk());
205
+ return false;
206
+ }
207
+
208
+ if (!isLast) {
209
+ setImmediate(sendNextChunk);
210
+ }
211
+
212
+ return true;
213
+ };
214
+
215
+ res.cork(() => {
216
+ const ok = sendNextChunk();
217
+ if (!ok) {
218
+ res.onWritable(sendNextChunk);
219
+ }
220
+ });
221
+
222
+ return true;
223
+ };
224
+
225
+ export default {
226
+ streamJSON,
227
+ sendJSON,
228
+ sendError,
229
+ streamJSONArray
230
+ };
231
+
package/src/utils/uuid.js CHANGED
@@ -1,5 +1,5 @@
1
- import { v4 as uuidv4 } from 'uuid';
1
+ import { v7 as uuidv7 } from 'uuid';
2
2
 
3
3
  export const generateUUID = () => {
4
- return uuidv4();
4
+ return uuidv7();
5
5
  };
@@ -191,10 +191,17 @@ export const createWebSocketServer = (app, eventManager) => {
191
191
  try {
192
192
  const result = await pool.query(`
193
193
  SELECT
194
- (SELECT COUNT(*) FROM queen.messages_status WHERE status = 'pending' AND consumer_group IS NULL) as pending,
195
- (SELECT COUNT(*) FROM queen.messages_status WHERE status = 'processing' AND consumer_group IS NULL) as processing,
194
+ (SELECT COALESCE(SUM(pending_estimate), 0)::integer FROM queen.partition_consumers
195
+ WHERE consumer_group = '__QUEUE_MODE__') as pending,
196
+ (SELECT COUNT(*) FROM queen.partition_consumers
197
+ WHERE consumer_group = '__QUEUE_MODE__'
198
+ AND lease_expires_at IS NOT NULL
199
+ AND lease_expires_at > NOW()) as processing,
196
200
  (SELECT COUNT(*) FROM queen.messages WHERE created_at > NOW() - INTERVAL '1 minute') as recent_created,
197
- (SELECT COUNT(*) FROM queen.messages_status WHERE completed_at > NOW() - INTERVAL '1 minute' AND consumer_group IS NULL) as recent_completed
201
+ (SELECT COALESCE(SUM(CASE WHEN last_consumed_at > NOW() - INTERVAL '1 minute'
202
+ THEN total_batches_consumed ELSE 0 END), 0)::integer
203
+ FROM queen.partition_consumers
204
+ WHERE consumer_group = '__QUEUE_MODE__') as recent_completed
198
205
  `);
199
206
 
200
207
  const stats = result.rows[0];