@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 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 }` &mdash; 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`) &mdash; 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 &mdash; 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.2",
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.2"
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
- if (this.cache && step.type === 'task') {
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;