queen-mq 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +862 -752
- package/AUTH.md +2044 -0
- package/LICENSE.md +202 -0
- package/README.md +1705 -1051
- package/WEBAPP.md +1889 -0
- package/assets/dashboard-01.png +0 -0
- package/assets/queen-logo-blue.svg +210 -0
- package/assets/queen-logo-cyan.svg +210 -0
- package/assets/queen-logo-indigo.svg +210 -0
- package/assets/queen-logo-orange.svg +210 -0
- package/assets/queen-logo-pink.svg +210 -0
- package/assets/queen-logo-purple.svg +210 -0
- package/assets/queen-logo-rose.svg +239 -0
- package/assets/queen-logo.svg +263 -0
- package/examples/batch-processing.js +58 -0
- package/examples/test-complete-client.js +260 -0
- package/examples/test-dashboard-api.js +200 -0
- package/examples/test-traceid.js +147 -0
- package/package.json +17 -4
- package/server.log +1 -0
- package/src/benchmark/consumer.js +207 -0
- package/src/benchmark/consumer_multi.js +216 -0
- package/src/benchmark/producer.js +75 -0
- package/src/benchmark/producer_multi.js +115 -0
- package/src/client/client.js +300 -31
- package/src/client/queenClient.js +5 -0
- package/src/cluster-server.js +242 -0
- package/src/config.js +19 -5
- package/src/database/connection.js +42 -16
- package/src/database/poolManager.js +7 -0
- package/src/database/schema-v2.sql +194 -130
- package/src/managers/queueManagerOptimized.js +823 -933
- package/src/managers/systemEventManager.js +8 -3
- package/src/routes/messages.js +127 -57
- package/src/routes/pop.js +27 -43
- package/src/routes/resources.js +61 -27
- package/src/routes/status.js +1037 -0
- package/src/server.js +308 -272
- package/src/services/evictionService.js +57 -28
- package/src/services/retentionService.js +44 -11
- package/src/test/MIGRATION_ISSUES.md +174 -0
- package/src/test/README.md +203 -0
- package/src/test/advanced-pattern-tests.js +1137 -0
- package/src/test/bus-mode-tests.js +361 -0
- package/src/test/core-tests.js +342 -0
- package/src/test/edge-case-tests.js +561 -0
- package/src/test/enterprise-tests.js +637 -0
- package/src/test/partition-locking-tests.js +545 -0
- package/src/test/test-new.js +278 -0
- package/src/test/test.js +6 -3
- package/src/test/utils.js +169 -0
- package/src/utils/streaming.js +231 -0
- package/src/utils/uuid.js +2 -2
- package/src/websocket/wsServer.js +10 -3
- package/test-keepalive-v2.sh +22 -0
- package/webapp/COLOR_GUIDE.md +118 -0
- package/webapp/README.md +143 -0
- package/webapp/index.html +14 -0
- package/webapp/package-lock.json +3184 -0
- package/webapp/package.json +25 -0
- package/webapp/postcss.config.js +7 -0
- package/webapp/public/assets/queen-logo-blue.svg +210 -0
- package/webapp/public/assets/queen-logo-cyan.svg +210 -0
- package/webapp/public/assets/queen-logo-indigo.svg +210 -0
- package/webapp/public/assets/queen-logo-orange.svg +210 -0
- package/webapp/public/assets/queen-logo-pink.svg +210 -0
- package/webapp/public/assets/queen-logo-purple.svg +210 -0
- package/webapp/public/assets/queen-logo-rose.svg +239 -0
- package/webapp/public/assets/queen-logo.svg +263 -0
- package/webapp/src/App.vue +19 -0
- package/webapp/src/api/analytics.js +10 -0
- package/webapp/src/api/client.js +29 -0
- package/webapp/src/api/consumers.js +52 -0
- package/webapp/src/api/health.js +7 -0
- package/webapp/src/api/messages.js +26 -0
- package/webapp/src/api/queues.js +14 -0
- package/webapp/src/api/resources.js +8 -0
- package/webapp/src/assets/styles/main.css +357 -0
- package/webapp/src/components/analytics/AnalyticsFilters.vue +87 -0
- package/webapp/src/components/analytics/AnalyticsMetrics.vue +57 -0
- package/webapp/src/components/analytics/MessageDistributionChart.vue +111 -0
- package/webapp/src/components/analytics/MessageFlowChart.vue +173 -0
- package/webapp/src/components/analytics/TimeRangeSelector.vue +27 -0
- package/webapp/src/components/analytics/TopQueuesChart.vue +132 -0
- package/webapp/src/components/common/ConfirmDialog.vue +56 -0
- package/webapp/src/components/common/LoadingSpinner.vue +6 -0
- package/webapp/src/components/common/MetricCard.vue +43 -0
- package/webapp/src/components/common/StatusBadge.vue +45 -0
- package/webapp/src/components/dashboard/MessageStatusCard.vue +50 -0
- package/webapp/src/components/dashboard/PerformanceCard.vue +38 -0
- package/webapp/src/components/dashboard/ThroughputChart.vue +182 -0
- package/webapp/src/components/dashboard/TopQueuesTable.vue +53 -0
- package/webapp/src/components/layout/AppLayout.vue +110 -0
- package/webapp/src/components/layout/AppSidebar.vue +304 -0
- package/webapp/src/components/messages/MessageDetailPanel.vue +242 -0
- package/webapp/src/components/messages/MessageFilters.vue +114 -0
- package/webapp/src/components/queue-detail/PartitionList.vue +79 -0
- package/webapp/src/components/queue-detail/PushMessageModal.vue +175 -0
- package/webapp/src/components/queue-detail/QueueConfig.vue +63 -0
- package/webapp/src/components/queue-detail/QueueDetailHeader.vue +53 -0
- package/webapp/src/components/queue-detail/RecentMessages.vue +76 -0
- package/webapp/src/components/queues/CreateQueueModal.vue +193 -0
- package/webapp/src/components/queues/QueueFilters.vue +90 -0
- package/webapp/src/composables/useApi.js +34 -0
- package/webapp/src/composables/useTheme.js +36 -0
- package/webapp/src/main.js +11 -0
- package/webapp/src/router/index.js +42 -0
- package/webapp/src/utils/colors.js +96 -0
- package/webapp/src/utils/formatters.js +49 -0
- package/webapp/src/views/Analytics.vue +377 -0
- package/webapp/src/views/ConsumerGroups.vue +433 -0
- package/webapp/src/views/Dashboard.vue +418 -0
- package/webapp/src/views/Messages.vue +361 -0
- package/webapp/src/views/QueueDetail.vue +582 -0
- package/webapp/src/views/Queues.vue +496 -0
- package/webapp/tailwind.config.js +25 -0
- package/webapp/vite.config.js +10 -0
- package/CACHE.md +0 -519
- package/DASHBOARD-V3.md +0 -478
- package/DASHBOARD.md +0 -382
- package/MOD_QUEUE.md +0 -453
- package/PARTITION_LOCKING_DESIGN.md +0 -989
- package/PLAN.md +0 -707
- package/QUERY_ANALSYS.md +0 -72
- package/QUEUE_BUS.md +0 -334
- package/V2-PLAN.md +0 -236
- package/dashboard/.vscode/extensions.json +0 -3
- package/dashboard/README.md +0 -5
- package/dashboard/index.html +0 -14
- package/dashboard/package-lock.json +0 -1458
- package/dashboard/package.json +0 -25
- package/dashboard/public/vite.svg +0 -1
- package/dashboard/src/App.vue +0 -29
- package/dashboard/src/assets/styles/main.css +0 -908
- package/dashboard/src/assets/vue.svg +0 -1
- package/dashboard/src/components/cards/MetricCard.vue +0 -298
- package/dashboard/src/components/charts/QueueDepthChart.vue +0 -276
- package/dashboard/src/components/charts/QueueLagChart.vue +0 -436
- package/dashboard/src/components/charts/ThroughputChart.vue +0 -302
- package/dashboard/src/components/common/ActivityFeed.vue +0 -251
- package/dashboard/src/components/layout/AppHeader.vue +0 -208
- package/dashboard/src/components/layout/AppLayout.vue +0 -88
- package/dashboard/src/components/layout/AppSidebar.vue +0 -261
- package/dashboard/src/main.js +0 -44
- package/dashboard/src/router.js +0 -54
- package/dashboard/src/services/api.js +0 -187
- package/dashboard/src/services/websocket.js +0 -167
- package/dashboard/src/utils/constants.js +0 -56
- package/dashboard/src/utils/helpers.js +0 -118
- package/dashboard/src/views/Analytics.vue +0 -912
- package/dashboard/src/views/Dashboard.vue +0 -906
- package/dashboard/src/views/Messages.vue +0 -437
- package/dashboard/src/views/QueueDetail.vue +0 -501
- package/dashboard/src/views/Queues.vue +0 -333
- package/dashboard/vite.config.js +0 -30
- package/debug-namespace.js +0 -110
- package/docs/long-polling.md +0 -159
- package/docs/multi-server-cache-solutions.md +0 -185
- package/docs/performance-tuning.md +0 -222
- package/src/routes/analytics.js +0 -812
package/src/client/client.js
CHANGED
|
@@ -55,6 +55,14 @@ export class Queen {
|
|
|
55
55
|
this.#connected = true;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Validate if a string is a valid UUID v4
|
|
60
|
+
*/
|
|
61
|
+
#isValidUUID(str) {
|
|
62
|
+
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;
|
|
63
|
+
return uuidRegex.test(str);
|
|
64
|
+
}
|
|
65
|
+
|
|
58
66
|
/**
|
|
59
67
|
* Parse address string into components
|
|
60
68
|
* Examples:
|
|
@@ -62,9 +70,37 @@ export class Queen {
|
|
|
62
70
|
* - "myqueue/urgent" -> { queue: "myqueue", partition: "urgent" }
|
|
63
71
|
* - "myqueue@workers" -> { queue: "myqueue", consumerGroup: "workers" }
|
|
64
72
|
* - "myqueue/urgent@workers" -> { queue: "myqueue", partition: "urgent", consumerGroup: "workers" }
|
|
73
|
+
* - "namespace:billing" -> { namespace: "billing" }
|
|
74
|
+
* - "task:process" -> { task: "process" }
|
|
75
|
+
* - "namespace:billing/task:process" -> { namespace: "billing", task: "process" }
|
|
65
76
|
*/
|
|
66
77
|
#parseAddress(address) {
|
|
67
|
-
//
|
|
78
|
+
// Check for namespace/task pattern
|
|
79
|
+
if (address.includes('namespace:') || address.includes('task:')) {
|
|
80
|
+
const parts = {};
|
|
81
|
+
|
|
82
|
+
// Extract consumer group if present (after @)
|
|
83
|
+
let workingAddress = address;
|
|
84
|
+
const atIndex = address.lastIndexOf('@');
|
|
85
|
+
if (atIndex > 0) {
|
|
86
|
+
parts.consumerGroup = address.substring(atIndex + 1);
|
|
87
|
+
workingAddress = address.substring(0, atIndex);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const segments = workingAddress.split('/');
|
|
91
|
+
|
|
92
|
+
for (const segment of segments) {
|
|
93
|
+
if (segment.startsWith('namespace:')) {
|
|
94
|
+
parts.namespace = segment.substring(10);
|
|
95
|
+
} else if (segment.startsWith('task:')) {
|
|
96
|
+
parts.task = segment.substring(5);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return parts;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Standard queue[/partition][@group] pattern
|
|
68
104
|
const match = address.match(/^([^/@]+)(?:\/([^@]+))?(?:@(.+))?$/);
|
|
69
105
|
if (!match) {
|
|
70
106
|
throw new Error(`Invalid address format: ${address}`);
|
|
@@ -81,13 +117,18 @@ export class Queen {
|
|
|
81
117
|
* Configure a queue with options
|
|
82
118
|
* @param {string} name - Queue name
|
|
83
119
|
* @param {Object} options - Queue configuration options
|
|
120
|
+
* @param {Object} metadata - Optional metadata { namespace, task }
|
|
84
121
|
*/
|
|
85
|
-
async queue(name, options = {}) {
|
|
122
|
+
async queue(name, options = {}, metadata = {}) {
|
|
86
123
|
await this.#ensureConnected();
|
|
87
124
|
|
|
125
|
+
const { namespace, task } = metadata;
|
|
126
|
+
|
|
88
127
|
const result = await withRetry(
|
|
89
128
|
() => this.#http.post('/api/v1/configure', {
|
|
90
129
|
queue: name,
|
|
130
|
+
namespace: namespace || null,
|
|
131
|
+
task: task || null,
|
|
91
132
|
options
|
|
92
133
|
}),
|
|
93
134
|
this.#config.retryAttempts,
|
|
@@ -105,8 +146,9 @@ export class Queen {
|
|
|
105
146
|
* Push messages to a queue
|
|
106
147
|
* @param {string} address - Queue address (e.g., "myqueue" or "myqueue/partition")
|
|
107
148
|
* @param {Object|Array} payload - Single message or array of messages
|
|
149
|
+
* @param {Object} options - Optional message properties { transactionId, traceId }
|
|
108
150
|
*/
|
|
109
|
-
async push(address, payload) {
|
|
151
|
+
async push(address, payload, options = {}) {
|
|
110
152
|
await this.#ensureConnected();
|
|
111
153
|
|
|
112
154
|
const { queue, partition } = this.#parseAddress(address);
|
|
@@ -115,12 +157,44 @@ export class Queen {
|
|
|
115
157
|
const items = Array.isArray(payload) ? payload : [payload];
|
|
116
158
|
|
|
117
159
|
// Format items for the API
|
|
118
|
-
const formattedItems = items.map(item =>
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
160
|
+
const formattedItems = items.map(item => {
|
|
161
|
+
// If item is an object with special properties, extract them
|
|
162
|
+
const isMessageObject = typeof item === 'object' && item !== null &&
|
|
163
|
+
(item._payload || item._transactionId || item._traceId);
|
|
164
|
+
|
|
165
|
+
if (isMessageObject) {
|
|
166
|
+
const result = {
|
|
167
|
+
queue,
|
|
168
|
+
partition,
|
|
169
|
+
payload: item._payload || item,
|
|
170
|
+
transactionId: item._transactionId || item.transactionId
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Include traceId if provided and valid UUID
|
|
174
|
+
const traceId = item._traceId || item.traceId;
|
|
175
|
+
if (traceId && this.#isValidUUID(traceId)) {
|
|
176
|
+
result.traceId = traceId;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Otherwise use the item as payload and apply options
|
|
183
|
+
const result = {
|
|
184
|
+
queue,
|
|
185
|
+
partition,
|
|
186
|
+
payload: item,
|
|
187
|
+
transactionId: options.transactionId || (item && typeof item === 'object' ? item.transactionId : undefined)
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Include traceId if provided and valid UUID
|
|
191
|
+
const traceId = options.traceId || (item && typeof item === 'object' ? item.traceId : undefined);
|
|
192
|
+
if (traceId && this.#isValidUUID(traceId)) {
|
|
193
|
+
result.traceId = traceId;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return result;
|
|
197
|
+
});
|
|
124
198
|
|
|
125
199
|
const result = await withRetry(
|
|
126
200
|
() => this.#http.post('/api/v1/push', {
|
|
@@ -138,38 +212,52 @@ export class Queen {
|
|
|
138
212
|
}
|
|
139
213
|
|
|
140
214
|
/**
|
|
141
|
-
*
|
|
142
|
-
* @
|
|
215
|
+
* Internal method that yields batches of messages (arrays)
|
|
216
|
+
* @private
|
|
217
|
+
* @param {string} address - Queue address
|
|
143
218
|
* @param {Object} options - Options for taking messages
|
|
144
|
-
* @yields {
|
|
219
|
+
* @yields {Array} Arrays of message objects
|
|
145
220
|
*/
|
|
146
|
-
async
|
|
221
|
+
async *#takeInternal(address, options = {}) {
|
|
147
222
|
await this.#ensureConnected();
|
|
148
223
|
|
|
149
|
-
const { queue, partition, consumerGroup } = this.#parseAddress(address);
|
|
224
|
+
const { queue, partition, consumerGroup, namespace, task } = this.#parseAddress(address);
|
|
150
225
|
const {
|
|
151
226
|
limit = null,
|
|
152
227
|
batch = 1,
|
|
153
228
|
wait = false,
|
|
154
229
|
timeout = 30000,
|
|
155
230
|
subscriptionMode = null,
|
|
156
|
-
subscriptionFrom = null
|
|
231
|
+
subscriptionFrom = null,
|
|
232
|
+
idleTimeout = null
|
|
157
233
|
} = options;
|
|
158
234
|
|
|
159
|
-
let
|
|
235
|
+
let totalCount = 0;
|
|
160
236
|
let consecutiveEmptyResponses = 0;
|
|
161
237
|
const maxConsecutiveEmpty = 3;
|
|
238
|
+
let lastMessageTime = idleTimeout ? Date.now() : null;
|
|
162
239
|
|
|
163
240
|
while (true) {
|
|
164
241
|
// Check if we've reached the limit
|
|
165
|
-
if (limit &&
|
|
242
|
+
if (limit && totalCount >= limit) break;
|
|
243
|
+
|
|
244
|
+
// Check idle timeout
|
|
245
|
+
if (idleTimeout && lastMessageTime) {
|
|
246
|
+
const idleTime = Date.now() - lastMessageTime;
|
|
247
|
+
if (idleTime >= idleTimeout) {
|
|
248
|
+
break; // Exit if idle time exceeded
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Calculate batch size respecting the limit
|
|
253
|
+
const effectiveBatch = limit ? Math.min(batch, limit - totalCount) : batch;
|
|
166
254
|
|
|
167
255
|
// Build the request path and parameters
|
|
168
256
|
let path;
|
|
169
257
|
const params = new URLSearchParams({
|
|
170
258
|
wait: wait.toString(),
|
|
171
259
|
timeout: timeout.toString(),
|
|
172
|
-
batch:
|
|
260
|
+
batch: effectiveBatch.toString()
|
|
173
261
|
});
|
|
174
262
|
|
|
175
263
|
// Add consumer group parameters if provided
|
|
@@ -178,10 +266,19 @@ export class Queen {
|
|
|
178
266
|
if (subscriptionFrom) params.append('subscriptionFrom', subscriptionFrom);
|
|
179
267
|
|
|
180
268
|
// Determine the endpoint based on parameters
|
|
181
|
-
if (
|
|
182
|
-
|
|
269
|
+
if (queue) {
|
|
270
|
+
if (partition && partition !== 'Default') {
|
|
271
|
+
path = `/api/v1/pop/queue/${queue}/partition/${partition}`;
|
|
272
|
+
} else {
|
|
273
|
+
path = `/api/v1/pop/queue/${queue}`;
|
|
274
|
+
}
|
|
275
|
+
} else if (namespace || task) {
|
|
276
|
+
// Pop by namespace/task
|
|
277
|
+
path = '/api/v1/pop';
|
|
278
|
+
if (namespace) params.append('namespace', namespace);
|
|
279
|
+
if (task) params.append('task', task);
|
|
183
280
|
} else {
|
|
184
|
-
|
|
281
|
+
throw new Error('Must specify either queue, namespace, or task');
|
|
185
282
|
}
|
|
186
283
|
|
|
187
284
|
try {
|
|
@@ -210,11 +307,22 @@ export class Queen {
|
|
|
210
307
|
// Reset empty counter on successful fetch
|
|
211
308
|
consecutiveEmptyResponses = 0;
|
|
212
309
|
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
310
|
+
// Update last message time if tracking idle timeout
|
|
311
|
+
if (idleTimeout) {
|
|
312
|
+
lastMessageTime = Date.now();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Filter out null/undefined messages
|
|
316
|
+
const messages = result.messages.filter(msg => msg != null);
|
|
317
|
+
|
|
318
|
+
if (messages.length > 0) {
|
|
319
|
+
totalCount += messages.length;
|
|
320
|
+
yield messages;
|
|
321
|
+
|
|
322
|
+
// If we've hit the limit, stop
|
|
323
|
+
if (limit && totalCount >= limit) {
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
218
326
|
}
|
|
219
327
|
|
|
220
328
|
} catch (error) {
|
|
@@ -235,18 +343,154 @@ export class Queen {
|
|
|
235
343
|
}
|
|
236
344
|
|
|
237
345
|
/**
|
|
238
|
-
*
|
|
239
|
-
* @param {
|
|
346
|
+
* Take messages from a queue one at a time (async iterator)
|
|
347
|
+
* @param {string} address - Queue address (e.g., "myqueue", "myqueue/partition", "myqueue@group")
|
|
348
|
+
* @param {Object} options - Options for taking messages
|
|
349
|
+
* @yields {Object} Individual message objects
|
|
350
|
+
*/
|
|
351
|
+
async *take(address, options = {}) {
|
|
352
|
+
let count = 0;
|
|
353
|
+
const { limit = null } = options;
|
|
354
|
+
|
|
355
|
+
for await (const messages of this.#takeInternal(address, options)) {
|
|
356
|
+
for (const message of messages) {
|
|
357
|
+
yield message;
|
|
358
|
+
count++;
|
|
359
|
+
|
|
360
|
+
// Double-check limit (internal method also checks, but this ensures exact limit)
|
|
361
|
+
if (limit && count >= limit) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Take messages from a queue in batches (async iterator)
|
|
370
|
+
* @param {string} address - Queue address (e.g., "myqueue", "myqueue/partition", "myqueue@group")
|
|
371
|
+
* @param {Object} options - Options for taking messages
|
|
372
|
+
* @yields {Array} Arrays of message objects
|
|
373
|
+
*/
|
|
374
|
+
async *takeBatch(address, options = {}) {
|
|
375
|
+
for await (const messages of this.#takeInternal(address, options)) {
|
|
376
|
+
// Only yield non-empty batches
|
|
377
|
+
if (messages && messages.length > 0) {
|
|
378
|
+
yield messages;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Acknowledge a message or batch of messages
|
|
385
|
+
* @param {Object|string|Array} message - Message object, transaction ID, or array of messages
|
|
240
386
|
* @param {boolean|string} status - true for success, false for failure, or 'retry'
|
|
241
387
|
* @param {Object} context - Optional context (e.g., { group: 'workers', error: 'reason' })
|
|
242
388
|
*/
|
|
243
389
|
async ack(message, status = true, context = {}) {
|
|
244
390
|
await this.#ensureConnected();
|
|
245
391
|
|
|
392
|
+
// Handle batch acknowledgment
|
|
393
|
+
if (Array.isArray(message)) {
|
|
394
|
+
if (message.length === 0) {
|
|
395
|
+
return { processed: 0, results: [] };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Check if messages have individual status (Option B pattern)
|
|
399
|
+
const hasIndividualStatus = message.some(msg =>
|
|
400
|
+
typeof msg === 'object' && msg !== null && ('_status' in msg || '_error' in msg)
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
let acknowledgments;
|
|
404
|
+
|
|
405
|
+
if (hasIndividualStatus) {
|
|
406
|
+
// Option B: Each message has its own status
|
|
407
|
+
acknowledgments = message.map(msg => {
|
|
408
|
+
// Extract transaction ID
|
|
409
|
+
let transactionId;
|
|
410
|
+
if (typeof msg === 'string') {
|
|
411
|
+
transactionId = msg;
|
|
412
|
+
} else if (typeof msg === 'object' && msg !== null) {
|
|
413
|
+
transactionId = msg.transactionId || msg.id;
|
|
414
|
+
if (!transactionId) {
|
|
415
|
+
throw new Error('Message object must have transactionId or id property');
|
|
416
|
+
}
|
|
417
|
+
} else {
|
|
418
|
+
throw new Error('Invalid message in batch');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Get individual status or fall back to parameter
|
|
422
|
+
let msgStatus = msg._status !== undefined ? msg._status : status;
|
|
423
|
+
let statusStr;
|
|
424
|
+
if (typeof msgStatus === 'boolean') {
|
|
425
|
+
statusStr = msgStatus ? 'completed' : 'failed';
|
|
426
|
+
} else {
|
|
427
|
+
statusStr = msgStatus;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
transactionId,
|
|
432
|
+
status: statusStr,
|
|
433
|
+
error: msg._error || context.error || null
|
|
434
|
+
};
|
|
435
|
+
});
|
|
436
|
+
} else {
|
|
437
|
+
// Option A: Same status for all messages
|
|
438
|
+
const statusStr = typeof status === 'boolean'
|
|
439
|
+
? (status ? 'completed' : 'failed')
|
|
440
|
+
: status;
|
|
441
|
+
|
|
442
|
+
acknowledgments = message.map(msg => {
|
|
443
|
+
// Extract transaction ID
|
|
444
|
+
let transactionId;
|
|
445
|
+
if (typeof msg === 'string') {
|
|
446
|
+
transactionId = msg;
|
|
447
|
+
} else if (typeof msg === 'object' && msg !== null) {
|
|
448
|
+
transactionId = msg.transactionId || msg.id;
|
|
449
|
+
if (!transactionId) {
|
|
450
|
+
throw new Error('Message object must have transactionId or id property');
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
throw new Error('Invalid message in batch');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
transactionId,
|
|
458
|
+
status: statusStr,
|
|
459
|
+
error: context.error || null
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Call batch ack endpoint
|
|
465
|
+
const result = await withRetry(
|
|
466
|
+
() => this.#http.post('/api/v1/ack/batch', {
|
|
467
|
+
acknowledgments,
|
|
468
|
+
consumerGroup: context.group || null
|
|
469
|
+
}),
|
|
470
|
+
this.#config.retryAttempts,
|
|
471
|
+
this.#config.retryDelay
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
if (result && result.error) {
|
|
475
|
+
throw new Error(result.error);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return result;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Handle single message acknowledgment
|
|
246
482
|
// Extract transaction ID
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
message;
|
|
483
|
+
let transactionId;
|
|
484
|
+
if (typeof message === 'string') {
|
|
485
|
+
transactionId = message;
|
|
486
|
+
} else if (typeof message === 'object' && message !== null) {
|
|
487
|
+
transactionId = message.transactionId || message.id;
|
|
488
|
+
if (!transactionId) {
|
|
489
|
+
throw new Error('Message object must have transactionId or id property');
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
throw new Error('Message must be a string (transaction ID) or object with transactionId');
|
|
493
|
+
}
|
|
250
494
|
|
|
251
495
|
// Determine status string
|
|
252
496
|
let statusStr;
|
|
@@ -277,6 +521,31 @@ export class Queen {
|
|
|
277
521
|
return result;
|
|
278
522
|
}
|
|
279
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Delete a queue (removes queue and all its messages/partitions)
|
|
526
|
+
* @param {string} name - Queue name to delete
|
|
527
|
+
* @returns {Promise<Object>} Deletion result
|
|
528
|
+
*/
|
|
529
|
+
async queueDelete(name) {
|
|
530
|
+
await this.#ensureConnected();
|
|
531
|
+
|
|
532
|
+
if (typeof name !== 'string' || !name) {
|
|
533
|
+
throw new Error('Queue name must be a non-empty string');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const result = await withRetry(
|
|
537
|
+
() => this.#http.delete(`/api/v1/resources/queues/${encodeURIComponent(name)}`),
|
|
538
|
+
this.#config.retryAttempts,
|
|
539
|
+
this.#config.retryDelay
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
if (result && result.error) {
|
|
543
|
+
throw new Error(result.error);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
548
|
+
|
|
280
549
|
/**
|
|
281
550
|
* Close the client connection
|
|
282
551
|
*/
|
|
@@ -212,6 +212,11 @@ export const createQueenClient = (options = {}) => {
|
|
|
212
212
|
clear: async (queue, partition = null) => {
|
|
213
213
|
const params = partition ? `?partition=${partition}` : '';
|
|
214
214
|
return http.delete(`/api/v1/queues/${queue}/clear${params}`);
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
// Delete a queue (removes queue and all its messages/partitions)
|
|
218
|
+
delete: async (queue) => {
|
|
219
|
+
return http.delete(`/api/v1/resources/queues/${encodeURIComponent(queue)}`);
|
|
215
220
|
}
|
|
216
221
|
};
|
|
217
222
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Queen Message Queue - Clustered Server
|
|
5
|
+
*
|
|
6
|
+
* This module enables multi-core utilization using Node.js cluster module.
|
|
7
|
+
* Each worker runs an independent instance of the Queen server, sharing the same port.
|
|
8
|
+
*
|
|
9
|
+
* Benefits:
|
|
10
|
+
* - 10x throughput improvement (utilizes all CPU cores)
|
|
11
|
+
* - Automatic load balancing across workers
|
|
12
|
+
* - Fault tolerance (automatic worker restart on crash)
|
|
13
|
+
* - Zero-downtime deploys (rolling worker restarts)
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* node src/cluster-server.js
|
|
17
|
+
*
|
|
18
|
+
* Environment Variables:
|
|
19
|
+
* QUEEN_WORKERS=N - Number of workers (default: CPU count)
|
|
20
|
+
* WORKER_ID=name - Base worker ID prefix (default: queen-worker)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import cluster from 'cluster';
|
|
24
|
+
import os from 'os';
|
|
25
|
+
import { log } from './utils/logger.js';
|
|
26
|
+
|
|
27
|
+
// Configuration
|
|
28
|
+
const numWorkers = parseInt(process.env.QUEEN_WORKERS) || os.cpus().length;
|
|
29
|
+
const workerIdPrefix = process.env.WORKER_ID || 'queen-worker';
|
|
30
|
+
|
|
31
|
+
if (cluster.isPrimary) {
|
|
32
|
+
console.log('');
|
|
33
|
+
console.log('═══════════════════════════════════════════════════════════');
|
|
34
|
+
console.log('🚀 Queen Message Queue - Clustered Mode');
|
|
35
|
+
console.log('═══════════════════════════════════════════════════════════');
|
|
36
|
+
console.log(`📊 System: ${os.cpus()[0].model}`);
|
|
37
|
+
console.log(`🔢 CPU Cores: ${os.cpus().length}`);
|
|
38
|
+
console.log(`👷 Starting Workers: ${numWorkers}`);
|
|
39
|
+
console.log(`🆔 Worker ID Prefix: ${workerIdPrefix}`);
|
|
40
|
+
console.log('═══════════════════════════════════════════════════════════');
|
|
41
|
+
console.log('');
|
|
42
|
+
|
|
43
|
+
// Track worker statistics
|
|
44
|
+
const workerStats = {
|
|
45
|
+
startTime: Date.now(),
|
|
46
|
+
totalRestarts: 0,
|
|
47
|
+
workers: new Map()
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Graceful shutdown state (declared early for use in event handlers)
|
|
51
|
+
let isShuttingDown = false;
|
|
52
|
+
let forceExitTimeout = null;
|
|
53
|
+
let statusInterval = null;
|
|
54
|
+
|
|
55
|
+
// Fork workers
|
|
56
|
+
for (let i = 0; i < numWorkers; i++) {
|
|
57
|
+
forkWorker(i, workerStats);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Handle worker exit
|
|
61
|
+
cluster.on('exit', (worker, code, signal) => {
|
|
62
|
+
const workerInfo = workerStats.workers.get(worker.id);
|
|
63
|
+
const workerId = workerInfo ? workerInfo.workerId : `worker-${worker.id}`;
|
|
64
|
+
const workerIndex = workerInfo ? workerInfo.index : 0;
|
|
65
|
+
|
|
66
|
+
if (code !== 0 && !worker.exitedAfterDisconnect) {
|
|
67
|
+
// Unexpected crash
|
|
68
|
+
console.log('');
|
|
69
|
+
console.log(`⚠️ Worker ${workerId} (PID: ${worker.process.pid}) crashed!`);
|
|
70
|
+
console.log(` Exit code: ${code}, Signal: ${signal}`);
|
|
71
|
+
console.log(` Restarting worker...`);
|
|
72
|
+
|
|
73
|
+
workerStats.totalRestarts++;
|
|
74
|
+
|
|
75
|
+
// Remove from stats
|
|
76
|
+
workerStats.workers.delete(worker.id);
|
|
77
|
+
|
|
78
|
+
// Restart with same index
|
|
79
|
+
setTimeout(() => {
|
|
80
|
+
forkWorker(workerIndex, workerStats);
|
|
81
|
+
}, 1000); // Wait 1 second before restart
|
|
82
|
+
} else {
|
|
83
|
+
// Graceful shutdown
|
|
84
|
+
console.log(`✅ Worker ${workerId} (PID: ${worker.process.pid}) exited gracefully`);
|
|
85
|
+
workerStats.workers.delete(worker.id);
|
|
86
|
+
|
|
87
|
+
// Check if all workers have exited during shutdown
|
|
88
|
+
if (isShuttingDown && Object.keys(cluster.workers).length === 0) {
|
|
89
|
+
console.log('✅ All workers exited, shutting down master process');
|
|
90
|
+
clearInterval(statusInterval);
|
|
91
|
+
clearTimeout(forceExitTimeout);
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Handle worker online
|
|
98
|
+
cluster.on('online', (worker) => {
|
|
99
|
+
const workerInfo = workerStats.workers.get(worker.id);
|
|
100
|
+
if (workerInfo) {
|
|
101
|
+
// Update PID now that it's available
|
|
102
|
+
workerInfo.pid = worker.process.pid;
|
|
103
|
+
console.log(`✅ Worker ${workerInfo.workerId} (PID: ${worker.process.pid}) online and ready`);
|
|
104
|
+
} else {
|
|
105
|
+
console.log(`✅ Worker ${worker.id} (PID: ${worker.process.pid}) online`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Handle worker listening
|
|
110
|
+
cluster.on('listening', (worker, address) => {
|
|
111
|
+
const workerInfo = workerStats.workers.get(worker.id);
|
|
112
|
+
const workerId = workerInfo ? workerInfo.workerId : `worker-${worker.id}`;
|
|
113
|
+
console.log(`🎧 Worker ${workerId} listening on ${address.address}:${address.port}`);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Handle cluster errors
|
|
117
|
+
cluster.on('error', (error) => {
|
|
118
|
+
console.error('⚠️ Cluster error:', error.message);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Handle uncaught exceptions in master
|
|
122
|
+
process.on('uncaughtException', (error) => {
|
|
123
|
+
console.error('❌ Uncaught exception in master process:', error);
|
|
124
|
+
console.error(error.stack);
|
|
125
|
+
// Don't exit - try to keep the cluster running
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
129
|
+
console.error('❌ Unhandled rejection in master process:', reason);
|
|
130
|
+
// Don't exit - try to keep the cluster running
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Graceful shutdown handling
|
|
134
|
+
const gracefulShutdown = (signal) => {
|
|
135
|
+
if (isShuttingDown) return;
|
|
136
|
+
isShuttingDown = true;
|
|
137
|
+
|
|
138
|
+
console.log('');
|
|
139
|
+
console.log(`🛑 Received ${signal}, shutting down gracefully...`);
|
|
140
|
+
console.log(`📊 Total worker restarts during uptime: ${workerStats.totalRestarts}`);
|
|
141
|
+
console.log(`⏱️ Uptime: ${((Date.now() - workerStats.startTime) / 1000 / 60).toFixed(2)} minutes`);
|
|
142
|
+
|
|
143
|
+
// Stop status reporting during shutdown
|
|
144
|
+
clearInterval(statusInterval);
|
|
145
|
+
|
|
146
|
+
// Check if we already have no workers
|
|
147
|
+
if (Object.keys(cluster.workers).length === 0) {
|
|
148
|
+
console.log('✅ No active workers, exiting immediately');
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Disconnect all workers
|
|
153
|
+
for (const id in cluster.workers) {
|
|
154
|
+
cluster.workers[id].disconnect();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Force exit if workers don't shut down in 10 seconds (reduced from 30)
|
|
158
|
+
forceExitTimeout = setTimeout(() => {
|
|
159
|
+
console.log('⚠️ Forcing shutdown after 10 second timeout');
|
|
160
|
+
console.log(` Remaining workers: ${Object.keys(cluster.workers).length}`);
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}, 10000);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
166
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
167
|
+
|
|
168
|
+
// Status reporting every 60 seconds
|
|
169
|
+
statusInterval = setInterval(() => {
|
|
170
|
+
// Don't show status during shutdown
|
|
171
|
+
if (isShuttingDown) return;
|
|
172
|
+
|
|
173
|
+
const aliveWorkers = Object.keys(cluster.workers).length;
|
|
174
|
+
const uptime = ((Date.now() - workerStats.startTime) / 1000 / 60).toFixed(2);
|
|
175
|
+
|
|
176
|
+
console.log('');
|
|
177
|
+
console.log(`📊 Cluster Status (${new Date().toISOString()})`);
|
|
178
|
+
console.log(` Active Workers: ${aliveWorkers}/${numWorkers}`);
|
|
179
|
+
console.log(` Total Restarts: ${workerStats.totalRestarts}`);
|
|
180
|
+
console.log(` Uptime: ${uptime} minutes`);
|
|
181
|
+
}, 60000);
|
|
182
|
+
|
|
183
|
+
} else {
|
|
184
|
+
// Worker process - run the normal server
|
|
185
|
+
const workerId = process.env.WORKER_ID || `worker-${process.pid}`;
|
|
186
|
+
const workerIndex = process.env.WORKER_INDEX || '0';
|
|
187
|
+
|
|
188
|
+
console.log(`👷 Worker ${workerId} (index: ${workerIndex}, PID: ${process.pid}) starting...`);
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
// Import and run the main server
|
|
192
|
+
await import('./server.js');
|
|
193
|
+
|
|
194
|
+
// Worker-specific metrics could be added here
|
|
195
|
+
// e.g., track messages processed, errors, etc.
|
|
196
|
+
} catch (error) {
|
|
197
|
+
console.error(`❌ Worker ${workerId} failed to start:`, error.message);
|
|
198
|
+
console.error(error.stack);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Handle uncaught exceptions in worker
|
|
203
|
+
process.on('uncaughtException', (error) => {
|
|
204
|
+
console.error(`❌ Uncaught exception in worker ${workerId}:`, error);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
209
|
+
console.error(`❌ Unhandled rejection in worker ${workerId}:`, reason);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Fork a new worker with proper environment setup
|
|
216
|
+
*/
|
|
217
|
+
function forkWorker(index, stats) {
|
|
218
|
+
const workerId = `${workerIdPrefix}-${index}`;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const worker = cluster.fork({
|
|
222
|
+
WORKER_ID: workerId,
|
|
223
|
+
WORKER_INDEX: index.toString(),
|
|
224
|
+
// Each worker gets a unique ID for system events
|
|
225
|
+
SYSTEM_WORKER_ID: `${workerIdPrefix}-${index}-${Date.now()}`
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Store worker info immediately (PID will be updated in 'online' event)
|
|
229
|
+
stats.workers.set(worker.id, {
|
|
230
|
+
workerId,
|
|
231
|
+
index,
|
|
232
|
+
startTime: Date.now(),
|
|
233
|
+
pid: null // Will be set when worker comes online
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return worker;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.error(`❌ Failed to fork worker ${workerId}:`, error.message);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|