queen-mq 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +1116 -0
- package/CACHE.md +519 -0
- package/DASHBOARD-V3.md +478 -0
- package/DASHBOARD.md +382 -0
- package/MOD_QUEUE.md +453 -0
- package/PARTITION_LOCKING_DESIGN.md +989 -0
- package/PLAN.md +707 -0
- package/QUERY_ANALSYS.md +72 -0
- package/QUEUE_BUS.md +334 -0
- package/README.md +1495 -0
- package/V2-PLAN.md +236 -0
- package/assets/dashboard.png +0 -0
- package/dashboard/.vscode/extensions.json +3 -0
- package/dashboard/README.md +5 -0
- package/dashboard/index.html +14 -0
- package/dashboard/package-lock.json +1458 -0
- package/dashboard/package.json +25 -0
- package/dashboard/public/vite.svg +1 -0
- package/dashboard/src/App.vue +29 -0
- package/dashboard/src/assets/styles/main.css +908 -0
- package/dashboard/src/assets/vue.svg +1 -0
- package/dashboard/src/components/cards/MetricCard.vue +298 -0
- package/dashboard/src/components/charts/QueueDepthChart.vue +276 -0
- package/dashboard/src/components/charts/QueueLagChart.vue +436 -0
- package/dashboard/src/components/charts/ThroughputChart.vue +302 -0
- package/dashboard/src/components/common/ActivityFeed.vue +251 -0
- package/dashboard/src/components/layout/AppHeader.vue +208 -0
- package/dashboard/src/components/layout/AppLayout.vue +88 -0
- package/dashboard/src/components/layout/AppSidebar.vue +261 -0
- package/dashboard/src/main.js +44 -0
- package/dashboard/src/router.js +54 -0
- package/dashboard/src/services/api.js +187 -0
- package/dashboard/src/services/websocket.js +167 -0
- package/dashboard/src/utils/constants.js +56 -0
- package/dashboard/src/utils/helpers.js +118 -0
- package/dashboard/src/views/Analytics.vue +912 -0
- package/dashboard/src/views/Dashboard.vue +906 -0
- package/dashboard/src/views/Messages.vue +437 -0
- package/dashboard/src/views/QueueDetail.vue +501 -0
- package/dashboard/src/views/Queues.vue +333 -0
- package/dashboard/vite.config.js +30 -0
- package/debug-namespace.js +110 -0
- package/docs/long-polling.md +159 -0
- package/docs/multi-server-cache-solutions.md +185 -0
- package/docs/performance-tuning.md +222 -0
- package/examples/bus-mode.js +239 -0
- package/examples/continuous-consumer-optimized.js +215 -0
- package/examples/continuous-consumer.js +159 -0
- package/examples/continuous-producer.js +343 -0
- package/examples/mixed-mode.js +277 -0
- package/examples/multi-server-test.js +305 -0
- package/examples/single.js +64 -0
- package/examples/smartchat-dealyed.js +42 -0
- package/examples/smartchat.js +52 -0
- package/examples/test-cache-invalidation.js +119 -0
- package/examples/test-cache-multi-server.js +245 -0
- package/examples/test-minimal-client.js +112 -0
- package/examples/test-queue-creation-policy.js +137 -0
- package/init-db.js +20 -0
- package/package.json +36 -0
- package/src/client/client.js +291 -0
- package/src/client/index.js +6 -0
- package/src/client/queenClient.js +513 -0
- package/src/client/utils/http.js +172 -0
- package/src/client/utils/loadBalancer.js +152 -0
- package/src/client/utils/retry.js +35 -0
- package/src/config.js +215 -0
- package/src/database/connection.js +103 -0
- package/src/database/poolManager.js +192 -0
- package/src/database/schema-v2.sql +214 -0
- package/src/managers/eventManager.js +59 -0
- package/src/managers/queueManagerOptimized.js +1512 -0
- package/src/managers/resourceCache.js +96 -0
- package/src/managers/systemEventManager.js +127 -0
- package/src/routes/ack.js +26 -0
- package/src/routes/analytics.js +812 -0
- package/src/routes/configure.js +46 -0
- package/src/routes/messages.js +298 -0
- package/src/routes/pop.js +85 -0
- package/src/routes/push.js +28 -0
- package/src/routes/resources.js +296 -0
- package/src/server.js +1286 -0
- package/src/services/encryptionService.js +82 -0
- package/src/services/evictionService.js +131 -0
- package/src/services/retentionService.js +129 -0
- package/src/services/startupSync.js +35 -0
- package/src/test/test.js +4521 -0
- package/src/utils/logger.js +44 -0
- package/src/utils/uuid.js +5 -0
- package/src/websocket/wsServer.js +221 -0
|
@@ -0,0 +1,1512 @@
|
|
|
1
|
+
import { generateUUID } from '../utils/uuid.js';
|
|
2
|
+
import { withTransaction } from '../database/connection.js';
|
|
3
|
+
import * as encryption from '../services/encryptionService.js';
|
|
4
|
+
import { log, LogTypes } from '../utils/logger.js';
|
|
5
|
+
import config from '../config.js';
|
|
6
|
+
import { EventTypes } from './systemEventManager.js';
|
|
7
|
+
|
|
8
|
+
export const createOptimizedQueueManager = (pool, resourceCache, eventManager, systemEventManager = null) => {
|
|
9
|
+
|
|
10
|
+
// Batch size for database operations
|
|
11
|
+
const BATCH_INSERT_SIZE = config.QUEUE.BATCH_INSERT_SIZE;
|
|
12
|
+
|
|
13
|
+
// Ensure queue exists and partition exist (with caching)
|
|
14
|
+
// Queue must be created via configure endpoint, but partitions can be created on-demand
|
|
15
|
+
const ensureResources = async (client, queueName, partitionName = 'Default', namespace = null, task = null) => {
|
|
16
|
+
// CRITICAL: System queues bypass cache completely
|
|
17
|
+
if (queueName.startsWith('__system_')) {
|
|
18
|
+
// Direct database query for system queues
|
|
19
|
+
const result = await client.query(`
|
|
20
|
+
SELECT
|
|
21
|
+
q.id as queue_id,
|
|
22
|
+
q.name as queue_name,
|
|
23
|
+
p.id as partition_id,
|
|
24
|
+
q.*
|
|
25
|
+
FROM queen.queues q
|
|
26
|
+
JOIN queen.partitions p ON p.queue_id = q.id
|
|
27
|
+
WHERE q.name = $1 AND p.name = $2
|
|
28
|
+
`, [queueName, partitionName]);
|
|
29
|
+
|
|
30
|
+
if (result.rows.length === 0) {
|
|
31
|
+
throw new Error(`System queue ${queueName} not found`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const row = result.rows[0];
|
|
35
|
+
return {
|
|
36
|
+
queueId: row.queue_id,
|
|
37
|
+
queueName: row.queue_name,
|
|
38
|
+
partitionId: row.partition_id,
|
|
39
|
+
queueConfig: {
|
|
40
|
+
leaseTime: row.lease_time,
|
|
41
|
+
retryLimit: row.retry_limit,
|
|
42
|
+
retryDelay: row.retry_delay,
|
|
43
|
+
maxSize: row.max_size,
|
|
44
|
+
ttl: row.ttl,
|
|
45
|
+
deadLetterQueue: row.dead_letter_queue,
|
|
46
|
+
dlqAfterMaxRetries: row.dlq_after_max_retries,
|
|
47
|
+
delayedProcessing: row.delayed_processing,
|
|
48
|
+
windowBuffer: row.window_buffer,
|
|
49
|
+
retentionSeconds: row.retention_seconds,
|
|
50
|
+
completedRetentionSeconds: row.completed_retention_seconds,
|
|
51
|
+
retentionEnabled: row.retention_enabled,
|
|
52
|
+
priority: row.priority,
|
|
53
|
+
maxQueueSize: row.max_queue_size
|
|
54
|
+
},
|
|
55
|
+
encryptionEnabled: false, // System queues never encrypted
|
|
56
|
+
maxWaitTimeSeconds: row.max_wait_time_seconds
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check cache first, but skip cache if we're updating namespace/task
|
|
61
|
+
const cacheKey = `${queueName}:${partitionName}`;
|
|
62
|
+
const cached = resourceCache.checkResource(queueName, partitionName);
|
|
63
|
+
if (cached && namespace === null && task === null) return cached;
|
|
64
|
+
|
|
65
|
+
// Get queue - don't create if it doesn't exist
|
|
66
|
+
const queueResult = await client.query(
|
|
67
|
+
`SELECT id, name, namespace, task, encryption_enabled, max_wait_time_seconds,
|
|
68
|
+
lease_time, retry_limit, retry_delay, max_size, ttl, dead_letter_queue,
|
|
69
|
+
dlq_after_max_retries, delayed_processing, window_buffer, retention_seconds,
|
|
70
|
+
completed_retention_seconds, retention_enabled, priority, max_queue_size
|
|
71
|
+
FROM queen.queues
|
|
72
|
+
WHERE name = $1`,
|
|
73
|
+
[queueName]
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (queueResult.rows.length === 0) {
|
|
77
|
+
// Queue doesn't exist - throw error instead of creating
|
|
78
|
+
const errorMsg = `Queue '${queueName}' does not exist. Please create it using the configure endpoint first.`;
|
|
79
|
+
log(`${LogTypes.ERROR} | ${errorMsg}`);
|
|
80
|
+
throw new Error(errorMsg);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const queue = queueResult.rows[0];
|
|
84
|
+
const queueId = queue.id;
|
|
85
|
+
|
|
86
|
+
// Insert or get partition (no more options column)
|
|
87
|
+
const partitionResult = await client.query(
|
|
88
|
+
`INSERT INTO queen.partitions (queue_id, name) VALUES ($1, $2)
|
|
89
|
+
ON CONFLICT (queue_id, name) DO UPDATE SET name = EXCLUDED.name
|
|
90
|
+
RETURNING id`,
|
|
91
|
+
[queueId, partitionName]
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const result = {
|
|
95
|
+
queueId,
|
|
96
|
+
queueName: queue.name,
|
|
97
|
+
namespace: queue.namespace,
|
|
98
|
+
task: queue.task,
|
|
99
|
+
partitionId: partitionResult.rows[0].id,
|
|
100
|
+
// All configuration now comes from queue level
|
|
101
|
+
queueConfig: {
|
|
102
|
+
leaseTime: queue.lease_time,
|
|
103
|
+
retryLimit: queue.retry_limit,
|
|
104
|
+
retryDelay: queue.retry_delay,
|
|
105
|
+
maxSize: queue.max_size,
|
|
106
|
+
ttl: queue.ttl,
|
|
107
|
+
deadLetterQueue: queue.dead_letter_queue,
|
|
108
|
+
dlqAfterMaxRetries: queue.dlq_after_max_retries,
|
|
109
|
+
delayedProcessing: queue.delayed_processing,
|
|
110
|
+
windowBuffer: queue.window_buffer,
|
|
111
|
+
retentionSeconds: queue.retention_seconds,
|
|
112
|
+
completedRetentionSeconds: queue.completed_retention_seconds,
|
|
113
|
+
retentionEnabled: queue.retention_enabled,
|
|
114
|
+
priority: queue.priority,
|
|
115
|
+
maxQueueSize: queue.max_queue_size
|
|
116
|
+
},
|
|
117
|
+
encryptionEnabled: queue.encryption_enabled,
|
|
118
|
+
maxWaitTimeSeconds: queue.max_wait_time_seconds
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Cache the result
|
|
122
|
+
resourceCache.cacheResource(queueName, partitionName, result);
|
|
123
|
+
|
|
124
|
+
return result;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Optimized batch insert for high throughput (simplified - no status)
|
|
128
|
+
const pushMessagesBatch = async (items) => {
|
|
129
|
+
// Group items by queue and partition for efficient resource lookup
|
|
130
|
+
const partitionGroups = {};
|
|
131
|
+
for (const item of items) {
|
|
132
|
+
const partition = item.partition ?? 'Default';
|
|
133
|
+
const key = `${item.queue}:${partition}`;
|
|
134
|
+
if (!partitionGroups[key]) {
|
|
135
|
+
partitionGroups[key] = [];
|
|
136
|
+
}
|
|
137
|
+
partitionGroups[key].push(item);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const allResults = [];
|
|
141
|
+
|
|
142
|
+
// Process each partition group
|
|
143
|
+
for (const [partitionKey, partitionItems] of Object.entries(partitionGroups)) {
|
|
144
|
+
const [queueName, partitionName] = partitionKey.split(':');
|
|
145
|
+
|
|
146
|
+
// Use transaction for better error handling with retry on stale cache
|
|
147
|
+
let retryCount = 0;
|
|
148
|
+
const maxRetries = 1;
|
|
149
|
+
|
|
150
|
+
while (retryCount <= maxRetries) {
|
|
151
|
+
try {
|
|
152
|
+
const results = await withTransaction(pool, async (client) => {
|
|
153
|
+
// Ensure resources exist (cached after first call)
|
|
154
|
+
const resources = await ensureResources(client, queueName, partitionName);
|
|
155
|
+
const { partitionId, encryptionEnabled, queueConfig } = resources;
|
|
156
|
+
|
|
157
|
+
// Check queue capacity if max_queue_size is set
|
|
158
|
+
if (queueConfig.maxQueueSize > 0) {
|
|
159
|
+
const capacityCheck = await client.query(
|
|
160
|
+
`SELECT COUNT(m.id) as current_depth
|
|
161
|
+
FROM queen.messages m
|
|
162
|
+
JOIN queen.partitions p ON m.partition_id = p.id
|
|
163
|
+
JOIN queen.queues q ON p.queue_id = q.id
|
|
164
|
+
LEFT JOIN queen.messages_status ms ON m.id = ms.message_id
|
|
165
|
+
AND ms.consumer_group = '__QUEUE_MODE__'
|
|
166
|
+
WHERE q.name = $1
|
|
167
|
+
AND (ms.status IS NULL OR ms.status IN ('pending', 'processing'))`,
|
|
168
|
+
[queueName]
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const currentDepth = parseInt(capacityCheck.rows[0].current_depth);
|
|
172
|
+
const batchSize = partitionItems.length;
|
|
173
|
+
if (currentDepth + batchSize > queueConfig.maxQueueSize) {
|
|
174
|
+
throw new Error(`Queue '${queueName}' would exceed max capacity (${queueConfig.maxQueueSize}). Current: ${currentDepth}, Batch: ${batchSize}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Process items in batches
|
|
179
|
+
const batchResults = [];
|
|
180
|
+
|
|
181
|
+
for (let i = 0; i < partitionItems.length; i += BATCH_INSERT_SIZE) {
|
|
182
|
+
const batch = partitionItems.slice(i, i + BATCH_INSERT_SIZE);
|
|
183
|
+
|
|
184
|
+
// Prepare batch data
|
|
185
|
+
const messageIds = [];
|
|
186
|
+
const transactionIds = [];
|
|
187
|
+
const traceIds = [];
|
|
188
|
+
const payloads = [];
|
|
189
|
+
const encryptedFlags = [];
|
|
190
|
+
const duplicates = [];
|
|
191
|
+
|
|
192
|
+
for (const item of batch) {
|
|
193
|
+
const messageId = generateUUID();
|
|
194
|
+
const transactionId = item.transactionId || generateUUID();
|
|
195
|
+
const traceId = item.traceId || null;
|
|
196
|
+
|
|
197
|
+
// Check for duplicate transaction ID
|
|
198
|
+
const dupCheck = await client.query(
|
|
199
|
+
'SELECT id FROM queen.messages WHERE transaction_id = $1',
|
|
200
|
+
[transactionId]
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
if (dupCheck.rows.length > 0) {
|
|
204
|
+
duplicates.push({
|
|
205
|
+
id: dupCheck.rows[0].id,
|
|
206
|
+
transactionId: transactionId,
|
|
207
|
+
status: 'duplicate'
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
messageIds.push(messageId);
|
|
213
|
+
transactionIds.push(transactionId);
|
|
214
|
+
traceIds.push(traceId);
|
|
215
|
+
|
|
216
|
+
// Handle encryption if enabled
|
|
217
|
+
let payload = item.payload;
|
|
218
|
+
let isEncrypted = false;
|
|
219
|
+
|
|
220
|
+
if (encryptionEnabled && encryption.isEncryptionEnabled()) {
|
|
221
|
+
try {
|
|
222
|
+
payload = await encryption.encryptPayload(item.payload);
|
|
223
|
+
isEncrypted = true;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
log(`ERROR: Encryption failed for message ${messageId}:`, error);
|
|
226
|
+
// Continue with unencrypted payload
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Handle large payloads and special cases
|
|
231
|
+
let jsonPayload;
|
|
232
|
+
try {
|
|
233
|
+
// Handle null/undefined payloads - convert to JSON null string
|
|
234
|
+
if (payload === null || payload === undefined) {
|
|
235
|
+
jsonPayload = 'null'; // JSONB expects the string 'null' for null values
|
|
236
|
+
} else {
|
|
237
|
+
// Try to stringify the payload
|
|
238
|
+
jsonPayload = JSON.stringify(payload);
|
|
239
|
+
}
|
|
240
|
+
} catch (error) {
|
|
241
|
+
// If JSON.stringify fails (e.g., circular references, too large),
|
|
242
|
+
// store as a string representation
|
|
243
|
+
log(`WARN: Failed to stringify payload for message ${messageId}:`, error.message);
|
|
244
|
+
jsonPayload = JSON.stringify({
|
|
245
|
+
error: 'Payload serialization failed',
|
|
246
|
+
type: typeof payload,
|
|
247
|
+
message: error.message
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
payloads.push(jsonPayload);
|
|
252
|
+
encryptedFlags.push(isEncrypted);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Batch insert messages with trace_id
|
|
256
|
+
if (messageIds.length > 0) {
|
|
257
|
+
const insertQuery = `
|
|
258
|
+
INSERT INTO queen.messages (id, transaction_id, trace_id, partition_id, payload, is_encrypted)
|
|
259
|
+
SELECT * FROM UNNEST($1::uuid[], $2::varchar[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::boolean[])
|
|
260
|
+
RETURNING id, transaction_id, trace_id
|
|
261
|
+
`;
|
|
262
|
+
|
|
263
|
+
let insertResult;
|
|
264
|
+
try {
|
|
265
|
+
insertResult = await client.query(insertQuery, [
|
|
266
|
+
messageIds,
|
|
267
|
+
transactionIds,
|
|
268
|
+
traceIds,
|
|
269
|
+
Array(messageIds.length).fill(partitionId),
|
|
270
|
+
payloads,
|
|
271
|
+
encryptedFlags
|
|
272
|
+
]);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
// If foreign key constraint error, the cached partition is stale
|
|
275
|
+
if (error.code === '23503' && error.constraint === 'messages_partition_id_fkey') {
|
|
276
|
+
// Invalidate cache and throw a more helpful error
|
|
277
|
+
resourceCache.invalidate(queueName, partitionName);
|
|
278
|
+
throw new Error(`Stale cache detected for queue '${queueName}' partition '${partitionName}'. Please retry the operation.`);
|
|
279
|
+
}
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Format results
|
|
284
|
+
for (const row of insertResult.rows) {
|
|
285
|
+
batchResults.push({
|
|
286
|
+
id: row.id,
|
|
287
|
+
transactionId: row.transaction_id,
|
|
288
|
+
traceId: row.trace_id,
|
|
289
|
+
status: 'queued'
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Add duplicates to results
|
|
295
|
+
batchResults.push(...duplicates);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Notify event manager about new messages
|
|
299
|
+
const queuePath = `${queueName}/${partitionName}`;
|
|
300
|
+
eventManager.notifyMessageAvailable(queuePath);
|
|
301
|
+
|
|
302
|
+
return batchResults;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
allResults.push(...results);
|
|
306
|
+
break; // Success, exit retry loop
|
|
307
|
+
} catch (error) {
|
|
308
|
+
// Check if it's a stale cache error
|
|
309
|
+
if (error.message && error.message.includes('Stale cache detected') && retryCount < maxRetries) {
|
|
310
|
+
retryCount++;
|
|
311
|
+
// Cache already invalidated in the error handler, just retry
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
throw error; // Re-throw if not a stale cache error or max retries exceeded
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return allResults;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// Helper function to ensure consumer group exists
|
|
323
|
+
const ensureConsumerGroup = async (client, queueId, consumerGroup, subscriptionMode, subscriptionFrom) => {
|
|
324
|
+
// Handle special subscription values
|
|
325
|
+
let effectiveSubscriptionFrom = subscriptionFrom;
|
|
326
|
+
if (subscriptionFrom === 'now' || subscriptionMode === 'new' || subscriptionMode === 'new-only') {
|
|
327
|
+
effectiveSubscriptionFrom = new Date();
|
|
328
|
+
} else if (!subscriptionFrom || subscriptionFrom === 'all') {
|
|
329
|
+
effectiveSubscriptionFrom = null; // Will consume all messages
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const result = await client.query(`
|
|
333
|
+
INSERT INTO queen.consumer_groups (queue_id, name, subscription_start_from)
|
|
334
|
+
VALUES ($1, $2, $3)
|
|
335
|
+
ON CONFLICT (queue_id, name) DO UPDATE SET
|
|
336
|
+
subscription_start_from = CASE
|
|
337
|
+
WHEN queen.consumer_groups.subscription_start_from IS NULL
|
|
338
|
+
THEN EXCLUDED.subscription_start_from
|
|
339
|
+
ELSE queen.consumer_groups.subscription_start_from
|
|
340
|
+
END,
|
|
341
|
+
last_seen_at = NOW()
|
|
342
|
+
RETURNING *
|
|
343
|
+
`, [queueId, consumerGroup, effectiveSubscriptionFrom]);
|
|
344
|
+
|
|
345
|
+
return result.rows[0];
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Helper function for eviction on pop
|
|
349
|
+
const evictOnPop = async (client, queueName) => {
|
|
350
|
+
const evictionResult = await client.query(`
|
|
351
|
+
WITH queue_config AS (
|
|
352
|
+
SELECT id, max_wait_time_seconds
|
|
353
|
+
FROM queen.queues
|
|
354
|
+
WHERE name = $1 AND max_wait_time_seconds > 0
|
|
355
|
+
),
|
|
356
|
+
evicted AS (
|
|
357
|
+
DELETE FROM queen.messages
|
|
358
|
+
WHERE id IN (
|
|
359
|
+
SELECT m.id
|
|
360
|
+
FROM queen.messages m
|
|
361
|
+
JOIN queen.partitions p ON m.partition_id = p.id
|
|
362
|
+
JOIN queue_config qc ON p.queue_id = qc.id
|
|
363
|
+
WHERE m.created_at < NOW() - INTERVAL '1 second' * qc.max_wait_time_seconds
|
|
364
|
+
)
|
|
365
|
+
RETURNING id
|
|
366
|
+
)
|
|
367
|
+
SELECT COUNT(*) as count FROM evicted
|
|
368
|
+
`, [queueName]);
|
|
369
|
+
|
|
370
|
+
if (evictionResult.rows[0].count > 0) {
|
|
371
|
+
log(`${LogTypes.POP} | EVICTION | Queue: ${queueName} | Count: ${evictionResult.rows[0].count}`);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Pop messages with simplified transaction-based approach
|
|
377
|
+
* This is a drop-in replacement for the original popMessages function
|
|
378
|
+
*/
|
|
379
|
+
const popMessagesV2 = async (scope, options = {}) => {
|
|
380
|
+
const { queue, partition, consumerGroup } = scope;
|
|
381
|
+
const {
|
|
382
|
+
wait = false,
|
|
383
|
+
timeout = config.QUEUE.DEFAULT_TIMEOUT,
|
|
384
|
+
batch = config.QUEUE.DEFAULT_BATCH_SIZE,
|
|
385
|
+
subscriptionMode = null,
|
|
386
|
+
subscriptionFrom = null
|
|
387
|
+
} = options;
|
|
388
|
+
|
|
389
|
+
return withTransaction(pool, async (client) => {
|
|
390
|
+
// Set transaction isolation level to prevent dirty reads
|
|
391
|
+
await client.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
|
|
392
|
+
|
|
393
|
+
// Step 1: Reclaim expired leases
|
|
394
|
+
const reclaimResult = await client.query(`
|
|
395
|
+
UPDATE queen.messages_status
|
|
396
|
+
SET status = 'pending',
|
|
397
|
+
lease_expires_at = NULL,
|
|
398
|
+
worker_id = NULL,
|
|
399
|
+
processing_at = NULL
|
|
400
|
+
WHERE status = 'processing'
|
|
401
|
+
AND lease_expires_at < NOW()
|
|
402
|
+
AND consumer_group = $1
|
|
403
|
+
RETURNING message_id
|
|
404
|
+
`, [consumerGroup || '__QUEUE_MODE__']);
|
|
405
|
+
|
|
406
|
+
if (reclaimResult.rowCount > 0) {
|
|
407
|
+
log(`Reclaimed ${reclaimResult.rowCount} expired leases`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Step 2: Evict expired messages
|
|
411
|
+
await evictOnPop(client, queue);
|
|
412
|
+
|
|
413
|
+
// Step 3: Get queue information
|
|
414
|
+
const queueResult = await client.query(
|
|
415
|
+
'SELECT * FROM queen.queues WHERE name = $1',
|
|
416
|
+
[queue]
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
if (queueResult.rows.length === 0) {
|
|
420
|
+
return { messages: [] };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const queueInfo = queueResult.rows[0];
|
|
424
|
+
|
|
425
|
+
// Step 4: Handle consumer group if specified
|
|
426
|
+
let consumerGroupInfo = null;
|
|
427
|
+
if (consumerGroup) {
|
|
428
|
+
consumerGroupInfo = await ensureConsumerGroup(
|
|
429
|
+
client,
|
|
430
|
+
queueInfo.id,
|
|
431
|
+
consumerGroup,
|
|
432
|
+
subscriptionMode,
|
|
433
|
+
subscriptionFrom
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const actualConsumerGroup = consumerGroup || '__QUEUE_MODE__';
|
|
438
|
+
let subscriptionStart = consumerGroupInfo?.subscription_start_from;
|
|
439
|
+
|
|
440
|
+
// Handle subscription start date
|
|
441
|
+
if (consumerGroup) {
|
|
442
|
+
if (!subscriptionStart) {
|
|
443
|
+
subscriptionStart = '1970-01-01';
|
|
444
|
+
} else if (typeof subscriptionStart === 'object' && subscriptionStart instanceof Date) {
|
|
445
|
+
subscriptionStart = subscriptionStart.toISOString();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Step 5: Get or create partition
|
|
450
|
+
let partitionInfo;
|
|
451
|
+
if (partition) {
|
|
452
|
+
// Specific partition requested
|
|
453
|
+
const partitionResult = await client.query(`
|
|
454
|
+
SELECT p.* FROM queen.partitions p
|
|
455
|
+
WHERE p.queue_id = $1 AND p.name = $2
|
|
456
|
+
`, [queueInfo.id, partition]);
|
|
457
|
+
|
|
458
|
+
if (partitionResult.rows.length === 0) {
|
|
459
|
+
// Create partition if it doesn't exist
|
|
460
|
+
const createPartitionResult = await client.query(`
|
|
461
|
+
INSERT INTO queen.partitions (id, queue_id, name)
|
|
462
|
+
VALUES ($1, $2, $3)
|
|
463
|
+
ON CONFLICT (queue_id, name) DO UPDATE SET name = EXCLUDED.name
|
|
464
|
+
RETURNING *
|
|
465
|
+
`, [generateUUID(), queueInfo.id, partition]);
|
|
466
|
+
partitionInfo = createPartitionResult.rows[0];
|
|
467
|
+
} else {
|
|
468
|
+
partitionInfo = partitionResult.rows[0];
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
// Find any available partition with messages that is not currently leased
|
|
472
|
+
// This applies to both QUEUE MODE and BUS MODE for partition isolation
|
|
473
|
+
const partitionResult = await client.query(`
|
|
474
|
+
SELECT DISTINCT p.*
|
|
475
|
+
FROM queen.partitions p
|
|
476
|
+
JOIN queen.messages m ON m.partition_id = p.id
|
|
477
|
+
LEFT JOIN queen.messages_status ms ON m.id = ms.message_id
|
|
478
|
+
AND ms.consumer_group = $2
|
|
479
|
+
LEFT JOIN queen.partition_leases pl ON p.id = pl.partition_id
|
|
480
|
+
AND pl.consumer_group = $2
|
|
481
|
+
AND pl.released_at IS NULL
|
|
482
|
+
AND pl.lease_expires_at > NOW()
|
|
483
|
+
WHERE p.queue_id = $1
|
|
484
|
+
AND (ms.id IS NULL OR ms.status IN ('pending', 'failed'))
|
|
485
|
+
AND pl.id IS NULL -- No active lease for this partition
|
|
486
|
+
ORDER BY p.created_at
|
|
487
|
+
LIMIT 1
|
|
488
|
+
`, [queueInfo.id, actualConsumerGroup]);
|
|
489
|
+
|
|
490
|
+
if (partitionResult.rows.length === 0) {
|
|
491
|
+
// No partitions with available messages, check if we need to create default
|
|
492
|
+
const anyPartitionResult = await client.query(`
|
|
493
|
+
SELECT * FROM queen.partitions WHERE queue_id = $1 LIMIT 1
|
|
494
|
+
`, [queueInfo.id]);
|
|
495
|
+
|
|
496
|
+
if (anyPartitionResult.rows.length === 0) {
|
|
497
|
+
// Create default partition
|
|
498
|
+
const createPartitionResult = await client.query(`
|
|
499
|
+
INSERT INTO queen.partitions (id, queue_id, name)
|
|
500
|
+
VALUES ($1, $2, 'Default')
|
|
501
|
+
ON CONFLICT (queue_id, name) DO UPDATE SET name = EXCLUDED.name
|
|
502
|
+
RETURNING *
|
|
503
|
+
`, [generateUUID(), queueInfo.id]);
|
|
504
|
+
partitionInfo = createPartitionResult.rows[0];
|
|
505
|
+
} else {
|
|
506
|
+
return { messages: [] };
|
|
507
|
+
}
|
|
508
|
+
} else {
|
|
509
|
+
partitionInfo = partitionResult.rows[0];
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Step 6: Try to acquire or verify partition lease (for both QUEUE MODE and BUS MODE)
|
|
514
|
+
// This ensures partition isolation between consumers in the same group
|
|
515
|
+
|
|
516
|
+
// First, try to acquire a new lease or check if we can take over an expired one
|
|
517
|
+
const leaseResult = await client.query(`
|
|
518
|
+
INSERT INTO queen.partition_leases (
|
|
519
|
+
partition_id,
|
|
520
|
+
consumer_group,
|
|
521
|
+
lease_expires_at
|
|
522
|
+
) VALUES ($1, $2, NOW() + INTERVAL '1 second' * $3)
|
|
523
|
+
ON CONFLICT (partition_id, consumer_group)
|
|
524
|
+
DO UPDATE SET
|
|
525
|
+
lease_expires_at = CASE
|
|
526
|
+
WHEN queen.partition_leases.released_at IS NOT NULL
|
|
527
|
+
OR queen.partition_leases.lease_expires_at <= NOW()
|
|
528
|
+
THEN EXCLUDED.lease_expires_at
|
|
529
|
+
ELSE queen.partition_leases.lease_expires_at
|
|
530
|
+
END,
|
|
531
|
+
released_at = CASE
|
|
532
|
+
WHEN queen.partition_leases.released_at IS NOT NULL
|
|
533
|
+
OR queen.partition_leases.lease_expires_at <= NOW()
|
|
534
|
+
THEN NULL
|
|
535
|
+
ELSE queen.partition_leases.released_at
|
|
536
|
+
END
|
|
537
|
+
RETURNING
|
|
538
|
+
lease_expires_at,
|
|
539
|
+
(lease_expires_at = NOW() + INTERVAL '1 second' * $3) as acquired
|
|
540
|
+
`, [partitionInfo.id, actualConsumerGroup, queueInfo.lease_time]);
|
|
541
|
+
|
|
542
|
+
// Check if we actually acquired the lease
|
|
543
|
+
if (!leaseResult.rows[0].acquired) {
|
|
544
|
+
// Another consumer still has an active lease on this partition
|
|
545
|
+
return { messages: [] };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Step 7: Build WHERE clause based on mode
|
|
549
|
+
let whereClause;
|
|
550
|
+
let queryParams;
|
|
551
|
+
|
|
552
|
+
if (consumerGroup) {
|
|
553
|
+
// BUS MODE with consumer group
|
|
554
|
+
whereClause = `
|
|
555
|
+
m.partition_id = $2
|
|
556
|
+
AND (ms.id IS NULL OR ms.status IN ('pending', 'failed'))
|
|
557
|
+
AND m.created_at <= NOW() - INTERVAL '1 second' * $3
|
|
558
|
+
AND m.created_at >= $4::timestamp
|
|
559
|
+
AND ($5 = 0 OR m.created_at > NOW() - INTERVAL '1 second' * $5)
|
|
560
|
+
`;
|
|
561
|
+
queryParams = [
|
|
562
|
+
actualConsumerGroup,
|
|
563
|
+
partitionInfo.id,
|
|
564
|
+
queueInfo.delayed_processing || 0,
|
|
565
|
+
subscriptionStart,
|
|
566
|
+
queueInfo.max_wait_time_seconds || 0,
|
|
567
|
+
batch
|
|
568
|
+
];
|
|
569
|
+
} else {
|
|
570
|
+
// QUEUE MODE
|
|
571
|
+
whereClause = `
|
|
572
|
+
m.partition_id = $2
|
|
573
|
+
AND (ms.id IS NULL OR ms.status IN ('pending', 'failed'))
|
|
574
|
+
AND m.created_at <= NOW() - INTERVAL '1 second' * $3
|
|
575
|
+
AND ($4 = 0 OR m.created_at > NOW() - INTERVAL '1 second' * $4)
|
|
576
|
+
AND ($5 = 0 OR NOT EXISTS (
|
|
577
|
+
SELECT 1 FROM queen.messages m2
|
|
578
|
+
WHERE m2.partition_id = p.id
|
|
579
|
+
AND m2.created_at > NOW() - INTERVAL '1 second' * $5
|
|
580
|
+
))
|
|
581
|
+
`;
|
|
582
|
+
queryParams = [
|
|
583
|
+
actualConsumerGroup,
|
|
584
|
+
partitionInfo.id,
|
|
585
|
+
queueInfo.delayed_processing || 0,
|
|
586
|
+
queueInfo.max_wait_time_seconds || 0,
|
|
587
|
+
queueInfo.window_buffer || 0,
|
|
588
|
+
batch
|
|
589
|
+
];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Step 8: Select and lock messages
|
|
593
|
+
const messagesResult = await client.query(`
|
|
594
|
+
SELECT
|
|
595
|
+
m.id,
|
|
596
|
+
m.transaction_id,
|
|
597
|
+
m.trace_id,
|
|
598
|
+
m.payload,
|
|
599
|
+
m.is_encrypted,
|
|
600
|
+
m.created_at,
|
|
601
|
+
p.name as partition_name,
|
|
602
|
+
q.name as queue_name,
|
|
603
|
+
q.priority,
|
|
604
|
+
COALESCE(ms.retry_count, 0) as retry_count
|
|
605
|
+
FROM queen.messages m
|
|
606
|
+
JOIN queen.partitions p ON m.partition_id = p.id
|
|
607
|
+
JOIN queen.queues q ON p.queue_id = q.id
|
|
608
|
+
LEFT JOIN queen.messages_status ms ON m.id = ms.message_id
|
|
609
|
+
AND ms.consumer_group = $1
|
|
610
|
+
WHERE ${whereClause}
|
|
611
|
+
ORDER BY m.created_at ASC
|
|
612
|
+
LIMIT $6
|
|
613
|
+
FOR UPDATE OF m NOWAIT
|
|
614
|
+
`, queryParams).catch(err => {
|
|
615
|
+
if (err.code === '55P03') { // Lock not available
|
|
616
|
+
return { rows: [] };
|
|
617
|
+
}
|
|
618
|
+
throw err;
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const messages = messagesResult.rows;
|
|
622
|
+
|
|
623
|
+
if (messages.length === 0) {
|
|
624
|
+
// Release partition lease if no messages
|
|
625
|
+
await client.query(`
|
|
626
|
+
UPDATE queen.partition_leases
|
|
627
|
+
SET released_at = NOW()
|
|
628
|
+
WHERE partition_id = $1 AND consumer_group = $2
|
|
629
|
+
`, [partitionInfo.id, actualConsumerGroup]);
|
|
630
|
+
return { messages: [] };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Step 9: Update or insert message status for selected messages
|
|
634
|
+
const leaseExpiresAt = new Date(Date.now() + (queueInfo.lease_time * 1000));
|
|
635
|
+
|
|
636
|
+
for (const message of messages) {
|
|
637
|
+
await client.query(`
|
|
638
|
+
INSERT INTO queen.messages_status (
|
|
639
|
+
message_id,
|
|
640
|
+
consumer_group,
|
|
641
|
+
status,
|
|
642
|
+
lease_expires_at,
|
|
643
|
+
processing_at,
|
|
644
|
+
worker_id,
|
|
645
|
+
retry_count
|
|
646
|
+
) VALUES ($1, $2, 'processing', $3, NOW(), $4, $5)
|
|
647
|
+
ON CONFLICT (message_id, consumer_group)
|
|
648
|
+
DO UPDATE SET
|
|
649
|
+
status = 'processing',
|
|
650
|
+
lease_expires_at = EXCLUDED.lease_expires_at,
|
|
651
|
+
processing_at = NOW(),
|
|
652
|
+
worker_id = EXCLUDED.worker_id
|
|
653
|
+
-- Keep existing retry_count when re-processing failed messages
|
|
654
|
+
`, [
|
|
655
|
+
message.id,
|
|
656
|
+
actualConsumerGroup,
|
|
657
|
+
leaseExpiresAt,
|
|
658
|
+
config.WORKER_ID,
|
|
659
|
+
message.retry_count || 0
|
|
660
|
+
]);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Step 10: Update partition lease with message batch
|
|
664
|
+
if (messages.length > 0) {
|
|
665
|
+
const messageIds = messages.map(m => m.id);
|
|
666
|
+
await client.query(`
|
|
667
|
+
UPDATE queen.partition_leases
|
|
668
|
+
SET message_batch = $1::jsonb
|
|
669
|
+
WHERE partition_id = $2 AND consumer_group = $3
|
|
670
|
+
`, [JSON.stringify(messageIds), partitionInfo.id, actualConsumerGroup]);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Step 11: Decrypt payloads if needed and format response
|
|
674
|
+
const decryptedMessages = await Promise.all(messages.map(async (msg) => {
|
|
675
|
+
let payload = msg.payload;
|
|
676
|
+
if (msg.is_encrypted && encryption.isEncryptionEnabled()) {
|
|
677
|
+
try {
|
|
678
|
+
payload = await encryption.decryptPayload(msg.payload);
|
|
679
|
+
} catch (error) {
|
|
680
|
+
log(`${LogTypes.ERROR} | Failed to decrypt message ${msg.transaction_id}: ${error.message}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return {
|
|
685
|
+
id: msg.id,
|
|
686
|
+
transactionId: msg.transaction_id,
|
|
687
|
+
traceId: msg.trace_id,
|
|
688
|
+
queue: msg.queue_name,
|
|
689
|
+
partition: msg.partition_name,
|
|
690
|
+
data: payload,
|
|
691
|
+
payload: payload,
|
|
692
|
+
retryCount: msg.retry_count || 0,
|
|
693
|
+
priority: msg.priority || 0,
|
|
694
|
+
createdAt: msg.created_at,
|
|
695
|
+
consumerGroup: consumerGroup || null
|
|
696
|
+
};
|
|
697
|
+
}));
|
|
698
|
+
|
|
699
|
+
log(`${LogTypes.POP} | Count: ${decryptedMessages.length} | ConsumerGroup: ${actualConsumerGroup === '__QUEUE_MODE__' ? 'QUEUE_MODE' : actualConsumerGroup}`);
|
|
700
|
+
|
|
701
|
+
return { messages: decryptedMessages };
|
|
702
|
+
});
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
const popMessages = async (scope, options = {}) => {
|
|
707
|
+
return popMessagesV2(scope, options);
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// Acknowledge messages (now per consumer group)
|
|
711
|
+
const acknowledgeMessage = async (transactionId, status = 'completed', error = null, consumerGroup = null) => {
|
|
712
|
+
return withTransaction(pool, async (client) => {
|
|
713
|
+
// Find the message and status entry (must be in 'processing' status to be acknowledged)
|
|
714
|
+
const findQuery = consumerGroup
|
|
715
|
+
? `SELECT ms.retry_count, ms.status, ms.message_id, m.partition_id,
|
|
716
|
+
q.retry_limit, q.dlq_after_max_retries
|
|
717
|
+
FROM queen.messages_status ms
|
|
718
|
+
JOIN queen.messages m ON ms.message_id = m.id
|
|
719
|
+
JOIN queen.partitions p ON m.partition_id = p.id
|
|
720
|
+
JOIN queen.queues q ON p.queue_id = q.id
|
|
721
|
+
WHERE m.transaction_id = $1 AND ms.consumer_group = $2 AND ms.status = 'processing'`
|
|
722
|
+
: `SELECT ms.retry_count, ms.status, ms.message_id, m.partition_id,
|
|
723
|
+
q.retry_limit, q.dlq_after_max_retries
|
|
724
|
+
FROM queen.messages_status ms
|
|
725
|
+
JOIN queen.messages m ON ms.message_id = m.id
|
|
726
|
+
JOIN queen.partitions p ON m.partition_id = p.id
|
|
727
|
+
JOIN queen.queues q ON p.queue_id = q.id
|
|
728
|
+
WHERE m.transaction_id = $1 AND ms.consumer_group = '__QUEUE_MODE__' AND ms.status = 'processing'`;
|
|
729
|
+
|
|
730
|
+
const params = consumerGroup ? [transactionId, consumerGroup] : [transactionId];
|
|
731
|
+
const result = await client.query(findQuery, params);
|
|
732
|
+
|
|
733
|
+
if (result.rows.length === 0) {
|
|
734
|
+
log(`WARN: Message not found for acknowledgment: ${transactionId}, consumerGroup: ${consumerGroup || 'QUEUE_MODE'}`);
|
|
735
|
+
|
|
736
|
+
// Try to find the message regardless of status for debugging
|
|
737
|
+
const debugQuery = consumerGroup
|
|
738
|
+
? `SELECT ms.retry_count, ms.status FROM queen.messages_status ms
|
|
739
|
+
JOIN queen.messages m ON ms.message_id = m.id
|
|
740
|
+
WHERE m.transaction_id = $1 AND ms.consumer_group = $2`
|
|
741
|
+
: `SELECT ms.retry_count, ms.status FROM queen.messages_status ms
|
|
742
|
+
JOIN queen.messages m ON ms.message_id = m.id
|
|
743
|
+
WHERE m.transaction_id = $1 AND ms.consumer_group = '__QUEUE_MODE__'`;
|
|
744
|
+
const debugResult = await client.query(debugQuery, params);
|
|
745
|
+
if (debugResult.rows.length > 0) {
|
|
746
|
+
log(`DEBUG: Found message with status=${debugResult.rows[0].status}, retry_count=${debugResult.rows[0].retry_count}`);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return { status: 'not_found', transaction_id: transactionId };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const messageStatus = result.rows[0];
|
|
753
|
+
log(`DEBUG: Found message for ack with retry_count=${messageStatus.retry_count}, status=${messageStatus.status}, dlq=${messageStatus.dlq_after_max_retries}, limit=${messageStatus.retry_limit}`);
|
|
754
|
+
|
|
755
|
+
if (status === 'completed') {
|
|
756
|
+
// Mark as completed and release partition lease
|
|
757
|
+
const actualConsumerGroup = consumerGroup || '__QUEUE_MODE__';
|
|
758
|
+
const updateParams = consumerGroup
|
|
759
|
+
? [transactionId, consumerGroup]
|
|
760
|
+
: [transactionId];
|
|
761
|
+
|
|
762
|
+
const updateQuery = consumerGroup
|
|
763
|
+
? `UPDATE queen.messages_status ms
|
|
764
|
+
SET status = 'completed', completed_at = NOW()
|
|
765
|
+
FROM queen.messages m
|
|
766
|
+
WHERE ms.message_id = m.id
|
|
767
|
+
AND m.transaction_id = $1
|
|
768
|
+
AND ms.consumer_group = $2`
|
|
769
|
+
: `UPDATE queen.messages_status ms
|
|
770
|
+
SET status = 'completed', completed_at = NOW()
|
|
771
|
+
FROM queen.messages m
|
|
772
|
+
WHERE ms.message_id = m.id
|
|
773
|
+
AND m.transaction_id = $1
|
|
774
|
+
AND ms.consumer_group = '__QUEUE_MODE__'`;
|
|
775
|
+
|
|
776
|
+
await client.query(updateQuery, updateParams);
|
|
777
|
+
|
|
778
|
+
// Release partition lease if this was the last message in the batch
|
|
779
|
+
const releaseQuery = `
|
|
780
|
+
WITH message_partition AS (
|
|
781
|
+
-- Get the partition for this message
|
|
782
|
+
SELECT DISTINCT m.partition_id
|
|
783
|
+
FROM queen.messages m
|
|
784
|
+
WHERE m.transaction_id = $1
|
|
785
|
+
),
|
|
786
|
+
check_remaining AS (
|
|
787
|
+
-- Check if there are any other processing messages in this partition
|
|
788
|
+
SELECT
|
|
789
|
+
mp.partition_id,
|
|
790
|
+
COUNT(ms.message_id) as processing_count
|
|
791
|
+
FROM message_partition mp
|
|
792
|
+
LEFT JOIN queen.messages m ON m.partition_id = mp.partition_id
|
|
793
|
+
LEFT JOIN queen.messages_status ms ON ms.message_id = m.id
|
|
794
|
+
AND ms.consumer_group = $2
|
|
795
|
+
AND ms.status = 'processing'
|
|
796
|
+
GROUP BY mp.partition_id
|
|
797
|
+
)
|
|
798
|
+
-- Release the lease only if no messages are processing
|
|
799
|
+
UPDATE queen.partition_leases pl
|
|
800
|
+
SET released_at = NOW()
|
|
801
|
+
FROM check_remaining cr
|
|
802
|
+
WHERE pl.partition_id = cr.partition_id
|
|
803
|
+
AND pl.consumer_group = $2
|
|
804
|
+
AND pl.released_at IS NULL
|
|
805
|
+
AND cr.processing_count = 0`;
|
|
806
|
+
|
|
807
|
+
await client.query(releaseQuery, [transactionId, actualConsumerGroup]);
|
|
808
|
+
|
|
809
|
+
log(`${LogTypes.ACK} | TransactionId: ${transactionId} | Status: completed | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'}`);
|
|
810
|
+
} else if (status === 'failed') {
|
|
811
|
+
// Handle failure with retry logic
|
|
812
|
+
const actualConsumerGroup = consumerGroup || '__QUEUE_MODE__';
|
|
813
|
+
const currentRetryCount = messageStatus.retry_count || 0;
|
|
814
|
+
const nextRetryCount = currentRetryCount + 1;
|
|
815
|
+
const retryLimit = messageStatus.retry_limit || config.QUEUE.DEFAULT_RETRY_LIMIT;
|
|
816
|
+
const dlqEnabled = messageStatus.dlq_after_max_retries;
|
|
817
|
+
|
|
818
|
+
log(`${LogTypes.ACK} | Retry check: current=${currentRetryCount}, next=${nextRetryCount}, limit=${retryLimit}, dlq=${dlqEnabled}`);
|
|
819
|
+
|
|
820
|
+
if (nextRetryCount > retryLimit && dlqEnabled) {
|
|
821
|
+
// Move to dead letter queue after exceeding retry limit
|
|
822
|
+
const updateParams = consumerGroup
|
|
823
|
+
? [transactionId, error, consumerGroup]
|
|
824
|
+
: [transactionId, error];
|
|
825
|
+
|
|
826
|
+
const updateQuery = consumerGroup
|
|
827
|
+
? `UPDATE queen.messages_status ms
|
|
828
|
+
SET status = 'dead_letter', failed_at = NOW(), error_message = $2
|
|
829
|
+
FROM queen.messages m
|
|
830
|
+
WHERE ms.message_id = m.id
|
|
831
|
+
AND m.transaction_id = $1
|
|
832
|
+
AND ms.consumer_group = $3`
|
|
833
|
+
: `UPDATE queen.messages_status ms
|
|
834
|
+
SET status = 'dead_letter', failed_at = NOW(), error_message = $2
|
|
835
|
+
FROM queen.messages m
|
|
836
|
+
WHERE ms.message_id = m.id
|
|
837
|
+
AND m.transaction_id = $1
|
|
838
|
+
AND ms.consumer_group = '__QUEUE_MODE__'`;
|
|
839
|
+
|
|
840
|
+
await client.query(updateQuery, updateParams);
|
|
841
|
+
|
|
842
|
+
log(`${LogTypes.ACK} | TransactionId: ${transactionId} | Status: dead_letter | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'} | Error: ${error}`);
|
|
843
|
+
|
|
844
|
+
return { status: 'dead_letter', transaction_id: transactionId };
|
|
845
|
+
} else {
|
|
846
|
+
// Mark as failed and increment retry count
|
|
847
|
+
const updateParams = consumerGroup
|
|
848
|
+
? [transactionId, error, consumerGroup]
|
|
849
|
+
: [transactionId, error];
|
|
850
|
+
|
|
851
|
+
const updateQuery = consumerGroup
|
|
852
|
+
? `UPDATE queen.messages_status ms
|
|
853
|
+
SET status = 'failed',
|
|
854
|
+
failed_at = NOW(),
|
|
855
|
+
error_message = $2,
|
|
856
|
+
lease_expires_at = NULL,
|
|
857
|
+
retry_count = COALESCE(retry_count, 0) + 1
|
|
858
|
+
FROM queen.messages m
|
|
859
|
+
WHERE ms.message_id = m.id
|
|
860
|
+
AND m.transaction_id = $1
|
|
861
|
+
AND ms.consumer_group = $3`
|
|
862
|
+
: `UPDATE queen.messages_status ms
|
|
863
|
+
SET status = 'failed',
|
|
864
|
+
failed_at = NOW(),
|
|
865
|
+
error_message = $2,
|
|
866
|
+
lease_expires_at = NULL,
|
|
867
|
+
retry_count = COALESCE(retry_count, 0) + 1
|
|
868
|
+
FROM queen.messages m
|
|
869
|
+
WHERE ms.message_id = m.id
|
|
870
|
+
AND m.transaction_id = $1
|
|
871
|
+
AND ms.consumer_group = '__QUEUE_MODE__'`;
|
|
872
|
+
|
|
873
|
+
await client.query(updateQuery, updateParams);
|
|
874
|
+
|
|
875
|
+
// Release partition lease on failure as well (so partition can be retried)
|
|
876
|
+
const releaseOnFailQuery = `
|
|
877
|
+
WITH message_partition AS (
|
|
878
|
+
SELECT DISTINCT m.partition_id
|
|
879
|
+
FROM queen.messages m
|
|
880
|
+
WHERE m.transaction_id = $1
|
|
881
|
+
),
|
|
882
|
+
check_remaining AS (
|
|
883
|
+
SELECT
|
|
884
|
+
mp.partition_id,
|
|
885
|
+
COUNT(ms.message_id) as processing_count
|
|
886
|
+
FROM message_partition mp
|
|
887
|
+
LEFT JOIN queen.messages m ON m.partition_id = mp.partition_id
|
|
888
|
+
LEFT JOIN queen.messages_status ms ON ms.message_id = m.id
|
|
889
|
+
AND ms.consumer_group = $2
|
|
890
|
+
AND ms.status = 'processing'
|
|
891
|
+
GROUP BY mp.partition_id
|
|
892
|
+
)
|
|
893
|
+
UPDATE queen.partition_leases pl
|
|
894
|
+
SET released_at = NOW()
|
|
895
|
+
FROM check_remaining cr
|
|
896
|
+
WHERE pl.partition_id = cr.partition_id
|
|
897
|
+
AND pl.consumer_group = $2
|
|
898
|
+
AND pl.released_at IS NULL
|
|
899
|
+
AND cr.processing_count = 0`;
|
|
900
|
+
|
|
901
|
+
await client.query(releaseOnFailQuery, [transactionId, actualConsumerGroup]);
|
|
902
|
+
|
|
903
|
+
log(`${LogTypes.ACK} | TransactionId: ${transactionId} | Status: failed (retry ${nextRetryCount}/${retryLimit}) | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'} | Error: ${error}`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
return { status, transaction_id: transactionId };
|
|
908
|
+
});
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
// Batch acknowledge messages
|
|
912
|
+
const acknowledgeMessages = async (acknowledgments, consumerGroup = null) => {
|
|
913
|
+
const results = [];
|
|
914
|
+
|
|
915
|
+
// Group by status for efficient processing
|
|
916
|
+
const grouped = {
|
|
917
|
+
completed: [],
|
|
918
|
+
failed: []
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
acknowledgments.forEach(ack => {
|
|
922
|
+
if (grouped[ack.status]) {
|
|
923
|
+
grouped[ack.status].push(ack);
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
await withTransaction(pool, async (client) => {
|
|
928
|
+
// Process completed messages
|
|
929
|
+
if (grouped.completed.length > 0) {
|
|
930
|
+
const ids = grouped.completed.map(a => a.transactionId);
|
|
931
|
+
const actualConsumerGroup = consumerGroup || '__QUEUE_MODE__';
|
|
932
|
+
|
|
933
|
+
const updateQuery = consumerGroup
|
|
934
|
+
? `UPDATE queen.messages_status ms
|
|
935
|
+
SET status = 'completed', completed_at = NOW()
|
|
936
|
+
FROM queen.messages m
|
|
937
|
+
WHERE ms.message_id = m.id
|
|
938
|
+
AND m.transaction_id = ANY($1::varchar[])
|
|
939
|
+
AND ms.consumer_group = $2`
|
|
940
|
+
: `UPDATE queen.messages_status ms
|
|
941
|
+
SET status = 'completed', completed_at = NOW()
|
|
942
|
+
FROM queen.messages m
|
|
943
|
+
WHERE ms.message_id = m.id
|
|
944
|
+
AND m.transaction_id = ANY($1::varchar[])
|
|
945
|
+
AND ms.consumer_group = '__QUEUE_MODE__'`;
|
|
946
|
+
|
|
947
|
+
const params = consumerGroup ? [ids, consumerGroup] : [ids];
|
|
948
|
+
await client.query(updateQuery, params);
|
|
949
|
+
|
|
950
|
+
// Release partition leases for partitions with no more processing messages
|
|
951
|
+
const releaseQuery = `
|
|
952
|
+
WITH affected_partitions AS (
|
|
953
|
+
SELECT DISTINCT m.partition_id
|
|
954
|
+
FROM queen.messages m
|
|
955
|
+
WHERE m.transaction_id = ANY($1::varchar[])
|
|
956
|
+
)
|
|
957
|
+
UPDATE queen.partition_leases pl
|
|
958
|
+
SET released_at = NOW()
|
|
959
|
+
FROM affected_partitions ap
|
|
960
|
+
WHERE pl.partition_id = ap.partition_id
|
|
961
|
+
AND pl.consumer_group = $2
|
|
962
|
+
AND pl.released_at IS NULL
|
|
963
|
+
AND NOT EXISTS (
|
|
964
|
+
SELECT 1
|
|
965
|
+
FROM queen.messages_status ms
|
|
966
|
+
JOIN queen.messages m2 ON ms.message_id = m2.id
|
|
967
|
+
WHERE m2.partition_id = ap.partition_id
|
|
968
|
+
AND ms.consumer_group = $2
|
|
969
|
+
AND ms.status = 'processing'
|
|
970
|
+
)`;
|
|
971
|
+
|
|
972
|
+
await client.query(releaseQuery, [ids, actualConsumerGroup]);
|
|
973
|
+
|
|
974
|
+
log(`${LogTypes.ACK_BATCH} | Status: completed | Count: ${ids.length} | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'} | TransactionIds: [${ids.join(', ')}]`);
|
|
975
|
+
|
|
976
|
+
grouped.completed.forEach(ack => {
|
|
977
|
+
results.push({ transactionId: ack.transactionId, status: 'completed' });
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Process failed messages (needs individual handling for retry logic)
|
|
982
|
+
for (const ack of grouped.failed) {
|
|
983
|
+
const result = await acknowledgeMessage(ack.transactionId, 'failed', ack.error, consumerGroup);
|
|
984
|
+
results.push({ transactionId: ack.transactionId, ...result });
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
return results;
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
// Reclaim expired leases (now includes partition leases)
|
|
992
|
+
const reclaimExpiredLeases = async () => {
|
|
993
|
+
return withTransaction(pool, async (client) => {
|
|
994
|
+
// First, handle expired partition leases
|
|
995
|
+
const partitionLeaseResult = await client.query(
|
|
996
|
+
`WITH expired_leases AS (
|
|
997
|
+
UPDATE queen.partition_leases
|
|
998
|
+
SET released_at = NOW()
|
|
999
|
+
WHERE lease_expires_at < NOW()
|
|
1000
|
+
AND released_at IS NULL
|
|
1001
|
+
RETURNING partition_id, consumer_group, message_batch
|
|
1002
|
+
)
|
|
1003
|
+
-- Reset message status for messages in expired leases
|
|
1004
|
+
UPDATE queen.messages_status ms
|
|
1005
|
+
SET status = 'pending',
|
|
1006
|
+
retry_count = COALESCE(retry_count, 0) + 1,
|
|
1007
|
+
failed_at = NOW(),
|
|
1008
|
+
error_message = 'Partition lease expired',
|
|
1009
|
+
lease_expires_at = NULL,
|
|
1010
|
+
worker_id = NULL,
|
|
1011
|
+
processing_at = NULL
|
|
1012
|
+
FROM expired_leases el
|
|
1013
|
+
WHERE ms.message_id = ANY(
|
|
1014
|
+
SELECT jsonb_array_elements_text(el.message_batch)::uuid
|
|
1015
|
+
)
|
|
1016
|
+
AND ms.consumer_group = el.consumer_group
|
|
1017
|
+
RETURNING ms.message_id`
|
|
1018
|
+
);
|
|
1019
|
+
|
|
1020
|
+
if (partitionLeaseResult.rows.length > 0) {
|
|
1021
|
+
log(`${LogTypes.RECLAIM} | Expired partition leases reclaimed | Message count: ${partitionLeaseResult.rows.length}`);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Then handle individual message lease expiration
|
|
1025
|
+
const result = await client.query(
|
|
1026
|
+
`UPDATE queen.messages_status
|
|
1027
|
+
SET status = 'pending',
|
|
1028
|
+
lease_expires_at = NULL,
|
|
1029
|
+
worker_id = NULL,
|
|
1030
|
+
processing_at = NULL
|
|
1031
|
+
WHERE status = 'processing'
|
|
1032
|
+
AND lease_expires_at < NOW()
|
|
1033
|
+
RETURNING message_id, consumer_group`
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
if (result.rows.length > 0) {
|
|
1037
|
+
const byGroup = {};
|
|
1038
|
+
result.rows.forEach(row => {
|
|
1039
|
+
const group = row.consumer_group || 'QUEUE_MODE';
|
|
1040
|
+
byGroup[group] = (byGroup[group] || 0) + 1;
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
Object.entries(byGroup).forEach(([group, count]) => {
|
|
1044
|
+
log(`Reclaimed ${count} expired leases for consumer group: ${group}`);
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
return result.rows.length;
|
|
1049
|
+
});
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
// Get queue statistics (updated for new structure)
|
|
1053
|
+
const getQueueStats = async (filters = {}) => {
|
|
1054
|
+
const { queue, namespace, task, fromDateTime, toDateTime } = filters;
|
|
1055
|
+
|
|
1056
|
+
let query = `
|
|
1057
|
+
SELECT
|
|
1058
|
+
q.name as queue,
|
|
1059
|
+
q.namespace,
|
|
1060
|
+
q.task,
|
|
1061
|
+
p.name as partition,
|
|
1062
|
+
cg.name as consumer_group,
|
|
1063
|
+
COUNT(CASE WHEN ms.status = 'pending' THEN 1 END) as pending,
|
|
1064
|
+
COUNT(CASE WHEN ms.status = 'processing' THEN 1 END) as processing,
|
|
1065
|
+
COUNT(CASE WHEN ms.status = 'completed' THEN 1 END) as completed,
|
|
1066
|
+
COUNT(CASE WHEN ms.status = 'failed' THEN 1 END) as failed,
|
|
1067
|
+
COUNT(CASE WHEN ms.status = 'dead_letter' THEN 1 END) as dead_letter,
|
|
1068
|
+
COUNT(m.id) as total_messages,
|
|
1069
|
+
COUNT(DISTINCT cg.name) as consumer_groups_count
|
|
1070
|
+
FROM queen.queues q
|
|
1071
|
+
LEFT JOIN queen.partitions p ON p.queue_id = q.id
|
|
1072
|
+
LEFT JOIN queen.messages m ON m.partition_id = p.id
|
|
1073
|
+
LEFT JOIN queen.messages_status ms ON ms.message_id = m.id
|
|
1074
|
+
LEFT JOIN queen.consumer_groups cg ON cg.queue_id = q.id AND cg.active = true
|
|
1075
|
+
`;
|
|
1076
|
+
|
|
1077
|
+
const conditions = [];
|
|
1078
|
+
const params = [];
|
|
1079
|
+
|
|
1080
|
+
if (queue) {
|
|
1081
|
+
conditions.push(`q.name = $${params.length + 1}`);
|
|
1082
|
+
params.push(queue);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (namespace) {
|
|
1086
|
+
conditions.push(`q.namespace = $${params.length + 1}`);
|
|
1087
|
+
params.push(namespace);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (task) {
|
|
1091
|
+
conditions.push(`q.task = $${params.length + 1}`);
|
|
1092
|
+
params.push(task);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Add date/time filtering
|
|
1096
|
+
if (fromDateTime) {
|
|
1097
|
+
conditions.push(`m.created_at >= $${params.length + 1}::timestamp`);
|
|
1098
|
+
params.push(fromDateTime);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (toDateTime) {
|
|
1102
|
+
conditions.push(`m.created_at <= $${params.length + 1}::timestamp`);
|
|
1103
|
+
params.push(toDateTime);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (conditions.length > 0) {
|
|
1107
|
+
query += ` WHERE ${conditions.join(' AND ')}`;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
query += ` GROUP BY q.name, q.namespace, q.task, p.name, cg.name
|
|
1111
|
+
ORDER BY q.name, p.name, cg.name`;
|
|
1112
|
+
|
|
1113
|
+
const result = await pool.query(query, params);
|
|
1114
|
+
return result.rows;
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
// Get queue lag statistics
|
|
1118
|
+
const getQueueLag = async (filters = {}) => {
|
|
1119
|
+
const { queue, namespace, task, fromDateTime, toDateTime } = filters;
|
|
1120
|
+
|
|
1121
|
+
let query = `
|
|
1122
|
+
WITH lag_stats AS (
|
|
1123
|
+
SELECT
|
|
1124
|
+
q.name as queue,
|
|
1125
|
+
q.namespace,
|
|
1126
|
+
q.task,
|
|
1127
|
+
p.name as partition,
|
|
1128
|
+
COUNT(CASE WHEN ms.status = 'pending' THEN 1 END) as pending_count,
|
|
1129
|
+
COUNT(CASE WHEN ms.status = 'processing' THEN 1 END) as processing_count,
|
|
1130
|
+
COUNT(CASE WHEN ms.status IN ('pending', 'processing') THEN 1 END) as total_backlog,
|
|
1131
|
+
COUNT(CASE
|
|
1132
|
+
WHEN ms.status = 'completed'
|
|
1133
|
+
AND ms.completed_at >= COALESCE($1::timestamp, NOW() - INTERVAL '1 hour')
|
|
1134
|
+
AND ms.completed_at <= COALESCE($2::timestamp, NOW())
|
|
1135
|
+
THEN 1
|
|
1136
|
+
END) as completed_messages,
|
|
1137
|
+
AVG(CASE
|
|
1138
|
+
WHEN ms.status = 'completed' AND ms.completed_at IS NOT NULL AND m.created_at IS NOT NULL
|
|
1139
|
+
THEN EXTRACT(EPOCH FROM (ms.completed_at - m.created_at))
|
|
1140
|
+
ELSE NULL
|
|
1141
|
+
END) as avg_processing_time_seconds,
|
|
1142
|
+
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY
|
|
1143
|
+
CASE
|
|
1144
|
+
WHEN ms.status = 'completed' AND ms.completed_at IS NOT NULL AND m.created_at IS NOT NULL
|
|
1145
|
+
THEN EXTRACT(EPOCH FROM (ms.completed_at - m.created_at))
|
|
1146
|
+
ELSE NULL
|
|
1147
|
+
END
|
|
1148
|
+
) as median_processing_time_seconds,
|
|
1149
|
+
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY
|
|
1150
|
+
CASE
|
|
1151
|
+
WHEN ms.status = 'completed' AND ms.completed_at IS NOT NULL AND m.created_at IS NOT NULL
|
|
1152
|
+
THEN EXTRACT(EPOCH FROM (ms.completed_at - m.created_at))
|
|
1153
|
+
ELSE NULL
|
|
1154
|
+
END
|
|
1155
|
+
) as p95_processing_time_seconds,
|
|
1156
|
+
MIN(m.created_at) FILTER (WHERE ms.status IN ('pending', 'processing')) as oldest_unprocessed,
|
|
1157
|
+
MAX(m.created_at) FILTER (WHERE ms.status = 'completed') as newest_completed
|
|
1158
|
+
FROM queen.queues q
|
|
1159
|
+
LEFT JOIN queen.partitions p ON p.queue_id = q.id
|
|
1160
|
+
LEFT JOIN queen.messages m ON m.partition_id = p.id
|
|
1161
|
+
LEFT JOIN queen.messages_status ms ON ms.message_id = m.id AND ms.consumer_group = '__QUEUE_MODE__'
|
|
1162
|
+
`;
|
|
1163
|
+
|
|
1164
|
+
const conditions = [];
|
|
1165
|
+
const params = [];
|
|
1166
|
+
|
|
1167
|
+
// Add date params first (they're referenced in the query above as $1 and $2)
|
|
1168
|
+
params.push(fromDateTime || null);
|
|
1169
|
+
params.push(toDateTime || null);
|
|
1170
|
+
|
|
1171
|
+
if (queue) {
|
|
1172
|
+
conditions.push(`q.name = $${params.length + 1}`);
|
|
1173
|
+
params.push(queue);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (namespace) {
|
|
1177
|
+
conditions.push(`q.namespace = $${params.length + 1}`);
|
|
1178
|
+
params.push(namespace);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
if (task) {
|
|
1182
|
+
conditions.push(`q.task = $${params.length + 1}`);
|
|
1183
|
+
params.push(task);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (conditions.length > 0) {
|
|
1187
|
+
query += ` WHERE ${conditions.join(' AND ')}`;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
query += ` GROUP BY q.name, q.namespace, q.task, p.name
|
|
1191
|
+
)
|
|
1192
|
+
SELECT
|
|
1193
|
+
queue,
|
|
1194
|
+
namespace,
|
|
1195
|
+
task,
|
|
1196
|
+
partition,
|
|
1197
|
+
jsonb_build_object(
|
|
1198
|
+
'pendingCount', pending_count,
|
|
1199
|
+
'processingCount', processing_count,
|
|
1200
|
+
'totalBacklog', total_backlog,
|
|
1201
|
+
'completedMessages', completed_messages,
|
|
1202
|
+
'avgProcessingTimeSeconds', COALESCE(avg_processing_time_seconds, 0),
|
|
1203
|
+
'medianProcessingTimeSeconds', COALESCE(median_processing_time_seconds, 0),
|
|
1204
|
+
'p95ProcessingTimeSeconds', COALESCE(p95_processing_time_seconds, 0),
|
|
1205
|
+
'estimatedLagSeconds', CASE
|
|
1206
|
+
WHEN oldest_unprocessed IS NOT NULL
|
|
1207
|
+
THEN EXTRACT(EPOCH FROM (NOW() - oldest_unprocessed))
|
|
1208
|
+
ELSE 0
|
|
1209
|
+
END,
|
|
1210
|
+
'medianLagSeconds', CASE
|
|
1211
|
+
WHEN oldest_unprocessed IS NOT NULL AND newest_completed IS NOT NULL
|
|
1212
|
+
THEN EXTRACT(EPOCH FROM (newest_completed - oldest_unprocessed)) / 2
|
|
1213
|
+
ELSE 0
|
|
1214
|
+
END,
|
|
1215
|
+
'p95LagSeconds', CASE
|
|
1216
|
+
WHEN oldest_unprocessed IS NOT NULL
|
|
1217
|
+
THEN EXTRACT(EPOCH FROM (NOW() - oldest_unprocessed)) * 1.5
|
|
1218
|
+
ELSE 0
|
|
1219
|
+
END
|
|
1220
|
+
) as stats
|
|
1221
|
+
FROM lag_stats
|
|
1222
|
+
ORDER BY queue, partition`;
|
|
1223
|
+
|
|
1224
|
+
const result = await pool.query(query, params);
|
|
1225
|
+
return result.rows;
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Build query for popping messages with filters (namespace/task)
|
|
1230
|
+
* This is a helper function to construct the complex query used in popMessagesWithFilters
|
|
1231
|
+
*/
|
|
1232
|
+
const buildPopMessagesWithFiltersQuery = (actualConsumerGroup, namespace, task, batch) => {
|
|
1233
|
+
let query = `
|
|
1234
|
+
WITH available_messages AS (
|
|
1235
|
+
SELECT m.id, m.transaction_id, m.trace_id, m.payload, m.is_encrypted, m.created_at,
|
|
1236
|
+
p.id as partition_id, p.name as partition_name, q.name as queue_name,
|
|
1237
|
+
q.priority, q.lease_time, q.retry_limit, q.delayed_processing,
|
|
1238
|
+
q.window_buffer, q.max_wait_time_seconds, q.namespace, q.task
|
|
1239
|
+
FROM queen.messages m
|
|
1240
|
+
JOIN queen.partitions p ON m.partition_id = p.id
|
|
1241
|
+
JOIN queen.queues q ON p.queue_id = q.id
|
|
1242
|
+
LEFT JOIN queen.messages_status ms ON m.id = ms.message_id AND ms.consumer_group = $1
|
|
1243
|
+
WHERE (ms.id IS NULL OR ms.status = 'pending')
|
|
1244
|
+
AND m.created_at <= NOW() - INTERVAL '1 second' * q.delayed_processing
|
|
1245
|
+
AND (q.max_wait_time_seconds = 0 OR
|
|
1246
|
+
m.created_at > NOW() - INTERVAL '1 second' * q.max_wait_time_seconds)
|
|
1247
|
+
AND (q.window_buffer = 0 OR NOT EXISTS (
|
|
1248
|
+
SELECT 1 FROM queen.messages m2
|
|
1249
|
+
WHERE m2.partition_id = p.id
|
|
1250
|
+
AND m2.created_at > NOW() - INTERVAL '1 second' * q.window_buffer
|
|
1251
|
+
))
|
|
1252
|
+
AND NOT EXISTS (
|
|
1253
|
+
-- Check for active partition leases
|
|
1254
|
+
SELECT 1 FROM queen.partition_leases pl
|
|
1255
|
+
WHERE pl.partition_id = p.id
|
|
1256
|
+
AND pl.consumer_group = $1
|
|
1257
|
+
AND pl.released_at IS NULL
|
|
1258
|
+
AND pl.lease_expires_at > NOW()
|
|
1259
|
+
)
|
|
1260
|
+
`;
|
|
1261
|
+
|
|
1262
|
+
const params = [actualConsumerGroup];
|
|
1263
|
+
|
|
1264
|
+
if (namespace) {
|
|
1265
|
+
params.push(namespace);
|
|
1266
|
+
query += ` AND q.namespace = $${params.length}`;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
if (task) {
|
|
1270
|
+
params.push(task);
|
|
1271
|
+
query += ` AND q.task = $${params.length}`;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
params.push(batch);
|
|
1275
|
+
query += ` ORDER BY q.priority DESC, m.created_at ASC LIMIT $${params.length} FOR UPDATE OF m SKIP LOCKED`;
|
|
1276
|
+
|
|
1277
|
+
// Add the INSERT part
|
|
1278
|
+
params.push(config.WORKER_ID);
|
|
1279
|
+
query += `)
|
|
1280
|
+
INSERT INTO queen.messages_status (message_id, consumer_group, status, lease_expires_at, processing_at, worker_id)
|
|
1281
|
+
SELECT id, $1, 'processing', NOW() + INTERVAL '1 second' * lease_time, NOW(), $${params.length}
|
|
1282
|
+
FROM available_messages
|
|
1283
|
+
ON CONFLICT (message_id, consumer_group)
|
|
1284
|
+
DO UPDATE SET
|
|
1285
|
+
status = 'processing',
|
|
1286
|
+
lease_expires_at = EXCLUDED.lease_expires_at,
|
|
1287
|
+
processing_at = EXCLUDED.processing_at,
|
|
1288
|
+
worker_id = EXCLUDED.worker_id,
|
|
1289
|
+
retry_count = queen.messages_status.retry_count + 1
|
|
1290
|
+
RETURNING message_id,
|
|
1291
|
+
(SELECT transaction_id FROM queen.messages WHERE id = message_id),
|
|
1292
|
+
(SELECT trace_id FROM queen.messages WHERE id = message_id),
|
|
1293
|
+
(SELECT payload FROM queen.messages WHERE id = message_id),
|
|
1294
|
+
(SELECT is_encrypted FROM queen.messages WHERE id = message_id),
|
|
1295
|
+
(SELECT created_at FROM queen.messages WHERE id = message_id),
|
|
1296
|
+
(SELECT p.id FROM queen.messages m2 JOIN queen.partitions p ON m2.partition_id = p.id WHERE m2.id = message_id) as partition_id,
|
|
1297
|
+
(SELECT p.name FROM queen.messages m2 JOIN queen.partitions p ON m2.partition_id = p.id WHERE m2.id = message_id) as partition_name,
|
|
1298
|
+
(SELECT q.name FROM queen.messages m2 JOIN queen.partitions p ON m2.partition_id = p.id JOIN queen.queues q ON p.queue_id = q.id WHERE m2.id = message_id) as queue_name,
|
|
1299
|
+
(SELECT q.priority FROM queen.messages m2 JOIN queen.partitions p ON m2.partition_id = p.id JOIN queen.queues q ON p.queue_id = q.id WHERE m2.id = message_id) as priority,
|
|
1300
|
+
(SELECT q.lease_time FROM queen.messages m2 JOIN queen.partitions p ON m2.partition_id = p.id JOIN queen.queues q ON p.queue_id = q.id WHERE m2.id = message_id) as lease_time,
|
|
1301
|
+
retry_count
|
|
1302
|
+
`;
|
|
1303
|
+
|
|
1304
|
+
return { query, params };
|
|
1305
|
+
};
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Pop messages with namespace/task filtering
|
|
1309
|
+
* Supports filtering by namespace and/or task in addition to consumer groups
|
|
1310
|
+
*/
|
|
1311
|
+
const popMessagesWithFilters = async (filters, options = {}) => {
|
|
1312
|
+
const { namespace, task, consumerGroup } = filters;
|
|
1313
|
+
const { wait = false, timeout = config.QUEUE.DEFAULT_TIMEOUT, batch = config.QUEUE.DEFAULT_BATCH_SIZE } = options;
|
|
1314
|
+
|
|
1315
|
+
return withTransaction(pool, async (client) => {
|
|
1316
|
+
// Determine the actual consumer group to use
|
|
1317
|
+
const actualConsumerGroup = consumerGroup || '__QUEUE_MODE__';
|
|
1318
|
+
|
|
1319
|
+
// Use the helper function to build the query
|
|
1320
|
+
const { query, params } = buildPopMessagesWithFiltersQuery(actualConsumerGroup, namespace, task, batch);
|
|
1321
|
+
|
|
1322
|
+
const result = await client.query(query, params);
|
|
1323
|
+
|
|
1324
|
+
// Lock all partitions that we got messages from
|
|
1325
|
+
if (result.rows.length > 0) {
|
|
1326
|
+
const partitionIds = [...new Set(result.rows.map(r => r.partition_id))];
|
|
1327
|
+
const leaseTime = Math.max(...result.rows.map(r => r.lease_time || 30));
|
|
1328
|
+
|
|
1329
|
+
// Acquire partition leases for all partitions we got messages from
|
|
1330
|
+
for (const partitionId of partitionIds) {
|
|
1331
|
+
await client.query(`
|
|
1332
|
+
INSERT INTO queen.partition_leases (
|
|
1333
|
+
partition_id,
|
|
1334
|
+
consumer_group,
|
|
1335
|
+
lease_expires_at
|
|
1336
|
+
) VALUES ($1, $2, NOW() + INTERVAL '1 second' * $3)
|
|
1337
|
+
ON CONFLICT (partition_id, consumer_group)
|
|
1338
|
+
DO UPDATE SET
|
|
1339
|
+
lease_expires_at = EXCLUDED.lease_expires_at,
|
|
1340
|
+
released_at = NULL
|
|
1341
|
+
`, [partitionId, actualConsumerGroup, leaseTime]);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (result.rows.length === 0 && wait) {
|
|
1346
|
+
// For namespace/task filtering, we can't easily wait on specific queue paths
|
|
1347
|
+
// So we'll use simple polling
|
|
1348
|
+
return { messages: [] };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Format and decrypt messages
|
|
1352
|
+
const messages = await Promise.all(
|
|
1353
|
+
result.rows.map(async (row) => {
|
|
1354
|
+
let decryptedPayload = row.payload;
|
|
1355
|
+
|
|
1356
|
+
if (row.is_encrypted && encryption.isEncryptionEnabled()) {
|
|
1357
|
+
try {
|
|
1358
|
+
decryptedPayload = await encryption.decryptPayload(row.payload);
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
log('ERROR: Decryption failed:', error);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
return {
|
|
1365
|
+
id: row.message_id,
|
|
1366
|
+
transactionId: row.transaction_id,
|
|
1367
|
+
traceId: row.trace_id,
|
|
1368
|
+
queue: row.queue_name,
|
|
1369
|
+
partition: row.partition_name,
|
|
1370
|
+
data: decryptedPayload,
|
|
1371
|
+
payload: decryptedPayload,
|
|
1372
|
+
retryCount: row.retry_count || 0,
|
|
1373
|
+
priority: row.priority || 0,
|
|
1374
|
+
createdAt: row.created_at
|
|
1375
|
+
};
|
|
1376
|
+
})
|
|
1377
|
+
);
|
|
1378
|
+
|
|
1379
|
+
if (messages.length > 0) {
|
|
1380
|
+
log(`${LogTypes.POP} | Count: ${messages.length} | Namespace: ${namespace || 'ANY'} | Task: ${task || 'ANY'}`);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
return { messages };
|
|
1384
|
+
});
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* Configure queue settings with namespace and task support
|
|
1389
|
+
* Updates queue configuration options and optionally sets namespace/task
|
|
1390
|
+
*/
|
|
1391
|
+
const configureQueue = async (queueName, options = {}, namespace = null, task = null) => {
|
|
1392
|
+
return withTransaction(pool, async (client) => {
|
|
1393
|
+
// Check if queue exists (for update vs create detection)
|
|
1394
|
+
const existingQueue = await client.query(
|
|
1395
|
+
'SELECT * FROM queen.queues WHERE name = $1',
|
|
1396
|
+
[queueName]
|
|
1397
|
+
);
|
|
1398
|
+
const isUpdate = existingQueue.rows.length > 0;
|
|
1399
|
+
|
|
1400
|
+
// First create/update the queue with namespace and task
|
|
1401
|
+
const queueResult = await client.query(
|
|
1402
|
+
`INSERT INTO queen.queues (name, namespace, task) VALUES ($1, $2, $3)
|
|
1403
|
+
ON CONFLICT (name) DO UPDATE SET
|
|
1404
|
+
namespace = CASE WHEN EXCLUDED.namespace IS NOT NULL THEN EXCLUDED.namespace ELSE queen.queues.namespace END,
|
|
1405
|
+
task = CASE WHEN EXCLUDED.task IS NOT NULL THEN EXCLUDED.task ELSE queen.queues.task END
|
|
1406
|
+
RETURNING id, name, namespace, task`,
|
|
1407
|
+
[queueName, namespace || null, task || null]
|
|
1408
|
+
);
|
|
1409
|
+
|
|
1410
|
+
// Build update query for all configuration options
|
|
1411
|
+
const updates = [];
|
|
1412
|
+
const params = [];
|
|
1413
|
+
let paramIndex = 1;
|
|
1414
|
+
|
|
1415
|
+
// Map options to database columns
|
|
1416
|
+
const optionMappings = {
|
|
1417
|
+
leaseTime: 'lease_time',
|
|
1418
|
+
retryLimit: 'retry_limit',
|
|
1419
|
+
retryDelay: 'retry_delay',
|
|
1420
|
+
maxSize: 'max_size',
|
|
1421
|
+
ttl: 'ttl',
|
|
1422
|
+
deadLetterQueue: 'dead_letter_queue',
|
|
1423
|
+
dlqAfterMaxRetries: 'dlq_after_max_retries',
|
|
1424
|
+
delayedProcessing: 'delayed_processing',
|
|
1425
|
+
windowBuffer: 'window_buffer',
|
|
1426
|
+
retentionSeconds: 'retention_seconds',
|
|
1427
|
+
completedRetentionSeconds: 'completed_retention_seconds',
|
|
1428
|
+
retentionEnabled: 'retention_enabled',
|
|
1429
|
+
priority: 'priority',
|
|
1430
|
+
encryptionEnabled: 'encryption_enabled',
|
|
1431
|
+
maxWaitTimeSeconds: 'max_wait_time_seconds',
|
|
1432
|
+
maxQueueSize: 'max_queue_size'
|
|
1433
|
+
};
|
|
1434
|
+
|
|
1435
|
+
// Detect what changed (for update events)
|
|
1436
|
+
const changes = {};
|
|
1437
|
+
|
|
1438
|
+
// Process each option
|
|
1439
|
+
for (const [optionKey, columnName] of Object.entries(optionMappings)) {
|
|
1440
|
+
if (options[optionKey] !== undefined) {
|
|
1441
|
+
// Track changes for update events
|
|
1442
|
+
if (isUpdate && existingQueue.rows[0]) {
|
|
1443
|
+
const old = existingQueue.rows[0];
|
|
1444
|
+
const newValue = options[optionKey];
|
|
1445
|
+
if (old[columnName] !== newValue) {
|
|
1446
|
+
changes[optionKey] = { old: old[columnName], new: newValue };
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
updates.push(`${columnName} = $${paramIndex}`);
|
|
1451
|
+
|
|
1452
|
+
// Handle different data types
|
|
1453
|
+
if (typeof options[optionKey] === 'boolean') {
|
|
1454
|
+
params.push(!!options[optionKey]);
|
|
1455
|
+
} else if (typeof options[optionKey] === 'number' || !isNaN(options[optionKey])) {
|
|
1456
|
+
params.push(parseInt(options[optionKey]) || 0);
|
|
1457
|
+
} else {
|
|
1458
|
+
params.push(options[optionKey]);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
paramIndex++;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// Apply updates if any
|
|
1466
|
+
if (updates.length > 0) {
|
|
1467
|
+
params.push(queueName);
|
|
1468
|
+
const updateQuery = `UPDATE queen.queues SET ${updates.join(', ')} WHERE name = $${paramIndex}`;
|
|
1469
|
+
await client.query(updateQuery, params);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Emit appropriate event (if systemEventManager is available)
|
|
1473
|
+
if (systemEventManager) {
|
|
1474
|
+
await systemEventManager.emit(
|
|
1475
|
+
isUpdate ? EventTypes.QUEUE_UPDATED : EventTypes.QUEUE_CREATED,
|
|
1476
|
+
{
|
|
1477
|
+
entityType: 'queue',
|
|
1478
|
+
entityId: queueName,
|
|
1479
|
+
changes: isUpdate ? changes : options,
|
|
1480
|
+
namespace,
|
|
1481
|
+
task
|
|
1482
|
+
}
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Local cache invalidation (immediate)
|
|
1487
|
+
resourceCache.invalidateQueue(queueName);
|
|
1488
|
+
|
|
1489
|
+
return {
|
|
1490
|
+
queue: queueName,
|
|
1491
|
+
namespace: queueResult.rows[0].namespace,
|
|
1492
|
+
task: queueResult.rows[0].task,
|
|
1493
|
+
options
|
|
1494
|
+
};
|
|
1495
|
+
});
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
// Export all functions
|
|
1499
|
+
return {
|
|
1500
|
+
pushMessages: pushMessagesBatch,
|
|
1501
|
+
pushMessagesBatch,
|
|
1502
|
+
popMessages,
|
|
1503
|
+
acknowledgeMessage,
|
|
1504
|
+
acknowledgeMessages,
|
|
1505
|
+
ackMessage: acknowledgeMessage, // Alias for compatibility
|
|
1506
|
+
reclaimExpiredLeases,
|
|
1507
|
+
getQueueStats,
|
|
1508
|
+
getQueueLag,
|
|
1509
|
+
popMessagesWithFilters,
|
|
1510
|
+
configureQueue
|
|
1511
|
+
};
|
|
1512
|
+
};
|