@pikku/core 0.12.37 → 0.12.39
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 +113 -0
- package/dist/errors/error-handler.d.ts +8 -0
- package/dist/errors/error-handler.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/services/in-memory-queue-service.d.ts +9 -0
- package/dist/services/in-memory-queue-service.js +42 -11
- package/dist/services/in-memory-workflow-service.d.ts +7 -2
- package/dist/services/in-memory-workflow-service.js +19 -1
- package/dist/services/workflow-service.d.ts +3 -0
- package/dist/testing/service-tests.js +35 -0
- package/dist/types/core.types.d.ts +1 -0
- package/dist/wirings/cli/cli-runner.js +12 -3
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
- package/dist/wirings/workflow/graph/graph-runner.js +168 -106
- package/dist/wirings/workflow/index.d.ts +4 -1
- package/dist/wirings/workflow/index.js +4 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +95 -5
- package/dist/wirings/workflow/pikku-workflow-service.js +323 -175
- package/dist/wirings/workflow/run-timeline.d.ts +85 -0
- package/dist/wirings/workflow/run-timeline.js +153 -0
- package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
- package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
- package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
- package/dist/wirings/workflow/workflow.types.d.ts +7 -0
- package/package.json +2 -1
- package/src/dev/hot-reload.test.ts +5 -0
- package/src/errors/error-handler.ts +10 -0
- package/src/index.ts +1 -0
- package/src/services/in-memory-queue-service.test.ts +97 -0
- package/src/services/in-memory-queue-service.ts +51 -16
- package/src/services/in-memory-workflow-service.ts +27 -1
- package/src/services/workflow-service.ts +9 -0
- package/src/testing/service-tests.ts +65 -0
- package/src/types/core.types.ts +4 -0
- package/src/wirings/cli/cli-runner.ts +11 -3
- package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
- package/src/wirings/workflow/graph/graph-runner.test.ts +159 -3
- package/src/wirings/workflow/graph/graph-runner.ts +253 -142
- package/src/wirings/workflow/index.ts +17 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +447 -213
- package/src/wirings/workflow/run-timeline.test.ts +211 -0
- package/src/wirings/workflow/run-timeline.ts +241 -0
- package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
- package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
- package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
- package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
- package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
- package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
- package/src/wirings/workflow/workflow.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -66,9 +66,30 @@ import {
|
|
|
66
66
|
runFromMeta,
|
|
67
67
|
} from './graph/graph-runner.js'
|
|
68
68
|
import type { WorkflowService } from '../../services/workflow-service.js'
|
|
69
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
PikkuError,
|
|
71
|
+
addError,
|
|
72
|
+
isExpectedError,
|
|
73
|
+
} from '../../errors/error-handler.js'
|
|
70
74
|
import { RPCNotFoundError } from '../rpc/rpc-runner.js'
|
|
71
75
|
import { ChildWorkflowStartedException } from './graph/graph-runner.js'
|
|
76
|
+
import { deriveInvocationId } from './workflow-invocation-id.js'
|
|
77
|
+
import {
|
|
78
|
+
buildRunTimeline,
|
|
79
|
+
reconstructStateAt,
|
|
80
|
+
type RunTimeline,
|
|
81
|
+
type ReconstructedRunState,
|
|
82
|
+
} from './run-timeline.js'
|
|
83
|
+
import type { JobOptions } from '../queue/queue.types.js'
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Default number of retries for a workflow step when none is specified. The
|
|
87
|
+
* workflow — not the queue — owns retry policy; a step inherits this unless it
|
|
88
|
+
* sets its own `retries` (including `retries: 0` to opt out entirely). Picked >0
|
|
89
|
+
* so a transient failure (a DB blip, a downstream restart, a deploy) is ridden
|
|
90
|
+
* out by default; safe because every step gets a stable `invocationId` to dedupe on.
|
|
91
|
+
*/
|
|
92
|
+
export const DEFAULT_STEP_RETRIES = 5
|
|
72
93
|
|
|
73
94
|
/**
|
|
74
95
|
* Exception thrown when workflow needs to pause for async step
|
|
@@ -109,6 +130,25 @@ export class WorkflowSuspendedException extends Error {
|
|
|
109
130
|
}
|
|
110
131
|
}
|
|
111
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Thrown when a step (or the orchestrator) could not be enqueued — the queue
|
|
135
|
+
* itself failed (e.g. pg-boss is momentarily down), NOT the step's own logic.
|
|
136
|
+
* This is transient infrastructure failure: the run is left untouched (the step
|
|
137
|
+
* stays `pending`, the run stays running) and the orchestrator job is rethrown
|
|
138
|
+
* so the queue redelivers it and the workflow replays from its snapshot. Treat
|
|
139
|
+
* it as non-terminal — never mark the run `failed` for it.
|
|
140
|
+
*/
|
|
141
|
+
export class WorkflowDispatchException extends Error {
|
|
142
|
+
constructor(
|
|
143
|
+
public readonly runId: string,
|
|
144
|
+
public readonly stepName: string,
|
|
145
|
+
options?: { cause?: unknown }
|
|
146
|
+
) {
|
|
147
|
+
super(`Failed to dispatch workflow step '${stepName}' (run ${runId})`, options)
|
|
148
|
+
this.name = 'WorkflowDispatchException'
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
112
152
|
/**
|
|
113
153
|
* Error class for workflow not found
|
|
114
154
|
*/
|
|
@@ -386,7 +426,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
386
426
|
// Build step summaries from history (latest attempt per step)
|
|
387
427
|
const stepMap = new Map<
|
|
388
428
|
string,
|
|
389
|
-
{ status: StepStatus; startedAt?: Date; completedAt?: Date }
|
|
429
|
+
{ status: StepStatus; startedAt?: Date; completedAt?: Date; attempts: number }
|
|
390
430
|
>()
|
|
391
431
|
for (const step of history) {
|
|
392
432
|
const existing = stepMap.get(step.stepName)
|
|
@@ -395,6 +435,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
395
435
|
status: step.status,
|
|
396
436
|
startedAt: step.runningAt ?? step.createdAt,
|
|
397
437
|
completedAt: step.succeededAt ?? step.failedAt,
|
|
438
|
+
attempts: step.attemptCount,
|
|
398
439
|
})
|
|
399
440
|
}
|
|
400
441
|
}
|
|
@@ -406,6 +447,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
406
447
|
s.startedAt && s.completedAt
|
|
407
448
|
? s.completedAt.getTime() - s.startedAt.getTime()
|
|
408
449
|
: undefined,
|
|
450
|
+
attempts: s.attempts,
|
|
409
451
|
}))
|
|
410
452
|
|
|
411
453
|
return {
|
|
@@ -423,6 +465,33 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
423
465
|
}
|
|
424
466
|
}
|
|
425
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Build the run's time-travel event stream from durable history.
|
|
470
|
+
* @param id - Run ID
|
|
471
|
+
* @returns Ordered timeline, or null if the run doesn't exist
|
|
472
|
+
*/
|
|
473
|
+
public async getRunTimeline(id: string): Promise<RunTimeline | null> {
|
|
474
|
+
const run = await this.getRun(id)
|
|
475
|
+
if (!run) return null
|
|
476
|
+
return buildRunTimeline(await this.getRunHistory(id))
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Reconstruct the run's state at a point in its timeline.
|
|
481
|
+
* @param id - Run ID
|
|
482
|
+
* @param at - A seq index (inclusive) or a Date (inclusive); omit for the
|
|
483
|
+
* final state.
|
|
484
|
+
* @returns Reconstructed state, or null if the run doesn't exist
|
|
485
|
+
*/
|
|
486
|
+
public async reconstructRunStateAt(
|
|
487
|
+
id: string,
|
|
488
|
+
at?: number | Date
|
|
489
|
+
): Promise<ReconstructedRunState | null> {
|
|
490
|
+
const timeline = await this.getRunTimeline(id)
|
|
491
|
+
if (!timeline) return null
|
|
492
|
+
return reconstructStateAt(timeline, at ?? timeline.length - 1)
|
|
493
|
+
}
|
|
494
|
+
|
|
426
495
|
/**
|
|
427
496
|
* Get workflow run history (all step attempts in chronological order)
|
|
428
497
|
* @param runId - Run ID
|
|
@@ -471,14 +540,16 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
471
540
|
stepName: string,
|
|
472
541
|
rpcName: string | null,
|
|
473
542
|
data: any,
|
|
474
|
-
stepOptions?: WorkflowStepOptions
|
|
543
|
+
stepOptions?: WorkflowStepOptions,
|
|
544
|
+
fromStepName?: string
|
|
475
545
|
): Promise<StepState> {
|
|
476
546
|
const step = await this.insertStepStateImpl(
|
|
477
547
|
runId,
|
|
478
548
|
stepName,
|
|
479
549
|
rpcName,
|
|
480
550
|
data,
|
|
481
|
-
stepOptions
|
|
551
|
+
stepOptions,
|
|
552
|
+
fromStepName
|
|
482
553
|
)
|
|
483
554
|
await this.safeMirror(() =>
|
|
484
555
|
this.mirror!.insertStepState(runId, { ...step, stepName, rpcName, data })
|
|
@@ -491,7 +562,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
491
562
|
stepName: string,
|
|
492
563
|
rpcName: string | null,
|
|
493
564
|
data: any,
|
|
494
|
-
stepOptions?: WorkflowStepOptions
|
|
565
|
+
stepOptions?: WorkflowStepOptions,
|
|
566
|
+
fromStepName?: string
|
|
495
567
|
): Promise<StepState>
|
|
496
568
|
|
|
497
569
|
/**
|
|
@@ -574,6 +646,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
574
646
|
message: error.message,
|
|
575
647
|
stack: error.stack,
|
|
576
648
|
code: (error as any).code,
|
|
649
|
+
expected: isExpectedError(error),
|
|
577
650
|
}
|
|
578
651
|
await this.safeMirror(() => this.mirror!.setStepError(stepId, serialized))
|
|
579
652
|
}
|
|
@@ -663,6 +736,20 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
663
736
|
nodeIds: string[]
|
|
664
737
|
): Promise<string[]>
|
|
665
738
|
|
|
739
|
+
/**
|
|
740
|
+
* List every step instance of a run (any status) with its predecessor.
|
|
741
|
+
* Drives bounded graph revisits: the runner counts instances per logical node
|
|
742
|
+
* and treats each `fromStepName → node` as a once-fired transition.
|
|
743
|
+
* @param runId - Run ID
|
|
744
|
+
*/
|
|
745
|
+
abstract getStepInstances(runId: string): Promise<
|
|
746
|
+
Array<{
|
|
747
|
+
stepName: string
|
|
748
|
+
status: StepStatus
|
|
749
|
+
fromStepName?: string
|
|
750
|
+
}>
|
|
751
|
+
>
|
|
752
|
+
|
|
666
753
|
/**
|
|
667
754
|
* Get results for specific nodes
|
|
668
755
|
* @param runId - Run ID
|
|
@@ -784,9 +871,42 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
784
871
|
const run = await this.getRun(runId)
|
|
785
872
|
workflowName = run?.workflow
|
|
786
873
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
874
|
+
// Carry an explicit retry policy on the orchestrator job too. Orchestrator
|
|
875
|
+
// runs are idempotent (they replay from the snapshot, returning cached step
|
|
876
|
+
// results), so redelivery is always safe — and it's what lets a transient
|
|
877
|
+
// dispatch/infra failure recover: the job is rethrown and retried instead of
|
|
878
|
+
// the run hanging. Passing `attempts` per-job overrides the queue default, so
|
|
879
|
+
// this holds even when the orchestrator queue is configured `retry_limit 0`.
|
|
880
|
+
await queueService.add(
|
|
881
|
+
this.getOrchestratorQueueName(workflowName),
|
|
882
|
+
{ runId },
|
|
883
|
+
this.resolveStepJobOptions()
|
|
884
|
+
)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Resolve a step's retry policy into queue job options. The workflow is the
|
|
889
|
+
* sole source of truth for retries: an explicitly-set `retries` (including 0)
|
|
890
|
+
* is always honored, an unset one defaults to {@link DEFAULT_STEP_RETRIES},
|
|
891
|
+
* and we ALWAYS pass `attempts` so the queue can never fall back to its own
|
|
892
|
+
* default — which would re-run a step the workflow said not to retry. Backoff
|
|
893
|
+
* defaults to exponential whenever there's at least one retry, so retries ride
|
|
894
|
+
* out a transient outage instead of firing instantly.
|
|
895
|
+
*/
|
|
896
|
+
protected resolveStepJobOptions(
|
|
897
|
+
stepOptions?: WorkflowStepOptions
|
|
898
|
+
): JobOptions {
|
|
899
|
+
const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES
|
|
900
|
+
const retryDelay = stepOptions?.retryDelay
|
|
901
|
+
const backoff =
|
|
902
|
+
typeof retryDelay === 'number'
|
|
903
|
+
? { type: 'fixed', delay: retryDelay }
|
|
904
|
+
: retryDelay === 'exponential'
|
|
905
|
+
? 'exponential'
|
|
906
|
+
: retries > 0
|
|
907
|
+
? 'exponential'
|
|
908
|
+
: undefined
|
|
909
|
+
return { attempts: retries + 1, ...(backoff ? { backoff } : {}) }
|
|
790
910
|
}
|
|
791
911
|
|
|
792
912
|
public async queueStepWorker(
|
|
@@ -794,32 +914,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
794
914
|
stepName: string,
|
|
795
915
|
rpcName: string,
|
|
796
916
|
data: any,
|
|
797
|
-
stepOptions?: WorkflowStepOptions
|
|
917
|
+
stepOptions?: WorkflowStepOptions,
|
|
918
|
+
fromStepName?: string
|
|
798
919
|
): Promise<void> {
|
|
799
920
|
const queueService = this.verifyQueueService()
|
|
800
|
-
const retries = stepOptions?.retries ?? 0
|
|
801
|
-
const retryDelay = stepOptions?.retryDelay
|
|
802
921
|
await queueService.add(
|
|
803
922
|
this.getStepWorkerQueueName(rpcName),
|
|
804
|
-
JSON.parse(
|
|
805
|
-
|
|
806
|
-
runId,
|
|
807
|
-
stepName,
|
|
808
|
-
rpcName,
|
|
809
|
-
data,
|
|
810
|
-
})
|
|
811
|
-
),
|
|
812
|
-
retries > 0 || retryDelay
|
|
813
|
-
? {
|
|
814
|
-
attempts: retries + 1,
|
|
815
|
-
backoff:
|
|
816
|
-
typeof retryDelay === 'number'
|
|
817
|
-
? { type: 'fixed', delay: retryDelay }
|
|
818
|
-
: retryDelay === 'exponential'
|
|
819
|
-
? 'exponential'
|
|
820
|
-
: undefined,
|
|
821
|
-
}
|
|
822
|
-
: undefined
|
|
923
|
+
JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })),
|
|
924
|
+
this.resolveStepJobOptions(stepOptions)
|
|
823
925
|
)
|
|
824
926
|
}
|
|
825
927
|
|
|
@@ -877,7 +979,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
877
979
|
stepName: string,
|
|
878
980
|
rpcName: string,
|
|
879
981
|
data: unknown,
|
|
880
|
-
stepOptions?: WorkflowStepOptions
|
|
982
|
+
stepOptions?: WorkflowStepOptions,
|
|
983
|
+
fromStepName?: string
|
|
881
984
|
): Promise<boolean> {
|
|
882
985
|
// Step execution is decided purely by the function's `inline` flag (default
|
|
883
986
|
// true). Only a function explicitly marked `inline: false` dispatches via
|
|
@@ -903,28 +1006,21 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
903
1006
|
)
|
|
904
1007
|
return false
|
|
905
1008
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
)
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
typeof retryDelay === 'number'
|
|
922
|
-
? { type: 'fixed', delay: retryDelay }
|
|
923
|
-
: retryDelay === 'exponential'
|
|
924
|
-
? 'exponential'
|
|
925
|
-
: undefined,
|
|
926
|
-
}
|
|
927
|
-
)
|
|
1009
|
+
try {
|
|
1010
|
+
await getSingletonServices()!.queueService!.add(
|
|
1011
|
+
this.getStepWorkerQueueName(rpcName),
|
|
1012
|
+
JSON.parse(
|
|
1013
|
+
JSON.stringify({ runId, stepName, rpcName, data, fromStepName })
|
|
1014
|
+
),
|
|
1015
|
+
this.resolveStepJobOptions(stepOptions)
|
|
1016
|
+
)
|
|
1017
|
+
} catch (cause) {
|
|
1018
|
+
// The queue is down/unreachable — NOT a step failure. Surface it as a
|
|
1019
|
+
// transient dispatch error so the caller leaves the step `pending` and the
|
|
1020
|
+
// orchestrator job is retried (replayed from snapshot) rather than the run
|
|
1021
|
+
// being marked failed. `add` already throws on failure in every adapter.
|
|
1022
|
+
throw new WorkflowDispatchException(runId, stepName, { cause })
|
|
1023
|
+
}
|
|
928
1024
|
return true
|
|
929
1025
|
}
|
|
930
1026
|
|
|
@@ -1037,19 +1133,31 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1037
1133
|
if (
|
|
1038
1134
|
error.name !== 'WorkflowAsyncException' &&
|
|
1039
1135
|
error.name !== 'WorkflowCancelledException' &&
|
|
1040
|
-
error.name !== 'WorkflowSuspendedException'
|
|
1136
|
+
error.name !== 'WorkflowSuspendedException' &&
|
|
1137
|
+
// Transient queue failure — leave the run resumable, don't fail it.
|
|
1138
|
+
error.name !== 'WorkflowDispatchException'
|
|
1041
1139
|
) {
|
|
1042
1140
|
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
1043
1141
|
name: error.name,
|
|
1044
1142
|
message: error.message,
|
|
1045
1143
|
stack: error.stack,
|
|
1046
1144
|
})
|
|
1145
|
+
// An expected failure (a PikkuError, e.g. a build gate tripping) —
|
|
1146
|
+
// its message is the whole story, so don't dump the stack. The
|
|
1147
|
+
// `expected` flag survives the step-boundary rehydration that strips
|
|
1148
|
+
// the class. Anything else is an uncaught/unexpected error: log it in
|
|
1149
|
+
// full so the trace is there to debug.
|
|
1047
1150
|
getSingletonServices()!.logger.error(
|
|
1048
1151
|
`Workflow ${name} (run ${runId}) failed:`,
|
|
1049
|
-
error
|
|
1152
|
+
isExpectedError(error) ? error.message : error
|
|
1050
1153
|
)
|
|
1051
1154
|
throw error
|
|
1052
1155
|
}
|
|
1156
|
+
if (error.name === 'WorkflowDispatchException') {
|
|
1157
|
+
// Rethrow so the caller (poll loop / starter) sees the transient
|
|
1158
|
+
// failure; the run stays running and can be resumed.
|
|
1159
|
+
throw error
|
|
1160
|
+
}
|
|
1053
1161
|
} finally {
|
|
1054
1162
|
this.inlineRuns.delete(runId)
|
|
1055
1163
|
}
|
|
@@ -1092,7 +1200,56 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1092
1200
|
}
|
|
1093
1201
|
}
|
|
1094
1202
|
|
|
1203
|
+
// Per-run, per-replay ordinal counters (runId → stepName → count).
|
|
1204
|
+
private stepOrdinals = new Map<string, Map<string, number>>()
|
|
1205
|
+
// Previous step key reached in the current DSL walk (runId → stepName), so a
|
|
1206
|
+
// new step records where it came from. Rebuilt each replay alongside ordinals.
|
|
1207
|
+
private stepLineage = new Map<string, string>()
|
|
1208
|
+
|
|
1209
|
+
private resetStepOrdinals(runId: string): void {
|
|
1210
|
+
this.stepOrdinals.set(runId, new Map())
|
|
1211
|
+
this.stepLineage.delete(runId)
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** The step the DSL walk last reached (the predecessor for the next step). */
|
|
1215
|
+
private lastStepName(runId: string): string | undefined {
|
|
1216
|
+
return this.stepLineage.get(runId)
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Physical, replay-stable key for the Nth reach of `logicalStepName` in a run:
|
|
1221
|
+
* bare name for the first reach (ordinal 0, unchanged behavior), `name#N` for
|
|
1222
|
+
* repeats — so the same literal step name can be invoked multiple times without
|
|
1223
|
+
* the rows clobbering. Deterministic given a deterministic DSL body.
|
|
1224
|
+
*/
|
|
1225
|
+
private nextStepKey(runId: string, logicalStepName: string): string {
|
|
1226
|
+
let perRun = this.stepOrdinals.get(runId)
|
|
1227
|
+
if (!perRun) {
|
|
1228
|
+
perRun = new Map()
|
|
1229
|
+
this.stepOrdinals.set(runId, perRun)
|
|
1230
|
+
}
|
|
1231
|
+
const ordinal = perRun.get(logicalStepName) ?? 0
|
|
1232
|
+
perRun.set(logicalStepName, ordinal + 1)
|
|
1233
|
+
const stepName =
|
|
1234
|
+
ordinal === 0 ? logicalStepName : `${logicalStepName}#${ordinal}`
|
|
1235
|
+
this.stepLineage.set(runId, stepName)
|
|
1236
|
+
return stepName
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1095
1239
|
public async runWorkflowJob(runId: string, rpcService: any): Promise<void> {
|
|
1240
|
+
// Fresh ordinal counters per replay so step keys are deterministic.
|
|
1241
|
+
this.resetStepOrdinals(runId)
|
|
1242
|
+
try {
|
|
1243
|
+
await this.runWorkflowJobInner(runId, rpcService)
|
|
1244
|
+
} finally {
|
|
1245
|
+
this.stepOrdinals.delete(runId)
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
private async runWorkflowJobInner(
|
|
1250
|
+
runId: string,
|
|
1251
|
+
rpcService: any
|
|
1252
|
+
): Promise<void> {
|
|
1096
1253
|
const run = await this.getRun(runId)
|
|
1097
1254
|
if (!run) {
|
|
1098
1255
|
throw new WorkflowRunNotFoundError(runId)
|
|
@@ -1335,17 +1492,25 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1335
1492
|
const isGraphWorkflow =
|
|
1336
1493
|
workflowMeta?.source === 'graph' ||
|
|
1337
1494
|
workflowMeta?.source === 'dynamic-workflow'
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1495
|
+
// Map the physical step key back to its logical node: a revisit instance
|
|
1496
|
+
// is `node#N` (ordinal), which isn't a literal key in `nodes`.
|
|
1497
|
+
let graphNodeId: string | undefined
|
|
1498
|
+
if (isGraphWorkflow && workflowMeta?.nodes) {
|
|
1499
|
+
if (stepName in workflowMeta.nodes) {
|
|
1500
|
+
graphNodeId = stepName
|
|
1501
|
+
} else {
|
|
1502
|
+
const hash = stepName.lastIndexOf('#')
|
|
1503
|
+
const base = hash > 0 ? stepName.slice(0, hash) : undefined
|
|
1504
|
+
if (base && base in workflowMeta.nodes) graphNodeId = base
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
if (graphNodeId) {
|
|
1343
1508
|
result = await executeGraphStep(
|
|
1344
1509
|
this,
|
|
1345
1510
|
rpcService,
|
|
1346
1511
|
runId,
|
|
1347
1512
|
stepState.stepId,
|
|
1348
|
-
|
|
1513
|
+
graphNodeId,
|
|
1349
1514
|
rpcName,
|
|
1350
1515
|
data,
|
|
1351
1516
|
run.workflow
|
|
@@ -1389,13 +1554,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1389
1554
|
)
|
|
1390
1555
|
}
|
|
1391
1556
|
} else {
|
|
1392
|
-
result = await
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1557
|
+
result = await this.invokeStepRpc(
|
|
1558
|
+
runId,
|
|
1559
|
+
stepName,
|
|
1560
|
+
stepState,
|
|
1561
|
+
rpcName,
|
|
1562
|
+
data,
|
|
1563
|
+
rpcService
|
|
1564
|
+
)
|
|
1399
1565
|
}
|
|
1400
1566
|
}
|
|
1401
1567
|
|
|
@@ -1424,7 +1590,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1424
1590
|
// Store error and mark failed
|
|
1425
1591
|
await this.setStepError(stepState.stepId, error)
|
|
1426
1592
|
|
|
1427
|
-
const maxAttempts = (stepState.retries ??
|
|
1593
|
+
const maxAttempts = (stepState.retries ?? DEFAULT_STEP_RETRIES) + 1
|
|
1428
1594
|
const retriesExhausted = stepState.attemptCount >= maxAttempts
|
|
1429
1595
|
|
|
1430
1596
|
if (retriesExhausted) {
|
|
@@ -1458,6 +1624,17 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1458
1624
|
return
|
|
1459
1625
|
}
|
|
1460
1626
|
|
|
1627
|
+
if (error.name === 'WorkflowDispatchException') {
|
|
1628
|
+
// Transient: the queue was unreachable, not a workflow failure. Leave the
|
|
1629
|
+
// run running and rethrow so the orchestrator job is redelivered and the
|
|
1630
|
+
// workflow replays from its snapshot. Do NOT mark the run failed.
|
|
1631
|
+
getSingletonServices()!.logger.warn(
|
|
1632
|
+
`Workflow run ${runId} could not dispatch a step (queue unavailable); leaving run for orchestrator retry`,
|
|
1633
|
+
error
|
|
1634
|
+
)
|
|
1635
|
+
throw error
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1461
1638
|
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
1462
1639
|
message: error.message,
|
|
1463
1640
|
stack: error.stack,
|
|
@@ -1478,14 +1655,101 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1478
1655
|
return getSingletonServices()!.queueService!
|
|
1479
1656
|
}
|
|
1480
1657
|
|
|
1481
|
-
|
|
1658
|
+
/**
|
|
1659
|
+
* Invoke a step's RPC with the workflow-step wire (step identity + provenance).
|
|
1660
|
+
* Identical for the queue executor and the inline executor — the only thing
|
|
1661
|
+
* that differs between transports is who calls it, not the call itself.
|
|
1662
|
+
*/
|
|
1663
|
+
private async invokeStepRpc(
|
|
1482
1664
|
runId: string,
|
|
1483
1665
|
stepName: string,
|
|
1666
|
+
stepState: StepState,
|
|
1667
|
+
rpcName: string,
|
|
1668
|
+
data: any,
|
|
1669
|
+
rpcService: any
|
|
1670
|
+
): Promise<any> {
|
|
1671
|
+
return rpcService.rpcWithWire(rpcName, data, {
|
|
1672
|
+
workflowStep: {
|
|
1673
|
+
runId,
|
|
1674
|
+
stepId: stepState.stepId,
|
|
1675
|
+
invocationId: deriveInvocationId(runId, stepName),
|
|
1676
|
+
attemptCount: stepState.attemptCount,
|
|
1677
|
+
fromInvocationId: stepState.fromStepName
|
|
1678
|
+
? deriveInvocationId(runId, stepState.fromStepName)
|
|
1679
|
+
: undefined,
|
|
1680
|
+
},
|
|
1681
|
+
})
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/**
|
|
1685
|
+
* Inline (straight-through) step execution with an in-process retry loop —
|
|
1686
|
+
* shared by inline RPC steps and inline function steps. Same scaffolding
|
|
1687
|
+
* (running → result, or fail → retry-attempt → backoff → retry) wrapped
|
|
1688
|
+
* around a step-specific `doWork` body. Stays O(K): no suspend/replay.
|
|
1689
|
+
*
|
|
1690
|
+
* `onError` is an optional hook for terminal errors that must NOT retry
|
|
1691
|
+
* (e.g. RPC-not-found → suspend the run for redeploy). If it throws, the
|
|
1692
|
+
* loop exits immediately without recording a step error or retrying.
|
|
1693
|
+
*/
|
|
1694
|
+
private async runInlineRetryLoop(
|
|
1695
|
+
stepState: StepState,
|
|
1696
|
+
retries: number,
|
|
1697
|
+
retryDelay: WorkflowStepOptions['retryDelay'],
|
|
1698
|
+
doWork: (currentStepState: StepState) => Promise<any>,
|
|
1699
|
+
onError?: (error: any) => Promise<void>
|
|
1700
|
+
): Promise<any> {
|
|
1701
|
+
let currentStepState = stepState
|
|
1702
|
+
while (true) {
|
|
1703
|
+
try {
|
|
1704
|
+
await this.setStepRunning(currentStepState.stepId)
|
|
1705
|
+
const result = await doWork(currentStepState)
|
|
1706
|
+
await this.setStepResult(currentStepState.stepId, result)
|
|
1707
|
+
return result
|
|
1708
|
+
} catch (error: any) {
|
|
1709
|
+
if (onError) await onError(error)
|
|
1710
|
+
|
|
1711
|
+
// Record the error (marks step as failed)
|
|
1712
|
+
await this.setStepError(currentStepState.stepId, error)
|
|
1713
|
+
|
|
1714
|
+
if (currentStepState.attemptCount < retries) {
|
|
1715
|
+
// Create a new pending retry attempt, then back off if configured.
|
|
1716
|
+
currentStepState = await this.createRetryAttempt(
|
|
1717
|
+
currentStepState.stepId,
|
|
1718
|
+
'pending'
|
|
1719
|
+
)
|
|
1720
|
+
if (retryDelay) {
|
|
1721
|
+
await new Promise((resolve) =>
|
|
1722
|
+
setTimeout(resolve, getDurationInMilliseconds(retryDelay))
|
|
1723
|
+
)
|
|
1724
|
+
}
|
|
1725
|
+
// Continue loop to retry
|
|
1726
|
+
} else {
|
|
1727
|
+
// No more retries, fail the workflow
|
|
1728
|
+
throw error
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
private async rpcStep(
|
|
1735
|
+
runId: string,
|
|
1736
|
+
logicalStepName: string,
|
|
1484
1737
|
rpcName: string,
|
|
1485
1738
|
data: any,
|
|
1486
1739
|
rpcService: any,
|
|
1487
1740
|
stepOptions?: WorkflowStepOptions
|
|
1488
1741
|
): Promise<any> {
|
|
1742
|
+
// Capture the predecessor before nextStepKey advances the lineage to us.
|
|
1743
|
+
const fromStepName = this.lastStepName(runId)
|
|
1744
|
+
const stepName = this.nextStepKey(runId, logicalStepName)
|
|
1745
|
+
// Resolve the retry policy ONCE here so the value persisted on the step
|
|
1746
|
+
// (which drives `retriesExhausted`) is the same one the queue dispatch turns
|
|
1747
|
+
// into `attempts`. Without this the queue could retry N times while the
|
|
1748
|
+
// engine thinks retries are already exhausted (or vice-versa).
|
|
1749
|
+
const resolvedStepOptions: WorkflowStepOptions = {
|
|
1750
|
+
retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
|
|
1751
|
+
retryDelay: stepOptions?.retryDelay,
|
|
1752
|
+
}
|
|
1489
1753
|
// Check if step already exists
|
|
1490
1754
|
let stepState: StepState
|
|
1491
1755
|
try {
|
|
@@ -1497,7 +1761,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1497
1761
|
stepName,
|
|
1498
1762
|
rpcName,
|
|
1499
1763
|
data,
|
|
1500
|
-
|
|
1764
|
+
resolvedStepOptions,
|
|
1765
|
+
fromStepName
|
|
1501
1766
|
)
|
|
1502
1767
|
}
|
|
1503
1768
|
|
|
@@ -1524,122 +1789,100 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1524
1789
|
throw new WorkflowAsyncException(runId, stepName)
|
|
1525
1790
|
}
|
|
1526
1791
|
|
|
1527
|
-
// Step is pending - schedule it
|
|
1528
|
-
await this.setStepScheduled(stepState.stepId)
|
|
1529
|
-
|
|
1530
1792
|
// Hand off to subclass-overridable transport. Default behavior enqueues
|
|
1531
1793
|
// via the queue service; DO-style subclasses RPC to a step worker.
|
|
1794
|
+
// Dispatch BEFORE marking the step `scheduled`: if the queue is down,
|
|
1795
|
+
// dispatchStep throws WorkflowDispatchException and the step stays `pending`,
|
|
1796
|
+
// so the orchestrator's next replay re-dispatches it. Marking `scheduled`
|
|
1797
|
+
// first would strand the step (replay sees `scheduled`, pauses, never
|
|
1798
|
+
// re-enqueues the job that was never created).
|
|
1532
1799
|
const dispatched = await this.dispatchStep(
|
|
1533
1800
|
runId,
|
|
1534
1801
|
stepName,
|
|
1535
1802
|
rpcName,
|
|
1536
1803
|
data,
|
|
1537
|
-
|
|
1804
|
+
resolvedStepOptions,
|
|
1805
|
+
fromStepName
|
|
1538
1806
|
)
|
|
1539
1807
|
if (dispatched) {
|
|
1808
|
+
await this.setStepScheduled(stepState.stepId)
|
|
1540
1809
|
throw new WorkflowAsyncException(runId, stepName)
|
|
1541
1810
|
}
|
|
1542
1811
|
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1812
|
+
// Inline (no transport available) - execute locally with the shared retry
|
|
1813
|
+
// loop. The body resolves to a sub-workflow result or a plain RPC result.
|
|
1814
|
+
const retries = resolvedStepOptions.retries ?? this.getConfig().retries
|
|
1815
|
+
const retryDelay = resolvedStepOptions.retryDelay
|
|
1816
|
+
|
|
1817
|
+
return this.runInlineRetryLoop(
|
|
1818
|
+
stepState,
|
|
1819
|
+
retries,
|
|
1820
|
+
retryDelay,
|
|
1821
|
+
async (currentStepState) => {
|
|
1822
|
+
// Check if the name refers to a workflow
|
|
1823
|
+
const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
|
|
1824
|
+
if (workflowMeta) {
|
|
1825
|
+
const childWire = {
|
|
1826
|
+
type: 'workflow',
|
|
1827
|
+
id: rpcName,
|
|
1828
|
+
parentRunId: runId,
|
|
1829
|
+
pikkuUserId: rpcService.wire?.pikkuUserId,
|
|
1830
|
+
}
|
|
1831
|
+
const { runId: childRunId } = await this.startWorkflow(
|
|
1832
|
+
rpcName,
|
|
1833
|
+
data,
|
|
1834
|
+
childWire,
|
|
1835
|
+
rpcService,
|
|
1836
|
+
{ inline: true }
|
|
1837
|
+
)
|
|
1838
|
+
await this.setStepChildRunId(currentStepState.stepId, childRunId)
|
|
1839
|
+
// Poll until child workflow completes
|
|
1840
|
+
while (true) {
|
|
1841
|
+
const childRun = await this.getRun(childRunId)
|
|
1842
|
+
if (!childRun) {
|
|
1843
|
+
throw new WorkflowRunNotFoundError(childRunId)
|
|
1561
1844
|
}
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
childWire,
|
|
1566
|
-
rpcService,
|
|
1567
|
-
{ inline: true }
|
|
1568
|
-
)
|
|
1569
|
-
await this.setStepChildRunId(currentStepState.stepId, childRunId)
|
|
1570
|
-
// Poll until child workflow completes
|
|
1571
|
-
while (true) {
|
|
1572
|
-
const childRun = await this.getRun(childRunId)
|
|
1573
|
-
if (!childRun) {
|
|
1574
|
-
throw new WorkflowRunNotFoundError(childRunId)
|
|
1845
|
+
if (WORKFLOW_END_STATES.has(childRun.status)) {
|
|
1846
|
+
if (childRun.status === 'failed') {
|
|
1847
|
+
throw new Error(childRun.error?.message || 'Sub-workflow failed')
|
|
1575
1848
|
}
|
|
1576
|
-
if (
|
|
1577
|
-
|
|
1578
|
-
throw new Error(
|
|
1579
|
-
childRun.error?.message || 'Sub-workflow failed'
|
|
1580
|
-
)
|
|
1581
|
-
}
|
|
1582
|
-
if (childRun.status === 'cancelled') {
|
|
1583
|
-
throw new Error('Sub-workflow was cancelled')
|
|
1584
|
-
}
|
|
1585
|
-
result = childRun.output
|
|
1586
|
-
break
|
|
1849
|
+
if (childRun.status === 'cancelled') {
|
|
1850
|
+
throw new Error('Sub-workflow was cancelled')
|
|
1587
1851
|
}
|
|
1588
|
-
|
|
1852
|
+
return childRun.output
|
|
1589
1853
|
}
|
|
1590
|
-
|
|
1591
|
-
result = await rpcService.rpcWithWire(rpcName, data, {
|
|
1592
|
-
workflowStep: {
|
|
1593
|
-
runId,
|
|
1594
|
-
stepId: currentStepState.stepId,
|
|
1595
|
-
attemptCount: currentStepState.attemptCount,
|
|
1596
|
-
},
|
|
1597
|
-
})
|
|
1598
|
-
}
|
|
1599
|
-
await this.setStepResult(currentStepState.stepId, result)
|
|
1600
|
-
return result
|
|
1601
|
-
} catch (error: any) {
|
|
1602
|
-
if (error instanceof RPCNotFoundError) {
|
|
1603
|
-
await this.updateRunStatus(runId, 'suspended', undefined, {
|
|
1604
|
-
message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
|
|
1605
|
-
code: 'RPC_NOT_FOUND',
|
|
1606
|
-
})
|
|
1607
|
-
throw error
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
// Record the error (marks step as failed)
|
|
1611
|
-
await this.setStepError(currentStepState.stepId, error)
|
|
1612
|
-
|
|
1613
|
-
// Check if we should retry
|
|
1614
|
-
if (currentStepState.attemptCount < retries) {
|
|
1615
|
-
// Create a new pending retry attempt
|
|
1616
|
-
currentStepState = await this.createRetryAttempt(
|
|
1617
|
-
currentStepState.stepId,
|
|
1618
|
-
'pending'
|
|
1619
|
-
)
|
|
1620
|
-
|
|
1621
|
-
// Wait for retry delay if specified
|
|
1622
|
-
if (retryDelay) {
|
|
1623
|
-
await new Promise((resolve) =>
|
|
1624
|
-
setTimeout(resolve, getDurationInMilliseconds(retryDelay))
|
|
1625
|
-
)
|
|
1626
|
-
}
|
|
1627
|
-
// Continue loop to retry
|
|
1628
|
-
} else {
|
|
1629
|
-
// No more retries, fail the workflow
|
|
1630
|
-
throw error
|
|
1854
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
1631
1855
|
}
|
|
1632
1856
|
}
|
|
1857
|
+
return this.invokeStepRpc(
|
|
1858
|
+
runId,
|
|
1859
|
+
stepName,
|
|
1860
|
+
currentStepState,
|
|
1861
|
+
rpcName,
|
|
1862
|
+
data,
|
|
1863
|
+
rpcService
|
|
1864
|
+
)
|
|
1865
|
+
},
|
|
1866
|
+
async (error) => {
|
|
1867
|
+
if (error instanceof RPCNotFoundError) {
|
|
1868
|
+
await this.updateRunStatus(runId, 'suspended', undefined, {
|
|
1869
|
+
message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
|
|
1870
|
+
code: 'RPC_NOT_FOUND',
|
|
1871
|
+
})
|
|
1872
|
+
throw error
|
|
1873
|
+
}
|
|
1633
1874
|
}
|
|
1634
|
-
|
|
1875
|
+
)
|
|
1635
1876
|
}
|
|
1636
1877
|
|
|
1637
1878
|
private async inlineStep(
|
|
1638
1879
|
runId: string,
|
|
1639
|
-
|
|
1880
|
+
logicalStepName: string,
|
|
1640
1881
|
fn: Function,
|
|
1641
1882
|
stepOptions?: WorkflowStepOptions
|
|
1642
1883
|
): Promise<any> {
|
|
1884
|
+
const fromStepName = this.lastStepName(runId)
|
|
1885
|
+
const stepName = this.nextStepKey(runId, logicalStepName)
|
|
1643
1886
|
// Check if step already exists
|
|
1644
1887
|
let stepState: StepState
|
|
1645
1888
|
try {
|
|
@@ -1651,7 +1894,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1651
1894
|
stepName,
|
|
1652
1895
|
null,
|
|
1653
1896
|
null,
|
|
1654
|
-
stepOptions
|
|
1897
|
+
stepOptions,
|
|
1898
|
+
fromStepName
|
|
1655
1899
|
)
|
|
1656
1900
|
}
|
|
1657
1901
|
|
|
@@ -1663,44 +1907,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1663
1907
|
// Execute inline function
|
|
1664
1908
|
const retries = stepOptions?.retries ?? this.getConfig().retries
|
|
1665
1909
|
const retryDelay = stepOptions?.retryDelay ?? this.getConfig().retryDelay
|
|
1666
|
-
let currentStepState = stepState
|
|
1667
1910
|
|
|
1668
1911
|
// Check if we're running inline (in-memory) or remote (queue-based)
|
|
1669
1912
|
if (this.isInline(runId)) {
|
|
1670
|
-
// Inline mode - execute with retry loop
|
|
1671
|
-
|
|
1672
|
-
try {
|
|
1673
|
-
await this.setStepRunning(currentStepState.stepId)
|
|
1674
|
-
const result = await fn()
|
|
1675
|
-
await this.setStepResult(currentStepState.stepId, result)
|
|
1676
|
-
return result
|
|
1677
|
-
} catch (error: any) {
|
|
1678
|
-
// Record the error (marks step as failed)
|
|
1679
|
-
await this.setStepError(currentStepState.stepId, error)
|
|
1680
|
-
|
|
1681
|
-
// Check if we should retry
|
|
1682
|
-
if (currentStepState.attemptCount < retries) {
|
|
1683
|
-
// Create a new pending retry attempt
|
|
1684
|
-
currentStepState = await this.createRetryAttempt(
|
|
1685
|
-
currentStepState.stepId,
|
|
1686
|
-
'pending'
|
|
1687
|
-
)
|
|
1688
|
-
|
|
1689
|
-
// Wait for retry delay if specified
|
|
1690
|
-
if (retryDelay) {
|
|
1691
|
-
await new Promise((resolve) =>
|
|
1692
|
-
setTimeout(resolve, getDurationInMilliseconds(retryDelay))
|
|
1693
|
-
)
|
|
1694
|
-
}
|
|
1695
|
-
// Continue loop to retry
|
|
1696
|
-
} else {
|
|
1697
|
-
// No more retries, fail the workflow
|
|
1698
|
-
throw error
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1913
|
+
// Inline mode - execute with the shared in-process retry loop.
|
|
1914
|
+
return this.runInlineRetryLoop(stepState, retries, retryDelay, () => fn())
|
|
1702
1915
|
} else {
|
|
1703
|
-
// Remote mode -
|
|
1916
|
+
// Remote mode - single attempt, then suspend for orchestrator-driven retry.
|
|
1917
|
+
let currentStepState = stepState
|
|
1704
1918
|
try {
|
|
1705
1919
|
await this.setStepRunning(currentStepState.stepId)
|
|
1706
1920
|
const result = await fn()
|
|
@@ -1728,16 +1942,27 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1728
1942
|
}
|
|
1729
1943
|
}
|
|
1730
1944
|
|
|
1731
|
-
private async sleepStep(
|
|
1945
|
+
private async sleepStep(
|
|
1946
|
+
runId: string,
|
|
1947
|
+
logicalStepName: string,
|
|
1948
|
+
duration: number
|
|
1949
|
+
) {
|
|
1950
|
+
const fromStepName = this.lastStepName(runId)
|
|
1951
|
+
const stepName = this.nextStepKey(runId, logicalStepName)
|
|
1732
1952
|
// Check if step already exists
|
|
1733
1953
|
let stepState: StepState
|
|
1734
1954
|
try {
|
|
1735
1955
|
stepState = await this.getStepState(runId, stepName)
|
|
1736
1956
|
} catch {
|
|
1737
1957
|
// Step doesn't exist - create it (sleep step, no RPC)
|
|
1738
|
-
stepState = await this.insertStepState(
|
|
1739
|
-
|
|
1740
|
-
|
|
1958
|
+
stepState = await this.insertStepState(
|
|
1959
|
+
runId,
|
|
1960
|
+
stepName,
|
|
1961
|
+
null,
|
|
1962
|
+
{ duration },
|
|
1963
|
+
undefined,
|
|
1964
|
+
fromStepName
|
|
1965
|
+
)
|
|
1741
1966
|
}
|
|
1742
1967
|
|
|
1743
1968
|
if (stepState.status === 'succeeded') {
|
|
@@ -1750,18 +1975,19 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1750
1975
|
throw new WorkflowAsyncException(runId, stepName)
|
|
1751
1976
|
}
|
|
1752
1977
|
|
|
1753
|
-
// Step is pending - schedule it
|
|
1754
|
-
await this.setStepScheduled(stepState.stepId)
|
|
1755
|
-
|
|
1756
1978
|
// Hand off to subclass-overridable transport. Default behavior schedules
|
|
1757
1979
|
// a delayed sleeper RPC via the scheduler service; DO-style subclasses
|
|
1758
|
-
// override to use native timer primitives (e.g. setAlarm).
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1980
|
+
// override to use native timer primitives (e.g. setAlarm). Schedule BEFORE
|
|
1981
|
+
// marking `scheduled` so a scheduler outage leaves the step `pending` for
|
|
1982
|
+
// re-scheduling on replay instead of stranding it (see rpcStep).
|
|
1983
|
+
let scheduled: boolean
|
|
1984
|
+
try {
|
|
1985
|
+
scheduled = await this.scheduleSleep(runId, stepState.stepId, duration)
|
|
1986
|
+
} catch (cause) {
|
|
1987
|
+
throw new WorkflowDispatchException(runId, stepName, { cause })
|
|
1988
|
+
}
|
|
1764
1989
|
if (scheduled) {
|
|
1990
|
+
await this.setStepScheduled(stepState.stepId)
|
|
1765
1991
|
throw new WorkflowAsyncException(runId, stepName)
|
|
1766
1992
|
}
|
|
1767
1993
|
|
|
@@ -1788,7 +2014,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1788
2014
|
}
|
|
1789
2015
|
|
|
1790
2016
|
private async suspendStep(runId: string, reason: string): Promise<void> {
|
|
1791
|
-
const
|
|
2017
|
+
const fromStepName = this.lastStepName(runId)
|
|
2018
|
+
const suspendStepName = this.nextStepKey(
|
|
2019
|
+
runId,
|
|
2020
|
+
this.getSuspendStepName(reason)
|
|
2021
|
+
)
|
|
1792
2022
|
await this.withStepLock(runId, suspendStepName, async () => {
|
|
1793
2023
|
let stepState: StepState
|
|
1794
2024
|
try {
|
|
@@ -1800,7 +2030,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1800
2030
|
'pikkuWorkflowSuspend',
|
|
1801
2031
|
{
|
|
1802
2032
|
reason,
|
|
1803
|
-
}
|
|
2033
|
+
},
|
|
2034
|
+
undefined,
|
|
2035
|
+
fromStepName
|
|
1804
2036
|
)
|
|
1805
2037
|
}
|
|
1806
2038
|
if (!stepState.stepId) {
|
|
@@ -1810,7 +2042,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1810
2042
|
'pikkuWorkflowSuspend',
|
|
1811
2043
|
{
|
|
1812
2044
|
reason,
|
|
1813
|
-
}
|
|
2045
|
+
},
|
|
2046
|
+
undefined,
|
|
2047
|
+
fromStepName
|
|
1814
2048
|
)
|
|
1815
2049
|
}
|
|
1816
2050
|
|
|
@@ -1900,7 +2134,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1900
2134
|
const singletonServices = getSingletonServices()
|
|
1901
2135
|
const workflow = singletonServices.config?.workflow
|
|
1902
2136
|
return {
|
|
1903
|
-
retries: workflow?.retries ??
|
|
2137
|
+
retries: workflow?.retries ?? DEFAULT_STEP_RETRIES,
|
|
1904
2138
|
retryDelay: workflow?.retryDelay ?? 0,
|
|
1905
2139
|
orchestratorQueueName:
|
|
1906
2140
|
workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',
|