@pikku/core 0.12.130 → 0.12.134
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 +101 -0
- package/dist/middleware-runner.d.ts +4 -0
- package/dist/middleware-runner.js +52 -16
- package/dist/services/in-memory-lease-service.d.ts +13 -0
- package/dist/services/in-memory-lease-service.js +49 -0
- package/dist/services/in-memory-workflow-service.d.ts +11 -3
- package/dist/services/in-memory-workflow-service.js +53 -23
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +2 -0
- package/dist/services/lease-service.d.ts +61 -0
- package/dist/services/lease-service.js +110 -0
- package/dist/services/workflow-service.d.ts +1 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/service-tests/lease-service-tests.d.ts +3 -0
- package/dist/testing/service-tests/lease-service-tests.js +138 -0
- package/dist/testing/service-tests/workflow-fencing-tests.d.ts +18 -0
- package/dist/testing/service-tests/workflow-fencing-tests.js +216 -0
- package/dist/testing/service-tests.d.ts +5 -0
- package/dist/testing/service-tests.js +8 -0
- package/dist/types/core.types.d.ts +12 -8
- package/dist/utils/hmac.d.ts +14 -10
- package/dist/utils/hmac.js +29 -25
- package/dist/wirings/http/pikku-fetch-http-response.js +13 -0
- package/dist/wirings/secret/derive-oauth2-app-secrets.js +1 -0
- package/dist/wirings/secret/secret.types.d.ts +6 -0
- package/dist/wirings/trigger/index.d.ts +2 -2
- package/dist/wirings/trigger/index.js +1 -1
- package/dist/wirings/trigger/webhook-source-runner.js +52 -1
- package/dist/wirings/trigger/webhook-source.types.d.ts +50 -0
- package/dist/wirings/trigger/webhook-source.types.js +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +33 -14
- package/dist/wirings/workflow/index.d.ts +4 -3
- package/dist/wirings/workflow/index.js +3 -2
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +57 -18
- package/dist/wirings/workflow/pikku-workflow-service.js +143 -140
- package/dist/wirings/workflow/workflow-constants.d.ts +28 -1
- package/dist/wirings/workflow/workflow-constants.js +28 -1
- package/dist/wirings/workflow/workflow-errors.d.ts +21 -0
- package/dist/wirings/workflow/workflow-errors.js +38 -0
- package/dist/wirings/workflow/workflow-queue-routing.d.ts +4 -2
- package/dist/wirings/workflow/workflow-queue-routing.js +9 -1
- package/dist/wirings/workflow/workflow-run-lease.d.ts +10 -0
- package/dist/wirings/workflow/workflow-run-lease.js +25 -0
- package/dist/wirings/workflow/workflow-run-status.d.ts +4 -0
- package/dist/wirings/workflow/workflow-run-status.js +37 -0
- package/dist/wirings/workflow/workflow-status-stream.d.ts +2 -1
- package/dist/wirings/workflow/workflow-status-stream.js +18 -1
- package/dist/wirings/workflow/workflow-step-claim.d.ts +13 -1
- package/dist/wirings/workflow/workflow-step-claim.js +29 -5
- package/dist/wirings/workflow/workflow-step-lease.d.ts +25 -0
- package/dist/wirings/workflow/workflow-step-lease.js +53 -0
- package/dist/wirings/workflow/workflow-step-retry.d.ts +14 -0
- package/dist/wirings/workflow/workflow-step-retry.js +33 -0
- package/dist/wirings/workflow/workflow-version-fallback.d.ts +11 -0
- package/dist/wirings/workflow/workflow-version-fallback.js +30 -0
- package/dist/wirings/workflow/workflow.types.d.ts +17 -0
- package/knowledge/decisions/internals/a-held-run-is-woken-later-not-retried.md +28 -0
- package/knowledge/decisions/internals/index.md +2 -1
- package/knowledge/decisions/internals/the-in-memory-workflow-service-is-inline-only-and-single-process.md +2 -2
- package/knowledge/decisions/internals/workflow-step-lock-is-held-only-to-claim-the-step.md +12 -5
- package/package.json +1 -1
- package/src/public-surface.json +18 -1
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
import { runPikkuFunc } from '../../function/function-runner.js';
|
|
2
2
|
import { getSingletonServices, getCreateWireServices, pikkuState, } from '../../pikku-state.js';
|
|
3
3
|
import { getDurationInMilliseconds } from '../../time-utils.js';
|
|
4
|
-
import { ChildWorkflowStartedException, continueGraph, executeGraphStep, runWorkflowGraph,
|
|
4
|
+
import { ChildWorkflowStartedException, continueGraph, executeGraphStep, runWorkflowGraph, stripInstanceOrdinal, } from './graph/graph-runner.js';
|
|
5
5
|
import { isExpectedError } from '../../errors/error-handler.js';
|
|
6
6
|
import { PikkuMissingMetaError } from '../../errors/errors.js';
|
|
7
7
|
import { RPCNotFoundError } from '../rpc/rpc-runner.js';
|
|
8
8
|
import { deriveInvocationId } from './workflow-invocation-id.js';
|
|
9
9
|
import { approvalDeciderFrom } from './workflow-approval-policy.js';
|
|
10
10
|
import { buildRunTimeline, reconstructStateAt, } from './run-timeline.js';
|
|
11
|
-
import { DEFAULT_STEP_RETRIES, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, isRunSettled, } from './workflow-constants.js';
|
|
12
|
-
import { WorkflowAsyncException, WorkflowCancelledException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotFoundError, WorkflowStepNameNotString, WorkflowSuspendedException, } from './workflow-errors.js';
|
|
11
|
+
import { DEFAULT_STEP_RETRIES, RUN_LEASE_RETRY_MS, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, isRunSettled, } from './workflow-constants.js';
|
|
12
|
+
import { WorkflowAsyncException, WorkflowCancelledException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotFoundError, WorkflowStepNameNotString, WorkflowStepSupersededError, WorkflowSuspendedException, } from './workflow-errors.js';
|
|
13
13
|
import { resolveWorkflowMeta } from './workflow-meta-resolver.js';
|
|
14
14
|
import { storeWorkflowVersion } from './workflow-version-store.js';
|
|
15
|
-
import { jobGroupFor, orchestratorQueueName, resolveWorkflowConfig, stepDispatchTarget, stepJobOptions, stepWorkerQueueName, } from './workflow-queue-routing.js';
|
|
15
|
+
import { jobGroupFor, orchestratorQueueName, requireQueueService, resolveWorkflowConfig, stepDispatchTarget, stepJobOptions, stepWorkerQueueName, } from './workflow-queue-routing.js';
|
|
16
16
|
import { wireWorkflowQueueWorkers } from './workflow-queue-wiring.js';
|
|
17
17
|
import { approvalStepNameFor, evaluateApprovalStep, recordApprovalDecision, } from './workflow-approval.js';
|
|
18
18
|
import { auditApprovalDecision } from './workflow-approval-audit.js';
|
|
19
19
|
import { recordSuspension, suspendStepNameFor } from './workflow-suspend.js';
|
|
20
20
|
import { claimStepByReadThenWrite } from './workflow-step-claim.js';
|
|
21
|
+
import { holdWorkflowLease, isRunLeaseError, nullWhenStepHeld, } from './workflow-run-lease.js';
|
|
22
|
+
import { summarizeRunStatus } from './workflow-run-status.js';
|
|
23
|
+
import { runningStepLease, startStepLeaseRefresh, stepLeaseMsForQueue, } from './workflow-step-lease.js';
|
|
24
|
+
import { runInlineRetryLoop } from './workflow-step-retry.js';
|
|
25
|
+
import { runVersionMismatchFallback } from './workflow-version-fallback.js';
|
|
21
26
|
import { RedispatchBackoff, sweepStalledRuns, sweepUndispatchedSteps, } from './workflow-recovery.js';
|
|
22
27
|
export class PikkuWorkflowService {
|
|
23
28
|
runExtension;
|
|
@@ -61,12 +66,19 @@ export class PikkuWorkflowService {
|
|
|
61
66
|
return getSingletonServices()?.logger;
|
|
62
67
|
}
|
|
63
68
|
mirror;
|
|
69
|
+
/**
|
|
70
|
+
* Serialises orchestration passes over a run and claims on a step. A service
|
|
71
|
+
* whose state no other process can reach — in-memory, a Durable Object —
|
|
72
|
+
* has no competitor to exclude, overrides both locks and passes none.
|
|
73
|
+
*/
|
|
74
|
+
leaseService;
|
|
64
75
|
queueStrategy;
|
|
65
76
|
queueConcurrency;
|
|
66
77
|
queueGroupConcurrency;
|
|
67
78
|
constructor(options = {}) {
|
|
68
79
|
const wireQueues = options.wireQueues ?? true;
|
|
69
80
|
this.mirror = options.mirror;
|
|
81
|
+
this.leaseService = options.leaseService;
|
|
70
82
|
this.queueStrategy = options.queueStrategy ?? 'per-workflow';
|
|
71
83
|
this.queueConcurrency = options.queueConcurrency ?? 20;
|
|
72
84
|
this.queueGroupConcurrency = options.queueGroupConcurrency ?? 2;
|
|
@@ -136,41 +148,7 @@ export class PikkuWorkflowService {
|
|
|
136
148
|
const run = await this.getRun(id);
|
|
137
149
|
if (!run)
|
|
138
150
|
return null;
|
|
139
|
-
|
|
140
|
-
const terminalStatuses = new Set(['completed', 'failed', 'cancelled']);
|
|
141
|
-
const stepMap = new Map();
|
|
142
|
-
for (const step of history) {
|
|
143
|
-
const existing = stepMap.get(step.stepName);
|
|
144
|
-
if (!existing || step.updatedAt > existing.completedAt) {
|
|
145
|
-
stepMap.set(step.stepName, {
|
|
146
|
-
status: step.status,
|
|
147
|
-
startedAt: step.runningAt ?? step.createdAt,
|
|
148
|
-
completedAt: step.succeededAt ?? step.failedAt,
|
|
149
|
-
attempts: step.attemptCount,
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
const steps = [...stepMap.entries()].map(([name, s]) => ({
|
|
154
|
-
name,
|
|
155
|
-
status: s.status,
|
|
156
|
-
duration: s.startedAt && s.completedAt
|
|
157
|
-
? s.completedAt.getTime() - s.startedAt.getTime()
|
|
158
|
-
: undefined,
|
|
159
|
-
attempts: s.attempts,
|
|
160
|
-
}));
|
|
161
|
-
return {
|
|
162
|
-
id: run.id,
|
|
163
|
-
status: run.status,
|
|
164
|
-
startedAt: run.createdAt,
|
|
165
|
-
completedAt: terminalStatuses.has(run.status) ? run.updatedAt : undefined,
|
|
166
|
-
deterministic: run.deterministic,
|
|
167
|
-
plannedSteps: run.plannedSteps,
|
|
168
|
-
steps,
|
|
169
|
-
output: run.status === 'completed' ? run.output : undefined,
|
|
170
|
-
error: run.error
|
|
171
|
-
? { message: run.error.message ?? 'Unknown error' }
|
|
172
|
-
: undefined,
|
|
173
|
-
};
|
|
151
|
+
return summarizeRunStatus(run, await this.getRunHistory(id));
|
|
174
152
|
}
|
|
175
153
|
async getRunTimeline(id) {
|
|
176
154
|
const run = await this.getRun(id);
|
|
@@ -197,17 +175,40 @@ export class PikkuWorkflowService {
|
|
|
197
175
|
async setStepRunning(stepId) {
|
|
198
176
|
await this.mirrored(() => this.setStepRunningImpl(stepId), (mirror) => mirror.setStepRunning(stepId));
|
|
199
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Push the claim on a `running` step forward, so it keeps reading as owned
|
|
180
|
+
* for another lease.
|
|
181
|
+
*
|
|
182
|
+
* Does nothing by default, which leaves a store that records no lease exactly
|
|
183
|
+
* as it was: its `running` steps are owned until they move on their own, and
|
|
184
|
+
* a dispatch that dies takes the step with it. A store that overrides this
|
|
185
|
+
* gains recovery from a lost worker, through both `claimStepForExecution` and
|
|
186
|
+
* `recoverStalledRuns`.
|
|
187
|
+
*
|
|
188
|
+
* `null` releases the lease without ending the step, for a step that is
|
|
189
|
+
* legitimately `running` with no worker on it — one parked on a child run,
|
|
190
|
+
* which the child's completion drives rather than a redispatch.
|
|
191
|
+
* `attempt` fences it to the claim that holds it.
|
|
192
|
+
*
|
|
193
|
+
* The lease runs `leaseMs` from now on the store's own clock, so workers
|
|
194
|
+
* whose clocks disagree still agree on when it lapses. Resolves `false` when
|
|
195
|
+
* the step is no longer this attempt's to renew.
|
|
196
|
+
*/
|
|
197
|
+
async refreshStepLease(_stepId, _leaseMs, _attempt) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
200
|
async setStepScheduled(stepId) {
|
|
201
201
|
await this.mirrored(() => this.setStepScheduledImpl(stepId), (mirror) => mirror.setStepScheduled(stepId));
|
|
202
202
|
}
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
/** `attempt` fences it: a newer claim's step throws `WorkflowStepSupersededError`. */
|
|
204
|
+
async setStepResult(stepId, result, attempt) {
|
|
205
|
+
await this.mirrored(() => this.setStepResultImpl(stepId, result, attempt), (mirror) => mirror.setStepResult(stepId, result));
|
|
205
206
|
}
|
|
206
207
|
async setStepChildRunId(stepId, childRunId) {
|
|
207
208
|
await this.mirrored(() => this.setStepChildRunIdImpl(stepId, childRunId), (mirror) => mirror.setStepChildRunId(stepId, childRunId));
|
|
208
209
|
}
|
|
209
|
-
async setStepError(stepId, error) {
|
|
210
|
-
await this.mirrored(() => this.setStepErrorImpl(stepId, error), (mirror) => {
|
|
210
|
+
async setStepError(stepId, error, attempt) {
|
|
211
|
+
await this.mirrored(() => this.setStepErrorImpl(stepId, error, attempt), (mirror) => {
|
|
211
212
|
const serialized = {
|
|
212
213
|
message: error.message,
|
|
213
214
|
stack: error.stack,
|
|
@@ -223,6 +224,15 @@ export class PikkuWorkflowService {
|
|
|
223
224
|
stepName: newStep.stepName ?? '',
|
|
224
225
|
}));
|
|
225
226
|
}
|
|
227
|
+
/** One orchestration pass under the run's lease; never waits (see `holdWorkflowLease`). */
|
|
228
|
+
async withRunLease(id, fn) {
|
|
229
|
+
return holdWorkflowLease(this, this.leaseService, `workflow-run:${id}`, fn);
|
|
230
|
+
}
|
|
231
|
+
/** `fn` under one step's lease; never waits (see `holdWorkflowLease`). */
|
|
232
|
+
async withStepLock(runId, stepName, fn) {
|
|
233
|
+
const key = `workflow-step:${runId}:${stepName}`;
|
|
234
|
+
return holdWorkflowLease(this, this.leaseService, key, fn);
|
|
235
|
+
}
|
|
226
236
|
async setBranchTaken(stepId, branchKey) {
|
|
227
237
|
await this.mirrored(() => this.setBranchTakenImpl(stepId, branchKey), (mirror) => mirror.setBranchTaken(stepId, branchKey));
|
|
228
238
|
}
|
|
@@ -236,20 +246,25 @@ export class PikkuWorkflowService {
|
|
|
236
246
|
await this.mirrored(() => this.updateWorkflowVersionStatusImpl(name, graphHash, status), (mirror) => mirror.updateWorkflowVersionStatus(name, graphHash, status));
|
|
237
247
|
}
|
|
238
248
|
async resumeWorkflow(runId, workflowName) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
249
|
+
await this.enqueueOrchestration(runId, workflowName);
|
|
250
|
+
}
|
|
251
|
+
async enqueueOrchestration(runId, workflowName, delay) {
|
|
252
|
+
const queueService = requireQueueService();
|
|
253
|
+
workflowName ??= (await this.getRun(runId))?.workflow;
|
|
244
254
|
await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, {
|
|
245
255
|
...this.resolveStepJobOptions(),
|
|
256
|
+
...(delay !== undefined ? { delay } : {}),
|
|
246
257
|
group: this.getJobGroup(workflowName),
|
|
247
258
|
});
|
|
248
259
|
}
|
|
249
260
|
/**
|
|
250
261
|
* Ids of runs that are stalled: still `running`, with no step in a state that
|
|
251
|
-
* something is expected to complete (`
|
|
252
|
-
* and no step activity since `before`.
|
|
262
|
+
* something is expected to complete (`scheduled`, `suspended`, or `running`
|
|
263
|
+
* under a live lease), and no step activity since `before`.
|
|
264
|
+
*
|
|
265
|
+
* A `running` step whose lease has lapsed is the opposite of in flight — the
|
|
266
|
+
* dispatch that claimed it is gone — so it does not hold its run out of the
|
|
267
|
+
* sweep.
|
|
253
268
|
*
|
|
254
269
|
* Returns nothing by default so a store that cannot express the query keeps
|
|
255
270
|
* working unchanged; a store that overrides it gains crash recovery through
|
|
@@ -301,7 +316,7 @@ export class PikkuWorkflowService {
|
|
|
301
316
|
return stepJobOptions(stepOptions);
|
|
302
317
|
}
|
|
303
318
|
async queueStepWorker(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
304
|
-
const queueService =
|
|
319
|
+
const queueService = requireQueueService();
|
|
305
320
|
await queueService.add(this.getStepWorkerQueueName(rpcName), { runId, stepName, rpcName, data, fromStepName }, {
|
|
306
321
|
...this.resolveStepJobOptions(stepOptions),
|
|
307
322
|
group: this.getJobGroup(rpcName),
|
|
@@ -312,7 +327,7 @@ export class PikkuWorkflowService {
|
|
|
312
327
|
await this.resumeWorkflow(runId);
|
|
313
328
|
}
|
|
314
329
|
async scheduleOrchestratorRetry(runId, retryDelay, workflowName) {
|
|
315
|
-
const queueService =
|
|
330
|
+
const queueService = requireQueueService();
|
|
316
331
|
if (!workflowName) {
|
|
317
332
|
const run = await this.getRun(runId);
|
|
318
333
|
workflowName = run?.workflow;
|
|
@@ -409,7 +424,8 @@ export class PikkuWorkflowService {
|
|
|
409
424
|
if (error.name !== 'WorkflowAsyncException' &&
|
|
410
425
|
error.name !== 'WorkflowCancelledException' &&
|
|
411
426
|
error.name !== 'WorkflowSuspendedException' &&
|
|
412
|
-
error.name !== 'WorkflowDispatchException'
|
|
427
|
+
error.name !== 'WorkflowDispatchException' &&
|
|
428
|
+
!isRunLeaseError(error)) {
|
|
413
429
|
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
414
430
|
name: error.name,
|
|
415
431
|
message: error.message,
|
|
@@ -418,7 +434,11 @@ export class PikkuWorkflowService {
|
|
|
418
434
|
getSingletonServices().logger.error(`Workflow ${name} (run ${runId}) failed:`, isExpectedError(error) ? error.message : error);
|
|
419
435
|
throw error;
|
|
420
436
|
}
|
|
421
|
-
|
|
437
|
+
// A lease error says nothing about how the run went — a lost lease is
|
|
438
|
+
// only seen once the body has already written its outcome — so the
|
|
439
|
+
// run keeps whatever status it reached.
|
|
440
|
+
if (error.name === 'WorkflowDispatchException' ||
|
|
441
|
+
isRunLeaseError(error)) {
|
|
422
442
|
throw error;
|
|
423
443
|
}
|
|
424
444
|
}
|
|
@@ -551,19 +571,24 @@ export class PikkuWorkflowService {
|
|
|
551
571
|
if (run.graphHash &&
|
|
552
572
|
workflowMeta?.graphHash &&
|
|
553
573
|
run.graphHash !== workflowMeta.graphHash) {
|
|
554
|
-
await this.runVersionMismatchFallback(run, workflowMeta, rpcService);
|
|
574
|
+
await this.withRunLease(runId, () => runVersionMismatchFallback(this, run, workflowMeta, rpcService));
|
|
555
575
|
return;
|
|
556
576
|
}
|
|
557
577
|
if (workflowMeta?.source === 'graph') {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
updatedRun?.status === '
|
|
565
|
-
|
|
566
|
-
|
|
578
|
+
// Two passes over one graph each plan from the state they read, so both
|
|
579
|
+
// can fire the same next node; the run lease keeps them one at a time,
|
|
580
|
+
// exactly as it does for a DSL run's replay.
|
|
581
|
+
await this.withRunLease(runId, async () => {
|
|
582
|
+
await continueGraph(this, runId, run.workflow);
|
|
583
|
+
const updatedRun = await this.getRun(runId);
|
|
584
|
+
if (updatedRun?.status === 'completed') {
|
|
585
|
+
await this.onChildWorkflowCompleted(updatedRun, updatedRun.output);
|
|
586
|
+
}
|
|
587
|
+
else if (updatedRun?.status === 'failed' ||
|
|
588
|
+
updatedRun?.status === 'cancelled') {
|
|
589
|
+
await this.onChildWorkflowFailed(updatedRun, new Error(updatedRun.error?.message || 'Child workflow failed'));
|
|
590
|
+
}
|
|
591
|
+
});
|
|
567
592
|
return;
|
|
568
593
|
}
|
|
569
594
|
const registrations = pikkuState(pkgName, 'workflows', 'registrations');
|
|
@@ -574,7 +599,7 @@ export class PikkuWorkflowService {
|
|
|
574
599
|
if (!workflow) {
|
|
575
600
|
throw new WorkflowNotFoundError(run.workflow);
|
|
576
601
|
}
|
|
577
|
-
await this.
|
|
602
|
+
await this.withRunLease(runId, async () => {
|
|
578
603
|
const addonNs = run.workflow.includes(':')
|
|
579
604
|
? run.workflow.substring(0, run.workflow.indexOf(':'))
|
|
580
605
|
: null;
|
|
@@ -667,32 +692,17 @@ export class PikkuWorkflowService {
|
|
|
667
692
|
await this.setStepError(parentStepId, error);
|
|
668
693
|
await this.resumeWorkflow(parentRunId);
|
|
669
694
|
}
|
|
670
|
-
async runVersionMismatchFallback(run, currentMeta, rpcService) {
|
|
671
|
-
const source = currentMeta.source;
|
|
672
|
-
if (source === 'complex') {
|
|
673
|
-
await this.updateRunStatus(run.id, 'failed', undefined, {
|
|
674
|
-
message: `Workflow '${run.workflow}' definition changed. Complex workflows with inline steps cannot be migrated.`,
|
|
675
|
-
stack: '',
|
|
676
|
-
code: 'VERSION_CONFLICT',
|
|
677
|
-
});
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
const version = await this.getWorkflowVersion(run.workflow, run.graphHash);
|
|
681
|
-
if (!version) {
|
|
682
|
-
await this.updateRunStatus(run.id, 'failed', undefined, {
|
|
683
|
-
message: `Workflow '${run.workflow}' version '${run.graphHash}' not found. Cannot resume with changed definition.`,
|
|
684
|
-
stack: '',
|
|
685
|
-
code: 'VERSION_NOT_FOUND',
|
|
686
|
-
});
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
await runFromMeta(this, run.id, version.graph, rpcService);
|
|
690
|
-
}
|
|
691
695
|
async executeWorkflowStep(runId, stepName, rpcName, data, rpcService) {
|
|
692
696
|
this.enterExecution(runId);
|
|
693
697
|
try {
|
|
694
698
|
await this.executeWorkflowStepInner(runId, stepName, rpcName, data, rpcService);
|
|
695
699
|
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
// A newer claim owns the step and its outcome; this dispatch's is dropped.
|
|
702
|
+
if (!(error instanceof WorkflowStepSupersededError))
|
|
703
|
+
throw error;
|
|
704
|
+
this.logger?.warn(error.message);
|
|
705
|
+
}
|
|
696
706
|
finally {
|
|
697
707
|
this.exitExecution(runId);
|
|
698
708
|
}
|
|
@@ -705,20 +715,28 @@ export class PikkuWorkflowService {
|
|
|
705
715
|
* believes were dropped, and a queue can redeliver a job it already handed
|
|
706
716
|
* out. This is the one place that keeps a duplicate dispatch from becoming a
|
|
707
717
|
* second execution of a side-effecting step, so it is only as strong as the
|
|
708
|
-
* exclusion it is built on
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
718
|
+
* exclusion it is built on: here, the step's lease on the app's
|
|
719
|
+
* `leaseService`. A store able to express the decision as one conditional
|
|
720
|
+
* write should override this rather than reach for a lock, which is what
|
|
721
|
+
* `@pikku/kysely` does with a status-guarded `UPDATE`.
|
|
722
|
+
*
|
|
723
|
+
* Ownership is held for `leaseMs` rather than forever. A dispatch that
|
|
724
|
+
* dies mid-step leaves the step `running` with nothing to complete it, and an
|
|
725
|
+
* unconditional rejection of `running` would then wedge it beyond the reach
|
|
726
|
+
* of both this claim and the stalled-run sweep.
|
|
712
727
|
*/
|
|
713
|
-
async claimStepForExecution(runId, stepName, rpcName) {
|
|
714
|
-
return this.withStepLock(runId, stepName, () => claimStepByReadThenWrite(this, runId, stepName, rpcName));
|
|
728
|
+
async claimStepForExecution(runId, stepName, rpcName, leaseMs) {
|
|
729
|
+
return nullWhenStepHeld(() => this.withStepLock(runId, stepName, () => claimStepByReadThenWrite(this, runId, stepName, rpcName, leaseMs)));
|
|
715
730
|
}
|
|
716
731
|
async executeWorkflowStepInner(runId, stepName, rpcName, data, rpcService) {
|
|
717
|
-
const
|
|
732
|
+
const leaseMs = stepLeaseMsForQueue(this.getStepWorkerQueueName(rpcName));
|
|
733
|
+
const claimed = await this.claimStepForExecution(runId, stepName, rpcName, leaseMs);
|
|
718
734
|
if (!claimed) {
|
|
719
735
|
return;
|
|
720
736
|
}
|
|
721
737
|
const stepState = claimed;
|
|
738
|
+
const attempt = stepState.attemptCount;
|
|
739
|
+
const stopLeaseRefresh = startStepLeaseRefresh(stepState.stepId, leaseMs, () => this.refreshStepLease(stepState.stepId, leaseMs, attempt));
|
|
722
740
|
try {
|
|
723
741
|
let result;
|
|
724
742
|
const run = await this.getRun(runId);
|
|
@@ -773,23 +791,28 @@ export class PikkuWorkflowService {
|
|
|
773
791
|
result = await this.invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService, run);
|
|
774
792
|
}
|
|
775
793
|
}
|
|
776
|
-
await this.setStepResult(stepState.stepId, result);
|
|
794
|
+
await this.setStepResult(stepState.stepId, result, attempt);
|
|
777
795
|
await this.resumeWorkflow(runId);
|
|
778
796
|
}
|
|
779
797
|
catch (error) {
|
|
780
798
|
if (error instanceof ChildWorkflowStartedException) {
|
|
799
|
+
// The step stays `running` with no worker on it, which is not the same
|
|
800
|
+
// as abandoned: the child run is what completes it. Releasing the lease
|
|
801
|
+
// says so, once no renewal in flight can undo it.
|
|
802
|
+
await stopLeaseRefresh();
|
|
803
|
+
await this.refreshStepLease(stepState.stepId, null, attempt);
|
|
781
804
|
this.logger?.debug(`Workflow step '${stepName}': child workflow ${error.childRunId} started, waiting for completion`);
|
|
782
805
|
return;
|
|
783
806
|
}
|
|
784
807
|
if (error instanceof RPCNotFoundError) {
|
|
785
|
-
await this.setStepError(stepState.stepId, error);
|
|
808
|
+
await this.setStepError(stepState.stepId, error, attempt);
|
|
786
809
|
await this.updateRunStatus(runId, 'suspended', undefined, {
|
|
787
810
|
message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
|
|
788
811
|
code: 'RPC_NOT_FOUND',
|
|
789
812
|
});
|
|
790
813
|
return;
|
|
791
814
|
}
|
|
792
|
-
await this.setStepError(stepState.stepId, error);
|
|
815
|
+
await this.setStepError(stepState.stepId, error, attempt);
|
|
793
816
|
const maxAttempts = (stepState.retries ?? DEFAULT_STEP_RETRIES) + 1;
|
|
794
817
|
const retriesExhausted = stepState.attemptCount >= maxAttempts;
|
|
795
818
|
if (retriesExhausted) {
|
|
@@ -797,6 +820,9 @@ export class PikkuWorkflowService {
|
|
|
797
820
|
}
|
|
798
821
|
throw error;
|
|
799
822
|
}
|
|
823
|
+
finally {
|
|
824
|
+
await stopLeaseRefresh();
|
|
825
|
+
}
|
|
800
826
|
}
|
|
801
827
|
async orchestrateWorkflow(runId, rpcService) {
|
|
802
828
|
try {
|
|
@@ -808,6 +834,16 @@ export class PikkuWorkflowService {
|
|
|
808
834
|
error.name === 'WorkflowSuspendedException') {
|
|
809
835
|
return;
|
|
810
836
|
}
|
|
837
|
+
// Another orchestrator holds the run. Its pass may already have read the
|
|
838
|
+
// state this message was sent to report, so the message cannot simply be
|
|
839
|
+
// dropped — and handing it back to the queue spends the retry budget the
|
|
840
|
+
// queue keeps for real failures, which a long pass exhausts. Wake the run
|
|
841
|
+
// again once the holder has had time to finish.
|
|
842
|
+
if (isRunLeaseError(error)) {
|
|
843
|
+
getSingletonServices().logger.info(`Workflow run ${runId} is being orchestrated elsewhere; waking it again in ${RUN_LEASE_RETRY_MS}ms`);
|
|
844
|
+
await this.enqueueOrchestration(runId, undefined, RUN_LEASE_RETRY_MS);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
811
847
|
if (error.name === 'WorkflowDispatchException') {
|
|
812
848
|
getSingletonServices().logger.warn(`Workflow run ${runId} could not dispatch a step (queue unavailable); leaving run for orchestrator retry`, error);
|
|
813
849
|
throw error;
|
|
@@ -820,12 +856,6 @@ export class PikkuWorkflowService {
|
|
|
820
856
|
throw error;
|
|
821
857
|
}
|
|
822
858
|
}
|
|
823
|
-
verifyQueueService() {
|
|
824
|
-
if (!getSingletonServices()?.queueService) {
|
|
825
|
-
throw new Error('QueueService not configured. Remote workflows require a queue service.');
|
|
826
|
-
}
|
|
827
|
-
return getSingletonServices().queueService;
|
|
828
|
-
}
|
|
829
859
|
async invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService, knownRun) {
|
|
830
860
|
const run = knownRun ?? (await this.getRunIdentity(runId));
|
|
831
861
|
return rpcService.rpcWithWire(rpcName, data, {
|
|
@@ -841,34 +871,6 @@ export class PikkuWorkflowService {
|
|
|
841
871
|
},
|
|
842
872
|
});
|
|
843
873
|
}
|
|
844
|
-
async runInlineRetryLoop(stepState, retries, retryDelay, doWork, onError) {
|
|
845
|
-
let currentStepState = stepState;
|
|
846
|
-
while (true) {
|
|
847
|
-
try {
|
|
848
|
-
await this.setStepRunning(currentStepState.stepId);
|
|
849
|
-
const result = await doWork(currentStepState);
|
|
850
|
-
await this.setStepResult(currentStepState.stepId, result);
|
|
851
|
-
return result;
|
|
852
|
-
}
|
|
853
|
-
catch (error) {
|
|
854
|
-
if (onError)
|
|
855
|
-
await onError(error);
|
|
856
|
-
await this.setStepError(currentStepState.stepId, error);
|
|
857
|
-
if (currentStepState.attemptCount < retries) {
|
|
858
|
-
currentStepState = await this.createRetryAttempt(currentStepState.stepId, 'pending');
|
|
859
|
-
if (retryDelay) {
|
|
860
|
-
await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(retryDelay)));
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
else {
|
|
864
|
-
throw error;
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
async runStepCompensation(runId, stepName, onErrorRpcName, rpcService, error) {
|
|
870
|
-
await this.rpcStep(runId, `${stepName}:onError`, onErrorRpcName, { error: { message: error.message } }, rpcService, { retries: 0 });
|
|
871
|
-
}
|
|
872
874
|
async rpcStep(runId, logicalStepName, rpcName, data, rpcService, stepOptions) {
|
|
873
875
|
const fromStepName = this.lastStepName(runId);
|
|
874
876
|
const stepName = this.nextStepKey(runId, logicalStepName);
|
|
@@ -886,7 +888,7 @@ export class PikkuWorkflowService {
|
|
|
886
888
|
const error = new Error(stepState.error?.message ||
|
|
887
889
|
`Step '${stepName}' failed after exhausting all retries`);
|
|
888
890
|
if (resolvedStepOptions.onError) {
|
|
889
|
-
await this.
|
|
891
|
+
await this.rpcStep(runId, `${stepName}:onError`, resolvedStepOptions.onError, { error: { message: error.message } }, rpcService, { retries: 0 });
|
|
890
892
|
}
|
|
891
893
|
if (stepState.error) {
|
|
892
894
|
Object.assign(error, stepState.error);
|
|
@@ -896,16 +898,22 @@ export class PikkuWorkflowService {
|
|
|
896
898
|
if (stepState.status === 'scheduled') {
|
|
897
899
|
throw new WorkflowAsyncException(runId, stepName);
|
|
898
900
|
}
|
|
901
|
+
const lease = runningStepLease(stepState);
|
|
902
|
+
if (lease === 'held') {
|
|
903
|
+
throw new WorkflowAsyncException(runId, stepName);
|
|
904
|
+
}
|
|
899
905
|
const dispatched = resolvedStepOptions.actor
|
|
900
906
|
? false
|
|
901
907
|
: await this.dispatchStep(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName);
|
|
902
908
|
if (dispatched) {
|
|
903
|
-
|
|
909
|
+
if (lease !== 'lapsed') {
|
|
910
|
+
await this.setStepScheduled(stepState.stepId);
|
|
911
|
+
}
|
|
904
912
|
throw new WorkflowAsyncException(runId, stepName);
|
|
905
913
|
}
|
|
906
914
|
const retries = resolvedStepOptions.retries ?? this.getConfig().retries;
|
|
907
915
|
const retryDelay = resolvedStepOptions.retryDelay;
|
|
908
|
-
return
|
|
916
|
+
return runInlineRetryLoop(this, stepState, retries, retryDelay, async (currentStepState) => {
|
|
909
917
|
if (resolvedStepOptions.actor) {
|
|
910
918
|
return resolvedStepOptions.actor.invoke(rpcName, data);
|
|
911
919
|
}
|
|
@@ -949,7 +957,7 @@ export class PikkuWorkflowService {
|
|
|
949
957
|
const retries = stepOptions?.retries ?? this.getConfig().retries;
|
|
950
958
|
const retryDelay = stepOptions?.retryDelay ?? this.getConfig().retryDelay;
|
|
951
959
|
if (await this.isInline(runId)) {
|
|
952
|
-
return
|
|
960
|
+
return runInlineRetryLoop(this, stepState, retries, retryDelay, () => fn());
|
|
953
961
|
}
|
|
954
962
|
else {
|
|
955
963
|
let currentStepState = stepState;
|
|
@@ -1001,15 +1009,10 @@ export class PikkuWorkflowService {
|
|
|
1001
1009
|
}
|
|
1002
1010
|
async scheduleRunWake(runId, delay) {
|
|
1003
1011
|
try {
|
|
1004
|
-
const queueService = this.verifyQueueService();
|
|
1005
1012
|
const run = await this.getRun(runId);
|
|
1006
1013
|
if (!run?.workflow)
|
|
1007
1014
|
return;
|
|
1008
|
-
await
|
|
1009
|
-
...this.resolveStepJobOptions(),
|
|
1010
|
-
delay,
|
|
1011
|
-
group: this.getJobGroup(run.workflow),
|
|
1012
|
-
});
|
|
1015
|
+
await this.enqueueOrchestration(runId, run.workflow, delay);
|
|
1013
1016
|
}
|
|
1014
1017
|
catch (error) {
|
|
1015
1018
|
this.logger?.warn(`Failed to schedule approval expiry wake for run ${runId}; expiry will still resolve on the next replay`, error);
|
|
@@ -10,7 +10,7 @@ export declare const WORKFLOW_TERMINAL_STATES: ReadonlySet<string>;
|
|
|
10
10
|
* such a message is routine rather than exceptional: the queue is
|
|
11
11
|
* at-least-once, the relay re-dispatches on purpose, and a run can settle
|
|
12
12
|
* while a message for it is still in flight. Replaying one is not free —
|
|
13
|
-
* `runWorkflowJob` takes the run
|
|
13
|
+
* `runWorkflowJob` takes the run lease and re-enters the workflow body, and a
|
|
14
14
|
* body re-entered after its run failed can park on a wait that nothing will
|
|
15
15
|
* ever satisfy, holding the lock and the connection under it until something
|
|
16
16
|
* external gives up. Every leaked advisory lock seen in production traced back
|
|
@@ -39,5 +39,32 @@ export declare const DEFAULT_UNDISPATCHED_STEP_LIMIT = 100;
|
|
|
39
39
|
export declare const REDISPATCH_BACKOFF_MS = 30000;
|
|
40
40
|
/** Ceiling on the doubling backoff, so a permanently stuck step still gets swept. */
|
|
41
41
|
export declare const REDISPATCH_BACKOFF_MAX_MS: number;
|
|
42
|
+
/**
|
|
43
|
+
* How long a claim on a step is good for when the queue it was dispatched
|
|
44
|
+
* through declares no duration of its own. Sits above the queue's own lock so
|
|
45
|
+
* the two never disagree about who owns the job.
|
|
46
|
+
*/
|
|
47
|
+
export declare const DEFAULT_STEP_LEASE_MS = 60000;
|
|
48
|
+
/**
|
|
49
|
+
* The refresh interval below which a lease is short enough to be worth saying
|
|
50
|
+
* so. It is a warning threshold, not a floor: applied as one it would push the
|
|
51
|
+
* first refresh past the expiry of any lease under three times this, which is how a
|
|
52
|
+
* step ends up claimed twice.
|
|
53
|
+
*/
|
|
54
|
+
export declare const STEP_LEASE_REFRESH_MIN_MS = 1000;
|
|
55
|
+
/**
|
|
56
|
+
* Whether a `running` step is still owned by the dispatch that claimed it.
|
|
57
|
+
*
|
|
58
|
+
* No lease at all counts as live: a store that records none has no way to tell
|
|
59
|
+
* an abandoned step from a working one, so it keeps the older, safer rule that
|
|
60
|
+
* a `running` step is never taken from underneath its holder.
|
|
61
|
+
*/
|
|
62
|
+
export declare const isStepLeaseLive: (leaseExpiresAt: Date | null | undefined, now?: number) => boolean;
|
|
63
|
+
/**
|
|
64
|
+
* How long an orchestrator message that found its run held waits before it
|
|
65
|
+
* wakes the run again. Long enough for the holder's pass to finish, short
|
|
66
|
+
* enough that the run does not visibly stall behind it.
|
|
67
|
+
*/
|
|
68
|
+
export declare const RUN_LEASE_RETRY_MS = 1000;
|
|
42
69
|
/** Bound on the in-process backoff map, so a long-lived process cannot grow it without bound. */
|
|
43
70
|
export declare const REDISPATCH_BACKOFF_MAX_ENTRIES = 10000;
|
|
@@ -19,7 +19,7 @@ export const WORKFLOW_TERMINAL_STATES = new Set([
|
|
|
19
19
|
* such a message is routine rather than exceptional: the queue is
|
|
20
20
|
* at-least-once, the relay re-dispatches on purpose, and a run can settle
|
|
21
21
|
* while a message for it is still in flight. Replaying one is not free —
|
|
22
|
-
* `runWorkflowJob` takes the run
|
|
22
|
+
* `runWorkflowJob` takes the run lease and re-enters the workflow body, and a
|
|
23
23
|
* body re-entered after its run failed can park on a wait that nothing will
|
|
24
24
|
* ever satisfy, holding the lock and the connection under it until something
|
|
25
25
|
* external gives up. Every leaked advisory lock seen in production traced back
|
|
@@ -48,5 +48,32 @@ export const DEFAULT_UNDISPATCHED_STEP_LIMIT = 100;
|
|
|
48
48
|
export const REDISPATCH_BACKOFF_MS = 30_000;
|
|
49
49
|
/** Ceiling on the doubling backoff, so a permanently stuck step still gets swept. */
|
|
50
50
|
export const REDISPATCH_BACKOFF_MAX_MS = 10 * 60_000;
|
|
51
|
+
/**
|
|
52
|
+
* How long a claim on a step is good for when the queue it was dispatched
|
|
53
|
+
* through declares no duration of its own. Sits above the queue's own lock so
|
|
54
|
+
* the two never disagree about who owns the job.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_STEP_LEASE_MS = 60_000;
|
|
57
|
+
/**
|
|
58
|
+
* The refresh interval below which a lease is short enough to be worth saying
|
|
59
|
+
* so. It is a warning threshold, not a floor: applied as one it would push the
|
|
60
|
+
* first refresh past the expiry of any lease under three times this, which is how a
|
|
61
|
+
* step ends up claimed twice.
|
|
62
|
+
*/
|
|
63
|
+
export const STEP_LEASE_REFRESH_MIN_MS = 1_000;
|
|
64
|
+
/**
|
|
65
|
+
* Whether a `running` step is still owned by the dispatch that claimed it.
|
|
66
|
+
*
|
|
67
|
+
* No lease at all counts as live: a store that records none has no way to tell
|
|
68
|
+
* an abandoned step from a working one, so it keeps the older, safer rule that
|
|
69
|
+
* a `running` step is never taken from underneath its holder.
|
|
70
|
+
*/
|
|
71
|
+
export const isStepLeaseLive = (leaseExpiresAt, now = Date.now()) => leaseExpiresAt == null || leaseExpiresAt.getTime() > now;
|
|
72
|
+
/**
|
|
73
|
+
* How long an orchestrator message that found its run held waits before it
|
|
74
|
+
* wakes the run again. Long enough for the holder's pass to finish, short
|
|
75
|
+
* enough that the run does not visibly stall behind it.
|
|
76
|
+
*/
|
|
77
|
+
export const RUN_LEASE_RETRY_MS = 1_000;
|
|
51
78
|
/** Bound on the in-process backoff map, so a long-lived process cannot grow it without bound. */
|
|
52
79
|
export const REDISPATCH_BACKOFF_MAX_ENTRIES = 10_000;
|
|
@@ -57,6 +57,27 @@ export declare class WorkflowStepFunctionMismatchError extends PikkuError {
|
|
|
57
57
|
readonly stepName: string;
|
|
58
58
|
constructor(runId: string, stepName: string);
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Every dispatch that took this step lost its worker before finishing it, and
|
|
62
|
+
* the step has no attempts left to hand out. Failing here is the loud end of
|
|
63
|
+
* the loop: without it the step would be re-claimed and abandoned forever.
|
|
64
|
+
*/
|
|
65
|
+
export declare class WorkflowStepLeaseExpiredError extends PikkuError {
|
|
66
|
+
readonly runId: string;
|
|
67
|
+
readonly stepName: string;
|
|
68
|
+
readonly attemptCount: number;
|
|
69
|
+
constructor(runId: string, stepName: string, attemptCount: number);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A write from a dispatch that no longer owns its step: its lease lapsed and
|
|
73
|
+
* another dispatch claimed the step as a newer attempt. The newer attempt owns
|
|
74
|
+
* the outcome, so the stale one is dropped rather than recorded over it.
|
|
75
|
+
*/
|
|
76
|
+
export declare class WorkflowStepSupersededError extends PikkuError {
|
|
77
|
+
readonly stepId: string;
|
|
78
|
+
readonly attempt: number;
|
|
79
|
+
constructor(stepId: string, attempt: number);
|
|
80
|
+
}
|
|
60
81
|
export declare class WorkflowStepNameNotString extends Error {
|
|
61
82
|
constructor(stepName: unknown);
|
|
62
83
|
}
|