queen-mq 0.6.4 → 0.12.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 +1 -1
- package/client-v2/Queen.js +31 -4
- package/client-v2/README.md +53 -2
- package/client-v2/admin/Admin.js +414 -0
- package/client-v2/admin/index.js +6 -0
- package/client-v2/builders/QueueBuilder.js +24 -1
- package/client-v2/consumer/ConsumerManager.js +25 -4
- package/client-v2/http/HttpClient.js +36 -15
- package/client-v2/http/LoadBalancer.js +227 -6
- package/client-v2/index.js +1 -0
- package/client-v2/utils/defaults.js +5 -2
- package/client-v2/utils/logger.js +13 -2
- package/package.json +3 -2
- package/test-v2/consume.js +6 -57
- package/test-v2/load.js +22 -20
- package/test-v2/maintenance.js +3 -1
- package/test-v2/push.js +0 -29
- package/test-v2/run.js +24 -3
package/README.md
CHANGED
|
@@ -614,7 +614,7 @@ const messages: Message<OrderData>[] = await queen.queue('orders').pop()
|
|
|
614
614
|
- **[Complete V2 Guide](client-v2/README.md)** - Full tutorial with all features (94 test examples)
|
|
615
615
|
- **[HTTP API Reference](https://github.com/smartpricing/queen/blob/master/server/API.md)** - Raw HTTP endpoints
|
|
616
616
|
- **[Server Guide](https://github.com/smartpricing/queen/blob/master/server/README.md)** - Server setup and configuration
|
|
617
|
-
- **[Architecture Guide](https://github.com/smartpricing/queen/blob/master/
|
|
617
|
+
- **[Architecture Guide](https://github.com/smartpricing/queen/blob/master/documentation/ARCHITECTURE.md)** - Deep dive into internals
|
|
618
618
|
|
|
619
619
|
---
|
|
620
620
|
|
package/client-v2/Queen.js
CHANGED
|
@@ -10,6 +10,7 @@ import { QueueBuilder } from './builders/QueueBuilder.js'
|
|
|
10
10
|
import { TransactionBuilder } from './builders/TransactionBuilder.js'
|
|
11
11
|
import { StreamBuilder } from './stream/StreamBuilder.js'
|
|
12
12
|
import { StreamConsumer } from './stream/StreamConsumer.js'
|
|
13
|
+
import { Admin } from './admin/Admin.js'
|
|
13
14
|
import { CLIENT_DEFAULTS } from './utils/defaults.js'
|
|
14
15
|
import { validateUrl, validateUrls } from './utils/validation.js'
|
|
15
16
|
import * as logger from './utils/logger.js'
|
|
@@ -19,6 +20,7 @@ export class Queen {
|
|
|
19
20
|
#bufferManager
|
|
20
21
|
#config
|
|
21
22
|
#shutdownHandlers = []
|
|
23
|
+
#admin = null
|
|
22
24
|
|
|
23
25
|
constructor(config = {}) {
|
|
24
26
|
logger.log('Queen.constructor', { config: typeof config === 'object' && !Array.isArray(config) ? { ...config, urls: config.urls?.length || 0 } : { type: typeof config } })
|
|
@@ -75,7 +77,7 @@ export class Queen {
|
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
#createHttpClient() {
|
|
78
|
-
const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, enableFailover } = this.#config
|
|
80
|
+
const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken } = this.#config
|
|
79
81
|
|
|
80
82
|
if (urls.length === 1) {
|
|
81
83
|
// Single server
|
|
@@ -83,22 +85,32 @@ export class Queen {
|
|
|
83
85
|
baseUrl: urls[0],
|
|
84
86
|
timeoutMillis,
|
|
85
87
|
retryAttempts,
|
|
86
|
-
retryDelayMillis
|
|
88
|
+
retryDelayMillis,
|
|
89
|
+
bearerToken
|
|
87
90
|
})
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
// Multiple servers with load balancing
|
|
91
|
-
const loadBalancer = new LoadBalancer(urls, loadBalancingStrategy
|
|
94
|
+
const loadBalancer = new LoadBalancer(urls, loadBalancingStrategy, {
|
|
95
|
+
affinityHashRing,
|
|
96
|
+
healthRetryAfterMillis
|
|
97
|
+
})
|
|
92
98
|
return new HttpClient({
|
|
93
99
|
loadBalancer,
|
|
94
100
|
timeoutMillis,
|
|
95
101
|
retryAttempts,
|
|
96
102
|
retryDelayMillis,
|
|
97
|
-
enableFailover
|
|
103
|
+
enableFailover,
|
|
104
|
+
bearerToken
|
|
98
105
|
})
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
#setupGracefulShutdown() {
|
|
109
|
+
// Skip signal handlers in browser environment
|
|
110
|
+
if (typeof process === 'undefined' || typeof process.on !== 'function') {
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
102
114
|
let signalReceivedCount = 0;
|
|
103
115
|
const shutdown = async (signal) => {
|
|
104
116
|
console.log(`\nReceived ${signal}, shutting down gracefully...`)
|
|
@@ -137,6 +149,21 @@ export class Queen {
|
|
|
137
149
|
return new QueueBuilder(this, this.#httpClient, this.#bufferManager, name)
|
|
138
150
|
}
|
|
139
151
|
|
|
152
|
+
// ===========================
|
|
153
|
+
// Admin API Entry Point
|
|
154
|
+
// ===========================
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get the Admin API for administrative and observability operations
|
|
158
|
+
* @returns {Admin} Admin API instance (lazily initialized, singleton)
|
|
159
|
+
*/
|
|
160
|
+
get admin() {
|
|
161
|
+
if (!this.#admin) {
|
|
162
|
+
this.#admin = new Admin(this.#httpClient)
|
|
163
|
+
}
|
|
164
|
+
return this.#admin
|
|
165
|
+
}
|
|
166
|
+
|
|
140
167
|
// ===========================
|
|
141
168
|
// Transaction API
|
|
142
169
|
// ===========================
|
package/client-v2/README.md
CHANGED
|
@@ -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!
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin API for Queen - Administrative and observability endpoints
|
|
3
|
+
* These APIs are typically used by dashboards and admin tools, not regular applications
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as logger from '../utils/logger.js'
|
|
7
|
+
|
|
8
|
+
export class Admin {
|
|
9
|
+
#httpClient
|
|
10
|
+
|
|
11
|
+
constructor(httpClient) {
|
|
12
|
+
this.#httpClient = httpClient
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ===========================
|
|
16
|
+
// Resources API
|
|
17
|
+
// ===========================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get system overview with queue counts, message stats, etc.
|
|
21
|
+
* @returns {Promise<object>}
|
|
22
|
+
*/
|
|
23
|
+
async getOverview() {
|
|
24
|
+
logger.log('Admin.getOverview', {})
|
|
25
|
+
return this.#httpClient.get('/api/v1/resources/overview')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get all namespaces
|
|
30
|
+
* @returns {Promise<object>}
|
|
31
|
+
*/
|
|
32
|
+
async getNamespaces() {
|
|
33
|
+
logger.log('Admin.getNamespaces', {})
|
|
34
|
+
return this.#httpClient.get('/api/v1/resources/namespaces')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get all tasks
|
|
39
|
+
* @returns {Promise<object>}
|
|
40
|
+
*/
|
|
41
|
+
async getTasks() {
|
|
42
|
+
logger.log('Admin.getTasks', {})
|
|
43
|
+
return this.#httpClient.get('/api/v1/resources/tasks')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ===========================
|
|
47
|
+
// Queues API
|
|
48
|
+
// ===========================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* List all queues
|
|
52
|
+
* @param {object} params - Query parameters (limit, offset, search, etc.)
|
|
53
|
+
* @returns {Promise<object>}
|
|
54
|
+
*/
|
|
55
|
+
async listQueues(params = {}) {
|
|
56
|
+
logger.log('Admin.listQueues', { params })
|
|
57
|
+
const queryString = this.#buildQueryString(params)
|
|
58
|
+
return this.#httpClient.get(`/api/v1/resources/queues${queryString}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get queue details
|
|
63
|
+
* @param {string} name - Queue name
|
|
64
|
+
* @returns {Promise<object>}
|
|
65
|
+
*/
|
|
66
|
+
async getQueue(name) {
|
|
67
|
+
logger.log('Admin.getQueue', { name })
|
|
68
|
+
return this.#httpClient.get(`/api/v1/resources/queues/${encodeURIComponent(name)}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Clear all messages from a queue
|
|
73
|
+
* @param {string} name - Queue name
|
|
74
|
+
* @param {string} [partition] - Optional partition to clear
|
|
75
|
+
* @returns {Promise<object>}
|
|
76
|
+
*/
|
|
77
|
+
async clearQueue(name, partition = null) {
|
|
78
|
+
logger.log('Admin.clearQueue', { name, partition })
|
|
79
|
+
const queryString = partition ? `?partition=${encodeURIComponent(partition)}` : ''
|
|
80
|
+
return this.#httpClient.delete(`/api/v1/queues/${encodeURIComponent(name)}/clear${queryString}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get all partitions
|
|
85
|
+
* @param {object} params - Query parameters (queue, limit, etc.)
|
|
86
|
+
* @returns {Promise<object>}
|
|
87
|
+
*/
|
|
88
|
+
async getPartitions(params = {}) {
|
|
89
|
+
logger.log('Admin.getPartitions', { params })
|
|
90
|
+
const queryString = this.#buildQueryString(params)
|
|
91
|
+
return this.#httpClient.get(`/api/v1/resources/partitions${queryString}`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ===========================
|
|
95
|
+
// Messages API
|
|
96
|
+
// ===========================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* List messages with filters
|
|
100
|
+
* @param {object} params - Query parameters (queue, partition, status, limit, offset, etc.)
|
|
101
|
+
* @returns {Promise<object>}
|
|
102
|
+
*/
|
|
103
|
+
async listMessages(params = {}) {
|
|
104
|
+
logger.log('Admin.listMessages', { params })
|
|
105
|
+
const queryString = this.#buildQueryString(params)
|
|
106
|
+
return this.#httpClient.get(`/api/v1/messages${queryString}`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get a specific message
|
|
111
|
+
* @param {string} partitionId - Partition ID
|
|
112
|
+
* @param {string} transactionId - Transaction ID
|
|
113
|
+
* @returns {Promise<object>}
|
|
114
|
+
*/
|
|
115
|
+
async getMessage(partitionId, transactionId) {
|
|
116
|
+
logger.log('Admin.getMessage', { partitionId, transactionId })
|
|
117
|
+
return this.#httpClient.get(`/api/v1/messages/${partitionId}/${transactionId}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Delete a specific message
|
|
122
|
+
* @param {string} partitionId - Partition ID
|
|
123
|
+
* @param {string} transactionId - Transaction ID
|
|
124
|
+
* @returns {Promise<object>}
|
|
125
|
+
*/
|
|
126
|
+
async deleteMessage(partitionId, transactionId) {
|
|
127
|
+
logger.log('Admin.deleteMessage', { partitionId, transactionId })
|
|
128
|
+
return this.#httpClient.delete(`/api/v1/messages/${partitionId}/${transactionId}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Retry a failed message
|
|
133
|
+
* @param {string} partitionId - Partition ID
|
|
134
|
+
* @param {string} transactionId - Transaction ID
|
|
135
|
+
* @returns {Promise<object>}
|
|
136
|
+
*/
|
|
137
|
+
async retryMessage(partitionId, transactionId) {
|
|
138
|
+
logger.log('Admin.retryMessage', { partitionId, transactionId })
|
|
139
|
+
return this.#httpClient.post(`/api/v1/messages/${partitionId}/${transactionId}/retry`, {})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Move a message to the Dead Letter Queue
|
|
144
|
+
* @param {string} partitionId - Partition ID
|
|
145
|
+
* @param {string} transactionId - Transaction ID
|
|
146
|
+
* @returns {Promise<object>}
|
|
147
|
+
*/
|
|
148
|
+
async moveMessageToDLQ(partitionId, transactionId) {
|
|
149
|
+
logger.log('Admin.moveMessageToDLQ', { partitionId, transactionId })
|
|
150
|
+
return this.#httpClient.post(`/api/v1/messages/${partitionId}/${transactionId}/dlq`, {})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ===========================
|
|
154
|
+
// Traces API
|
|
155
|
+
// ===========================
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get traces by name
|
|
159
|
+
* @param {string} traceName - Trace name to search for
|
|
160
|
+
* @param {object} params - Query parameters (limit, offset, from, to, etc.)
|
|
161
|
+
* @returns {Promise<object>}
|
|
162
|
+
*/
|
|
163
|
+
async getTracesByName(traceName, params = {}) {
|
|
164
|
+
logger.log('Admin.getTracesByName', { traceName, params })
|
|
165
|
+
const queryString = this.#buildQueryString(params)
|
|
166
|
+
return this.#httpClient.get(`/api/v1/traces/by-name/${encodeURIComponent(traceName)}${queryString}`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get available trace names
|
|
171
|
+
* @param {object} params - Query parameters (limit, search, etc.)
|
|
172
|
+
* @returns {Promise<object>}
|
|
173
|
+
*/
|
|
174
|
+
async getTraceNames(params = {}) {
|
|
175
|
+
logger.log('Admin.getTraceNames', { params })
|
|
176
|
+
const queryString = this.#buildQueryString(params)
|
|
177
|
+
return this.#httpClient.get(`/api/v1/traces/names${queryString}`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get traces for a specific message
|
|
182
|
+
* @param {string} partitionId - Partition ID
|
|
183
|
+
* @param {string} transactionId - Transaction ID
|
|
184
|
+
* @returns {Promise<object>}
|
|
185
|
+
*/
|
|
186
|
+
async getTracesForMessage(partitionId, transactionId) {
|
|
187
|
+
logger.log('Admin.getTracesForMessage', { partitionId, transactionId })
|
|
188
|
+
return this.#httpClient.get(`/api/v1/traces/${partitionId}/${transactionId}`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ===========================
|
|
192
|
+
// Analytics/Status API
|
|
193
|
+
// ===========================
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get system status
|
|
197
|
+
* @param {object} params - Query parameters
|
|
198
|
+
* @returns {Promise<object>}
|
|
199
|
+
*/
|
|
200
|
+
async getStatus(params = {}) {
|
|
201
|
+
logger.log('Admin.getStatus', { params })
|
|
202
|
+
const queryString = this.#buildQueryString(params)
|
|
203
|
+
return this.#httpClient.get(`/api/v1/status${queryString}`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get queue statistics
|
|
208
|
+
* @param {object} params - Query parameters (limit, offset, etc.)
|
|
209
|
+
* @returns {Promise<object>}
|
|
210
|
+
*/
|
|
211
|
+
async getQueueStats(params = {}) {
|
|
212
|
+
logger.log('Admin.getQueueStats', { params })
|
|
213
|
+
const queryString = this.#buildQueryString(params)
|
|
214
|
+
return this.#httpClient.get(`/api/v1/status/queues${queryString}`)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get detailed statistics for a specific queue
|
|
219
|
+
* @param {string} name - Queue name
|
|
220
|
+
* @param {object} params - Query parameters
|
|
221
|
+
* @returns {Promise<object>}
|
|
222
|
+
*/
|
|
223
|
+
async getQueueDetail(name, params = {}) {
|
|
224
|
+
logger.log('Admin.getQueueDetail', { name, params })
|
|
225
|
+
const queryString = this.#buildQueryString(params)
|
|
226
|
+
return this.#httpClient.get(`/api/v1/status/queues/${encodeURIComponent(name)}${queryString}`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get analytics data
|
|
231
|
+
* @param {object} params - Query parameters (from, to, interval, etc.)
|
|
232
|
+
* @returns {Promise<object>}
|
|
233
|
+
*/
|
|
234
|
+
async getAnalytics(params = {}) {
|
|
235
|
+
logger.log('Admin.getAnalytics', { params })
|
|
236
|
+
const queryString = this.#buildQueryString(params)
|
|
237
|
+
return this.#httpClient.get(`/api/v1/status/analytics${queryString}`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ===========================
|
|
241
|
+
// Consumer Groups API
|
|
242
|
+
// ===========================
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* List all consumer groups
|
|
246
|
+
* @returns {Promise<object>}
|
|
247
|
+
*/
|
|
248
|
+
async listConsumerGroups() {
|
|
249
|
+
logger.log('Admin.listConsumerGroups', {})
|
|
250
|
+
return this.#httpClient.get('/api/v1/consumer-groups')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Refresh consumer group statistics
|
|
255
|
+
* @returns {Promise<object>}
|
|
256
|
+
*/
|
|
257
|
+
async refreshConsumerStats() {
|
|
258
|
+
logger.log('Admin.refreshConsumerStats', {})
|
|
259
|
+
return this.#httpClient.post('/api/v1/stats/refresh', {})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get consumer group details
|
|
264
|
+
* @param {string} name - Consumer group name
|
|
265
|
+
* @returns {Promise<object>}
|
|
266
|
+
*/
|
|
267
|
+
async getConsumerGroup(name) {
|
|
268
|
+
logger.log('Admin.getConsumerGroup', { name })
|
|
269
|
+
return this.#httpClient.get(`/api/v1/consumer-groups/${encodeURIComponent(name)}`)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get lagging consumer groups
|
|
274
|
+
* @param {number} [minLagSeconds=60] - Minimum lag in seconds to be considered lagging
|
|
275
|
+
* @returns {Promise<object>}
|
|
276
|
+
*/
|
|
277
|
+
async getLaggingConsumers(minLagSeconds = 60) {
|
|
278
|
+
logger.log('Admin.getLaggingConsumers', { minLagSeconds })
|
|
279
|
+
return this.#httpClient.get(`/api/v1/consumer-groups/lagging?minLagSeconds=${minLagSeconds}`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Delete a consumer group for a specific queue
|
|
284
|
+
* @param {string} consumerGroup - Consumer group name
|
|
285
|
+
* @param {string} queueName - Queue name
|
|
286
|
+
* @param {boolean} [deleteMetadata=true] - Whether to delete subscription metadata
|
|
287
|
+
* @returns {Promise<object>}
|
|
288
|
+
*/
|
|
289
|
+
async deleteConsumerGroupForQueue(consumerGroup, queueName, deleteMetadata = true) {
|
|
290
|
+
logger.log('Admin.deleteConsumerGroupForQueue', { consumerGroup, queueName, deleteMetadata })
|
|
291
|
+
const url = `/api/v1/consumer-groups/${encodeURIComponent(consumerGroup)}/queues/${encodeURIComponent(queueName)}?deleteMetadata=${deleteMetadata}`
|
|
292
|
+
return this.#httpClient.delete(url)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Seek consumer group offset for a queue
|
|
297
|
+
* @param {string} consumerGroup - Consumer group name
|
|
298
|
+
* @param {string} queueName - Queue name
|
|
299
|
+
* @param {object} options - Seek options (timestamp, position, etc.)
|
|
300
|
+
* @returns {Promise<object>}
|
|
301
|
+
*/
|
|
302
|
+
async seekConsumerGroup(consumerGroup, queueName, options = {}) {
|
|
303
|
+
logger.log('Admin.seekConsumerGroup', { consumerGroup, queueName, options })
|
|
304
|
+
const url = `/api/v1/consumer-groups/${encodeURIComponent(consumerGroup)}/queues/${encodeURIComponent(queueName)}/seek`
|
|
305
|
+
return this.#httpClient.post(url, options)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ===========================
|
|
309
|
+
// System API
|
|
310
|
+
// ===========================
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Health check
|
|
314
|
+
* @returns {Promise<object>}
|
|
315
|
+
*/
|
|
316
|
+
async health() {
|
|
317
|
+
logger.log('Admin.health', {})
|
|
318
|
+
return this.#httpClient.get('/health')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Get Prometheus metrics
|
|
323
|
+
* @returns {Promise<string>} Raw metrics text
|
|
324
|
+
*/
|
|
325
|
+
async metrics() {
|
|
326
|
+
logger.log('Admin.metrics', {})
|
|
327
|
+
return this.#httpClient.get('/metrics')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get push maintenance mode status
|
|
332
|
+
* @returns {Promise<object>}
|
|
333
|
+
*/
|
|
334
|
+
async getMaintenanceMode() {
|
|
335
|
+
logger.log('Admin.getMaintenanceMode', {})
|
|
336
|
+
return this.#httpClient.get('/api/v1/system/maintenance')
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Set push maintenance mode
|
|
341
|
+
* @param {boolean} enabled - Enable or disable maintenance mode
|
|
342
|
+
* @returns {Promise<object>}
|
|
343
|
+
*/
|
|
344
|
+
async setMaintenanceMode(enabled) {
|
|
345
|
+
logger.log('Admin.setMaintenanceMode', { enabled })
|
|
346
|
+
return this.#httpClient.post('/api/v1/system/maintenance', { enabled })
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Get pop maintenance mode status
|
|
351
|
+
* @returns {Promise<object>}
|
|
352
|
+
*/
|
|
353
|
+
async getPopMaintenanceMode() {
|
|
354
|
+
logger.log('Admin.getPopMaintenanceMode', {})
|
|
355
|
+
return this.#httpClient.get('/api/v1/system/maintenance/pop')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Set pop maintenance mode
|
|
360
|
+
* @param {boolean} enabled - Enable or disable pop maintenance mode
|
|
361
|
+
* @returns {Promise<object>}
|
|
362
|
+
*/
|
|
363
|
+
async setPopMaintenanceMode(enabled) {
|
|
364
|
+
logger.log('Admin.setPopMaintenanceMode', { enabled })
|
|
365
|
+
return this.#httpClient.post('/api/v1/system/maintenance/pop', { enabled })
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Get system metrics (CPU, memory, connections, etc.)
|
|
370
|
+
* @param {object} params - Query parameters (from, to, etc.)
|
|
371
|
+
* @returns {Promise<object>}
|
|
372
|
+
*/
|
|
373
|
+
async getSystemMetrics(params = {}) {
|
|
374
|
+
logger.log('Admin.getSystemMetrics', { params })
|
|
375
|
+
const queryString = this.#buildQueryString(params)
|
|
376
|
+
return this.#httpClient.get(`/api/v1/analytics/system-metrics${queryString}`)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Get worker metrics
|
|
381
|
+
* @param {object} params - Query parameters
|
|
382
|
+
* @returns {Promise<object>}
|
|
383
|
+
*/
|
|
384
|
+
async getWorkerMetrics(params = {}) {
|
|
385
|
+
logger.log('Admin.getWorkerMetrics', { params })
|
|
386
|
+
const queryString = this.#buildQueryString(params)
|
|
387
|
+
return this.#httpClient.get(`/api/v1/analytics/worker-metrics${queryString}`)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Get PostgreSQL statistics
|
|
392
|
+
* @returns {Promise<object>}
|
|
393
|
+
*/
|
|
394
|
+
async getPostgresStats() {
|
|
395
|
+
logger.log('Admin.getPostgresStats', {})
|
|
396
|
+
return this.#httpClient.get('/api/v1/analytics/postgres-stats')
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ===========================
|
|
400
|
+
// Helper Methods
|
|
401
|
+
// ===========================
|
|
402
|
+
|
|
403
|
+
#buildQueryString(params) {
|
|
404
|
+
const searchParams = new URLSearchParams()
|
|
405
|
+
for (const [key, value] of Object.entries(params)) {
|
|
406
|
+
if (value !== undefined && value !== null) {
|
|
407
|
+
searchParams.append(key, String(value))
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const queryString = searchParams.toString()
|
|
411
|
+
return queryString ? `?${queryString}` : ''
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
@@ -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) {
|