@pikku/core 0.12.134 → 0.12.135
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 +7 -0
- 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 +2 -0
- package/dist/function/function-runner.js +17 -0
- package/dist/function/functions.types.d.ts +7 -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/wirings/rpc/rpc-runner.js +21 -1
- package/dist/wirings/trigger/webhook-source.types.d.ts +4 -3
- 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 +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## 0.12.135
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- ce3e5f5: Saga compensation for workflows. A function declares `compensate` inline; when a workflow step fails, completed steps are undone newest-first as durable `<step>:compensate` steps. New run statuses `compensating`, `compensated` and `compensation_failed`, `workflow.milestone(name)` to bound the unwind, `{ compensate: false }` to opt a call out, nested-workflow unwinding, and `PikkuWorkflowService.cancelRun` which unwinds too. Graph nodes replace `onError` with `recover` (`nodeId`, `nodeId[]` or `'ignore'`), exposing the error as `wire.graph.recoveringFrom`. **Breaking:** the DSL `onError` step option and the graph `onError` node field are removed; `getRunSteps` is now abstract on `PikkuWorkflowService`.
|
|
6
|
+
- fb36c37: Remove `WebhookSigningSecret` from `@pikku/core/hmac`: declare `verify` on `wireTriggerWebhookSource` instead, or use `hmacDigest`, `verifyHmacSignature`, `verifyPublicKeySignature` and `timingSafeStringEqual` directly. The trigger skill and the online-shop snippet now teach declarative `verify`, and the `verify` JSDoc describes bodiless requests correctly.
|
|
7
|
+
|
|
1
8
|
## 0.12.134
|
|
2
9
|
|
|
3
10
|
### Patch Changes
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const COMPENSATION_STEP_SUFFIX = ":compensate";
|
|
2
|
+
export declare const compensationStepName: (stepName: string) => string;
|
|
3
|
+
export declare const isCompensationStepName: (stepName: string) => boolean;
|
|
4
|
+
export declare const forwardStepName: (stepName: string) => string;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const COMPENSATION_STEP_SUFFIX = ':compensate';
|
|
2
|
+
export const compensationStepName = (stepName) => `${stepName}${COMPENSATION_STEP_SUFFIX}`;
|
|
3
|
+
export const isCompensationStepName = (stepName) => stepName.endsWith(COMPENSATION_STEP_SUFFIX);
|
|
4
|
+
export const forwardStepName = (stepName) => isCompensationStepName(stepName)
|
|
5
|
+
? stepName.slice(0, -COMPENSATION_STEP_SUFFIX.length)
|
|
6
|
+
: stepName;
|
|
@@ -58,6 +58,8 @@ export type FunctionRuntimeMeta = {
|
|
|
58
58
|
readonly?: boolean;
|
|
59
59
|
deploy?: 'serverless' | 'server' | 'auto';
|
|
60
60
|
sessionless?: boolean;
|
|
61
|
+
/** The function declares a `compensate`, run as the sibling `<id>:compensate` when a workflow unwinds. */
|
|
62
|
+
compensate?: boolean;
|
|
61
63
|
/** When true, workflow steps calling this function are dispatched via the queue. No queue service configured is a hard error. */
|
|
62
64
|
workflowQueued?: boolean;
|
|
63
65
|
/** Retry count when this function is used as a workflow step. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { beginChanges } from './abort-scope.js';
|
|
2
|
+
import { forwardStepName, isCompensationStepName } from './compensation-name.js';
|
|
2
3
|
import { runMiddleware, combineMiddleware } from '../middleware-runner.js';
|
|
3
4
|
import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../wirings/channel/channel-middleware-runner.js';
|
|
4
5
|
import { runPermissions } from '../permissions.js';
|
|
@@ -71,6 +72,22 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
|
|
|
71
72
|
let funcConfig = funcMap.get(funcName);
|
|
72
73
|
const allMeta = pikkuState(packageName, 'function', 'meta');
|
|
73
74
|
let funcMeta = allMeta[funcName];
|
|
75
|
+
if ((!funcConfig || !funcMeta) && isCompensationStepName(funcName)) {
|
|
76
|
+
const forwardName = forwardStepName(funcName);
|
|
77
|
+
const forward = funcMap.get(forwardName);
|
|
78
|
+
const forwardMeta = allMeta[forwardName];
|
|
79
|
+
if (forward?.compensate && forwardMeta) {
|
|
80
|
+
const { compensate, ...rest } = forward;
|
|
81
|
+
funcConfig = { ...rest, func: compensate };
|
|
82
|
+
funcMeta = {
|
|
83
|
+
...forwardMeta,
|
|
84
|
+
pikkuFuncId: funcName,
|
|
85
|
+
outputs: null,
|
|
86
|
+
expose: false,
|
|
87
|
+
compensate: undefined,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
74
91
|
if (!funcConfig || !funcMeta) {
|
|
75
92
|
const { baseName, version } = parseVersionedId(funcName);
|
|
76
93
|
if (version !== null) {
|
|
@@ -83,6 +83,13 @@ export type CorePikkuFunctionConfig<PikkuFunction extends CorePikkuFunction<any,
|
|
|
83
83
|
approvalRequired?: boolean;
|
|
84
84
|
/** When true, workflow steps calling this function are dispatched via the queue. No queue service configured is a hard error. Defaults to false (inline). */
|
|
85
85
|
workflowQueued?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Undoes this function's effect when a workflow that called it unwinds. It
|
|
88
|
+
* receives the function's own input, and `wire.workflow.compensatingFor`
|
|
89
|
+
* carries the forward output (or the error, when the forward step failed).
|
|
90
|
+
* It is never callable on its own — only the workflow engine runs it.
|
|
91
|
+
*/
|
|
92
|
+
compensate?: (services: any, data: any, wire: any) => Promise<any> | any;
|
|
86
93
|
/** Number of retry attempts when this function is used as a workflow step. */
|
|
87
94
|
workflowRetries?: number;
|
|
88
95
|
/** Timeout for this function when used as a workflow step (e.g. '30s', '5m'). */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { PikkuWorkflowService } from '../../wirings/workflow/pikku-workflow-service.js';
|
|
2
|
+
import type { CompensatingFor } from '../../wirings/workflow/dsl/workflow-dsl.types.js';
|
|
3
|
+
export type Handler = {
|
|
4
|
+
forward: (data: any, wire: any) => Promise<any> | any;
|
|
5
|
+
compensate?: (data: any, context: CompensatingFor) => Promise<void> | void;
|
|
6
|
+
queued?: boolean;
|
|
7
|
+
};
|
|
8
|
+
interface Job {
|
|
9
|
+
queue: string;
|
|
10
|
+
data: any;
|
|
11
|
+
attempts?: number;
|
|
12
|
+
attempt?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Drives a workflow the way a deployment does: every orchestration and every
|
|
16
|
+
* `workflowQueued` step goes through a queue, and a pump plays the workers.
|
|
17
|
+
* Delays are ignored, so a retry runs on the next pass.
|
|
18
|
+
*/
|
|
19
|
+
export declare class QueuedWorkflowHarness {
|
|
20
|
+
handlers: Record<string, Handler>;
|
|
21
|
+
log: string[];
|
|
22
|
+
contexts: Array<{
|
|
23
|
+
rpc: string;
|
|
24
|
+
context: CompensatingFor | undefined;
|
|
25
|
+
}>;
|
|
26
|
+
recovering: Array<{
|
|
27
|
+
rpc: string;
|
|
28
|
+
from: any;
|
|
29
|
+
}>;
|
|
30
|
+
queue: Job[];
|
|
31
|
+
jobsRun: Array<{
|
|
32
|
+
queue: string;
|
|
33
|
+
step?: string;
|
|
34
|
+
}>;
|
|
35
|
+
ws: PikkuWorkflowService;
|
|
36
|
+
/** Return true to drop the job instead of running it, simulating a worker that never ran. */
|
|
37
|
+
dropWhen?: (job: Job) => boolean;
|
|
38
|
+
/** Make `queue.add` throw for a job, simulating the broker being unreachable. */
|
|
39
|
+
rejectAddWhen?: (queue: string, data: any) => boolean;
|
|
40
|
+
constructor(service: PikkuWorkflowService);
|
|
41
|
+
readonly rpc: {
|
|
42
|
+
rpcWithWire: (rpcName: string, data: any, wire: any) => Promise<any>;
|
|
43
|
+
};
|
|
44
|
+
register(name: string, handler: Handler): void;
|
|
45
|
+
defineDsl(name: string, body: (workflow: any, input: any) => Promise<any>): void;
|
|
46
|
+
defineGraph(name: string, entry: string, nodes: Record<string, any>): void;
|
|
47
|
+
start(name: string, input?: any): Promise<string>;
|
|
48
|
+
/** Run jobs until the queue is empty. */
|
|
49
|
+
pump(limit?: number): Promise<void>;
|
|
50
|
+
run(runId: string): Promise<import("../../wirings/workflow/workflow.types.js").WorkflowRun>;
|
|
51
|
+
steps(runId: string): Promise<(import("../../wirings/workflow/workflow.types.js").StepState & {
|
|
52
|
+
stepName: string;
|
|
53
|
+
rpcName?: string;
|
|
54
|
+
data?: any;
|
|
55
|
+
})[]>;
|
|
56
|
+
get undone(): string[];
|
|
57
|
+
ok(output?: any): Handler;
|
|
58
|
+
boom(message?: string): Handler;
|
|
59
|
+
undoable(output?: any): Handler;
|
|
60
|
+
}
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { pikkuState, resetPikkuState } from '../../pikku-state.js';
|
|
2
|
+
import { addWorkflow } from '../../wirings/workflow/dsl/workflow-runner.js';
|
|
3
|
+
/**
|
|
4
|
+
* Drives a workflow the way a deployment does: every orchestration and every
|
|
5
|
+
* `workflowQueued` step goes through a queue, and a pump plays the workers.
|
|
6
|
+
* Delays are ignored, so a retry runs on the next pass.
|
|
7
|
+
*/
|
|
8
|
+
export class QueuedWorkflowHarness {
|
|
9
|
+
handlers = {};
|
|
10
|
+
log = [];
|
|
11
|
+
contexts = [];
|
|
12
|
+
recovering = [];
|
|
13
|
+
queue = [];
|
|
14
|
+
jobsRun = [];
|
|
15
|
+
ws;
|
|
16
|
+
/** Return true to drop the job instead of running it, simulating a worker that never ran. */
|
|
17
|
+
dropWhen;
|
|
18
|
+
/** Make `queue.add` throw for a job, simulating the broker being unreachable. */
|
|
19
|
+
rejectAddWhen;
|
|
20
|
+
constructor(service) {
|
|
21
|
+
resetPikkuState();
|
|
22
|
+
this.ws = service;
|
|
23
|
+
const queueService = {
|
|
24
|
+
add: async (queue, data, options) => {
|
|
25
|
+
if (this.rejectAddWhen?.(queue, data)) {
|
|
26
|
+
throw new Error('broker unreachable');
|
|
27
|
+
}
|
|
28
|
+
this.queue.push({ queue, data, attempts: options?.attempts ?? 1 });
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
32
|
+
logger: { error() { }, info() { }, warn() { }, debug() { } },
|
|
33
|
+
queueService,
|
|
34
|
+
workflowService: this.ws,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
rpc = {
|
|
38
|
+
rpcWithWire: async (rpcName, data, wire) => {
|
|
39
|
+
const isCompensation = rpcName.endsWith(':compensate');
|
|
40
|
+
const base = isCompensation
|
|
41
|
+
? rpcName.slice(0, -':compensate'.length)
|
|
42
|
+
: rpcName;
|
|
43
|
+
const handler = this.handlers[base];
|
|
44
|
+
this.contexts.push({
|
|
45
|
+
rpc: rpcName,
|
|
46
|
+
context: wire.workflow?.compensatingFor,
|
|
47
|
+
});
|
|
48
|
+
if (isCompensation) {
|
|
49
|
+
this.log.push(`undo:${base}`);
|
|
50
|
+
return handler.compensate(data, wire.workflow.compensatingFor);
|
|
51
|
+
}
|
|
52
|
+
this.log.push(`do:${base}`);
|
|
53
|
+
if (wire.graph?.recoveringFrom) {
|
|
54
|
+
this.recovering.push({ rpc: base, from: wire.graph.recoveringFrom });
|
|
55
|
+
}
|
|
56
|
+
return handler.forward(data, wire);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
register(name, handler) {
|
|
60
|
+
this.handlers[name] = handler;
|
|
61
|
+
pikkuState(null, 'rpc', 'meta')[name] = name;
|
|
62
|
+
pikkuState(null, 'function', 'meta')[name] = {
|
|
63
|
+
name,
|
|
64
|
+
pikkuFuncId: name,
|
|
65
|
+
sessionless: true,
|
|
66
|
+
permissions: [],
|
|
67
|
+
workflowQueued: handler.queued !== false,
|
|
68
|
+
...(handler.compensate ? { compensate: true } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
defineDsl(name, body) {
|
|
72
|
+
pikkuState(null, 'workflows', 'meta')[name] = {
|
|
73
|
+
name,
|
|
74
|
+
pikkuFuncId: name,
|
|
75
|
+
source: 'dsl',
|
|
76
|
+
graphHash: `${name}-hash`,
|
|
77
|
+
};
|
|
78
|
+
pikkuState(null, 'function', 'meta')[name] = {
|
|
79
|
+
name,
|
|
80
|
+
sessionless: true,
|
|
81
|
+
permissions: [],
|
|
82
|
+
};
|
|
83
|
+
addWorkflow(name, {
|
|
84
|
+
func: async (_services, input, wire) => body(wire.workflow, input),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
defineGraph(name, entry, nodes) {
|
|
88
|
+
pikkuState(null, 'workflows', 'meta')[name] = {
|
|
89
|
+
name,
|
|
90
|
+
pikkuFuncId: name,
|
|
91
|
+
source: 'graph',
|
|
92
|
+
entryNodeIds: [entry],
|
|
93
|
+
graphHash: `${name}-hash`,
|
|
94
|
+
nodes: Object.fromEntries(Object.entries(nodes).map(([id, node]) => [
|
|
95
|
+
id,
|
|
96
|
+
{ nodeId: id, rpcName: id, retries: 0, ...node },
|
|
97
|
+
])),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
async start(name, input = {}) {
|
|
101
|
+
const { runId } = await this.ws.startWorkflow(name, input, { type: 'test' }, this.rpc);
|
|
102
|
+
await this.pump();
|
|
103
|
+
return runId;
|
|
104
|
+
}
|
|
105
|
+
/** Run jobs until the queue is empty. */
|
|
106
|
+
async pump(limit = 500) {
|
|
107
|
+
for (let i = 0; i < limit && this.queue.length > 0; i++) {
|
|
108
|
+
const job = this.queue.shift();
|
|
109
|
+
if (this.dropWhen?.(job))
|
|
110
|
+
continue;
|
|
111
|
+
this.jobsRun.push({ queue: job.queue, step: job.data.stepName });
|
|
112
|
+
try {
|
|
113
|
+
if (job.data.stepName !== undefined) {
|
|
114
|
+
await this.ws.executeWorkflowStep(job.data.runId, job.data.stepName, job.data.rpcName, job.data.data, this.rpc);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
await this.ws.orchestrateWorkflow(job.data.runId, this.rpc);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
job.attempt = (job.attempt ?? 1) + 1;
|
|
122
|
+
if (job.attempt <= (job.attempts ?? 1))
|
|
123
|
+
this.queue.push(job);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (this.queue.length > 0)
|
|
127
|
+
throw new Error('queue did not drain');
|
|
128
|
+
}
|
|
129
|
+
async run(runId) {
|
|
130
|
+
return (await this.ws.getRun(runId));
|
|
131
|
+
}
|
|
132
|
+
async steps(runId) {
|
|
133
|
+
return this.ws.getRunSteps(runId);
|
|
134
|
+
}
|
|
135
|
+
get undone() {
|
|
136
|
+
return this.log.filter((l) => l.startsWith('undo'));
|
|
137
|
+
}
|
|
138
|
+
ok(output = {}) {
|
|
139
|
+
return { forward: async () => output };
|
|
140
|
+
}
|
|
141
|
+
boom(message = 'boom') {
|
|
142
|
+
return {
|
|
143
|
+
forward: async () => {
|
|
144
|
+
throw new Error(message);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
undoable(output = {}) {
|
|
149
|
+
return { ...this.ok(output), compensate: async () => { } };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PikkuWorkflowService } from '../../wirings/workflow/pikku-workflow-service.js';
|
|
2
|
+
/**
|
|
3
|
+
* Conformance suite for saga compensation and graph `recover` on the queued
|
|
4
|
+
* path: every orchestration and step goes through a queue and is played back by
|
|
5
|
+
* a pump, as in a deployment. Each test gets a fresh service.
|
|
6
|
+
*/
|
|
7
|
+
export interface QueuedCompensationOptions {
|
|
8
|
+
/** False for a runtime where one run owns its storage and cannot start a child run. */
|
|
9
|
+
childWorkflows?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare const defineWorkflowCompensationQueuedTests: (name: string, create: () => Promise<PikkuWorkflowService>, options?: QueuedCompensationOptions) => void;
|