@pikku/core 0.12.130 → 0.12.133

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/dist/middleware-runner.d.ts +4 -0
  3. package/dist/middleware-runner.js +52 -16
  4. package/dist/services/in-memory-lease-service.d.ts +13 -0
  5. package/dist/services/in-memory-lease-service.js +49 -0
  6. package/dist/services/in-memory-workflow-service.d.ts +11 -3
  7. package/dist/services/in-memory-workflow-service.js +53 -23
  8. package/dist/services/index.d.ts +2 -0
  9. package/dist/services/index.js +2 -0
  10. package/dist/services/lease-service.d.ts +61 -0
  11. package/dist/services/lease-service.js +110 -0
  12. package/dist/services/workflow-service.d.ts +1 -1
  13. package/dist/testing/index.d.ts +1 -0
  14. package/dist/testing/service-tests/lease-service-tests.d.ts +3 -0
  15. package/dist/testing/service-tests/lease-service-tests.js +138 -0
  16. package/dist/testing/service-tests/workflow-fencing-tests.d.ts +18 -0
  17. package/dist/testing/service-tests/workflow-fencing-tests.js +216 -0
  18. package/dist/testing/service-tests.d.ts +5 -0
  19. package/dist/testing/service-tests.js +8 -0
  20. package/dist/types/core.types.d.ts +12 -8
  21. package/dist/utils/hmac.d.ts +14 -10
  22. package/dist/utils/hmac.js +29 -25
  23. package/dist/wirings/http/pikku-fetch-http-response.js +13 -0
  24. package/dist/wirings/secret/derive-oauth2-app-secrets.js +1 -0
  25. package/dist/wirings/secret/secret.types.d.ts +6 -0
  26. package/dist/wirings/trigger/index.d.ts +2 -2
  27. package/dist/wirings/trigger/index.js +1 -1
  28. package/dist/wirings/trigger/webhook-source-runner.js +45 -1
  29. package/dist/wirings/trigger/webhook-source.types.d.ts +50 -0
  30. package/dist/wirings/trigger/webhook-source.types.js +2 -0
  31. package/dist/wirings/workflow/graph/graph-runner.js +33 -14
  32. package/dist/wirings/workflow/index.d.ts +4 -3
  33. package/dist/wirings/workflow/index.js +3 -2
  34. package/dist/wirings/workflow/pikku-workflow-service.d.ts +57 -18
  35. package/dist/wirings/workflow/pikku-workflow-service.js +143 -140
  36. package/dist/wirings/workflow/workflow-constants.d.ts +28 -1
  37. package/dist/wirings/workflow/workflow-constants.js +28 -1
  38. package/dist/wirings/workflow/workflow-errors.d.ts +21 -0
  39. package/dist/wirings/workflow/workflow-errors.js +38 -0
  40. package/dist/wirings/workflow/workflow-queue-routing.d.ts +4 -2
  41. package/dist/wirings/workflow/workflow-queue-routing.js +9 -1
  42. package/dist/wirings/workflow/workflow-run-lease.d.ts +10 -0
  43. package/dist/wirings/workflow/workflow-run-lease.js +25 -0
  44. package/dist/wirings/workflow/workflow-run-status.d.ts +4 -0
  45. package/dist/wirings/workflow/workflow-run-status.js +37 -0
  46. package/dist/wirings/workflow/workflow-status-stream.d.ts +2 -1
  47. package/dist/wirings/workflow/workflow-status-stream.js +18 -1
  48. package/dist/wirings/workflow/workflow-step-claim.d.ts +13 -1
  49. package/dist/wirings/workflow/workflow-step-claim.js +29 -5
  50. package/dist/wirings/workflow/workflow-step-lease.d.ts +25 -0
  51. package/dist/wirings/workflow/workflow-step-lease.js +53 -0
  52. package/dist/wirings/workflow/workflow-step-retry.d.ts +14 -0
  53. package/dist/wirings/workflow/workflow-step-retry.js +33 -0
  54. package/dist/wirings/workflow/workflow-version-fallback.d.ts +11 -0
  55. package/dist/wirings/workflow/workflow-version-fallback.js +30 -0
  56. package/dist/wirings/workflow/workflow.types.d.ts +17 -0
  57. package/knowledge/decisions/internals/a-held-run-is-woken-later-not-retried.md +28 -0
  58. package/knowledge/decisions/internals/index.md +2 -1
  59. package/knowledge/decisions/internals/the-in-memory-workflow-service-is-inline-only-and-single-process.md +2 -2
  60. package/knowledge/decisions/internals/workflow-step-lock-is-held-only-to-claim-the-step.md +12 -5
  61. package/package.json +1 -1
  62. 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, runFromMeta, stripInstanceOrdinal, } from './graph/graph-runner.js';
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
- const history = await this.getRunHistory(id);
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
- async setStepResult(stepId, result) {
204
- await this.mirrored(() => this.setStepResultImpl(stepId, result), (mirror) => mirror.setStepResult(stepId, result));
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
- const queueService = this.verifyQueueService();
240
- if (!workflowName) {
241
- const run = await this.getRun(runId);
242
- workflowName = run?.workflow;
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 (`running`, `scheduled`, `suspended`),
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 = this.verifyQueueService();
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 = this.verifyQueueService();
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
- if (error.name === 'WorkflowDispatchException') {
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
- await continueGraph(this, runId, run.workflow);
559
- const updatedRun = await this.getRun(runId);
560
- if (updatedRun?.status === 'completed') {
561
- await this.onChildWorkflowCompleted(updatedRun, updatedRun.output);
562
- }
563
- else if (updatedRun?.status === 'failed' ||
564
- updatedRun?.status === 'cancelled') {
565
- await this.onChildWorkflowFailed(updatedRun, new Error(updatedRun.error?.message || 'Child workflow failed'));
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.withRunLock(runId, async () => {
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 — and `withStepLock` excludes nothing unless the
709
- * store backs it with a real primitive. A store able to express the decision
710
- * as one conditional write should override this rather than reach for a lock,
711
- * which is what `@pikku/kysely` does with a status-guarded `UPDATE`.
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 claimed = await this.claimStepForExecution(runId, stepName, rpcName);
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.runStepCompensation(runId, stepName, resolvedStepOptions.onError, rpcService, error);
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
- await this.setStepScheduled(stepState.stepId);
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 this.runInlineRetryLoop(stepState, retries, retryDelay, async (currentStepState) => {
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 this.runInlineRetryLoop(stepState, retries, retryDelay, () => fn());
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 queueService.add(this.getOrchestratorQueueName(run.workflow), { runId }, {
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 lock and re-enters the workflow body, and a
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 lock and re-enters the workflow body, and a
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
  }