@onlineapps/cookbook-router 1.0.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.
- package/README.md +215 -0
- package/package.json +38 -0
- package/src/index.js +31 -0
- package/src/queueManager.js +187 -0
- package/src/retryHandler.js +186 -0
- package/src/router.js +207 -0
- package/src/serviceDiscovery.js +174 -0
package/README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# @onlineapps/cookbook-router
|
|
2
|
+
|
|
3
|
+
Message routing for cookbook workflows - handles service discovery and queue routing.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This package provides routing capabilities for cookbook workflows in a distributed microservices environment. It handles service discovery, queue management, and retry logic for reliable message delivery.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @onlineapps/cookbook-router
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **Service discovery** - Integration with service registry
|
|
18
|
+
- **Queue management** - RabbitMQ operations and DLQ handling
|
|
19
|
+
- **Workflow routing** - Route between services based on cookbook steps
|
|
20
|
+
- **Retry logic** - Exponential backoff with jitter
|
|
21
|
+
- **Health checking** - Service availability monitoring
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### Basic routing setup
|
|
26
|
+
|
|
27
|
+
```javascript
|
|
28
|
+
const { CookbookRouter } = require('@onlineapps/cookbook-router');
|
|
29
|
+
const MQClient = require('@onlineapps/connector-mq-client');
|
|
30
|
+
const RegistryClient = require('@onlineapps/connector-registry-client');
|
|
31
|
+
|
|
32
|
+
const mqClient = new MQClient(mqConfig);
|
|
33
|
+
const registryClient = new RegistryClient(registryConfig);
|
|
34
|
+
|
|
35
|
+
const router = new CookbookRouter(mqClient, registryClient, {
|
|
36
|
+
defaultQueue: 'workflow.init',
|
|
37
|
+
completedQueue: 'workflow.completed',
|
|
38
|
+
maxRetries: 3
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Route workflow to first service
|
|
43
|
+
|
|
44
|
+
```javascript
|
|
45
|
+
const cookbook = {
|
|
46
|
+
id: 'my-workflow',
|
|
47
|
+
steps: [
|
|
48
|
+
{ id: 'step1', type: 'task', service: 'user-service', operation: 'getUser' },
|
|
49
|
+
{ id: 'step2', type: 'task', service: 'email-service', operation: 'sendEmail' }
|
|
50
|
+
]
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const context = {
|
|
54
|
+
workflow_id: 'wf_123',
|
|
55
|
+
api_input: { userId: '456' }
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
await router.routeWorkflow(cookbook, context);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Route to next service
|
|
62
|
+
|
|
63
|
+
```javascript
|
|
64
|
+
// After completing step1, route to next service
|
|
65
|
+
await router.routeToNextService(cookbook, updatedContext, 'step1');
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Handle failures
|
|
69
|
+
|
|
70
|
+
```javascript
|
|
71
|
+
try {
|
|
72
|
+
await router.routeWorkflow(cookbook, context);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// Route to dead letter queue
|
|
75
|
+
await router.routeToDLQ(cookbook, context, error, 'user-service');
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Service Discovery
|
|
80
|
+
|
|
81
|
+
```javascript
|
|
82
|
+
const { ServiceDiscovery } = require('@onlineapps/cookbook-router');
|
|
83
|
+
|
|
84
|
+
const discovery = new ServiceDiscovery(registryClient);
|
|
85
|
+
|
|
86
|
+
// Check service availability
|
|
87
|
+
const exists = await discovery.serviceExists('user-service');
|
|
88
|
+
|
|
89
|
+
// Get service details
|
|
90
|
+
const service = await discovery.getService('user-service');
|
|
91
|
+
|
|
92
|
+
// Find services by capability
|
|
93
|
+
const services = await discovery.findServicesByCapability('email');
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Queue Management
|
|
97
|
+
|
|
98
|
+
```javascript
|
|
99
|
+
const { QueueManager } = require('@onlineapps/cookbook-router');
|
|
100
|
+
|
|
101
|
+
const queueManager = new QueueManager(mqClient);
|
|
102
|
+
|
|
103
|
+
// Publish message
|
|
104
|
+
await queueManager.publish('user.queue', { action: 'process' });
|
|
105
|
+
|
|
106
|
+
// Subscribe to queue
|
|
107
|
+
await queueManager.subscribe('user.queue', async (message) => {
|
|
108
|
+
console.log('Received:', message);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Setup dead letter queue
|
|
112
|
+
await queueManager.setupDLQ('user.queue', 'user.dlq');
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Retry Handling
|
|
116
|
+
|
|
117
|
+
```javascript
|
|
118
|
+
const { RetryHandler } = require('@onlineapps/cookbook-router');
|
|
119
|
+
|
|
120
|
+
const retryHandler = new RetryHandler({
|
|
121
|
+
maxAttempts: 3,
|
|
122
|
+
initialDelay: 1000,
|
|
123
|
+
backoffFactor: 2
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Execute with retry
|
|
127
|
+
const result = await retryHandler.execute(async () => {
|
|
128
|
+
return await unreliableOperation();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Wrap function with retry
|
|
132
|
+
const reliableOperation = retryHandler.wrap(unreliableOperation);
|
|
133
|
+
await reliableOperation();
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Configuration Options
|
|
137
|
+
|
|
138
|
+
### CookbookRouter Options
|
|
139
|
+
|
|
140
|
+
```javascript
|
|
141
|
+
{
|
|
142
|
+
defaultQueue: 'workflow.init', // Default entry queue
|
|
143
|
+
completedQueue: 'workflow.completed', // Completion queue
|
|
144
|
+
dlqSuffix: '.dlq', // Dead letter queue suffix
|
|
145
|
+
maxRetries: 3, // Max retry attempts
|
|
146
|
+
retryDelay: 2000, // Base retry delay (ms)
|
|
147
|
+
logger: console // Logger instance
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### ServiceDiscovery Options
|
|
152
|
+
|
|
153
|
+
```javascript
|
|
154
|
+
{
|
|
155
|
+
cacheTTL: 60000, // Cache TTL in ms
|
|
156
|
+
logger: console // Logger instance
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### QueueManager Options
|
|
161
|
+
|
|
162
|
+
```javascript
|
|
163
|
+
{
|
|
164
|
+
defaultTTL: 30000, // Message TTL
|
|
165
|
+
persistent: true, // Persistent messages
|
|
166
|
+
logger: console // Logger instance
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### RetryHandler Options
|
|
171
|
+
|
|
172
|
+
```javascript
|
|
173
|
+
{
|
|
174
|
+
maxAttempts: 3, // Max retry attempts
|
|
175
|
+
initialDelay: 1000, // Initial delay (ms)
|
|
176
|
+
maxDelay: 30000, // Max delay cap (ms)
|
|
177
|
+
backoffFactor: 2, // Exponential factor
|
|
178
|
+
jitter: true, // Add random jitter
|
|
179
|
+
logger: console // Logger instance
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Queue Naming Conventions
|
|
184
|
+
|
|
185
|
+
- `{service}.workflow` - Workflow messages
|
|
186
|
+
- `{service}.queue` - Direct messages
|
|
187
|
+
- `{service}.dlq` - Dead letter queue
|
|
188
|
+
- `workflow.init` - Entry point
|
|
189
|
+
- `workflow.completed` - Completed workflows
|
|
190
|
+
|
|
191
|
+
## Error Handling
|
|
192
|
+
|
|
193
|
+
The router distinguishes between retryable and non-retryable errors:
|
|
194
|
+
|
|
195
|
+
**Retryable:**
|
|
196
|
+
- Network timeouts
|
|
197
|
+
- Connection refused
|
|
198
|
+
- Service temporarily unavailable
|
|
199
|
+
|
|
200
|
+
**Non-retryable:**
|
|
201
|
+
- Validation errors
|
|
202
|
+
- Authentication failures
|
|
203
|
+
- Not found errors
|
|
204
|
+
- Bad requests
|
|
205
|
+
|
|
206
|
+
## Related Packages
|
|
207
|
+
|
|
208
|
+
- `@onlineapps/cookbook-core` - Core parsing and validation
|
|
209
|
+
- `@onlineapps/cookbook-executor` - Workflow execution engine
|
|
210
|
+
- `@onlineapps/cookbook-transformer` - OpenAPI mapping
|
|
211
|
+
- `@onlineapps/connector-cookbook` - Full-featured wrapper
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
PROPRIETARY - All rights reserved
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@onlineapps/cookbook-router",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Message routing for cookbook workflows - handles service discovery and queue routing",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "jest",
|
|
8
|
+
"test:watch": "jest --watch",
|
|
9
|
+
"test:coverage": "jest --coverage",
|
|
10
|
+
"docs": "jsdoc2md --files src/**/*.js > API.md"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"cookbook",
|
|
14
|
+
"routing",
|
|
15
|
+
"queue",
|
|
16
|
+
"workflow",
|
|
17
|
+
"messaging"
|
|
18
|
+
],
|
|
19
|
+
"author": "OnlineApps",
|
|
20
|
+
"license": "PROPRIETARY",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@onlineapps/cookbook-core": "^1.0.0",
|
|
23
|
+
"@onlineapps/conn-infra-mq": "^1.1.0",
|
|
24
|
+
"@onlineapps/conn-orch-registry": "^1.1.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"jest": "^29.7.0"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18.0.0"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module @onlineapps/cookbook-router
|
|
5
|
+
*
|
|
6
|
+
* Message routing library for cookbook workflows.
|
|
7
|
+
* Handles service discovery, queue routing, and retry logic.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const CookbookRouter = require('./router');
|
|
11
|
+
const ServiceDiscovery = require('./serviceDiscovery');
|
|
12
|
+
const QueueManager = require('./queueManager');
|
|
13
|
+
const RetryHandler = require('./retryHandler');
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
// Main router class
|
|
17
|
+
CookbookRouter,
|
|
18
|
+
|
|
19
|
+
// Supporting classes
|
|
20
|
+
ServiceDiscovery,
|
|
21
|
+
QueueManager,
|
|
22
|
+
RetryHandler,
|
|
23
|
+
|
|
24
|
+
// Factory function
|
|
25
|
+
createRouter: (mqClient, registryClient, options = {}) => {
|
|
26
|
+
return new CookbookRouter(mqClient, registryClient, options);
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
// Utility exports
|
|
30
|
+
VERSION: '1.0.0'
|
|
31
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QueueManager - Queue operations and management
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
class QueueManager {
|
|
8
|
+
constructor(mqClient, options = {}) {
|
|
9
|
+
this.mqClient = mqClient;
|
|
10
|
+
this.options = {
|
|
11
|
+
ensureQueues: options.ensureQueues !== false,
|
|
12
|
+
defaultOptions: {
|
|
13
|
+
durable: true,
|
|
14
|
+
persistent: true,
|
|
15
|
+
...options.defaultOptions
|
|
16
|
+
},
|
|
17
|
+
logger: options.logger || console,
|
|
18
|
+
...options
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
this.ensuredQueues = new Set();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Publish message to queue
|
|
26
|
+
* @param {string} queueName - Target queue name
|
|
27
|
+
* @param {Object} message - Message to publish
|
|
28
|
+
* @param {Object} options - Publishing options
|
|
29
|
+
* @returns {Promise<boolean>}
|
|
30
|
+
*/
|
|
31
|
+
async publish(queueName, message, options = {}) {
|
|
32
|
+
const { logger } = this.options;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
// Ensure queue exists if enabled
|
|
36
|
+
if (this.options.ensureQueues) {
|
|
37
|
+
await this.ensureQueue(queueName);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Add timestamp to message
|
|
41
|
+
const messageWithTimestamp = {
|
|
42
|
+
...message,
|
|
43
|
+
timestamp: new Date().toISOString()
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Merge publishing options
|
|
47
|
+
const publishOptions = {
|
|
48
|
+
...this.options.defaultOptions,
|
|
49
|
+
...options
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
logger.debug(`Publishing to ${queueName}`);
|
|
53
|
+
|
|
54
|
+
// Handle connection errors with retry
|
|
55
|
+
try {
|
|
56
|
+
await this.mqClient.publish(queueName, messageWithTimestamp, publishOptions);
|
|
57
|
+
return true;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// If connection lost, retry once
|
|
60
|
+
if (error.message.includes('Connection lost')) {
|
|
61
|
+
await this.mqClient.publish(queueName, messageWithTimestamp, publishOptions);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
} catch (error) {
|
|
68
|
+
logger.error(`Failed to publish to ${queueName}:`, error);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Ensure queue exists
|
|
75
|
+
* @param {string} queueName - Queue name
|
|
76
|
+
* @param {Object} options - Queue options
|
|
77
|
+
* @returns {Promise<boolean>}
|
|
78
|
+
*/
|
|
79
|
+
async ensureQueue(queueName, options = {}) {
|
|
80
|
+
// Check if already ensured
|
|
81
|
+
if (this.ensuredQueues.has(queueName)) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const queueOptions = {
|
|
86
|
+
...this.options.defaultOptions,
|
|
87
|
+
...options
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await this.mqClient.assertQueue(queueName, queueOptions);
|
|
92
|
+
this.ensuredQueues.add(queueName);
|
|
93
|
+
return true;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Setup consumer for queue
|
|
101
|
+
* @param {string} queueName - Queue to consume from
|
|
102
|
+
* @param {Function} handler - Message handler
|
|
103
|
+
* @param {Object} options - Consumer options
|
|
104
|
+
* @returns {Promise<void>}
|
|
105
|
+
*/
|
|
106
|
+
async consume(queueName, handler, options = {}) {
|
|
107
|
+
const { logger } = this.options;
|
|
108
|
+
|
|
109
|
+
// Ensure queue exists
|
|
110
|
+
if (this.options.ensureQueues) {
|
|
111
|
+
await this.ensureQueue(queueName);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const consumerOptions = {
|
|
115
|
+
noAck: false,
|
|
116
|
+
...options
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Wrap handler with error handling
|
|
120
|
+
const wrappedHandler = async (message) => {
|
|
121
|
+
try {
|
|
122
|
+
// Parse message content
|
|
123
|
+
const content = JSON.parse(message.content.toString());
|
|
124
|
+
|
|
125
|
+
// Call user handler
|
|
126
|
+
await handler(content, message);
|
|
127
|
+
|
|
128
|
+
// Acknowledge message
|
|
129
|
+
this.mqClient.ack(message);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
logger.error(`Error processing message from ${queueName}:`, error);
|
|
132
|
+
// Reject message without requeue
|
|
133
|
+
this.mqClient.nack(message, false, false);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
await this.mqClient.consume(queueName, wrappedHandler, consumerOptions);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get queue information
|
|
142
|
+
* @param {string} queueName - Queue name
|
|
143
|
+
* @returns {Promise<Object>}
|
|
144
|
+
*/
|
|
145
|
+
async getQueueInfo(queueName) {
|
|
146
|
+
return await this.mqClient.checkQueue(queueName);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Purge queue (remove all messages)
|
|
151
|
+
* @param {string} queueName - Queue name
|
|
152
|
+
* @returns {Promise<Object>}
|
|
153
|
+
*/
|
|
154
|
+
async purgeQueue(queueName) {
|
|
155
|
+
const { logger } = this.options;
|
|
156
|
+
const result = await this.mqClient.purgeQueue(queueName);
|
|
157
|
+
logger.warn(`Purged ${result.messageCount} messages from ${queueName}`);
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Delete queue
|
|
163
|
+
* @param {string} queueName - Queue name
|
|
164
|
+
* @returns {Promise<boolean>}
|
|
165
|
+
*/
|
|
166
|
+
async deleteQueue(queueName) {
|
|
167
|
+
const { logger } = this.options;
|
|
168
|
+
const result = await this.mqClient.deleteQueue(queueName);
|
|
169
|
+
|
|
170
|
+
// Remove from ensured queues cache
|
|
171
|
+
this.ensuredQueues.delete(queueName);
|
|
172
|
+
|
|
173
|
+
logger.warn(`Deleted queue ${queueName}`);
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Reset connection and clear cache
|
|
179
|
+
*/
|
|
180
|
+
resetConnection() {
|
|
181
|
+
const { logger } = this.options;
|
|
182
|
+
this.ensuredQueues.clear();
|
|
183
|
+
logger.info('Connection reset, cleared queue cache');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = QueueManager;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RetryHandler - Handles retry logic with exponential backoff
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
class RetryHandler {
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.options = {
|
|
10
|
+
maxAttempts: options.maxAttempts || 3,
|
|
11
|
+
baseDelay: options.baseDelay || 1000,
|
|
12
|
+
maxDelay: options.maxDelay || 30000,
|
|
13
|
+
backoffMultiplier: options.backoffMultiplier || 2,
|
|
14
|
+
logger: options.logger || console,
|
|
15
|
+
...options
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
this.attempts = new Map();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if retry should be attempted
|
|
23
|
+
* @param {string} identifier - Unique identifier for the retry
|
|
24
|
+
* @param {Object} options - Override options
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
shouldRetry(identifier, options = {}) {
|
|
28
|
+
const { logger } = this.options;
|
|
29
|
+
const maxAttempts = options.maxAttempts || this.options.maxAttempts;
|
|
30
|
+
|
|
31
|
+
// Check for non-retryable error
|
|
32
|
+
if (options.error && options.error.retryable === false) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Get current attempt count
|
|
37
|
+
const record = this.attempts.get(identifier);
|
|
38
|
+
const currentAttempts = record ? record.count : 0;
|
|
39
|
+
|
|
40
|
+
const shouldRetry = currentAttempts < maxAttempts;
|
|
41
|
+
|
|
42
|
+
logger.debug(`Retry check for ${identifier}: attempts=${currentAttempts}, max=${maxAttempts}, shouldRetry=${shouldRetry}`);
|
|
43
|
+
|
|
44
|
+
return shouldRetry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get retry delay with exponential backoff
|
|
49
|
+
* @param {string} identifier - Unique identifier
|
|
50
|
+
* @param {Object} options - Override options
|
|
51
|
+
* @returns {number} Delay in milliseconds
|
|
52
|
+
*/
|
|
53
|
+
getRetryDelay(identifier, options = {}) {
|
|
54
|
+
const baseDelay = options.baseDelay || this.options.baseDelay;
|
|
55
|
+
const maxDelay = this.options.maxDelay;
|
|
56
|
+
const backoffMultiplier = this.options.backoffMultiplier;
|
|
57
|
+
const jitter = options.jitter || false;
|
|
58
|
+
|
|
59
|
+
const record = this.attempts.get(identifier);
|
|
60
|
+
const attemptNumber = record ? record.count : 1;
|
|
61
|
+
|
|
62
|
+
// Calculate exponential backoff delay
|
|
63
|
+
let delay = baseDelay * Math.pow(backoffMultiplier, attemptNumber - 1);
|
|
64
|
+
|
|
65
|
+
// Cap at max delay
|
|
66
|
+
delay = Math.min(delay, maxDelay);
|
|
67
|
+
|
|
68
|
+
// Add jitter if requested
|
|
69
|
+
if (jitter) {
|
|
70
|
+
const jitterAmount = delay * 0.1 * Math.random();
|
|
71
|
+
delay = delay + jitterAmount;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return Math.floor(delay);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Record an attempt
|
|
79
|
+
* @param {string} identifier - Unique identifier
|
|
80
|
+
* @param {Object} context - Additional context
|
|
81
|
+
*/
|
|
82
|
+
recordAttempt(identifier, context = {}) {
|
|
83
|
+
const { logger } = this.options;
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
|
|
86
|
+
let record = this.attempts.get(identifier);
|
|
87
|
+
if (!record) {
|
|
88
|
+
record = { count: 0, firstAttempt: now, lastAttempt: now };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
record.count += 1;
|
|
92
|
+
record.lastAttempt = now;
|
|
93
|
+
|
|
94
|
+
if (context.error) {
|
|
95
|
+
record.lastError = context.error;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.attempts.set(identifier, record);
|
|
99
|
+
|
|
100
|
+
logger.debug(`Recording attempt ${record.count} for ${identifier}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Reset retry state for identifier
|
|
105
|
+
* @param {string} identifier - Unique identifier
|
|
106
|
+
*/
|
|
107
|
+
reset(identifier) {
|
|
108
|
+
const { logger } = this.options;
|
|
109
|
+
this.attempts.delete(identifier);
|
|
110
|
+
logger.debug(`Reset retry state for ${identifier}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Reset all retry states
|
|
115
|
+
*/
|
|
116
|
+
resetAll() {
|
|
117
|
+
const { logger } = this.options;
|
|
118
|
+
this.attempts.clear();
|
|
119
|
+
logger.info('Reset all retry states');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get attempt count for identifier
|
|
124
|
+
* @param {string} identifier - Unique identifier
|
|
125
|
+
* @returns {number}
|
|
126
|
+
*/
|
|
127
|
+
getAttemptCount(identifier) {
|
|
128
|
+
const record = this.attempts.get(identifier);
|
|
129
|
+
return record ? record.count : 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Execute function with retry logic
|
|
134
|
+
* @param {string} identifier - Unique identifier
|
|
135
|
+
* @param {Function} fn - Function to execute
|
|
136
|
+
* @param {Object} options - Override options
|
|
137
|
+
* @returns {Promise<*>}
|
|
138
|
+
*/
|
|
139
|
+
async executeWithRetry(identifier, fn, options = {}) {
|
|
140
|
+
const { logger } = this.options;
|
|
141
|
+
const maxAttempts = options.maxAttempts || this.options.maxAttempts;
|
|
142
|
+
|
|
143
|
+
let lastError;
|
|
144
|
+
let attempts = 0;
|
|
145
|
+
|
|
146
|
+
while (attempts < maxAttempts) {
|
|
147
|
+
try {
|
|
148
|
+
const result = await fn();
|
|
149
|
+
return result;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
attempts++;
|
|
152
|
+
lastError = error;
|
|
153
|
+
this.recordAttempt(identifier, { error });
|
|
154
|
+
|
|
155
|
+
if (attempts < maxAttempts) {
|
|
156
|
+
const delay = this.getRetryDelay(identifier, options);
|
|
157
|
+
logger.warn(`Retry attempt ${attempts + 1} for ${identifier} in ${delay}ms`);
|
|
158
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
throw lastError;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Cleanup old attempts
|
|
168
|
+
* @param {number} maxAge - Maximum age in milliseconds
|
|
169
|
+
*/
|
|
170
|
+
cleanup(maxAge = 3600000) { // Default 1 hour
|
|
171
|
+
const { logger } = this.options;
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
let cleaned = 0;
|
|
174
|
+
|
|
175
|
+
for (const [identifier, record] of this.attempts.entries()) {
|
|
176
|
+
if (now - record.lastAttempt > maxAge) {
|
|
177
|
+
this.attempts.delete(identifier);
|
|
178
|
+
cleaned++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
logger.debug(`Cleaned up ${cleaned} old retry records`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = RetryHandler;
|
package/src/router.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CookbookRouter - Main routing class for workflow messages
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const ServiceDiscovery = require('./serviceDiscovery');
|
|
8
|
+
const QueueManager = require('./queueManager');
|
|
9
|
+
const RetryHandler = require('./retryHandler');
|
|
10
|
+
|
|
11
|
+
class CookbookRouter {
|
|
12
|
+
constructor(mqClient, registryClient, options = {}) {
|
|
13
|
+
this.mqClient = mqClient;
|
|
14
|
+
this.registryClient = registryClient;
|
|
15
|
+
this.options = {
|
|
16
|
+
defaultQueue: 'workflow.init',
|
|
17
|
+
completedQueue: 'workflow.completed',
|
|
18
|
+
dlqSuffix: '.dlq',
|
|
19
|
+
maxRetries: 3,
|
|
20
|
+
retryDelay: 2000,
|
|
21
|
+
logger: console,
|
|
22
|
+
...options
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
this.serviceDiscovery = new ServiceDiscovery(registryClient, options);
|
|
26
|
+
this.queueManager = new QueueManager(mqClient, options);
|
|
27
|
+
this.retryHandler = new RetryHandler(options);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Route workflow to the first service
|
|
32
|
+
* @param {Object} cookbook - Cookbook definition
|
|
33
|
+
* @param {Object} context - Workflow context
|
|
34
|
+
* @returns {Promise<void>}
|
|
35
|
+
*/
|
|
36
|
+
async routeWorkflow(cookbook, context) {
|
|
37
|
+
const { logger } = this.options;
|
|
38
|
+
|
|
39
|
+
// Find first step
|
|
40
|
+
const firstStep = cookbook.steps?.[0];
|
|
41
|
+
if (!firstStep) {
|
|
42
|
+
throw new Error('No steps defined in cookbook');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Determine target service
|
|
46
|
+
const targetService = await this.determineTargetService(firstStep);
|
|
47
|
+
|
|
48
|
+
// Build workflow message
|
|
49
|
+
const message = this.buildWorkflowMessage(cookbook, context, firstStep, targetService);
|
|
50
|
+
|
|
51
|
+
// Send to service queue
|
|
52
|
+
const queueName = `${targetService}.workflow`;
|
|
53
|
+
logger.info(`Routing workflow to ${queueName}`);
|
|
54
|
+
|
|
55
|
+
await this.queueManager.publish(queueName, message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Route to next service in workflow
|
|
60
|
+
* @param {Object} cookbook - Cookbook definition
|
|
61
|
+
* @param {Object} context - Current context
|
|
62
|
+
* @param {string} currentStepId - Current step ID
|
|
63
|
+
* @returns {Promise<void>}
|
|
64
|
+
*/
|
|
65
|
+
async routeToNextService(cookbook, context, currentStepId) {
|
|
66
|
+
const { logger } = this.options;
|
|
67
|
+
|
|
68
|
+
// Find current step index
|
|
69
|
+
const currentIndex = cookbook.steps.findIndex(s => s.id === currentStepId);
|
|
70
|
+
if (currentIndex === -1) {
|
|
71
|
+
throw new Error(`Step not found: ${currentStepId}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check if there's a next step
|
|
75
|
+
const nextStep = cookbook.steps[currentIndex + 1];
|
|
76
|
+
if (!nextStep) {
|
|
77
|
+
// Workflow completed
|
|
78
|
+
logger.info('Workflow completed, routing to completed queue');
|
|
79
|
+
return this.routeToCompleted(cookbook, context);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Determine target service for next step
|
|
83
|
+
const targetService = await this.determineTargetService(nextStep);
|
|
84
|
+
|
|
85
|
+
// Build message for next step
|
|
86
|
+
const message = this.buildWorkflowMessage(cookbook, context, nextStep, targetService);
|
|
87
|
+
|
|
88
|
+
// Send to next service
|
|
89
|
+
const queueName = `${targetService}.workflow`;
|
|
90
|
+
logger.info(`Routing to next service: ${queueName}`);
|
|
91
|
+
|
|
92
|
+
await this.queueManager.publish(queueName, message);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Route completed workflow
|
|
97
|
+
* @param {Object} cookbook - Cookbook definition
|
|
98
|
+
* @param {Object} context - Final context
|
|
99
|
+
* @returns {Promise<void>}
|
|
100
|
+
*/
|
|
101
|
+
async routeToCompleted(cookbook, context) {
|
|
102
|
+
const message = {
|
|
103
|
+
workflow_id: context.workflow_id,
|
|
104
|
+
cookbook_id: cookbook.id,
|
|
105
|
+
status: 'completed',
|
|
106
|
+
result: context.result || {},
|
|
107
|
+
trace: context.trace || [],
|
|
108
|
+
completed_at: new Date().toISOString()
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
await this.queueManager.publish(this.options.completedQueue, message);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Route failed workflow to DLQ
|
|
116
|
+
* @param {Object} cookbook - Cookbook definition
|
|
117
|
+
* @param {Object} context - Context at failure
|
|
118
|
+
* @param {Error} error - Error that caused failure
|
|
119
|
+
* @param {string} service - Service that failed
|
|
120
|
+
* @returns {Promise<void>}
|
|
121
|
+
*/
|
|
122
|
+
async routeToDLQ(cookbook, context, error, service) {
|
|
123
|
+
const { logger } = this.options;
|
|
124
|
+
|
|
125
|
+
const dlqName = `${service}${this.options.dlqSuffix}`;
|
|
126
|
+
logger.error(`Routing to DLQ: ${dlqName}`, error);
|
|
127
|
+
|
|
128
|
+
const message = {
|
|
129
|
+
workflow_id: context.workflow_id,
|
|
130
|
+
cookbook_id: cookbook.id,
|
|
131
|
+
service,
|
|
132
|
+
error: {
|
|
133
|
+
message: error.message,
|
|
134
|
+
stack: error.stack,
|
|
135
|
+
code: error.code
|
|
136
|
+
},
|
|
137
|
+
context,
|
|
138
|
+
failed_at: new Date().toISOString()
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
await this.queueManager.publish(dlqName, message);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Determine target service for a step
|
|
146
|
+
* @private
|
|
147
|
+
*/
|
|
148
|
+
async determineTargetService(step) {
|
|
149
|
+
if (step.type === 'task' && step.service) {
|
|
150
|
+
// Verify service is available
|
|
151
|
+
const isAvailable = await this.serviceDiscovery.isServiceAvailable(step.service);
|
|
152
|
+
if (!isAvailable) {
|
|
153
|
+
throw new Error(`Service not available: ${step.service}`);
|
|
154
|
+
}
|
|
155
|
+
return step.service;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Control flow steps go to orchestrator
|
|
159
|
+
if (['foreach', 'switch', 'fork_join'].includes(step.type)) {
|
|
160
|
+
return 'workflow.orchestrator';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
throw new Error(`Unknown step type: ${step.type}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build workflow message
|
|
168
|
+
* @private
|
|
169
|
+
*/
|
|
170
|
+
buildWorkflowMessage(cookbook, context, step, targetService) {
|
|
171
|
+
return {
|
|
172
|
+
workflow_id: context.workflow_id,
|
|
173
|
+
cookbook,
|
|
174
|
+
context,
|
|
175
|
+
step,
|
|
176
|
+
target_service: targetService,
|
|
177
|
+
timestamp: new Date().toISOString()
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Handle retry logic for steps
|
|
183
|
+
* @param {Object} step - Step to retry
|
|
184
|
+
* @param {Object} context - Current context
|
|
185
|
+
* @returns {Promise<boolean>}
|
|
186
|
+
*/
|
|
187
|
+
async handleRetry(step, context) {
|
|
188
|
+
const stepId = step.id;
|
|
189
|
+
const attempts = context.attempts?.[stepId] || 0;
|
|
190
|
+
|
|
191
|
+
// Check if should retry
|
|
192
|
+
const shouldRetry = this.retryHandler.shouldRetry(stepId, { attempts });
|
|
193
|
+
|
|
194
|
+
if (shouldRetry) {
|
|
195
|
+
// Record attempt
|
|
196
|
+
this.retryHandler.recordAttempt(stepId);
|
|
197
|
+
|
|
198
|
+
// Apply delay
|
|
199
|
+
const delay = this.retryHandler.getRetryDelay(stepId);
|
|
200
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return shouldRetry;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = CookbookRouter;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ServiceDiscovery - Service discovery and health checking
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
class ServiceDiscovery {
|
|
8
|
+
constructor(registryClient, options = {}) {
|
|
9
|
+
this.registryClient = registryClient;
|
|
10
|
+
this.options = {
|
|
11
|
+
cacheEnabled: options.cacheEnabled !== false,
|
|
12
|
+
cacheTTL: options.cacheTTL || 300000, // 5 minutes default
|
|
13
|
+
logger: options.logger || console,
|
|
14
|
+
...options
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
this.cache = new Map();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if a service is available
|
|
22
|
+
* @param {string} serviceName - Service name
|
|
23
|
+
* @returns {Promise<boolean>}
|
|
24
|
+
*/
|
|
25
|
+
async isServiceAvailable(serviceName) {
|
|
26
|
+
try {
|
|
27
|
+
// Check cache first if enabled
|
|
28
|
+
if (this.options.cacheEnabled) {
|
|
29
|
+
const cached = this.getCached(serviceName);
|
|
30
|
+
if (cached !== null) {
|
|
31
|
+
return cached.status === 'active';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const service = await this.registryClient.getService(serviceName);
|
|
36
|
+
|
|
37
|
+
if (service && this.options.cacheEnabled) {
|
|
38
|
+
this.setCached(serviceName, service);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return service && service.status === 'active';
|
|
42
|
+
} catch (error) {
|
|
43
|
+
// Handle specific error codes
|
|
44
|
+
if (error.code === 'ECONNREFUSED') {
|
|
45
|
+
this.options.logger.error('Registry connection failed', { serviceName, error });
|
|
46
|
+
} else {
|
|
47
|
+
this.options.logger.error(`Service discovery failed for ${serviceName}:`, error);
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get service information
|
|
55
|
+
* @param {string} serviceName - Service name
|
|
56
|
+
* @returns {Promise<Object>}
|
|
57
|
+
*/
|
|
58
|
+
async getServiceInfo(serviceName) {
|
|
59
|
+
this.options.logger.debug(`Getting service info for ${serviceName}`);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// Check cache first if enabled
|
|
63
|
+
if (this.options.cacheEnabled) {
|
|
64
|
+
const cached = this.getCached(serviceName);
|
|
65
|
+
if (cached !== null) {
|
|
66
|
+
return cached;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const service = await this.registryClient.getService(serviceName);
|
|
71
|
+
|
|
72
|
+
if (!service) {
|
|
73
|
+
throw new Error(`Service not found: ${serviceName}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (this.options.cacheEnabled) {
|
|
77
|
+
this.setCached(serviceName, service);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return service;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
this.options.logger.error(`Failed to get service info for ${serviceName}:`, error);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* List available services
|
|
89
|
+
* @returns {Promise<Array>}
|
|
90
|
+
*/
|
|
91
|
+
async listAvailableServices() {
|
|
92
|
+
try {
|
|
93
|
+
const services = await this.registryClient.listServices();
|
|
94
|
+
return services
|
|
95
|
+
.filter(service => service.status === 'active')
|
|
96
|
+
.map(service => service.name);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
this.options.logger.error('Failed to list services:', error);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get service queue name
|
|
105
|
+
* @param {string} serviceName - Service name
|
|
106
|
+
* @param {Object} options - Options
|
|
107
|
+
* @returns {Promise<string>}
|
|
108
|
+
*/
|
|
109
|
+
async getServiceQueue(serviceName, options = {}) {
|
|
110
|
+
const serviceInfo = await this.getServiceInfo(serviceName);
|
|
111
|
+
|
|
112
|
+
if (serviceInfo.endpoints && serviceInfo.endpoints.workflow) {
|
|
113
|
+
return serviceInfo.endpoints.workflow;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (options.useDefault) {
|
|
117
|
+
return `${serviceName}.workflow`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw new Error(`No workflow queue defined for service: ${serviceName}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Invalidate cache
|
|
125
|
+
* @param {string} serviceName - Service name (optional)
|
|
126
|
+
*/
|
|
127
|
+
invalidateCache(serviceName) {
|
|
128
|
+
if (serviceName) {
|
|
129
|
+
this.cache.delete(serviceName);
|
|
130
|
+
this.options.logger.debug(`Invalidating cache for ${serviceName}`);
|
|
131
|
+
} else {
|
|
132
|
+
this.cache.clear();
|
|
133
|
+
this.options.logger.debug('Invalidating entire cache');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get cached service data
|
|
139
|
+
* @private
|
|
140
|
+
*/
|
|
141
|
+
getCached(serviceName) {
|
|
142
|
+
if (!this.options.cacheEnabled) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const cached = this.cache.get(serviceName);
|
|
147
|
+
|
|
148
|
+
if (!cached) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const age = Date.now() - cached.timestamp;
|
|
153
|
+
if (age > this.options.cacheTTL) {
|
|
154
|
+
this.cache.delete(serviceName);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return cached.data;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Set cached service data
|
|
163
|
+
* @private
|
|
164
|
+
*/
|
|
165
|
+
setCached(serviceName, data) {
|
|
166
|
+
this.cache.set(serviceName, {
|
|
167
|
+
data,
|
|
168
|
+
timestamp: Date.now()
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = ServiceDiscovery;
|