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.
- package/README.md +309 -167
- package/client-js/client-v2/Queen.js +66 -0
- package/client-js/client-v2/README.md +67 -10
- package/client-js/client-v2/builders/QueueBuilder.js +7 -1
- package/client-js/client-v2/builders/TransactionBuilder.js +20 -4
- package/client-js/client-v2/stream/StreamBuilder.js +72 -0
- package/client-js/client-v2/stream/StreamConsumer.js +156 -0
- package/client-js/client-v2/stream/Window.js +140 -0
- package/client-js/test-v2/GETTING_STARTED.md +27 -0
- package/client-js/test-v2/MAINTENANCE_TEST.md +148 -0
- package/client-js/test-v2/README_SUBSCRIPTION_TESTS.md +201 -0
- package/client-js/test-v2/consume.js +11 -0
- package/client-js/test-v2/dlq.js +1 -1
- package/client-js/test-v2/load.js +2 -0
- package/client-js/test-v2/maintenance.js +261 -0
- package/client-js/test-v2/retention.js +69 -0
- package/client-js/test-v2/run.js +7 -1
- package/client-js/test-v2/subscription.js +198 -18
- package/client-js/test-v2/transaction.js +66 -4
- package/package.json +2 -2
- package/client-js/client/client.js +0 -1536
- package/client-js/client/index.js +0 -4
- package/client-js/client/utils/http.js +0 -173
- package/client-js/client/utils/loadBalancer.js +0 -152
- package/client-js/client/utils/retry.js +0 -41
- package/client-js/services/encryptionService.js +0 -82
- package/client-js/services/evictionService.js +0 -160
- package/client-js/services/retentionService.js +0 -162
- package/client-js/services/startupSync.js +0 -35
- package/client-js/test/README.md +0 -224
- package/client-js/test/advanced-client-tests.js +0 -761
- package/client-js/test/advanced-pattern-tests.js +0 -1137
- package/client-js/test/bus-mode-tests.js +0 -361
- package/client-js/test/core-tests.js +0 -457
- package/client-js/test/edge-case-tests.js +0 -562
- package/client-js/test/enterprise-tests.js +0 -637
- package/client-js/test/human.js +0 -162
- package/client-js/test/partition-locking-tests.js +0 -545
- package/client-js/test/partition-transaction-tests.js +0 -482
- package/client-js/test/qos0-tests.js +0 -334
- package/client-js/test/test-new.js +0 -370
- package/client-js/test/utils.js +0 -169
- package/client-js/test/window-buffer-test.js +0 -114
- package/client-js/utils/logger.js +0 -44
- 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 '
|
|
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
|
|
384
|
+
Skip historical messages and process messages that arrive **near** subscription time:
|
|
372
385
|
|
|
373
386
|
```javascript
|
|
374
|
-
//
|
|
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
|
|
386
|
-
2.
|
|
387
|
-
3. Only messages
|
|
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
|
|
498
|
-
- Use default (all) for batch processing and
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|