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 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/docs/ARCHITECTURE.md)** - Deep dive into internals
617
+ - **[Architecture Guide](https://github.com/smartpricing/queen/blob/master/documentation/ARCHITECTURE.md)** - Deep dive into internals
618
618
 
619
619
  ---
620
620
 
@@ -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
  // ===========================
@@ -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', // or 'session'
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
+
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Admin module exports
3
+ */
4
+
5
+ export { Admin } from './Admin.js'
6
+
@@ -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
- const result = await this.#httpClient.get(`${path}?${params}`, this.#timeoutMillis + 5000)
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) {