@onlineapps/cookbook-router 1.0.35 → 2.0.0-rc.1
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 +59 -180
- package/package.json +2 -6
- package/src/index.js +14 -5
- package/src/queueManager.js +0 -87
- package/src/router.js +19 -185
- package/src/serviceDiscovery.js +0 -84
- package/src/retryHandler.js +0 -186
package/README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# @onlineapps/cookbook-router
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Publishes a workflow message to a service's workflow queue, after checking with
|
|
4
|
+
the registry that the service is available.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
That is the whole package. It does not execute cookbooks, does not walk steps,
|
|
7
|
+
does not retry, does not complete or dead-letter a workflow, and does not
|
|
8
|
+
consume anything. All of that belongs to `WorkflowOrchestrator` in
|
|
9
|
+
`@onlineapps/conn-orch-orchestrator` — confirmation
|
|
10
|
+
[`cookbook-execution-owner.md`](/api/docs/governance/confirmations/cookbook-execution-owner.md)
|
|
11
|
+
001 — which is also this package's only caller.
|
|
8
12
|
|
|
9
13
|
## Installation
|
|
10
14
|
|
|
@@ -12,204 +16,79 @@ This package provides routing capabilities for cookbook workflows in a distribut
|
|
|
12
16
|
npm install @onlineapps/cookbook-router
|
|
13
17
|
```
|
|
14
18
|
|
|
15
|
-
##
|
|
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
|
|
19
|
+
## The whole surface
|
|
116
20
|
|
|
117
21
|
```javascript
|
|
118
|
-
const {
|
|
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();
|
|
22
|
+
const { createRouter, CookbookRouter, ServiceDiscovery, QueueManager } =
|
|
23
|
+
require('@onlineapps/cookbook-router');
|
|
134
24
|
```
|
|
135
25
|
|
|
136
|
-
|
|
26
|
+
| Export | What it is |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `createRouter(mqClient, registryClient, options)` | Factory returning a `CookbookRouter`. This is what `WorkflowOrchestrator.js:71` takes. |
|
|
29
|
+
| `CookbookRouter` | One method: `routeToService(serviceName, message)`. |
|
|
30
|
+
| `ServiceDiscovery` | One method: `isServiceAvailable(serviceName)`, with a TTL cache. |
|
|
31
|
+
| `QueueManager` | Two methods: `publish(queue, message, options)` and `ensureQueue(queue, options)`. |
|
|
32
|
+
| `VERSION` | String constant. |
|
|
137
33
|
|
|
138
|
-
|
|
34
|
+
## Usage
|
|
139
35
|
|
|
140
36
|
```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
|
-
```
|
|
37
|
+
const { createRouter } = require('@onlineapps/cookbook-router');
|
|
150
38
|
|
|
151
|
-
|
|
39
|
+
const router = createRouter(mqClient, registryClient, { logger });
|
|
152
40
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
cacheTTL: 60000, // Cache TTL in ms
|
|
156
|
-
logger: console // Logger instance
|
|
157
|
-
}
|
|
41
|
+
// Publishes to `biz-invoicing.workflow`.
|
|
42
|
+
await router.routeToService('biz-invoicing', workflowMessage);
|
|
158
43
|
```
|
|
159
44
|
|
|
160
|
-
|
|
45
|
+
`routeToService` fails fast and never publishes a message it could not place:
|
|
161
46
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
```
|
|
47
|
+
| Condition | Result |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `serviceName` missing or not a string | throws `[CookbookRouter] routeToService - serviceName is required and must be a string` |
|
|
50
|
+
| `message` missing or not an object | throws `[CookbookRouter] routeToService - message is required and must be an object` |
|
|
51
|
+
| registry says the service is not `active` | throws `[CookbookRouter] Service not available: <name>` |
|
|
52
|
+
| the publish itself fails | the underlying error propagates unchanged |
|
|
169
53
|
|
|
170
|
-
|
|
54
|
+
## Options
|
|
171
55
|
|
|
172
|
-
|
|
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
|
-
```
|
|
56
|
+
The single caller passes `{ logger }` and nothing else. `options` is forwarded
|
|
57
|
+
verbatim to both collaborators, so their keys travel through it:
|
|
182
58
|
|
|
183
|
-
|
|
59
|
+
| Key | Read by | Default |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `logger` | `CookbookRouter`, `ServiceDiscovery`, `QueueManager` | `console` |
|
|
62
|
+
| `cacheEnabled` | `ServiceDiscovery` | `true` |
|
|
63
|
+
| `cacheTTL` | `ServiceDiscovery` (ms) | `300000` |
|
|
64
|
+
| `ensureQueues` | `QueueManager` — assert the queue before the first publish | `true` |
|
|
65
|
+
| `defaultOptions` | `QueueManager` — merged into every publish and assert | `{ durable: true, persistent: true }` |
|
|
184
66
|
|
|
185
|
-
|
|
186
|
-
- `{service}.queue` - Direct messages
|
|
187
|
-
- `{service}.dlq` - Dead letter queue
|
|
188
|
-
- `workflow.init` - Entry point
|
|
189
|
-
- `workflow.completed` - Completed workflows
|
|
67
|
+
A key not in this table is not read by anything here.
|
|
190
68
|
|
|
191
|
-
##
|
|
69
|
+
## Queue naming
|
|
192
70
|
|
|
193
|
-
|
|
71
|
+
`routeToService` publishes to `<serviceName>.workflow`. The package builds no
|
|
72
|
+
other queue name; `workflow.completed` and the `.dlq` queues are the
|
|
73
|
+
orchestrator's, not this package's.
|
|
194
74
|
|
|
195
|
-
|
|
196
|
-
- Network timeouts
|
|
197
|
-
- Connection refused
|
|
198
|
-
- Service temporarily unavailable
|
|
75
|
+
## Collaborator contracts
|
|
199
76
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
-
|
|
204
|
-
|
|
77
|
+
- `registryClient.getService(serviceName)` resolves to an object whose `status`
|
|
78
|
+
is `'active'` when the service is up. A rejection is logged and read as "not
|
|
79
|
+
available" — `isServiceAvailable` returns `false`, it does not throw.
|
|
80
|
+
- `mqClient.assertQueue(queue, options)` and `mqClient.publish(queue, message,
|
|
81
|
+
options)`. `publish` retries exactly once when the error message contains
|
|
82
|
+
`Connection lost`; every other error propagates.
|
|
205
83
|
|
|
206
|
-
## Related
|
|
84
|
+
## Related packages
|
|
207
85
|
|
|
208
|
-
- `@onlineapps/cookbook-core`
|
|
209
|
-
- `@onlineapps/cookbook
|
|
210
|
-
|
|
211
|
-
- `@onlineapps/
|
|
86
|
+
- `@onlineapps/cookbook-core` — cookbook parsing and validation
|
|
87
|
+
- `@onlineapps/conn-orch-cookbook` — the wrapper that re-exports this package to
|
|
88
|
+
`ServiceWrapper`
|
|
89
|
+
- `@onlineapps/conn-orch-orchestrator` — the owner of execution, retry, DLQ and
|
|
90
|
+
completion
|
|
212
91
|
|
|
213
92
|
## License
|
|
214
93
|
|
|
215
|
-
PROPRIETARY - All rights reserved
|
|
94
|
+
PROPRIETARY - All rights reserved
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/cookbook-router",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-rc.1",
|
|
4
4
|
"description": "Message routing for cookbook workflows - handles service discovery and queue routing",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -18,11 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"author": "OnlineApps",
|
|
20
20
|
"license": "PROPRIETARY",
|
|
21
|
-
"dependencies": {
|
|
22
|
-
"@onlineapps/conn-infra-mq": "1.1.70",
|
|
23
|
-
"@onlineapps/conn-orch-registry": "1.2.2",
|
|
24
|
-
"@onlineapps/cookbook-core": "2.1.16"
|
|
25
|
-
},
|
|
21
|
+
"dependencies": {},
|
|
26
22
|
"devDependencies": {
|
|
27
23
|
"jest": "^29.7.0"
|
|
28
24
|
},
|
package/src/index.js
CHANGED
|
@@ -3,14 +3,24 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @module @onlineapps/cookbook-router
|
|
5
5
|
*
|
|
6
|
-
* Message routing
|
|
7
|
-
*
|
|
6
|
+
* Message routing for cookbook workflows: given a service name and a workflow
|
|
7
|
+
* message, publish it to that service's workflow queue.
|
|
8
|
+
*
|
|
9
|
+
* The package does NOT execute cookbooks, drive flow control, retry, complete
|
|
10
|
+
* or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
|
|
11
|
+
* `api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
|
|
12
|
+
* is also the only caller: it takes `createRouter`
|
|
13
|
+
* (WorkflowOrchestrator.js:71) and calls `routeToService` on the result
|
|
14
|
+
* (:78,233,241,1223,1230). The second routing rail, the retry rail and the
|
|
15
|
+
* queue/registry administration this package used to carry had zero callers
|
|
16
|
+
* outside it and were removed on 2026-09-02.
|
|
17
|
+
*
|
|
18
|
+
* @see /api/shared/cookbook/cookbook-router/README.md
|
|
8
19
|
*/
|
|
9
20
|
|
|
10
21
|
const CookbookRouter = require('./router');
|
|
11
22
|
const ServiceDiscovery = require('./serviceDiscovery');
|
|
12
23
|
const QueueManager = require('./queueManager');
|
|
13
|
-
const RetryHandler = require('./retryHandler');
|
|
14
24
|
|
|
15
25
|
module.exports = {
|
|
16
26
|
// Main router class
|
|
@@ -19,7 +29,6 @@ module.exports = {
|
|
|
19
29
|
// Supporting classes
|
|
20
30
|
ServiceDiscovery,
|
|
21
31
|
QueueManager,
|
|
22
|
-
RetryHandler,
|
|
23
32
|
|
|
24
33
|
// Factory function
|
|
25
34
|
createRouter: (mqClient, registryClient, options = {}) => {
|
|
@@ -28,4 +37,4 @@ module.exports = {
|
|
|
28
37
|
|
|
29
38
|
// Utility exports
|
|
30
39
|
VERSION: '1.0.0'
|
|
31
|
-
};
|
|
40
|
+
};
|
package/src/queueManager.js
CHANGED
|
@@ -95,93 +95,6 @@ class QueueManager {
|
|
|
95
95
|
throw error;
|
|
96
96
|
}
|
|
97
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
98
|
}
|
|
186
99
|
|
|
187
100
|
module.exports = QueueManager;
|
package/src/router.js
CHANGED
|
@@ -1,181 +1,40 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* CookbookRouter -
|
|
4
|
+
* CookbookRouter - routes a workflow message to a service queue.
|
|
5
|
+
*
|
|
6
|
+
* ONE responsibility, ONE method: `routeToService`. Execution, flow control,
|
|
7
|
+
* retry, DLQ and completion belong to `WorkflowOrchestrator`
|
|
8
|
+
* (confirmation `api/docs/governance/confirmations/cookbook-execution-owner.md`
|
|
9
|
+
* 001), which is also this class's only caller —
|
|
10
|
+
* `WorkflowOrchestrator.js:78,233,241,1223,1230`.
|
|
11
|
+
*
|
|
12
|
+
* @see /api/shared/cookbook/cookbook-router/README.md
|
|
5
13
|
*/
|
|
6
14
|
|
|
7
15
|
const ServiceDiscovery = require('./serviceDiscovery');
|
|
8
16
|
const QueueManager = require('./queueManager');
|
|
9
|
-
const RetryHandler = require('./retryHandler');
|
|
10
17
|
|
|
11
18
|
class CookbookRouter {
|
|
12
19
|
constructor(mqClient, registryClient, options = {}) {
|
|
13
20
|
this.mqClient = mqClient;
|
|
14
21
|
this.registryClient = registryClient;
|
|
22
|
+
// `logger` is the only key this class reads. The block used to declare
|
|
23
|
+
// `defaultQueue`, `completedQueue`, `dlqSuffix`, `maxRetries` and
|
|
24
|
+
// `retryDelay` as well: the first three fed the routing rail removed in
|
|
25
|
+
// DÁVKA 83, and the last two had no reader even before it — `RetryHandler`
|
|
26
|
+
// keyed on `maxAttempts`/`baseDelay`, never on these. A declaration nothing
|
|
27
|
+
// reads is dead (`.claude/rules/change-discipline.md` § Removing).
|
|
28
|
+
// `options` is still forwarded whole to the two collaborators below, so
|
|
29
|
+
// their own keys (`cacheEnabled`, `cacheTTL`, `ensureQueues`,
|
|
30
|
+
// `defaultOptions`) reach them unchanged.
|
|
15
31
|
this.options = {
|
|
16
|
-
defaultQueue: 'workflow.init',
|
|
17
|
-
completedQueue: 'workflow.completed',
|
|
18
|
-
dlqSuffix: '.dlq',
|
|
19
|
-
maxRetries: 3,
|
|
20
|
-
retryDelay: 2000,
|
|
21
32
|
logger: console,
|
|
22
33
|
...options
|
|
23
34
|
};
|
|
24
35
|
|
|
25
36
|
this.serviceDiscovery = new ServiceDiscovery(registryClient, options);
|
|
26
37
|
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
38
|
}
|
|
180
39
|
|
|
181
40
|
/**
|
|
@@ -206,31 +65,6 @@ class CookbookRouter {
|
|
|
206
65
|
|
|
207
66
|
await this.queueManager.publish(queueName, message);
|
|
208
67
|
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Handle retry logic for steps
|
|
212
|
-
* @param {Object} step - Step to retry
|
|
213
|
-
* @param {Object} context - Current context
|
|
214
|
-
* @returns {Promise<boolean>}
|
|
215
|
-
*/
|
|
216
|
-
async handleRetry(step, context) {
|
|
217
|
-
const stepId = step.id;
|
|
218
|
-
const attempts = context.attempts?.[stepId] || 0;
|
|
219
|
-
|
|
220
|
-
// Check if should retry
|
|
221
|
-
const shouldRetry = this.retryHandler.shouldRetry(stepId, { attempts });
|
|
222
|
-
|
|
223
|
-
if (shouldRetry) {
|
|
224
|
-
// Record attempt
|
|
225
|
-
this.retryHandler.recordAttempt(stepId);
|
|
226
|
-
|
|
227
|
-
// Apply delay
|
|
228
|
-
const delay = this.retryHandler.getRetryDelay(stepId);
|
|
229
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return shouldRetry;
|
|
233
|
-
}
|
|
234
68
|
}
|
|
235
69
|
|
|
236
|
-
module.exports = CookbookRouter;
|
|
70
|
+
module.exports = CookbookRouter;
|
package/src/serviceDiscovery.js
CHANGED
|
@@ -50,90 +50,6 @@ class ServiceDiscovery {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
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
53
|
/**
|
|
138
54
|
* Get cached service data
|
|
139
55
|
* @private
|
package/src/retryHandler.js
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
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;
|