queen-mq 0.12.1 → 0.12.3

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.
@@ -34,10 +34,12 @@ export class Queen {
34
34
  // Create buffer manager
35
35
  this.#bufferManager = new BufferManager(this.#httpClient)
36
36
 
37
- // Setup graceful shutdown
38
- this.#setupGracefulShutdown()
37
+ // Setup graceful shutdown (opt-out via handleSignals: false)
38
+ if (this.#config.handleSignals) {
39
+ this.#setupGracefulShutdown()
40
+ }
39
41
 
40
- logger.log('Queen.constructor', { status: 'initialized', urls: this.#config.urls.length })
42
+ logger.log('Queen.constructor', { status: 'initialized', urls: this.#config.urls.length, handleSignals: this.#config.handleSignals })
41
43
  }
42
44
 
43
45
  #normalizeConfig(config) {
@@ -77,7 +79,7 @@ export class Queen {
77
79
  }
78
80
 
79
81
  #createHttpClient() {
80
- const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken } = this.#config
82
+ const { urls, timeoutMillis, retryAttempts, retryDelayMillis, loadBalancingStrategy, affinityHashRing, healthRetryAfterMillis, enableFailover, bearerToken, headers } = this.#config
81
83
 
82
84
  if (urls.length === 1) {
83
85
  // Single server
@@ -86,7 +88,8 @@ export class Queen {
86
88
  timeoutMillis,
87
89
  retryAttempts,
88
90
  retryDelayMillis,
89
- bearerToken
91
+ bearerToken,
92
+ headers
90
93
  })
91
94
  }
92
95
 
@@ -101,7 +104,8 @@ export class Queen {
101
104
  retryAttempts,
102
105
  retryDelayMillis,
103
106
  enableFailover,
104
- bearerToken
107
+ bearerToken,
108
+ headers
105
109
  })
106
110
  }
107
111
 
@@ -452,6 +456,28 @@ export class Queen {
452
456
  // Graceful Shutdown
453
457
  // ===========================
454
458
 
459
+ /**
460
+ * Register SIGINT/SIGTERM handlers so the client flushes buffers before exit.
461
+ * Called automatically unless handleSignals is set to false.
462
+ */
463
+ enableGracefulShutdown() {
464
+ if (this.#shutdownHandlers.length > 0) return this
465
+ this.#setupGracefulShutdown()
466
+ return this
467
+ }
468
+
469
+ /**
470
+ * Remove previously registered SIGINT/SIGTERM handlers.
471
+ * Use this when Queen is embedded in a larger application that manages its own lifecycle.
472
+ */
473
+ disableGracefulShutdown() {
474
+ for (const cleanup of this.#shutdownHandlers) {
475
+ cleanup()
476
+ }
477
+ this.#shutdownHandlers = []
478
+ return this
479
+ }
480
+
455
481
  async close() {
456
482
  logger.log('Queen.close', 'Starting shutdown')
457
483
  console.log('Closing Queen client...')
@@ -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,9 @@ 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
19
+ handleSignals: true // Register SIGINT/SIGTERM handlers (disable when used as a library)
18
20
  }
19
21
 
20
22
  export const QUEUE_DEFAULTS = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "queen-mq",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
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]