queen-mq 0.1.6 → 0.2.11

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 +166 -2078
  2. package/{src → client-js}/benchmark/consumer.js +5 -3
  3. package/{src → client-js}/benchmark/producer.js +9 -4
  4. package/client-js/client/client.js +1511 -0
  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/client-js/test/advanced-client-tests.js +761 -0
  9. package/{src → client-js}/test/advanced-pattern-tests.js +2 -2
  10. package/{src → client-js}/test/bus-mode-tests.js +6 -6
  11. package/{src → client-js}/test/core-tests.js +8 -3
  12. package/{src → client-js}/test/edge-case-tests.js +4 -3
  13. package/client-js/test/human.js +162 -0
  14. package/client-js/test/qos0-tests.js +334 -0
  15. package/{src → client-js}/test/test-new.js +78 -1
  16. package/package.json +8 -29
  17. package/init-db.js +0 -20
  18. package/src/client/client.js +0 -586
  19. package/src/cluster-server.js +0 -242
  20. package/src/config.js +0 -229
  21. package/src/database/connection.js +0 -129
  22. package/src/database/poolManager.js +0 -199
  23. package/src/database/schema-v2.sql +0 -278
  24. package/src/managers/eventManager.js +0 -59
  25. package/src/managers/queueManagerOptimized.js +0 -1431
  26. package/src/managers/resourceCache.js +0 -96
  27. package/src/managers/systemEventManager.js +0 -132
  28. package/src/routes/ack.js +0 -26
  29. package/src/routes/configure.js +0 -46
  30. package/src/routes/messages.js +0 -368
  31. package/src/routes/pop.js +0 -69
  32. package/src/routes/push.js +0 -28
  33. package/src/routes/resources.js +0 -330
  34. package/src/routes/status.js +0 -1037
  35. package/src/server.js +0 -1528
  36. package/src/test/MIGRATION_ISSUES.md +0 -174
  37. package/src/test/test.js +0 -4524
  38. package/src/utils/streaming.js +0 -231
  39. package/src/webapp-dist/assets/Analytics-DJrE4Rz2.css +0 -1
  40. package/src/webapp-dist/assets/Analytics-HEMXKmfJ.js +0 -1
  41. package/src/webapp-dist/assets/ConfirmDialog-YEoCdE7x.js +0 -1
  42. package/src/webapp-dist/assets/ConsumerGroups-CC5VJywp.css +0 -1
  43. package/src/webapp-dist/assets/ConsumerGroups-D8Z20HhZ.js +0 -1
  44. package/src/webapp-dist/assets/Dashboard-BRFOz6it.css +0 -1
  45. package/src/webapp-dist/assets/Dashboard-Bpx9I32h.js +0 -1
  46. package/src/webapp-dist/assets/LoadingSpinner-BPttXcbs.js +0 -1
  47. package/src/webapp-dist/assets/Messages-BOfVHKma.css +0 -1
  48. package/src/webapp-dist/assets/Messages-EKURCbLl.js +0 -1
  49. package/src/webapp-dist/assets/QueueDetail-BXXA7xoZ.css +0 -1
  50. package/src/webapp-dist/assets/QueueDetail-Bmxg0KiA.js +0 -1
  51. package/src/webapp-dist/assets/Queues-CvrJkrZT.css +0 -1
  52. package/src/webapp-dist/assets/Queues-yUx9_3-9.js +0 -1
  53. package/src/webapp-dist/assets/StatusBadge-C1bBtXHh.css +0 -1
  54. package/src/webapp-dist/assets/StatusBadge-C6N84XmO.js +0 -1
  55. package/src/webapp-dist/assets/analytics-BapTekLM.js +0 -1
  56. package/src/webapp-dist/assets/colors-5z6Q_kUr.js +0 -18
  57. package/src/webapp-dist/assets/index-B7w2bKT6.css +0 -1
  58. package/src/webapp-dist/assets/index-CUSnwYTH.js +0 -31
  59. package/src/webapp-dist/assets/messages-CRV6E-xU.js +0 -1
  60. package/src/webapp-dist/assets/queen-logo-blue.svg +0 -210
  61. package/src/webapp-dist/assets/queen-logo-cyan.svg +0 -210
  62. package/src/webapp-dist/assets/queen-logo-indigo.svg +0 -210
  63. package/src/webapp-dist/assets/queen-logo-orange.svg +0 -210
  64. package/src/webapp-dist/assets/queen-logo-pink.svg +0 -210
  65. package/src/webapp-dist/assets/queen-logo-purple.svg +0 -210
  66. package/src/webapp-dist/assets/queen-logo-rose.svg +0 -239
  67. package/src/webapp-dist/assets/queen-logo.svg +0 -263
  68. package/src/webapp-dist/assets/queues-BdUbxfBC.js +0 -1
  69. package/src/webapp-dist/assets/resources-Bs9U-ceF.js +0 -1
  70. package/src/webapp-dist/index.html +0 -15
  71. package/src/websocket/wsServer.js +0 -228
  72. /package/{src → client-js}/benchmark/consumer_multi.js +0 -0
  73. /package/{src → client-js}/benchmark/producer_multi.js +0 -0
  74. /package/{src → client-js}/client/utils/loadBalancer.js +0 -0
  75. /package/{src → client-js}/services/encryptionService.js +0 -0
  76. /package/{src → client-js}/services/evictionService.js +0 -0
  77. /package/{src → client-js}/services/retentionService.js +0 -0
  78. /package/{src → client-js}/services/startupSync.js +0 -0
  79. /package/{src → client-js}/test/README.md +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
@@ -0,0 +1,1511 @@
1
+ import { createHttpClient, createLoadBalancedHttpClient } from './utils/http.js';
2
+ import { withRetry } from './utils/retry.js';
3
+ import { createLoadBalancer, LoadBalancingStrategy } from './utils/loadBalancer.js';
4
+ import { generateUUID } from '../utils/uuid.js';
5
+
6
+ /**
7
+ * Minimalist Queen Message Queue Client
8
+ *
9
+ * Simple, powerful API with just 4 methods:
10
+ * - queue: Configure a queue
11
+ * - push: Send messages
12
+ * - take: Receive messages (async iterator)
13
+ * - ack: Acknowledge messages
14
+ */
15
+ export class Queen {
16
+ #config;
17
+ #http;
18
+ #loadBalancer;
19
+ #connected = false;
20
+ #bufferManager;
21
+
22
+ constructor(config = {}) {
23
+ this.#config = {
24
+ baseUrls: null,
25
+ loadBalancingStrategy: LoadBalancingStrategy.ROUND_ROBIN,
26
+ enableFailover: true,
27
+ timeout: 30000,
28
+ retryAttempts: 3,
29
+ retryDelay: 1000,
30
+ ...config
31
+ };
32
+ this.#bufferManager = new BufferManager(this);
33
+ }
34
+
35
+ /**
36
+ * Ensure HTTP client is connected
37
+ */
38
+ async #ensureConnected() {
39
+ if (this.#connected) return;
40
+
41
+ const { baseUrl, baseUrls, loadBalancingStrategy, enableFailover, timeout } = this.#config;
42
+
43
+ if (baseUrls && Array.isArray(baseUrls) && baseUrls.length > 0) {
44
+ // Multiple servers with load balancing
45
+ this.#loadBalancer = createLoadBalancer(baseUrls, loadBalancingStrategy);
46
+ this.#http = createLoadBalancedHttpClient({
47
+ baseUrls,
48
+ loadBalancer: this.#loadBalancer,
49
+ timeout,
50
+ enableFailover
51
+ });
52
+ } else {
53
+ // Single server
54
+ const singleUrl = baseUrls && baseUrls.length === 1 ? baseUrls[0] : baseUrl;
55
+ this.#http = createHttpClient({ baseUrl: singleUrl, timeout });
56
+ }
57
+
58
+ this.#connected = true;
59
+ }
60
+
61
+ /**
62
+ * Validate if a string is a valid UUID v4
63
+ */
64
+ #isValidUUID(str) {
65
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
66
+ return uuidRegex.test(str);
67
+ }
68
+
69
+ /**
70
+ * Parse address string into components
71
+ * Examples:
72
+ * - "myqueue" -> { queue: "myqueue" }
73
+ * - "myqueue/urgent" -> { queue: "myqueue", partition: "urgent" }
74
+ * - "myqueue@workers" -> { queue: "myqueue", consumerGroup: "workers" }
75
+ * - "myqueue/urgent@workers" -> { queue: "myqueue", partition: "urgent", consumerGroup: "workers" }
76
+ * - "namespace:billing" -> { namespace: "billing" }
77
+ * - "task:process" -> { task: "process" }
78
+ * - "namespace:billing/task:process" -> { namespace: "billing", task: "process" }
79
+ */
80
+ #parseAddress(address) {
81
+ // Check for namespace/task pattern
82
+ if (address.includes('namespace:') || address.includes('task:')) {
83
+ const parts = {};
84
+
85
+ // Extract consumer group if present (after @)
86
+ let workingAddress = address;
87
+ const atIndex = address.lastIndexOf('@');
88
+ if (atIndex > 0) {
89
+ parts.consumerGroup = address.substring(atIndex + 1);
90
+ workingAddress = address.substring(0, atIndex);
91
+ }
92
+
93
+ const segments = workingAddress.split('/');
94
+
95
+ for (const segment of segments) {
96
+ if (segment.startsWith('namespace:')) {
97
+ parts.namespace = segment.substring(10);
98
+ } else if (segment.startsWith('task:')) {
99
+ parts.task = segment.substring(5);
100
+ }
101
+ }
102
+
103
+ return parts;
104
+ }
105
+
106
+ // Standard queue[/partition][@group] pattern
107
+ const match = address.match(/^([^/@]+)(?:\/([^@]+))?(?:@(.+))?$/);
108
+ if (!match) {
109
+ throw new Error(`Invalid address format: ${address}`);
110
+ }
111
+
112
+ return {
113
+ queue: match[1],
114
+ partition: match[2] || 'Default',
115
+ consumerGroup: match[3] || null
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Configure a queue with options
121
+ * @param {string} name - Queue name
122
+ * @param {Object} options - Queue configuration options
123
+ * @param {Object} metadata - Optional metadata { namespace, task }
124
+ */
125
+ async queue(name, options = {}, metadata = {}) {
126
+ await this.#ensureConnected();
127
+
128
+ const { namespace, task } = metadata;
129
+
130
+ const result = await withRetry(
131
+ () => this.#http.post('/api/v1/configure', {
132
+ queue: name,
133
+ namespace: namespace || null,
134
+ task: task || null,
135
+ options
136
+ }),
137
+ this.#config.retryAttempts,
138
+ this.#config.retryDelay
139
+ );
140
+
141
+ if (result && result.error) {
142
+ throw new Error(result.error);
143
+ }
144
+
145
+ return result;
146
+ }
147
+
148
+ /**
149
+ * Push messages to a queue
150
+ * @param {string} address - Queue address (e.g., "myqueue" or "myqueue/partition")
151
+ * @param {Object|Array} payload - Single message or array of messages
152
+ * @param {Object} options - Optional message properties { transactionId, traceId, buffer }
153
+ */
154
+ async push(address, payload, options = {}) {
155
+ await this.#ensureConnected();
156
+
157
+ const { queue, partition } = this.#parseAddress(address);
158
+
159
+ // Handle both single and array inputs
160
+ const items = Array.isArray(payload) ? payload : [payload];
161
+
162
+ // Format items for the API
163
+ const formattedItems = items.map(item => {
164
+ // If item is an object with special properties, extract them
165
+ const isMessageObject = typeof item === 'object' && item !== null &&
166
+ (item._payload || item._transactionId || item._traceId);
167
+
168
+ if (isMessageObject) {
169
+ const result = {
170
+ queue,
171
+ partition,
172
+ payload: item._payload || item,
173
+ transactionId: item._transactionId || item.transactionId || generateUUID() // Always generate if missing
174
+ };
175
+
176
+ // Include traceId if provided and valid UUID
177
+ const traceId = item._traceId || item.traceId;
178
+ if (traceId && this.#isValidUUID(traceId)) {
179
+ result.traceId = traceId;
180
+ }
181
+
182
+ return result;
183
+ }
184
+
185
+ // Otherwise use the item as payload and apply options
186
+ const result = {
187
+ queue,
188
+ partition,
189
+ payload: item,
190
+ transactionId: options.transactionId ||
191
+ (item && typeof item === 'object' ? item.transactionId : undefined) ||
192
+ generateUUID() // Always generate if missing
193
+ };
194
+
195
+ // Include traceId if provided and valid UUID
196
+ const traceId = options.traceId || (item && typeof item === 'object' ? item.traceId : undefined);
197
+ if (traceId && this.#isValidUUID(traceId)) {
198
+ result.traceId = traceId;
199
+ }
200
+
201
+ return result;
202
+ });
203
+
204
+ // Check if client-side buffering is enabled
205
+ if (options.buffer && typeof options.buffer === 'object') {
206
+ return this.#pushBuffered(address, formattedItems, options.buffer);
207
+ }
208
+
209
+ // Build request body for immediate push
210
+ const requestBody = { items: formattedItems };
211
+
212
+ // Add server-side buffering flag if specified
213
+ if (options.buffer === true || options.qos0 === true) {
214
+ requestBody.qos0 = true; // Server still uses 'qos0' internally
215
+ }
216
+
217
+ // Legacy support: bufferMs/bufferMax are aliases for buffer
218
+ if (options.bufferMs !== undefined || options.bufferMax !== undefined) {
219
+ requestBody.qos0 = true;
220
+ }
221
+
222
+ const result = await withRetry(
223
+ () => this.#http.post('/api/v1/push', requestBody),
224
+ this.#config.retryAttempts,
225
+ this.#config.retryDelay
226
+ );
227
+
228
+ if (result && result.error) {
229
+ throw new Error(result.error);
230
+ }
231
+
232
+ return result;
233
+ }
234
+
235
+ /**
236
+ * Push messages using client-side buffering
237
+ * @private
238
+ */
239
+ #pushBuffered(address, formattedItems, bufferOptions) {
240
+ // Validate buffer options
241
+ if (!bufferOptions.size || !bufferOptions.time) {
242
+ throw new Error('Buffer options must include both size and time');
243
+ }
244
+
245
+ // Add each formatted message to the buffer
246
+ for (const item of formattedItems) {
247
+ this.#bufferManager.addMessage(address, item, bufferOptions);
248
+ }
249
+
250
+ // Return immediately (non-blocking)
251
+ return { buffered: true, count: formattedItems.length };
252
+ }
253
+
254
+ /**
255
+ * Internal method that yields batches of messages (arrays)
256
+ * @private
257
+ * @param {string} address - Queue address
258
+ * @param {Object} options - Options for taking messages
259
+ * @yields {Array} Arrays of message objects
260
+ */
261
+ async *#takeInternal(address, options = {}) {
262
+ await this.#ensureConnected();
263
+
264
+ const { queue, partition, consumerGroup, namespace, task } = this.#parseAddress(address);
265
+ const {
266
+ limit = null,
267
+ batch = 1,
268
+ wait = false,
269
+ timeout = 30000,
270
+ subscriptionMode = null,
271
+ subscriptionFrom = null,
272
+ idleTimeout = null
273
+ } = options;
274
+
275
+ let totalCount = 0;
276
+ let consecutiveEmptyResponses = 0;
277
+ let consecutiveNetworkErrors = 0;
278
+ const maxConsecutiveEmpty = 3;
279
+ let lastMessageTime = idleTimeout ? Date.now() : null;
280
+
281
+ while (true) {
282
+ // Check if we've reached the limit
283
+ if (limit && totalCount >= limit) break;
284
+
285
+ // Check idle timeout
286
+ if (idleTimeout && lastMessageTime) {
287
+ const idleTime = Date.now() - lastMessageTime;
288
+ if (idleTime >= idleTimeout) {
289
+ break; // Exit if idle time exceeded
290
+ }
291
+ }
292
+
293
+ // Calculate batch size respecting the limit
294
+ const effectiveBatch = limit ? Math.min(batch, limit - totalCount) : batch;
295
+
296
+ // Build the request path and parameters
297
+ let path;
298
+ const params = new URLSearchParams({
299
+ wait: wait.toString(),
300
+ timeout: timeout.toString(),
301
+ batch: effectiveBatch.toString()
302
+ });
303
+
304
+ // Add consumer group parameters if provided
305
+ if (consumerGroup) params.append('consumerGroup', consumerGroup);
306
+ if (subscriptionMode) params.append('subscriptionMode', subscriptionMode);
307
+ if (subscriptionFrom) params.append('subscriptionFrom', subscriptionFrom);
308
+
309
+ // Add auto-ack option if specified
310
+ if (options.autoAck) params.append('autoAck', 'true');
311
+
312
+ // Determine the endpoint based on parameters
313
+ if (queue) {
314
+ if (partition && partition !== 'Default') {
315
+ path = `/api/v1/pop/queue/${queue}/partition/${partition}`;
316
+ } else {
317
+ path = `/api/v1/pop/queue/${queue}`;
318
+ }
319
+ } else if (namespace || task) {
320
+ // Pop by namespace/task
321
+ path = '/api/v1/pop';
322
+ if (namespace) params.append('namespace', namespace);
323
+ if (task) params.append('task', task);
324
+ } else {
325
+ throw new Error('Must specify either queue, namespace, or task');
326
+ }
327
+
328
+ try {
329
+ // For long polling, use a slightly longer client timeout
330
+ const clientTimeout = wait ? timeout + 5000 : timeout;
331
+
332
+ const result = await this.#http.get(`${path}?${params}`, clientTimeout);
333
+
334
+ // Handle empty response
335
+ if (!result || !result.messages || result.messages.length === 0) {
336
+ if (wait) {
337
+ // For long polling, immediately retry
338
+ continue;
339
+ } else {
340
+ // For non-waiting mode, stop after several empty responses
341
+ consecutiveEmptyResponses++;
342
+ if (consecutiveEmptyResponses >= maxConsecutiveEmpty) {
343
+ break;
344
+ }
345
+ // Small delay before retry
346
+ await new Promise(resolve => setTimeout(resolve, 100));
347
+ continue;
348
+ }
349
+ }
350
+
351
+ // Reset counters on successful fetch
352
+ consecutiveEmptyResponses = 0;
353
+ consecutiveNetworkErrors = 0;
354
+
355
+ // Update last message time if tracking idle timeout
356
+ if (idleTimeout) {
357
+ lastMessageTime = Date.now();
358
+ }
359
+
360
+ // Filter out null/undefined messages
361
+ const messages = result.messages.filter(msg => msg != null);
362
+
363
+ if (messages.length > 0) {
364
+ totalCount += messages.length;
365
+ yield messages;
366
+
367
+ // If we've hit the limit, stop
368
+ if (limit && totalCount >= limit) {
369
+ break;
370
+ }
371
+ }
372
+
373
+ } catch (error) {
374
+ // Check if this is a timeout error (expected for long polling)
375
+ const isTimeoutError = error.name === 'AbortError' ||
376
+ error.message?.includes('abort') ||
377
+ error.message?.includes('timeout');
378
+
379
+ if (isTimeoutError && wait) {
380
+ // For long polling timeout, immediately retry
381
+ continue;
382
+ }
383
+
384
+ // Check if this is a network error (connection refused, socket closed, etc.)
385
+ const isNetworkError = error.message?.includes('fetch failed') ||
386
+ error.message?.includes('ECONNREFUSED') ||
387
+ error.message?.includes('ECONNRESET') ||
388
+ error.message?.includes('closed') ||
389
+ error.cause?.code === 'UND_ERR_SOCKET' ||
390
+ error.code === 'ECONNREFUSED' ||
391
+ error.code === 'ECONNRESET';
392
+
393
+ if (isNetworkError) {
394
+ // Log the network error
395
+ console.warn(`Network error while polling queue: ${error.message}`);
396
+
397
+ // Retry with exponential backoff (capped at 30 seconds)
398
+ const retryDelay = Math.min(this.#config.retryDelay * Math.pow(2, Math.min(consecutiveNetworkErrors, 10)), 30000);
399
+ console.warn(`Retrying in ${retryDelay}ms... (attempt ${consecutiveNetworkErrors + 1})`);
400
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
401
+
402
+ consecutiveNetworkErrors++;
403
+
404
+ // Continue retrying indefinitely
405
+ continue;
406
+ }
407
+
408
+ // For other errors, throw
409
+ throw error;
410
+ }
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Take messages from a queue one at a time (async iterator)
416
+ * @param {string} address - Queue address (e.g., "myqueue", "myqueue/partition", "myqueue@group")
417
+ * @param {Object} options - Options for taking messages
418
+ * @yields {Object} Individual message objects
419
+ */
420
+ async *take(address, options = {}) {
421
+ let count = 0;
422
+ const { limit = null } = options;
423
+
424
+ for await (const messages of this.#takeInternal(address, options)) {
425
+ for (const message of messages) {
426
+ yield message;
427
+ count++;
428
+
429
+ // Double-check limit (internal method also checks, but this ensures exact limit)
430
+ if (limit && count >= limit) {
431
+ return;
432
+ }
433
+ }
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Take messages from a queue in batches (async iterator)
439
+ * @param {string} address - Queue address (e.g., "myqueue", "myqueue/partition", "myqueue@group")
440
+ * @param {Object} options - Options for taking messages
441
+ * @yields {Array} Arrays of message objects
442
+ */
443
+ async *takeBatch(address, options = {}) {
444
+ for await (const messages of this.#takeInternal(address, options)) {
445
+ // Only yield non-empty batches
446
+ if (messages && messages.length > 0) {
447
+ yield messages;
448
+ }
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Take a single batch of messages (convenience method)
454
+ * @param {string} address - Queue address
455
+ * @param {Object} options - Options for taking messages
456
+ * @returns {Promise<Array>} Array of messages
457
+ */
458
+ async takeSingleBatch(address, options = {}) {
459
+ const messages = [];
460
+ for await (const batch of this.takeBatch(address, options)) {
461
+ messages.push(...batch);
462
+ break; // Take only one batch
463
+ }
464
+ return messages;
465
+ }
466
+
467
+ /**
468
+ * Renew lease for a message or batch of messages
469
+ * @param {Object|Array|string} messageOrLeaseId - Message object(s) or lease ID(s)
470
+ * @returns {Promise<Object>} Renewal result
471
+ */
472
+ async renewLease(messageOrLeaseId) {
473
+ await this.#ensureConnected();
474
+
475
+ // Handle different input types
476
+ let leaseIds = [];
477
+
478
+ if (typeof messageOrLeaseId === 'string') {
479
+ // Direct lease ID
480
+ leaseIds = [messageOrLeaseId];
481
+ } else if (Array.isArray(messageOrLeaseId)) {
482
+ // Array of messages or lease IDs
483
+ leaseIds = messageOrLeaseId.map(item =>
484
+ typeof item === 'string' ? item : item.leaseId
485
+ ).filter(Boolean);
486
+ } else if (messageOrLeaseId && typeof messageOrLeaseId === 'object') {
487
+ // Single message object
488
+ if (messageOrLeaseId.leaseId) {
489
+ leaseIds = [messageOrLeaseId.leaseId];
490
+ }
491
+ }
492
+
493
+ if (leaseIds.length === 0) {
494
+ throw new Error('No valid lease IDs found for renewal');
495
+ }
496
+
497
+ // Renew all leases
498
+ const results = [];
499
+ for (const leaseId of leaseIds) {
500
+ try {
501
+ const result = await withRetry(
502
+ () => this.#http.post(`/api/v1/lease/${leaseId}/extend`, {}),
503
+ this.#config.retryAttempts,
504
+ this.#config.retryDelay
505
+ );
506
+ results.push({
507
+ leaseId,
508
+ success: true,
509
+ newExpiresAt: result.leaseId ? result.newExpiresAt : result.lease_expires_at
510
+ });
511
+ } catch (error) {
512
+ results.push({ leaseId, success: false, error: error.message });
513
+ }
514
+ }
515
+
516
+ // Return single result for single input, array for array input
517
+ return Array.isArray(messageOrLeaseId) ? results : results[0];
518
+ }
519
+
520
+ /**
521
+ * Acknowledge a message or batch of messages
522
+ * @param {Object|string|Array} message - Message object, transaction ID, or array of messages
523
+ * @param {boolean|string} status - true for success, false for failure, or 'retry'
524
+ * @param {Object} context - Optional context (e.g., { group: 'workers', error: 'reason' })
525
+ */
526
+ async ack(message, status = true, context = {}) {
527
+ await this.#ensureConnected();
528
+
529
+ // Handle batch acknowledgment
530
+ if (Array.isArray(message)) {
531
+ if (message.length === 0) {
532
+ return { processed: 0, results: [] };
533
+ }
534
+
535
+ // Check if messages have individual status (Option B pattern)
536
+ const hasIndividualStatus = message.some(msg =>
537
+ typeof msg === 'object' && msg !== null && ('_status' in msg || '_error' in msg)
538
+ );
539
+
540
+ let acknowledgments;
541
+
542
+ if (hasIndividualStatus) {
543
+ // Option B: Each message has its own status
544
+ acknowledgments = message.map(msg => {
545
+ // Extract transaction ID and lease ID
546
+ let transactionId;
547
+ let leaseId = null;
548
+
549
+ if (typeof msg === 'string') {
550
+ transactionId = msg;
551
+ } else if (typeof msg === 'object' && msg !== null) {
552
+ transactionId = msg.transactionId || msg.id;
553
+ leaseId = msg.leaseId || null;
554
+ if (!transactionId) {
555
+ throw new Error('Message object must have transactionId or id property');
556
+ }
557
+ } else {
558
+ throw new Error('Invalid message in batch');
559
+ }
560
+
561
+ // Get individual status or fall back to parameter
562
+ let msgStatus = msg._status !== undefined ? msg._status : status;
563
+ let statusStr;
564
+ if (typeof msgStatus === 'boolean') {
565
+ statusStr = msgStatus ? 'completed' : 'failed';
566
+ } else {
567
+ statusStr = msgStatus;
568
+ }
569
+
570
+ const ack = {
571
+ transactionId,
572
+ status: statusStr,
573
+ error: msg._error || context.error || null
574
+ };
575
+
576
+ // Include lease ID if available
577
+ if (leaseId) {
578
+ ack.leaseId = leaseId;
579
+ }
580
+
581
+ return ack;
582
+ });
583
+ } else {
584
+ // Option A: Same status for all messages
585
+ const statusStr = typeof status === 'boolean'
586
+ ? (status ? 'completed' : 'failed')
587
+ : status;
588
+
589
+ acknowledgments = message.map(msg => {
590
+ // Extract transaction ID and lease ID
591
+ let transactionId;
592
+ let leaseId = null;
593
+
594
+ if (typeof msg === 'string') {
595
+ transactionId = msg;
596
+ } else if (typeof msg === 'object' && msg !== null) {
597
+ transactionId = msg.transactionId || msg.id;
598
+ leaseId = msg.leaseId || null;
599
+ if (!transactionId) {
600
+ throw new Error('Message object must have transactionId or id property');
601
+ }
602
+ } else {
603
+ throw new Error('Invalid message in batch');
604
+ }
605
+
606
+ const ack = {
607
+ transactionId,
608
+ status: statusStr,
609
+ error: context.error || null
610
+ };
611
+
612
+ // Include lease ID if available
613
+ if (leaseId) {
614
+ ack.leaseId = leaseId;
615
+ }
616
+
617
+ return ack;
618
+ });
619
+ }
620
+
621
+ // Call batch ack endpoint
622
+ const result = await withRetry(
623
+ () => this.#http.post('/api/v1/ack/batch', {
624
+ acknowledgments,
625
+ consumerGroup: context.group || null
626
+ }),
627
+ this.#config.retryAttempts,
628
+ this.#config.retryDelay
629
+ );
630
+
631
+ if (result && result.error) {
632
+ throw new Error(result.error);
633
+ }
634
+
635
+ return result;
636
+ }
637
+
638
+ // Handle single message acknowledgment
639
+ // Extract transaction ID and lease ID
640
+ let transactionId;
641
+ let leaseId = null;
642
+
643
+ if (typeof message === 'string') {
644
+ transactionId = message;
645
+ } else if (typeof message === 'object' && message !== null) {
646
+ transactionId = message.transactionId || message.id;
647
+ leaseId = message.leaseId || null; // Extract lease ID if present
648
+ if (!transactionId) {
649
+ throw new Error('Message object must have transactionId or id property');
650
+ }
651
+ } else {
652
+ throw new Error('Message must be a string (transaction ID) or object with transactionId');
653
+ }
654
+
655
+ // Determine status string
656
+ let statusStr;
657
+ if (typeof status === 'boolean') {
658
+ statusStr = status ? 'completed' : 'failed';
659
+ } else {
660
+ statusStr = status; // Allow custom status like 'retry'
661
+ }
662
+
663
+ // Build request body
664
+ const body = {
665
+ transactionId,
666
+ status: statusStr,
667
+ error: context.error || null,
668
+ consumerGroup: context.group || null
669
+ };
670
+
671
+ // Include lease ID if available for lease validation
672
+ if (leaseId) {
673
+ body.leaseId = leaseId;
674
+ }
675
+
676
+ const result = await withRetry(
677
+ () => this.#http.post('/api/v1/ack', body),
678
+ this.#config.retryAttempts,
679
+ this.#config.retryDelay
680
+ );
681
+
682
+ if (result && result.error) {
683
+ throw new Error(result.error);
684
+ }
685
+
686
+ return result;
687
+ }
688
+
689
+ /**
690
+ * Delete a queue (removes queue and all its messages/partitions)
691
+ * @param {string} name - Queue name to delete
692
+ * @returns {Promise<Object>} Deletion result
693
+ */
694
+ async queueDelete(name) {
695
+ await this.#ensureConnected();
696
+
697
+ if (typeof name !== 'string' || !name) {
698
+ throw new Error('Queue name must be a non-empty string');
699
+ }
700
+
701
+ const result = await withRetry(
702
+ () => this.#http.delete(`/api/v1/resources/queues/${encodeURIComponent(name)}`),
703
+ this.#config.retryAttempts,
704
+ this.#config.retryDelay
705
+ );
706
+
707
+ if (result && result.error) {
708
+ throw new Error(result.error);
709
+ }
710
+
711
+ return result;
712
+ }
713
+
714
+ /**
715
+ * Manually flush a specific buffer
716
+ * @param {string} address - Queue address to flush
717
+ * @returns {Promise<void>}
718
+ */
719
+ async flushBuffer(address) {
720
+ return this.#bufferManager.flushBuffer(address);
721
+ }
722
+
723
+ /**
724
+ * Manually flush all buffers
725
+ * @returns {Promise<void>}
726
+ */
727
+ async flushAllBuffers() {
728
+ return this.#bufferManager.flushAllBuffers();
729
+ }
730
+
731
+ /**
732
+ * Get buffer statistics
733
+ * @returns {Object} Buffer statistics
734
+ */
735
+ getBufferStats() {
736
+ return this.#bufferManager.getStats();
737
+ }
738
+
739
+ /**
740
+ * Close the client connection
741
+ */
742
+ async close() {
743
+ // Flush all buffers before closing
744
+ try {
745
+ await this.#bufferManager.flushAllBuffers();
746
+ } catch (error) {
747
+ console.warn('Error flushing buffers during close:', error);
748
+ }
749
+
750
+ // Cleanup buffer manager
751
+ this.#bufferManager.cleanup();
752
+
753
+ this.#connected = false;
754
+ this.#http = null;
755
+ this.#loadBalancer = null;
756
+ }
757
+
758
+ /**
759
+ * Create a transaction builder for atomic operations
760
+ * @returns {TransactionBuilder} Transaction builder instance
761
+ */
762
+ transaction() {
763
+ return new TransactionBuilder(this);
764
+ }
765
+
766
+ /**
767
+ * Create a pipeline for message processing workflows
768
+ * @param {string} queue - Source queue name
769
+ * @returns {PipelineBuilder} Pipeline builder instance
770
+ */
771
+ pipeline(queue) {
772
+ return new PipelineBuilder(this, queue);
773
+ }
774
+
775
+ // Internal helper methods for Transaction and Pipeline
776
+ async _ensureConnected() {
777
+ return this.#ensureConnected();
778
+ }
779
+
780
+ get _http() {
781
+ return this.#http;
782
+ }
783
+
784
+ get _config() {
785
+ return this.#config;
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Message Buffer for client-side buffering
791
+ */
792
+ class MessageBuffer {
793
+ constructor(address, options, flushCallback) {
794
+ this.address = address;
795
+ this.messages = [];
796
+ this.options = options;
797
+ this.flushCallback = flushCallback;
798
+ this.timer = null;
799
+ this.firstMessageTime = null;
800
+ this.flushing = false;
801
+ }
802
+
803
+ add(formattedMessage) {
804
+ // Set first message time if this is the first message
805
+ if (this.messages.length === 0) {
806
+ this.firstMessageTime = Date.now();
807
+ this.#startTimer();
808
+ }
809
+
810
+ this.messages.push(formattedMessage);
811
+
812
+ // Check if we should flush based on size
813
+ if (this.messages.length >= this.options.size) {
814
+ this.#triggerFlush();
815
+ }
816
+ }
817
+
818
+ #startTimer() {
819
+ if (this.timer) return; // Timer already running
820
+
821
+ this.timer = setTimeout(() => {
822
+ this.#triggerFlush();
823
+ }, this.options.time);
824
+ }
825
+
826
+ #triggerFlush() {
827
+ if (this.flushing || this.messages.length === 0) return;
828
+
829
+ // Clear timer
830
+ if (this.timer) {
831
+ clearTimeout(this.timer);
832
+ this.timer = null;
833
+ }
834
+
835
+ // Trigger flush via callback
836
+ this.flushCallback(this.address);
837
+ }
838
+
839
+ extractMessages() {
840
+ const messages = [...this.messages];
841
+ this.messages = [];
842
+ this.firstMessageTime = null;
843
+ this.flushing = false;
844
+
845
+ if (this.timer) {
846
+ clearTimeout(this.timer);
847
+ this.timer = null;
848
+ }
849
+
850
+ return messages;
851
+ }
852
+
853
+ cleanup() {
854
+ if (this.timer) {
855
+ clearTimeout(this.timer);
856
+ this.timer = null;
857
+ }
858
+ this.messages = [];
859
+ this.firstMessageTime = null;
860
+ this.flushing = false;
861
+ }
862
+ }
863
+
864
+ /**
865
+ * Buffer Manager for client-side message buffering
866
+ */
867
+ class BufferManager {
868
+ constructor(client) {
869
+ this.client = client;
870
+ this.buffers = new Map(); // address -> MessageBuffer
871
+ this.flushCount = 0;
872
+ }
873
+
874
+ addMessage(address, formattedMessage, bufferOptions) {
875
+ const bufferKey = address;
876
+
877
+ if (!this.buffers.has(bufferKey)) {
878
+ this.buffers.set(bufferKey, new MessageBuffer(
879
+ address,
880
+ bufferOptions,
881
+ (addr) => this.#flushBuffer(addr)
882
+ ));
883
+ }
884
+
885
+ const buffer = this.buffers.get(bufferKey);
886
+ buffer.add(formattedMessage);
887
+ }
888
+
889
+ async #flushBuffer(address) {
890
+ const buffer = this.buffers.get(address);
891
+ if (!buffer || buffer.flushing || buffer.messages.length === 0) {
892
+ return;
893
+ }
894
+
895
+ buffer.flushing = true;
896
+
897
+ try {
898
+ const messages = buffer.extractMessages();
899
+
900
+ if (messages.length === 0) return;
901
+
902
+ // Call the original push logic with the buffered messages
903
+ const requestBody = { items: messages };
904
+
905
+ await withRetry(
906
+ () => this.client._http.post('/api/v1/push', requestBody),
907
+ this.client._config.retryAttempts,
908
+ this.client._config.retryDelay
909
+ );
910
+
911
+ this.flushCount++;
912
+
913
+ // Call onFlush callback if provided
914
+ if (buffer.options.onFlush) {
915
+ buffer.options.onFlush(address, messages.length);
916
+ }
917
+
918
+ // Remove empty buffer
919
+ this.buffers.delete(address);
920
+
921
+ } catch (error) {
922
+ buffer.flushing = false;
923
+
924
+ // Call onError callback if provided
925
+ if (buffer.options.onError) {
926
+ buffer.options.onError(address, error);
927
+ }
928
+
929
+ throw error;
930
+ }
931
+ }
932
+
933
+ async flushBuffer(address) {
934
+ return this.#flushBuffer(address);
935
+ }
936
+
937
+ async flushAllBuffers() {
938
+ const flushPromises = [];
939
+ for (const address of this.buffers.keys()) {
940
+ flushPromises.push(this.#flushBuffer(address));
941
+ }
942
+ await Promise.all(flushPromises);
943
+ }
944
+
945
+ getStats() {
946
+ let totalBufferedMessages = 0;
947
+ let oldestBufferAge = 0;
948
+
949
+ for (const buffer of this.buffers.values()) {
950
+ totalBufferedMessages += buffer.messages.length;
951
+ if (buffer.firstMessageTime) {
952
+ const age = Date.now() - buffer.firstMessageTime;
953
+ oldestBufferAge = Math.max(oldestBufferAge, age);
954
+ }
955
+ }
956
+
957
+ return {
958
+ activeBuffers: this.buffers.size,
959
+ totalBufferedMessages,
960
+ oldestBufferAge,
961
+ flushesPerformed: this.flushCount
962
+ };
963
+ }
964
+
965
+ cleanup() {
966
+ for (const buffer of this.buffers.values()) {
967
+ buffer.cleanup();
968
+ }
969
+ this.buffers.clear();
970
+ }
971
+ }
972
+
973
+ /**
974
+ * Transaction Builder for atomic operations
975
+ */
976
+ class TransactionBuilder {
977
+ #client;
978
+ #operations = [];
979
+ #requiredLeases = [];
980
+
981
+ constructor(client) {
982
+ this.#client = client;
983
+ }
984
+
985
+ /**
986
+ * Add ACK operation to transaction
987
+ * @param {Array|Object} messages - Messages to acknowledge
988
+ * @param {string} status - Status ('completed' or 'failed')
989
+ * @returns {TransactionBuilder} this for chaining
990
+ */
991
+ ack(messages, status = 'completed') {
992
+ const msgs = Array.isArray(messages) ? messages : [messages];
993
+
994
+ msgs.forEach(msg => {
995
+ const transactionId = msg.transactionId || msg.id || msg;
996
+ const leaseId = msg.leaseId || null;
997
+
998
+ this.#operations.push({
999
+ type: 'ack',
1000
+ transactionId,
1001
+ status
1002
+ });
1003
+
1004
+ if (leaseId) {
1005
+ this.#requiredLeases.push(leaseId);
1006
+ }
1007
+ });
1008
+
1009
+ return this;
1010
+ }
1011
+
1012
+ /**
1013
+ * Add PUSH operation to transaction
1014
+ * @param {string} queue - Target queue
1015
+ * @param {Array|Object} items - Items to push
1016
+ * @returns {TransactionBuilder} this for chaining
1017
+ */
1018
+ push(queue, items) {
1019
+ const itemArray = Array.isArray(items) ? items : [items];
1020
+
1021
+ this.#operations.push({
1022
+ type: 'push',
1023
+ items: itemArray.map(item => ({
1024
+ queue,
1025
+ payload: item
1026
+ }))
1027
+ });
1028
+
1029
+ return this;
1030
+ }
1031
+
1032
+ /**
1033
+ * Add lease extension to transaction
1034
+ * @param {string} leaseId - Lease ID to extend
1035
+ * @returns {TransactionBuilder} this for chaining
1036
+ */
1037
+ extend(leaseId) {
1038
+ this.#operations.push({
1039
+ type: 'extend',
1040
+ leaseId
1041
+ });
1042
+
1043
+ this.#requiredLeases.push(leaseId);
1044
+ return this;
1045
+ }
1046
+
1047
+ /**
1048
+ * Execute the transaction
1049
+ * @returns {Promise<Object>} Transaction result
1050
+ */
1051
+ async commit() {
1052
+ await this.#client._ensureConnected();
1053
+
1054
+ const result = await this.#client._http.post('/api/v1/transaction', {
1055
+ operations: this.#operations,
1056
+ requiredLeases: [...new Set(this.#requiredLeases)] // Unique leases
1057
+ });
1058
+
1059
+ if (!result.success) {
1060
+ throw new Error(result.error || 'Transaction failed');
1061
+ }
1062
+
1063
+ return result;
1064
+ }
1065
+ }
1066
+
1067
+ /**
1068
+ * Pipeline Builder for message processing workflows
1069
+ */
1070
+ class PipelineBuilder {
1071
+ #client;
1072
+ #queue;
1073
+ #options = {};
1074
+ #processor = null;
1075
+ #errorHandler = null;
1076
+ #atomicOps = null;
1077
+ #leaseRenewal = null;
1078
+ #repeatConfig = null;
1079
+ #concurrency = 1;
1080
+
1081
+ constructor(client, queue) {
1082
+ this.#client = client;
1083
+ this.#queue = queue;
1084
+ }
1085
+
1086
+ /**
1087
+ * Take messages from the queue
1088
+ * @param {number} count - Number of messages to take
1089
+ * @param {Object} options - Take options
1090
+ * @returns {PipelineBuilder} this for chaining
1091
+ */
1092
+ take(count, options = {}) {
1093
+ this.#options = { ...options, batch: count, limit: count };
1094
+ return this;
1095
+ }
1096
+
1097
+ /**
1098
+ * Process messages individually (one at a time)
1099
+ * @param {Function} handler - Async function to process a single message
1100
+ * @returns {PipelineBuilder} this for chaining
1101
+ */
1102
+ process(handler) {
1103
+ // Wrap the single-message handler to process messages one by one
1104
+ this.#processor = async (messages) => {
1105
+ const results = [];
1106
+ for (const message of messages) {
1107
+ const result = await handler(message);
1108
+ results.push(result);
1109
+ }
1110
+ return results;
1111
+ };
1112
+ return this;
1113
+ }
1114
+
1115
+ /**
1116
+ * Process messages as a batch
1117
+ * @param {Function} handler - Async function to process a batch of messages
1118
+ * @returns {PipelineBuilder} this for chaining
1119
+ */
1120
+ processBatch(handler) {
1121
+ this.#processor = handler;
1122
+ return this;
1123
+ }
1124
+
1125
+ /**
1126
+ * Define atomic operations to execute after processing
1127
+ * @param {Function} txBuilder - Function that receives a TransactionBuilder
1128
+ * @returns {PipelineBuilder} this for chaining
1129
+ */
1130
+ atomically(txBuilder) {
1131
+ this.#atomicOps = txBuilder;
1132
+ return this;
1133
+ }
1134
+
1135
+ /**
1136
+ * Handle errors
1137
+ * @param {Function} handler - Error handler function
1138
+ * @returns {PipelineBuilder} this for chaining
1139
+ */
1140
+ onError(handler) {
1141
+ this.#errorHandler = handler;
1142
+ return this;
1143
+ }
1144
+
1145
+ /**
1146
+ * Enable automatic lease renewal
1147
+ * @param {Object} options - Renewal options
1148
+ * @returns {PipelineBuilder} this for chaining
1149
+ */
1150
+ withAutoRenewal(options = {}) {
1151
+ this.#leaseRenewal = {
1152
+ interval: options.interval || 30000, // Renew every 30s by default
1153
+ enabled: true
1154
+ };
1155
+ return this;
1156
+ }
1157
+
1158
+ /**
1159
+ * Set concurrency level for parallel batch processing
1160
+ * @param {number} level - Number of concurrent batches to process
1161
+ * @returns {PipelineBuilder} this for chaining
1162
+ */
1163
+ withConcurrency(level) {
1164
+ this.#concurrency = Math.max(1, level);
1165
+ return this;
1166
+ }
1167
+
1168
+ /**
1169
+ * Configure repeated execution
1170
+ * @param {Object} options - Repeat options
1171
+ * @returns {PipelineBuilder} this for chaining
1172
+ */
1173
+ repeat(options = {}) {
1174
+ this.#repeatConfig = {
1175
+ maxIterations: options.maxIterations || Infinity,
1176
+ delay: options.delay || 0,
1177
+ continuous: options.continuous !== undefined ? options.continuous : true, // Default to continuous mode
1178
+ waitOnEmpty: options.waitOnEmpty || 1000 // Wait time when no messages
1179
+ };
1180
+ return this;
1181
+ }
1182
+
1183
+ /**
1184
+ * Execute the pipeline (once or repeatedly based on configuration)
1185
+ * @param {Object} options - Execution options
1186
+ * @param {boolean} options.returnGenerator - For repeat mode, return generator instead of running loop
1187
+ * @returns {Promise<Object>} Result or summary of all iterations
1188
+ */
1189
+ async execute(options = {}) {
1190
+ // If repeat is configured
1191
+ if (this.#repeatConfig) {
1192
+ // Option to return generator for advanced users
1193
+ if (options.returnGenerator) {
1194
+ return this.#executeRepeatedly();
1195
+ }
1196
+
1197
+ // Default: run the loop internally and return summary
1198
+ return this.#executeRepeatedlyWithSummary();
1199
+ }
1200
+
1201
+ // Otherwise execute once
1202
+ return this.#executeSingle();
1203
+ }
1204
+
1205
+ /**
1206
+ * Execute the pipeline once (internal)
1207
+ * @private
1208
+ * @returns {Promise<Object>} Pipeline execution result
1209
+ */
1210
+ async #executeSingle() {
1211
+ // Use local variables to avoid race conditions in parallel execution
1212
+ let messages = null;
1213
+ let processedMessages = null;
1214
+
1215
+ try {
1216
+ // 1. Take messages
1217
+ messages = await this.#client.takeSingleBatch(this.#queue, this.#options);
1218
+
1219
+ if (!messages || messages.length === 0) {
1220
+ return { processed: 0, messages: [] };
1221
+ }
1222
+
1223
+ // 2. Set up auto-renewal if enabled
1224
+ let renewalTimer = null;
1225
+ if (this.#leaseRenewal?.enabled) {
1226
+ renewalTimer = this.#setupAutoRenewal(messages);
1227
+ }
1228
+
1229
+ try {
1230
+ // 3. Process messages
1231
+ if (this.#processor) {
1232
+ processedMessages = await this.#processor(messages);
1233
+ } else {
1234
+ processedMessages = messages;
1235
+ }
1236
+
1237
+ // 4. Execute atomic operations
1238
+ if (this.#atomicOps) {
1239
+ const tx = new TransactionBuilder(this.#client);
1240
+
1241
+ // Apply the atomic operations
1242
+ this.#atomicOps(tx, messages, processedMessages);
1243
+
1244
+ await tx.commit();
1245
+ } else {
1246
+ // Default: just ACK the messages
1247
+ await this.#client.ack(messages, true);
1248
+ }
1249
+
1250
+ return {
1251
+ processed: messages.length,
1252
+ messages: processedMessages
1253
+ };
1254
+
1255
+ } finally {
1256
+ // Clean up renewal timer
1257
+ if (renewalTimer) {
1258
+ clearInterval(renewalTimer);
1259
+ }
1260
+ }
1261
+
1262
+ } catch (error) {
1263
+ if (this.#errorHandler) {
1264
+ await this.#errorHandler(error, messages || []);
1265
+ return { processed: 0, messages: [], error: error.message };
1266
+ } else {
1267
+ throw error;
1268
+ }
1269
+ }
1270
+ }
1271
+
1272
+ /**
1273
+ * Execute the pipeline repeatedly (internal)
1274
+ * @private
1275
+ * @returns {AsyncGenerator} Async generator of results
1276
+ */
1277
+ async *#executeRepeatedly() {
1278
+ const { maxIterations, delay } = this.#repeatConfig;
1279
+ let iteration = 0;
1280
+
1281
+ while (iteration < maxIterations) {
1282
+ const result = await this.#executeSingle();
1283
+ yield result;
1284
+
1285
+ iteration++;
1286
+
1287
+ if (delay > 0) {
1288
+ await new Promise(resolve => setTimeout(resolve, delay));
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ /**
1294
+ * Execute the pipeline repeatedly and return summary
1295
+ * @private
1296
+ * @returns {Promise<Object>} Summary of all iterations
1297
+ */
1298
+ async #executeRepeatedlyWithSummary() {
1299
+ const { maxIterations, delay, continuous, waitOnEmpty } = this.#repeatConfig;
1300
+
1301
+ // If concurrency > 1, use parallel workers
1302
+ if (this.#concurrency > 1) {
1303
+ return this.#executeRepeatedlyParallel();
1304
+ }
1305
+
1306
+ // Sequential processing
1307
+ let iteration = 0;
1308
+ let totalProcessed = 0;
1309
+ const results = [];
1310
+ let consecutiveEmpty = 0;
1311
+ const maxConsecutiveEmpty = continuous ? Infinity : 3; // In continuous mode, never stop on empty
1312
+
1313
+ while (iteration < maxIterations) {
1314
+ const result = await this.#executeSingle();
1315
+
1316
+ if (result.processed === 0) {
1317
+ consecutiveEmpty++;
1318
+
1319
+ // In continuous mode, wait and retry
1320
+ if (continuous || consecutiveEmpty < maxConsecutiveEmpty) {
1321
+ await new Promise(resolve => setTimeout(resolve, waitOnEmpty));
1322
+ continue; // Don't increment iteration for empty results
1323
+ } else {
1324
+ // Not continuous and too many empty results
1325
+ break;
1326
+ }
1327
+ } else {
1328
+ // Reset empty counter on successful batch
1329
+ consecutiveEmpty = 0;
1330
+ results.push(result);
1331
+ totalProcessed += result.processed;
1332
+ iteration++;
1333
+ }
1334
+
1335
+ if (delay > 0 && iteration < maxIterations) {
1336
+ await new Promise(resolve => setTimeout(resolve, delay));
1337
+ }
1338
+ }
1339
+
1340
+ return {
1341
+ iterations: iteration,
1342
+ totalProcessed,
1343
+ results,
1344
+ summary: {
1345
+ averagePerBatch: iteration > 0 ? totalProcessed / iteration : 0,
1346
+ completed: iteration >= maxIterations ? 'maxIterations' : 'noMessages',
1347
+ concurrency: this.#concurrency,
1348
+ continuous
1349
+ }
1350
+ };
1351
+ }
1352
+
1353
+ /**
1354
+ * Execute pipeline with parallel processing
1355
+ * Each worker independently calls take(), allowing them to work on different partitions
1356
+ * @private
1357
+ * @returns {Promise<Object>} Summary of all parallel executions
1358
+ */
1359
+ async #executeRepeatedlyParallel() {
1360
+ const { maxIterations = Infinity, delay, continuous, waitOnEmpty } = this.#repeatConfig || {};
1361
+ const workers = [];
1362
+ const results = [];
1363
+
1364
+ // Shared state for coordination
1365
+ const sharedState = {
1366
+ shouldStop: false,
1367
+ emptyWorkers: new Set(),
1368
+ lock: Promise.resolve()
1369
+ };
1370
+
1371
+ // Worker function - each worker independently takes and processes messages
1372
+ const worker = async (workerId) => {
1373
+ let workerIterations = 0;
1374
+ let workerProcessed = 0;
1375
+ let consecutiveEmpty = 0;
1376
+ const maxConsecutiveEmpty = continuous ? Infinity : 10;
1377
+
1378
+ while (!sharedState.shouldStop && workerIterations < maxIterations) {
1379
+ try {
1380
+ // Each worker independently calls executeSingle (which calls take)
1381
+ const result = await this.#executeSingle();
1382
+
1383
+ if (result.processed === 0) {
1384
+ consecutiveEmpty++;
1385
+
1386
+ // Add this worker to empty set
1387
+ sharedState.emptyWorkers.add(workerId);
1388
+
1389
+ // If all workers are empty, consider stopping
1390
+ if (!continuous && sharedState.emptyWorkers.size === this.#concurrency) {
1391
+ // Double-check with a small delay to avoid race conditions
1392
+ await new Promise(resolve => setTimeout(resolve, 200));
1393
+
1394
+ // Re-check after delay
1395
+ if (sharedState.emptyWorkers.size === this.#concurrency) {
1396
+ sharedState.shouldStop = true;
1397
+ break;
1398
+ }
1399
+ }
1400
+
1401
+ // Individual worker timeout
1402
+ if (!continuous && consecutiveEmpty >= maxConsecutiveEmpty) {
1403
+ break;
1404
+ }
1405
+
1406
+ // Wait before retry
1407
+ await new Promise(resolve => setTimeout(resolve, waitOnEmpty || 1000));
1408
+ continue; // Don't count empty iterations
1409
+ }
1410
+
1411
+ // Reset empty counter on successful batch
1412
+ consecutiveEmpty = 0;
1413
+ sharedState.emptyWorkers.delete(workerId);
1414
+
1415
+ // Store result with worker info
1416
+ results.push({
1417
+ ...result,
1418
+ workerId,
1419
+ timestamp: new Date().toISOString()
1420
+ });
1421
+
1422
+ workerProcessed += result.processed;
1423
+ workerIterations++;
1424
+
1425
+ if (delay > 0) {
1426
+ await new Promise(resolve => setTimeout(resolve, delay));
1427
+ }
1428
+ } catch (error) {
1429
+ console.error(`Worker ${workerId} error:`, error);
1430
+ if (this.#errorHandler) {
1431
+ await this.#errorHandler(error, []);
1432
+ } else {
1433
+ sharedState.shouldStop = true;
1434
+ throw error;
1435
+ }
1436
+ }
1437
+ }
1438
+
1439
+ return {
1440
+ workerId,
1441
+ iterations: workerIterations,
1442
+ processed: workerProcessed,
1443
+ consecutiveEmpty
1444
+ };
1445
+ };
1446
+
1447
+ // Start parallel workers
1448
+ console.log(`🚀 Starting ${this.#concurrency} parallel workers...`);
1449
+ for (let i = 0; i < this.#concurrency; i++) {
1450
+ workers.push(worker(i));
1451
+ }
1452
+
1453
+ // Wait for all workers
1454
+ const workerResults = await Promise.all(workers);
1455
+
1456
+ // Aggregate results
1457
+ let totalProcessed = 0;
1458
+ let totalIterations = 0;
1459
+
1460
+ workerResults.forEach(wr => {
1461
+ totalProcessed += wr.processed;
1462
+ totalIterations += wr.iterations;
1463
+ if (wr.iterations > 0) {
1464
+ console.log(`Worker ${wr.workerId}: Processed ${wr.processed} messages in ${wr.iterations} batches`);
1465
+ }
1466
+ });
1467
+
1468
+ return {
1469
+ iterations: totalIterations,
1470
+ totalProcessed,
1471
+ results,
1472
+ workers: workerResults,
1473
+ summary: {
1474
+ averagePerBatch: totalIterations > 0 ? totalProcessed / totalIterations : 0,
1475
+ completed: totalIterations >= (maxIterations * this.#concurrency) ? 'maxIterations' : 'noMessages',
1476
+ concurrency: this.#concurrency,
1477
+ workersUtilized: workerResults.filter(w => w.iterations > 0).length,
1478
+ continuous
1479
+ }
1480
+ };
1481
+ }
1482
+
1483
+ /**
1484
+ * Set up automatic lease renewal
1485
+ * @private
1486
+ */
1487
+ #setupAutoRenewal(messages) {
1488
+ const leaseIds = messages
1489
+ .map(m => m.leaseId)
1490
+ .filter(id => id != null);
1491
+
1492
+ if (leaseIds.length === 0) return null;
1493
+
1494
+ return setInterval(async () => {
1495
+ try {
1496
+ // Extend all leases using the client's renewLease method
1497
+ for (const leaseId of leaseIds) {
1498
+ const result = await this.#client.renewLease(leaseId);
1499
+ if (!result.success) {
1500
+ console.error('Lease renewal failed:', result.error);
1501
+ }
1502
+ }
1503
+ } catch (error) {
1504
+ console.error('Failed to renew lease:', error);
1505
+ }
1506
+ }, this.#leaseRenewal.interval);
1507
+ }
1508
+ }
1509
+
1510
+ // Export the class as default as well for convenience
1511
+ export default Queen;