queen-mq 0.3.0 → 0.4.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.
Files changed (39) hide show
  1. package/README.md +212 -144
  2. package/client-js/client-v2/Queen.js +28 -0
  3. package/client-js/client-v2/README.md +6 -2
  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/MAINTENANCE_TEST.md +148 -0
  10. package/client-js/test-v2/consume.js +1 -0
  11. package/client-js/test-v2/maintenance.js +261 -0
  12. package/client-js/test-v2/retention.js +69 -0
  13. package/client-js/test-v2/run.js +7 -1
  14. package/package.json +2 -2
  15. package/client-js/client/client.js +0 -1536
  16. package/client-js/client/index.js +0 -4
  17. package/client-js/client/utils/http.js +0 -173
  18. package/client-js/client/utils/loadBalancer.js +0 -152
  19. package/client-js/client/utils/retry.js +0 -41
  20. package/client-js/services/encryptionService.js +0 -82
  21. package/client-js/services/evictionService.js +0 -160
  22. package/client-js/services/retentionService.js +0 -162
  23. package/client-js/services/startupSync.js +0 -35
  24. package/client-js/test/README.md +0 -224
  25. package/client-js/test/advanced-client-tests.js +0 -761
  26. package/client-js/test/advanced-pattern-tests.js +0 -1137
  27. package/client-js/test/bus-mode-tests.js +0 -361
  28. package/client-js/test/core-tests.js +0 -457
  29. package/client-js/test/edge-case-tests.js +0 -562
  30. package/client-js/test/enterprise-tests.js +0 -637
  31. package/client-js/test/human.js +0 -162
  32. package/client-js/test/partition-locking-tests.js +0 -545
  33. package/client-js/test/partition-transaction-tests.js +0 -482
  34. package/client-js/test/qos0-tests.js +0 -334
  35. package/client-js/test/test-new.js +0 -370
  36. package/client-js/test/utils.js +0 -169
  37. package/client-js/test/window-buffer-test.js +0 -114
  38. package/client-js/utils/logger.js +0 -44
  39. package/client-js/utils/uuid.js +0 -5
@@ -1,4 +0,0 @@
1
- export { LoadBalancingStrategy } from './utils/loadBalancer.js';
2
-
3
- // New minimalist interface
4
- export { Queen } from './client.js';
@@ -1,173 +0,0 @@
1
- export const httpRequest = async (url, options = {}) => {
2
- //console.log('httpRequest', url, options);
3
- const response = await fetch(url, {
4
- ...options,
5
- headers: {
6
- 'Content-Type': 'application/json',
7
- ...options.headers
8
- }
9
- });
10
- //console.log('response', response);
11
- // Handle 204 No Content
12
- if (response.status === 204) {
13
- return null; // No content
14
- }
15
-
16
- // Handle errors
17
- if (!response.ok) {
18
- const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
19
- error.status = response.status;
20
-
21
- try {
22
- const text = await response.text();
23
- if (text) {
24
- const body = JSON.parse(text);
25
- error.message = body.error || error.message;
26
- }
27
- } catch (e) {
28
- // Ignore JSON parse errors
29
- }
30
-
31
- throw error;
32
- }
33
-
34
- // Handle successful responses
35
- const contentType = response.headers.get('content-type');
36
- const contentLength = response.headers.get('content-length');
37
-
38
- // Check if there's actually content to parse
39
- if (!contentType || !contentType.includes('application/json') || contentLength === '0') {
40
- const text = await response.text();
41
- if (!text || text.length === 0) {
42
- return null; // Empty response
43
- }
44
- try {
45
- return JSON.parse(text);
46
- } catch (e) {
47
- console.warn('Failed to parse response as JSON:', text);
48
- return null;
49
- }
50
- }
51
-
52
- return response.json();
53
- };
54
-
55
- export const createHttpClient = ({ baseUrl, timeout = 30000 }) => {
56
- const request = (method, path, body = null, requestTimeout = null) => {
57
- const effectiveTimeout = requestTimeout || timeout;
58
- const controller = new AbortController();
59
- const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout);
60
-
61
- const options = {
62
- method,
63
- signal: controller.signal
64
- };
65
-
66
- if (body) {
67
- options.body = JSON.stringify(body);
68
- }
69
-
70
- return httpRequest(`${baseUrl}${path}`, options)
71
- .catch(error => {
72
- // Enhance abort errors with more context
73
- if (error.name === 'AbortError') {
74
- const timeoutError = new Error(`Request timeout after ${effectiveTimeout}ms`);
75
- timeoutError.name = 'AbortError';
76
- timeoutError.timeout = effectiveTimeout;
77
- throw timeoutError;
78
- }
79
- throw error;
80
- })
81
- .finally(() => clearTimeout(timeoutId));
82
- };
83
-
84
- return {
85
- get: (path, requestTimeout) => request('GET', path, null, requestTimeout),
86
- post: (path, body, requestTimeout) => request('POST', path, body, requestTimeout),
87
- put: (path, body, requestTimeout) => request('PUT', path, body, requestTimeout),
88
- delete: (path, requestTimeout) => request('DELETE', path, null, requestTimeout)
89
- };
90
- };
91
-
92
- /**
93
- * Create an HTTP client with load balancing support
94
- */
95
- export const createLoadBalancedHttpClient = ({ baseUrls, loadBalancer, timeout = 30000, enableFailover = true }) => {
96
- const request = async (method, path, body = null, requestTimeout = null) => {
97
- const effectiveTimeout = requestTimeout || timeout;
98
-
99
- const executeRequest = async (baseUrl) => {
100
- const controller = new AbortController();
101
- const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout);
102
-
103
- const options = {
104
- method,
105
- signal: controller.signal
106
- };
107
-
108
- if (body) {
109
- options.body = JSON.stringify(body);
110
- }
111
-
112
- try {
113
- return await httpRequest(`${baseUrl}${path}`, options);
114
- } catch (error) {
115
- // Enhance abort errors with more context
116
- if (error.name === 'AbortError') {
117
- const timeoutError = new Error(`Request timeout after ${effectiveTimeout}ms`);
118
- timeoutError.name = 'AbortError';
119
- timeoutError.timeout = effectiveTimeout;
120
- throw timeoutError;
121
- }
122
- throw error;
123
- } finally {
124
- clearTimeout(timeoutId);
125
- }
126
- };
127
-
128
- if (enableFailover) {
129
- // Try with failover to different servers
130
- const attemptedUrls = new Set();
131
- const urls = loadBalancer.getAllUrls();
132
- let lastError = null;
133
-
134
- for (let attempt = 0; attempt < urls.length; attempt++) {
135
- const url = loadBalancer.getNextUrl();
136
-
137
- if (attemptedUrls.has(url)) {
138
- continue;
139
- }
140
-
141
- attemptedUrls.add(url);
142
-
143
- try {
144
- return await executeRequest(url);
145
- } catch (error) {
146
- lastError = error;
147
- console.warn(`Request failed for ${url}: ${method} ${path} - ${error.message}`);
148
-
149
- // Don't retry on client errors (4xx)
150
- if (error.status && error.status >= 400 && error.status < 500) {
151
- throw error;
152
- }
153
-
154
- // Continue to next server for server errors or network issues
155
- }
156
- }
157
-
158
- throw lastError || new Error('All servers failed');
159
- } else {
160
- // No failover, just use the next URL from load balancer
161
- const baseUrl = loadBalancer.getNextUrl();
162
- return executeRequest(baseUrl);
163
- }
164
- };
165
-
166
- return {
167
- get: (path, requestTimeout) => request('GET', path, null, requestTimeout),
168
- post: (path, body, requestTimeout) => request('POST', path, body, requestTimeout),
169
- put: (path, body, requestTimeout) => request('PUT', path, body, requestTimeout),
170
- delete: (path, requestTimeout) => request('DELETE', path, null, requestTimeout),
171
- getLoadBalancer: () => loadBalancer
172
- };
173
- };
@@ -1,152 +0,0 @@
1
- /**
2
- * Load balancer utility for distributing requests across multiple servers
3
- */
4
-
5
- export const LoadBalancingStrategy = {
6
- ROUND_ROBIN: 'ROUND_ROBIN',
7
- SESSION: 'SESSION'
8
- };
9
-
10
- /**
11
- * Create a load balancer for distributing requests across multiple base URLs
12
- */
13
- export const createLoadBalancer = (urls, strategy = LoadBalancingStrategy.ROUND_ROBIN) => {
14
- if (!Array.isArray(urls) || urls.length === 0) {
15
- throw new Error('URLs must be a non-empty array');
16
- }
17
-
18
- // Normalize URLs (remove trailing slashes)
19
- const normalizedUrls = urls.map(url => url.replace(/\/$/, ''));
20
-
21
- // Round robin state
22
- let currentIndex = 0;
23
-
24
- // Session affinity state - map of session ID to URL index
25
- const sessionMap = new Map();
26
-
27
- // Generate a session ID for the current client instance
28
- const sessionId = generateSessionId();
29
-
30
- /**
31
- * Get the next URL based on the configured strategy
32
- */
33
- const getNextUrl = (options = {}) => {
34
- const { sessionKey = sessionId } = options;
35
-
36
- switch (strategy) {
37
- case LoadBalancingStrategy.ROUND_ROBIN:
38
- // Round robin: cycle through URLs
39
- const url = normalizedUrls[currentIndex];
40
- currentIndex = (currentIndex + 1) % normalizedUrls.length;
41
- return url;
42
-
43
- case LoadBalancingStrategy.SESSION:
44
- // Session affinity: stick to the same server per session
45
- if (!sessionMap.has(sessionKey)) {
46
- // Assign a server to this session (using round robin for initial assignment)
47
- const assignedIndex = currentIndex;
48
- sessionMap.set(sessionKey, assignedIndex);
49
- currentIndex = (currentIndex + 1) % normalizedUrls.length;
50
- }
51
- return normalizedUrls[sessionMap.get(sessionKey)];
52
-
53
- default:
54
- throw new Error(`Unknown load balancing strategy: ${strategy}`);
55
- }
56
- };
57
-
58
- /**
59
- * Get all configured URLs
60
- */
61
- const getAllUrls = () => [...normalizedUrls];
62
-
63
- /**
64
- * Get current strategy
65
- */
66
- const getStrategy = () => strategy;
67
-
68
- /**
69
- * Reset the load balancer state
70
- */
71
- const reset = () => {
72
- currentIndex = 0;
73
- sessionMap.clear();
74
- };
75
-
76
- /**
77
- * Get statistics about URL usage (for debugging)
78
- */
79
- const getStats = () => {
80
- return {
81
- strategy,
82
- urls: normalizedUrls,
83
- currentIndex,
84
- sessionCount: sessionMap.size,
85
- sessions: Array.from(sessionMap.entries()).map(([key, index]) => ({
86
- sessionKey: key,
87
- url: normalizedUrls[index]
88
- }))
89
- };
90
- };
91
-
92
- return {
93
- getNextUrl,
94
- getAllUrls,
95
- getStrategy,
96
- reset,
97
- getStats
98
- };
99
- };
100
-
101
- /**
102
- * Generate a unique session ID
103
- */
104
- const generateSessionId = () => {
105
- return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
106
- };
107
-
108
- /**
109
- * Create a failover wrapper that tries different servers on failure
110
- */
111
- export const createFailoverWrapper = (loadBalancer, maxRetries = 3) => {
112
- const attemptedUrls = new Set();
113
-
114
- const executeWithFailover = async (requestFn, options = {}) => {
115
- const urls = loadBalancer.getAllUrls();
116
- let lastError = null;
117
-
118
- // Try each URL up to maxRetries times total
119
- for (let attempt = 0; attempt < Math.min(maxRetries, urls.length); attempt++) {
120
- const url = loadBalancer.getNextUrl(options);
121
-
122
- // Skip if we've already tried this URL in this request
123
- if (attemptedUrls.has(url)) {
124
- continue;
125
- }
126
-
127
- attemptedUrls.add(url);
128
-
129
- try {
130
- const result = await requestFn(url);
131
- attemptedUrls.clear(); // Clear for next request
132
- return result;
133
- } catch (error) {
134
- lastError = error;
135
- console.warn(`Request failed for ${url}, attempting next server...`, error.message);
136
-
137
- // If it's a client error (4xx), don't retry on other servers
138
- if (error.status && error.status >= 400 && error.status < 500) {
139
- attemptedUrls.clear();
140
- throw error;
141
- }
142
- }
143
- }
144
-
145
- attemptedUrls.clear();
146
- throw lastError || new Error('All servers failed');
147
- };
148
-
149
- return {
150
- executeWithFailover
151
- };
152
- };
@@ -1,41 +0,0 @@
1
- const config = {
2
- CLIENT: {
3
- DEFAULT_RETRY_ATTEMPTS: 3,
4
- DEFAULT_RETRY_DELAY: 1000,
5
- DEFAULT_RETRY_BACKOFF: 2
6
- }
7
- }
8
-
9
- export const withRetry = async (fn, attempts = config.CLIENT.DEFAULT_RETRY_ATTEMPTS, delay = config.CLIENT.DEFAULT_RETRY_DELAY, backoff = config.CLIENT.DEFAULT_RETRY_BACKOFF) => {
10
- let lastError;
11
-
12
- for (let i = 0; i < attempts; i++) {
13
- try {
14
- return await fn();
15
- } catch (error) {
16
- lastError = error;
17
-
18
- // Don't retry on client errors (4xx)
19
- if (error.status && error.status >= 400 && error.status < 500) {
20
- throw error;
21
- }
22
-
23
- if (i < attempts - 1) {
24
- const waitTime = delay * Math.pow(backoff, i);
25
- await new Promise(resolve => setTimeout(resolve, waitTime));
26
- }
27
- }
28
- }
29
-
30
- throw lastError;
31
- };
32
-
33
- export const createRetryableFunction = (fn, options = {}) => {
34
- const {
35
- attempts = config.CLIENT.DEFAULT_RETRY_ATTEMPTS,
36
- delay = config.CLIENT.DEFAULT_RETRY_DELAY,
37
- backoff = config.CLIENT.DEFAULT_RETRY_BACKOFF
38
- } = options;
39
-
40
- return (...args) => withRetry(() => fn(...args), attempts, delay, backoff);
41
- };
@@ -1,82 +0,0 @@
1
- import crypto from 'crypto';
2
- import { log } from '../utils/logger.js';
3
-
4
- const ALGORITHM = 'aes-256-gcm';
5
- const KEY_ENV = 'QUEEN_ENCRYPTION_KEY';
6
-
7
- // Get and validate the encryption key
8
- const getKey = () => {
9
- const keyHex = process.env[KEY_ENV];
10
- if (!keyHex) return null;
11
-
12
- try {
13
- const key = Buffer.from(keyHex, 'hex');
14
- if (key.length !== 32) {
15
- log(`ERROR: ${KEY_ENV} must be 32 bytes (64 hex characters)`);
16
- return null;
17
- }
18
- return key;
19
- } catch (error) {
20
- log(`ERROR: Invalid ${KEY_ENV} format:`, error.message);
21
- return null;
22
- }
23
- };
24
-
25
- // Encrypt a payload
26
- export const encryptPayload = async (payload) => {
27
- const key = getKey();
28
- if (!key) throw new Error('Encryption key not configured');
29
-
30
- const iv = crypto.randomBytes(16);
31
- const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
32
-
33
- const payloadStr = JSON.stringify(payload);
34
- const encrypted = Buffer.concat([
35
- cipher.update(payloadStr, 'utf8'),
36
- cipher.final()
37
- ]);
38
-
39
- return {
40
- encrypted: encrypted.toString('base64'),
41
- iv: iv.toString('base64'),
42
- authTag: cipher.getAuthTag().toString('base64')
43
- };
44
- };
45
-
46
- // Decrypt a payload
47
- export const decryptPayload = async (encryptedData) => {
48
- const key = getKey();
49
- if (!key) throw new Error('Encryption key not configured');
50
-
51
- const decipher = crypto.createDecipheriv(
52
- ALGORITHM,
53
- key,
54
- Buffer.from(encryptedData.iv, 'base64')
55
- );
56
-
57
- decipher.setAuthTag(Buffer.from(encryptedData.authTag, 'base64'));
58
-
59
- const decrypted = Buffer.concat([
60
- decipher.update(Buffer.from(encryptedData.encrypted, 'base64')),
61
- decipher.final()
62
- ]);
63
-
64
- return JSON.parse(decrypted.toString('utf8'));
65
- };
66
-
67
- // Check if encryption is available
68
- export const isEncryptionEnabled = () => {
69
- const key = getKey();
70
- return key !== null;
71
- };
72
-
73
- // Initialize and log status
74
- export const initEncryption = () => {
75
- const enabled = isEncryptionEnabled();
76
- if (enabled) {
77
- log('✅ Encryption service initialized');
78
- } else {
79
- log('⚠️ Encryption service disabled (QUEEN_ENCRYPTION_KEY not set)');
80
- }
81
- return enabled;
82
- };
@@ -1,160 +0,0 @@
1
- // Functional eviction service for stale messages
2
- import { log, LogTypes } from '../utils/logger.js';
3
- import config from '../config.js';
4
-
5
- const EVICTION_INTERVAL = config.JOBS.EVICTION_INTERVAL;
6
-
7
- // Evict messages that exceeded max wait time for a queue
8
- // Note: Eviction is already handled by POP query filtering old messages
9
- // This service optionally moves them to DLQ for visibility
10
- const evictMessages = async (client, queueName, maxWaitTimeSeconds) => {
11
- if (!maxWaitTimeSeconds || maxWaitTimeSeconds <= 0) return 0;
12
-
13
- try {
14
- // Find old messages that haven't been consumed and move to DLQ
15
- // Use a WHERE EXISTS check to ensure message still exists before inserting into DLQ
16
- const result = await client.query(`
17
- WITH queue_partitions AS (
18
- SELECT p.id, p.queue_id
19
- FROM queen.partitions p
20
- JOIN queen.queues q ON p.queue_id = q.id
21
- WHERE q.name = $1
22
- ),
23
- old_unconsumed_messages AS (
24
- SELECT m.id, m.partition_id, m.created_at
25
- FROM queen.messages m
26
- JOIN queue_partitions qp ON m.partition_id = qp.id
27
- LEFT JOIN queen.partition_consumers pc ON pc.partition_id = m.partition_id
28
- AND pc.consumer_group = '__QUEUE_MODE__'
29
- WHERE m.created_at < NOW() - INTERVAL '1 second' * $2
30
- -- Message has not been consumed yet
31
- AND ((m.created_at, m.id) > (COALESCE(pc.last_consumed_created_at, '1970-01-01'::timestamptz),
32
- COALESCE(pc.last_consumed_id, '00000000-0000-0000-0000-000000000000'::uuid))
33
- OR pc.last_consumed_id IS NULL)
34
- )
35
- INSERT INTO queen.dead_letter_queue (
36
- message_id,
37
- partition_id,
38
- consumer_group,
39
- error_message,
40
- original_created_at
41
- )
42
- SELECT
43
- om.id,
44
- om.partition_id,
45
- '__QUEUE_MODE__',
46
- 'Message exceeded maximum wait time',
47
- om.created_at
48
- FROM old_unconsumed_messages om
49
- WHERE EXISTS (
50
- SELECT 1 FROM queen.messages m WHERE m.id = om.id
51
- )
52
- ON CONFLICT DO NOTHING
53
- RETURNING message_id
54
- `, [queueName, maxWaitTimeSeconds]);
55
-
56
- return result.rowCount || 0;
57
- } catch (error) {
58
- // Catch foreign key violations (messages deleted between SELECT and INSERT)
59
- if (error.code === '23503') {
60
- // Foreign key constraint violation - message was deleted
61
- // This is a harmless race condition, just log it
62
- log(`${LogTypes.EVICTION} | Queue: ${queueName} | Warning: Message deleted during eviction (race condition)`);
63
- return 0;
64
- }
65
- // Re-throw other errors
66
- throw error;
67
- }
68
- };
69
-
70
- // Evict messages during pop operation (inline eviction)
71
- export const evictOnPop = async (client, queueName) => {
72
- const queueResult = await client.query(
73
- 'SELECT max_wait_time_seconds FROM queen.queues WHERE name = $1',
74
- [queueName]
75
- );
76
-
77
- if (!queueResult.rows[0]) return 0;
78
-
79
- const maxWaitTimeSeconds = queueResult.rows[0].max_wait_time_seconds;
80
- const evictedCount = await evictMessages(client, queueName, maxWaitTimeSeconds);
81
-
82
- if (evictedCount > 0) {
83
- log(`${LogTypes.EVICTION} | Queue: ${queueName} | Count: ${evictedCount} | MaxWaitTime: ${maxWaitTimeSeconds}s`);
84
-
85
- // Log to retention history (reusing the table)
86
- await client.query(`
87
- INSERT INTO queen.retention_history (messages_deleted, retention_type)
88
- VALUES ($1, 'evicted')
89
- `, [evictedCount]);
90
- }
91
-
92
- return evictedCount;
93
- };
94
-
95
- // Background eviction for all queues
96
- const performEviction = async (pool) => {
97
- const client = await pool.connect();
98
-
99
- try {
100
- // Get all queues with eviction configured
101
- const queuesResult = await client.query(`
102
- SELECT name, max_wait_time_seconds
103
- FROM queen.queues
104
- WHERE max_wait_time_seconds > 0
105
- `);
106
-
107
- let totalEvicted = 0;
108
- for (const queue of queuesResult.rows) {
109
- const evicted = await evictMessages(
110
- client,
111
- queue.name,
112
- queue.max_wait_time_seconds
113
- );
114
-
115
- if (evicted > 0) {
116
- log(`${LogTypes.EVICTION} | Queue: ${queue.name} | Count: ${evicted} | MaxWaitTime: ${queue.max_wait_time_seconds}s`);
117
- totalEvicted += evicted;
118
- }
119
- }
120
-
121
- if (totalEvicted > 0) {
122
- await client.query(`
123
- INSERT INTO queen.retention_history (messages_deleted, retention_type)
124
- VALUES ($1, 'evicted')
125
- `, [totalEvicted]);
126
- }
127
-
128
- return totalEvicted;
129
- } catch (error) {
130
- log('Eviction error:', error);
131
- return 0;
132
- } finally {
133
- client.release();
134
- }
135
- };
136
-
137
- // Start the eviction job (optional background job)
138
- export const startEvictionJob = (pool, eventManager) => {
139
- const intervalId = setInterval(async () => {
140
- try {
141
- const evicted = await performEviction(pool);
142
- if (evicted > 0 && eventManager) {
143
- eventManager.emit('messages:evicted', { count: evicted });
144
- }
145
- } catch (error) {
146
- log('Eviction job error:', error);
147
- }
148
- }, EVICTION_INTERVAL);
149
-
150
- log(`⏰ Eviction job started (interval: ${EVICTION_INTERVAL}ms)`);
151
-
152
- // Return cleanup function
153
- return () => {
154
- clearInterval(intervalId);
155
- log('Eviction job stopped');
156
- };
157
- };
158
-
159
- // Export for manual execution
160
- export const runEviction = performEviction;