queen-mq 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -77,7 +77,7 @@ export class Queen {
77
77
  }
78
78
 
79
79
  #createHttpClient() {
80
- const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken } = this.#config
80
+ const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken, headers } = this.#config
81
81
 
82
82
  if (urls.length === 1) {
83
83
  // Single server
@@ -86,7 +86,8 @@ export class Queen {
86
86
  timeoutMillis,
87
87
  retryAttempts,
88
88
  retryDelayMillis,
89
- bearerToken
89
+ bearerToken,
90
+ headers
90
91
  })
91
92
  }
92
93
 
@@ -101,7 +102,8 @@ export class Queen {
101
102
  retryAttempts,
102
103
  retryDelayMillis,
103
104
  enableFailover,
104
- bearerToken
105
+ bearerToken,
106
+ headers
105
107
  })
106
108
  }
107
109
 
@@ -13,10 +13,10 @@ export class TransactionBuilder {
13
13
  this.#httpClient = httpClient
14
14
  }
15
15
 
16
- ack(messages, status = 'completed') {
16
+ ack(messages, status = 'completed', context = {}) {
17
17
  const msgs = Array.isArray(messages) ? messages : [messages]
18
18
 
19
- logger.log('TransactionBuilder.ack', { count: msgs.length, status })
19
+ logger.log('TransactionBuilder.ack', { count: msgs.length, status, consumerGroup: context.consumerGroup })
20
20
 
21
21
  msgs.forEach(msg => {
22
22
  const transactionId = typeof msg === 'string' ? msg : (msg.transactionId || msg.id)
@@ -32,12 +32,19 @@ export class TransactionBuilder {
32
32
  throw new Error('Message must have partitionId property to ensure message uniqueness')
33
33
  }
34
34
 
35
- this.#operations.push({
35
+ const operation = {
36
36
  type: 'ack',
37
37
  transactionId,
38
38
  partitionId,
39
39
  status
40
- })
40
+ }
41
+
42
+ // Add consumerGroup if provided in context
43
+ if (context.consumerGroup) {
44
+ operation.consumerGroup = context.consumerGroup
45
+ }
46
+
47
+ this.#operations.push(operation)
41
48
 
42
49
  if (leaseId) {
43
50
  this.#requiredLeases.push(leaseId)
@@ -12,6 +12,7 @@ export class HttpClient {
12
12
  #retryDelayMillis
13
13
  #enableFailover
14
14
  #bearerToken
15
+ #headers
15
16
 
16
17
  constructor(options = {}) {
17
18
  const {
@@ -21,7 +22,8 @@ export class HttpClient {
21
22
  retryAttempts = 3,
22
23
  retryDelayMillis = 1000,
23
24
  enableFailover = true,
24
- bearerToken = null
25
+ bearerToken = null,
26
+ headers = {}
25
27
  } = options
26
28
 
27
29
  this.#baseUrl = baseUrl
@@ -31,6 +33,7 @@ export class HttpClient {
31
33
  this.#retryDelayMillis = retryDelayMillis
32
34
  this.#enableFailover = enableFailover
33
35
  this.#bearerToken = bearerToken
36
+ this.#headers = headers || {}
34
37
 
35
38
  logger.log('HttpClient.constructor', {
36
39
  hasLoadBalancer: !!loadBalancer,
@@ -38,7 +41,8 @@ export class HttpClient {
38
41
  timeoutMillis,
39
42
  retryAttempts,
40
43
  enableFailover,
41
- hasAuth: !!bearerToken
44
+ hasAuth: !!bearerToken,
45
+ customHeaders: Object.keys(this.#headers).length
42
46
  })
43
47
  }
44
48
 
@@ -54,6 +58,7 @@ export class HttpClient {
54
58
  if (this.#bearerToken) {
55
59
  headers['Authorization'] = `Bearer ${this.#bearerToken}`
56
60
  }
61
+ Object.assign(headers, this.#headers)
57
62
 
58
63
  const options = {
59
64
  method,
@@ -14,7 +14,8 @@ export const CLIENT_DEFAULTS = {
14
14
  affinityHashRing: 128, // Number of virtual nodes per server for affinity strategy
15
15
  enableFailover: true, // Auto-failover to other servers
16
16
  healthRetryAfterMillis: 5000, // Retry unhealthy backends after 5 seconds
17
- bearerToken: null // Bearer token for proxy authentication
17
+ bearerToken: null, // Bearer token for proxy authentication
18
+ headers: {} // Custom headers to include in every request
18
19
  }
19
20
 
20
21
  export const QUEUE_DEFAULTS = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "queen-mq",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "type": "module",
5
5
  "description": "High-performance C++ message queue backed by PostgreSQL",
6
6
  "main": "client-v2/index.js",
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Consumer Group Bootstrap Tests
3
+ *
4
+ * Tests the partition_consumers bootstrap optimization:
5
+ * When a new consumer group registers (via subscription mode), all existing
6
+ * partition_consumers rows should be pre-seeded in bulk, avoiding slow
7
+ * one-at-a-time discovery.
8
+ *
9
+ * Test scenarios:
10
+ * 1. testCgBootstrapNew: CG with subscriptionMode('new')
11
+ * 2. testCgBootstrapTimestamp: CG with subscriptionFrom(timestamp)
12
+ *
13
+ * Both push 5000 partitions, register a CG (triggering bootstrap), then
14
+ * consume new messages and verify all partitions were discovered fast.
15
+ *
16
+ * Run: node run.js testCgBootstrapNew
17
+ * node run.js testCgBootstrapTimestamp
18
+ */
19
+
20
+ const PARTITION_COUNT = 5000
21
+ const QUEUE_NAME_NEW = 'test-queue-v2-bootstrap-new'
22
+ const QUEUE_NAME_TS = 'test-queue-v2-bootstrap-ts'
23
+
24
+ async function pushToPartitions(client, queueName, partitionCount, prefix = '') {
25
+ const PARALLEL_BATCH = 50
26
+ let pushed = 0
27
+ for (let i = 0; i < partitionCount; i += PARALLEL_BATCH) {
28
+ const batch = []
29
+ for (let j = i; j < Math.min(i + PARALLEL_BATCH, partitionCount); j++) {
30
+ batch.push(
31
+ client
32
+ .queue(queueName)
33
+ .partition(`p-${j}`)
34
+ .push([{ data: { id: j, prefix } }])
35
+ )
36
+ }
37
+ await Promise.all(batch)
38
+ pushed += batch.length
39
+ }
40
+ return pushed
41
+ }
42
+
43
+ /**
44
+ * Test bootstrap with subscriptionMode('new')
45
+ * - Push historical messages to 5000 partitions
46
+ * - Register CG with mode 'new' (triggers bootstrap)
47
+ * - Push new messages to all partitions
48
+ * - Consume with CG and verify all new messages are received fast
49
+ */
50
+ export async function testCgBootstrapNew(client) {
51
+ // Cleanup
52
+ try {
53
+ await client.deleteConsumerGroup('bootstrap-cg-new', true)
54
+ } catch (e) {
55
+ // Ignore
56
+ }
57
+
58
+ // 1. Create queue
59
+ const queue = await client.queue(QUEUE_NAME_NEW).create()
60
+ if (!queue.configured) {
61
+ return { success: false, message: 'Queue not created' }
62
+ }
63
+
64
+ // 2. Push 1 message to each of 5000 partitions (historical messages)
65
+ console.log(` Pushing to ${PARTITION_COUNT} partitions...`)
66
+ const pushStart = Date.now()
67
+ await pushToPartitions(client, QUEUE_NAME_NEW, PARTITION_COUNT, 'historical')
68
+ const pushTime = Date.now() - pushStart
69
+ console.log(` Push complete: ${PARTITION_COUNT} partitions in ${pushTime}ms`)
70
+
71
+ // 3. Consume all messages in queue mode (no CG) - baseline
72
+ console.log(` Consuming in queue mode (no CG)...`)
73
+ const queueModeStart = Date.now()
74
+ let queueModeCount = 0
75
+
76
+ await client
77
+ .queue(QUEUE_NAME_NEW)
78
+ .concurrency(10)
79
+ .batch(1)
80
+ .wait(false)
81
+ .idleMillis(5000)
82
+ .consume(async msgs => {
83
+ queueModeCount += msgs.length
84
+ })
85
+
86
+ const queueModeTime = Date.now() - queueModeStart
87
+ console.log(` Queue mode: consumed ${queueModeCount} messages in ${queueModeTime}ms`)
88
+
89
+ if (queueModeCount !== PARTITION_COUNT) {
90
+ return {
91
+ success: false,
92
+ message: `Queue mode consumed ${queueModeCount}/${PARTITION_COUNT} messages in ${queueModeTime}ms`
93
+ }
94
+ }
95
+
96
+ // 4. Register a new CG with 'new' mode
97
+ // The first pop triggers metadata registration + bootstrap (seeds all partition_consumers)
98
+ console.log(` Registering CG 'bootstrap-cg-new' with mode 'new'...`)
99
+ const registerStart = Date.now()
100
+
101
+ const firstPop = await client
102
+ .queue(QUEUE_NAME_NEW)
103
+ .group('bootstrap-cg-new')
104
+ .subscriptionMode('new')
105
+ .batch(1)
106
+ .wait(false)
107
+ .pop()
108
+
109
+ const registerTime = Date.now() - registerStart
110
+ console.log(` CG registered + bootstrap in ${registerTime}ms (first pop returned ${firstPop.length} msgs)`)
111
+
112
+ // First pop should return 0 messages (mode 'new' skips historical)
113
+ if (firstPop.length !== 0) {
114
+ return {
115
+ success: false,
116
+ message: `CG with mode 'new' should skip historical messages, got ${firstPop.length}`
117
+ }
118
+ }
119
+
120
+ // 5. Push NEW messages to all 5000 partitions
121
+ console.log(` Pushing new messages to ${PARTITION_COUNT} partitions...`)
122
+ const pushNewStart = Date.now()
123
+ await pushToPartitions(client, QUEUE_NAME_NEW, PARTITION_COUNT, 'new')
124
+ const pushNewTime = Date.now() - pushNewStart
125
+ console.log(` Push new complete: ${PARTITION_COUNT} partitions in ${pushNewTime}ms`)
126
+
127
+ // 6. Consume all new messages with the CG
128
+ // If bootstrap worked, all partitions are already known -> fast consumption
129
+ // If bootstrap didn't work, it would discover one-at-a-time -> very slow
130
+ console.log(` Consuming with CG 'bootstrap-cg-new'...`)
131
+ const cgConsumeStart = Date.now()
132
+ let cgConsumeCount = 0
133
+
134
+ await client
135
+ .queue(QUEUE_NAME_NEW)
136
+ .group('bootstrap-cg-new')
137
+ .subscriptionMode('new')
138
+ .concurrency(10)
139
+ .batch(1)
140
+ .wait(false)
141
+ .idleMillis(5000)
142
+ .consume(async msgs => {
143
+ cgConsumeCount += msgs.length
144
+ })
145
+
146
+ const cgConsumeTime = Date.now() - cgConsumeStart
147
+ console.log(` CG consume: ${cgConsumeCount} messages in ${cgConsumeTime}ms`)
148
+
149
+ // 7. Drain new messages in queue mode too (cleanup)
150
+ console.log(` Draining new messages in queue mode...`)
151
+ let queueDrainCount = 0
152
+ await client
153
+ .queue(QUEUE_NAME_NEW)
154
+ .concurrency(10)
155
+ .batch(1)
156
+ .wait(false)
157
+ .idleMillis(5000)
158
+ .consume(async msgs => {
159
+ queueDrainCount += msgs.length
160
+ })
161
+ console.log(` Queue mode drain: ${queueDrainCount} messages`)
162
+
163
+ // 8. Results
164
+ const allConsumed = cgConsumeCount === PARTITION_COUNT
165
+ const bootstrapFast = registerTime < 10000
166
+
167
+ console.log(` ---- Results (subscriptionMode='new') ----`)
168
+ console.log(` Partitions: ${PARTITION_COUNT}`)
169
+ console.log(` Push (historical): ${pushTime}ms`)
170
+ console.log(` Queue mode consume: ${queueModeTime}ms (${queueModeCount} msgs)`)
171
+ console.log(` CG registration: ${registerTime}ms (includes bootstrap)`)
172
+ console.log(` Push (new): ${pushNewTime}ms`)
173
+ console.log(` CG consume: ${cgConsumeTime}ms (${cgConsumeCount} msgs)`)
174
+ console.log(` Bootstrap fast: ${bootstrapFast ? 'YES' : 'NO'} (${registerTime}ms < 10000ms)`)
175
+
176
+ if (!allConsumed) {
177
+ return {
178
+ success: false,
179
+ message: `CG consumed ${cgConsumeCount}/${PARTITION_COUNT} in ${cgConsumeTime}ms (bootstrap: ${registerTime}ms)`
180
+ }
181
+ }
182
+
183
+ return {
184
+ success: true,
185
+ message: `[new] ${PARTITION_COUNT} partitions: push=${pushTime}ms, queue_mode=${queueModeTime}ms, bootstrap=${registerTime}ms, cg_consume=${cgConsumeTime}ms`
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Test bootstrap with subscriptionFrom(timestamp)
191
+ * - Push historical messages to 5000 partitions
192
+ * - Record a cutoff timestamp
193
+ * - Register CG with subscriptionFrom(cutoff) (triggers bootstrap via 'timestamp' path)
194
+ * - Push new messages to all partitions
195
+ * - Consume with CG and verify all new messages are received fast
196
+ */
197
+ export async function testCgBootstrapTimestamp(client) {
198
+ // Cleanup
199
+ try {
200
+ await client.deleteConsumerGroup('bootstrap-cg-ts', true)
201
+ } catch (e) {
202
+ // Ignore
203
+ }
204
+
205
+ // 1. Create queue
206
+ const queue = await client.queue(QUEUE_NAME_TS).create()
207
+ if (!queue.configured) {
208
+ return { success: false, message: 'Queue not created' }
209
+ }
210
+
211
+ // 2. Push 1 message to each of 5000 partitions (historical messages)
212
+ console.log(` Pushing to ${PARTITION_COUNT} partitions...`)
213
+ const pushStart = Date.now()
214
+ await pushToPartitions(client, QUEUE_NAME_TS, PARTITION_COUNT, 'historical')
215
+ const pushTime = Date.now() - pushStart
216
+ console.log(` Push complete: ${PARTITION_COUNT} partitions in ${pushTime}ms`)
217
+
218
+ // 3. Record cutoff timestamp (after historical, before new messages)
219
+ // Wait before capturing the cutoff to handle clock skew between the
220
+ // client (Date.now) and PostgreSQL (NOW()). Without this, the last
221
+ // push batch may have a PG created_at AFTER the client-side cutoff.
222
+ await new Promise(resolve => setTimeout(resolve, 2000))
223
+ const cutoffTimestamp = new Date().toISOString()
224
+ console.log(` Cutoff timestamp: ${cutoffTimestamp}`)
225
+
226
+ // 4. Register a new CG with subscriptionFrom(timestamp)
227
+ // The first pop triggers metadata registration + bootstrap (seeds all partition_consumers)
228
+ console.log(` Registering CG 'bootstrap-cg-ts' with subscriptionFrom('${cutoffTimestamp}')...`)
229
+ const registerStart = Date.now()
230
+
231
+ const firstPop = await client
232
+ .queue(QUEUE_NAME_TS)
233
+ .group('bootstrap-cg-ts')
234
+ .subscriptionFrom(cutoffTimestamp)
235
+ .batch(1)
236
+ .wait(false)
237
+ .pop()
238
+
239
+ const registerTime = Date.now() - registerStart
240
+ console.log(` CG registered + bootstrap in ${registerTime}ms (first pop returned ${firstPop.length} msgs)`)
241
+
242
+ // First pop should return 0 messages (all messages are before the cutoff)
243
+ if (firstPop.length !== 0) {
244
+ return {
245
+ success: false,
246
+ message: `CG with subscriptionFrom should skip historical messages, got ${firstPop.length}`
247
+ }
248
+ }
249
+
250
+ // 5. Push NEW messages to all 5000 partitions (after cutoff)
251
+ console.log(` Pushing new messages to ${PARTITION_COUNT} partitions...`)
252
+ const pushNewStart = Date.now()
253
+ await pushToPartitions(client, QUEUE_NAME_TS, PARTITION_COUNT, 'new')
254
+ const pushNewTime = Date.now() - pushNewStart
255
+ console.log(` Push new complete: ${PARTITION_COUNT} partitions in ${pushNewTime}ms`)
256
+
257
+ // 6. Consume all new messages with the CG
258
+ // If bootstrap worked, all partitions are already known -> fast consumption
259
+ // If bootstrap didn't work, it would discover one-at-a-time -> very slow
260
+ console.log(` Consuming with CG 'bootstrap-cg-ts'...`)
261
+ const cgConsumeStart = Date.now()
262
+ let cgConsumeCount = 0
263
+
264
+ await client
265
+ .queue(QUEUE_NAME_TS)
266
+ .group('bootstrap-cg-ts')
267
+ .subscriptionFrom(cutoffTimestamp)
268
+ .concurrency(10)
269
+ .batch(1)
270
+ .wait(false)
271
+ .idleMillis(5000)
272
+ .consume(async msgs => {
273
+ cgConsumeCount += msgs.length
274
+ })
275
+
276
+ const cgConsumeTime = Date.now() - cgConsumeStart
277
+ console.log(` CG consume: ${cgConsumeCount} messages in ${cgConsumeTime}ms`)
278
+
279
+ // 7. Drain new messages in queue mode too (cleanup)
280
+ console.log(` Draining new messages in queue mode...`)
281
+ let queueDrainCount = 0
282
+ await client
283
+ .queue(QUEUE_NAME_TS)
284
+ .concurrency(10)
285
+ .batch(1)
286
+ .wait(false)
287
+ .idleMillis(5000)
288
+ .consume(async msgs => {
289
+ queueDrainCount += msgs.length
290
+ })
291
+ console.log(` Queue mode drain: ${queueDrainCount} messages`)
292
+
293
+ // 8. Results
294
+ const allConsumed = cgConsumeCount === PARTITION_COUNT
295
+ const bootstrapFast = registerTime < 10000
296
+
297
+ console.log(` ---- Results (subscriptionFrom=timestamp) ----`)
298
+ console.log(` Partitions: ${PARTITION_COUNT}`)
299
+ console.log(` Push (historical): ${pushTime}ms`)
300
+ console.log(` CG registration: ${registerTime}ms (includes bootstrap)`)
301
+ console.log(` Push (new): ${pushNewTime}ms`)
302
+ console.log(` CG consume: ${cgConsumeTime}ms (${cgConsumeCount} msgs)`)
303
+ console.log(` Bootstrap fast: ${bootstrapFast ? 'YES' : 'NO'} (${registerTime}ms < 10000ms)`)
304
+
305
+ if (!allConsumed) {
306
+ return {
307
+ success: false,
308
+ message: `CG consumed ${cgConsumeCount}/${PARTITION_COUNT} in ${cgConsumeTime}ms (bootstrap: ${registerTime}ms)`
309
+ }
310
+ }
311
+
312
+ return {
313
+ success: true,
314
+ message: `[timestamp] ${PARTITION_COUNT} partitions: push=${pushTime}ms, bootstrap=${registerTime}ms, cg_consume=${cgConsumeTime}ms`
315
+ }
316
+ }
package/test-v2/run.js CHANGED
@@ -11,13 +11,7 @@ import * as transactionTests from './transaction.js'
11
11
  import * as subscriptionTests from './subscription.js'
12
12
  import * as maintenanceTests from './maintenance.js'
13
13
  import * as retentionTests from './retention.js'
14
- import * as errorHandlingTests from './ai_error_handling.js'
15
- import * as leaseRenewalTests from './ai_lease_renewal.js'
16
- import * as resourcesTests from './ai_resources.js'
17
- import * as bufferingTests from './ai_buffering.js'
18
- import * as priorityTests from './ai_priority.js'
19
- import * as ttlRetentionTests from './ai_ttl_retention.js'
20
- import * as mixedScenariosTests from './ai_mixed_scenarios.js'
14
+ import * as bootstrapTests from './bootstrap.js'
21
15
  import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
22
16
 
23
17
 
@@ -118,17 +112,12 @@ async function main() {
118
112
  transactionTests,
119
113
  subscriptionTests,
120
114
  retentionTests,
121
- maintenanceTests
115
+ maintenanceTests,
116
+ bootstrapTests
122
117
  ]
123
118
 
124
119
  const aiTests = [
125
- errorHandlingTests,
126
- leaseRenewalTests,
127
- resourcesTests,
128
- bufferingTests,
129
- priorityTests,
130
- ttlRetentionTests,
131
- mixedScenariosTests
120
+
132
121
  ]
133
122
 
134
123
  const allTests = [...humanTests, ...aiTests]
@@ -503,3 +503,69 @@ export async function transactionRollback(client) {
503
503
  }
504
504
  }
505
505
 
506
+ export async function transactionAckWithConsumerGroup(client) {
507
+ const queueA = await client.queue('test-queue-v2-txn-cg-a').create()
508
+ const queueB = await client.queue('test-queue-v2-txn-cg-b').create()
509
+ if (!queueA.configured || !queueB.configured) {
510
+ return { success: false, message: 'Queues not created' }
511
+ }
512
+
513
+ const consumerGroup = 'test-consumer-group-txn'
514
+
515
+ // Push messages to queue A
516
+ await client.queue('test-queue-v2-txn-cg-a').push([
517
+ { data: { value: 1 } },
518
+ { data: { value: 2 } }
519
+ ])
520
+
521
+ // Pop with consumer group
522
+ const messages = await client.queue('test-queue-v2-txn-cg-a')
523
+ .group(consumerGroup)
524
+ .batch(2)
525
+ .wait(false)
526
+ .pop()
527
+
528
+ if (messages.length !== 2) {
529
+ return { success: false, message: `Expected 2 messages, got ${messages.length}` }
530
+ }
531
+
532
+ // Transaction: ACK with consumer group context, push to B
533
+ await client
534
+ .transaction()
535
+ .ack(messages[0], 'completed', { consumerGroup })
536
+ .ack(messages[1], 'completed', { consumerGroup })
537
+ .queue('test-queue-v2-txn-cg-b')
538
+ .push([{ data: { sum: messages[0].data.value + messages[1].data.value } }])
539
+ .commit()
540
+
541
+ // Verify: Pop again with same consumer group should get no messages
542
+ // (consumer group cursor should have advanced)
543
+ const messagesAfterAck = await client.queue('test-queue-v2-txn-cg-a')
544
+ .group(consumerGroup)
545
+ .batch(2)
546
+ .wait(false)
547
+ .pop()
548
+
549
+ // Verify: Queue B has the result
550
+ const resultB = await client.queue('test-queue-v2-txn-cg-b').batch(1).wait(false).pop()
551
+
552
+ // Verify: Using a different consumer group should still see the messages
553
+ const messagesOtherGroup = await client.queue('test-queue-v2-txn-cg-a')
554
+ .group('other-consumer-group')
555
+ .batch(2)
556
+ .wait(false)
557
+ .pop()
558
+
559
+ const success = messagesAfterAck.length === 0 &&
560
+ resultB.length === 1 &&
561
+ resultB[0].data.sum === 3 &&
562
+ messagesOtherGroup.length === 2
563
+
564
+ return {
565
+ success,
566
+ message: success
567
+ ? 'Transaction ack with consumer group completed - cursor advanced correctly'
568
+ : `Failed: afterAck=${messagesAfterAck.length} (expected 0), resultB=${resultB.length} (expected 1), otherGroup=${messagesOtherGroup.length} (expected 2)`
569
+ }
570
+ }
571
+