@cosmicdrift/kumiko-bundled-features 0.231.0 → 0.232.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/package.json +9 -9
- package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +328 -0
- package/src/workflow-runner/__tests__/pending-projection.integration.test.ts +292 -0
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +277 -0
- package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +6 -11
- package/src/workflow-runner/db/queries/due-runs.ts +23 -0
- package/src/workflow-runner/event-subscriber.ts +93 -0
- package/src/workflow-runner/event-trigger.ts +14 -4
- package/src/workflow-runner/feature.ts +62 -5
- package/src/workflow-runner/handlers/resume-run.write.ts +277 -0
- package/src/workflow-runner/pending-projection.ts +148 -0
- package/src/workflow-runner/runner.ts +36 -13
- package/src/workflow-runner/tables.ts +95 -0
- package/src/workflow-runner/workflow-registry.ts +23 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// resume-loop — the time-based resume path (framework#2513 Phase 2)
|
|
2
|
+
// against real Postgres.
|
|
3
|
+
//
|
|
4
|
+
// Drives `resume-due-runs` directly via stack.registry.getJob(...) (same
|
|
5
|
+
// style as data-retention/__tests__/retention-cleanup.integration.test.ts)
|
|
6
|
+
// with `write` wired to the real dispatcher — this exercises the full
|
|
7
|
+
// resume-run handler (access gate, Q7 check, claim, pipeline re-entry), not
|
|
8
|
+
// just runResumeDueRunsJob's own SELECT.
|
|
9
|
+
//
|
|
10
|
+
// wait's `for` is a resolver reading the trigger payload, so both the due
|
|
11
|
+
// and not-yet-due cases share one workflow fixture — no need to fake the
|
|
12
|
+
// clock or sleep past a real backoff. The retry fixture's backoff can't be
|
|
13
|
+
// steered the same way (only `wait`'s wait-length step-arg is resolver-
|
|
14
|
+
// controlled), so its due-case tampers `wake_at` directly, same as the Q7
|
|
15
|
+
// mismatch test tampers `definition_fingerprint` directly — neither touches
|
|
16
|
+
// the workflow-registry, keeping "registry overwrite" out of the test
|
|
17
|
+
// contract.
|
|
18
|
+
//
|
|
19
|
+
// pending-projection.ts and the event-trigger MSP both run off
|
|
20
|
+
// stack.eventDispatcher.runOnce() — this file's own tests are unaffected by
|
|
21
|
+
// (and must not touch) whatever separately-named test file exercises the
|
|
22
|
+
// projection itself.
|
|
23
|
+
|
|
24
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
25
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
26
|
+
import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
27
|
+
import {
|
|
28
|
+
createSystemUser,
|
|
29
|
+
defineFeature,
|
|
30
|
+
defineWorkflow,
|
|
31
|
+
type JobContext,
|
|
32
|
+
stepsPipeline,
|
|
33
|
+
WORKFLOW_RESUMED_TYPE,
|
|
34
|
+
WORKFLOW_RETRY_SCHEDULED_TYPE,
|
|
35
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
36
|
+
WORKFLOW_RUN_FAILED_TYPE,
|
|
37
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
38
|
+
WORKFLOW_WAITING_TYPE,
|
|
39
|
+
type WorkflowDefinition,
|
|
40
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
41
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
42
|
+
import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
43
|
+
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
|
|
44
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
45
|
+
import { workflowRunAggregateId } from "../aggregate-id";
|
|
46
|
+
import { registerEventTrigger } from "../event-trigger";
|
|
47
|
+
import { workflowRunnerFeature } from "../feature";
|
|
48
|
+
|
|
49
|
+
let stack: TestStack;
|
|
50
|
+
const admin = TestUsers.admin;
|
|
51
|
+
const T = getTemporal();
|
|
52
|
+
|
|
53
|
+
// Uniquely prefixed ("rl-") — the workflow-registry is a process-global
|
|
54
|
+
// module singleton, and workflow-runner.integration.test.ts's own fixtures
|
|
55
|
+
// ("wr-integration-*") already live in the same test process.
|
|
56
|
+
const waitWorkflow: WorkflowDefinition = defineWorkflow({
|
|
57
|
+
name: "rl-wait",
|
|
58
|
+
trigger: { kind: "event", eventType: "rl-test.wait" },
|
|
59
|
+
idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
|
|
60
|
+
steps: stepsPipeline(({ r }) => [
|
|
61
|
+
r.step.wait({ for: (ctx) => (ctx.event.payload as { forIso: string }).forIso }),
|
|
62
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
63
|
+
]),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const retryWorkflow: WorkflowDefinition = defineWorkflow({
|
|
67
|
+
name: "rl-retry",
|
|
68
|
+
trigger: { kind: "event", eventType: "rl-test.retry" },
|
|
69
|
+
idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
|
|
70
|
+
steps: stepsPipeline(({ r }) => [
|
|
71
|
+
r.step.retry({
|
|
72
|
+
times: 2,
|
|
73
|
+
backoff: "linear",
|
|
74
|
+
do: [
|
|
75
|
+
r.step.compute("gate", (ctx) => {
|
|
76
|
+
const attempt = ctx.workflow?.retryAttempt ?? 1;
|
|
77
|
+
if (attempt === 1) throw new Error("first-attempt-fails");
|
|
78
|
+
return attempt;
|
|
79
|
+
}),
|
|
80
|
+
],
|
|
81
|
+
}),
|
|
82
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
83
|
+
]),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const testTriggersFeature = defineFeature("workflow-runner-resume-loop-test-triggers", (r) => {
|
|
87
|
+
registerEventTrigger(r, waitWorkflow);
|
|
88
|
+
registerEventTrigger(r, retryWorkflow);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const noopLogger: JobContext["log"] = {
|
|
92
|
+
info() {},
|
|
93
|
+
warn() {},
|
|
94
|
+
error() {},
|
|
95
|
+
debug() {},
|
|
96
|
+
child() {
|
|
97
|
+
return noopLogger;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
async function fireTrigger(eventType: string, payload: Record<string, unknown>): Promise<void> {
|
|
102
|
+
await insertOne(stack.db, eventsTable, {
|
|
103
|
+
aggregateId: crypto.randomUUID(),
|
|
104
|
+
aggregateType: "rl-test-source",
|
|
105
|
+
tenantId: admin.tenantId,
|
|
106
|
+
version: 1,
|
|
107
|
+
type: eventType,
|
|
108
|
+
eventVersion: 1,
|
|
109
|
+
payload,
|
|
110
|
+
metadata: { userId: admin.id },
|
|
111
|
+
createdBy: admin.id,
|
|
112
|
+
});
|
|
113
|
+
// Two passes: event-trigger's MSP consumes this event and writes the
|
|
114
|
+
// run-started/suspension events on the first pass; pending-projection is a
|
|
115
|
+
// separate MSP consumer that only observes those newly-written events
|
|
116
|
+
// (and materialises the pending row) on a pass after they exist.
|
|
117
|
+
await stack.eventDispatcher?.runOnce();
|
|
118
|
+
await stack.eventDispatcher?.runOnce();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function loadRunEvents(runId: string) {
|
|
122
|
+
return selectMany(
|
|
123
|
+
stack.db,
|
|
124
|
+
eventsTable,
|
|
125
|
+
{ aggregateId: runId },
|
|
126
|
+
{ orderBy: { col: "version", direction: "asc" } },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function pendingRowExists(runId: string, stepIndex: number): Promise<boolean> {
|
|
131
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
132
|
+
`SELECT 1 FROM workflow_run_pending WHERE run_id = $1 AND step_index = $2`,
|
|
133
|
+
[runId, stepIndex],
|
|
134
|
+
)) as ReadonlyArray<unknown>;
|
|
135
|
+
return rows.length > 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function tamperWakeAtToPast(runId: string, stepIndex: number): Promise<void> {
|
|
139
|
+
await asRawClient(stack.db).unsafe(
|
|
140
|
+
`UPDATE workflow_run_pending SET wake_at = now() - interval '1 hour' WHERE run_id = $1 AND step_index = $2`,
|
|
141
|
+
[runId, stepIndex],
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function tamperFingerprint(runId: string, stepIndex: number, value: string): Promise<void> {
|
|
146
|
+
await asRawClient(stack.db).unsafe(
|
|
147
|
+
`UPDATE workflow_run_pending SET definition_fingerprint = $1 WHERE run_id = $2 AND step_index = $3`,
|
|
148
|
+
[value, runId, stepIndex],
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function runResumeDueRunsJob(): Promise<void> {
|
|
153
|
+
const job = stack.registry.getJob("workflow-runner:job:resume-due-runs");
|
|
154
|
+
expect(job).toBeDefined();
|
|
155
|
+
if (!job) return;
|
|
156
|
+
|
|
157
|
+
const systemUser = createSystemUser(admin.tenantId);
|
|
158
|
+
const ctx: JobContext = {
|
|
159
|
+
db: stack.db,
|
|
160
|
+
registry: stack.registry,
|
|
161
|
+
systemUser,
|
|
162
|
+
log: noopLogger,
|
|
163
|
+
triggeredBy: null,
|
|
164
|
+
...bridgeStub({ user: systemUser }),
|
|
165
|
+
write: (qn, payload) => stack.dispatcher.write(qn, payload, systemUser),
|
|
166
|
+
};
|
|
167
|
+
await job.handler({}, ctx);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
describe("workflow-runner resume loop", () => {
|
|
171
|
+
beforeAll(async () => {
|
|
172
|
+
stack = await setupTestStack({ features: [workflowRunnerFeature, testTriggersFeature] });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
afterAll(async () => {
|
|
176
|
+
await stack.cleanup();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("due wait: resume-due-runs resumes it to completion, pending row deleted", async () => {
|
|
180
|
+
const runKey = crypto.randomUUID();
|
|
181
|
+
const runId = workflowRunAggregateId(waitWorkflow.name, runKey);
|
|
182
|
+
const pastIso = T.Now.instant().subtract({ hours: 1 }).toString();
|
|
183
|
+
|
|
184
|
+
await fireTrigger("rl-test.wait", { runKey, forIso: pastIso });
|
|
185
|
+
expect(await pendingRowExists(runId, 0)).toBe(true);
|
|
186
|
+
|
|
187
|
+
await runResumeDueRunsJob();
|
|
188
|
+
|
|
189
|
+
const rows = await loadRunEvents(runId);
|
|
190
|
+
expect(rows.map((row) => row["type"])).toEqual([
|
|
191
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
192
|
+
WORKFLOW_WAITING_TYPE,
|
|
193
|
+
WORKFLOW_RESUMED_TYPE,
|
|
194
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
195
|
+
]);
|
|
196
|
+
expect(rows[3]!["payload"]).toEqual({ workflowName: waitWorkflow.name, stepIndex: 2 });
|
|
197
|
+
|
|
198
|
+
// pending-projection deletes on WORKFLOW_RESUMED via a separate MSP pass.
|
|
199
|
+
await stack.eventDispatcher?.runOnce();
|
|
200
|
+
expect(await pendingRowExists(runId, 0)).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("not-yet-due wait: resume-due-runs leaves the row and run untouched", async () => {
|
|
204
|
+
const runKey = crypto.randomUUID();
|
|
205
|
+
const runId = workflowRunAggregateId(waitWorkflow.name, runKey);
|
|
206
|
+
const futureIso = T.Now.instant().add({ hours: 1 }).toString();
|
|
207
|
+
|
|
208
|
+
await fireTrigger("rl-test.wait", { runKey, forIso: futureIso });
|
|
209
|
+
expect(await pendingRowExists(runId, 0)).toBe(true);
|
|
210
|
+
|
|
211
|
+
await runResumeDueRunsJob();
|
|
212
|
+
|
|
213
|
+
const rows = await loadRunEvents(runId);
|
|
214
|
+
expect(rows.map((row) => row["type"])).toEqual([
|
|
215
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
216
|
+
WORKFLOW_WAITING_TYPE,
|
|
217
|
+
]);
|
|
218
|
+
expect(await pendingRowExists(runId, 0)).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("changed workflow definition: resume-run fails loud with reason workflow_definition_changed, no resume", async () => {
|
|
222
|
+
const runKey = crypto.randomUUID();
|
|
223
|
+
const runId = workflowRunAggregateId(waitWorkflow.name, runKey);
|
|
224
|
+
const pastIso = T.Now.instant().subtract({ hours: 1 }).toString();
|
|
225
|
+
|
|
226
|
+
await fireTrigger("rl-test.wait", { runKey, forIso: pastIso });
|
|
227
|
+
await tamperFingerprint(runId, 0, "tampered-fingerprint-value");
|
|
228
|
+
|
|
229
|
+
await runResumeDueRunsJob();
|
|
230
|
+
|
|
231
|
+
const rows = await loadRunEvents(runId);
|
|
232
|
+
expect(rows.map((row) => row["type"])).toEqual([
|
|
233
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
234
|
+
WORKFLOW_WAITING_TYPE,
|
|
235
|
+
WORKFLOW_RUN_FAILED_TYPE,
|
|
236
|
+
]);
|
|
237
|
+
expect(rows[2]!["payload"]).toMatchObject({
|
|
238
|
+
workflowName: waitWorkflow.name,
|
|
239
|
+
stepIndex: 0,
|
|
240
|
+
reason: "workflow_definition_changed",
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await stack.eventDispatcher?.runOnce();
|
|
244
|
+
expect(await pendingRowExists(runId, 0)).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("retry suspension: resumeFrom is the same stepIndex — the step repeats with retryAttempt threaded, then completes", async () => {
|
|
248
|
+
const runKey = crypto.randomUUID();
|
|
249
|
+
const runId = workflowRunAggregateId(retryWorkflow.name, runKey);
|
|
250
|
+
|
|
251
|
+
await fireTrigger("rl-test.retry", { runKey });
|
|
252
|
+
|
|
253
|
+
const rowsAfterSuspend = await loadRunEvents(runId);
|
|
254
|
+
expect(rowsAfterSuspend.map((row) => row["type"])).toEqual([
|
|
255
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
256
|
+
WORKFLOW_RETRY_SCHEDULED_TYPE,
|
|
257
|
+
]);
|
|
258
|
+
expect(rowsAfterSuspend[1]!["payload"]).toMatchObject({ stepIndex: 0, attempt: 1 });
|
|
259
|
+
|
|
260
|
+
await tamperWakeAtToPast(runId, 0);
|
|
261
|
+
await runResumeDueRunsJob();
|
|
262
|
+
|
|
263
|
+
const rowsAfterResume = await loadRunEvents(runId);
|
|
264
|
+
expect(rowsAfterResume.map((row) => row["type"])).toEqual([
|
|
265
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
266
|
+
WORKFLOW_RETRY_SCHEDULED_TYPE,
|
|
267
|
+
WORKFLOW_RESUMED_TYPE,
|
|
268
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
269
|
+
]);
|
|
270
|
+
// Only one retry-scheduled ever: proves retryAttempt was threaded into
|
|
271
|
+
// the resumed pass (attempt=2 clears the gate) instead of the gate
|
|
272
|
+
// throwing again and scheduling a second retry at attempt=1.
|
|
273
|
+
expect(
|
|
274
|
+
rowsAfterResume.filter((row) => row["type"] === WORKFLOW_RETRY_SCHEDULED_TYPE),
|
|
275
|
+
).toHaveLength(1);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
16
16
|
WORKFLOW_RUN_FAILED_TYPE,
|
|
17
17
|
WORKFLOW_RUN_STARTED_TYPE,
|
|
18
|
+
WORKFLOW_WAITING_TYPE,
|
|
18
19
|
type WorkflowDefinition,
|
|
19
20
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
21
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
@@ -134,22 +135,16 @@ describe("workflow-runner event-trigger", () => {
|
|
|
134
135
|
expect(String(rows[1]!["payload"]["error"])).toContain("boom-explicit-failure");
|
|
135
136
|
});
|
|
136
137
|
|
|
137
|
-
test("suspension: a wait step
|
|
138
|
+
test("suspension: a wait step suspends the run instead of failing it — resumable by framework#2513 Phase 2, no run-completed yet", async () => {
|
|
138
139
|
const runKey = crypto.randomUUID();
|
|
139
140
|
const runId = workflowRunAggregateId(suspendingWorkflow.name, runKey);
|
|
140
141
|
|
|
141
142
|
await fireTrigger("wr-test.suspend", { runKey });
|
|
142
143
|
|
|
143
144
|
const rows = await loadRunEvents(runId);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const failed = rows.find((row) => row["type"] === WORKFLOW_RUN_FAILED_TYPE)!;
|
|
150
|
-
const errorText = String(failed["payload"]["error"]);
|
|
151
|
-
expect(errorText).toContain("WorkflowSuspensionUnsupportedError");
|
|
152
|
-
expect(errorText).toContain(suspendingWorkflow.name);
|
|
153
|
-
expect(errorText).toContain("resume-loop");
|
|
145
|
+
expect(rows.map((row) => row["type"])).toEqual([
|
|
146
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
147
|
+
WORKFLOW_WAITING_TYPE,
|
|
148
|
+
]);
|
|
154
149
|
});
|
|
155
150
|
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
|
|
5
|
+
export type DueRow = { readonly run_id: string; readonly step_index: number };
|
|
6
|
+
|
|
7
|
+
// Standalone statement (no db.begin) — FOR UPDATE SKIP LOCKED only
|
|
8
|
+
// needs to survive this one SELECT to reduce redundant dispatches
|
|
9
|
+
// under true concurrency; it auto-commits and releases the row locks
|
|
10
|
+
// immediately after. The real correctness guarantee is resume-run's
|
|
11
|
+
// VersionConflictError-checked claim (ctx.tryAppendEvent on
|
|
12
|
+
// WORKFLOW_RESUMED) — a second dispatch for the same row just loses
|
|
13
|
+
// that race and no-ops, same as the sample this was adapted from.
|
|
14
|
+
export async function selectDueWorkflowRunPending(
|
|
15
|
+
db: DbConnection,
|
|
16
|
+
tenantId: TenantId,
|
|
17
|
+
): Promise<readonly DueRow[]> {
|
|
18
|
+
// kumiko-lint-ignore raw-sql tenant-scoped due-row pickup with FOR UPDATE SKIP LOCKED
|
|
19
|
+
return (await asRawClient(db).unsafe(
|
|
20
|
+
`SELECT run_id, step_index FROM workflow_run_pending WHERE tenant_id = $1 AND wake_at < now() FOR UPDATE SKIP LOCKED`,
|
|
21
|
+
[tenantId],
|
|
22
|
+
)) as readonly DueRow[];
|
|
23
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// event-subscriber — wakes up pending waitForEvent suspensions when their
|
|
2
|
+
// awaited domain event arrives (framework#2513 Phase 3b, D4 in
|
|
3
|
+
// workflow-resume-loop.md).
|
|
4
|
+
//
|
|
5
|
+
// registerEventTrigger (event-trigger.ts) calls registerEventWakeup for
|
|
6
|
+
// every workflow it registers, building ONE MultiStreamProjection PER
|
|
7
|
+
// WORKFLOW from that workflow's own `awaits` declaration — not a single
|
|
8
|
+
// shared MSP built by scanning the process-global workflow-registry.
|
|
9
|
+
// Reason: the registrar throws synchronously if an MSP's apply-map is
|
|
10
|
+
// empty at r.multiStreamProjection() call time (feature-ui-extensions.ts).
|
|
11
|
+
// workflow-runner's own feature.ts is imported independently of whatever
|
|
12
|
+
// app-specific feature module calls registerEventTrigger — a shared MSP
|
|
13
|
+
// built by reading the registry inside feature.ts's registration would
|
|
14
|
+
// depend on every awaits-declaring feature already having run
|
|
15
|
+
// registerEventTrigger() first, which is an unenforced app-import-order
|
|
16
|
+
// hazard. Building the map right here, from the `workflow` argument
|
|
17
|
+
// already in hand, has no such hazard — same reasoning event-trigger.ts
|
|
18
|
+
// already applies to the run-start MSP.
|
|
19
|
+
//
|
|
20
|
+
// This subscriber never resumes a run itself (D3, workflow-resume-loop.md):
|
|
21
|
+
// the MultiStreamApplyContext it runs in has no callFeature/runStepList
|
|
22
|
+
// access, only unsafeAppendEvent/loadAggregate. It writes triggerEventType
|
|
23
|
+
// + triggerPayload + wakeAt=now() onto the matching pending row(s); the
|
|
24
|
+
// existing resume-due-runs job (Phase 2) picks the row up on its next
|
|
25
|
+
// cron tick — the one resume path stays exactly the one Phase 2 built.
|
|
26
|
+
|
|
27
|
+
import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
28
|
+
import type {
|
|
29
|
+
FeatureRegistrar,
|
|
30
|
+
MultiStreamApplyFn,
|
|
31
|
+
MultiStreamProjectionDefinition,
|
|
32
|
+
WorkflowDefinition,
|
|
33
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
34
|
+
import { evaluateEventMatch } from "@cosmicdrift/kumiko-framework/engine";
|
|
35
|
+
import { workflowRunPendingTable } from "./tables";
|
|
36
|
+
|
|
37
|
+
type CandidateRow = {
|
|
38
|
+
readonly runId: string;
|
|
39
|
+
readonly tenantId: string;
|
|
40
|
+
readonly stepIndex: number;
|
|
41
|
+
readonly matchExpr: unknown | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const wakeupApply: MultiStreamApplyFn = async (event, tx) => {
|
|
45
|
+
const rows = await selectMany<CandidateRow>(tx, workflowRunPendingTable, {
|
|
46
|
+
tenantId: event.tenantId,
|
|
47
|
+
waitEventType: event.type,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
const matches = row.matchExpr
|
|
52
|
+
? evaluateEventMatch(
|
|
53
|
+
// @cast-boundary engine-payload — matchExpr round-trips through
|
|
54
|
+
// jsonb as the EventMatch AST waitForEvent's `match` resolver
|
|
55
|
+
// persisted (see steps/wait-for-event.ts); never an unchecked
|
|
56
|
+
// external value.
|
|
57
|
+
row.matchExpr as Parameters<typeof evaluateEventMatch>[0],
|
|
58
|
+
event.payload,
|
|
59
|
+
)
|
|
60
|
+
: true;
|
|
61
|
+
// skip: this row's matchExpr rejected the event — not this row's
|
|
62
|
+
// resume, leave wakeAt/triggerEventType untouched.
|
|
63
|
+
if (!matches) continue;
|
|
64
|
+
|
|
65
|
+
await updateMany(
|
|
66
|
+
tx,
|
|
67
|
+
workflowRunPendingTable,
|
|
68
|
+
{
|
|
69
|
+
triggerEventType: event.type,
|
|
70
|
+
triggerPayload: event.payload,
|
|
71
|
+
wakeAt: Temporal.Now.instant().toString(),
|
|
72
|
+
},
|
|
73
|
+
{ tenantId: row.tenantId, runId: row.runId, stepIndex: row.stepIndex },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export function registerEventWakeup(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
|
|
79
|
+
const eventTypes = Object.values(workflow.awaits ?? {});
|
|
80
|
+
// skip: no `awaits` declared — nothing to subscribe to, and an empty
|
|
81
|
+
// apply-map throws at r.multiStreamProjection() registration time.
|
|
82
|
+
if (eventTypes.length === 0) return;
|
|
83
|
+
|
|
84
|
+
const apply: Record<string, MultiStreamApplyFn> = {};
|
|
85
|
+
for (const eventType of eventTypes) {
|
|
86
|
+
apply[eventType] = wakeupApply;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
r.multiStreamProjection({
|
|
90
|
+
name: `workflow-${workflow.name}-wakeup`,
|
|
91
|
+
apply,
|
|
92
|
+
} satisfies MultiStreamProjectionDefinition);
|
|
93
|
+
}
|
|
@@ -6,10 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// The MSP apply-fn runs in the dispatcher's own tx, so `workflow.run-started`
|
|
8
8
|
// plus the synchronous portion of the pipeline land atomically. Any throw
|
|
9
|
-
// from startAndRunWorkflow
|
|
10
|
-
//
|
|
11
|
-
// `workflow.run-failed` and rethrown so the dispatcher's retry/dead-letter
|
|
12
|
-
// handling still applies.
|
|
9
|
+
// from startAndRunWorkflow is recorded as `workflow.run-failed` and
|
|
10
|
+
// rethrown so the dispatcher's retry/dead-letter handling still applies.
|
|
13
11
|
|
|
14
12
|
import type {
|
|
15
13
|
FeatureRegistrar,
|
|
@@ -22,9 +20,21 @@ import {
|
|
|
22
20
|
WORKFLOW_RUN_FAILED_TYPE,
|
|
23
21
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
24
22
|
import { workflowRunAggregateId } from "./aggregate-id";
|
|
23
|
+
import { registerEventWakeup } from "./event-subscriber";
|
|
25
24
|
import { startAndRunWorkflow, type WorkflowRunFailedPayload } from "./runner";
|
|
25
|
+
import { registerWorkflow } from "./workflow-registry";
|
|
26
26
|
|
|
27
27
|
export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
|
|
28
|
+
// Populate the workflow-registry unconditionally, before the event-trigger
|
|
29
|
+
// guard below — resume-run (framework#2513 Phase 2) looks workflows up by
|
|
30
|
+
// name regardless of trigger kind, and a cron-triggered workflow that never
|
|
31
|
+
// reaches the MSP branch still needs to be resumable.
|
|
32
|
+
registerWorkflow(workflow);
|
|
33
|
+
|
|
34
|
+
// Independent of trigger.kind — a cron-triggered workflow can still have
|
|
35
|
+
// waitForEvent steps in its pipeline (framework#2513 Phase 3b, D4).
|
|
36
|
+
registerEventWakeup(r, workflow);
|
|
37
|
+
|
|
28
38
|
// skip: cron-triggered workflows have no domain event to project off — they
|
|
29
39
|
// need a scheduler, not an MSP, so there is nothing to register here.
|
|
30
40
|
if (workflow.trigger.kind !== "event") return;
|
|
@@ -7,13 +7,28 @@
|
|
|
7
7
|
// WorkflowDefinition via `defineWorkflow` and wire it up with
|
|
8
8
|
// `registerEventTrigger(r, myWorkflow)` inside their own feature. Mounting
|
|
9
9
|
// `workflowRunnerFeature` documents that dependency and reserves the
|
|
10
|
-
// picker/manifest slot
|
|
10
|
+
// picker/manifest slot.
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// It also registers the resume loop (framework#2513 Phase 2 + 3b):
|
|
13
|
+
// - the workflow_run_pending MSP (Phase 1) materialises suspended
|
|
14
|
+
// wait/waitForEvent/retry steps into a pending-set table
|
|
15
|
+
// - the `resume-due-runs` cron job (perTenant) scans that table for
|
|
16
|
+
// `wakeAt < now()` rows and dispatches `resume-run` per row — the job
|
|
17
|
+
// itself does no resume logic, only SELECT + dispatch
|
|
18
|
+
// - the `resume-run` write handler (r.systemScope(), SYSTEM_ROLE-only)
|
|
19
|
+
// does the actual Q7-check + claim + pipeline re-entry
|
|
20
|
+
// - `registerEventTrigger` (event-trigger.ts) additionally registers an
|
|
21
|
+
// event-wakeup MSP per workflow that declares `awaits` — it marks the
|
|
22
|
+
// matching pending row due as soon as the awaited event arrives,
|
|
23
|
+
// instead of waiting out the full timeout (event-subscriber.ts)
|
|
24
|
+
//
|
|
25
|
+
// wait/retry/waitForEvent suspensions all resume automatically now.
|
|
15
26
|
|
|
16
27
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
28
|
+
import { selectDueWorkflowRunPending } from "./db/queries/due-runs";
|
|
29
|
+
import { resumeRunHandler } from "./handlers/resume-run.write";
|
|
30
|
+
import { registerWorkflowRunPendingProjection } from "./pending-projection";
|
|
31
|
+
import { workflowRunPendingTableMeta } from "./tables";
|
|
17
32
|
|
|
18
33
|
// Kebab-case matching the "./workflow-runner" export subpath — every other
|
|
19
34
|
// bundled feature's runtime name follows this convention, and
|
|
@@ -22,11 +37,53 @@ const FEATURE_NAME = "workflow-runner";
|
|
|
22
37
|
|
|
23
38
|
export const workflowRunnerFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
24
39
|
r.describe(
|
|
25
|
-
"Writes the run-envelope (`workflow.run-started` / `workflow.run-completed` / `workflow.run-failed`) for a workflow run onto its `workflow-run` aggregate stream. Provides `startAndRunWorkflow` and `registerEventTrigger` for consumer features to wire up `defineWorkflow`-based workflows against domain events. Registers no workflows itself.
|
|
40
|
+
"Writes the run-envelope (`workflow.run-started` / `workflow.run-completed` / `workflow.run-failed`) for a workflow run onto its `workflow-run` aggregate stream. Provides `startAndRunWorkflow` and `registerEventTrigger` for consumer features to wire up `defineWorkflow`-based workflows against domain events. Registers no workflows itself. Runs the resume loop for suspended `wait`/`retry`/`waitForEvent` steps (`resume-due-runs` job + `resume-run` handler); `registerEventTrigger` wires an event-wakeup subscriber per workflow that declares `awaits` so a `waitForEvent` step resumes as soon as its awaited event arrives, not just on timeout.",
|
|
26
41
|
);
|
|
27
42
|
r.uiHints({
|
|
28
43
|
displayLabel: "Workflow Runner",
|
|
29
44
|
category: "infrastructure",
|
|
30
45
|
recommended: false,
|
|
31
46
|
});
|
|
47
|
+
// resume-run needs a full HandlerContext + ctx.systemDb (the pending-row
|
|
48
|
+
// scan crosses whatever tenant the row belongs to relative to the job's
|
|
49
|
+
// own tenant loop iteration) — same reasoning as tenant/feature.ts.
|
|
50
|
+
r.systemScope();
|
|
51
|
+
r.storeTable(workflowRunPendingTableMeta, {
|
|
52
|
+
reason: "resume_loop.pending_suspended_steps",
|
|
53
|
+
});
|
|
54
|
+
registerWorkflowRunPendingProjection(r);
|
|
55
|
+
r.writeHandler(resumeRunHandler);
|
|
56
|
+
|
|
57
|
+
r.job(
|
|
58
|
+
"resume-due-runs",
|
|
59
|
+
{ trigger: { cron: "* * * * *" }, perTenant: true, concurrency: "skip" },
|
|
60
|
+
async (_payload, ctx) => {
|
|
61
|
+
if (!ctx.db) {
|
|
62
|
+
throw new Error("resume-due-runs: ctx.db required (JobContext incomplete)");
|
|
63
|
+
}
|
|
64
|
+
const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
|
|
65
|
+
if (tenantId === undefined) {
|
|
66
|
+
// skip: cron fired without a perTenant fan-out tenant — nothing scoped
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const dueRows = await selectDueWorkflowRunPending(ctx.db, tenantId);
|
|
71
|
+
|
|
72
|
+
for (const row of dueRows) {
|
|
73
|
+
try {
|
|
74
|
+
await ctx.write("workflow-runner:write:resume-run", {
|
|
75
|
+
runId: row.run_id,
|
|
76
|
+
stepIndex: row.step_index,
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// skip: one row's dispatch failing (infra hiccup, not a business
|
|
80
|
+
// outcome — resume-run itself never throws for expected cases)
|
|
81
|
+
// must not stop the rest of this tenant's due rows from resuming.
|
|
82
|
+
ctx.log.warn(
|
|
83
|
+
`[workflow-runner:resume-due-runs] runId=${row.run_id} stepIndex=${row.step_index} dispatch failed: ${String(error)}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
);
|
|
32
89
|
});
|