@pikku/core 0.12.134 → 0.12.136
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/CHANGELOG.md +15 -0
- package/dist/analytics/analytics.types.d.ts +20 -0
- package/dist/analytics/anonymous-analytics-identity.d.ts +2 -0
- package/dist/analytics/anonymous-analytics-identity.js +2 -0
- package/dist/analytics/compose-analytics-identity.d.ts +2 -0
- package/dist/analytics/compose-analytics-identity.js +2 -0
- package/dist/analytics/cookie-analytics-identity.d.ts +2 -0
- package/dist/analytics/cookie-analytics-identity.js +2 -0
- package/dist/analytics/define-analytics-events.d.ts +3 -0
- package/dist/analytics/define-analytics-events.js +2 -0
- package/dist/analytics/fan-out-analytics.d.ts +2 -0
- package/dist/analytics/fan-out-analytics.js +2 -0
- package/dist/analytics/logger-analytics-service.d.ts +2 -0
- package/dist/analytics/logger-analytics-service.js +2 -0
- package/dist/analytics/mint-cookie.d.ts +8 -1
- package/dist/analytics/mint-cookie.js +8 -4
- package/dist/function/compensation-name.d.ts +4 -0
- package/dist/function/compensation-name.js +6 -0
- package/dist/function/function-meta.types.d.ts +7 -0
- package/dist/function/function-runner.js +17 -0
- package/dist/function/functions.types.d.ts +18 -0
- package/dist/function/functions.types.js +11 -0
- package/dist/middleware/require-origin.d.ts +2 -0
- package/dist/middleware/require-origin.js +2 -0
- package/dist/permissions.d.ts +5 -0
- package/dist/permissions.js +5 -0
- package/dist/testing/service-tests/queued-workflow-harness.d.ts +61 -0
- package/dist/testing/service-tests/queued-workflow-harness.js +151 -0
- package/dist/testing/service-tests/workflow-compensation-queued-tests.d.ts +11 -0
- package/dist/testing/service-tests/workflow-compensation-queued-tests.js +683 -0
- package/dist/testing/service-tests.d.ts +4 -0
- package/dist/testing/service-tests.js +4 -0
- package/dist/utils/hmac.d.ts +0 -33
- package/dist/utils/hmac.js +0 -61
- package/dist/utils.d.ts +7 -0
- package/dist/utils.js +16 -0
- package/dist/wirings/flag/define-feature-flags.d.ts +1 -13
- package/dist/wirings/flag/define-feature-flags.js +1 -13
- package/dist/wirings/http/http-runner.js +1 -3
- package/dist/wirings/rpc/rpc-runner.js +21 -1
- package/dist/wirings/trigger/webhook-source-runner.d.ts +2 -2
- package/dist/wirings/trigger/webhook-source-runner.js +15 -15
- package/dist/wirings/trigger/webhook-source.types.d.ts +9 -12
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +38 -6
- package/dist/wirings/workflow/graph/graph-node.d.ts +2 -1
- package/dist/wirings/workflow/graph/graph-node.js +2 -1
- package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -1
- package/dist/wirings/workflow/graph/graph-runner.js +104 -67
- package/dist/wirings/workflow/graph/graph-validation.js +2 -2
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +20 -1
- package/dist/wirings/workflow/index.d.ts +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +13 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +113 -143
- package/dist/wirings/workflow/run-timeline.d.ts +2 -0
- package/dist/wirings/workflow/run-timeline.js +3 -0
- package/dist/wirings/workflow/workflow-child-step.d.ts +17 -0
- package/dist/wirings/workflow/workflow-child-step.js +31 -0
- package/dist/wirings/workflow/workflow-compensation.d.ts +68 -0
- package/dist/wirings/workflow/workflow-compensation.js +282 -0
- package/dist/wirings/workflow/workflow-constants.d.ts +5 -0
- package/dist/wirings/workflow/workflow-constants.js +9 -0
- package/dist/wirings/workflow/workflow-dsl-pass.d.ts +12 -0
- package/dist/wirings/workflow/workflow-dsl-pass.js +75 -0
- package/dist/wirings/workflow/workflow-queue-routing.js +3 -3
- package/dist/wirings/workflow/workflow-run-status.js +13 -1
- package/dist/wirings/workflow/workflow-status-stream.js +2 -0
- package/dist/wirings/workflow/workflow-unwind-plan.d.ts +38 -0
- package/dist/wirings/workflow/workflow-unwind-plan.js +120 -0
- package/dist/wirings/workflow/workflow.types.d.ts +9 -2
- package/knowledge/decisions/internals/workflow-step-compensation-runs-as-its-own-durable-step.md +21 -18
- package/package.json +1 -1
- package/src/public-surface.json +1 -1
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { DEFAULT_STEP_RETRIES, NO_COMPENSATE_STATE_PREFIX, WORKFLOW_TERMINAL_STATES, compensationStepName, } from './workflow-constants.js';
|
|
2
|
+
import { pikkuState } from '../../pikku-state.js';
|
|
3
|
+
import { resolveNamespace } from '../rpc/rpc-runner.js';
|
|
4
|
+
import { runningStepLease } from './workflow-step-lease.js';
|
|
5
|
+
import { planUnwind } from './workflow-unwind-plan.js';
|
|
6
|
+
const ENTERABLE = new Set([
|
|
7
|
+
'running',
|
|
8
|
+
'suspended',
|
|
9
|
+
'failed',
|
|
10
|
+
'cancelled',
|
|
11
|
+
'completed',
|
|
12
|
+
]);
|
|
13
|
+
export const canEnterCompensation = (status) => ENTERABLE.has(status);
|
|
14
|
+
export const compensatingForStep = (forward) => forward.status === 'succeeded'
|
|
15
|
+
? { ok: true, output: forward.result, stepName: forward.stepName }
|
|
16
|
+
: {
|
|
17
|
+
ok: false,
|
|
18
|
+
output: null,
|
|
19
|
+
error: forward.error ?? { message: 'step failed' },
|
|
20
|
+
stepName: forward.stepName,
|
|
21
|
+
};
|
|
22
|
+
export const skippedSteps = (state) => new Set(Object.keys(state)
|
|
23
|
+
.filter((k) => k.startsWith(NO_COMPENSATE_STATE_PREFIX) && state[k])
|
|
24
|
+
.map((k) => k.slice(NO_COMPENSATE_STATE_PREFIX.length)));
|
|
25
|
+
export const wouldCompensate = async (host, runId) => {
|
|
26
|
+
const [records, state] = await Promise.all([
|
|
27
|
+
host.getRunSteps(runId),
|
|
28
|
+
host.getRunState(runId),
|
|
29
|
+
]);
|
|
30
|
+
const plan = planUnwind({
|
|
31
|
+
records,
|
|
32
|
+
skip: skippedSteps(state),
|
|
33
|
+
hasCompensation: host.hasCompensation,
|
|
34
|
+
isChildWorkflow: host.isChildWorkflow,
|
|
35
|
+
});
|
|
36
|
+
return plan.candidates.length > 0 || plan.forwardInFlight || !!plan.restedAt;
|
|
37
|
+
};
|
|
38
|
+
export const driveUnwind = async (host, runId) => {
|
|
39
|
+
for (;;) {
|
|
40
|
+
const run = await host.getRun(runId);
|
|
41
|
+
if (!run || WORKFLOW_TERMINAL_STATES.has(run.status))
|
|
42
|
+
return 'finished';
|
|
43
|
+
const [records, state] = await Promise.all([
|
|
44
|
+
host.getRunSteps(runId),
|
|
45
|
+
host.getRunState(runId),
|
|
46
|
+
]);
|
|
47
|
+
const plan = planUnwind({
|
|
48
|
+
records,
|
|
49
|
+
skip: skippedSteps(state),
|
|
50
|
+
hasCompensation: host.hasCompensation,
|
|
51
|
+
isChildWorkflow: host.isChildWorkflow,
|
|
52
|
+
});
|
|
53
|
+
if (plan.forwardInFlight)
|
|
54
|
+
return 'waiting';
|
|
55
|
+
if (plan.ready.length > 0) {
|
|
56
|
+
let progressed = false;
|
|
57
|
+
for (const forward of plan.ready) {
|
|
58
|
+
const outcome = await host.runCompensationStep(runId, forward, compensatingForStep(forward));
|
|
59
|
+
if (outcome !== 'waiting')
|
|
60
|
+
progressed = true;
|
|
61
|
+
}
|
|
62
|
+
if (progressed)
|
|
63
|
+
continue;
|
|
64
|
+
return 'waiting';
|
|
65
|
+
}
|
|
66
|
+
if (!plan.settled)
|
|
67
|
+
return 'waiting';
|
|
68
|
+
if (plan.stuck.length > 0) {
|
|
69
|
+
await host.finish(run, 'compensation_failed', { stuckSteps: plan.stuck }, run.error);
|
|
70
|
+
}
|
|
71
|
+
else if (plan.compensated || plan.restedAt) {
|
|
72
|
+
const restedAt = plan.childRestedAt ?? plan.restedAt;
|
|
73
|
+
await host.finish(run, 'compensated', restedAt ? { restedAt } : undefined, run.error);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
await host.finish(run, 'failed', undefined, run.error);
|
|
77
|
+
}
|
|
78
|
+
return 'finished';
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
export const compensationRpcName = (rpcName) => `${rpcName}:compensate`;
|
|
82
|
+
export const createUnwindHost = (api) => ({
|
|
83
|
+
getRun: (id) => api.getRun(id),
|
|
84
|
+
getRunSteps: (id) => api.getRunSteps(id),
|
|
85
|
+
getRunState: (id) => api.getRunState(id),
|
|
86
|
+
hasCompensation: (r) => api.hasCompensation(r),
|
|
87
|
+
isChildWorkflow: (r) => api.isChildWorkflow(r),
|
|
88
|
+
runCompensationStep: (runId, forward, compensatingFor) => api.isChildWorkflow(forward)
|
|
89
|
+
? compensateChildStep(api, runId, forward)
|
|
90
|
+
: compensateFunctionStep(api, runId, forward, compensatingFor),
|
|
91
|
+
finish: async (run, status, output, error) => {
|
|
92
|
+
await api.updateRunStatus(run.id, status, output, error);
|
|
93
|
+
const settled = (await api.getRun(run.id)) ?? run;
|
|
94
|
+
await api.notifyParent(settled, childCompensationError(status, output, error));
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
export const childCompensationError = (status, output, error) => {
|
|
98
|
+
const source = output;
|
|
99
|
+
const message = status === 'compensation_failed'
|
|
100
|
+
? `Sub-workflow compensation failed at ${source?.stuckSteps?.map((s) => s.stepName).join(', ')}`
|
|
101
|
+
: error?.message || 'Sub-workflow failed';
|
|
102
|
+
return Object.assign(new Error(message), {
|
|
103
|
+
childCompensation: { status, ...(source ?? {}) },
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
const compensateFunctionStep = async (api, runId, forward, compensatingFor) => {
|
|
107
|
+
const stepName = compensationStepName(forward.stepName);
|
|
108
|
+
const rpcName = compensationRpcName(forward.rpcName);
|
|
109
|
+
const options = {
|
|
110
|
+
retries: forward.retries ?? DEFAULT_STEP_RETRIES,
|
|
111
|
+
};
|
|
112
|
+
const state = await api.loadStep(runId, stepName, () => api.insertStepState(runId, stepName, rpcName, forward.data, options, forward.stepName));
|
|
113
|
+
if (state.status === 'succeeded')
|
|
114
|
+
return 'done';
|
|
115
|
+
if (state.status === 'failed')
|
|
116
|
+
return 'stuck';
|
|
117
|
+
if (state.status === 'scheduled')
|
|
118
|
+
return 'waiting';
|
|
119
|
+
const lease = runningStepLease(state);
|
|
120
|
+
if (lease === 'held')
|
|
121
|
+
return 'waiting';
|
|
122
|
+
const dispatched = await api.dispatchStep(runId, stepName, rpcName, forward.data, options, forward.stepName);
|
|
123
|
+
if (dispatched) {
|
|
124
|
+
if (lease !== 'lapsed')
|
|
125
|
+
await api.setStepScheduled(state.stepId);
|
|
126
|
+
return 'waiting';
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await api.runInline(state, options.retries, undefined, (current) => api.invoke(runId, stepName, current, rpcName, forward.data, compensatingFor));
|
|
130
|
+
return 'done';
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return 'stuck';
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const compensateChildStep = async (api, runId, forward) => {
|
|
137
|
+
const stepName = compensationStepName(forward.stepName);
|
|
138
|
+
const state = await api.loadStep(runId, stepName, () => api.insertStepState(runId, stepName, null, { childRunId: forward.childRunId }, { retries: 0 }, forward.stepName));
|
|
139
|
+
if (state.status === 'succeeded')
|
|
140
|
+
return 'done';
|
|
141
|
+
if (state.status === 'failed')
|
|
142
|
+
return 'stuck';
|
|
143
|
+
if (!forward.childRunId) {
|
|
144
|
+
await api.setStepResult(state.stepId, undefined);
|
|
145
|
+
return 'done';
|
|
146
|
+
}
|
|
147
|
+
const child = await api.getRun(forward.childRunId);
|
|
148
|
+
if (child?.status === 'completed') {
|
|
149
|
+
await api.failOrUnwind(child, new Error('unwind'));
|
|
150
|
+
}
|
|
151
|
+
const after = await api.getRun(forward.childRunId);
|
|
152
|
+
switch (after?.status) {
|
|
153
|
+
case 'compensated': {
|
|
154
|
+
const restedAt = after.output
|
|
155
|
+
?.restedAt;
|
|
156
|
+
await api.setStepResult(state.stepId, restedAt ? { restedAt } : undefined);
|
|
157
|
+
return 'done';
|
|
158
|
+
}
|
|
159
|
+
case 'compensation_failed':
|
|
160
|
+
await api.setStepError(state.stepId, new Error('child compensation failed'));
|
|
161
|
+
return 'stuck';
|
|
162
|
+
case 'failed':
|
|
163
|
+
await api.setStepResult(state.stepId, undefined);
|
|
164
|
+
return 'done';
|
|
165
|
+
default:
|
|
166
|
+
return 'waiting';
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
export const functionHasCompensation = (rpcName) => {
|
|
170
|
+
const funcId = pikkuState(null, 'rpc', 'meta')[rpcName];
|
|
171
|
+
const local = pikkuState(null, 'function', 'meta')[typeof funcId === 'string' ? funcId : rpcName];
|
|
172
|
+
if (local)
|
|
173
|
+
return local.compensate === true;
|
|
174
|
+
const resolved = resolveNamespace(rpcName);
|
|
175
|
+
return resolved
|
|
176
|
+
? pikkuState(resolved.package, 'function', 'meta')[resolved.function]
|
|
177
|
+
?.compensate === true
|
|
178
|
+
: false;
|
|
179
|
+
};
|
|
180
|
+
export const isChildWorkflowRecord = (record) => !!record.rpcName &&
|
|
181
|
+
pikkuState(null, 'workflows', 'meta')[record.rpcName] !== undefined;
|
|
182
|
+
export const recordHasCompensation = (record) => isChildWorkflowRecord(record)
|
|
183
|
+
? !!record.childRunId
|
|
184
|
+
: !!record.rpcName && functionHasCompensation(record.rpcName);
|
|
185
|
+
export const childRunFailure = (childRun) => {
|
|
186
|
+
switch (childRun.status) {
|
|
187
|
+
case 'failed':
|
|
188
|
+
return new Error(childRun.error?.message || 'Sub-workflow failed');
|
|
189
|
+
case 'cancelled':
|
|
190
|
+
return new Error('Sub-workflow was cancelled');
|
|
191
|
+
case 'compensated':
|
|
192
|
+
case 'compensation_failed':
|
|
193
|
+
return childCompensationError(childRun.status, childRun.output, childRun.error);
|
|
194
|
+
default:
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* The error carrying a child's compensation outcome does not survive a step
|
|
200
|
+
* boundary, so a queued parent finds it again from the failed child step.
|
|
201
|
+
*/
|
|
202
|
+
const failedChildCompensation = async (api, runId) => {
|
|
203
|
+
const steps = await api.getRunSteps(runId);
|
|
204
|
+
let rested;
|
|
205
|
+
for (const step of steps) {
|
|
206
|
+
if (step.status !== 'failed' || !step.childRunId)
|
|
207
|
+
continue;
|
|
208
|
+
const child = await api.getRun(step.childRunId);
|
|
209
|
+
if (child?.status === 'compensation_failed') {
|
|
210
|
+
return childCompensationError(child.status, child.output, child.error)
|
|
211
|
+
.childCompensation;
|
|
212
|
+
}
|
|
213
|
+
if (child?.status === 'compensated') {
|
|
214
|
+
rested ??= childCompensationError(child.status, child.output, child.error).childCompensation;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return rested;
|
|
218
|
+
};
|
|
219
|
+
export const beginUnwind = async (api, host, run, cause) => {
|
|
220
|
+
const child = cause.childCompensation ?? (await failedChildCompensation(api, run.id));
|
|
221
|
+
if (child?.status === 'compensation_failed') {
|
|
222
|
+
await host.finish(run, 'compensation_failed', { stuckSteps: child.stuckSteps }, cause);
|
|
223
|
+
return 'finished';
|
|
224
|
+
}
|
|
225
|
+
if (child?.status === 'compensated' && child.restedAt) {
|
|
226
|
+
await host.finish(run, 'compensated', { restedAt: child.restedAt }, cause);
|
|
227
|
+
return 'finished';
|
|
228
|
+
}
|
|
229
|
+
if (!(await wouldCompensate(host, run.id)))
|
|
230
|
+
return 'not-needed';
|
|
231
|
+
const fresh = await api.getRun(run.id);
|
|
232
|
+
if (!fresh || !canEnterCompensation(fresh.status))
|
|
233
|
+
return 'finished';
|
|
234
|
+
await api.updateRunStatus(run.id, 'compensating', undefined, cause);
|
|
235
|
+
return (await driveUnwind(host, run.id)) === 'waiting'
|
|
236
|
+
? 'unwinding'
|
|
237
|
+
: 'finished';
|
|
238
|
+
};
|
|
239
|
+
export const isUnwound = (status) => status === 'compensating' ||
|
|
240
|
+
status === 'compensated' ||
|
|
241
|
+
status === 'compensation_failed';
|
|
242
|
+
export const failRunOrUnwind = async (api, run, error, onFailed) => {
|
|
243
|
+
const cause = {
|
|
244
|
+
message: error.message,
|
|
245
|
+
stack: error.stack,
|
|
246
|
+
code: error.code,
|
|
247
|
+
...(error.childCompensation
|
|
248
|
+
? { childCompensation: error.childCompensation }
|
|
249
|
+
: {}),
|
|
250
|
+
};
|
|
251
|
+
const outcome = await beginUnwind(api, createUnwindHost(api), run, cause);
|
|
252
|
+
if (outcome !== 'not-needed')
|
|
253
|
+
return outcome;
|
|
254
|
+
await api.updateRunStatus(run.id, 'failed', undefined, cause);
|
|
255
|
+
await onFailed();
|
|
256
|
+
return 'failed';
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Child workflows are cancelled first so nothing keeps producing effects under
|
|
260
|
+
* a parent that is unwinding. A run with nothing to undo stays `cancelled`.
|
|
261
|
+
*/
|
|
262
|
+
export const cancelAndUnwind = async (api, host, runId, reason) => {
|
|
263
|
+
const run = await api.getRun(runId);
|
|
264
|
+
if (!run)
|
|
265
|
+
return undefined;
|
|
266
|
+
if (WORKFLOW_TERMINAL_STATES.has(run.status) || isUnwound(run.status)) {
|
|
267
|
+
return run.status;
|
|
268
|
+
}
|
|
269
|
+
for (const step of await host.getRunSteps(runId)) {
|
|
270
|
+
if (step.childRunId) {
|
|
271
|
+
await cancelAndUnwind(api, host, step.childRunId, reason);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const cause = {
|
|
275
|
+
message: reason || 'Workflow cancelled',
|
|
276
|
+
stack: '',
|
|
277
|
+
code: 'WORKFLOW_CANCELLED',
|
|
278
|
+
};
|
|
279
|
+
await api.updateRunStatus(runId, 'cancelled', undefined, cause);
|
|
280
|
+
await beginUnwind(api, host, run, cause);
|
|
281
|
+
return (await api.getRun(runId))?.status;
|
|
282
|
+
};
|
|
@@ -3,6 +3,10 @@ export declare const DEFAULT_STEP_RETRIES = 5;
|
|
|
3
3
|
export declare const WORKFLOW_END_STATES: ReadonlySet<string>;
|
|
4
4
|
/** Statuses a run cannot leave at all. */
|
|
5
5
|
export declare const WORKFLOW_TERMINAL_STATES: ReadonlySet<string>;
|
|
6
|
+
export declare const NO_COMPENSATE_STATE_PREFIX = "noCompensate:";
|
|
7
|
+
export declare const MILESTONE_STEP_PREFIX = "__milestone__:";
|
|
8
|
+
export declare const milestoneStepName: (name: string) => string;
|
|
9
|
+
export declare const isMilestoneStepName: (stepName: string) => boolean;
|
|
6
10
|
/**
|
|
7
11
|
* True for a run that will never move again, whatever arrives for it.
|
|
8
12
|
*
|
|
@@ -68,3 +72,4 @@ export declare const isStepLeaseLive: (leaseExpiresAt: Date | null | undefined,
|
|
|
68
72
|
export declare const RUN_LEASE_RETRY_MS = 1000;
|
|
69
73
|
/** Bound on the in-process backoff map, so a long-lived process cannot grow it without bound. */
|
|
70
74
|
export declare const REDISPATCH_BACKOFF_MAX_ENTRIES = 10000;
|
|
75
|
+
export { COMPENSATION_STEP_SUFFIX, compensationStepName, forwardStepName, isCompensationStepName, } from '../../function/compensation-name.js';
|
|
@@ -5,13 +5,21 @@ export const WORKFLOW_END_STATES = new Set([
|
|
|
5
5
|
'failed',
|
|
6
6
|
'cancelled',
|
|
7
7
|
'suspended',
|
|
8
|
+
'compensated',
|
|
9
|
+
'compensation_failed',
|
|
8
10
|
]);
|
|
9
11
|
/** Statuses a run cannot leave at all. */
|
|
10
12
|
export const WORKFLOW_TERMINAL_STATES = new Set([
|
|
11
13
|
'completed',
|
|
12
14
|
'failed',
|
|
13
15
|
'cancelled',
|
|
16
|
+
'compensated',
|
|
17
|
+
'compensation_failed',
|
|
14
18
|
]);
|
|
19
|
+
export const NO_COMPENSATE_STATE_PREFIX = 'noCompensate:';
|
|
20
|
+
export const MILESTONE_STEP_PREFIX = '__milestone__:';
|
|
21
|
+
export const milestoneStepName = (name) => `${MILESTONE_STEP_PREFIX}${name}`;
|
|
22
|
+
export const isMilestoneStepName = (stepName) => stepName.startsWith(MILESTONE_STEP_PREFIX);
|
|
15
23
|
/**
|
|
16
24
|
* True for a run that will never move again, whatever arrives for it.
|
|
17
25
|
*
|
|
@@ -77,3 +85,4 @@ export const isStepLeaseLive = (leaseExpiresAt, now = Date.now()) => leaseExpire
|
|
|
77
85
|
export const RUN_LEASE_RETRY_MS = 1_000;
|
|
78
86
|
/** Bound on the in-process backoff map, so a long-lived process cannot grow it without bound. */
|
|
79
87
|
export const REDISPATCH_BACKOFF_MAX_ENTRIES = 10_000;
|
|
88
|
+
export { COMPENSATION_STEP_SUFFIX, compensationStepName, forwardStepName, isCompensationStepName, } from '../../function/compensation-name.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SerializedError } from '../../errors/serialized-error.js';
|
|
2
|
+
import type { RunLifecycleContext, WorkflowRunExtension } from './workflow-run-engine.types.js';
|
|
3
|
+
import type { PikkuWorkflowWire, WorkflowRun, WorkflowStatus } from './workflow.types.js';
|
|
4
|
+
export interface DslPassDeps {
|
|
5
|
+
workflowWire: (addonNamespace: string | null) => PikkuWorkflowWire;
|
|
6
|
+
extension: WorkflowRunExtension | undefined;
|
|
7
|
+
updateRunStatus: (status: WorkflowStatus, output?: unknown, error?: SerializedError) => Promise<void>;
|
|
8
|
+
onChildCompleted: (result: unknown) => Promise<void>;
|
|
9
|
+
onChildFailed: (error: Error) => Promise<void>;
|
|
10
|
+
failOrUnwind: (error: Error) => Promise<'unwinding' | 'finished' | 'failed'>;
|
|
11
|
+
}
|
|
12
|
+
export declare const runDslWorkflowPass: (deps: DslPassDeps, run: WorkflowRun, workflowMeta: RunLifecycleContext['workflowMeta'], workflow: RunLifecycleContext['workflow'], pkgName: string | null) => Promise<void>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { runPikkuFunc } from '../../function/function-runner.js';
|
|
2
|
+
import { getCreateWireServices, getSingletonServices, } from '../../pikku-state.js';
|
|
3
|
+
import { WorkflowAsyncException, WorkflowCancelledException, WorkflowSuspendedException, } from './workflow-errors.js';
|
|
4
|
+
export const runDslWorkflowPass = async (deps, run, workflowMeta, workflow, pkgName) => {
|
|
5
|
+
const runId = run.id;
|
|
6
|
+
const addonNs = run.workflow.includes(':')
|
|
7
|
+
? run.workflow.substring(0, run.workflow.indexOf(':'))
|
|
8
|
+
: null;
|
|
9
|
+
const workflowWire = deps.workflowWire(addonNs);
|
|
10
|
+
workflowWire.pikkuUserId = run.wire?.pikkuUserId;
|
|
11
|
+
const wire = {
|
|
12
|
+
workflow: workflowWire,
|
|
13
|
+
pikkuUserId: run.wire?.pikkuUserId,
|
|
14
|
+
};
|
|
15
|
+
deps.extension?.decorateRunWire(wire, {
|
|
16
|
+
runId,
|
|
17
|
+
workflowMeta,
|
|
18
|
+
workflowWire,
|
|
19
|
+
});
|
|
20
|
+
const lifecycle = {
|
|
21
|
+
runId,
|
|
22
|
+
run,
|
|
23
|
+
workflowMeta,
|
|
24
|
+
workflow,
|
|
25
|
+
wire,
|
|
26
|
+
packageName: pkgName,
|
|
27
|
+
};
|
|
28
|
+
let outcome = 'completed';
|
|
29
|
+
let failure;
|
|
30
|
+
try {
|
|
31
|
+
await deps.extension?.onBeforeRunFunc(lifecycle);
|
|
32
|
+
const result = await runPikkuFunc('workflow', workflowMeta.name, workflowMeta.pikkuFuncId, {
|
|
33
|
+
singletonServices: getSingletonServices(),
|
|
34
|
+
wire,
|
|
35
|
+
createWireServices: getCreateWireServices(),
|
|
36
|
+
data: () => run.input,
|
|
37
|
+
packageName: pkgName ?? undefined,
|
|
38
|
+
});
|
|
39
|
+
await deps.updateRunStatus('completed', result);
|
|
40
|
+
await deps.onChildCompleted(result);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
failure = error;
|
|
44
|
+
if (error instanceof WorkflowAsyncException) {
|
|
45
|
+
outcome = 'interrupted';
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
if (error instanceof WorkflowCancelledException) {
|
|
49
|
+
outcome = 'failed';
|
|
50
|
+
await deps.updateRunStatus('cancelled', undefined, {
|
|
51
|
+
message: error.message || 'Workflow cancelled',
|
|
52
|
+
stack: '',
|
|
53
|
+
code: 'WORKFLOW_CANCELLED',
|
|
54
|
+
});
|
|
55
|
+
await deps.onChildFailed(error);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
if (error instanceof WorkflowSuspendedException) {
|
|
59
|
+
outcome = 'interrupted';
|
|
60
|
+
await deps.updateRunStatus('suspended', undefined, {
|
|
61
|
+
message: error.message || 'Workflow suspended',
|
|
62
|
+
stack: '',
|
|
63
|
+
code: 'WORKFLOW_SUSPENDED',
|
|
64
|
+
});
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
outcome = 'failed';
|
|
68
|
+
if ((await deps.failOrUnwind(error)) === 'unwinding')
|
|
69
|
+
return;
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await deps.extension?.onAfterRunFunc(lifecycle, outcome, failure);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getSingletonServices, pikkuState } from '../../pikku-state.js';
|
|
2
2
|
import { getDurationInMilliseconds } from '../../time-utils.js';
|
|
3
|
-
import { DEFAULT_STEP_RETRIES } from './workflow-constants.js';
|
|
3
|
+
import { DEFAULT_STEP_RETRIES, forwardStepName } from './workflow-constants.js';
|
|
4
4
|
const toKebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
5
5
|
export const resolveWorkflowConfig = () => {
|
|
6
6
|
const workflow = getSingletonServices().config?.workflow;
|
|
@@ -24,7 +24,7 @@ const dedicatedQueueName = (prefix, name, strategy, fallback) => {
|
|
|
24
24
|
return pikkuState(null, 'queue', 'meta')[dedicated] ? dedicated : fallback;
|
|
25
25
|
};
|
|
26
26
|
export const orchestratorQueueName = (strategy, workflowName) => dedicatedQueueName('wf-orchestrator-', workflowName, strategy, resolveWorkflowConfig().orchestratorQueueName);
|
|
27
|
-
export const stepWorkerQueueName = (strategy, rpcName) => dedicatedQueueName('wf-step-', rpcName, strategy, resolveWorkflowConfig().stepWorkerQueueName);
|
|
27
|
+
export const stepWorkerQueueName = (strategy, rpcName) => dedicatedQueueName('wf-step-', rpcName && forwardStepName(rpcName), strategy, resolveWorkflowConfig().stepWorkerQueueName);
|
|
28
28
|
/**
|
|
29
29
|
* How a step reaches its worker: on the queue, or here in the orchestrator.
|
|
30
30
|
*
|
|
@@ -43,7 +43,7 @@ export const stepWorkerQueueName = (strategy, rpcName) => dedicatedQueueName('wf
|
|
|
43
43
|
* and has a queue to reach ever asks.
|
|
44
44
|
*/
|
|
45
45
|
export const stepDispatchTarget = async (rpcName, stepName, parentIsInline) => {
|
|
46
|
-
const rpcFuncId = pikkuState(null, 'rpc', 'meta')[rpcName];
|
|
46
|
+
const rpcFuncId = pikkuState(null, 'rpc', 'meta')[forwardStepName(rpcName)];
|
|
47
47
|
const rpcMeta = typeof rpcFuncId === 'string'
|
|
48
48
|
? pikkuState(null, 'function', 'meta')[rpcFuncId]
|
|
49
49
|
: undefined;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { WORKFLOW_TERMINAL_STATES } from './workflow-constants.js';
|
|
1
2
|
/** A run's status with each step folded to its latest attempt. */
|
|
2
3
|
export function summarizeRunStatus(run, history) {
|
|
3
|
-
const terminalStatuses =
|
|
4
|
+
const terminalStatuses = WORKFLOW_TERMINAL_STATES;
|
|
4
5
|
const stepMap = new Map();
|
|
5
6
|
for (const step of history) {
|
|
6
7
|
const existing = stepMap.get(step.stepName);
|
|
@@ -33,5 +34,16 @@ export function summarizeRunStatus(run, history) {
|
|
|
33
34
|
error: run.error
|
|
34
35
|
? { message: run.error.message ?? 'Unknown error' }
|
|
35
36
|
: undefined,
|
|
37
|
+
...compensationOutcome(run),
|
|
36
38
|
};
|
|
37
39
|
}
|
|
40
|
+
function compensationOutcome(run) {
|
|
41
|
+
const output = run.output;
|
|
42
|
+
if (run.status === 'compensated' && output?.restedAt) {
|
|
43
|
+
return { restedAt: output.restedAt };
|
|
44
|
+
}
|
|
45
|
+
if (run.status === 'compensation_failed' && output?.stuckSteps) {
|
|
46
|
+
return { stuckSteps: output.stuckSteps };
|
|
47
|
+
}
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SerializedError } from '../../errors/serialized-error.js';
|
|
2
|
+
import type { StepStatus } from './workflow.types.js';
|
|
3
|
+
export interface UnwindRecord {
|
|
4
|
+
stepName: string;
|
|
5
|
+
rpcName?: string | null;
|
|
6
|
+
status: StepStatus;
|
|
7
|
+
createdAt: Date;
|
|
8
|
+
updatedAt: Date;
|
|
9
|
+
succeededAt?: Date;
|
|
10
|
+
failedAt?: Date;
|
|
11
|
+
result?: unknown;
|
|
12
|
+
error?: SerializedError;
|
|
13
|
+
childRunId?: string;
|
|
14
|
+
data?: unknown;
|
|
15
|
+
retries?: number;
|
|
16
|
+
}
|
|
17
|
+
export type UnwindStepState = 'done' | 'rested' | 'stuck' | 'blocked' | 'running' | 'ready' | 'waiting';
|
|
18
|
+
export interface UnwindPlan {
|
|
19
|
+
candidates: UnwindRecord[];
|
|
20
|
+
states: Map<string, UnwindStepState>;
|
|
21
|
+
ready: UnwindRecord[];
|
|
22
|
+
stuck: Array<{
|
|
23
|
+
stepName: string;
|
|
24
|
+
error: string;
|
|
25
|
+
}>;
|
|
26
|
+
restedAt?: string;
|
|
27
|
+
childRestedAt?: string;
|
|
28
|
+
forwardInFlight: boolean;
|
|
29
|
+
settled: boolean;
|
|
30
|
+
compensated: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface PlanUnwindInput {
|
|
33
|
+
records: UnwindRecord[];
|
|
34
|
+
skip: ReadonlySet<string>;
|
|
35
|
+
hasCompensation: (record: UnwindRecord) => boolean;
|
|
36
|
+
isChildWorkflow: (record: UnwindRecord) => boolean;
|
|
37
|
+
}
|
|
38
|
+
export declare function planUnwind(input: PlanUnwindInput): UnwindPlan;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { isCompensationStepName, isMilestoneStepName, } from './workflow-constants.js';
|
|
2
|
+
const IN_FLIGHT = new Set([
|
|
3
|
+
'pending',
|
|
4
|
+
'scheduled',
|
|
5
|
+
'running',
|
|
6
|
+
]);
|
|
7
|
+
const startedAt = (record) => record.createdAt.getTime();
|
|
8
|
+
const finishedAt = (record) => (record.succeededAt ?? record.failedAt ?? record.updatedAt).getTime();
|
|
9
|
+
export function planUnwind(input) {
|
|
10
|
+
const forward = input.records.filter((r) => !isCompensationStepName(r.stepName) && !isMilestoneStepName(r.stepName));
|
|
11
|
+
const compRows = new Map(input.records
|
|
12
|
+
.filter((r) => isCompensationStepName(r.stepName))
|
|
13
|
+
.map((r) => [r.stepName, r]));
|
|
14
|
+
const milestones = input.records
|
|
15
|
+
.filter((r) => isMilestoneStepName(r.stepName) && r.status === 'succeeded')
|
|
16
|
+
.sort((a, b) => finishedAt(b) - finishedAt(a) ||
|
|
17
|
+
input.records.indexOf(b) - input.records.indexOf(a));
|
|
18
|
+
const lastMilestone = milestones[0];
|
|
19
|
+
const forwardInFlight = forward.some((r) => IN_FLIGHT.has(r.status) && input.hasCompensation(r));
|
|
20
|
+
let candidates = forward.filter((r) => {
|
|
21
|
+
if (!r.rpcName || input.skip.has(r.stepName))
|
|
22
|
+
return false;
|
|
23
|
+
if (r.status === 'failed' && input.isChildWorkflow(r))
|
|
24
|
+
return false;
|
|
25
|
+
if (r.status !== 'succeeded' && r.status !== 'failed')
|
|
26
|
+
return false;
|
|
27
|
+
return input.hasCompensation(r);
|
|
28
|
+
});
|
|
29
|
+
if (lastMilestone) {
|
|
30
|
+
const at = finishedAt(lastMilestone);
|
|
31
|
+
candidates = candidates.filter((r) => finishedAt(r) > at ||
|
|
32
|
+
(finishedAt(r) === at &&
|
|
33
|
+
input.records.indexOf(r) > input.records.indexOf(lastMilestone)));
|
|
34
|
+
}
|
|
35
|
+
candidates.sort((a, b) => finishedAt(b) - finishedAt(a) ||
|
|
36
|
+
input.records.indexOf(b) - input.records.indexOf(a));
|
|
37
|
+
const order = (r) => input.records.indexOf(r);
|
|
38
|
+
const states = new Map();
|
|
39
|
+
const resolving = new Set();
|
|
40
|
+
const dependents = (x) => candidates.filter((y) => y !== x &&
|
|
41
|
+
startedAt(y) >= finishedAt(x) &&
|
|
42
|
+
(finishedAt(y) > finishedAt(x) ||
|
|
43
|
+
(finishedAt(y) === finishedAt(x) && order(y) > order(x))));
|
|
44
|
+
const resolve = (x) => {
|
|
45
|
+
const cached = states.get(x.stepName);
|
|
46
|
+
if (cached)
|
|
47
|
+
return cached;
|
|
48
|
+
if (resolving.has(x.stepName))
|
|
49
|
+
return 'waiting';
|
|
50
|
+
resolving.add(x.stepName);
|
|
51
|
+
const comp = compRows.get(`${x.stepName}:compensate`);
|
|
52
|
+
let state;
|
|
53
|
+
if (comp?.status === 'succeeded') {
|
|
54
|
+
const restedAt = comp.result
|
|
55
|
+
?.restedAt;
|
|
56
|
+
state = restedAt ? 'rested' : 'done';
|
|
57
|
+
}
|
|
58
|
+
else if (comp?.status === 'failed') {
|
|
59
|
+
state = 'stuck';
|
|
60
|
+
}
|
|
61
|
+
else if (comp?.status === 'scheduled') {
|
|
62
|
+
state = 'running';
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const deps = dependents(x).map(resolve);
|
|
66
|
+
if (deps.some((s) => s === 'stuck' || s === 'blocked' || s === 'rested')) {
|
|
67
|
+
state = 'blocked';
|
|
68
|
+
}
|
|
69
|
+
else if (deps.every((s) => s === 'done')) {
|
|
70
|
+
state = 'ready';
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
state = 'waiting';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
resolving.delete(x.stepName);
|
|
77
|
+
states.set(x.stepName, state);
|
|
78
|
+
return state;
|
|
79
|
+
};
|
|
80
|
+
for (const c of candidates)
|
|
81
|
+
resolve(c);
|
|
82
|
+
const ready = forwardInFlight
|
|
83
|
+
? []
|
|
84
|
+
: candidates.filter((c) => states.get(c.stepName) === 'ready');
|
|
85
|
+
const stuck = candidates
|
|
86
|
+
.filter((c) => states.get(c.stepName) === 'stuck')
|
|
87
|
+
.map((c) => ({
|
|
88
|
+
stepName: c.stepName,
|
|
89
|
+
error: compRows.get(`${c.stepName}:compensate`)?.error?.message ??
|
|
90
|
+
'compensation failed',
|
|
91
|
+
}));
|
|
92
|
+
const rested = candidates.find((c) => states.get(c.stepName) === 'rested');
|
|
93
|
+
const childRestedAt = rested
|
|
94
|
+
? compRows.get(`${rested.stepName}:compensate`)?.result?.restedAt
|
|
95
|
+
: undefined;
|
|
96
|
+
const open = candidates.some((c) => {
|
|
97
|
+
const s = states.get(c.stepName);
|
|
98
|
+
return s === 'ready' || s === 'waiting' || s === 'running';
|
|
99
|
+
});
|
|
100
|
+
const compensated = candidates.some((c) => {
|
|
101
|
+
const s = states.get(c.stepName);
|
|
102
|
+
return s === 'done' || s === 'rested';
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
candidates,
|
|
106
|
+
states,
|
|
107
|
+
ready,
|
|
108
|
+
stuck,
|
|
109
|
+
restedAt: lastMilestone
|
|
110
|
+
? milestoneNameOf(lastMilestone.stepName)
|
|
111
|
+
: undefined,
|
|
112
|
+
childRestedAt,
|
|
113
|
+
forwardInFlight,
|
|
114
|
+
settled: !forwardInFlight && !open,
|
|
115
|
+
compensated,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function milestoneNameOf(stepName) {
|
|
119
|
+
return stepName.slice(stepName.indexOf(':') + 1);
|
|
120
|
+
}
|
|
@@ -4,7 +4,7 @@ import type { CorePikkuFunctionConfig } from '../../function/functions.types.js'
|
|
|
4
4
|
import type { GroupConcurrencyConfig } from '../queue/queue.types.js';
|
|
5
5
|
import type { LeaseService } from '../../services/lease-service.js';
|
|
6
6
|
export type { WorkflowService } from '../../services/workflow-service.js';
|
|
7
|
-
export type { WorkflowStepOptions, WorkflowWireDoRPC, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, Condition, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, } from './dsl/workflow-dsl.types.js';
|
|
7
|
+
export type { WorkflowStepOptions, WorkflowWireDoRPC, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, Condition, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, MilestoneStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, } from './dsl/workflow-dsl.types.js';
|
|
8
8
|
import type { WorkflowStepMeta } from './dsl/workflow-dsl.types.js';
|
|
9
9
|
export interface WorkflowRunWire {
|
|
10
10
|
type: string;
|
|
@@ -38,7 +38,7 @@ export interface WorkflowPlannedStep {
|
|
|
38
38
|
stepName: string;
|
|
39
39
|
displayName?: string;
|
|
40
40
|
}
|
|
41
|
-
export type WorkflowStatus = 'running' | 'suspended' | 'completed' | 'failed' | 'cancelled';
|
|
41
|
+
export type WorkflowStatus = 'running' | 'suspended' | 'completed' | 'failed' | 'cancelled' | 'compensating' | 'compensated' | 'compensation_failed';
|
|
42
42
|
export type WorkflowVersionStatus = 'draft' | 'active' | 'declined';
|
|
43
43
|
export type StepStatus = 'pending' | 'running' | 'scheduled' | 'succeeded' | 'failed' | 'suspended';
|
|
44
44
|
export interface WorkflowRun {
|
|
@@ -75,6 +75,8 @@ export interface StepState {
|
|
|
75
75
|
createdAt: Date;
|
|
76
76
|
updatedAt: Date;
|
|
77
77
|
childRunId?: string;
|
|
78
|
+
/** True for the row that undoes `<stepName>` rather than running it. */
|
|
79
|
+
compensating?: boolean;
|
|
78
80
|
/**
|
|
79
81
|
* When the dispatch that claimed this step stops owning it. The holder pushes
|
|
80
82
|
* it forward while it is still working, so a lapsed lease means the worker is
|
|
@@ -104,6 +106,11 @@ export interface WorkflowRunStatus {
|
|
|
104
106
|
error?: {
|
|
105
107
|
message: string;
|
|
106
108
|
};
|
|
109
|
+
restedAt?: string;
|
|
110
|
+
stuckSteps?: Array<{
|
|
111
|
+
stepName: string;
|
|
112
|
+
error: string;
|
|
113
|
+
}>;
|
|
107
114
|
}
|
|
108
115
|
export interface WorkflowRunService {
|
|
109
116
|
listRuns(options?: {
|