queen-mq 0.6.3 → 0.7.4
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 +500 -327
- package/{client-js/client-v2 → client-v2}/Queen.js +5 -2
- package/{client-js/client-v2 → client-v2}/README.md +53 -2
- package/{client-js/client-v2 → client-v2}/builders/QueueBuilder.js +24 -1
- package/{client-js/client-v2 → client-v2}/consumer/ConsumerManager.js +25 -4
- package/{client-js/client-v2 → client-v2}/http/HttpClient.js +24 -12
- package/client-v2/http/LoadBalancer.js +271 -0
- package/{client-js/client-v2 → client-v2}/utils/defaults.js +4 -2
- package/package.json +6 -8
- package/{client-js/test-v2 → test-v2}/maintenance.js +1 -1
- package/LICENSE.md +0 -202
- package/client-js/benchmark/consumer.js +0 -209
- package/client-js/benchmark/consumer_multi.js +0 -216
- package/client-js/benchmark/producer.js +0 -80
- package/client-js/benchmark/producer_multi.js +0 -115
- package/client-js/client-v2/http/LoadBalancer.js +0 -50
- /package/{client-js/client-v2 → client-v2}/LOGGING.md +0 -0
- /package/{client-js/client-v2 → client-v2}/buffer/BufferManager.js +0 -0
- /package/{client-js/client-v2 → client-v2}/buffer/MessageBuffer.js +0 -0
- /package/{client-js/client-v2 → client-v2}/builders/TransactionBuilder.js +0 -0
- /package/{client-js/client-v2 → client-v2}/index.js +0 -0
- /package/{client-js/client-v2 → client-v2}/stream/StreamBuilder.js +0 -0
- /package/{client-js/client-v2 → client-v2}/stream/StreamConsumer.js +0 -0
- /package/{client-js/client-v2 → client-v2}/stream/Window.js +0 -0
- /package/{client-js/client-v2 → client-v2}/utils/logger.js +0 -0
- /package/{client-js/client-v2 → client-v2}/utils/validation.js +0 -0
- /package/{client-js/test-v2 → test-v2}/AI_TEST_SUMMARY.md +0 -0
- /package/{client-js/test-v2 → test-v2}/GETTING_STARTED.md +0 -0
- /package/{client-js/test-v2 → test-v2}/MAINTENANCE_TEST.md +0 -0
- /package/{client-js/test-v2 → test-v2}/README_SUBSCRIPTION_TESTS.md +0 -0
- /package/{client-js/test-v2 → test-v2}/ai_buffering.js +0 -0
- /package/{client-js/test-v2 → test-v2}/ai_error_handling.js +0 -0
- /package/{client-js/test-v2 → test-v2}/ai_lease_renewal.js +0 -0
- /package/{client-js/test-v2 → test-v2}/ai_mixed_scenarios.js +0 -0
- /package/{client-js/test-v2 → test-v2}/ai_priority.js +0 -0
- /package/{client-js/test-v2 → test-v2}/ai_resources.js +0 -0
- /package/{client-js/test-v2 → test-v2}/ai_ttl_retention.js +0 -0
- /package/{client-js/test-v2 → test-v2}/complete.js +0 -0
- /package/{client-js/test-v2 → test-v2}/consume.js +0 -0
- /package/{client-js/test-v2 → test-v2}/dlq.js +0 -0
- /package/{client-js/test-v2 → test-v2}/load.js +0 -0
- /package/{client-js/test-v2 → test-v2}/pop.js +0 -0
- /package/{client-js/test-v2 → test-v2}/push.js +0 -0
- /package/{client-js/test-v2 → test-v2}/queue.js +0 -0
- /package/{client-js/test-v2 → test-v2}/retention.js +0 -0
- /package/{client-js/test-v2 → test-v2}/run.js +0 -0
- /package/{client-js/test-v2 → test-v2}/subscription.js +0 -0
- /package/{client-js/test-v2 → test-v2}/transaction.js +0 -0
|
@@ -75,7 +75,7 @@ export class Queen {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
#createHttpClient() {
|
|
78
|
-
const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, enableFailover } = this.#config
|
|
78
|
+
const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover } = this.#config
|
|
79
79
|
|
|
80
80
|
if (urls.length === 1) {
|
|
81
81
|
// Single server
|
|
@@ -88,7 +88,10 @@ export class Queen {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
// Multiple servers with load balancing
|
|
91
|
-
const loadBalancer = new LoadBalancer(urls, loadBalancingStrategy
|
|
91
|
+
const loadBalancer = new LoadBalancer(urls, loadBalancingStrategy, {
|
|
92
|
+
affinityHashRing,
|
|
93
|
+
healthRetryAfterMillis
|
|
94
|
+
})
|
|
92
95
|
return new HttpClient({
|
|
93
96
|
loadBalancer,
|
|
94
97
|
timeoutMillis,
|
|
@@ -43,16 +43,67 @@ const queen = new Queen(['http://server1:6632', 'http://server2:6632'])
|
|
|
43
43
|
|
|
44
44
|
// Or with full configuration
|
|
45
45
|
const queen = new Queen({
|
|
46
|
-
urls: ['http://server1:6632', 'http://server2:6632'],
|
|
46
|
+
urls: ['http://server1:6632', 'http://server2:6632', 'http://server3:6632'],
|
|
47
47
|
timeoutMillis: 30000,
|
|
48
48
|
retryAttempts: 3,
|
|
49
|
-
loadBalancingStrategy: 'round-robin',
|
|
49
|
+
loadBalancingStrategy: 'affinity', // 'affinity', 'round-robin', or 'session'
|
|
50
|
+
affinityHashRing: 150, // Virtual nodes per server (for affinity mode)
|
|
50
51
|
enableFailover: true
|
|
51
52
|
})
|
|
52
53
|
```
|
|
53
54
|
|
|
54
55
|
That's it! You're connected. Now let's do something fun. 🎉
|
|
55
56
|
|
|
57
|
+
### Load Balancing Strategies
|
|
58
|
+
|
|
59
|
+
When connecting to multiple Queen servers, you can choose how requests are distributed:
|
|
60
|
+
|
|
61
|
+
#### Affinity Mode (Recommended for Production)
|
|
62
|
+
|
|
63
|
+
Uses consistent hashing with virtual nodes to route consumer groups to the same backend server. This optimizes database queries by consolidating poll intentions.
|
|
64
|
+
|
|
65
|
+
```javascript
|
|
66
|
+
const queen = new Queen({
|
|
67
|
+
urls: ['http://server1:6632', 'http://server2:6632', 'http://server3:6632'],
|
|
68
|
+
loadBalancingStrategy: 'affinity',
|
|
69
|
+
affinityHashRing: 150 // Virtual nodes per server (default: 150)
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Benefits:**
|
|
74
|
+
- ✅ Same consumer group always routes to same server
|
|
75
|
+
- ✅ Poll intentions consolidated → optimized DB queries
|
|
76
|
+
- ✅ Graceful failover (only ~33% of keys move if server fails)
|
|
77
|
+
- ✅ Works great with 3-server HA setup
|
|
78
|
+
|
|
79
|
+
**How it works:**
|
|
80
|
+
1. Each consumer group generates an affinity key: `queue:partition:consumerGroup`
|
|
81
|
+
2. Key is hashed and mapped to a virtual node on the ring
|
|
82
|
+
3. Virtual node maps to a real backend server
|
|
83
|
+
4. Same key always routes to same server
|
|
84
|
+
|
|
85
|
+
#### Round-Robin Mode
|
|
86
|
+
|
|
87
|
+
Cycles through servers in order. Simple but doesn't optimize for poll intention consolidation.
|
|
88
|
+
|
|
89
|
+
```javascript
|
|
90
|
+
const queen = new Queen({
|
|
91
|
+
urls: ['http://server1:6632', 'http://server2:6632'],
|
|
92
|
+
loadBalancingStrategy: 'round-robin'
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
#### Session Mode
|
|
97
|
+
|
|
98
|
+
Sticky sessions - each client instance sticks to one server.
|
|
99
|
+
|
|
100
|
+
```javascript
|
|
101
|
+
const queen = new Queen({
|
|
102
|
+
urls: ['http://server1:6632', 'http://server2:6632'],
|
|
103
|
+
loadBalancingStrategy: 'session'
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
56
107
|
---
|
|
57
108
|
|
|
58
109
|
## Part 1: Hello Queue!
|
|
@@ -48,6 +48,26 @@ export class QueueBuilder {
|
|
|
48
48
|
this.#queueName = queueName
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// ===========================
|
|
52
|
+
// Affinity Key Generation
|
|
53
|
+
// ===========================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generate affinity key for consistent routing
|
|
57
|
+
* Matches server's PollIntention::grouping_key() format
|
|
58
|
+
* Format: queue:partition:consumerGroup or namespace:task:consumerGroup
|
|
59
|
+
*/
|
|
60
|
+
#getAffinityKey() {
|
|
61
|
+
if (this.#queueName) {
|
|
62
|
+
// Queue-based routing: queue:partition:consumerGroup
|
|
63
|
+
return `${this.#queueName}:${this.#partition || '*'}:${this.#group || '__QUEUE_MODE__'}`
|
|
64
|
+
} else if (this.#namespace || this.#task) {
|
|
65
|
+
// Namespace/task-based routing: namespace:task:consumerGroup
|
|
66
|
+
return `${this.#namespace || '*'}:${this.#task || '*'}:${this.#group || '__QUEUE_MODE__'}`
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
// ===========================
|
|
52
72
|
// Queue Configuration Methods
|
|
53
73
|
// ===========================
|
|
@@ -270,7 +290,10 @@ export class QueueBuilder {
|
|
|
270
290
|
if (this.#subscriptionMode) params.append('subscriptionMode', this.#subscriptionMode)
|
|
271
291
|
if (this.#subscriptionFrom) params.append('subscriptionFrom', this.#subscriptionFrom)
|
|
272
292
|
|
|
273
|
-
|
|
293
|
+
// Generate affinity key for consistent routing to same backend
|
|
294
|
+
const affinityKey = this.#getAffinityKey()
|
|
295
|
+
|
|
296
|
+
const result = await this.#httpClient.get(`${path}?${params}`, this.#timeoutMillis + 5000, affinityKey)
|
|
274
297
|
|
|
275
298
|
if (!result || !result.messages) {
|
|
276
299
|
logger.log('QueueBuilder.pop', { status: 'no-messages' })
|
|
@@ -13,6 +13,22 @@ export class ConsumerManager {
|
|
|
13
13
|
this.#queen = queen
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Generate affinity key for consistent routing
|
|
18
|
+
* Matches server's PollIntention::grouping_key() format
|
|
19
|
+
* Format: queue:partition:consumerGroup or namespace:task:consumerGroup
|
|
20
|
+
*/
|
|
21
|
+
#getAffinityKey(queue, partition, namespace, task, group) {
|
|
22
|
+
if (queue) {
|
|
23
|
+
// Queue-based routing: queue:partition:consumerGroup
|
|
24
|
+
return `${queue}:${partition || '*'}:${group || '__QUEUE_MODE__'}`
|
|
25
|
+
} else if (namespace || task) {
|
|
26
|
+
// Namespace/task-based routing: namespace:task:consumerGroup
|
|
27
|
+
return `${namespace || '*'}:${task || '*'}:${group || '__QUEUE_MODE__'}`
|
|
28
|
+
}
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
async start(handler, options) {
|
|
17
33
|
const {
|
|
18
34
|
queue,
|
|
@@ -52,6 +68,9 @@ export class ConsumerManager {
|
|
|
52
68
|
// Build the path and params for pop requests
|
|
53
69
|
const path = this.#buildPath(queue, partition, namespace, task)
|
|
54
70
|
const baseParams = this.#buildParams(batch, wait, timeoutMillis, group, subscriptionMode, subscriptionFrom, namespace, task, autoAck)
|
|
71
|
+
|
|
72
|
+
// Generate affinity key for consistent routing to same backend
|
|
73
|
+
const affinityKey = this.#getAffinityKey(queue, partition, namespace, task, group)
|
|
55
74
|
|
|
56
75
|
// Start workers
|
|
57
76
|
const workers = []
|
|
@@ -67,7 +86,8 @@ export class ConsumerManager {
|
|
|
67
86
|
renewLeaseIntervalMillis,
|
|
68
87
|
each,
|
|
69
88
|
signal,
|
|
70
|
-
group // Pass consumer group to workers
|
|
89
|
+
group, // Pass consumer group to workers
|
|
90
|
+
affinityKey // Pass affinity key to workers
|
|
71
91
|
}))
|
|
72
92
|
}
|
|
73
93
|
|
|
@@ -91,7 +111,8 @@ export class ConsumerManager {
|
|
|
91
111
|
renewLeaseIntervalMillis,
|
|
92
112
|
each,
|
|
93
113
|
signal,
|
|
94
|
-
group
|
|
114
|
+
group,
|
|
115
|
+
affinityKey
|
|
95
116
|
} = options
|
|
96
117
|
|
|
97
118
|
logger.log('ConsumerManager.worker', { workerId, status: 'started', limit, idleMillis })
|
|
@@ -122,9 +143,9 @@ export class ConsumerManager {
|
|
|
122
143
|
}
|
|
123
144
|
|
|
124
145
|
try {
|
|
125
|
-
// Pop messages
|
|
146
|
+
// Pop messages with affinity key for consistent routing
|
|
126
147
|
const clientTimeout = wait ? timeoutMillis + 5000 : timeoutMillis
|
|
127
|
-
const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout)
|
|
148
|
+
const result = await this.#httpClient.get(`${path}?${baseParams}`, clientTimeout, affinityKey)
|
|
128
149
|
|
|
129
150
|
// Handle empty response
|
|
130
151
|
if (!result || !result.messages || result.messages.length === 0) {
|
|
@@ -145,7 +145,7 @@ export class HttpClient {
|
|
|
145
145
|
throw lastError
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
async #requestWithFailover(method, path, body = null, requestTimeoutMillis = null) {
|
|
148
|
+
async #requestWithFailover(method, path, body = null, requestTimeoutMillis = null, affinityKey = null) {
|
|
149
149
|
if (!this.#loadBalancer || !this.#enableFailover) {
|
|
150
150
|
return this.#requestWithRetry(method, path, body, requestTimeoutMillis)
|
|
151
151
|
}
|
|
@@ -154,10 +154,11 @@ export class HttpClient {
|
|
|
154
154
|
const attemptedUrls = new Set()
|
|
155
155
|
let lastError = null
|
|
156
156
|
|
|
157
|
-
logger.log('HttpClient.failover', { method, path, totalServers: urls.length })
|
|
157
|
+
logger.log('HttpClient.failover', { method, path, totalServers: urls.length, affinityKey })
|
|
158
158
|
|
|
159
159
|
for (let i = 0; i < urls.length; i++) {
|
|
160
|
-
|
|
160
|
+
// Pass affinity key to load balancer for consistent routing
|
|
161
|
+
const url = this.#loadBalancer.getNextUrl(affinityKey)
|
|
161
162
|
|
|
162
163
|
if (attemptedUrls.has(url)) {
|
|
163
164
|
continue
|
|
@@ -166,9 +167,20 @@ export class HttpClient {
|
|
|
166
167
|
attemptedUrls.add(url)
|
|
167
168
|
|
|
168
169
|
try {
|
|
169
|
-
|
|
170
|
+
const result = await this.#executeRequest(url + path, method, body, requestTimeoutMillis)
|
|
171
|
+
|
|
172
|
+
// Mark backend as healthy on success
|
|
173
|
+
this.#loadBalancer.markHealthy(url)
|
|
174
|
+
|
|
175
|
+
return result
|
|
170
176
|
} catch (error) {
|
|
171
177
|
lastError = error
|
|
178
|
+
|
|
179
|
+
// Mark backend as unhealthy on failure (5xx or network errors)
|
|
180
|
+
if (!error.status || error.status >= 500) {
|
|
181
|
+
this.#loadBalancer.markUnhealthy(url)
|
|
182
|
+
}
|
|
183
|
+
|
|
172
184
|
logger.warn('HttpClient.failover', { url, method, path, error: error.message })
|
|
173
185
|
console.warn(`Request failed for ${url}: ${method} ${path} - ${error.message}`)
|
|
174
186
|
|
|
@@ -192,20 +204,20 @@ export class HttpClient {
|
|
|
192
204
|
return this.#baseUrl
|
|
193
205
|
}
|
|
194
206
|
|
|
195
|
-
async get(path, requestTimeoutMillis = null) {
|
|
196
|
-
return this.#requestWithFailover('GET', path, null, requestTimeoutMillis)
|
|
207
|
+
async get(path, requestTimeoutMillis = null, affinityKey = null) {
|
|
208
|
+
return this.#requestWithFailover('GET', path, null, requestTimeoutMillis, affinityKey)
|
|
197
209
|
}
|
|
198
210
|
|
|
199
|
-
async post(path, body = null, requestTimeoutMillis = null) {
|
|
200
|
-
return this.#requestWithFailover('POST', path, body, requestTimeoutMillis)
|
|
211
|
+
async post(path, body = null, requestTimeoutMillis = null, affinityKey = null) {
|
|
212
|
+
return this.#requestWithFailover('POST', path, body, requestTimeoutMillis, affinityKey)
|
|
201
213
|
}
|
|
202
214
|
|
|
203
|
-
async put(path, body = null, requestTimeoutMillis = null) {
|
|
204
|
-
return this.#requestWithFailover('PUT', path, body, requestTimeoutMillis)
|
|
215
|
+
async put(path, body = null, requestTimeoutMillis = null, affinityKey = null) {
|
|
216
|
+
return this.#requestWithFailover('PUT', path, body, requestTimeoutMillis, affinityKey)
|
|
205
217
|
}
|
|
206
218
|
|
|
207
|
-
async delete(path, requestTimeoutMillis = null) {
|
|
208
|
-
return this.#requestWithFailover('DELETE', path, null, requestTimeoutMillis)
|
|
219
|
+
async delete(path, requestTimeoutMillis = null, affinityKey = null) {
|
|
220
|
+
return this.#requestWithFailover('DELETE', path, null, requestTimeoutMillis, affinityKey)
|
|
209
221
|
}
|
|
210
222
|
|
|
211
223
|
getLoadBalancer() {
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load balancer for distributing requests across multiple servers
|
|
3
|
+
* Supports round-robin, session, and affinity-based routing with virtual nodes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class LoadBalancer {
|
|
7
|
+
#urls
|
|
8
|
+
#strategy
|
|
9
|
+
#currentIndex = 0
|
|
10
|
+
#sessionMap = new Map()
|
|
11
|
+
#sessionId
|
|
12
|
+
#healthStatus
|
|
13
|
+
#virtualNodes = null
|
|
14
|
+
#affinityHashRing
|
|
15
|
+
#healthRetryAfterMillis
|
|
16
|
+
|
|
17
|
+
constructor(urls, strategy = 'round-robin', options = {}) {
|
|
18
|
+
this.#urls = urls.map(url => url.replace(/\/$/, '')) // Remove trailing slashes
|
|
19
|
+
this.#strategy = strategy
|
|
20
|
+
this.#sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
|
21
|
+
|
|
22
|
+
// Virtual node replicas for affinity strategy (default: 150)
|
|
23
|
+
this.#affinityHashRing = options.affinityHashRing || 150
|
|
24
|
+
|
|
25
|
+
// Health retry interval (default: 30 seconds)
|
|
26
|
+
this.#healthRetryAfterMillis = options.healthRetryAfterMillis || 30000
|
|
27
|
+
|
|
28
|
+
// Initialize health status with last failure time
|
|
29
|
+
this.#healthStatus = new Map(this.#urls.map(url => [url, {
|
|
30
|
+
healthy: true,
|
|
31
|
+
failures: 0,
|
|
32
|
+
lastFailure: null
|
|
33
|
+
}]))
|
|
34
|
+
|
|
35
|
+
// Build virtual node ring for affinity strategy
|
|
36
|
+
if (strategy === 'affinity') {
|
|
37
|
+
this.#buildVirtualNodeRing()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build virtual node ring for consistent hashing
|
|
43
|
+
* Each real server gets multiple virtual positions in the hash space
|
|
44
|
+
*/
|
|
45
|
+
#buildVirtualNodeRing() {
|
|
46
|
+
this.#virtualNodes = []
|
|
47
|
+
|
|
48
|
+
for (const url of this.#urls) {
|
|
49
|
+
for (let i = 0; i < this.#affinityHashRing; i++) {
|
|
50
|
+
// Create virtual node identifier
|
|
51
|
+
const vnodeKey = `${url}#vnode${i}`
|
|
52
|
+
const hash = this.#hashString(vnodeKey)
|
|
53
|
+
|
|
54
|
+
this.#virtualNodes.push({
|
|
55
|
+
hash,
|
|
56
|
+
realServer: url
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Sort by hash value to create the ring
|
|
62
|
+
this.#virtualNodes.sort((a, b) => a.hash - b.hash)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Rebuild virtual node ring with specified URLs
|
|
67
|
+
* @param {Array<string>} urls - URLs to include in the ring (if not provided, uses all healthy URLs)
|
|
68
|
+
*/
|
|
69
|
+
#rebuildVirtualNodeRing(urls = null) {
|
|
70
|
+
const urlsToInclude = urls || this.#urls.filter(url => this.#healthStatus.get(url)?.healthy !== false)
|
|
71
|
+
|
|
72
|
+
this.#virtualNodes = []
|
|
73
|
+
|
|
74
|
+
for (const url of urlsToInclude) {
|
|
75
|
+
for (let i = 0; i < this.#affinityHashRing; i++) {
|
|
76
|
+
const vnodeKey = `${url}#vnode${i}`
|
|
77
|
+
const hash = this.#hashString(vnodeKey)
|
|
78
|
+
|
|
79
|
+
this.#virtualNodes.push({
|
|
80
|
+
hash,
|
|
81
|
+
realServer: url
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.#virtualNodes.sort((a, b) => a.hash - b.hash)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getNextUrl(sessionKey = null) {
|
|
90
|
+
const key = sessionKey || this.#sessionId
|
|
91
|
+
|
|
92
|
+
// Get healthy URLs + unhealthy URLs that are ready for retry
|
|
93
|
+
const now = Date.now()
|
|
94
|
+
let needRingRebuild = false
|
|
95
|
+
|
|
96
|
+
const healthyUrls = this.#urls.filter(url => {
|
|
97
|
+
const status = this.#healthStatus.get(url)
|
|
98
|
+
if (status.healthy) return true
|
|
99
|
+
|
|
100
|
+
// Allow retry of unhealthy backends after configured interval
|
|
101
|
+
if (status.lastFailure && (now - status.lastFailure) >= this.#healthRetryAfterMillis) {
|
|
102
|
+
needRingRebuild = true // Need to rebuild ring to include this server's vnodes
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return false
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Rebuild vnode ring if we have servers eligible for retry
|
|
110
|
+
// Pass healthyUrls so it includes servers ready for retry
|
|
111
|
+
if (needRingRebuild && this.#virtualNodes) {
|
|
112
|
+
this.#rebuildVirtualNodeRing(healthyUrls)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (healthyUrls.length === 0) {
|
|
116
|
+
// All backends unhealthy, try any backend as fallback
|
|
117
|
+
return this.#urls[this.#currentIndex % this.#urls.length]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (this.#strategy === 'affinity') {
|
|
121
|
+
// Virtual node consistent hashing
|
|
122
|
+
return this.#getAffinityUrl(key, healthyUrls)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (this.#strategy === 'session') {
|
|
126
|
+
// Session affinity: stick to the same server per session
|
|
127
|
+
if (!this.#sessionMap.has(key)) {
|
|
128
|
+
const assignedIndex = this.#currentIndex
|
|
129
|
+
this.#sessionMap.set(key, assignedIndex)
|
|
130
|
+
this.#currentIndex = (this.#currentIndex + 1) % healthyUrls.length
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const assignedUrl = this.#urls[this.#sessionMap.get(key)]
|
|
134
|
+
// If assigned URL is unhealthy, reassign
|
|
135
|
+
if (!healthyUrls.includes(assignedUrl)) {
|
|
136
|
+
const newIndex = this.#currentIndex % healthyUrls.length
|
|
137
|
+
this.#sessionMap.set(key, newIndex)
|
|
138
|
+
this.#currentIndex = (this.#currentIndex + 1) % healthyUrls.length
|
|
139
|
+
return healthyUrls[newIndex]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return assignedUrl
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Round robin: cycle through healthy URLs only
|
|
146
|
+
const url = healthyUrls[this.#currentIndex % healthyUrls.length]
|
|
147
|
+
this.#currentIndex = (this.#currentIndex + 1) % healthyUrls.length
|
|
148
|
+
return url
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Affinity-based routing using consistent hashing with virtual nodes
|
|
153
|
+
* Same key always routes to same backend (unless unhealthy)
|
|
154
|
+
*/
|
|
155
|
+
#getAffinityUrl(key, healthyUrls) {
|
|
156
|
+
if (!key || !this.#virtualNodes || this.#virtualNodes.length === 0) {
|
|
157
|
+
// No key or no vnodes, fall back to round-robin
|
|
158
|
+
return healthyUrls[this.#currentIndex++ % healthyUrls.length]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Hash the consumer group key
|
|
162
|
+
const keyHash = this.#hashString(key)
|
|
163
|
+
|
|
164
|
+
// Binary search to find the first vnode >= keyHash (clockwise on ring)
|
|
165
|
+
let left = 0
|
|
166
|
+
let right = this.#virtualNodes.length - 1
|
|
167
|
+
let result = 0
|
|
168
|
+
|
|
169
|
+
while (left <= right) {
|
|
170
|
+
const mid = Math.floor((left + right) / 2)
|
|
171
|
+
if (this.#virtualNodes[mid].hash >= keyHash) {
|
|
172
|
+
result = mid
|
|
173
|
+
right = mid - 1
|
|
174
|
+
} else {
|
|
175
|
+
left = mid + 1
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Walk forward on the ring to find first healthy server
|
|
180
|
+
const startIdx = result
|
|
181
|
+
for (let i = 0; i < this.#virtualNodes.length; i++) {
|
|
182
|
+
const idx = (startIdx + i) % this.#virtualNodes.length
|
|
183
|
+
const vnode = this.#virtualNodes[idx]
|
|
184
|
+
|
|
185
|
+
if (healthyUrls.includes(vnode.realServer)) {
|
|
186
|
+
return vnode.realServer
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Fallback (should never happen if healthyUrls is not empty)
|
|
191
|
+
return healthyUrls[0]
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* FNV-1a hash function for consistent hashing
|
|
196
|
+
*/
|
|
197
|
+
#hashString(str) {
|
|
198
|
+
let hash = 2166136261 // FNV offset basis
|
|
199
|
+
for (let i = 0; i < str.length; i++) {
|
|
200
|
+
hash ^= str.charCodeAt(i)
|
|
201
|
+
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)
|
|
202
|
+
}
|
|
203
|
+
return hash >>> 0 // Convert to unsigned 32-bit integer
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Mark a backend as unhealthy after failure
|
|
208
|
+
*/
|
|
209
|
+
markUnhealthy(url) {
|
|
210
|
+
const status = this.#healthStatus.get(url)
|
|
211
|
+
if (status) {
|
|
212
|
+
status.healthy = false
|
|
213
|
+
status.failures++
|
|
214
|
+
status.lastFailure = Date.now()
|
|
215
|
+
|
|
216
|
+
const retryInSeconds = (this.#healthRetryAfterMillis / 1000).toFixed(0)
|
|
217
|
+
|
|
218
|
+
// Rebuild vnode ring to exclude unhealthy server
|
|
219
|
+
if (this.#virtualNodes) {
|
|
220
|
+
this.#rebuildVirtualNodeRing()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Mark a backend as healthy after success
|
|
227
|
+
*/
|
|
228
|
+
markHealthy(url) {
|
|
229
|
+
const status = this.#healthStatus.get(url)
|
|
230
|
+
if (!status) return
|
|
231
|
+
|
|
232
|
+
const wasUnhealthy = status.healthy === false
|
|
233
|
+
|
|
234
|
+
status.healthy = true
|
|
235
|
+
status.failures = 0
|
|
236
|
+
status.lastFailure = null
|
|
237
|
+
|
|
238
|
+
// Rebuild vnode ring to include recovered server
|
|
239
|
+
if (this.#virtualNodes && wasUnhealthy) {
|
|
240
|
+
this.#rebuildVirtualNodeRing()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get health status of all backends
|
|
246
|
+
*/
|
|
247
|
+
getHealthStatus() {
|
|
248
|
+
return new Map(this.#healthStatus)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
getAllUrls() {
|
|
252
|
+
return [...this.#urls]
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
getStrategy() {
|
|
256
|
+
return this.#strategy
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Get number of virtual nodes in the ring
|
|
261
|
+
*/
|
|
262
|
+
getVirtualNodeCount() {
|
|
263
|
+
return this.#virtualNodes ? this.#virtualNodes.length : 0
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
reset() {
|
|
267
|
+
this.#currentIndex = 0
|
|
268
|
+
this.#sessionMap.clear()
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
@@ -10,8 +10,10 @@ export const CLIENT_DEFAULTS = {
|
|
|
10
10
|
timeoutMillis: 30000, // 30 seconds
|
|
11
11
|
retryAttempts: 3, // 3 retry attempts
|
|
12
12
|
retryDelayMillis: 1000, // 1 second initial delay (exponential backoff)
|
|
13
|
-
loadBalancingStrategy: '
|
|
14
|
-
|
|
13
|
+
loadBalancingStrategy: 'affinity', // 'round-robin', 'session', or 'affinity'
|
|
14
|
+
affinityHashRing: 128, // Number of virtual nodes per server for affinity strategy
|
|
15
|
+
enableFailover: true, // Auto-failover to other servers
|
|
16
|
+
healthRetryAfterMillis: 5000 // Retry unhealthy backends after 5 seconds
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export const QUEUE_DEFAULTS = {
|
package/package.json
CHANGED
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "queen-mq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "High-performance C++ message queue backed by PostgreSQL",
|
|
6
|
-
"main": "client-
|
|
6
|
+
"main": "client-v2/index.js",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"
|
|
9
|
-
"build:webapp": "cd webapp && npm install && npm run build",
|
|
10
|
-
"publish:public": "npm publish --access public",
|
|
11
|
-
"test": "node client-js/test-v2/run.js human"
|
|
8
|
+
"test": "node test-v2/run.js human"
|
|
12
9
|
},
|
|
13
10
|
"files": [
|
|
14
|
-
"client-
|
|
15
|
-
"
|
|
11
|
+
"client-v2/**/*",
|
|
12
|
+
"test-v2/**/*",
|
|
16
13
|
"README.md"
|
|
17
14
|
],
|
|
18
15
|
"dependencies": {
|
|
19
16
|
"axios": "^1.12.2",
|
|
17
|
+
"pg": "^8.16.3",
|
|
20
18
|
"uuid": "^13.0.0"
|
|
21
19
|
},
|
|
22
20
|
"engines": {
|
|
@@ -63,7 +63,7 @@ export async function test_maintenance_mode(client = null) {
|
|
|
63
63
|
const consumePromise = (async () => {
|
|
64
64
|
while (consumerActive) {
|
|
65
65
|
try {
|
|
66
|
-
const messages = await queen.queue(QUEUE_NAME).batch(10).wait(
|
|
66
|
+
const messages = await queen.queue(QUEUE_NAME).batch(10).wait(false).limit(1).each().pop();
|
|
67
67
|
|
|
68
68
|
if (messages && messages.length > 0) {
|
|
69
69
|
receivedCount += messages.length;
|