queen-mq 0.2.22 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +162 -136
- package/client-js/benchmark/consumer.js +1 -1
- package/client-js/client/client.js +24 -3
- package/client-js/client-v2/LOGGING.md +240 -0
- package/client-js/client-v2/Queen.js +389 -0
- package/client-js/client-v2/README.md +1883 -0
- package/client-js/client-v2/buffer/BufferManager.js +215 -0
- package/client-js/client-v2/buffer/MessageBuffer.js +132 -0
- package/client-js/client-v2/builders/QueueBuilder.js +724 -0
- package/client-js/client-v2/builders/TransactionBuilder.js +110 -0
- package/client-js/client-v2/consumer/ConsumerManager.js +390 -0
- package/client-js/client-v2/http/HttpClient.js +215 -0
- package/client-js/client-v2/http/LoadBalancer.js +50 -0
- package/client-js/client-v2/index.js +7 -0
- package/client-js/client-v2/utils/defaults.js +54 -0
- package/client-js/client-v2/utils/logger.js +54 -0
- package/client-js/client-v2/utils/validation.js +31 -0
- package/client-js/test-v2/AI_TEST_SUMMARY.md +226 -0
- package/client-js/test-v2/GETTING_STARTED.md +154 -0
- package/client-js/test-v2/ai_buffering.js +194 -0
- package/client-js/test-v2/ai_error_handling.js +223 -0
- package/client-js/test-v2/ai_lease_renewal.js +206 -0
- package/client-js/test-v2/ai_mixed_scenarios.js +278 -0
- package/client-js/test-v2/ai_priority.js +169 -0
- package/client-js/test-v2/ai_resources.js +217 -0
- package/client-js/test-v2/ai_ttl_retention.js +170 -0
- package/client-js/test-v2/complete.js +59 -0
- package/client-js/test-v2/consume.js +655 -0
- package/client-js/test-v2/dlq.js +82 -0
- package/client-js/test-v2/load.js +177 -0
- package/client-js/test-v2/pop.js +114 -0
- package/client-js/test-v2/push.js +333 -0
- package/client-js/test-v2/queue.js +39 -0
- package/client-js/test-v2/run.js +187 -0
- package/client-js/test-v2/subscription.js +354 -0
- package/client-js/test-v2/transaction.js +443 -0
- package/package.json +1 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transaction builder for atomic operations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as logger from '../utils/logger.js'
|
|
6
|
+
|
|
7
|
+
export class TransactionBuilder {
|
|
8
|
+
#httpClient
|
|
9
|
+
#operations = []
|
|
10
|
+
#requiredLeases = []
|
|
11
|
+
|
|
12
|
+
constructor(httpClient) {
|
|
13
|
+
this.#httpClient = httpClient
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
ack(messages, status = 'completed') {
|
|
17
|
+
const msgs = Array.isArray(messages) ? messages : [messages]
|
|
18
|
+
|
|
19
|
+
logger.log('TransactionBuilder.ack', { count: msgs.length, status })
|
|
20
|
+
|
|
21
|
+
msgs.forEach(msg => {
|
|
22
|
+
const transactionId = typeof msg === 'string' ? msg : (msg.transactionId || msg.id)
|
|
23
|
+
const partitionId = typeof msg === 'object' ? msg.partitionId : null
|
|
24
|
+
const leaseId = typeof msg === 'object' ? msg.leaseId : null
|
|
25
|
+
|
|
26
|
+
if (!transactionId) {
|
|
27
|
+
throw new Error('Message must have transactionId or id property')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// CRITICAL: partitionId is now MANDATORY to prevent acking wrong message
|
|
31
|
+
if (!partitionId) {
|
|
32
|
+
throw new Error('Message must have partitionId property to ensure message uniqueness')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.#operations.push({
|
|
36
|
+
type: 'ack',
|
|
37
|
+
transactionId,
|
|
38
|
+
partitionId,
|
|
39
|
+
status
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (leaseId) {
|
|
43
|
+
this.#requiredLeases.push(leaseId)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
return this
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
queue(queueName) {
|
|
51
|
+
// Return a sub-builder for push operations
|
|
52
|
+
return {
|
|
53
|
+
push: (items) => {
|
|
54
|
+
const itemArray = Array.isArray(items) ? items : [items]
|
|
55
|
+
|
|
56
|
+
logger.log('TransactionBuilder.queue.push', { queue: queueName, count: itemArray.length })
|
|
57
|
+
|
|
58
|
+
this.#operations.push({
|
|
59
|
+
type: 'push',
|
|
60
|
+
items: itemArray.map(item => {
|
|
61
|
+
// Check if property exists, not just truthy (to support null values)
|
|
62
|
+
let payloadValue
|
|
63
|
+
if ('data' in item) {
|
|
64
|
+
payloadValue = item.data
|
|
65
|
+
} else if ('payload' in item) {
|
|
66
|
+
payloadValue = item.payload
|
|
67
|
+
} else {
|
|
68
|
+
payloadValue = item
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
queue: queueName,
|
|
73
|
+
payload: payloadValue
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
return this
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async commit() {
|
|
84
|
+
if (this.#operations.length === 0) {
|
|
85
|
+
logger.error('TransactionBuilder.commit', 'No operations to commit')
|
|
86
|
+
throw new Error('Transaction has no operations to commit')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
logger.log('TransactionBuilder.commit', { operationCount: this.#operations.length, requiredLeases: this.#requiredLeases.length })
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const result = await this.#httpClient.post('/api/v1/transaction', {
|
|
93
|
+
operations: this.#operations,
|
|
94
|
+
requiredLeases: [...new Set(this.#requiredLeases)] // Unique leases
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
if (!result.success) {
|
|
98
|
+
logger.error('TransactionBuilder.commit', { error: result.error })
|
|
99
|
+
throw new Error(result.error || 'Transaction failed')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
logger.log('TransactionBuilder.commit', { status: 'success' })
|
|
103
|
+
return result
|
|
104
|
+
} catch (error) {
|
|
105
|
+
logger.error('TransactionBuilder.commit', { error: error.message })
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumer manager for handling concurrent workers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as logger from '../utils/logger.js'
|
|
6
|
+
|
|
7
|
+
export class ConsumerManager {
|
|
8
|
+
#httpClient
|
|
9
|
+
#queen
|
|
10
|
+
|
|
11
|
+
constructor(httpClient, queen) {
|
|
12
|
+
this.#httpClient = httpClient
|
|
13
|
+
this.#queen = queen
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async start(handler, options) {
|
|
17
|
+
const {
|
|
18
|
+
queue,
|
|
19
|
+
partition,
|
|
20
|
+
namespace,
|
|
21
|
+
task,
|
|
22
|
+
group,
|
|
23
|
+
concurrency,
|
|
24
|
+
batch,
|
|
25
|
+
limit,
|
|
26
|
+
idleMillis,
|
|
27
|
+
autoAck,
|
|
28
|
+
wait,
|
|
29
|
+
timeoutMillis,
|
|
30
|
+
renewLease,
|
|
31
|
+
renewLeaseIntervalMillis,
|
|
32
|
+
subscriptionMode,
|
|
33
|
+
subscriptionFrom,
|
|
34
|
+
each,
|
|
35
|
+
signal
|
|
36
|
+
} = options
|
|
37
|
+
|
|
38
|
+
logger.log('ConsumerManager.start', {
|
|
39
|
+
queue,
|
|
40
|
+
partition,
|
|
41
|
+
namespace,
|
|
42
|
+
task,
|
|
43
|
+
group,
|
|
44
|
+
concurrency,
|
|
45
|
+
batch,
|
|
46
|
+
limit,
|
|
47
|
+
autoAck,
|
|
48
|
+
wait,
|
|
49
|
+
each
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// Build the path and params for pop requests
|
|
53
|
+
const path = this.#buildPath(queue, partition, namespace, task)
|
|
54
|
+
const baseParams = this.#buildParams(batch, wait, timeoutMillis, group, subscriptionMode, subscriptionFrom, namespace, task, autoAck)
|
|
55
|
+
|
|
56
|
+
// Start workers
|
|
57
|
+
const workers = []
|
|
58
|
+
for (let i = 0; i < concurrency; i++) {
|
|
59
|
+
workers.push(this.#worker(i, handler, path, baseParams, {
|
|
60
|
+
batch,
|
|
61
|
+
limit,
|
|
62
|
+
idleMillis,
|
|
63
|
+
autoAck,
|
|
64
|
+
wait,
|
|
65
|
+
timeoutMillis,
|
|
66
|
+
renewLease,
|
|
67
|
+
renewLeaseIntervalMillis,
|
|
68
|
+
each,
|
|
69
|
+
signal,
|
|
70
|
+
group // Pass consumer group to workers
|
|
71
|
+
}))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
logger.log('ConsumerManager.start', { status: 'workers-started', count: concurrency })
|
|
75
|
+
|
|
76
|
+
// Wait for all workers to complete
|
|
77
|
+
await Promise.all(workers)
|
|
78
|
+
|
|
79
|
+
logger.log('ConsumerManager.start', { status: 'completed' })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async #worker(workerId, handler, path, baseParams, options) {
|
|
83
|
+
const {
|
|
84
|
+
batch,
|
|
85
|
+
limit,
|
|
86
|
+
idleMillis,
|
|
87
|
+
autoAck,
|
|
88
|
+
wait,
|
|
89
|
+
timeoutMillis,
|
|
90
|
+
renewLease,
|
|
91
|
+
renewLeaseIntervalMillis,
|
|
92
|
+
each,
|
|
93
|
+
signal,
|
|
94
|
+
group
|
|
95
|
+
} = options
|
|
96
|
+
|
|
97
|
+
logger.log('ConsumerManager.worker', { workerId, status: 'started', limit, idleMillis })
|
|
98
|
+
|
|
99
|
+
let processedCount = 0
|
|
100
|
+
let lastMessageTime = idleMillis ? Date.now() : null
|
|
101
|
+
|
|
102
|
+
while (true) {
|
|
103
|
+
// Check abort signal
|
|
104
|
+
if (signal && signal.aborted) {
|
|
105
|
+
logger.log('ConsumerManager.worker', { workerId, status: 'aborted', processedCount })
|
|
106
|
+
break
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check limit
|
|
110
|
+
if (limit && processedCount >= limit) {
|
|
111
|
+
logger.log('ConsumerManager.worker', { workerId, status: 'limit-reached', processedCount, limit })
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check idle timeout
|
|
116
|
+
if (idleMillis && lastMessageTime) {
|
|
117
|
+
const idleTime = Date.now() - lastMessageTime
|
|
118
|
+
if (idleTime >= idleMillis) {
|
|
119
|
+
logger.log('ConsumerManager.worker', { workerId, status: 'idle-timeout', processedCount, idleTime })
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// Pop messages
|
|
126
|
+
const clientTimeout = wait ? timeoutMillis + 5000 : timeoutMillis
|
|
127
|
+
const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout)
|
|
128
|
+
|
|
129
|
+
// Handle empty response
|
|
130
|
+
if (!result || !result.messages || result.messages.length === 0) {
|
|
131
|
+
if (wait) {
|
|
132
|
+
continue // Long polling timeout, retry
|
|
133
|
+
} else {
|
|
134
|
+
// Short delay before retry
|
|
135
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const messages = result.messages.filter(msg => msg != null)
|
|
141
|
+
|
|
142
|
+
if (messages.length === 0) {
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
logger.log('ConsumerManager.worker', { workerId, status: 'messages-received', count: messages.length })
|
|
147
|
+
|
|
148
|
+
// Enhance messages with trace() method
|
|
149
|
+
this.#enhanceMessagesWithTrace(messages, group)
|
|
150
|
+
|
|
151
|
+
// Update last message time
|
|
152
|
+
if (idleMillis) {
|
|
153
|
+
lastMessageTime = Date.now()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Set up lease renewal if enabled
|
|
157
|
+
let renewalTimer = null
|
|
158
|
+
if (renewLease && renewLeaseIntervalMillis) {
|
|
159
|
+
renewalTimer = this.#setupLeaseRenewal(messages, renewLeaseIntervalMillis)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
// Process messages
|
|
164
|
+
if (each) {
|
|
165
|
+
// Process one at a time
|
|
166
|
+
for (const message of messages) {
|
|
167
|
+
if (signal && signal.aborted) break
|
|
168
|
+
|
|
169
|
+
await this.#processMessage(message, handler, autoAck, group)
|
|
170
|
+
processedCount++
|
|
171
|
+
|
|
172
|
+
if (limit && processedCount >= limit) break
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
// Process as batch
|
|
176
|
+
await this.#processBatch(messages, handler, autoAck, group)
|
|
177
|
+
processedCount += messages.length
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
logger.log('ConsumerManager.worker', { workerId, status: 'messages-processed', count: messages.length, total: processedCount })
|
|
181
|
+
} finally {
|
|
182
|
+
// Clear renewal timer
|
|
183
|
+
if (renewalTimer) {
|
|
184
|
+
clearInterval(renewalTimer)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
} catch (error) {
|
|
189
|
+
// Check if this is a timeout error (expected for long polling)
|
|
190
|
+
const isTimeoutError = error.name === 'AbortError' ||
|
|
191
|
+
error.message?.includes('timeout')
|
|
192
|
+
|
|
193
|
+
if (isTimeoutError && wait) {
|
|
194
|
+
continue // Retry on timeout
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Check if network error
|
|
198
|
+
const isNetworkError = error.message?.includes('fetch failed') ||
|
|
199
|
+
error.message?.includes('ECONNREFUSED') ||
|
|
200
|
+
error.code === 'ECONNREFUSED'
|
|
201
|
+
|
|
202
|
+
if (isNetworkError) {
|
|
203
|
+
logger.warn('ConsumerManager.worker', { workerId, error: 'network', message: error.message })
|
|
204
|
+
console.warn(`Worker ${workerId}: Network error - ${error.message}`)
|
|
205
|
+
// Wait before retry
|
|
206
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Other errors - rethrow
|
|
211
|
+
logger.error('ConsumerManager.worker', { workerId, error: error.message })
|
|
212
|
+
throw error
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
logger.log('ConsumerManager.worker', { workerId, status: 'stopped', processedCount })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async #processMessage(message, handler, autoAck, group) {
|
|
220
|
+
try {
|
|
221
|
+
await handler(message)
|
|
222
|
+
|
|
223
|
+
// Auto-ack on success if enabled
|
|
224
|
+
if (autoAck) {
|
|
225
|
+
const context = group ? { group } : {}
|
|
226
|
+
await this.#queen.ack(message, true, context)
|
|
227
|
+
logger.log('ConsumerManager.processMessage', { transactionId: message.transactionId, status: 'acked' })
|
|
228
|
+
}
|
|
229
|
+
} catch (error) {
|
|
230
|
+
// Auto-nack on error if enabled
|
|
231
|
+
if (autoAck) {
|
|
232
|
+
const context = group ? { group } : {}
|
|
233
|
+
await this.#queen.ack(message, false, context)
|
|
234
|
+
logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message, status: 'nacked' })
|
|
235
|
+
// Don't rethrow when autoAck is enabled - NACK was already sent
|
|
236
|
+
// This allows the consumer to continue and retry
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
logger.error('ConsumerManager.processMessage', { transactionId: message.transactionId, error: error.message })
|
|
240
|
+
throw error
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async #processBatch(messages, handler, autoAck, group) {
|
|
245
|
+
try {
|
|
246
|
+
await handler(messages)
|
|
247
|
+
|
|
248
|
+
// Auto-ack on success if enabled
|
|
249
|
+
if (autoAck) {
|
|
250
|
+
const context = group ? { group } : {}
|
|
251
|
+
await this.#queen.ack(messages, true, context)
|
|
252
|
+
logger.log('ConsumerManager.processBatch', { count: messages.length, status: 'acked' })
|
|
253
|
+
}
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// Auto-nack on error if enabled
|
|
256
|
+
if (autoAck) {
|
|
257
|
+
const context = group ? { group } : {}
|
|
258
|
+
await this.#queen.ack(messages, false, context)
|
|
259
|
+
logger.error('ConsumerManager.processBatch', { count: messages.length, error: error.message, status: 'nacked' })
|
|
260
|
+
// Don't rethrow when autoAck is enabled - NACK was already sent
|
|
261
|
+
// This allows the consumer to continue and retry
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
logger.error('ConsumerManager.processBatch', { count: messages.length, error: error.message })
|
|
265
|
+
throw error
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#setupLeaseRenewal(messages, intervalMillis) {
|
|
270
|
+
const leaseIds = messages.map(m => m.leaseId).filter(id => id != null)
|
|
271
|
+
|
|
272
|
+
if (leaseIds.length === 0) return null
|
|
273
|
+
|
|
274
|
+
return setInterval(async () => {
|
|
275
|
+
try {
|
|
276
|
+
await this.#queen.renew(messages)
|
|
277
|
+
} catch (error) {
|
|
278
|
+
console.error('Lease renewal failed:', error)
|
|
279
|
+
}
|
|
280
|
+
}, intervalMillis)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#enhanceMessagesWithTrace(messages, group) {
|
|
284
|
+
const httpClient = this.#httpClient
|
|
285
|
+
const consumerGroup = group || '__QUEUE_MODE__'
|
|
286
|
+
|
|
287
|
+
for (const message of messages) {
|
|
288
|
+
/**
|
|
289
|
+
* Record a trace event for this message
|
|
290
|
+
* @param {object} traceConfig - Configuration object
|
|
291
|
+
* @param {string|string[]} [traceConfig.traceName] - Single name or array of names for categorization
|
|
292
|
+
* @param {string} [traceConfig.eventType='info'] - Event type (info, error, step, processing, etc.)
|
|
293
|
+
* @param {object} traceConfig.data - User data to store with the trace
|
|
294
|
+
* @returns {Promise<object>} Result with success status
|
|
295
|
+
*
|
|
296
|
+
* IMPORTANT: This method will NEVER crash - errors are logged but don't throw
|
|
297
|
+
*
|
|
298
|
+
* @example
|
|
299
|
+
* await msg.trace({
|
|
300
|
+
* traceName: ['tenant-acme', 'chat-room-123'],
|
|
301
|
+
* eventType: 'info',
|
|
302
|
+
* data: { text: 'Started processing', orderId: 123 }
|
|
303
|
+
* });
|
|
304
|
+
*/
|
|
305
|
+
message.trace = async (traceConfig) => {
|
|
306
|
+
try {
|
|
307
|
+
// Validate required structure
|
|
308
|
+
if (typeof traceConfig !== 'object' || !traceConfig.data) {
|
|
309
|
+
logger.warn('ConsumerManager.trace', {
|
|
310
|
+
error: 'Invalid trace config: requires { data: {...} }',
|
|
311
|
+
transactionId: message.transactionId
|
|
312
|
+
})
|
|
313
|
+
return { success: false, error: 'Invalid trace config: requires { data: {...} }' }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Normalize traceName to array
|
|
317
|
+
let traceNames = null
|
|
318
|
+
if (traceConfig.traceName) {
|
|
319
|
+
if (Array.isArray(traceConfig.traceName)) {
|
|
320
|
+
traceNames = traceConfig.traceName.filter(n => typeof n === 'string' && n.length > 0)
|
|
321
|
+
if (traceNames.length === 0) traceNames = null
|
|
322
|
+
} else if (typeof traceConfig.traceName === 'string') {
|
|
323
|
+
traceNames = [traceConfig.traceName]
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const response = await httpClient.post('/api/v1/traces', {
|
|
328
|
+
transactionId: message.transactionId,
|
|
329
|
+
partitionId: message.partitionId,
|
|
330
|
+
consumerGroup: consumerGroup,
|
|
331
|
+
traceNames: traceNames,
|
|
332
|
+
eventType: traceConfig.eventType || 'info',
|
|
333
|
+
data: traceConfig.data
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
logger.log('ConsumerManager.trace', {
|
|
337
|
+
transactionId: message.transactionId,
|
|
338
|
+
success: true,
|
|
339
|
+
traceNames: traceNames
|
|
340
|
+
})
|
|
341
|
+
return { success: true, ...response }
|
|
342
|
+
} catch (error) {
|
|
343
|
+
// CRITICAL: NEVER CRASH - just log and return gracefully
|
|
344
|
+
logger.error('ConsumerManager.trace', {
|
|
345
|
+
transactionId: message.transactionId,
|
|
346
|
+
error: error.message,
|
|
347
|
+
phase: 'trace-failed'
|
|
348
|
+
})
|
|
349
|
+
console.warn(`[TRACE FAILED] ${message.transactionId}: ${error.message}`)
|
|
350
|
+
|
|
351
|
+
return { success: false, error: error.message }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
#buildPath(queue, partition, namespace, task) {
|
|
358
|
+
if (queue) {
|
|
359
|
+
if (partition) {
|
|
360
|
+
return `/api/v1/pop/queue/${queue}/partition/${partition}`
|
|
361
|
+
}
|
|
362
|
+
return `/api/v1/pop/queue/${queue}`
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (namespace || task) {
|
|
366
|
+
return '/api/v1/pop'
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
throw new Error('Must specify queue, namespace, or task')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
#buildParams(batch, wait, timeoutMillis, group, subscriptionMode, subscriptionFrom, namespace, task, autoAck) {
|
|
373
|
+
const params = new URLSearchParams({
|
|
374
|
+
batch: batch.toString(),
|
|
375
|
+
wait: wait.toString(),
|
|
376
|
+
timeout: timeoutMillis.toString() // Server expects 'timeout', not 'timeoutMillis'
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
if (group) params.append('consumerGroup', group)
|
|
380
|
+
if (subscriptionMode) params.append('subscriptionMode', subscriptionMode)
|
|
381
|
+
if (subscriptionFrom) params.append('subscriptionFrom', subscriptionFrom)
|
|
382
|
+
if (namespace) params.append('namespace', namespace)
|
|
383
|
+
if (task) params.append('task', task)
|
|
384
|
+
// NEVER send autoAck for consume - client always manages acking
|
|
385
|
+
// autoAck is only for pop() where server auto-acks immediately
|
|
386
|
+
|
|
387
|
+
return params
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client with retry, load balancing, and failover support
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as logger from '../utils/logger.js'
|
|
6
|
+
|
|
7
|
+
export class HttpClient {
|
|
8
|
+
#baseUrl
|
|
9
|
+
#loadBalancer
|
|
10
|
+
#timeoutMillis
|
|
11
|
+
#retryAttempts
|
|
12
|
+
#retryDelayMillis
|
|
13
|
+
#enableFailover
|
|
14
|
+
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
const {
|
|
17
|
+
baseUrl = null,
|
|
18
|
+
loadBalancer = null,
|
|
19
|
+
timeoutMillis = 30000,
|
|
20
|
+
retryAttempts = 3,
|
|
21
|
+
retryDelayMillis = 1000,
|
|
22
|
+
enableFailover = true
|
|
23
|
+
} = options
|
|
24
|
+
|
|
25
|
+
this.#baseUrl = baseUrl
|
|
26
|
+
this.#loadBalancer = loadBalancer
|
|
27
|
+
this.#timeoutMillis = timeoutMillis
|
|
28
|
+
this.#retryAttempts = retryAttempts
|
|
29
|
+
this.#retryDelayMillis = retryDelayMillis
|
|
30
|
+
this.#enableFailover = enableFailover
|
|
31
|
+
|
|
32
|
+
logger.log('HttpClient.constructor', {
|
|
33
|
+
hasLoadBalancer: !!loadBalancer,
|
|
34
|
+
baseUrl: baseUrl || 'load-balanced',
|
|
35
|
+
timeoutMillis,
|
|
36
|
+
retryAttempts,
|
|
37
|
+
enableFailover
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async #executeRequest(url, method, body = null, requestTimeoutMillis = null) {
|
|
42
|
+
const effectiveTimeout = requestTimeoutMillis || this.#timeoutMillis
|
|
43
|
+
logger.log('HttpClient.request', { method, url, hasBody: !!body, timeout: effectiveTimeout })
|
|
44
|
+
|
|
45
|
+
const controller = new AbortController()
|
|
46
|
+
const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout)
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const options = {
|
|
50
|
+
method,
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
headers: { 'Content-Type': 'application/json' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (body) {
|
|
56
|
+
options.body = JSON.stringify(body)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const response = await fetch(url, options)
|
|
60
|
+
|
|
61
|
+
logger.log('HttpClient.response', { method, url, status: response.status })
|
|
62
|
+
|
|
63
|
+
// Handle 204 No Content
|
|
64
|
+
if (response.status === 204) {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Handle errors
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
71
|
+
error.status = response.status
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const text = await response.text()
|
|
75
|
+
if (text) {
|
|
76
|
+
const body = JSON.parse(text)
|
|
77
|
+
error.message = body.error || error.message
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
// Ignore JSON parse errors
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
logger.error('HttpClient.request', { method, url, status: error.status, error: error.message })
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Parse successful response
|
|
88
|
+
const contentType = response.headers.get('content-type')
|
|
89
|
+
const contentLength = response.headers.get('content-length')
|
|
90
|
+
|
|
91
|
+
if (!contentType || !contentType.includes('application/json') || contentLength === '0') {
|
|
92
|
+
const text = await response.text()
|
|
93
|
+
if (!text || text.length === 0) {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(text)
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return response.json()
|
|
104
|
+
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error.name === 'AbortError') {
|
|
107
|
+
const timeoutError = new Error(`Request timeout after ${effectiveTimeout}ms`)
|
|
108
|
+
timeoutError.name = 'AbortError'
|
|
109
|
+
timeoutError.timeout = effectiveTimeout
|
|
110
|
+
logger.error('HttpClient.request', { method, url, error: 'timeout', timeout: effectiveTimeout })
|
|
111
|
+
throw timeoutError
|
|
112
|
+
}
|
|
113
|
+
logger.error('HttpClient.request', { method, url, error: error.message })
|
|
114
|
+
throw error
|
|
115
|
+
} finally {
|
|
116
|
+
clearTimeout(timeoutId)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async #requestWithRetry(method, path, body = null, requestTimeoutMillis = null) {
|
|
121
|
+
let lastError = null
|
|
122
|
+
|
|
123
|
+
for (let attempt = 0; attempt < this.#retryAttempts; attempt++) {
|
|
124
|
+
try {
|
|
125
|
+
const url = this.#getUrl() + path
|
|
126
|
+
return await this.#executeRequest(url, method, body, requestTimeoutMillis)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
lastError = error
|
|
129
|
+
|
|
130
|
+
// Don't retry on client errors (4xx)
|
|
131
|
+
if (error.status && error.status >= 400 && error.status < 500) {
|
|
132
|
+
throw error
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Wait before retry (except on last attempt)
|
|
136
|
+
if (attempt < this.#retryAttempts - 1) {
|
|
137
|
+
const delay = this.#retryDelayMillis * Math.pow(2, attempt)
|
|
138
|
+
logger.warn('HttpClient.retry', { method, path, attempt: attempt + 1, delay, error: error.message })
|
|
139
|
+
await new Promise(resolve => setTimeout(resolve, delay))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
logger.error('HttpClient.retry', { method, path, error: 'Max retries exceeded', attempts: this.#retryAttempts })
|
|
145
|
+
throw lastError
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async #requestWithFailover(method, path, body = null, requestTimeoutMillis = null) {
|
|
149
|
+
if (!this.#loadBalancer || !this.#enableFailover) {
|
|
150
|
+
return this.#requestWithRetry(method, path, body, requestTimeoutMillis)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const urls = this.#loadBalancer.getAllUrls()
|
|
154
|
+
const attemptedUrls = new Set()
|
|
155
|
+
let lastError = null
|
|
156
|
+
|
|
157
|
+
logger.log('HttpClient.failover', { method, path, totalServers: urls.length })
|
|
158
|
+
|
|
159
|
+
for (let i = 0; i < urls.length; i++) {
|
|
160
|
+
const url = this.#loadBalancer.getNextUrl()
|
|
161
|
+
|
|
162
|
+
if (attemptedUrls.has(url)) {
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
attemptedUrls.add(url)
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
return await this.#executeRequest(url + path, method, body, requestTimeoutMillis)
|
|
170
|
+
} catch (error) {
|
|
171
|
+
lastError = error
|
|
172
|
+
logger.warn('HttpClient.failover', { url, method, path, error: error.message })
|
|
173
|
+
console.warn(`Request failed for ${url}: ${method} ${path} - ${error.message}`)
|
|
174
|
+
|
|
175
|
+
// Don't retry on client errors (4xx)
|
|
176
|
+
if (error.status && error.status >= 400 && error.status < 500) {
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Continue to next server for server errors or network issues
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
logger.error('HttpClient.failover', { method, path, error: 'All servers failed', attempted: attemptedUrls.size })
|
|
185
|
+
throw lastError || new Error('All servers failed')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#getUrl() {
|
|
189
|
+
if (this.#loadBalancer) {
|
|
190
|
+
return this.#loadBalancer.getNextUrl()
|
|
191
|
+
}
|
|
192
|
+
return this.#baseUrl
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async get(path, requestTimeoutMillis = null) {
|
|
196
|
+
return this.#requestWithFailover('GET', path, null, requestTimeoutMillis)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async post(path, body = null, requestTimeoutMillis = null) {
|
|
200
|
+
return this.#requestWithFailover('POST', path, body, requestTimeoutMillis)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async put(path, body = null, requestTimeoutMillis = null) {
|
|
204
|
+
return this.#requestWithFailover('PUT', path, body, requestTimeoutMillis)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async delete(path, requestTimeoutMillis = null) {
|
|
208
|
+
return this.#requestWithFailover('DELETE', path, null, requestTimeoutMillis)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
getLoadBalancer() {
|
|
212
|
+
return this.#loadBalancer
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|