queen-mq 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/API.md +862 -752
  2. package/AUTH.md +2044 -0
  3. package/LICENSE.md +202 -0
  4. package/README.md +1705 -1051
  5. package/WEBAPP.md +1889 -0
  6. package/assets/dashboard-01.png +0 -0
  7. package/assets/queen-logo-blue.svg +210 -0
  8. package/assets/queen-logo-cyan.svg +210 -0
  9. package/assets/queen-logo-indigo.svg +210 -0
  10. package/assets/queen-logo-orange.svg +210 -0
  11. package/assets/queen-logo-pink.svg +210 -0
  12. package/assets/queen-logo-purple.svg +210 -0
  13. package/assets/queen-logo-rose.svg +239 -0
  14. package/assets/queen-logo.svg +263 -0
  15. package/examples/batch-processing.js +58 -0
  16. package/examples/test-complete-client.js +260 -0
  17. package/examples/test-dashboard-api.js +200 -0
  18. package/examples/test-traceid.js +147 -0
  19. package/package.json +17 -4
  20. package/server.log +1 -0
  21. package/src/benchmark/consumer.js +207 -0
  22. package/src/benchmark/consumer_multi.js +216 -0
  23. package/src/benchmark/producer.js +75 -0
  24. package/src/benchmark/producer_multi.js +115 -0
  25. package/src/client/client.js +300 -31
  26. package/src/client/queenClient.js +5 -0
  27. package/src/cluster-server.js +242 -0
  28. package/src/config.js +19 -5
  29. package/src/database/connection.js +42 -16
  30. package/src/database/poolManager.js +7 -0
  31. package/src/database/schema-v2.sql +194 -130
  32. package/src/managers/queueManagerOptimized.js +823 -933
  33. package/src/managers/systemEventManager.js +8 -3
  34. package/src/routes/messages.js +127 -57
  35. package/src/routes/pop.js +27 -43
  36. package/src/routes/resources.js +61 -27
  37. package/src/routes/status.js +1037 -0
  38. package/src/server.js +308 -272
  39. package/src/services/evictionService.js +57 -28
  40. package/src/services/retentionService.js +44 -11
  41. package/src/test/MIGRATION_ISSUES.md +174 -0
  42. package/src/test/README.md +203 -0
  43. package/src/test/advanced-pattern-tests.js +1137 -0
  44. package/src/test/bus-mode-tests.js +361 -0
  45. package/src/test/core-tests.js +342 -0
  46. package/src/test/edge-case-tests.js +561 -0
  47. package/src/test/enterprise-tests.js +637 -0
  48. package/src/test/partition-locking-tests.js +545 -0
  49. package/src/test/test-new.js +278 -0
  50. package/src/test/test.js +6 -3
  51. package/src/test/utils.js +169 -0
  52. package/src/utils/streaming.js +231 -0
  53. package/src/utils/uuid.js +2 -2
  54. package/src/websocket/wsServer.js +10 -3
  55. package/test-keepalive-v2.sh +22 -0
  56. package/webapp/COLOR_GUIDE.md +118 -0
  57. package/webapp/README.md +143 -0
  58. package/webapp/index.html +14 -0
  59. package/webapp/package-lock.json +3184 -0
  60. package/webapp/package.json +25 -0
  61. package/webapp/postcss.config.js +7 -0
  62. package/webapp/public/assets/queen-logo-blue.svg +210 -0
  63. package/webapp/public/assets/queen-logo-cyan.svg +210 -0
  64. package/webapp/public/assets/queen-logo-indigo.svg +210 -0
  65. package/webapp/public/assets/queen-logo-orange.svg +210 -0
  66. package/webapp/public/assets/queen-logo-pink.svg +210 -0
  67. package/webapp/public/assets/queen-logo-purple.svg +210 -0
  68. package/webapp/public/assets/queen-logo-rose.svg +239 -0
  69. package/webapp/public/assets/queen-logo.svg +263 -0
  70. package/webapp/src/App.vue +19 -0
  71. package/webapp/src/api/analytics.js +10 -0
  72. package/webapp/src/api/client.js +29 -0
  73. package/webapp/src/api/consumers.js +52 -0
  74. package/webapp/src/api/health.js +7 -0
  75. package/webapp/src/api/messages.js +26 -0
  76. package/webapp/src/api/queues.js +14 -0
  77. package/webapp/src/api/resources.js +8 -0
  78. package/webapp/src/assets/styles/main.css +357 -0
  79. package/webapp/src/components/analytics/AnalyticsFilters.vue +87 -0
  80. package/webapp/src/components/analytics/AnalyticsMetrics.vue +57 -0
  81. package/webapp/src/components/analytics/MessageDistributionChart.vue +111 -0
  82. package/webapp/src/components/analytics/MessageFlowChart.vue +173 -0
  83. package/webapp/src/components/analytics/TimeRangeSelector.vue +27 -0
  84. package/webapp/src/components/analytics/TopQueuesChart.vue +132 -0
  85. package/webapp/src/components/common/ConfirmDialog.vue +56 -0
  86. package/webapp/src/components/common/LoadingSpinner.vue +6 -0
  87. package/webapp/src/components/common/MetricCard.vue +43 -0
  88. package/webapp/src/components/common/StatusBadge.vue +45 -0
  89. package/webapp/src/components/dashboard/MessageStatusCard.vue +50 -0
  90. package/webapp/src/components/dashboard/PerformanceCard.vue +38 -0
  91. package/webapp/src/components/dashboard/ThroughputChart.vue +182 -0
  92. package/webapp/src/components/dashboard/TopQueuesTable.vue +53 -0
  93. package/webapp/src/components/layout/AppLayout.vue +110 -0
  94. package/webapp/src/components/layout/AppSidebar.vue +304 -0
  95. package/webapp/src/components/messages/MessageDetailPanel.vue +242 -0
  96. package/webapp/src/components/messages/MessageFilters.vue +114 -0
  97. package/webapp/src/components/queue-detail/PartitionList.vue +79 -0
  98. package/webapp/src/components/queue-detail/PushMessageModal.vue +175 -0
  99. package/webapp/src/components/queue-detail/QueueConfig.vue +63 -0
  100. package/webapp/src/components/queue-detail/QueueDetailHeader.vue +53 -0
  101. package/webapp/src/components/queue-detail/RecentMessages.vue +76 -0
  102. package/webapp/src/components/queues/CreateQueueModal.vue +193 -0
  103. package/webapp/src/components/queues/QueueFilters.vue +90 -0
  104. package/webapp/src/composables/useApi.js +34 -0
  105. package/webapp/src/composables/useTheme.js +36 -0
  106. package/webapp/src/main.js +11 -0
  107. package/webapp/src/router/index.js +42 -0
  108. package/webapp/src/utils/colors.js +96 -0
  109. package/webapp/src/utils/formatters.js +49 -0
  110. package/webapp/src/views/Analytics.vue +377 -0
  111. package/webapp/src/views/ConsumerGroups.vue +433 -0
  112. package/webapp/src/views/Dashboard.vue +418 -0
  113. package/webapp/src/views/Messages.vue +361 -0
  114. package/webapp/src/views/QueueDetail.vue +582 -0
  115. package/webapp/src/views/Queues.vue +496 -0
  116. package/webapp/tailwind.config.js +25 -0
  117. package/webapp/vite.config.js +10 -0
  118. package/CACHE.md +0 -519
  119. package/DASHBOARD-V3.md +0 -478
  120. package/DASHBOARD.md +0 -382
  121. package/MOD_QUEUE.md +0 -453
  122. package/PARTITION_LOCKING_DESIGN.md +0 -989
  123. package/PLAN.md +0 -707
  124. package/QUERY_ANALSYS.md +0 -72
  125. package/QUEUE_BUS.md +0 -334
  126. package/V2-PLAN.md +0 -236
  127. package/dashboard/.vscode/extensions.json +0 -3
  128. package/dashboard/README.md +0 -5
  129. package/dashboard/index.html +0 -14
  130. package/dashboard/package-lock.json +0 -1458
  131. package/dashboard/package.json +0 -25
  132. package/dashboard/public/vite.svg +0 -1
  133. package/dashboard/src/App.vue +0 -29
  134. package/dashboard/src/assets/styles/main.css +0 -908
  135. package/dashboard/src/assets/vue.svg +0 -1
  136. package/dashboard/src/components/cards/MetricCard.vue +0 -298
  137. package/dashboard/src/components/charts/QueueDepthChart.vue +0 -276
  138. package/dashboard/src/components/charts/QueueLagChart.vue +0 -436
  139. package/dashboard/src/components/charts/ThroughputChart.vue +0 -302
  140. package/dashboard/src/components/common/ActivityFeed.vue +0 -251
  141. package/dashboard/src/components/layout/AppHeader.vue +0 -208
  142. package/dashboard/src/components/layout/AppLayout.vue +0 -88
  143. package/dashboard/src/components/layout/AppSidebar.vue +0 -261
  144. package/dashboard/src/main.js +0 -44
  145. package/dashboard/src/router.js +0 -54
  146. package/dashboard/src/services/api.js +0 -187
  147. package/dashboard/src/services/websocket.js +0 -167
  148. package/dashboard/src/utils/constants.js +0 -56
  149. package/dashboard/src/utils/helpers.js +0 -118
  150. package/dashboard/src/views/Analytics.vue +0 -912
  151. package/dashboard/src/views/Dashboard.vue +0 -906
  152. package/dashboard/src/views/Messages.vue +0 -437
  153. package/dashboard/src/views/QueueDetail.vue +0 -501
  154. package/dashboard/src/views/Queues.vue +0 -333
  155. package/dashboard/vite.config.js +0 -30
  156. package/debug-namespace.js +0 -110
  157. package/docs/long-polling.md +0 -159
  158. package/docs/multi-server-cache-solutions.md +0 -185
  159. package/docs/performance-tuning.md +0 -222
  160. package/src/routes/analytics.js +0 -812
@@ -10,6 +10,83 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
10
10
  // Batch size for database operations
11
11
  const BATCH_INSERT_SIZE = config.QUEUE.BATCH_INSERT_SIZE;
12
12
 
13
+ // Performance metrics tracking for filtered pops
14
+ const popMetrics = {
15
+ partitionQueryTimes: [],
16
+ leaseAttempts: 0,
17
+ leaseSuccesses: 0,
18
+ messageQueryTimes: [],
19
+ totalPopTimes: [],
20
+ batchSizes: [],
21
+ lastLogTime: Date.now()
22
+ };
23
+
24
+ // Performance metrics for direct pops (queue/partition)
25
+ const directPopMetrics = {
26
+ leaseAcquisitionTimes: [],
27
+ messageQueryTimes: [],
28
+ statusUpdateTimes: [],
29
+ leaseUpdateTimes: [],
30
+ decryptionTimes: [],
31
+ totalPopTimes: [],
32
+ batchSizes: [],
33
+ lastLogTime: Date.now()
34
+ };
35
+
36
+ // Log metrics periodically (every 5 seconds OR after 10 samples)
37
+ const logMetrics = () => {
38
+ const now = Date.now();
39
+ const timeSinceLastLog = now - Math.max(popMetrics.lastLogTime, directPopMetrics.lastLogTime);
40
+ const hasEnoughSamples = popMetrics.totalPopTimes.length >= 10 || directPopMetrics.totalPopTimes.length >= 10;
41
+
42
+ // Only log if enough time passed OR we have enough samples
43
+ if (timeSinceLastLog < 5000 && !hasEnoughSamples) return;
44
+
45
+ if (popMetrics.totalPopTimes.length === 0 && directPopMetrics.totalPopTimes.length === 0) return; // No data yet
46
+
47
+ const avg = arr => arr.length > 0 ? (arr.reduce((a,b) => a+b, 0) / arr.length).toFixed(2) : 0;
48
+ const max = arr => arr.length > 0 ? Math.max(...arr).toFixed(2) : 0;
49
+ const min = arr => arr.length > 0 ? Math.min(...arr).toFixed(2) : 0;
50
+
51
+ if (popMetrics.totalPopTimes.length > 0) {
52
+ log(`📊 PERF METRICS (filtered pop) | Samples: ${popMetrics.totalPopTimes.length}`);
53
+ log(` - Partition Query: avg=${avg(popMetrics.partitionQueryTimes)}ms max=${max(popMetrics.partitionQueryTimes)}ms`);
54
+ log(` - Message Query: avg=${avg(popMetrics.messageQueryTimes)}ms max=${max(popMetrics.messageQueryTimes)}ms`);
55
+ log(` - Total Pop: avg=${avg(popMetrics.totalPopTimes)}ms max=${max(popMetrics.totalPopTimes)}ms`);
56
+ log(` - Lease Success Rate: ${popMetrics.leaseSuccesses}/${popMetrics.leaseAttempts} (${(popMetrics.leaseSuccesses/popMetrics.leaseAttempts*100).toFixed(1)}%)`);
57
+ log(` - Avg Batch Size: ${avg(popMetrics.batchSizes)} messages`);
58
+ }
59
+
60
+ if (directPopMetrics.totalPopTimes.length > 0) {
61
+ log(`📊 PERF METRICS (direct pop) | Samples: ${directPopMetrics.totalPopTimes.length}`);
62
+ log(` - Lease Acquisition: avg=${avg(directPopMetrics.leaseAcquisitionTimes)}ms max=${max(directPopMetrics.leaseAcquisitionTimes)}ms`);
63
+ log(` - Message Query: avg=${avg(directPopMetrics.messageQueryTimes)}ms max=${max(directPopMetrics.messageQueryTimes)}ms`);
64
+ log(` - Status Update: avg=${avg(directPopMetrics.statusUpdateTimes)}ms max=${max(directPopMetrics.statusUpdateTimes)}ms`);
65
+ log(` - Lease Update: avg=${avg(directPopMetrics.leaseUpdateTimes)}ms max=${max(directPopMetrics.leaseUpdateTimes)}ms`);
66
+ log(` - Decryption/Format: avg=${avg(directPopMetrics.decryptionTimes)}ms max=${max(directPopMetrics.decryptionTimes)}ms`);
67
+ log(` - Total Pop: avg=${avg(directPopMetrics.totalPopTimes)}ms max=${max(directPopMetrics.totalPopTimes)}ms`);
68
+ log(` - Avg Batch Size: ${avg(directPopMetrics.batchSizes)} messages`);
69
+ }
70
+
71
+ // Reset metrics after logging
72
+ popMetrics.partitionQueryTimes = [];
73
+ popMetrics.leaseAttempts = 0;
74
+ popMetrics.leaseSuccesses = 0;
75
+ popMetrics.messageQueryTimes = [];
76
+ popMetrics.totalPopTimes = [];
77
+ popMetrics.batchSizes = [];
78
+ popMetrics.lastLogTime = now;
79
+
80
+ directPopMetrics.leaseAcquisitionTimes = [];
81
+ directPopMetrics.messageQueryTimes = [];
82
+ directPopMetrics.statusUpdateTimes = [];
83
+ directPopMetrics.leaseUpdateTimes = [];
84
+ directPopMetrics.decryptionTimes = [];
85
+ directPopMetrics.totalPopTimes = [];
86
+ directPopMetrics.batchSizes = [];
87
+ directPopMetrics.lastLogTime = now;
88
+ };
89
+
13
90
  // Ensure queue exists and partition exist (with caching)
14
91
  // Queue must be created via configure endpoint, but partitions can be created on-demand
15
92
  const ensureResources = async (client, queueName, partitionName = 'Default', namespace = null, task = null) => {
@@ -126,6 +203,8 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
126
203
 
127
204
  // Optimized batch insert for high throughput (simplified - no status)
128
205
  const pushMessagesBatch = async (items) => {
206
+ const pushStartTime = Date.now();
207
+
129
208
  // Group items by queue and partition for efficient resource lookup
130
209
  const partitionGroups = {};
131
210
  for (const item of items) {
@@ -139,6 +218,45 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
139
218
 
140
219
  const allResults = [];
141
220
 
221
+ // OPTIMIZATION: Check queue capacity once per queue, not per partition
222
+ // Build a map of queue -> total batch size across all partitions
223
+ const queueBatchSizes = {};
224
+ for (const [partitionKey, partitionItems] of Object.entries(partitionGroups)) {
225
+ const [queueName] = partitionKey.split(':');
226
+ queueBatchSizes[queueName] = (queueBatchSizes[queueName] || 0) + partitionItems.length;
227
+ }
228
+
229
+ // Check capacity for all queues with max_queue_size set (single query)
230
+ const queuesToCheck = Object.keys(queueBatchSizes);
231
+ if (queuesToCheck.length > 0) {
232
+ await withTransaction(pool, async (client) => {
233
+ const capacityCheck = await client.query(
234
+ `SELECT
235
+ q.name,
236
+ q.max_queue_size,
237
+ COALESCE(SUM(pc.pending_estimate), 0)::integer as current_depth
238
+ FROM queen.queues q
239
+ LEFT JOIN queen.partitions p ON p.queue_id = q.id
240
+ LEFT JOIN queen.partition_consumers pc ON pc.partition_id = p.id
241
+ AND pc.consumer_group = '__QUEUE_MODE__'
242
+ WHERE q.name = ANY($1::varchar[])
243
+ AND q.max_queue_size > 0
244
+ GROUP BY q.name, q.max_queue_size`,
245
+ [queuesToCheck]
246
+ );
247
+
248
+ // Check each queue's capacity
249
+ for (const row of capacityCheck.rows) {
250
+ const currentDepth = parseInt(row.current_depth);
251
+ const batchSize = queueBatchSizes[row.name];
252
+ const maxSize = parseInt(row.max_queue_size);
253
+ if (currentDepth + batchSize > maxSize) {
254
+ throw new Error(`Queue '${row.name}' would exceed max capacity (${maxSize}). Current: ${currentDepth}, Batch: ${batchSize}`);
255
+ }
256
+ }
257
+ });
258
+ }
259
+
142
260
  // Process each partition group
143
261
  for (const [partitionKey, partitionItems] of Object.entries(partitionGroups)) {
144
262
  const [queueName, partitionName] = partitionKey.split(':');
@@ -154,27 +272,6 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
154
272
  const resources = await ensureResources(client, queueName, partitionName);
155
273
  const { partitionId, encryptionEnabled, queueConfig } = resources;
156
274
 
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
275
  // Process items in batches
179
276
  const batchResults = [];
180
277
 
@@ -189,29 +286,39 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
189
286
  const encryptedFlags = [];
190
287
  const duplicates = [];
191
288
 
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
289
+ // ⚡ OPTIMIZATION #1: Batch duplicate check - single query for all transaction IDs
290
+ const allTransactionIds = batch.map(item => item.transactionId || generateUUID());
291
+
292
+ // Skip duplicate check if no transaction IDs were explicitly provided (all generated)
293
+ const hasExplicitTxnIds = batch.some(item => item.transactionId);
294
+ let existingTxnIds = new Map();
295
+
296
+ if (hasExplicitTxnIds) {
297
+ // Use more efficient LEFT JOIN approach instead of ANY() for better index usage
198
298
  const dupCheck = await client.query(
199
- 'SELECT id FROM queen.messages WHERE transaction_id = $1',
200
- [transactionId]
299
+ `SELECT t.txn_id as transaction_id, m.id
300
+ FROM UNNEST($1::varchar[]) AS t(txn_id)
301
+ LEFT JOIN queen.messages m ON m.transaction_id = t.txn_id
302
+ WHERE m.id IS NOT NULL`,
303
+ [allTransactionIds]
201
304
  );
202
305
 
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
- }
306
+ // Create a map of existing transaction IDs for O(1) lookup
307
+ existingTxnIds = new Map(dupCheck.rows.map(row => [row.transaction_id, row.id]));
308
+ }
309
+
310
+ // ⚡ OPTIMIZATION #2: Parallel encryption - encrypt all payloads concurrently
311
+ const encryptionTasks = batch.map(async (item, idx) => {
312
+ const transactionId = allTransactionIds[idx];
211
313
 
212
- messageIds.push(messageId);
213
- transactionIds.push(transactionId);
214
- traceIds.push(traceId);
314
+ // Check if this transaction ID already exists
315
+ if (existingTxnIds.has(transactionId)) {
316
+ return {
317
+ isDuplicate: true,
318
+ id: existingTxnIds.get(transactionId),
319
+ transactionId
320
+ };
321
+ }
215
322
 
216
323
  // Handle encryption if enabled
217
324
  let payload = item.payload;
@@ -222,34 +329,61 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
222
329
  payload = await encryption.encryptPayload(item.payload);
223
330
  isEncrypted = true;
224
331
  } catch (error) {
225
- log(`ERROR: Encryption failed for message ${messageId}:`, error);
332
+ log(`ERROR: Encryption failed for transaction ${transactionId}:`, error);
226
333
  // Continue with unencrypted payload
227
334
  }
228
335
  }
229
336
 
337
+ return {
338
+ isDuplicate: false,
339
+ messageId: generateUUID(),
340
+ transactionId,
341
+ traceId: item.traceId || null,
342
+ payload,
343
+ isEncrypted
344
+ };
345
+ });
346
+
347
+ // Wait for all encryption tasks to complete in parallel
348
+ const encryptionResults = await Promise.all(encryptionTasks);
349
+
350
+ // Process results
351
+ for (const result of encryptionResults) {
352
+ if (result.isDuplicate) {
353
+ duplicates.push({
354
+ id: result.id,
355
+ transactionId: result.transactionId,
356
+ status: 'duplicate'
357
+ });
358
+ continue;
359
+ }
360
+
230
361
  // Handle large payloads and special cases
231
362
  let jsonPayload;
232
363
  try {
233
364
  // Handle null/undefined payloads - convert to JSON null string
234
- if (payload === null || payload === undefined) {
365
+ if (result.payload === null || result.payload === undefined) {
235
366
  jsonPayload = 'null'; // JSONB expects the string 'null' for null values
236
367
  } else {
237
368
  // Try to stringify the payload
238
- jsonPayload = JSON.stringify(payload);
369
+ jsonPayload = JSON.stringify(result.payload);
239
370
  }
240
371
  } catch (error) {
241
372
  // If JSON.stringify fails (e.g., circular references, too large),
242
373
  // store as a string representation
243
- log(`WARN: Failed to stringify payload for message ${messageId}:`, error.message);
374
+ log(`WARN: Failed to stringify payload for message ${result.messageId}:`, error.message);
244
375
  jsonPayload = JSON.stringify({
245
376
  error: 'Payload serialization failed',
246
- type: typeof payload,
377
+ type: typeof result.payload,
247
378
  message: error.message
248
379
  });
249
380
  }
250
381
 
382
+ messageIds.push(result.messageId);
383
+ transactionIds.push(result.transactionId);
384
+ traceIds.push(result.traceId);
251
385
  payloads.push(jsonPayload);
252
- encryptedFlags.push(isEncrypted);
386
+ encryptedFlags.push(result.isEncrypted);
253
387
  }
254
388
 
255
389
  // Batch insert messages with trace_id
@@ -316,601 +450,24 @@ export const createOptimizedQueueManager = (pool, resourceCache, eventManager, s
316
450
  }
317
451
  }
318
452
 
453
+ const duration = Date.now() - pushStartTime;
454
+ log(`[PUSH] Pushed ${items.length} items → ${allResults.length} results in ${duration}ms (${(items.length / (duration / 1000)).toFixed(0)} msg/s)`);
319
455
  return allResults;
320
456
  };
321
457
 
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
458
 
710
459
  // Acknowledge messages (now per consumer group)
460
+ // CURSOR-BASED: Single ACK now routes through batch ACK to advance cursor properly
711
461
  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
- });
462
+ const batchResult = await acknowledgeMessages([{ transactionId, status, error }], consumerGroup);
463
+ return batchResult[0] || { status: 'not_found', transaction_id: transactionId };
909
464
  };
910
465
 
911
- // Batch acknowledge messages
466
+ // Batch acknowledge messages with cursor-based advancement
912
467
  const acknowledgeMessages = async (acknowledgments, consumerGroup = null) => {
468
+ const ackStartTime = Date.now();
913
469
  const results = [];
470
+ const actualConsumerGroup = consumerGroup || '__QUEUE_MODE__';
914
471
 
915
472
  // Group by status for efficient processing
916
473
  const grouped = {
@@ -924,113 +481,190 @@ const popMessages = async (scope, options = {}) => {
924
481
  }
925
482
  });
926
483
 
484
+ const totalMessages = acknowledgments.length;
485
+ const failedCount = grouped.failed.length;
486
+ const successCount = grouped.completed.length;
487
+
488
+ // Determine if this is a TOTAL batch failure or partial failure
489
+ // Only treat as "total batch failure" if it's a real batch (>1 message)
490
+ // Single-message failures always go to DLQ
491
+ const isTotalBatchFailure = failedCount === totalMessages && totalMessages > 1;
492
+
927
493
  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__'`;
494
+ // CURSOR-BASED APPROACH: Handle total batch failure vs partial failure differently
495
+
496
+ if (isTotalBatchFailure) {
497
+ // ═══════════════════════════════════════════════════════════════
498
+ // TOTAL BATCH FAILURE: Don't advance cursor, allow retry
499
+ // ═══════════════════════════════════════════════════════════════
500
+ log(`[CURSOR] Total batch failure - cursor NOT advanced, same batch will retry on next POP`);
501
+ const ackTime = Date.now() - ackStartTime;
502
+ const throughput = ackTime > 0 ? (totalMessages / (ackTime / 1000)).toFixed(0) : 0;
946
503
 
947
- const params = consumerGroup ? [ids, consumerGroup] : [ids];
948
- await client.query(updateQuery, params);
504
+ log(`[${LogTypes.ACK_BATCH}] Total batch failure: ${failedCount} msgs | ${throughput} msg/s | ${ackTime}ms | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'}`);
949
505
 
950
- // Release partition leases for partitions with no more processing messages
951
- const releaseQuery = `
506
+ // Just release the lease - next POP will get same batch
507
+ const allIds = acknowledgments.map(a => a.transactionId);
508
+ await client.query(`
952
509
  WITH affected_partitions AS (
953
510
  SELECT DISTINCT m.partition_id
954
511
  FROM queen.messages m
955
512
  WHERE m.transaction_id = ANY($1::varchar[])
956
513
  )
957
- UPDATE queen.partition_leases pl
958
- SET released_at = NOW()
514
+ UPDATE queen.partition_consumers pc
515
+ SET lease_expires_at = NULL,
516
+ lease_acquired_at = NULL,
517
+ message_batch = NULL,
518
+ batch_size = 0,
519
+ acked_count = 0
959
520
  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
- )`;
521
+ WHERE pc.partition_id = ap.partition_id
522
+ AND pc.consumer_group = $2
523
+ `, [allIds, actualConsumerGroup]);
524
+
525
+ grouped.failed.forEach(ack => {
526
+ results.push({ transactionId: ack.transactionId, status: 'failed_retry' });
527
+ });
528
+
529
+ } else {
530
+ // ═══════════════════════════════════════════════════════════════
531
+ // PARTIAL SUCCESS: Advance cursor, move failed to DLQ
532
+ // ═══════════════════════════════════════════════════════════════
533
+
534
+ // Get all messages in this batch (successful and failed) to find cursor position
535
+ const allIds = acknowledgments.map(a => a.transactionId);
536
+ const batchInfo = await client.query(`
537
+ SELECT
538
+ m.id,
539
+ m.transaction_id,
540
+ m.created_at,
541
+ m.partition_id,
542
+ p.name as partition_name
543
+ FROM queen.messages m
544
+ JOIN queen.partitions p ON p.id = m.partition_id
545
+ WHERE m.transaction_id = ANY($1::varchar[])
546
+ ORDER BY m.created_at DESC, m.id DESC
547
+ LIMIT 1
548
+ `, [allIds]);
549
+
550
+ if (batchInfo.rows.length === 0) {
551
+ throw new Error('No messages found for acknowledgment');
552
+ }
971
553
 
972
- await client.query(releaseQuery, [ids, actualConsumerGroup]);
554
+ const lastMessage = batchInfo.rows[0];
555
+ const partitionId = lastMessage.partition_id;
556
+ const partitionName = lastMessage.partition_name;
973
557
 
974
- log(`${LogTypes.ACK_BATCH} | Status: completed | Count: ${ids.length} | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'} | TransactionIds: [${ids.join(', ')}]`);
558
+ // Advance cursor + update lease atomically in partition_consumers
559
+ const cursorUpdateResult = await client.query(`
560
+ UPDATE queen.partition_consumers
561
+ SET
562
+ -- Advance cursor (regardless of success/failure)
563
+ last_consumed_created_at = $1,
564
+ last_consumed_id = $2,
565
+ total_messages_consumed = total_messages_consumed + $3,
566
+ total_batches_consumed = total_batches_consumed + 1,
567
+ last_consumed_at = NOW(),
568
+
569
+ -- Update pending estimate
570
+ pending_estimate = GREATEST(0, pending_estimate - $3),
571
+ last_stats_update = NOW(),
572
+
573
+ -- Update/release lease
574
+ acked_count = acked_count + $3,
575
+ lease_expires_at = CASE
576
+ WHEN acked_count + $3 >= batch_size AND batch_size > 0
577
+ THEN NULL
578
+ ELSE lease_expires_at
579
+ END,
580
+ lease_acquired_at = CASE
581
+ WHEN acked_count + $3 >= batch_size AND batch_size > 0
582
+ THEN NULL
583
+ ELSE lease_acquired_at
584
+ END,
585
+ message_batch = CASE
586
+ WHEN acked_count + $3 >= batch_size AND batch_size > 0
587
+ THEN NULL
588
+ ELSE message_batch
589
+ END,
590
+ batch_size = CASE
591
+ WHEN acked_count + $3 >= batch_size AND batch_size > 0
592
+ THEN 0
593
+ ELSE batch_size
594
+ END
595
+ WHERE partition_id = $4
596
+ AND consumer_group = $5
597
+ RETURNING total_messages_consumed, total_batches_consumed,
598
+ (lease_expires_at IS NULL) as lease_released
599
+ `, [lastMessage.created_at, lastMessage.id, totalMessages, partitionId, actualConsumerGroup]);
975
600
 
601
+ const newTotalConsumed = cursorUpdateResult.rows[0]?.total_messages_consumed || 0;
602
+ const newTotalBatches = cursorUpdateResult.rows[0]?.total_batches_consumed || 0;
603
+ const leaseReleased = cursorUpdateResult.rows[0]?.lease_released || false;
604
+
605
+ const ackTime = Date.now() - ackStartTime;
606
+ const throughput = ackTime > 0 ? (totalMessages / (ackTime / 1000)).toFixed(0) : 0;
607
+
608
+ log(`[${LogTypes.ACK_BATCH}] Success: ${successCount} | Failed: ${failedCount} | ${throughput} msg/s | ${ackTime}ms | Cursor: ${newTotalConsumed} msgs | Lease: ${leaseReleased ? 'Released' : 'Active'} | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'}`);
609
+
610
+ // Move failed messages to DLQ (individual failures, not batch retry)
611
+ if (grouped.failed.length > 0) {
612
+ const failedIds = grouped.failed.map(a => a.transactionId);
613
+ const failedErrors = grouped.failed.map(a => a.error || 'Unknown error');
614
+
615
+ await client.query(`
616
+ INSERT INTO queen.dead_letter_queue (message_id, partition_id, consumer_group, error_message, original_created_at)
617
+ SELECT
618
+ m.id,
619
+ m.partition_id,
620
+ $2,
621
+ e.error_message,
622
+ m.created_at
623
+ FROM queen.messages m
624
+ CROSS JOIN LATERAL UNNEST($1::varchar[], $3::text[]) AS e(txn_id, error_message)
625
+ WHERE m.transaction_id = e.txn_id
626
+ AND m.transaction_id = ANY($1::varchar[])
627
+ ON CONFLICT DO NOTHING
628
+ `, [failedIds, actualConsumerGroup, failedErrors]);
629
+
630
+ log(`[${LogTypes.ACK_BATCH}] Moved ${failedCount} failed messages to DLQ | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'}`);
631
+ }
632
+
633
+ // Track consumption metrics for time-series analytics
634
+ await client.query(`
635
+ INSERT INTO queen.messages_consumed (partition_id, consumer_group, messages_completed, messages_failed)
636
+ VALUES ($1, $2, $3, $4)
637
+ `, [partitionId, actualConsumerGroup, successCount, failedCount]);
638
+
639
+ // Add results
976
640
  grouped.completed.forEach(ack => {
977
641
  results.push({ transactionId: ack.transactionId, status: 'completed' });
978
642
  });
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 });
643
+
644
+ grouped.failed.forEach(ack => {
645
+ results.push({ transactionId: ack.transactionId, status: 'failed_dlq' });
646
+ });
985
647
  }
986
648
  });
987
649
 
988
650
  return results;
989
651
  };
990
652
 
991
- // Reclaim expired leases (now includes partition leases)
653
+ // Reclaim expired leases from partition_consumers
992
654
  const reclaimExpiredLeases = async () => {
993
655
  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
656
+ // Clear expired leases from partition_consumers
1025
657
  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'
658
+ `UPDATE queen.partition_consumers
659
+ SET lease_expires_at = NULL,
660
+ lease_acquired_at = NULL,
661
+ message_batch = NULL,
662
+ batch_size = 0,
663
+ acked_count = 0,
664
+ worker_id = NULL
665
+ WHERE lease_expires_at IS NOT NULL
1032
666
  AND lease_expires_at < NOW()
1033
- RETURNING message_id, consumer_group`
667
+ RETURNING partition_id, consumer_group`
1034
668
  );
1035
669
 
1036
670
  if (result.rows.length > 0) {
@@ -1041,7 +675,7 @@ const popMessages = async (scope, options = {}) => {
1041
675
  });
1042
676
 
1043
677
  Object.entries(byGroup).forEach(([group, count]) => {
1044
- log(`Reclaimed ${count} expired leases for consumer group: ${group}`);
678
+ log(`${LogTypes.RECLAIM} | Reclaimed ${count} expired leases for consumer group: ${group}`);
1045
679
  });
1046
680
  }
1047
681
 
@@ -1049,7 +683,7 @@ const popMessages = async (scope, options = {}) => {
1049
683
  });
1050
684
  };
1051
685
 
1052
- // Get queue statistics (updated for new structure)
686
+ // Get queue statistics (using partition_consumers for fast estimates)
1053
687
  const getQueueStats = async (filters = {}) => {
1054
688
  const { queue, namespace, task, fromDateTime, toDateTime } = filters;
1055
689
 
@@ -1059,19 +693,26 @@ const popMessages = async (scope, options = {}) => {
1059
693
  q.namespace,
1060
694
  q.task,
1061
695
  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
696
+ COALESCE(pc.pending_estimate, 0) as pending,
697
+ CASE WHEN pc.lease_expires_at IS NOT NULL THEN 1 ELSE 0 END as processing,
698
+ COALESCE(pc.total_messages_consumed, 0) as completed,
699
+ 0 as failed,
700
+ (SELECT COUNT(*) FROM queen.dead_letter_queue dlq
701
+ WHERE dlq.partition_id = p.id
702
+ AND dlq.consumer_group = COALESCE(pc.consumer_group, '__QUEUE_MODE__')) as dead_letter,
703
+ (SELECT COUNT(*) FROM queen.messages m
704
+ WHERE m.partition_id = p.id
705
+ ${fromDateTime ? 'AND m.created_at >= $' + (1 + (queue ? 1 : 0) + (namespace ? 1 : 0) + (task ? 1 : 0)) + '::timestamp' : ''}
706
+ ${toDateTime ? 'AND m.created_at <= $' + (1 + (queue ? 1 : 0) + (namespace ? 1 : 0) + (task ? 1 : 0) + (fromDateTime ? 1 : 0)) + '::timestamp' : ''}
707
+ ) as total_messages,
708
+ (SELECT COUNT(DISTINCT consumer_group)
709
+ FROM queen.partition_consumers
710
+ WHERE partition_id = p.id
711
+ AND consumer_group != '__QUEUE_MODE__') as consumer_groups_count
1070
712
  FROM queen.queues q
1071
713
  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
714
+ LEFT JOIN queen.partition_consumers pc ON pc.partition_id = p.id
715
+ AND pc.consumer_group = '__QUEUE_MODE__'
1075
716
  `;
1076
717
 
1077
718
  const conditions = [];
@@ -1092,14 +733,12 @@ const popMessages = async (scope, options = {}) => {
1092
733
  params.push(task);
1093
734
  }
1094
735
 
1095
- // Add date/time filtering
736
+ // Add date params for subqueries
1096
737
  if (fromDateTime) {
1097
- conditions.push(`m.created_at >= $${params.length + 1}::timestamp`);
1098
738
  params.push(fromDateTime);
1099
739
  }
1100
740
 
1101
741
  if (toDateTime) {
1102
- conditions.push(`m.created_at <= $${params.length + 1}::timestamp`);
1103
742
  params.push(toDateTime);
1104
743
  }
1105
744
 
@@ -1107,14 +746,13 @@ const popMessages = async (scope, options = {}) => {
1107
746
  query += ` WHERE ${conditions.join(' AND ')}`;
1108
747
  }
1109
748
 
1110
- query += ` GROUP BY q.name, q.namespace, q.task, p.name, cg.name
1111
- ORDER BY q.name, p.name, cg.name`;
749
+ query += ` ORDER BY q.name, p.name`;
1112
750
 
1113
751
  const result = await pool.query(query, params);
1114
752
  return result.rows;
1115
753
  };
1116
754
 
1117
- // Get queue lag statistics
755
+ // Get queue lag statistics (simplified with partition_consumers)
1118
756
  const getQueueLag = async (filters = {}) => {
1119
757
  const { queue, namespace, task, fromDateTime, toDateTime } = filters;
1120
758
 
@@ -1125,49 +763,32 @@ const popMessages = async (scope, options = {}) => {
1125
763
  q.namespace,
1126
764
  q.task,
1127
765
  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
766
+ COALESCE(pc.pending_estimate, 0) as pending_count,
767
+ CASE WHEN pc.lease_expires_at IS NOT NULL THEN 1 ELSE 0 END as processing_count,
768
+ COALESCE(pc.pending_estimate, 0) + CASE WHEN pc.lease_expires_at IS NOT NULL THEN 1 ELSE 0 END as total_backlog,
769
+ COALESCE(pc.total_messages_consumed, 0) as completed_messages,
770
+ 0 as avg_processing_time_seconds,
771
+ 0 as median_processing_time_seconds,
772
+ 0 as p95_processing_time_seconds,
773
+ (SELECT MIN(created_at) FROM queen.messages m
774
+ WHERE m.partition_id = p.id
775
+ AND (m.created_at, m.id) > (COALESCE(pc.last_consumed_created_at, '1970-01-01'::timestamptz),
776
+ COALESCE(pc.last_consumed_id, '00000000-0000-0000-0000-000000000000'::uuid))
777
+ ) as oldest_unprocessed,
778
+ (SELECT MAX(created_at) FROM queen.messages m
779
+ WHERE m.partition_id = p.id
780
+ AND (m.created_at, m.id) <= (COALESCE(pc.last_consumed_created_at, NOW()::timestamptz),
781
+ COALESCE(pc.last_consumed_id, gen_random_uuid()))
782
+ ) as newest_completed
1158
783
  FROM queen.queues q
1159
784
  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__'
785
+ LEFT JOIN queen.partition_consumers pc ON pc.partition_id = p.id
786
+ AND pc.consumer_group = '__QUEUE_MODE__'
1162
787
  `;
1163
788
 
1164
789
  const conditions = [];
1165
790
  const params = [];
1166
791
 
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
792
  if (queue) {
1172
793
  conditions.push(`q.name = $${params.length + 1}`);
1173
794
  params.push(queue);
@@ -1187,7 +808,9 @@ const popMessages = async (scope, options = {}) => {
1187
808
  query += ` WHERE ${conditions.join(' AND ')}`;
1188
809
  }
1189
810
 
1190
- query += ` GROUP BY q.name, q.namespace, q.task, p.name
811
+ query += ` GROUP BY q.name, q.namespace, q.task, p.name, pc.pending_estimate,
812
+ pc.lease_expires_at, pc.total_messages_consumed,
813
+ pc.last_consumed_created_at, pc.last_consumed_id, p.id
1191
814
  )
1192
815
  SELECT
1193
816
  queue,
@@ -1199,9 +822,9 @@ const popMessages = async (scope, options = {}) => {
1199
822
  'processingCount', processing_count,
1200
823
  'totalBacklog', total_backlog,
1201
824
  '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),
825
+ 'avgProcessingTimeSeconds', avg_processing_time_seconds,
826
+ 'medianProcessingTimeSeconds', median_processing_time_seconds,
827
+ 'p95ProcessingTimeSeconds', p95_processing_time_seconds,
1205
828
  'estimatedLagSeconds', CASE
1206
829
  WHEN oldest_unprocessed IS NOT NULL
1207
830
  THEN EXTRACT(EPOCH FROM (NOW() - oldest_unprocessed))
@@ -1225,165 +848,6 @@ const popMessages = async (scope, options = {}) => {
1225
848
  return result.rows;
1226
849
  };
1227
850
 
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
851
  /**
1388
852
  * Configure queue settings with namespace and task support
1389
853
  * Updates queue configuration options and optionally sets namespace/task
@@ -1495,18 +959,444 @@ const popMessages = async (scope, options = {}) => {
1495
959
  });
1496
960
  };
1497
961
 
962
+ /**
963
+ * ═══════════════════════════════════════════════════════════════════════════
964
+ * UNIFIED POP FUNCTION - Handles all 3 access patterns in one implementation
965
+ * ═══════════════════════════════════════════════════════════════════════════
966
+ *
967
+ * This function unifies:
968
+ * 1. Direct partition access (queue + partition specified)
969
+ * 2. Queue-level access (queue only, find unlocked partition)
970
+ * 3. Filtered access (namespace/task, find matching queues + partitions)
971
+ *
972
+ * Benefits over separate functions:
973
+ * - Single source of truth for lease acquisition
974
+ * - Consistent multi-candidate retry logic
975
+ * - Easier to maintain and optimize
976
+ * - Shared backpressure and metrics
977
+ */
978
+ const uniquePop = async (scope, options = {}) => {
979
+ const {
980
+ queue,
981
+ partition,
982
+ namespace,
983
+ task,
984
+ consumerGroup
985
+ } = scope;
986
+
987
+ const {
988
+ wait = false,
989
+ timeout = config.QUEUE.DEFAULT_TIMEOUT,
990
+ batch = config.QUEUE.DEFAULT_BATCH_SIZE,
991
+ subscriptionMode = null,
992
+ subscriptionFrom = null
993
+ } = options;
994
+
995
+ const startTime = Date.now();
996
+ const actualConsumerGroup = consumerGroup || '__QUEUE_MODE__';
997
+
998
+ // Determine access mode
999
+ const accessMode = partition ? 'direct' : (namespace || task ? 'filtered' : 'queue');
1000
+
1001
+ return withTransaction(pool, async (client) => {
1002
+ const connectionWaitTime = Date.now() - startTime;
1003
+ if (connectionWaitTime > 50) {
1004
+ log(`⚠️ : ${connectionWaitTime}ms`);
1005
+ }
1006
+
1007
+ // ─────────────────────────────────────────────────────────────────────
1008
+ // PHASE 1: Find Candidate Partitions (mode-specific queries)
1009
+ // ─────────────────────────────────────────────────────────────────────
1010
+
1011
+ const candidateQueryStart = Date.now();
1012
+ let candidatePartitions = [];
1013
+ let queueInfo = null;
1014
+
1015
+ if (accessMode === 'direct') {
1016
+ // CASE 1: Direct partition access - single partition, no search needed
1017
+ const result = await client.query(`
1018
+ SELECT
1019
+ q.id as queue_id,
1020
+ q.name as queue_name,
1021
+ q.lease_time,
1022
+ q.retry_limit,
1023
+ q.delayed_processing,
1024
+ q.max_wait_time_seconds,
1025
+ q.window_buffer,
1026
+ q.priority,
1027
+ p.id as partition_id,
1028
+ p.name as partition_name
1029
+ FROM queen.queues q
1030
+ JOIN queen.partitions p ON p.queue_id = q.id
1031
+ WHERE q.name = $1 AND p.name = $2
1032
+ `, [queue, partition]);
1033
+
1034
+ if (result.rows.length > 0) {
1035
+ candidatePartitions = [result.rows[0]];
1036
+ queueInfo = result.rows[0];
1037
+ }
1038
+
1039
+ } else if (accessMode === 'queue') {
1040
+ // CASE 2: Queue-level access - find unlocked partitions
1041
+ const result = await client.query(`
1042
+ SELECT
1043
+ q.id as queue_id,
1044
+ q.name as queue_name,
1045
+ q.lease_time,
1046
+ q.retry_limit,
1047
+ q.delayed_processing,
1048
+ q.max_wait_time_seconds,
1049
+ q.window_buffer,
1050
+ q.priority,
1051
+ p.id as partition_id,
1052
+ p.name as partition_name
1053
+ FROM queen.queues q
1054
+ JOIN queen.partitions p ON p.queue_id = q.id
1055
+ WHERE q.name = $2
1056
+ AND NOT EXISTS (
1057
+ SELECT 1 FROM queen.partition_consumers pc
1058
+ WHERE pc.partition_id = p.id
1059
+ AND pc.consumer_group = $1
1060
+ AND pc.lease_expires_at IS NOT NULL
1061
+ AND pc.lease_expires_at > NOW()
1062
+ )
1063
+ ORDER BY RANDOM()
1064
+ LIMIT $3
1065
+ `, [actualConsumerGroup, queue, config.QUEUE.MAX_PARTITION_CANDIDATES]);
1066
+
1067
+ if (result.rows.length > 0) {
1068
+ candidatePartitions = result.rows;
1069
+ queueInfo = result.rows[0];
1070
+ }
1071
+
1072
+ } else {
1073
+ // CASE 3: Filtered access - namespace/task
1074
+ let filterQuery = `
1075
+ SELECT
1076
+ q.id as queue_id,
1077
+ q.name as queue_name,
1078
+ q.lease_time,
1079
+ q.retry_limit,
1080
+ q.delayed_processing,
1081
+ q.max_wait_time_seconds,
1082
+ q.window_buffer,
1083
+ q.priority,
1084
+ p.id as partition_id,
1085
+ p.name as partition_name,
1086
+ MIN(m.created_at) as oldest_message
1087
+ FROM queen.messages m
1088
+ JOIN queen.partitions p ON m.partition_id = p.id
1089
+ JOIN queen.queues q ON p.queue_id = q.id
1090
+ LEFT JOIN queen.partition_consumers pc ON p.id = pc.partition_id
1091
+ AND pc.consumer_group = $1
1092
+ WHERE m.id > COALESCE(pc.last_consumed_id, '00000000-0000-0000-0000-000000000000'::uuid)
1093
+ AND (q.delayed_processing = 0 OR m.created_at <= NOW() - INTERVAL '1 second' * q.delayed_processing)
1094
+ AND (q.max_wait_time_seconds = 0 OR
1095
+ m.created_at > NOW() - INTERVAL '1 second' * q.max_wait_time_seconds)
1096
+ AND (q.window_buffer = 0 OR NOT EXISTS (
1097
+ SELECT 1 FROM queen.messages m2
1098
+ WHERE m2.partition_id = p.id
1099
+ AND m2.created_at > NOW() - INTERVAL '1 second' * q.window_buffer
1100
+ ))
1101
+ AND NOT EXISTS (
1102
+ SELECT 1 FROM queen.partition_consumers pc2
1103
+ WHERE pc2.partition_id = p.id
1104
+ AND pc2.consumer_group = $1
1105
+ AND pc2.lease_expires_at IS NOT NULL
1106
+ AND pc2.lease_expires_at > NOW()
1107
+ )
1108
+ `;
1109
+
1110
+ const params = [actualConsumerGroup];
1111
+
1112
+ if (namespace) {
1113
+ params.push(namespace);
1114
+ filterQuery += ` AND q.namespace = $${params.length}`;
1115
+ }
1116
+
1117
+ if (task) {
1118
+ params.push(task);
1119
+ filterQuery += ` AND q.task = $${params.length}`;
1120
+ }
1121
+
1122
+ params.push(config.QUEUE.MAX_PARTITION_CANDIDATES);
1123
+ filterQuery += `
1124
+ GROUP BY q.id, q.name, q.lease_time, q.retry_limit, q.delayed_processing,
1125
+ q.max_wait_time_seconds, q.window_buffer, q.priority,
1126
+ p.id, p.name
1127
+ HAVING COUNT(*) > 0
1128
+ ORDER BY q.priority DESC, MIN(m.created_at) ASC, RANDOM()
1129
+ LIMIT $${params.length}
1130
+ `;
1131
+
1132
+ const result = await client.query(filterQuery, params);
1133
+
1134
+ if (result.rows.length > 0) {
1135
+ candidatePartitions = result.rows;
1136
+ queueInfo = result.rows[0];
1137
+ }
1138
+ }
1139
+
1140
+ const candidateQueryTime = Date.now() - candidateQueryStart;
1141
+
1142
+ if (candidatePartitions.length === 0) {
1143
+ return { messages: [] };
1144
+ }
1145
+
1146
+ // ─────────────────────────────────────────────────────────────────────
1147
+ // PHASE 2: Try to Acquire Lease on a Candidate Partition (shared logic)
1148
+ // ─────────────────────────────────────────────────────────────────────
1149
+
1150
+ let acquiredPartition = null;
1151
+ const MAX_ATTEMPTS = accessMode === 'direct' ? 1 : 3;
1152
+
1153
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
1154
+ // Shuffle candidates to distribute across consumers
1155
+ const shuffled = [...candidatePartitions];
1156
+ for (let i = shuffled.length - 1; i > 0; i--) {
1157
+ const j = Math.floor(Math.random() * (i + 1));
1158
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
1159
+ }
1160
+
1161
+ // Try to acquire lease on each candidate
1162
+ for (const candidate of shuffled) {
1163
+ const leaseAcqStart = Date.now();
1164
+
1165
+ // Try to acquire lease - check if lease is available based on DB state
1166
+ // The key insight: released_at IS NULL means someone holds the lease
1167
+ // We can only acquire if: released_at IS NOT NULL OR lease expired
1168
+ try {
1169
+ // Get or create cursor position for this partition/consumer group
1170
+ // Handle subscription modes for bus mode (new-only, from-timestamp, etc.)
1171
+ let initialCursorId = '00000000-0000-0000-0000-000000000000';
1172
+ let initialCursorTimestamp = null;
1173
+
1174
+ // For bus mode with subscription preferences
1175
+ if (consumerGroup && (subscriptionMode || subscriptionFrom)) {
1176
+ if (subscriptionMode === 'new' || subscriptionMode === 'new-only' || subscriptionFrom === 'now') {
1177
+ // Start from NOW - get the latest message ID to use as cursor
1178
+ const latestMsg = await client.query(`
1179
+ SELECT id, created_at FROM queen.messages
1180
+ WHERE partition_id = $1
1181
+ ORDER BY created_at DESC, id DESC
1182
+ LIMIT 1
1183
+ `, [candidate.partition_id]);
1184
+
1185
+ if (latestMsg.rows.length > 0) {
1186
+ initialCursorId = latestMsg.rows[0].id;
1187
+ initialCursorTimestamp = latestMsg.rows[0].created_at;
1188
+ }
1189
+ }
1190
+ // else: subscriptionFrom='all' or no preference → start from beginning (00000000...)
1191
+ }
1192
+
1193
+ // Step 1: Ensure cursor exists (idempotent)
1194
+ await client.query(`
1195
+ INSERT INTO queen.partition_consumers (
1196
+ partition_id,
1197
+ consumer_group,
1198
+ last_consumed_id,
1199
+ last_consumed_created_at
1200
+ )
1201
+ VALUES ($1, $2, $3, $4)
1202
+ ON CONFLICT (partition_id, consumer_group) DO NOTHING
1203
+ `, [candidate.partition_id, actualConsumerGroup, initialCursorId, initialCursorTimestamp]);
1204
+
1205
+ // Step 2: Try to acquire lease and get cursor position
1206
+ const leaseResult = await client.query(`
1207
+ UPDATE queen.partition_consumers
1208
+ SET lease_expires_at = NOW() + INTERVAL '1 second' * $3,
1209
+ lease_acquired_at = NOW(),
1210
+ message_batch = NULL,
1211
+ batch_size = 0,
1212
+ acked_count = 0
1213
+ WHERE partition_id = $1
1214
+ AND consumer_group = $2
1215
+ AND (lease_expires_at IS NULL OR lease_expires_at <= NOW())
1216
+ RETURNING partition_id, last_consumed_id, last_consumed_created_at,
1217
+ total_messages_consumed, lease_expires_at
1218
+ `, [candidate.partition_id, actualConsumerGroup, candidate.lease_time]);
1219
+
1220
+ // Check if we acquired the lease
1221
+ const acquired = leaseResult.rows.length > 0;
1222
+ const result = leaseResult.rows[0];
1223
+
1224
+ if (acquired) {
1225
+ // Lease acquired! Now check if this partition has messages
1226
+ // If not, release and try next candidate
1227
+ const messageQueryStart = Date.now();
1228
+
1229
+ const cursor = result;
1230
+ const cursorId = cursor.last_consumed_id || '00000000-0000-0000-0000-000000000000';
1231
+ const totalConsumed = cursor.total_messages_consumed || 0;
1232
+
1233
+ // Cursor-based query using ONLY UUID comparison (UUIDv7 is time-ordered)
1234
+ // Conditionally add timestamp filters only when configured
1235
+ const hasDelayedProcessing = candidate.delayed_processing > 0;
1236
+ const hasEviction = candidate.max_wait_time_seconds > 0;
1237
+
1238
+ let whereClause = 'm.partition_id = $4 AND m.id > $5::uuid';
1239
+ let queryParams = [
1240
+ candidate.partition_name,
1241
+ candidate.queue_name,
1242
+ candidate.priority || 0,
1243
+ candidate.partition_id,
1244
+ cursorId
1245
+ ];
1246
+ let paramIndex = 6;
1247
+
1248
+ if (hasDelayedProcessing) {
1249
+ whereClause += ` AND m.created_at <= NOW() - INTERVAL '1 second' * $${paramIndex}`;
1250
+ queryParams.push(candidate.delayed_processing);
1251
+ paramIndex++;
1252
+ }
1253
+
1254
+ if (hasEviction) {
1255
+ whereClause += ` AND m.created_at > NOW() - INTERVAL '1 second' * $${paramIndex}`;
1256
+ queryParams.push(candidate.max_wait_time_seconds);
1257
+ paramIndex++;
1258
+ }
1259
+
1260
+ queryParams.push(batch);
1261
+ const limitParam = `$${paramIndex}`;
1262
+
1263
+ const messagesResult = await client.query(`
1264
+ SELECT
1265
+ m.id,
1266
+ m.transaction_id,
1267
+ m.trace_id,
1268
+ m.payload,
1269
+ m.is_encrypted,
1270
+ m.created_at,
1271
+ $1 as partition_name,
1272
+ $2 as queue_name,
1273
+ $3 as priority,
1274
+ 0 as retry_count
1275
+ FROM queen.messages m
1276
+ WHERE ${whereClause}
1277
+ ORDER BY m.created_at ASC, m.id ASC
1278
+ LIMIT ${limitParam}
1279
+ FOR UPDATE OF m SKIP LOCKED
1280
+ `, queryParams)
1281
+ .catch(err => {
1282
+ if (err.code === '55P03') { // Lock not available
1283
+ return { rows: [] };
1284
+ }
1285
+ throw err;
1286
+ });
1287
+
1288
+ const messageQueryTime = Date.now() - messageQueryStart;
1289
+ const messages = messagesResult.rows;
1290
+
1291
+ // Check if we found messages
1292
+ if (messages.length === 0) {
1293
+ // No messages in this partition (cursor exhausted) - release lease and try next candidate
1294
+ log(`[CURSOR] Partition ${candidate.partition_name} exhausted after ${totalConsumed} messages`);
1295
+ await client.query(`
1296
+ UPDATE queen.partition_consumers
1297
+ SET lease_expires_at = NULL,
1298
+ lease_acquired_at = NULL
1299
+ WHERE partition_id = $1 AND consumer_group = $2
1300
+ `, [candidate.partition_id, actualConsumerGroup]);
1301
+ continue; // Try next candidate partition
1302
+ }
1303
+
1304
+ // Success! We have messages from this partition
1305
+ acquiredPartition = candidate;
1306
+
1307
+ // ─────────────────────────────────────────────────────────────────────
1308
+ // PHASE 4: Update Partition Lease with Message Batch
1309
+ // ─────────────────────────────────────────────────────────────────────
1310
+
1311
+ const leaseUpdateStart = Date.now();
1312
+ const messageIds = messages.map(m => m.id);
1313
+ await client.query(`
1314
+ UPDATE queen.partition_consumers
1315
+ SET message_batch = $1::jsonb,
1316
+ batch_size = $2::integer,
1317
+ acked_count = 0
1318
+ WHERE partition_id = $3 AND consumer_group = $4
1319
+ `, [JSON.stringify(messageIds), messageIds.length, acquiredPartition.partition_id, actualConsumerGroup]);
1320
+ const leaseUpdateTime = Date.now() - leaseUpdateStart;
1321
+
1322
+ log(`[CURSOR-LEASE] Partition: ${acquiredPartition.partition_name} | Leased ${messageIds.length} messages for processing`);
1323
+
1324
+ // ─────────────────────────────────────────────────────────────────────
1325
+ // PHASE 5: Decrypt and Format Messages
1326
+ // ─────────────────────────────────────────────────────────────────────
1327
+
1328
+ const decryptStart = Date.now();
1329
+ const formattedMessages = await Promise.all(messages.map(async (msg) => {
1330
+ let payload = msg.payload;
1331
+
1332
+ if (msg.is_encrypted && encryption.isEncryptionEnabled()) {
1333
+ try {
1334
+ payload = await encryption.decryptPayload(msg.payload);
1335
+ } catch (error) {
1336
+ log(`${LogTypes.ERROR} | Failed to decrypt message ${msg.transaction_id}: ${error.message}`);
1337
+ }
1338
+ }
1339
+
1340
+ return {
1341
+ id: msg.id,
1342
+ transactionId: msg.transaction_id,
1343
+ traceId: msg.trace_id,
1344
+ queue: msg.queue_name,
1345
+ partition: msg.partition_name,
1346
+ data: payload,
1347
+ payload: payload,
1348
+ retryCount: msg.retry_count || 0,
1349
+ priority: msg.priority || 0,
1350
+ createdAt: msg.created_at,
1351
+ consumerGroup: consumerGroup || null
1352
+ };
1353
+ }));
1354
+ const decryptTime = Date.now() - decryptStart;
1355
+
1356
+ // ─────────────────────────────────────────────────────────────────────
1357
+ // PHASE 6: Metrics and Logging
1358
+ // ─────────────────────────────────────────────────────────────────────
1359
+
1360
+ const totalTime = Date.now() - startTime;
1361
+ const overhead = totalTime - (connectionWaitTime + candidateQueryTime + messageQueryTime + leaseUpdateTime + decryptTime);
1362
+ const throughput = totalTime > 0 ? (formattedMessages.length / (totalTime / 1000)).toFixed(0) : 0;
1363
+
1364
+ log(`[${LogTypes.POP}] Mode: ${accessMode} | Count: ${formattedMessages.length} | ${throughput} msg/s | ConsumerGroup: ${actualConsumerGroup === '__QUEUE_MODE__' ? 'QUEUE_MODE' : actualConsumerGroup}`);
1365
+ log(` ⏱️ ConnWait:${connectionWaitTime.toFixed(0)}ms CandidateQ:${candidateQueryTime.toFixed(0)}ms MsgQuery:${messageQueryTime.toFixed(0)}ms LeaseUpd:${leaseUpdateTime.toFixed(0)}ms Decrypt:${decryptTime.toFixed(0)}ms Overhead:${overhead.toFixed(0)}ms Total:${totalTime.toFixed(0)}ms`);
1366
+
1367
+ return { messages: formattedMessages };
1368
+ }
1369
+ } catch (err) {
1370
+ // Unexpected error
1371
+ if (!candidate.queue_name.startsWith('__')) {
1372
+ log(`LEASE ERROR: Partition=${candidate.partition_name} | Error: ${err.message}`);
1373
+ }
1374
+ throw err;
1375
+ }
1376
+ }
1377
+
1378
+ if (acquiredPartition) break;
1379
+ }
1380
+
1381
+ // If we exhausted all attempts and couldn't get messages
1382
+ if (!acquiredPartition) {
1383
+ log(`WARN: Failed to acquire any partition with messages after ${MAX_ATTEMPTS} attempts`);
1384
+ return { messages: [] };
1385
+ }
1386
+ });
1387
+ };
1388
+
1498
1389
  // Export all functions
1499
1390
  return {
1500
1391
  pushMessages: pushMessagesBatch,
1501
1392
  pushMessagesBatch,
1502
- popMessages,
1503
1393
  acknowledgeMessage,
1504
1394
  acknowledgeMessages,
1505
1395
  ackMessage: acknowledgeMessage, // Alias for compatibility
1506
1396
  reclaimExpiredLeases,
1507
1397
  getQueueStats,
1508
1398
  getQueueLag,
1509
- popMessagesWithFilters,
1399
+ uniquePop, // ← New unified function
1510
1400
  configureQueue
1511
1401
  };
1512
1402
  };