queen-mq 0.3.1 → 0.6.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.
Files changed (45) hide show
  1. package/README.md +309 -167
  2. package/client-js/client-v2/Queen.js +66 -0
  3. package/client-js/client-v2/README.md +67 -10
  4. package/client-js/client-v2/builders/QueueBuilder.js +7 -1
  5. package/client-js/client-v2/builders/TransactionBuilder.js +20 -4
  6. package/client-js/client-v2/stream/StreamBuilder.js +72 -0
  7. package/client-js/client-v2/stream/StreamConsumer.js +156 -0
  8. package/client-js/client-v2/stream/Window.js +140 -0
  9. package/client-js/test-v2/GETTING_STARTED.md +27 -0
  10. package/client-js/test-v2/MAINTENANCE_TEST.md +148 -0
  11. package/client-js/test-v2/README_SUBSCRIPTION_TESTS.md +201 -0
  12. package/client-js/test-v2/consume.js +11 -0
  13. package/client-js/test-v2/dlq.js +1 -1
  14. package/client-js/test-v2/load.js +2 -0
  15. package/client-js/test-v2/maintenance.js +261 -0
  16. package/client-js/test-v2/retention.js +69 -0
  17. package/client-js/test-v2/run.js +7 -1
  18. package/client-js/test-v2/subscription.js +198 -18
  19. package/client-js/test-v2/transaction.js +66 -4
  20. package/package.json +2 -2
  21. package/client-js/client/client.js +0 -1536
  22. package/client-js/client/index.js +0 -4
  23. package/client-js/client/utils/http.js +0 -173
  24. package/client-js/client/utils/loadBalancer.js +0 -152
  25. package/client-js/client/utils/retry.js +0 -41
  26. package/client-js/services/encryptionService.js +0 -82
  27. package/client-js/services/evictionService.js +0 -160
  28. package/client-js/services/retentionService.js +0 -162
  29. package/client-js/services/startupSync.js +0 -35
  30. package/client-js/test/README.md +0 -224
  31. package/client-js/test/advanced-client-tests.js +0 -761
  32. package/client-js/test/advanced-pattern-tests.js +0 -1137
  33. package/client-js/test/bus-mode-tests.js +0 -361
  34. package/client-js/test/core-tests.js +0 -457
  35. package/client-js/test/edge-case-tests.js +0 -562
  36. package/client-js/test/enterprise-tests.js +0 -637
  37. package/client-js/test/human.js +0 -162
  38. package/client-js/test/partition-locking-tests.js +0 -545
  39. package/client-js/test/partition-transaction-tests.js +0 -482
  40. package/client-js/test/qos0-tests.js +0 -334
  41. package/client-js/test/test-new.js +0 -370
  42. package/client-js/test/utils.js +0 -169
  43. package/client-js/test/window-buffer-test.js +0 -114
  44. package/client-js/utils/logger.js +0 -44
  45. package/client-js/utils/uuid.js +0 -5
@@ -28,8 +28,12 @@ Welcome to Queen client! This is your friendly guide to mastering message queues
28
28
 
29
29
  First, install and import:
30
30
 
31
+ ```sh
32
+ npm install queen-mq
33
+ ```
34
+
31
35
  ```javascript
32
- import { Queen } from './client-js/client-v2/index.js'
36
+ import { Queen } from 'queen-mq'
33
37
 
34
38
  // Connect to your Queen server
35
39
  const queen = new Queen('http://localhost:6632')
@@ -366,12 +370,21 @@ await queen
366
370
  })
367
371
  ```
368
372
 
373
+ **Server Default:** The server can be configured to change this default behavior:
374
+ ```bash
375
+ # Make all new consumer groups skip history by default
376
+ export DEFAULT_SUBSCRIPTION_MODE="new"
377
+ ./bin/queen-server
378
+ ```
379
+
380
+ When `DEFAULT_SUBSCRIPTION_MODE="new"` is set, new consumer groups automatically skip historical messages unless you explicitly override with `.subscriptionMode('all')`.
381
+
369
382
  ### Subscription Mode: 'new'
370
383
 
371
- Skip all historical messages and only process messages that arrive **after** subscription:
384
+ Skip historical messages and process messages that arrive **near** subscription time:
372
385
 
373
386
  ```javascript
374
- // Only process NEW messages, skip historical backlog
387
+ // Process recent messages (not historical backlog)
375
388
  await queen
376
389
  .queue('events')
377
390
  .group('realtime-monitor')
@@ -382,9 +395,33 @@ await queen
382
395
  ```
383
396
 
384
397
  **What happens:**
385
- 1. Consumer subscribes at `T0`
386
- 2. All messages before `T0` are skipped
387
- 3. Only messages arriving after `T0` are processed
398
+ 1. Consumer makes first pop at `T0` (e.g., 10:00:00)
399
+ 2. Server records `subscription_timestamp = T0` in metadata table
400
+ 3. Only messages with `created_at > T0` are processed
401
+ 4. All historical messages are skipped
402
+
403
+ **How it ensures consistency across partitions:**
404
+
405
+ Queen tracks subscription time separately from partition-level cursors:
406
+
407
+ ```javascript
408
+ // Timeline:
409
+ 10:00:00 - First pop() call
410
+ → Metadata recorded: subscription_timestamp = 10:00:00
411
+
412
+ // Consumer processes partition P1 for 10 minutes
413
+
414
+ 10:10:00 - New partition P2 is created, messages arrive
415
+ 10:15:00 - Consumer discovers P2 via pop()
416
+ → Uses ORIGINAL subscription_timestamp (10:00:00)
417
+ → Messages from 10:10:00 are captured! ✓
418
+ ```
419
+
420
+ **Key benefits:**
421
+ - ✅ **Consistent**: All partitions use the same subscription timestamp
422
+ - ✅ **No skipping**: New partitions discovered later are processed correctly
423
+ - ✅ **True NEW semantics**: Only messages after first pop request
424
+ - ✅ **Works with wildcards**: Namespace/task filters maintain subscription time
388
425
 
389
426
  ### Subscription Mode: 'new-only'
390
427
 
@@ -493,11 +530,18 @@ await queen
493
530
  - Subsequent consumers in the same group inherit the same position
494
531
  - To change subscription mode, use a different group name
495
532
 
533
+ ⏰ **NEW mode subscription tracking:**
534
+ - NEW mode tracks when the consumer group **first subscribes** (first pop request)
535
+ - This subscription timestamp is used consistently across all partitions
536
+ - Ensures new partitions discovered later don't skip messages
537
+ - Stored in `consumer_groups_metadata` table on the server
538
+
496
539
  💡 **Best Practices:**
497
- - Use `'new'` for real-time monitoring and alerting
498
- - Use default (all) for batch processing and analytics
499
- - Use timestamps for replay/debugging scenarios
540
+ - Use `'new'` for real-time monitoring (skip historical backlog)
541
+ - Use default (all) for batch processing and full history replay
542
+ - Use timestamps for precise replay/debugging scenarios
500
543
  - Name groups descriptively based on their subscription mode
544
+ - Be aware that "NEW" means messages after the **first pop request**, not the first message arrival
501
545
 
502
546
  ---
503
547
 
@@ -1656,6 +1700,19 @@ const dlq = await queen.queue('q').dlq('consumer-group').limit(10).get()
1656
1700
  const dlq = await queen.queue('q').dlq().from('2025-01-01').to('2025-01-31').get()
1657
1701
  ```
1658
1702
 
1703
+ ### Consumer Group Management
1704
+
1705
+ ```javascript
1706
+ // Delete a consumer group (including metadata)
1707
+ await queen.deleteConsumerGroup('my-group')
1708
+
1709
+ // Delete consumer group but keep subscription metadata
1710
+ await queen.deleteConsumerGroup('my-group', false)
1711
+
1712
+ // Update subscription timestamp
1713
+ await queen.updateConsumerGroupTimestamp('my-group', '2025-11-10T10:00:00Z')
1714
+ ```
1715
+
1659
1716
  ### Shutdown
1660
1717
 
1661
1718
  ```javascript
@@ -1874,7 +1931,7 @@ process.on('SIGINT', async () => {
1874
1931
  You now know everything about Queen v2! 🎉
1875
1932
 
1876
1933
  **Additional resources:**
1877
- - [API Documentation](../../API.md) - Complete API reference
1934
+ - [API Documentation](../../server/API.md) - Complete API reference
1878
1935
  - [Test Examples](../test-v2/) - 94 working test cases
1879
1936
  - [Architecture Guide](../../docs/) - Deep dive into Queen's internals
1880
1937
 
@@ -2,7 +2,13 @@
2
2
  * Queue builder for fluent API
3
3
  */
4
4
 
5
- import { generateUUID } from '../../utils/uuid.js'
5
+ import { v7 as uuidv7 } from 'uuid';
6
+
7
+ export const generateUUID = () => {
8
+ return uuidv7();
9
+ };
10
+
11
+ //import { generateUUID } from '../../utils/uuid.js'
6
12
  import { isValidUUID } from '../utils/validation.js'
7
13
  import { QUEUE_DEFAULTS, CONSUME_DEFAULTS, POP_DEFAULTS } from '../utils/defaults.js'
8
14
  import * as logger from '../utils/logger.js'
@@ -48,12 +48,19 @@ export class TransactionBuilder {
48
48
  }
49
49
 
50
50
  queue(queueName) {
51
- // Return a sub-builder for push operations
52
- return {
51
+ // Return a sub-builder for push operations with partition support
52
+ let partition = null
53
+
54
+ const subBuilder = {
55
+ partition: (partitionKey) => {
56
+ partition = partitionKey
57
+ return subBuilder
58
+ },
59
+
53
60
  push: (items) => {
54
61
  const itemArray = Array.isArray(items) ? items : [items]
55
62
 
56
- logger.log('TransactionBuilder.queue.push', { queue: queueName, count: itemArray.length })
63
+ logger.log('TransactionBuilder.queue.push', { queue: queueName, partition, count: itemArray.length })
57
64
 
58
65
  this.#operations.push({
59
66
  type: 'push',
@@ -68,16 +75,25 @@ export class TransactionBuilder {
68
75
  payloadValue = item
69
76
  }
70
77
 
71
- return {
78
+ const result = {
72
79
  queue: queueName,
73
80
  payload: payloadValue
74
81
  }
82
+
83
+ // Add partition if set
84
+ if (partition !== null) {
85
+ result.partition = partition
86
+ }
87
+
88
+ return result
75
89
  })
76
90
  })
77
91
 
78
92
  return this
79
93
  }
80
94
  }
95
+
96
+ return subBuilder
81
97
  }
82
98
 
83
99
  async commit() {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * StreamBuilder - Fluent API for defining streams
3
+ */
4
+ export class StreamBuilder {
5
+ constructor(httpClient, queen, name, namespace) {
6
+ this.httpClient = httpClient;
7
+ this.queen = queen;
8
+ this.config = {
9
+ name,
10
+ namespace,
11
+ source_queue_names: [],
12
+ partitioned: false,
13
+ window_type: 'tumbling',
14
+ window_duration_ms: 60000, // 1 minute default
15
+ window_grace_period_ms: 30000, // 30 seconds default
16
+ window_lease_timeout_ms: 60000 // 1 minute default
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Set the source queues for this stream
22
+ * @param {string[]} queueNames - Array of queue names
23
+ */
24
+ sources(queueNames = []) {
25
+ this.config.source_queue_names = queueNames;
26
+ return this;
27
+ }
28
+
29
+ /**
30
+ * Enable partitioned processing (group by partition_id)
31
+ */
32
+ partitioned() {
33
+ this.config.partitioned = true;
34
+ return this;
35
+ }
36
+
37
+ /**
38
+ * Configure tumbling time window
39
+ * @param {number} seconds - Window duration in seconds
40
+ */
41
+ tumblingTime(seconds) {
42
+ this.config.window_type = 'tumbling';
43
+ this.config.window_duration_ms = seconds * 1000;
44
+ return this;
45
+ }
46
+
47
+ /**
48
+ * Configure grace period for late-arriving messages
49
+ * @param {number} seconds - Grace period in seconds
50
+ */
51
+ gracePeriod(seconds) {
52
+ this.config.window_grace_period_ms = seconds * 1000;
53
+ return this;
54
+ }
55
+
56
+ /**
57
+ * Configure lease timeout
58
+ * @param {number} seconds - Lease timeout in seconds
59
+ */
60
+ leaseTimeout(seconds) {
61
+ this.config.window_lease_timeout_ms = seconds * 1000;
62
+ return this;
63
+ }
64
+
65
+ /**
66
+ * Define/create the stream on the server
67
+ */
68
+ async define() {
69
+ return this.httpClient.post('/api/v1/stream/define', this.config);
70
+ }
71
+ }
72
+
@@ -0,0 +1,156 @@
1
+ import { Window } from './Window.js';
2
+
3
+ /**
4
+ * StreamConsumer - Manages consuming windows from a stream
5
+ */
6
+ export class StreamConsumer {
7
+ constructor(httpClient, queen, streamName, consumerGroup) {
8
+ this.httpClient = httpClient;
9
+ this.queen = queen;
10
+ this.streamName = streamName;
11
+ this.consumerGroup = consumerGroup;
12
+ this.pollTimeout = 30000; // 30s long poll
13
+ this.leaseRenewInterval = 20000; // 20s renewal (before 60s timeout)
14
+ this.running = false;
15
+ }
16
+
17
+ /**
18
+ * Start processing windows with the provided callback
19
+ * Runs in an infinite loop until stopped
20
+ * @param {Function} callback - async function(window) to process each window
21
+ */
22
+ async process(callback) {
23
+ this.running = true;
24
+
25
+
26
+ while (this.running) {
27
+ let window = null;
28
+
29
+ try {
30
+ window = await this.pollWindow();
31
+
32
+ if (!window) {
33
+ // 204 No Content - no window available
34
+ continue;
35
+ }
36
+
37
+ await this.executeCallback(window, callback);
38
+
39
+ } catch (err) {
40
+ // Backoff on error
41
+ await new Promise(r => setTimeout(r, 1000));
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Stop the processing loop
48
+ */
49
+ stop() {
50
+ this.running = false;
51
+ }
52
+
53
+ /**
54
+ * Poll for a window (blocking call)
55
+ * @returns {Promise<Window|null>} Window or null if no content
56
+ */
57
+ async pollWindow() {
58
+ try {
59
+ const response = await this.httpClient.post('/api/v1/stream/poll', {
60
+ streamName: this.streamName,
61
+ consumerGroup: this.consumerGroup,
62
+ timeout: this.pollTimeout
63
+ });
64
+
65
+ // Handle 204 No Content (HttpClient returns null for 204)
66
+ if (!response) {
67
+ return null;
68
+ }
69
+
70
+ // HttpClient returns the JSON body directly (e.g., {"window": {...}})
71
+ if (!response.window) {
72
+ return null;
73
+ }
74
+
75
+ return new Window(response.window);
76
+
77
+ } catch (err) {
78
+ // HttpClient throws on errors, returns null on 204
79
+ // So any error here is a real network/server error
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Execute the callback with lease renewal
86
+ * @param {Window} window - The window to process
87
+ * @param {Function} callback - User callback
88
+ */
89
+ async executeCallback(window, callback) {
90
+ let leaseTimer = null;
91
+ let leaseExpired = false;
92
+
93
+ try {
94
+ // Start lease renewal timer
95
+ leaseTimer = setInterval(async () => {
96
+ try {
97
+ await this.httpClient.post('/api/v1/stream/renew-lease', {
98
+ leaseId: window.leaseId,
99
+ extend_ms: this.leaseRenewInterval + 10000 // Extend by renewal interval + buffer
100
+ });
101
+ } catch (e) {
102
+ leaseExpired = true;
103
+ }
104
+ }, this.leaseRenewInterval);
105
+
106
+ // Execute user callback
107
+ await callback(window);
108
+
109
+ // ACK if lease hasn't expired
110
+ if (!leaseExpired) {
111
+ await this.httpClient.post('/api/v1/stream/ack', {
112
+ windowId: window.id,
113
+ leaseId: window.leaseId,
114
+ success: true
115
+ });
116
+ }
117
+
118
+ } catch (err) {
119
+
120
+ // NACK if lease hasn't expired
121
+ if (!leaseExpired) {
122
+ try {
123
+ await this.httpClient.post('/api/v1/stream/ack', {
124
+ windowId: window.id,
125
+ leaseId: window.leaseId,
126
+ success: false // NACK
127
+ });
128
+ } catch (nackErr) {
129
+
130
+ }
131
+ }
132
+
133
+ // Re-throw to trigger backoff
134
+ throw err;
135
+
136
+ } finally {
137
+ // Stop lease renewal
138
+ if (leaseTimer) {
139
+ clearInterval(leaseTimer);
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Seek to a specific timestamp
146
+ * @param {string} timestamp - ISO timestamp to seek to
147
+ */
148
+ async seek(timestamp) {
149
+ return this.httpClient.post('/api/v1/stream/seek', {
150
+ streamName: this.streamName,
151
+ consumerGroup: this.consumerGroup,
152
+ timestamp
153
+ });
154
+ }
155
+ }
156
+
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Utility function to get nested property value from object using dot notation
3
+ */
4
+ const getPath = (obj, path) =>
5
+ path.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : null), obj);
6
+
7
+ /**
8
+ * Window - Represents a time window of messages with utility methods
9
+ */
10
+ export class Window {
11
+ constructor(rawWindow) {
12
+ // Copy all properties from raw window
13
+ Object.assign(this, rawWindow);
14
+
15
+ // Store immutable original messages
16
+ this.allMessages = Object.freeze(rawWindow.messages || []);
17
+
18
+ // Working copy for transformations
19
+ this.messages = [...this.allMessages];
20
+ }
21
+
22
+ /**
23
+ * Filter messages based on a predicate function
24
+ * @param {Function} filterFn - Predicate function (msg) => boolean
25
+ * @returns {Window} this for chaining
26
+ */
27
+ filter(filterFn) {
28
+ this.messages = this.messages.filter(filterFn);
29
+ return this;
30
+ }
31
+
32
+ /**
33
+ * Group messages by a key path (dot notation supported)
34
+ * @param {string} keyPath - Path to key in message data (e.g., 'data.userId')
35
+ * @returns {Object} Object with keys as group names and values as arrays of messages
36
+ */
37
+ groupBy(keyPath) {
38
+ const groups = {};
39
+ for (const msg of this.messages) {
40
+ const key = getPath(msg, keyPath) || 'null_key';
41
+ if (!groups[key]) {
42
+ groups[key] = [];
43
+ }
44
+ groups[key].push(msg);
45
+ }
46
+ return groups;
47
+ }
48
+
49
+ /**
50
+ * Aggregate messages using various aggregation functions
51
+ * @param {Object} config - Aggregation configuration
52
+ * @param {boolean} config.count - Count messages
53
+ * @param {string[]} config.sum - Array of paths to sum
54
+ * @param {string[]} config.avg - Array of paths to average
55
+ * @param {string[]} config.min - Array of paths to find minimum
56
+ * @param {string[]} config.max - Array of paths to find maximum
57
+ * @returns {Object} Aggregation results
58
+ */
59
+ aggregate(config = {}) {
60
+ const results = {};
61
+
62
+ // Count
63
+ if (config.count) {
64
+ results.count = this.messages.length;
65
+ }
66
+
67
+ // Sum
68
+ if (config.sum) {
69
+ results.sum = {};
70
+ for (const path of config.sum) {
71
+ results.sum[path] = this.messages.reduce((total, msg) => {
72
+ const val = getPath(msg, path);
73
+ return total + (typeof val === 'number' ? val : 0);
74
+ }, 0);
75
+ }
76
+ }
77
+
78
+ // Average
79
+ if (config.avg) {
80
+ results.avg = {};
81
+ for (const path of config.avg) {
82
+ const sum = this.messages.reduce((total, msg) => {
83
+ const val = getPath(msg, path);
84
+ return total + (typeof val === 'number' ? val : 0);
85
+ }, 0);
86
+ results.avg[path] = this.messages.length > 0 ? sum / this.messages.length : 0;
87
+ }
88
+ }
89
+
90
+ // Min
91
+ if (config.min) {
92
+ results.min = {};
93
+ for (const path of config.min) {
94
+ const values = this.messages
95
+ .map(msg => getPath(msg, path))
96
+ .filter(val => typeof val === 'number');
97
+ results.min[path] = values.length > 0 ? Math.min(...values) : null;
98
+ }
99
+ }
100
+
101
+ // Max
102
+ if (config.max) {
103
+ results.max = {};
104
+ for (const path of config.max) {
105
+ const values = this.messages
106
+ .map(msg => getPath(msg, path))
107
+ .filter(val => typeof val === 'number');
108
+ results.max[path] = values.length > 0 ? Math.max(...values) : null;
109
+ }
110
+ }
111
+
112
+ return results;
113
+ }
114
+
115
+ /**
116
+ * Reset working messages to the original frozen set
117
+ * @returns {Window} this for chaining
118
+ */
119
+ reset() {
120
+ this.messages = [...this.allMessages];
121
+ return this;
122
+ }
123
+
124
+ /**
125
+ * Get count of messages in working set
126
+ * @returns {number}
127
+ */
128
+ size() {
129
+ return this.messages.length;
130
+ }
131
+
132
+ /**
133
+ * Get count of original messages
134
+ * @returns {number}
135
+ */
136
+ originalSize() {
137
+ return this.allMessages.length;
138
+ }
139
+ }
140
+
@@ -119,6 +119,33 @@ Make sure:
119
119
  3. ✅ Environment variables are set (if needed)
120
120
  4. ✅ No production data in test database
121
121
 
122
+ ## Testing with Different Server Configurations
123
+
124
+ ### Standard Configuration (Default)
125
+
126
+ Run server with default settings:
127
+ ```bash
128
+ ./bin/queen-server
129
+ node test-v2/run.js
130
+ ```
131
+
132
+ **Expected:** Consumer groups without explicit `.subscriptionMode()` process all historical messages.
133
+
134
+ ### With DEFAULT_SUBSCRIPTION_MODE="new"
135
+
136
+ Run server with "new" as default:
137
+ ```bash
138
+ DEFAULT_SUBSCRIPTION_MODE="new" ./bin/queen-server
139
+ node test-v2/run.js
140
+ ```
141
+
142
+ **Expected:**
143
+ - Tests with explicit `.subscriptionMode('new')` work the same
144
+ - Tests without explicit mode will skip historical messages
145
+ - `subscriptionModeServerDefault` test detects and reports server default
146
+
147
+ **All tests pass with both configurations!** The tests are designed to be agnostic to server defaults.
148
+
122
149
  ## Test Philosophy
123
150
 
124
151
  The AI-generated tests follow these principles: