queen-mq 0.2.0 → 0.2.22

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 (85) hide show
  1. package/README.md +186 -763
  2. package/{src → client-js}/benchmark/consumer.js +6 -4
  3. package/{src → client-js}/benchmark/producer.js +9 -4
  4. package/{src → client-js}/client/client.js +277 -7
  5. package/{src → client-js}/client/index.js +1 -4
  6. package/{src → client-js}/client/utils/http.js +3 -2
  7. package/{src → client-js}/client/utils/retry.js +7 -1
  8. package/{src → client-js}/test/README.md +30 -9
  9. package/{src → client-js}/test/core-tests.js +7 -2
  10. package/{src → client-js}/test/edge-case-tests.js +2 -1
  11. package/client-js/test/human.js +162 -0
  12. package/client-js/test/partition-transaction-tests.js +482 -0
  13. package/client-js/test/qos0-tests.js +334 -0
  14. package/{src → client-js}/test/test-new.js +55 -0
  15. package/package.json +8 -17
  16. package/init-db.js +0 -20
  17. package/src/cluster-server.js +0 -242
  18. package/src/config.js +0 -229
  19. package/src/database/connection.js +0 -129
  20. package/src/database/poolManager.js +0 -199
  21. package/src/database/schema-v2.sql +0 -278
  22. package/src/managers/eventManager.js +0 -59
  23. package/src/managers/queueManagerOptimized.js +0 -1634
  24. package/src/managers/resourceCache.js +0 -96
  25. package/src/managers/systemEventManager.js +0 -132
  26. package/src/routes/ack.js +0 -26
  27. package/src/routes/configure.js +0 -46
  28. package/src/routes/messages.js +0 -368
  29. package/src/routes/pop.js +0 -69
  30. package/src/routes/push.js +0 -28
  31. package/src/routes/resources.js +0 -330
  32. package/src/routes/status.js +0 -1037
  33. package/src/server.js +0 -1633
  34. package/src/test/MIGRATION_ISSUES.md +0 -174
  35. package/src/test/test.js +0 -4524
  36. package/src/utils/streaming.js +0 -231
  37. package/src/webapp-dist/assets/Analytics-DJrE4Rz2.css +0 -1
  38. package/src/webapp-dist/assets/Analytics-HEMXKmfJ.js +0 -1
  39. package/src/webapp-dist/assets/ConfirmDialog-YEoCdE7x.js +0 -1
  40. package/src/webapp-dist/assets/ConsumerGroups-CC5VJywp.css +0 -1
  41. package/src/webapp-dist/assets/ConsumerGroups-D8Z20HhZ.js +0 -1
  42. package/src/webapp-dist/assets/Dashboard-BRFOz6it.css +0 -1
  43. package/src/webapp-dist/assets/Dashboard-Bpx9I32h.js +0 -1
  44. package/src/webapp-dist/assets/LoadingSpinner-BPttXcbs.js +0 -1
  45. package/src/webapp-dist/assets/Messages-BOfVHKma.css +0 -1
  46. package/src/webapp-dist/assets/Messages-EKURCbLl.js +0 -1
  47. package/src/webapp-dist/assets/QueueDetail-BXXA7xoZ.css +0 -1
  48. package/src/webapp-dist/assets/QueueDetail-Bmxg0KiA.js +0 -1
  49. package/src/webapp-dist/assets/Queues-CvrJkrZT.css +0 -1
  50. package/src/webapp-dist/assets/Queues-yUx9_3-9.js +0 -1
  51. package/src/webapp-dist/assets/StatusBadge-C1bBtXHh.css +0 -1
  52. package/src/webapp-dist/assets/StatusBadge-C6N84XmO.js +0 -1
  53. package/src/webapp-dist/assets/analytics-BapTekLM.js +0 -1
  54. package/src/webapp-dist/assets/colors-5z6Q_kUr.js +0 -18
  55. package/src/webapp-dist/assets/index-B7w2bKT6.css +0 -1
  56. package/src/webapp-dist/assets/index-CUSnwYTH.js +0 -31
  57. package/src/webapp-dist/assets/messages-CRV6E-xU.js +0 -1
  58. package/src/webapp-dist/assets/queen-logo-blue.svg +0 -210
  59. package/src/webapp-dist/assets/queen-logo-cyan.svg +0 -210
  60. package/src/webapp-dist/assets/queen-logo-indigo.svg +0 -210
  61. package/src/webapp-dist/assets/queen-logo-orange.svg +0 -210
  62. package/src/webapp-dist/assets/queen-logo-pink.svg +0 -210
  63. package/src/webapp-dist/assets/queen-logo-purple.svg +0 -210
  64. package/src/webapp-dist/assets/queen-logo-rose.svg +0 -239
  65. package/src/webapp-dist/assets/queen-logo.svg +0 -263
  66. package/src/webapp-dist/assets/queues-BdUbxfBC.js +0 -1
  67. package/src/webapp-dist/assets/resources-Bs9U-ceF.js +0 -1
  68. package/src/webapp-dist/index.html +0 -15
  69. package/src/websocket/wsServer.js +0 -228
  70. /package/{src → client-js}/benchmark/consumer_multi.js +0 -0
  71. /package/{src → client-js}/benchmark/producer_multi.js +0 -0
  72. /package/{src → client-js}/client/utils/loadBalancer.js +0 -0
  73. /package/{src → client-js}/services/encryptionService.js +0 -0
  74. /package/{src → client-js}/services/evictionService.js +0 -0
  75. /package/{src → client-js}/services/retentionService.js +0 -0
  76. /package/{src → client-js}/services/startupSync.js +0 -0
  77. /package/{src → client-js}/test/advanced-client-tests.js +0 -0
  78. /package/{src → client-js}/test/advanced-pattern-tests.js +0 -0
  79. /package/{src → client-js}/test/bus-mode-tests.js +0 -0
  80. /package/{src → client-js}/test/enterprise-tests.js +0 -0
  81. /package/{src → client-js}/test/partition-locking-tests.js +0 -0
  82. /package/{src → client-js}/test/utils.js +0 -0
  83. /package/{src → client-js}/test/window-buffer-test.js +0 -0
  84. /package/{src → client-js}/utils/logger.js +0 -0
  85. /package/{src → client-js}/utils/uuid.js +0 -0
@@ -1,1634 +0,0 @@
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
- // 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
-
90
- // Ensure queue exists and partition exist (with caching)
91
- // Queue must be created via configure endpoint, but partitions can be created on-demand
92
- const ensureResources = async (client, queueName, partitionName = 'Default', namespace = null, task = null) => {
93
- // CRITICAL: System queues bypass cache completely
94
- if (queueName.startsWith('__system_')) {
95
- // Direct database query for system queues
96
- const result = await client.query(`
97
- SELECT
98
- q.id as queue_id,
99
- q.name as queue_name,
100
- p.id as partition_id,
101
- q.*
102
- FROM queen.queues q
103
- JOIN queen.partitions p ON p.queue_id = q.id
104
- WHERE q.name = $1 AND p.name = $2
105
- `, [queueName, partitionName]);
106
-
107
- if (result.rows.length === 0) {
108
- throw new Error(`System queue ${queueName} not found`);
109
- }
110
-
111
- const row = result.rows[0];
112
- return {
113
- queueId: row.queue_id,
114
- queueName: row.queue_name,
115
- partitionId: row.partition_id,
116
- queueConfig: {
117
- leaseTime: row.lease_time,
118
- retryLimit: row.retry_limit,
119
- retryDelay: row.retry_delay,
120
- maxSize: row.max_size,
121
- ttl: row.ttl,
122
- deadLetterQueue: row.dead_letter_queue,
123
- dlqAfterMaxRetries: row.dlq_after_max_retries,
124
- delayedProcessing: row.delayed_processing,
125
- windowBuffer: row.window_buffer,
126
- retentionSeconds: row.retention_seconds,
127
- completedRetentionSeconds: row.completed_retention_seconds,
128
- retentionEnabled: row.retention_enabled,
129
- priority: row.priority,
130
- maxQueueSize: row.max_queue_size
131
- },
132
- encryptionEnabled: false, // System queues never encrypted
133
- maxWaitTimeSeconds: row.max_wait_time_seconds
134
- };
135
- }
136
-
137
- // Check cache first, but skip cache if we're updating namespace/task
138
- const cacheKey = `${queueName}:${partitionName}`;
139
- const cached = resourceCache.checkResource(queueName, partitionName);
140
- if (cached && namespace === null && task === null) return cached;
141
-
142
- // Get queue - don't create if it doesn't exist
143
- const queueResult = await client.query(
144
- `SELECT id, name, namespace, task, encryption_enabled, max_wait_time_seconds,
145
- lease_time, retry_limit, retry_delay, max_size, ttl, dead_letter_queue,
146
- dlq_after_max_retries, delayed_processing, window_buffer, retention_seconds,
147
- completed_retention_seconds, retention_enabled, priority, max_queue_size
148
- FROM queen.queues
149
- WHERE name = $1`,
150
- [queueName]
151
- );
152
-
153
- if (queueResult.rows.length === 0) {
154
- // Queue doesn't exist - throw error instead of creating
155
- const errorMsg = `Queue '${queueName}' does not exist. Please create it using the configure endpoint first.`;
156
- log(`${LogTypes.ERROR} | ${errorMsg}`);
157
- throw new Error(errorMsg);
158
- }
159
-
160
- const queue = queueResult.rows[0];
161
- const queueId = queue.id;
162
-
163
- // Insert or get partition (no more options column)
164
- const partitionResult = await client.query(
165
- `INSERT INTO queen.partitions (queue_id, name) VALUES ($1, $2)
166
- ON CONFLICT (queue_id, name) DO UPDATE SET name = EXCLUDED.name
167
- RETURNING id`,
168
- [queueId, partitionName]
169
- );
170
-
171
- const result = {
172
- queueId,
173
- queueName: queue.name,
174
- namespace: queue.namespace,
175
- task: queue.task,
176
- partitionId: partitionResult.rows[0].id,
177
- // All configuration now comes from queue level
178
- queueConfig: {
179
- leaseTime: queue.lease_time,
180
- retryLimit: queue.retry_limit,
181
- retryDelay: queue.retry_delay,
182
- maxSize: queue.max_size,
183
- ttl: queue.ttl,
184
- deadLetterQueue: queue.dead_letter_queue,
185
- dlqAfterMaxRetries: queue.dlq_after_max_retries,
186
- delayedProcessing: queue.delayed_processing,
187
- windowBuffer: queue.window_buffer,
188
- retentionSeconds: queue.retention_seconds,
189
- completedRetentionSeconds: queue.completed_retention_seconds,
190
- retentionEnabled: queue.retention_enabled,
191
- priority: queue.priority,
192
- maxQueueSize: queue.max_queue_size
193
- },
194
- encryptionEnabled: queue.encryption_enabled,
195
- maxWaitTimeSeconds: queue.max_wait_time_seconds
196
- };
197
-
198
- // Cache the result
199
- resourceCache.cacheResource(queueName, partitionName, result);
200
-
201
- return result;
202
- };
203
-
204
- // Internal push operation that accepts a client
205
- const pushMessagesInternal = async (client, items) => {
206
- const pushStartTime = Date.now();
207
-
208
- // Group items by queue and partition for efficient resource lookup
209
- const partitionGroups = {};
210
- for (const item of items) {
211
- const partition = item.partition ?? 'Default';
212
- const key = `${item.queue}:${partition}`;
213
- if (!partitionGroups[key]) {
214
- partitionGroups[key] = [];
215
- }
216
- partitionGroups[key].push(item);
217
- }
218
-
219
- const allResults = [];
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
- const capacityCheck = await client.query(
233
- `SELECT
234
- q.name,
235
- q.max_queue_size,
236
- COALESCE(SUM(pc.pending_estimate), 0)::integer as current_depth
237
- FROM queen.queues q
238
- LEFT JOIN queen.partitions p ON p.queue_id = q.id
239
- LEFT JOIN queen.partition_consumers pc ON pc.partition_id = p.id
240
- AND pc.consumer_group = '__QUEUE_MODE__'
241
- WHERE q.name = ANY($1::varchar[])
242
- AND q.max_queue_size > 0
243
- GROUP BY q.name, q.max_queue_size`,
244
- [queuesToCheck]
245
- );
246
-
247
- // Check each queue's capacity
248
- for (const row of capacityCheck.rows) {
249
- const currentDepth = parseInt(row.current_depth);
250
- const batchSize = queueBatchSizes[row.name];
251
- const maxSize = parseInt(row.max_queue_size);
252
- if (currentDepth + batchSize > maxSize) {
253
- throw new Error(`Queue '${row.name}' would exceed max capacity (${maxSize}). Current: ${currentDepth}, Batch: ${batchSize}`);
254
- }
255
- }
256
- }
257
-
258
- // Process each partition group
259
- for (const [partitionKey, partitionItems] of Object.entries(partitionGroups)) {
260
- const [queueName, partitionName] = partitionKey.split(':');
261
-
262
- // Use transaction for better error handling with retry on stale cache
263
- let retryCount = 0;
264
- const maxRetries = 1;
265
-
266
- while (retryCount <= maxRetries) {
267
- try {
268
- // Ensure resources exist (cached after first call)
269
- const resources = await ensureResources(client, queueName, partitionName);
270
- const { partitionId, encryptionEnabled, queueConfig } = resources;
271
-
272
- // Process items in batches
273
- const batchResults = [];
274
-
275
- for (let i = 0; i < partitionItems.length; i += BATCH_INSERT_SIZE) {
276
- const batch = partitionItems.slice(i, i + BATCH_INSERT_SIZE);
277
-
278
- // Prepare batch data
279
- const messageIds = [];
280
- const transactionIds = [];
281
- const traceIds = [];
282
- const payloads = [];
283
- const encryptedFlags = [];
284
- const duplicates = [];
285
-
286
- // ⚡ OPTIMIZATION #1: Batch duplicate check - single query for all transaction IDs
287
- const allTransactionIds = batch.map(item => item.transactionId || generateUUID());
288
-
289
- // Skip duplicate check if no transaction IDs were explicitly provided (all generated)
290
- const hasExplicitTxnIds = batch.some(item => item.transactionId);
291
- let existingTxnIds = new Map();
292
-
293
- if (hasExplicitTxnIds) {
294
- // Use more efficient LEFT JOIN approach instead of ANY() for better index usage
295
- const dupCheck = await client.query(
296
- `SELECT t.txn_id as transaction_id, m.id
297
- FROM UNNEST($1::varchar[]) AS t(txn_id)
298
- LEFT JOIN queen.messages m ON m.transaction_id = t.txn_id
299
- WHERE m.id IS NOT NULL`,
300
- [allTransactionIds]
301
- );
302
-
303
- // Create a map of existing transaction IDs for O(1) lookup
304
- existingTxnIds = new Map(dupCheck.rows.map(row => [row.transaction_id, row.id]));
305
- }
306
-
307
- // ⚡ OPTIMIZATION #2: Parallel encryption - encrypt all payloads concurrently
308
- const encryptionTasks = batch.map(async (item, idx) => {
309
- const transactionId = allTransactionIds[idx];
310
-
311
- // Check if this transaction ID already exists
312
- if (existingTxnIds.has(transactionId)) {
313
- return {
314
- isDuplicate: true,
315
- id: existingTxnIds.get(transactionId),
316
- transactionId
317
- };
318
- }
319
-
320
- // Handle encryption if enabled
321
- let payload = item.payload;
322
- let isEncrypted = false;
323
-
324
- if (encryptionEnabled && encryption.isEncryptionEnabled()) {
325
- try {
326
- payload = await encryption.encryptPayload(item.payload);
327
- isEncrypted = true;
328
- } catch (error) {
329
- log(`ERROR: Encryption failed for transaction ${transactionId}:`, error);
330
- // Continue with unencrypted payload
331
- }
332
- }
333
-
334
- return {
335
- isDuplicate: false,
336
- messageId: generateUUID(),
337
- transactionId,
338
- traceId: item.traceId || null,
339
- payload,
340
- isEncrypted
341
- };
342
- });
343
-
344
- // Wait for all encryption tasks to complete in parallel
345
- const encryptionResults = await Promise.all(encryptionTasks);
346
-
347
- // Process results
348
- for (const result of encryptionResults) {
349
- if (result.isDuplicate) {
350
- duplicates.push({
351
- id: result.id,
352
- transactionId: result.transactionId,
353
- status: 'duplicate'
354
- });
355
- continue;
356
- }
357
-
358
- // Handle large payloads and special cases
359
- let jsonPayload;
360
- try {
361
- // Handle null/undefined payloads - convert to JSON null string
362
- if (result.payload === null || result.payload === undefined) {
363
- jsonPayload = 'null'; // JSONB expects the string 'null' for null values
364
- } else {
365
- // Try to stringify the payload
366
- jsonPayload = JSON.stringify(result.payload);
367
- }
368
- } catch (error) {
369
- // If JSON.stringify fails (e.g., circular references, too large),
370
- // store as a string representation
371
- log(`WARN: Failed to stringify payload for message ${result.messageId}:`, error.message);
372
- jsonPayload = JSON.stringify({
373
- error: 'Payload serialization failed',
374
- type: typeof result.payload,
375
- message: error.message
376
- });
377
- }
378
-
379
- messageIds.push(result.messageId);
380
- transactionIds.push(result.transactionId);
381
- traceIds.push(result.traceId);
382
- payloads.push(jsonPayload);
383
- encryptedFlags.push(result.isEncrypted);
384
- }
385
-
386
- // Batch insert messages with trace_id
387
- if (messageIds.length > 0) {
388
- const insertQuery = `
389
- INSERT INTO queen.messages (id, transaction_id, trace_id, partition_id, payload, is_encrypted)
390
- SELECT * FROM UNNEST($1::uuid[], $2::varchar[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::boolean[])
391
- RETURNING id, transaction_id, trace_id
392
- `;
393
-
394
- let insertResult;
395
- try {
396
- insertResult = await client.query(insertQuery, [
397
- messageIds,
398
- transactionIds,
399
- traceIds,
400
- Array(messageIds.length).fill(partitionId),
401
- payloads,
402
- encryptedFlags
403
- ]);
404
- } catch (error) {
405
- // If foreign key constraint error, the cached partition is stale
406
- if (error.code === '23503' && error.constraint === 'messages_partition_id_fkey') {
407
- // Invalidate cache and throw a more helpful error
408
- resourceCache.invalidate(queueName, partitionName);
409
- throw new Error(`Stale cache detected for queue '${queueName}' partition '${partitionName}'. Please retry the operation.`);
410
- }
411
- throw error;
412
- }
413
-
414
- // Format results
415
- for (const row of insertResult.rows) {
416
- batchResults.push({
417
- id: row.id,
418
- transactionId: row.transaction_id,
419
- traceId: row.trace_id,
420
- status: 'queued'
421
- });
422
- }
423
- }
424
-
425
- // Add duplicates to results
426
- batchResults.push(...duplicates);
427
- }
428
-
429
- // Notify event manager about new messages
430
- const queuePath = `${queueName}/${partitionName}`;
431
- eventManager.notifyMessageAvailable(queuePath);
432
-
433
- allResults.push(...batchResults);
434
- break; // Success, exit retry loop
435
- } catch (error) {
436
- // Check if it's a stale cache error
437
- if (error.message && error.message.includes('Stale cache detected') && retryCount < maxRetries) {
438
- retryCount++;
439
- // Cache already invalidated in the error handler, just retry
440
- continue;
441
- }
442
- throw error; // Re-throw if not a stale cache error or max retries exceeded
443
- }
444
- }
445
- }
446
-
447
- const duration = Date.now() - pushStartTime;
448
- log(`[PUSH] Pushed ${items.length} items → ${allResults.length} results in ${duration}ms (${(items.length / (duration / 1000)).toFixed(0)} msg/s)`);
449
- return allResults;
450
- };
451
-
452
- // Public wrapper for push that creates its own transaction
453
- const pushMessagesBatch = async (items) => {
454
- return withTransaction(pool, async (client) => {
455
- return pushMessagesInternal(client, items);
456
- });
457
- };
458
-
459
-
460
- // Acknowledge messages (now per consumer group)
461
- // CURSOR-BASED: Single ACK now routes through batch ACK to advance cursor properly
462
- const acknowledgeMessage = async (transactionId, status = 'completed', error = null, consumerGroup = null, leaseId = null) => {
463
- // If lease ID provided, validate it first
464
- if (leaseId) {
465
- log(`Validating lease ${leaseId} for transaction ${transactionId}`);
466
- const valid = await pool.query(`
467
- SELECT 1 FROM queen.partition_consumers pc
468
- JOIN queen.messages m ON m.partition_id = pc.partition_id
469
- WHERE m.transaction_id = $1
470
- AND pc.worker_id = $2
471
- AND pc.lease_expires_at > NOW()
472
- `, [transactionId, leaseId]);
473
-
474
- if (valid.rows.length === 0) {
475
- log(`Invalid lease ${leaseId} for transaction ${transactionId} - validation failed`);
476
- throw new Error('Invalid lease - cannot ACK');
477
- }
478
- log(`Lease ${leaseId} validated successfully`);
479
- }
480
-
481
- const batchResult = await acknowledgeMessages([{ transactionId, status, error }], consumerGroup);
482
- return batchResult[0] || { status: 'not_found', transaction_id: transactionId };
483
- };
484
-
485
- // Internal acknowledge operation that accepts a client
486
- const acknowledgeMessagesInternal = async (client, acknowledgments, consumerGroup = null) => {
487
- const ackStartTime = Date.now();
488
- const results = [];
489
- const actualConsumerGroup = consumerGroup || '__QUEUE_MODE__';
490
-
491
- // Group by status for efficient processing
492
- const grouped = {
493
- completed: [],
494
- failed: []
495
- };
496
-
497
- acknowledgments.forEach(ack => {
498
- if (grouped[ack.status]) {
499
- grouped[ack.status].push(ack);
500
- }
501
- });
502
-
503
- const totalMessages = acknowledgments.length;
504
- const failedCount = grouped.failed.length;
505
- const successCount = grouped.completed.length;
506
-
507
- // Determine if this is a TOTAL batch failure or partial failure
508
- // Total batch failure: all messages being ACKed right now are failures AND
509
- // we're ACKing the complete leased batch
510
- const allIds = acknowledgments.map(a => a.transactionId);
511
- const batchSizeInfo = await client.query(`
512
- SELECT pc.batch_size, pc.partition_id
513
- FROM queen.messages m
514
- JOIN queen.partition_consumers pc ON pc.partition_id = m.partition_id
515
- AND pc.consumer_group = $2
516
- WHERE m.transaction_id = ANY($1::varchar[])
517
- LIMIT 1
518
- `, [allIds, actualConsumerGroup]);
519
-
520
- if (batchSizeInfo.rows.length === 0) {
521
- throw new Error('Cannot find leased batch size for acknowledgment');
522
- }
523
-
524
- const leasedBatchSize = batchSizeInfo.rows[0].batch_size;
525
-
526
- // Total batch failure:
527
- // 1. All messages in this ACK call are failures (failedCount === totalMessages)
528
- // 2. This ACK call covers the complete leased batch (totalMessages === leasedBatchSize)
529
- const isTotalBatchFailure = (failedCount === totalMessages) && (totalMessages === leasedBatchSize);
530
-
531
- // CURSOR-BASED APPROACH: Handle total batch failure vs partial failure differently
532
-
533
- if (isTotalBatchFailure) {
534
- // ═══════════════════════════════════════════════════════════════
535
- // TOTAL BATCH FAILURE: Check retry limit before retrying
536
- // ═══════════════════════════════════════════════════════════════
537
-
538
- // Get queue retry limit and current batch retry count
539
- const allIds = acknowledgments.map(a => a.transactionId);
540
- const batchRetryInfo = await client.query(`
541
- SELECT
542
- q.retry_limit,
543
- pc.batch_retry_count,
544
- pc.partition_id,
545
- p.name as partition_name
546
- FROM queen.messages m
547
- JOIN queen.partitions p ON p.id = m.partition_id
548
- JOIN queen.queues q ON q.id = p.queue_id
549
- JOIN queen.partition_consumers pc ON pc.partition_id = m.partition_id
550
- AND pc.consumer_group = $2
551
- WHERE m.transaction_id = ANY($1::varchar[])
552
- LIMIT 1
553
- `, [allIds, actualConsumerGroup]);
554
-
555
- if (batchRetryInfo.rows.length === 0) {
556
- throw new Error('Cannot find queue retry limit for batch failure');
557
- }
558
-
559
- const { retry_limit, batch_retry_count, partition_id, partition_name } = batchRetryInfo.rows[0];
560
- const currentRetryCount = batch_retry_count || 0;
561
-
562
- const ackTime = Date.now() - ackStartTime;
563
- const throughput = ackTime > 0 ? (totalMessages / (ackTime / 1000)).toFixed(0) : 0;
564
-
565
- if (currentRetryCount >= retry_limit) {
566
- // ═══════════════════════════════════════════════════════════════
567
- // BATCH RETRY LIMIT EXCEEDED: Move to DLQ and advance cursor
568
- // ═══════════════════════════════════════════════════════════════
569
- log(`[CURSOR] Batch retry limit exceeded (${currentRetryCount}/${retry_limit}) - moving batch to DLQ and advancing cursor`);
570
- log(`[${LogTypes.ACK_BATCH}] Batch retry limit exceeded: ${failedCount} msgs → DLQ | ${throughput} msg/s | ${ackTime}ms | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'}`);
571
-
572
- // Move all messages to DLQ
573
- const failedErrors = grouped.failed.map(a => a.error || `Batch retry limit exceeded (${currentRetryCount}/${retry_limit})`);
574
- await client.query(`
575
- INSERT INTO queen.dead_letter_queue (message_id, partition_id, consumer_group, error_message, original_created_at)
576
- SELECT
577
- m.id,
578
- m.partition_id,
579
- $2,
580
- e.error_message,
581
- m.created_at
582
- FROM queen.messages m
583
- CROSS JOIN LATERAL UNNEST($1::varchar[], $3::text[]) AS e(txn_id, error_message)
584
- WHERE m.transaction_id = e.txn_id
585
- AND m.transaction_id = ANY($1::varchar[])
586
- ON CONFLICT DO NOTHING
587
- `, [allIds, actualConsumerGroup, failedErrors]);
588
-
589
- // Get last message for cursor advancement
590
- const lastMessageInfo = await client.query(`
591
- SELECT id, created_at
592
- FROM queen.messages m
593
- WHERE m.transaction_id = ANY($1::varchar[])
594
- ORDER BY m.created_at DESC, m.id DESC
595
- LIMIT 1
596
- `, [allIds]);
597
-
598
- const lastMessage = lastMessageInfo.rows[0];
599
-
600
- // Advance cursor and reset batch retry count
601
- await client.query(`
602
- UPDATE queen.partition_consumers
603
- SET
604
- -- Advance cursor past failed batch
605
- last_consumed_created_at = $1,
606
- last_consumed_id = $2,
607
- total_messages_consumed = total_messages_consumed + $3,
608
- total_batches_consumed = total_batches_consumed + 1,
609
- last_consumed_at = NOW(),
610
-
611
- -- Reset batch retry count
612
- batch_retry_count = 0,
613
-
614
- -- Update pending estimate
615
- pending_estimate = GREATEST(0, pending_estimate - $3),
616
- last_stats_update = NOW(),
617
-
618
- -- Release lease
619
- lease_expires_at = NULL,
620
- lease_acquired_at = NULL,
621
- message_batch = NULL,
622
- batch_size = 0,
623
- acked_count = 0
624
- WHERE partition_id = $4
625
- AND consumer_group = $5
626
- `, [lastMessage.created_at, lastMessage.id, totalMessages, partition_id, actualConsumerGroup]);
627
-
628
- grouped.failed.forEach(ack => {
629
- results.push({ transactionId: ack.transactionId, status: 'failed_dlq' });
630
- });
631
-
632
- } else {
633
- // ═══════════════════════════════════════════════════════════════
634
- // BATCH RETRY: Increment retry count and retry same batch
635
- // ═══════════════════════════════════════════════════════════════
636
- log(`[CURSOR] Total batch failure - cursor NOT advanced, same batch will retry (attempt ${currentRetryCount + 1}/${retry_limit})`);
637
- log(`[${LogTypes.ACK_BATCH}] Total batch failure: ${failedCount} msgs | Retry ${currentRetryCount + 1}/${retry_limit} | ${throughput} msg/s | ${ackTime}ms | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'}`);
638
-
639
- // Increment batch retry count and release lease
640
- await client.query(`
641
- UPDATE queen.partition_consumers pc
642
- SET lease_expires_at = NULL,
643
- lease_acquired_at = NULL,
644
- message_batch = NULL,
645
- batch_size = 0,
646
- acked_count = 0,
647
- batch_retry_count = COALESCE(batch_retry_count, 0) + 1
648
- WHERE pc.partition_id = $1
649
- AND pc.consumer_group = $2
650
- `, [partition_id, actualConsumerGroup]);
651
-
652
- grouped.failed.forEach(ack => {
653
- results.push({ transactionId: ack.transactionId, status: 'failed_retry' });
654
- });
655
- }
656
-
657
- } else {
658
- // ═══════════════════════════════════════════════════════════════
659
- // PARTIAL SUCCESS: Advance cursor, move failed to DLQ
660
- // ═══════════════════════════════════════════════════════════════
661
-
662
- // Get all messages in this batch (successful and failed) to find cursor position
663
- const allIds = acknowledgments.map(a => a.transactionId);
664
- const batchInfo = await client.query(`
665
- SELECT
666
- m.id,
667
- m.transaction_id,
668
- m.created_at,
669
- m.partition_id,
670
- p.name as partition_name
671
- FROM queen.messages m
672
- JOIN queen.partitions p ON p.id = m.partition_id
673
- WHERE m.transaction_id = ANY($1::varchar[])
674
- ORDER BY m.created_at DESC, m.id DESC
675
- LIMIT 1
676
- `, [allIds]);
677
-
678
- if (batchInfo.rows.length === 0) {
679
- throw new Error('No messages found for acknowledgment');
680
- }
681
-
682
- const lastMessage = batchInfo.rows[0];
683
- const partitionId = lastMessage.partition_id;
684
- const partitionName = lastMessage.partition_name;
685
-
686
- // Advance cursor + update lease atomically in partition_consumers
687
- const cursorUpdateResult = await client.query(`
688
- UPDATE queen.partition_consumers
689
- SET
690
- -- Advance cursor (regardless of success/failure)
691
- last_consumed_created_at = $1,
692
- last_consumed_id = $2,
693
- total_messages_consumed = total_messages_consumed + $3,
694
- total_batches_consumed = total_batches_consumed + 1,
695
- last_consumed_at = NOW(),
696
-
697
- -- Reset batch retry count on successful processing
698
- batch_retry_count = 0,
699
-
700
- -- Update pending estimate
701
- pending_estimate = GREATEST(0, pending_estimate - $3),
702
- last_stats_update = NOW(),
703
-
704
- -- Update/release lease
705
- acked_count = acked_count + $3,
706
- lease_expires_at = CASE
707
- WHEN acked_count + $3 >= batch_size AND batch_size > 0
708
- THEN NULL
709
- ELSE lease_expires_at
710
- END,
711
- lease_acquired_at = CASE
712
- WHEN acked_count + $3 >= batch_size AND batch_size > 0
713
- THEN NULL
714
- ELSE lease_acquired_at
715
- END,
716
- message_batch = CASE
717
- WHEN acked_count + $3 >= batch_size AND batch_size > 0
718
- THEN NULL
719
- ELSE message_batch
720
- END,
721
- batch_size = CASE
722
- WHEN acked_count + $3 >= batch_size AND batch_size > 0
723
- THEN 0
724
- ELSE batch_size
725
- END
726
- WHERE partition_id = $4
727
- AND consumer_group = $5
728
- RETURNING total_messages_consumed, total_batches_consumed,
729
- (lease_expires_at IS NULL) as lease_released
730
- `, [lastMessage.created_at, lastMessage.id, totalMessages, partitionId, actualConsumerGroup]);
731
-
732
- const newTotalConsumed = cursorUpdateResult.rows[0]?.total_messages_consumed || 0;
733
- const newTotalBatches = cursorUpdateResult.rows[0]?.total_batches_consumed || 0;
734
- const leaseReleased = cursorUpdateResult.rows[0]?.lease_released || false;
735
-
736
- const ackTime = Date.now() - ackStartTime;
737
- const throughput = ackTime > 0 ? (totalMessages / (ackTime / 1000)).toFixed(0) : 0;
738
-
739
- log(`[${LogTypes.ACK_BATCH}] Success: ${successCount} | Failed: ${failedCount} | ${throughput} msg/s | ${ackTime}ms | Cursor: ${newTotalConsumed} msgs | Lease: ${leaseReleased ? 'Released' : 'Active'} | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'}`);
740
-
741
- // Move failed messages to DLQ (individual failures, not batch retry)
742
- if (grouped.failed.length > 0) {
743
- const failedIds = grouped.failed.map(a => a.transactionId);
744
- const failedErrors = grouped.failed.map(a => a.error || 'Unknown error');
745
-
746
- await client.query(`
747
- INSERT INTO queen.dead_letter_queue (message_id, partition_id, consumer_group, error_message, original_created_at)
748
- SELECT
749
- m.id,
750
- m.partition_id,
751
- $2,
752
- e.error_message,
753
- m.created_at
754
- FROM queen.messages m
755
- CROSS JOIN LATERAL UNNEST($1::varchar[], $3::text[]) AS e(txn_id, error_message)
756
- WHERE m.transaction_id = e.txn_id
757
- AND m.transaction_id = ANY($1::varchar[])
758
- ON CONFLICT DO NOTHING
759
- `, [failedIds, actualConsumerGroup, failedErrors]);
760
-
761
- log(`[${LogTypes.ACK_BATCH}] Moved ${failedCount} failed messages to DLQ | ConsumerGroup: ${consumerGroup || 'QUEUE_MODE'}`);
762
- }
763
-
764
- // Track consumption metrics for time-series analytics
765
- await client.query(`
766
- INSERT INTO queen.messages_consumed (partition_id, consumer_group, messages_completed, messages_failed)
767
- VALUES ($1, $2, $3, $4)
768
- `, [partitionId, actualConsumerGroup, successCount, failedCount]);
769
-
770
- // Add results
771
- grouped.completed.forEach(ack => {
772
- results.push({ transactionId: ack.transactionId, status: 'completed' });
773
- });
774
-
775
- grouped.failed.forEach(ack => {
776
- results.push({ transactionId: ack.transactionId, status: 'failed_dlq' });
777
- });
778
- }
779
-
780
- return results;
781
- };
782
-
783
- // Public wrapper for acknowledge that creates its own transaction
784
- const acknowledgeMessages = async (acknowledgments, consumerGroup = null) => {
785
- return withTransaction(pool, async (client) => {
786
- return acknowledgeMessagesInternal(client, acknowledgments, consumerGroup);
787
- });
788
- };
789
-
790
- // Reclaim expired leases from partition_consumers
791
- const reclaimExpiredLeases = async () => {
792
- return withTransaction(pool, async (client) => {
793
- // Clear expired leases from partition_consumers
794
- const result = await client.query(
795
- `UPDATE queen.partition_consumers
796
- SET lease_expires_at = NULL,
797
- lease_acquired_at = NULL,
798
- message_batch = NULL,
799
- batch_size = 0,
800
- acked_count = 0,
801
- worker_id = NULL
802
- WHERE lease_expires_at IS NOT NULL
803
- AND lease_expires_at < NOW()
804
- RETURNING partition_id, consumer_group`
805
- );
806
-
807
- if (result.rows.length > 0) {
808
- const byGroup = {};
809
- result.rows.forEach(row => {
810
- const group = row.consumer_group || 'QUEUE_MODE';
811
- byGroup[group] = (byGroup[group] || 0) + 1;
812
- });
813
-
814
- Object.entries(byGroup).forEach(([group, count]) => {
815
- log(`${LogTypes.RECLAIM} | Reclaimed ${count} expired leases for consumer group: ${group}`);
816
- });
817
- }
818
-
819
- return result.rows.length;
820
- });
821
- };
822
-
823
- // Get queue statistics (using partition_consumers for fast estimates)
824
- const getQueueStats = async (filters = {}) => {
825
- const { queue, namespace, task, fromDateTime, toDateTime } = filters;
826
-
827
- let query = `
828
- SELECT
829
- q.name as queue,
830
- q.namespace,
831
- q.task,
832
- p.name as partition,
833
- COALESCE(pc.pending_estimate, 0) as pending,
834
- CASE WHEN pc.lease_expires_at IS NOT NULL THEN 1 ELSE 0 END as processing,
835
- COALESCE(pc.total_messages_consumed, 0) as completed,
836
- 0 as failed,
837
- (SELECT COUNT(*) FROM queen.dead_letter_queue dlq
838
- WHERE dlq.partition_id = p.id
839
- AND dlq.consumer_group = COALESCE(pc.consumer_group, '__QUEUE_MODE__')) as dead_letter,
840
- (SELECT COUNT(*) FROM queen.messages m
841
- WHERE m.partition_id = p.id
842
- ${fromDateTime ? 'AND m.created_at >= $' + (1 + (queue ? 1 : 0) + (namespace ? 1 : 0) + (task ? 1 : 0)) + '::timestamp' : ''}
843
- ${toDateTime ? 'AND m.created_at <= $' + (1 + (queue ? 1 : 0) + (namespace ? 1 : 0) + (task ? 1 : 0) + (fromDateTime ? 1 : 0)) + '::timestamp' : ''}
844
- ) as total_messages,
845
- (SELECT COUNT(DISTINCT consumer_group)
846
- FROM queen.partition_consumers
847
- WHERE partition_id = p.id
848
- AND consumer_group != '__QUEUE_MODE__') as consumer_groups_count
849
- FROM queen.queues q
850
- LEFT JOIN queen.partitions p ON p.queue_id = q.id
851
- LEFT JOIN queen.partition_consumers pc ON pc.partition_id = p.id
852
- AND pc.consumer_group = '__QUEUE_MODE__'
853
- `;
854
-
855
- const conditions = [];
856
- const params = [];
857
-
858
- if (queue) {
859
- conditions.push(`q.name = $${params.length + 1}`);
860
- params.push(queue);
861
- }
862
-
863
- if (namespace) {
864
- conditions.push(`q.namespace = $${params.length + 1}`);
865
- params.push(namespace);
866
- }
867
-
868
- if (task) {
869
- conditions.push(`q.task = $${params.length + 1}`);
870
- params.push(task);
871
- }
872
-
873
- // Add date params for subqueries
874
- if (fromDateTime) {
875
- params.push(fromDateTime);
876
- }
877
-
878
- if (toDateTime) {
879
- params.push(toDateTime);
880
- }
881
-
882
- if (conditions.length > 0) {
883
- query += ` WHERE ${conditions.join(' AND ')}`;
884
- }
885
-
886
- query += ` ORDER BY q.name, p.name`;
887
-
888
- const result = await pool.query(query, params);
889
- return result.rows;
890
- };
891
-
892
- // Get queue lag statistics (simplified with partition_consumers)
893
- const getQueueLag = async (filters = {}) => {
894
- const { queue, namespace, task, fromDateTime, toDateTime } = filters;
895
-
896
- let query = `
897
- WITH lag_stats AS (
898
- SELECT
899
- q.name as queue,
900
- q.namespace,
901
- q.task,
902
- p.name as partition,
903
- COALESCE(pc.pending_estimate, 0) as pending_count,
904
- CASE WHEN pc.lease_expires_at IS NOT NULL THEN 1 ELSE 0 END as processing_count,
905
- COALESCE(pc.pending_estimate, 0) + CASE WHEN pc.lease_expires_at IS NOT NULL THEN 1 ELSE 0 END as total_backlog,
906
- COALESCE(pc.total_messages_consumed, 0) as completed_messages,
907
- 0 as avg_processing_time_seconds,
908
- 0 as median_processing_time_seconds,
909
- 0 as p95_processing_time_seconds,
910
- (SELECT MIN(created_at) FROM queen.messages m
911
- WHERE m.partition_id = p.id
912
- AND (m.created_at, m.id) > (COALESCE(pc.last_consumed_created_at, '1970-01-01'::timestamptz),
913
- COALESCE(pc.last_consumed_id, '00000000-0000-0000-0000-000000000000'::uuid))
914
- ) as oldest_unprocessed,
915
- (SELECT MAX(created_at) FROM queen.messages m
916
- WHERE m.partition_id = p.id
917
- AND (m.created_at, m.id) <= (COALESCE(pc.last_consumed_created_at, NOW()::timestamptz),
918
- COALESCE(pc.last_consumed_id, gen_random_uuid()))
919
- ) as newest_completed
920
- FROM queen.queues q
921
- LEFT JOIN queen.partitions p ON p.queue_id = q.id
922
- LEFT JOIN queen.partition_consumers pc ON pc.partition_id = p.id
923
- AND pc.consumer_group = '__QUEUE_MODE__'
924
- `;
925
-
926
- const conditions = [];
927
- const params = [];
928
-
929
- if (queue) {
930
- conditions.push(`q.name = $${params.length + 1}`);
931
- params.push(queue);
932
- }
933
-
934
- if (namespace) {
935
- conditions.push(`q.namespace = $${params.length + 1}`);
936
- params.push(namespace);
937
- }
938
-
939
- if (task) {
940
- conditions.push(`q.task = $${params.length + 1}`);
941
- params.push(task);
942
- }
943
-
944
- if (conditions.length > 0) {
945
- query += ` WHERE ${conditions.join(' AND ')}`;
946
- }
947
-
948
- query += ` GROUP BY q.name, q.namespace, q.task, p.name, pc.pending_estimate,
949
- pc.lease_expires_at, pc.total_messages_consumed,
950
- pc.last_consumed_created_at, pc.last_consumed_id, p.id
951
- )
952
- SELECT
953
- queue,
954
- namespace,
955
- task,
956
- partition,
957
- jsonb_build_object(
958
- 'pendingCount', pending_count,
959
- 'processingCount', processing_count,
960
- 'totalBacklog', total_backlog,
961
- 'completedMessages', completed_messages,
962
- 'avgProcessingTimeSeconds', avg_processing_time_seconds,
963
- 'medianProcessingTimeSeconds', median_processing_time_seconds,
964
- 'p95ProcessingTimeSeconds', p95_processing_time_seconds,
965
- 'estimatedLagSeconds', CASE
966
- WHEN oldest_unprocessed IS NOT NULL
967
- THEN EXTRACT(EPOCH FROM (NOW() - oldest_unprocessed))
968
- ELSE 0
969
- END,
970
- 'medianLagSeconds', CASE
971
- WHEN oldest_unprocessed IS NOT NULL AND newest_completed IS NOT NULL
972
- THEN EXTRACT(EPOCH FROM (newest_completed - oldest_unprocessed)) / 2
973
- ELSE 0
974
- END,
975
- 'p95LagSeconds', CASE
976
- WHEN oldest_unprocessed IS NOT NULL
977
- THEN EXTRACT(EPOCH FROM (NOW() - oldest_unprocessed)) * 1.5
978
- ELSE 0
979
- END
980
- ) as stats
981
- FROM lag_stats
982
- ORDER BY queue, partition`;
983
-
984
- const result = await pool.query(query, params);
985
- return result.rows;
986
- };
987
-
988
- /**
989
- * Configure queue settings with namespace and task support
990
- * Updates queue configuration options and optionally sets namespace/task
991
- */
992
- const configureQueue = async (queueName, options = {}, namespace = null, task = null) => {
993
- return withTransaction(pool, async (client) => {
994
- // Check if queue exists (for update vs create detection)
995
- const existingQueue = await client.query(
996
- 'SELECT * FROM queen.queues WHERE name = $1',
997
- [queueName]
998
- );
999
- const isUpdate = existingQueue.rows.length > 0;
1000
-
1001
- // First create/update the queue with namespace and task
1002
- const queueResult = await client.query(
1003
- `INSERT INTO queen.queues (name, namespace, task) VALUES ($1, $2, $3)
1004
- ON CONFLICT (name) DO UPDATE SET
1005
- namespace = CASE WHEN EXCLUDED.namespace IS NOT NULL THEN EXCLUDED.namespace ELSE queen.queues.namespace END,
1006
- task = CASE WHEN EXCLUDED.task IS NOT NULL THEN EXCLUDED.task ELSE queen.queues.task END
1007
- RETURNING id, name, namespace, task`,
1008
- [queueName, namespace || null, task || null]
1009
- );
1010
-
1011
- // Build update query for all configuration options
1012
- const updates = [];
1013
- const params = [];
1014
- let paramIndex = 1;
1015
-
1016
- // Map options to database columns
1017
- const optionMappings = {
1018
- leaseTime: 'lease_time',
1019
- retryLimit: 'retry_limit',
1020
- retryDelay: 'retry_delay',
1021
- maxSize: 'max_size',
1022
- ttl: 'ttl',
1023
- deadLetterQueue: 'dead_letter_queue',
1024
- dlqAfterMaxRetries: 'dlq_after_max_retries',
1025
- delayedProcessing: 'delayed_processing',
1026
- windowBuffer: 'window_buffer',
1027
- retentionSeconds: 'retention_seconds',
1028
- completedRetentionSeconds: 'completed_retention_seconds',
1029
- retentionEnabled: 'retention_enabled',
1030
- priority: 'priority',
1031
- encryptionEnabled: 'encryption_enabled',
1032
- maxWaitTimeSeconds: 'max_wait_time_seconds',
1033
- maxQueueSize: 'max_queue_size'
1034
- };
1035
-
1036
- // Detect what changed (for update events)
1037
- const changes = {};
1038
-
1039
- // Process each option
1040
- for (const [optionKey, columnName] of Object.entries(optionMappings)) {
1041
- if (options[optionKey] !== undefined) {
1042
- // Track changes for update events
1043
- if (isUpdate && existingQueue.rows[0]) {
1044
- const old = existingQueue.rows[0];
1045
- const newValue = options[optionKey];
1046
- if (old[columnName] !== newValue) {
1047
- changes[optionKey] = { old: old[columnName], new: newValue };
1048
- }
1049
- }
1050
-
1051
- updates.push(`${columnName} = $${paramIndex}`);
1052
-
1053
- // Handle different data types
1054
- if (typeof options[optionKey] === 'boolean') {
1055
- params.push(!!options[optionKey]);
1056
- } else if (typeof options[optionKey] === 'number' || !isNaN(options[optionKey])) {
1057
- params.push(parseInt(options[optionKey]) || 0);
1058
- } else {
1059
- params.push(options[optionKey]);
1060
- }
1061
-
1062
- paramIndex++;
1063
- }
1064
- }
1065
-
1066
- // Apply updates if any
1067
- if (updates.length > 0) {
1068
- params.push(queueName);
1069
- const updateQuery = `UPDATE queen.queues SET ${updates.join(', ')} WHERE name = $${paramIndex}`;
1070
- await client.query(updateQuery, params);
1071
- }
1072
-
1073
- // Emit appropriate event (if systemEventManager is available)
1074
- if (systemEventManager) {
1075
- await systemEventManager.emit(
1076
- isUpdate ? EventTypes.QUEUE_UPDATED : EventTypes.QUEUE_CREATED,
1077
- {
1078
- entityType: 'queue',
1079
- entityId: queueName,
1080
- changes: isUpdate ? changes : options,
1081
- namespace,
1082
- task
1083
- }
1084
- );
1085
- }
1086
-
1087
- // Local cache invalidation (immediate)
1088
- resourceCache.invalidateQueue(queueName);
1089
-
1090
- return {
1091
- queue: queueName,
1092
- namespace: queueResult.rows[0].namespace,
1093
- task: queueResult.rows[0].task,
1094
- options
1095
- };
1096
- });
1097
- };
1098
-
1099
- /**
1100
- * ═══════════════════════════════════════════════════════════════════════════
1101
- * UNIFIED POP FUNCTION - Handles all 3 access patterns in one implementation
1102
- * ═══════════════════════════════════════════════════════════════════════════
1103
- *
1104
- * This function unifies:
1105
- * 1. Direct partition access (queue + partition specified)
1106
- * 2. Queue-level access (queue only, find unlocked partition)
1107
- * 3. Filtered access (namespace/task, find matching queues + partitions)
1108
- *
1109
- * Benefits over separate functions:
1110
- * - Single source of truth for lease acquisition
1111
- * - Consistent multi-candidate retry logic
1112
- * - Easier to maintain and optimize
1113
- * - Shared backpressure and metrics
1114
- */
1115
- const uniquePop = async (scope, options = {}) => {
1116
- const {
1117
- queue,
1118
- partition,
1119
- namespace,
1120
- task,
1121
- consumerGroup
1122
- } = scope;
1123
-
1124
- const {
1125
- wait = false,
1126
- timeout = config.QUEUE.DEFAULT_TIMEOUT,
1127
- batch = config.QUEUE.DEFAULT_BATCH_SIZE,
1128
- subscriptionMode = null,
1129
- subscriptionFrom = null
1130
- } = options;
1131
-
1132
- const startTime = Date.now();
1133
- const actualConsumerGroup = consumerGroup || '__QUEUE_MODE__';
1134
-
1135
- // Determine access mode
1136
- const accessMode = partition ? 'direct' : (namespace || task ? 'filtered' : 'queue');
1137
-
1138
- return withTransaction(pool, async (client) => {
1139
- const connectionWaitTime = Date.now() - startTime;
1140
- if (connectionWaitTime > 1000) {
1141
- log(`⚠️ Warning connection wait time: ${connectionWaitTime}ms`);
1142
- }
1143
-
1144
- // ─────────────────────────────────────────────────────────────────────
1145
- // PHASE 1: Find Candidate Partitions (mode-specific queries)
1146
- // ─────────────────────────────────────────────────────────────────────
1147
-
1148
- const candidateQueryStart = Date.now();
1149
- let candidatePartitions = [];
1150
- let queueInfo = null;
1151
-
1152
- if (accessMode === 'direct') {
1153
- // CASE 1: Direct partition access - single partition, no search needed
1154
- const result = await client.query(`
1155
- SELECT
1156
- q.id as queue_id,
1157
- q.name as queue_name,
1158
- q.lease_time,
1159
- q.retry_limit,
1160
- q.delayed_processing,
1161
- q.max_wait_time_seconds,
1162
- q.window_buffer,
1163
- q.priority,
1164
- p.id as partition_id,
1165
- p.name as partition_name
1166
- FROM queen.queues q
1167
- JOIN queen.partitions p ON p.queue_id = q.id
1168
- WHERE q.name = $1 AND p.name = $2
1169
- AND (q.window_buffer = 0 OR NOT EXISTS (
1170
- SELECT 1 FROM queen.messages m
1171
- WHERE m.partition_id = p.id
1172
- AND m.created_at > NOW() - INTERVAL '1 second' * q.window_buffer
1173
- ))
1174
- `, [queue, partition]);
1175
-
1176
- if (result.rows.length > 0) {
1177
- candidatePartitions = [result.rows[0]];
1178
- queueInfo = result.rows[0];
1179
- }
1180
-
1181
- } else if (accessMode === 'queue') {
1182
- // CASE 2: Queue-level access - find unlocked partitions WITH unconsumed messages
1183
- const result = await client.query(`
1184
- SELECT
1185
- q.id as queue_id,
1186
- q.name as queue_name,
1187
- q.lease_time,
1188
- q.retry_limit,
1189
- q.delayed_processing,
1190
- q.max_wait_time_seconds,
1191
- q.window_buffer,
1192
- q.priority,
1193
- p.id as partition_id,
1194
- p.name as partition_name
1195
- FROM queen.queues q
1196
- JOIN queen.partitions p ON p.queue_id = q.id
1197
- LEFT JOIN queen.partition_consumers pc ON pc.partition_id = p.id
1198
- AND pc.consumer_group = $1
1199
- WHERE q.name = $2
1200
- AND NOT EXISTS (
1201
- SELECT 1 FROM queen.partition_consumers pc2
1202
- WHERE pc2.partition_id = p.id
1203
- AND pc2.consumer_group = $1
1204
- AND pc2.lease_expires_at IS NOT NULL
1205
- AND pc2.lease_expires_at > NOW()
1206
- )
1207
- AND (q.window_buffer = 0 OR NOT EXISTS (
1208
- SELECT 1 FROM queen.messages m
1209
- WHERE m.partition_id = p.id
1210
- AND m.created_at > NOW() - INTERVAL '1 second' * q.window_buffer
1211
- ))
1212
- AND EXISTS (
1213
- SELECT 1 FROM queen.messages m2
1214
- WHERE m2.partition_id = p.id
1215
- AND m2.id > COALESCE(pc.last_consumed_id, '00000000-0000-0000-0000-000000000000'::uuid)
1216
- AND (q.delayed_processing = 0 OR m2.created_at <= NOW() - INTERVAL '1 second' * q.delayed_processing)
1217
- AND (q.max_wait_time_seconds = 0 OR m2.created_at > NOW() - INTERVAL '1 second' * q.max_wait_time_seconds)
1218
- )
1219
- ORDER BY RANDOM()
1220
- LIMIT $3
1221
- `, [actualConsumerGroup, queue, config.QUEUE.MAX_PARTITION_CANDIDATES]);
1222
-
1223
- if (result.rows.length > 0) {
1224
- candidatePartitions = result.rows;
1225
- queueInfo = result.rows[0];
1226
- }
1227
-
1228
- } else {
1229
- // CASE 3: Filtered access - namespace/task
1230
- // Use CTE to first filter partitions by windowBuffer, then find messages
1231
- let filterQuery = `
1232
- WITH eligible_partitions AS (
1233
- SELECT DISTINCT
1234
- q.id as queue_id,
1235
- q.name as queue_name,
1236
- q.lease_time,
1237
- q.retry_limit,
1238
- q.delayed_processing,
1239
- q.max_wait_time_seconds,
1240
- q.window_buffer,
1241
- q.priority,
1242
- p.id as partition_id,
1243
- p.name as partition_name
1244
- FROM queen.queues q
1245
- JOIN queen.partitions p ON p.queue_id = q.id
1246
- WHERE (q.window_buffer = 0 OR NOT EXISTS (
1247
- SELECT 1 FROM queen.messages m2
1248
- WHERE m2.partition_id = p.id
1249
- AND m2.created_at > NOW() - INTERVAL '1 second' * q.window_buffer
1250
- ))
1251
- AND NOT EXISTS (
1252
- SELECT 1 FROM queen.partition_consumers pc2
1253
- WHERE pc2.partition_id = p.id
1254
- AND pc2.consumer_group = $1
1255
- AND pc2.lease_expires_at IS NOT NULL
1256
- AND pc2.lease_expires_at > NOW()
1257
- )
1258
- )
1259
- SELECT
1260
- ep.*,
1261
- MIN(m.created_at) as oldest_message
1262
- FROM queen.messages m
1263
- JOIN eligible_partitions ep ON m.partition_id = ep.partition_id
1264
- LEFT JOIN queen.partition_consumers pc ON ep.partition_id = pc.partition_id
1265
- AND pc.consumer_group = $1
1266
- WHERE m.id > COALESCE(pc.last_consumed_id, '00000000-0000-0000-0000-000000000000'::uuid)
1267
- AND (ep.delayed_processing = 0 OR m.created_at <= NOW() - INTERVAL '1 second' * ep.delayed_processing)
1268
- AND (ep.max_wait_time_seconds = 0 OR
1269
- m.created_at > NOW() - INTERVAL '1 second' * ep.max_wait_time_seconds)
1270
- `;
1271
-
1272
- const params = [actualConsumerGroup];
1273
-
1274
- //Build namespace/task filters for the CTE
1275
- let cteFilters = '';
1276
- if (namespace) {
1277
- params.push(namespace);
1278
- cteFilters += ` AND q.namespace = $${params.length}`;
1279
- }
1280
-
1281
- if (task) {
1282
- params.push(task);
1283
- cteFilters += ` AND q.task = $${params.length}`;
1284
- }
1285
-
1286
- // Insert CTE filters before the closing parenthesis
1287
- filterQuery = filterQuery.replace(' )\n )', ` )${cteFilters}\n )`);
1288
-
1289
- params.push(config.QUEUE.MAX_PARTITION_CANDIDATES);
1290
- filterQuery += `
1291
- GROUP BY ep.queue_id, ep.queue_name, ep.lease_time, ep.retry_limit, ep.delayed_processing,
1292
- ep.max_wait_time_seconds, ep.window_buffer, ep.priority,
1293
- ep.partition_id, ep.partition_name
1294
- HAVING COUNT(*) > 0
1295
- ORDER BY ep.priority DESC, MIN(m.created_at) ASC, RANDOM()
1296
- LIMIT $${params.length}
1297
- `;
1298
-
1299
- const result = await client.query(filterQuery, params);
1300
-
1301
- if (result.rows.length > 0) {
1302
- candidatePartitions = result.rows;
1303
- queueInfo = result.rows[0];
1304
- }
1305
- }
1306
-
1307
- const candidateQueryTime = Date.now() - candidateQueryStart;
1308
-
1309
- if (candidatePartitions.length === 0) {
1310
- return { messages: [] };
1311
- }
1312
-
1313
- // ─────────────────────────────────────────────────────────────────────
1314
- // PHASE 2: Try to Acquire Lease on a Candidate Partition (shared logic)
1315
- // ─────────────────────────────────────────────────────────────────────
1316
-
1317
- let acquiredPartition = null;
1318
- const MAX_ATTEMPTS = accessMode === 'direct' ? 1 : 3;
1319
-
1320
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
1321
- // Shuffle candidates to distribute across consumers
1322
- const shuffled = [...candidatePartitions];
1323
- for (let i = shuffled.length - 1; i > 0; i--) {
1324
- const j = Math.floor(Math.random() * (i + 1));
1325
- [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
1326
- }
1327
-
1328
- // Try to acquire lease on each candidate
1329
- for (const candidate of shuffled) {
1330
- const leaseAcqStart = Date.now();
1331
-
1332
- // Try to acquire lease - check if lease is available based on DB state
1333
- // The key insight: released_at IS NULL means someone holds the lease
1334
- // We can only acquire if: released_at IS NOT NULL OR lease expired
1335
- try {
1336
- // Get or create cursor position for this partition/consumer group
1337
- // Handle subscription modes for bus mode (new-only, from-timestamp, etc.)
1338
- let initialCursorId = '00000000-0000-0000-0000-000000000000';
1339
- let initialCursorTimestamp = null;
1340
-
1341
- // For bus mode with subscription preferences
1342
- if (consumerGroup && (subscriptionMode || subscriptionFrom)) {
1343
- if (subscriptionMode === 'new' || subscriptionMode === 'new-only' || subscriptionFrom === 'now') {
1344
- // Start from NOW - get the latest message ID to use as cursor
1345
- const latestMsg = await client.query(`
1346
- SELECT id, created_at FROM queen.messages
1347
- WHERE partition_id = $1
1348
- ORDER BY created_at DESC, id DESC
1349
- LIMIT 1
1350
- `, [candidate.partition_id]);
1351
-
1352
- if (latestMsg.rows.length > 0) {
1353
- initialCursorId = latestMsg.rows[0].id;
1354
- initialCursorTimestamp = latestMsg.rows[0].created_at;
1355
- }
1356
- }
1357
- // else: subscriptionFrom='all' or no preference → start from beginning (00000000...)
1358
- }
1359
-
1360
- // Step 1: Ensure cursor exists (idempotent)
1361
- await client.query(`
1362
- INSERT INTO queen.partition_consumers (
1363
- partition_id,
1364
- consumer_group,
1365
- last_consumed_id,
1366
- last_consumed_created_at
1367
- )
1368
- VALUES ($1, $2, $3, $4)
1369
- ON CONFLICT (partition_id, consumer_group) DO NOTHING
1370
- `, [candidate.partition_id, actualConsumerGroup, initialCursorId, initialCursorTimestamp]);
1371
-
1372
- // Generate unique lease ID for this POP operation
1373
- const leaseId = generateUUID();
1374
-
1375
- // Step 2: Try to acquire lease and get cursor position
1376
- const leaseResult = await client.query(`
1377
- UPDATE queen.partition_consumers
1378
- SET lease_expires_at = NOW() + INTERVAL '1 second' * $3,
1379
- lease_acquired_at = NOW(),
1380
- worker_id = $4, -- Store lease ID in worker_id field
1381
- message_batch = NULL,
1382
- batch_size = 0,
1383
- acked_count = 0
1384
- WHERE partition_id = $1
1385
- AND consumer_group = $2
1386
- AND (lease_expires_at IS NULL OR lease_expires_at <= NOW())
1387
- RETURNING partition_id, last_consumed_id, last_consumed_created_at,
1388
- total_messages_consumed, lease_expires_at, worker_id as lease_id
1389
- `, [candidate.partition_id, actualConsumerGroup, candidate.lease_time, leaseId]);
1390
-
1391
- // Check if we acquired the lease
1392
- const acquired = leaseResult.rows.length > 0;
1393
- const result = leaseResult.rows[0];
1394
-
1395
- if (acquired) {
1396
- // Lease acquired! Now check if this partition has messages
1397
- // If not, release and try next candidate
1398
- const messageQueryStart = Date.now();
1399
-
1400
- const cursor = result;
1401
- const cursorId = cursor.last_consumed_id || '00000000-0000-0000-0000-000000000000';
1402
- const totalConsumed = cursor.total_messages_consumed || 0;
1403
-
1404
- // Cursor-based query using ONLY UUID comparison (UUIDv7 is time-ordered)
1405
- // Conditionally add timestamp filters only when configured
1406
- const hasDelayedProcessing = candidate.delayed_processing > 0;
1407
- const hasEviction = candidate.max_wait_time_seconds > 0;
1408
-
1409
- let whereClause = 'm.partition_id = $4 AND m.id > $5::uuid';
1410
- let queryParams = [
1411
- candidate.partition_name,
1412
- candidate.queue_name,
1413
- candidate.priority || 0,
1414
- candidate.partition_id,
1415
- cursorId
1416
- ];
1417
- let paramIndex = 6;
1418
-
1419
- if (hasDelayedProcessing) {
1420
- whereClause += ` AND m.created_at <= NOW() - INTERVAL '1 second' * $${paramIndex}`;
1421
- queryParams.push(candidate.delayed_processing);
1422
- paramIndex++;
1423
- }
1424
-
1425
- if (hasEviction) {
1426
- whereClause += ` AND m.created_at > NOW() - INTERVAL '1 second' * $${paramIndex}`;
1427
- queryParams.push(candidate.max_wait_time_seconds);
1428
- paramIndex++;
1429
- }
1430
-
1431
- queryParams.push(batch);
1432
- const limitParam = `$${paramIndex}`;
1433
-
1434
- const messagesResult = await client.query(`
1435
- SELECT
1436
- m.id,
1437
- m.transaction_id,
1438
- m.trace_id,
1439
- m.payload,
1440
- m.is_encrypted,
1441
- m.created_at,
1442
- $1 as partition_name,
1443
- $2 as queue_name,
1444
- $3 as priority,
1445
- 0 as retry_count
1446
- FROM queen.messages m
1447
- WHERE ${whereClause}
1448
- ORDER BY m.created_at ASC, m.id ASC
1449
- LIMIT ${limitParam}
1450
- FOR UPDATE OF m SKIP LOCKED
1451
- `, queryParams)
1452
- .catch(err => {
1453
- if (err.code === '55P03') { // Lock not available
1454
- return { rows: [] };
1455
- }
1456
- throw err;
1457
- });
1458
-
1459
- const messageQueryTime = Date.now() - messageQueryStart;
1460
- const messages = messagesResult.rows;
1461
-
1462
- // Check if we found messages
1463
- if (messages.length === 0) {
1464
- // No messages in this partition (cursor exhausted) - release lease and try next candidate
1465
- log(`[CURSOR] Partition ${candidate.partition_name} exhausted after ${totalConsumed} messages`);
1466
- await client.query(`
1467
- UPDATE queen.partition_consumers
1468
- SET lease_expires_at = NULL,
1469
- lease_acquired_at = NULL
1470
- WHERE partition_id = $1 AND consumer_group = $2
1471
- `, [candidate.partition_id, actualConsumerGroup]);
1472
- continue; // Try next candidate partition
1473
- }
1474
-
1475
- // Success! We have messages from this partition
1476
- acquiredPartition = candidate;
1477
- acquiredPartition.leaseId = leaseId; // Store lease ID for later use
1478
-
1479
- // ─────────────────────────────────────────────────────────────────────
1480
- // PHASE 4: Update Partition Lease with Message Batch
1481
- // ─────────────────────────────────────────────────────────────────────
1482
-
1483
- const leaseUpdateStart = Date.now();
1484
- const messageIds = messages.map(m => m.id);
1485
- await client.query(`
1486
- UPDATE queen.partition_consumers
1487
- SET message_batch = $1::jsonb,
1488
- batch_size = $2::integer,
1489
- acked_count = 0
1490
- WHERE partition_id = $3 AND consumer_group = $4
1491
- `, [JSON.stringify(messageIds), messageIds.length, acquiredPartition.partition_id, actualConsumerGroup]);
1492
- const leaseUpdateTime = Date.now() - leaseUpdateStart;
1493
-
1494
- log(`[CURSOR-LEASE] Partition: ${acquiredPartition.partition_name} | Leased ${messageIds.length} messages for processing`);
1495
-
1496
- // ─────────────────────────────────────────────────────────────────────
1497
- // PHASE 5: Decrypt and Format Messages
1498
- // ─────────────────────────────────────────────────────────────────────
1499
-
1500
- const decryptStart = Date.now();
1501
- const formattedMessages = await Promise.all(messages.map(async (msg) => {
1502
- let payload = msg.payload;
1503
-
1504
- if (msg.is_encrypted && encryption.isEncryptionEnabled()) {
1505
- try {
1506
- payload = await encryption.decryptPayload(msg.payload);
1507
- } catch (error) {
1508
- log(`${LogTypes.ERROR} | Failed to decrypt message ${msg.transaction_id}: ${error.message}`);
1509
- }
1510
- }
1511
-
1512
- return {
1513
- id: msg.id,
1514
- transactionId: msg.transaction_id,
1515
- traceId: msg.trace_id,
1516
- queue: msg.queue_name,
1517
- partition: msg.partition_name,
1518
- data: payload,
1519
- retryCount: msg.retry_count || 0,
1520
- priority: msg.priority || 0,
1521
- createdAt: msg.created_at,
1522
- consumerGroup: consumerGroup || null,
1523
- leaseId: acquiredPartition.leaseId // Add lease ID to each message
1524
- };
1525
- }));
1526
- const decryptTime = Date.now() - decryptStart;
1527
-
1528
- // ─────────────────────────────────────────────────────────────────────
1529
- // PHASE 6: Metrics and Logging
1530
- // ─────────────────────────────────────────────────────────────────────
1531
-
1532
- const totalTime = Date.now() - startTime;
1533
- const overhead = totalTime - (connectionWaitTime + candidateQueryTime + messageQueryTime + leaseUpdateTime + decryptTime);
1534
- const throughput = totalTime > 0 ? (formattedMessages.length / (totalTime / 1000)).toFixed(0) : 0;
1535
-
1536
- log(`[${LogTypes.POP}] Mode: ${accessMode} | Count: ${formattedMessages.length} | ${throughput} msg/s | ConsumerGroup: ${actualConsumerGroup === '__QUEUE_MODE__' ? 'QUEUE_MODE' : actualConsumerGroup}`);
1537
- 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`);
1538
-
1539
- return { messages: formattedMessages };
1540
- }
1541
- } catch (err) {
1542
- // Unexpected error
1543
- if (!candidate.queue_name.startsWith('__')) {
1544
- log(`LEASE ERROR: Partition=${candidate.partition_name} | Error: ${err.message}`);
1545
- }
1546
- throw err;
1547
- }
1548
- }
1549
-
1550
- if (acquiredPartition) break;
1551
- }
1552
-
1553
- // If we exhausted all attempts and couldn't get messages
1554
- if (!acquiredPartition) {
1555
- log(`WARN: Failed to acquire any partition with messages after ${MAX_ATTEMPTS} attempts`);
1556
- return { messages: [] };
1557
- }
1558
- });
1559
- };
1560
-
1561
- // Execute multiple operations in a single transaction
1562
- const executeTransaction = async (operations, requiredLeases = []) => {
1563
- return withTransaction(pool, async (client) => {
1564
- // 1. Validate all required leases upfront
1565
- if (requiredLeases.length > 0) {
1566
- const leaseCheck = await client.query(`
1567
- SELECT COUNT(*) = $2 as all_valid
1568
- FROM queen.partition_consumers
1569
- WHERE worker_id = ANY($1::varchar[])
1570
- AND lease_expires_at > NOW()
1571
- `, [requiredLeases, requiredLeases.length]);
1572
-
1573
- if (!leaseCheck.rows[0].all_valid) {
1574
- throw new Error('One or more leases invalid or expired');
1575
- }
1576
- }
1577
-
1578
- // 2. Execute operations in order
1579
- const results = [];
1580
- for (const op of operations) {
1581
- switch (op.type) {
1582
- case 'push':
1583
- const pushResult = await pushMessagesInternal(client, op.items);
1584
- results.push({ type: 'push', result: pushResult });
1585
- break;
1586
-
1587
- case 'ack':
1588
- const acks = [{
1589
- transactionId: op.transactionId,
1590
- status: op.status || 'completed',
1591
- error: op.error
1592
- }];
1593
- const ackResult = await acknowledgeMessagesInternal(client, acks, op.consumerGroup);
1594
- results.push({ type: 'ack', result: ackResult });
1595
- break;
1596
-
1597
- case 'extend':
1598
- const extendResult = await client.query(`
1599
- UPDATE queen.partition_consumers
1600
- SET lease_expires_at = GREATEST(lease_expires_at, NOW() + INTERVAL '1 second' * $1)
1601
- WHERE worker_id = $2 AND lease_expires_at > NOW()
1602
- RETURNING lease_expires_at
1603
- `, [op.seconds || 60, op.leaseId]);
1604
-
1605
- if (extendResult.rows.length === 0) {
1606
- throw new Error(`Cannot extend lease ${op.leaseId} - not found or expired`);
1607
- }
1608
- results.push({ type: 'extend', result: extendResult.rows[0] });
1609
- break;
1610
-
1611
- default:
1612
- throw new Error(`Unknown operation type: ${op.type}`);
1613
- }
1614
- }
1615
-
1616
- return results;
1617
- });
1618
- };
1619
-
1620
- // Export all functions
1621
- return {
1622
- pushMessages: pushMessagesBatch,
1623
- pushMessagesBatch,
1624
- acknowledgeMessage,
1625
- acknowledgeMessages,
1626
- ackMessage: acknowledgeMessage, // Alias for compatibility
1627
- reclaimExpiredLeases,
1628
- getQueueStats,
1629
- getQueueLag,
1630
- uniquePop, // ← New unified function
1631
- configureQueue,
1632
- executeTransaction // ← New transaction support
1633
- };
1634
- };