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.
Files changed (90) hide show
  1. package/API.md +1116 -0
  2. package/CACHE.md +519 -0
  3. package/DASHBOARD-V3.md +478 -0
  4. package/DASHBOARD.md +382 -0
  5. package/MOD_QUEUE.md +453 -0
  6. package/PARTITION_LOCKING_DESIGN.md +989 -0
  7. package/PLAN.md +707 -0
  8. package/QUERY_ANALSYS.md +72 -0
  9. package/QUEUE_BUS.md +334 -0
  10. package/README.md +1495 -0
  11. package/V2-PLAN.md +236 -0
  12. package/assets/dashboard.png +0 -0
  13. package/dashboard/.vscode/extensions.json +3 -0
  14. package/dashboard/README.md +5 -0
  15. package/dashboard/index.html +14 -0
  16. package/dashboard/package-lock.json +1458 -0
  17. package/dashboard/package.json +25 -0
  18. package/dashboard/public/vite.svg +1 -0
  19. package/dashboard/src/App.vue +29 -0
  20. package/dashboard/src/assets/styles/main.css +908 -0
  21. package/dashboard/src/assets/vue.svg +1 -0
  22. package/dashboard/src/components/cards/MetricCard.vue +298 -0
  23. package/dashboard/src/components/charts/QueueDepthChart.vue +276 -0
  24. package/dashboard/src/components/charts/QueueLagChart.vue +436 -0
  25. package/dashboard/src/components/charts/ThroughputChart.vue +302 -0
  26. package/dashboard/src/components/common/ActivityFeed.vue +251 -0
  27. package/dashboard/src/components/layout/AppHeader.vue +208 -0
  28. package/dashboard/src/components/layout/AppLayout.vue +88 -0
  29. package/dashboard/src/components/layout/AppSidebar.vue +261 -0
  30. package/dashboard/src/main.js +44 -0
  31. package/dashboard/src/router.js +54 -0
  32. package/dashboard/src/services/api.js +187 -0
  33. package/dashboard/src/services/websocket.js +167 -0
  34. package/dashboard/src/utils/constants.js +56 -0
  35. package/dashboard/src/utils/helpers.js +118 -0
  36. package/dashboard/src/views/Analytics.vue +912 -0
  37. package/dashboard/src/views/Dashboard.vue +906 -0
  38. package/dashboard/src/views/Messages.vue +437 -0
  39. package/dashboard/src/views/QueueDetail.vue +501 -0
  40. package/dashboard/src/views/Queues.vue +333 -0
  41. package/dashboard/vite.config.js +30 -0
  42. package/debug-namespace.js +110 -0
  43. package/docs/long-polling.md +159 -0
  44. package/docs/multi-server-cache-solutions.md +185 -0
  45. package/docs/performance-tuning.md +222 -0
  46. package/examples/bus-mode.js +239 -0
  47. package/examples/continuous-consumer-optimized.js +215 -0
  48. package/examples/continuous-consumer.js +159 -0
  49. package/examples/continuous-producer.js +343 -0
  50. package/examples/mixed-mode.js +277 -0
  51. package/examples/multi-server-test.js +305 -0
  52. package/examples/single.js +64 -0
  53. package/examples/smartchat-dealyed.js +42 -0
  54. package/examples/smartchat.js +52 -0
  55. package/examples/test-cache-invalidation.js +119 -0
  56. package/examples/test-cache-multi-server.js +245 -0
  57. package/examples/test-minimal-client.js +112 -0
  58. package/examples/test-queue-creation-policy.js +137 -0
  59. package/init-db.js +20 -0
  60. package/package.json +36 -0
  61. package/src/client/client.js +291 -0
  62. package/src/client/index.js +6 -0
  63. package/src/client/queenClient.js +513 -0
  64. package/src/client/utils/http.js +172 -0
  65. package/src/client/utils/loadBalancer.js +152 -0
  66. package/src/client/utils/retry.js +35 -0
  67. package/src/config.js +215 -0
  68. package/src/database/connection.js +103 -0
  69. package/src/database/poolManager.js +192 -0
  70. package/src/database/schema-v2.sql +214 -0
  71. package/src/managers/eventManager.js +59 -0
  72. package/src/managers/queueManagerOptimized.js +1512 -0
  73. package/src/managers/resourceCache.js +96 -0
  74. package/src/managers/systemEventManager.js +127 -0
  75. package/src/routes/ack.js +26 -0
  76. package/src/routes/analytics.js +812 -0
  77. package/src/routes/configure.js +46 -0
  78. package/src/routes/messages.js +298 -0
  79. package/src/routes/pop.js +85 -0
  80. package/src/routes/push.js +28 -0
  81. package/src/routes/resources.js +296 -0
  82. package/src/server.js +1286 -0
  83. package/src/services/encryptionService.js +82 -0
  84. package/src/services/evictionService.js +131 -0
  85. package/src/services/retentionService.js +129 -0
  86. package/src/services/startupSync.js +35 -0
  87. package/src/test/test.js +4521 -0
  88. package/src/utils/logger.js +44 -0
  89. package/src/utils/uuid.js +5 -0
  90. 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
+ };