@onlineapps/conn-orch-orchestrator 2.0.2 → 2.1.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 +72 -0
- package/package.json +2 -2
- package/src/WorkflowOrchestrator.js +85 -4
package/README.md
CHANGED
|
@@ -44,6 +44,78 @@ const stats = orchestrator.getStats();
|
|
|
44
44
|
console.log(`Processed: ${stats.processed}, Failed: ${stats.failed}`);
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
## Cache Invalidation for Mutating Operations (`operationMutatesLookup`)
|
|
48
|
+
|
|
49
|
+
The orchestrator memoizes successful task-step results in the optional `cache` connector under
|
|
50
|
+
keys of the form `workflow:step:<step.service>:<operation>:<hash>` so that idempotent reads
|
|
51
|
+
inside the same workflow can be reused. Mutating operations (writes, deletes, RPC side effects)
|
|
52
|
+
must NOT be served from this cache, and any cached reads against the same target service must
|
|
53
|
+
be purged the moment a write succeeds.
|
|
54
|
+
|
|
55
|
+
To enable that behavior, pass an `operationMutatesLookup` hook to the constructor. The hook is
|
|
56
|
+
typically wired by `@onlineapps/service-wrapper` from each service's `operations.json` map.
|
|
57
|
+
|
|
58
|
+
### Signature
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} executingServiceName Name of the service running the cookbook.
|
|
63
|
+
* @param {Object} step The cookbook step about to execute.
|
|
64
|
+
* @returns {{ mutates?: boolean, resource_type?: string|null }|null|undefined}
|
|
65
|
+
*/
|
|
66
|
+
operationMutatesLookup(executingServiceName, step)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Return shape
|
|
70
|
+
|
|
71
|
+
- `{ mutates: true }` — force a read-cache bypass for this step AND, after the handler
|
|
72
|
+
returns success (`status` 2xx), invoke
|
|
73
|
+
`cache.deleteByPattern('workflow:step:<step.service>:*')` to purge stale reads.
|
|
74
|
+
- `{ mutates: false }` (or `undefined` / `null`) — treat as a read; the orchestrator may
|
|
75
|
+
reuse `cache.get` and store fresh results via `cache.set`.
|
|
76
|
+
- The `resource_type` field is informational and is not consumed by the orchestrator (yet).
|
|
77
|
+
|
|
78
|
+
### Error contract
|
|
79
|
+
|
|
80
|
+
If the hook throws, `_stepMutatesFlag` re-throws the same error. The orchestrator does NOT
|
|
81
|
+
silently swallow lookup failures — per
|
|
82
|
+
[architecture-principles.mdc](/docs/architecture/architecture-principles.md) §4 we fail
|
|
83
|
+
loudly so misconfigured `operations.json` is caught at workflow runtime, not by data drift.
|
|
84
|
+
|
|
85
|
+
### Fallback behavior without the hook
|
|
86
|
+
|
|
87
|
+
If `operationMutatesLookup` is omitted or is not a function, the orchestrator only treats a
|
|
88
|
+
step as mutating when the cookbook step itself sets `step.mutates === true`. Otherwise every
|
|
89
|
+
task step is eligible for read-cache reuse.
|
|
90
|
+
|
|
91
|
+
### Example
|
|
92
|
+
|
|
93
|
+
```javascript
|
|
94
|
+
const WorkflowOrchestrator = require('@onlineapps/conn-orch-orchestrator');
|
|
95
|
+
|
|
96
|
+
const operations = {
|
|
97
|
+
'get-invoice': { mutates: false, resource_type: 'invoice' },
|
|
98
|
+
'update-invoice': { mutates: true, resource_type: 'invoice' }
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const orchestrator = new WorkflowOrchestrator({
|
|
102
|
+
mqClient,
|
|
103
|
+
registryClient,
|
|
104
|
+
invoker,
|
|
105
|
+
cookbook,
|
|
106
|
+
cache,
|
|
107
|
+
logger,
|
|
108
|
+
defaultTimeout: 30000,
|
|
109
|
+
operationMutatesLookup: (executingServiceName, step) => {
|
|
110
|
+
const spec = operations[step.operation];
|
|
111
|
+
return {
|
|
112
|
+
mutates: !!(spec && spec.mutates === true),
|
|
113
|
+
resource_type: spec ? spec.resource_type : null
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
47
119
|
## Configuration
|
|
48
120
|
|
|
49
121
|
| Variable | Description | Default |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/conn-orch-orchestrator",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Workflow orchestration connector for OA Drive - handles message routing and workflow execution",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@onlineapps/conn-base-monitoring": "1.0.12",
|
|
25
25
|
"@onlineapps/conn-infra-mq": "1.1.70",
|
|
26
26
|
"@onlineapps/conn-orch-registry": "1.2.1",
|
|
27
|
-
"@onlineapps/conn-orch-cookbook": "2.1.
|
|
27
|
+
"@onlineapps/conn-orch-cookbook": "2.1.3"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"jest": "^29.5.0",
|
|
@@ -24,6 +24,11 @@ class WorkflowOrchestrator {
|
|
|
24
24
|
* @param {Object} [config.errorHandler] - Error handler connector
|
|
25
25
|
* @param {Object} [config.logger] - Logger instance
|
|
26
26
|
* @param {number} [config.defaultTimeout=30000] - Default timeout for operations
|
|
27
|
+
* @param {(executingServiceName: string, step: Object) =>
|
|
28
|
+
* {mutates?: boolean}|null|undefined} [config.operationMutatesLookup]
|
|
29
|
+
* Optional hook (usually service-wrapper bound to operations.json map). Returning
|
|
30
|
+
* `{ mutates: true }` forces task executions to bypass read-through cache reuse and
|
|
31
|
+
* deletes `workflow:step:<step.service>:*` after successful handler completion.
|
|
27
32
|
*/
|
|
28
33
|
constructor(config) {
|
|
29
34
|
if (!config.mqClient) throw new Error('mqClient is required');
|
|
@@ -49,6 +54,10 @@ class WorkflowOrchestrator {
|
|
|
49
54
|
throw new Error('[WorkflowOrchestrator] defaultTimeout is required — Expected number (ms)');
|
|
50
55
|
}
|
|
51
56
|
this.defaultTimeout = config.defaultTimeout;
|
|
57
|
+
this._operationMutatesLookup =
|
|
58
|
+
typeof config.operationMutatesLookup === 'function'
|
|
59
|
+
? config.operationMutatesLookup
|
|
60
|
+
: null;
|
|
52
61
|
|
|
53
62
|
// INTENTIONAL FALLBACK: operational defaults — see docs/standards/FALLBACKS_INVENTORY.md §5.2
|
|
54
63
|
this.retryConfig = {
|
|
@@ -79,6 +88,8 @@ class WorkflowOrchestrator {
|
|
|
79
88
|
* legacy placements from transports that move AMQP headers onto the
|
|
80
89
|
* parsed message object. Returns undefined if none are set - the caller
|
|
81
90
|
* is responsible for fail-fast.
|
|
91
|
+
*
|
|
92
|
+
* @see api/docs/standards/workflow-message-contract.md
|
|
82
93
|
* @private
|
|
83
94
|
* @param {Object} message - Inbound workflow message
|
|
84
95
|
* @returns {string|undefined}
|
|
@@ -176,6 +187,7 @@ class WorkflowOrchestrator {
|
|
|
176
187
|
// correlation_id is required by RFC §5.8 — validated below inside the
|
|
177
188
|
// try block so malformed messages flow through the retry/DLQ pipeline
|
|
178
189
|
// and a `failed` monitoring event is emitted (no silent redelivery).
|
|
190
|
+
// @see api/docs/standards/workflow-message-contract.md
|
|
179
191
|
const correlationId = this._extractCorrelationId(message);
|
|
180
192
|
|
|
181
193
|
try {
|
|
@@ -274,8 +286,13 @@ class WorkflowOrchestrator {
|
|
|
274
286
|
delivery: cookbookDef.delivery
|
|
275
287
|
};
|
|
276
288
|
|
|
289
|
+
const stepMutates = this._stepMutatesFlag(step, serviceName);
|
|
290
|
+
|
|
277
291
|
let result;
|
|
278
|
-
|
|
292
|
+
const useReadCache =
|
|
293
|
+
Boolean(this.cache) && step.type === 'task' && !stepMutates;
|
|
294
|
+
|
|
295
|
+
if (useReadCache) {
|
|
279
296
|
const cacheKey = this._getCacheKey(step, stepContext);
|
|
280
297
|
result = await this.cache.get(cacheKey);
|
|
281
298
|
if (!result) {
|
|
@@ -635,7 +652,8 @@ class WorkflowOrchestrator {
|
|
|
635
652
|
data: context,
|
|
636
653
|
workflow_id: context?.workflow_id || null,
|
|
637
654
|
step_id: stepId,
|
|
638
|
-
service: serviceName || null
|
|
655
|
+
service: serviceName || null,
|
|
656
|
+
logger: this.logger
|
|
639
657
|
};
|
|
640
658
|
|
|
641
659
|
const resolvedInput = await this._resolveInputReferencesAsync(step.input, context, helperContext);
|
|
@@ -657,6 +675,12 @@ class WorkflowOrchestrator {
|
|
|
657
675
|
});
|
|
658
676
|
|
|
659
677
|
if (envelope && envelope.status >= 200 && envelope.status < 300) {
|
|
678
|
+
const mutated = this._stepMutatesFlag(step, serviceName);
|
|
679
|
+
const targetService = step.service || serviceName;
|
|
680
|
+
if (mutated && this.cache && typeof this.cache.deleteByPattern === 'function') {
|
|
681
|
+
await this._invalidateWorkflowStepCaches(targetService);
|
|
682
|
+
}
|
|
683
|
+
|
|
660
684
|
this.logger.info(`Task step executed`, {
|
|
661
685
|
step: stepId,
|
|
662
686
|
service: step.service,
|
|
@@ -967,7 +991,8 @@ class WorkflowOrchestrator {
|
|
|
967
991
|
data: context,
|
|
968
992
|
workflow_id: context?.workflow_id || null,
|
|
969
993
|
step_id: stepId,
|
|
970
|
-
service: serviceName || null
|
|
994
|
+
service: serviceName || null,
|
|
995
|
+
logger: this.logger
|
|
971
996
|
};
|
|
972
997
|
|
|
973
998
|
const iteratorResolved = await this._resolveInputReferencesAsync(step.iterator, context, helperContext);
|
|
@@ -1021,7 +1046,8 @@ class WorkflowOrchestrator {
|
|
|
1021
1046
|
data: context,
|
|
1022
1047
|
workflow_id: context?.workflow_id || null,
|
|
1023
1048
|
step_id: stepId,
|
|
1024
|
-
service: serviceName || null
|
|
1049
|
+
service: serviceName || null,
|
|
1050
|
+
logger: this.logger
|
|
1025
1051
|
};
|
|
1026
1052
|
|
|
1027
1053
|
const value = await this._evaluateSwitchExpression(step.expression, context, helperContext);
|
|
@@ -1297,6 +1323,61 @@ class WorkflowOrchestrator {
|
|
|
1297
1323
|
return `workflow:step:${step.service}:${step.operation || stepId}:${hash}`;
|
|
1298
1324
|
}
|
|
1299
1325
|
|
|
1326
|
+
/**
|
|
1327
|
+
* Whether the task writes domain state and MUST bust cached reads for sibling operations.
|
|
1328
|
+
* @private
|
|
1329
|
+
* @param {Object} step
|
|
1330
|
+
* @param {string} executingServiceName consumer service running the cookbook step
|
|
1331
|
+
* @returns {boolean}
|
|
1332
|
+
*/
|
|
1333
|
+
_stepMutatesFlag(step, executingServiceName) {
|
|
1334
|
+
if (!step || step.type !== 'task') return false;
|
|
1335
|
+
if (step.mutates === true) return true;
|
|
1336
|
+
if (this._operationMutatesLookup) {
|
|
1337
|
+
try {
|
|
1338
|
+
const meta = this._operationMutatesLookup(executingServiceName, step);
|
|
1339
|
+
if (meta && meta.mutates === true) return true;
|
|
1340
|
+
} catch (e) {
|
|
1341
|
+
this.logger.warn('[WorkflowOrchestrator] operationMutatesLookup failed', {
|
|
1342
|
+
error: e.message,
|
|
1343
|
+
executingServiceName,
|
|
1344
|
+
step_id: step.step_id
|
|
1345
|
+
});
|
|
1346
|
+
throw e;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return false;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Remove cached orchestrator outputs for non-mutating task steps targeting a service queue.
|
|
1354
|
+
* @private
|
|
1355
|
+
* @param {string} serviceName cookbook step.service (routing target name)
|
|
1356
|
+
* @returns {Promise<void>}
|
|
1357
|
+
*/
|
|
1358
|
+
async _invalidateWorkflowStepCaches(serviceName) {
|
|
1359
|
+
if (
|
|
1360
|
+
!serviceName ||
|
|
1361
|
+
!this.cache ||
|
|
1362
|
+
typeof this.cache.deleteByPattern !== 'function'
|
|
1363
|
+
) {
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const pattern = `workflow:step:${serviceName}:*`;
|
|
1368
|
+
|
|
1369
|
+
await this.cache.deleteByPattern(pattern);
|
|
1370
|
+
|
|
1371
|
+
if (typeof this.logger?.info === 'function') {
|
|
1372
|
+
this.logger.info(
|
|
1373
|
+
'[WorkflowOrchestrator] Invalidated Redis workflow-step cache after mutate',
|
|
1374
|
+
{
|
|
1375
|
+
service: serviceName,
|
|
1376
|
+
pattern
|
|
1377
|
+
}
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1300
1381
|
}
|
|
1301
1382
|
|
|
1302
1383
|
module.exports = WorkflowOrchestrator;
|