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.
- package/API.md +1226 -0
- package/AUTH.md +2044 -0
- package/LICENSE.md +202 -0
- package/README.md +276 -53
- package/WEBAPP.md +1889 -0
- package/assets/dashboard-01.png +0 -0
- package/assets/queen-logo-blue.svg +210 -0
- package/assets/queen-logo-cyan.svg +210 -0
- package/assets/queen-logo-indigo.svg +210 -0
- package/assets/queen-logo-orange.svg +210 -0
- package/assets/queen-logo-pink.svg +210 -0
- package/assets/queen-logo-purple.svg +210 -0
- package/assets/queen-logo-rose.svg +239 -0
- package/assets/queen-logo.svg +263 -0
- package/examples/batch-processing.js +58 -0
- package/examples/test-dashboard-api.js +200 -0
- package/package.json +4 -2
- package/server.log +1 -0
- package/src/benchmark/consumer.js +207 -0
- package/src/benchmark/consumer_multi.js +216 -0
- package/src/benchmark/producer.js +75 -0
- package/src/benchmark/producer_multi.js +115 -0
- package/src/client/client.js +192 -16
- package/src/client/queenClient.js +5 -0
- package/src/cluster-server.js +242 -0
- package/src/config.js +19 -5
- package/src/database/connection.js +42 -16
- package/src/database/poolManager.js +7 -0
- package/src/database/schema-v2.sql +194 -130
- package/src/managers/queueManagerOptimized.js +823 -933
- package/src/managers/systemEventManager.js +8 -3
- package/src/routes/messages.js +127 -57
- package/src/routes/pop.js +27 -43
- package/src/routes/resources.js +61 -27
- package/src/routes/status.js +1037 -0
- package/src/server.js +308 -272
- package/src/services/evictionService.js +57 -28
- package/src/services/retentionService.js +44 -11
- package/src/test/advanced-pattern-tests.js +5 -5
- package/src/test/bus-mode-tests.js +24 -11
- package/src/test/edge-case-tests.js +12 -5
- package/src/test/enterprise-tests.js +48 -15
- package/src/test/utils.js +1 -1
- package/src/utils/streaming.js +231 -0
- package/src/utils/uuid.js +2 -2
- package/src/websocket/wsServer.js +10 -3
- package/test-keepalive-v2.sh +22 -0
- package/webapp/COLOR_GUIDE.md +118 -0
- package/webapp/README.md +143 -0
- package/webapp/index.html +14 -0
- package/webapp/package-lock.json +3184 -0
- package/webapp/package.json +25 -0
- package/webapp/postcss.config.js +7 -0
- package/webapp/public/assets/queen-logo-blue.svg +210 -0
- package/webapp/public/assets/queen-logo-cyan.svg +210 -0
- package/webapp/public/assets/queen-logo-indigo.svg +210 -0
- package/webapp/public/assets/queen-logo-orange.svg +210 -0
- package/webapp/public/assets/queen-logo-pink.svg +210 -0
- package/webapp/public/assets/queen-logo-purple.svg +210 -0
- package/webapp/public/assets/queen-logo-rose.svg +239 -0
- package/webapp/public/assets/queen-logo.svg +263 -0
- package/webapp/src/App.vue +19 -0
- package/webapp/src/api/analytics.js +10 -0
- package/webapp/src/api/client.js +29 -0
- package/webapp/src/api/consumers.js +52 -0
- package/webapp/src/api/health.js +7 -0
- package/webapp/src/api/messages.js +26 -0
- package/webapp/src/api/queues.js +14 -0
- package/webapp/src/api/resources.js +8 -0
- package/webapp/src/assets/styles/main.css +357 -0
- package/webapp/src/components/analytics/AnalyticsFilters.vue +87 -0
- package/webapp/src/components/analytics/AnalyticsMetrics.vue +57 -0
- package/webapp/src/components/analytics/MessageDistributionChart.vue +111 -0
- package/webapp/src/components/analytics/MessageFlowChart.vue +173 -0
- package/webapp/src/components/analytics/TimeRangeSelector.vue +27 -0
- package/webapp/src/components/analytics/TopQueuesChart.vue +132 -0
- package/webapp/src/components/common/ConfirmDialog.vue +56 -0
- package/webapp/src/components/common/LoadingSpinner.vue +6 -0
- package/webapp/src/components/common/MetricCard.vue +43 -0
- package/webapp/src/components/common/StatusBadge.vue +45 -0
- package/webapp/src/components/dashboard/MessageStatusCard.vue +50 -0
- package/webapp/src/components/dashboard/PerformanceCard.vue +38 -0
- package/webapp/src/components/dashboard/ThroughputChart.vue +182 -0
- package/webapp/src/components/dashboard/TopQueuesTable.vue +53 -0
- package/webapp/src/components/layout/AppLayout.vue +110 -0
- package/webapp/src/components/layout/AppSidebar.vue +304 -0
- package/webapp/src/components/messages/MessageDetailPanel.vue +242 -0
- package/webapp/src/components/messages/MessageFilters.vue +114 -0
- package/webapp/src/components/queue-detail/PartitionList.vue +79 -0
- package/webapp/src/components/queue-detail/PushMessageModal.vue +175 -0
- package/webapp/src/components/queue-detail/QueueConfig.vue +63 -0
- package/webapp/src/components/queue-detail/QueueDetailHeader.vue +53 -0
- package/webapp/src/components/queue-detail/RecentMessages.vue +76 -0
- package/webapp/src/components/queues/CreateQueueModal.vue +193 -0
- package/webapp/src/components/queues/QueueFilters.vue +90 -0
- package/webapp/src/composables/useApi.js +34 -0
- package/webapp/src/composables/useTheme.js +36 -0
- package/webapp/src/main.js +11 -0
- package/webapp/src/router/index.js +42 -0
- package/webapp/src/utils/colors.js +96 -0
- package/webapp/src/utils/formatters.js +49 -0
- package/webapp/src/views/Analytics.vue +377 -0
- package/webapp/src/views/ConsumerGroups.vue +433 -0
- package/webapp/src/views/Dashboard.vue +418 -0
- package/webapp/src/views/Messages.vue +361 -0
- package/webapp/src/views/QueueDetail.vue +582 -0
- package/webapp/src/views/Queues.vue +496 -0
- package/webapp/tailwind.config.js +25 -0
- package/webapp/vite.config.js +10 -0
- package/dashboard/.vscode/extensions.json +0 -3
- package/dashboard/README.md +0 -5
- package/dashboard/index.html +0 -14
- package/dashboard/package-lock.json +0 -1458
- package/dashboard/package.json +0 -25
- package/dashboard/public/vite.svg +0 -1
- package/dashboard/src/App.vue +0 -29
- package/dashboard/src/assets/styles/main.css +0 -908
- package/dashboard/src/assets/vue.svg +0 -1
- package/dashboard/src/components/cards/MetricCard.vue +0 -298
- package/dashboard/src/components/charts/QueueDepthChart.vue +0 -276
- package/dashboard/src/components/charts/QueueLagChart.vue +0 -436
- package/dashboard/src/components/charts/ThroughputChart.vue +0 -302
- package/dashboard/src/components/common/ActivityFeed.vue +0 -251
- package/dashboard/src/components/layout/AppHeader.vue +0 -208
- package/dashboard/src/components/layout/AppLayout.vue +0 -88
- package/dashboard/src/components/layout/AppSidebar.vue +0 -261
- package/dashboard/src/main.js +0 -44
- package/dashboard/src/router.js +0 -54
- package/dashboard/src/services/api.js +0 -187
- package/dashboard/src/services/websocket.js +0 -167
- package/dashboard/src/utils/constants.js +0 -56
- package/dashboard/src/utils/helpers.js +0 -118
- package/dashboard/src/views/Analytics.vue +0 -912
- package/dashboard/src/views/Dashboard.vue +0 -906
- package/dashboard/src/views/Messages.vue +0 -437
- package/dashboard/src/views/QueueDetail.vue +0 -501
- package/dashboard/src/views/Queues.vue +0 -333
- package/dashboard/vite.config.js +0 -30
- 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
220
|
+
// Check if message is in DLQ
|
|
221
221
|
const retryCheck = await dbPool.query(`
|
|
222
|
-
SELECT
|
|
223
|
-
FROM queen.
|
|
224
|
-
JOIN queen.messages m ON
|
|
225
|
-
WHERE m.transaction_id = $1 AND
|
|
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
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
333
|
-
const group2Status = statusCheck.rows.find(r => r.consumer_group === 'group2');
|
|
346
|
+
const result = statusCheck.rows[0];
|
|
334
347
|
|
|
335
|
-
if (
|
|
336
|
-
throw new Error(`Group1 status should be completed, got ${
|
|
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 (
|
|
340
|
-
throw new Error(`Group2 status should be failed, got ${
|
|
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
|
|
271
|
+
// Check partition lease status in database
|
|
272
272
|
const statusCheck = await dbPool.query(`
|
|
273
|
-
SELECT
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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'
|
|
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
|
@@ -191,10 +191,17 @@ export const createWebSocketServer = (app, eventManager) => {
|
|
|
191
191
|
try {
|
|
192
192
|
const result = await pool.query(`
|
|
193
193
|
SELECT
|
|
194
|
-
(SELECT
|
|
195
|
-
|
|
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
|
|
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];
|