@pikku/core 0.12.36 → 0.12.38
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 +67 -0
- package/dist/services/in-memory-workflow-service.d.ts +7 -2
- package/dist/services/in-memory-workflow-service.js +17 -1
- package/dist/services/meta-service.d.ts +4 -0
- package/dist/services/meta-service.js +9 -0
- package/dist/wirings/gateway/gateway.types.d.ts +4 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
- package/dist/wirings/workflow/graph/graph-runner.js +117 -45
- package/dist/wirings/workflow/index.d.ts +2 -1
- package/dist/wirings/workflow/index.js +2 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +63 -5
- package/dist/wirings/workflow/pikku-workflow-service.js +197 -66
- 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 +5 -0
- package/package.json +2 -1
- package/src/dev/hot-reload.test.ts +5 -0
- package/src/services/in-memory-workflow-service.ts +25 -1
- package/src/services/meta-service.ts +15 -0
- package/src/wirings/gateway/gateway.types.ts +6 -1
- 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 +87 -3
- package/src/wirings/workflow/graph/graph-runner.ts +158 -56
- package/src/wirings/workflow/index.ts +3 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +264 -86
- 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 +5 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -69,6 +69,17 @@ import type { WorkflowService } from '../../services/workflow-service.js'
|
|
|
69
69
|
import { PikkuError, addError } from '../../errors/error-handler.js'
|
|
70
70
|
import { RPCNotFoundError } from '../rpc/rpc-runner.js'
|
|
71
71
|
import { ChildWorkflowStartedException } from './graph/graph-runner.js'
|
|
72
|
+
import { deriveInvocationId } from './workflow-invocation-id.js'
|
|
73
|
+
import type { JobOptions } from '../queue/queue.types.js'
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Default number of retries for a workflow step when none is specified. The
|
|
77
|
+
* workflow — not the queue — owns retry policy; a step inherits this unless it
|
|
78
|
+
* sets its own `retries` (including `retries: 0` to opt out entirely). Picked >0
|
|
79
|
+
* so a transient failure (a DB blip, a downstream restart, a deploy) is ridden
|
|
80
|
+
* out by default; safe because every step gets a stable `invocationId` to dedupe on.
|
|
81
|
+
*/
|
|
82
|
+
export const DEFAULT_STEP_RETRIES = 5
|
|
72
83
|
|
|
73
84
|
/**
|
|
74
85
|
* Exception thrown when workflow needs to pause for async step
|
|
@@ -109,6 +120,25 @@ export class WorkflowSuspendedException extends Error {
|
|
|
109
120
|
}
|
|
110
121
|
}
|
|
111
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Thrown when a step (or the orchestrator) could not be enqueued — the queue
|
|
125
|
+
* itself failed (e.g. pg-boss is momentarily down), NOT the step's own logic.
|
|
126
|
+
* This is transient infrastructure failure: the run is left untouched (the step
|
|
127
|
+
* stays `pending`, the run stays running) and the orchestrator job is rethrown
|
|
128
|
+
* so the queue redelivers it and the workflow replays from its snapshot. Treat
|
|
129
|
+
* it as non-terminal — never mark the run `failed` for it.
|
|
130
|
+
*/
|
|
131
|
+
export class WorkflowDispatchException extends Error {
|
|
132
|
+
constructor(
|
|
133
|
+
public readonly runId: string,
|
|
134
|
+
public readonly stepName: string,
|
|
135
|
+
options?: { cause?: unknown }
|
|
136
|
+
) {
|
|
137
|
+
super(`Failed to dispatch workflow step '${stepName}' (run ${runId})`, options)
|
|
138
|
+
this.name = 'WorkflowDispatchException'
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
112
142
|
/**
|
|
113
143
|
* Error class for workflow not found
|
|
114
144
|
*/
|
|
@@ -471,14 +501,16 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
471
501
|
stepName: string,
|
|
472
502
|
rpcName: string | null,
|
|
473
503
|
data: any,
|
|
474
|
-
stepOptions?: WorkflowStepOptions
|
|
504
|
+
stepOptions?: WorkflowStepOptions,
|
|
505
|
+
fromStepName?: string
|
|
475
506
|
): Promise<StepState> {
|
|
476
507
|
const step = await this.insertStepStateImpl(
|
|
477
508
|
runId,
|
|
478
509
|
stepName,
|
|
479
510
|
rpcName,
|
|
480
511
|
data,
|
|
481
|
-
stepOptions
|
|
512
|
+
stepOptions,
|
|
513
|
+
fromStepName
|
|
482
514
|
)
|
|
483
515
|
await this.safeMirror(() =>
|
|
484
516
|
this.mirror!.insertStepState(runId, { ...step, stepName, rpcName, data })
|
|
@@ -491,7 +523,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
491
523
|
stepName: string,
|
|
492
524
|
rpcName: string | null,
|
|
493
525
|
data: any,
|
|
494
|
-
stepOptions?: WorkflowStepOptions
|
|
526
|
+
stepOptions?: WorkflowStepOptions,
|
|
527
|
+
fromStepName?: string
|
|
495
528
|
): Promise<StepState>
|
|
496
529
|
|
|
497
530
|
/**
|
|
@@ -663,6 +696,20 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
663
696
|
nodeIds: string[]
|
|
664
697
|
): Promise<string[]>
|
|
665
698
|
|
|
699
|
+
/**
|
|
700
|
+
* List every step instance of a run (any status) with its predecessor.
|
|
701
|
+
* Drives bounded graph revisits: the runner counts instances per logical node
|
|
702
|
+
* and treats each `fromStepName → node` as a once-fired transition.
|
|
703
|
+
* @param runId - Run ID
|
|
704
|
+
*/
|
|
705
|
+
abstract getStepInstances(runId: string): Promise<
|
|
706
|
+
Array<{
|
|
707
|
+
stepName: string
|
|
708
|
+
status: StepStatus
|
|
709
|
+
fromStepName?: string
|
|
710
|
+
}>
|
|
711
|
+
>
|
|
712
|
+
|
|
666
713
|
/**
|
|
667
714
|
* Get results for specific nodes
|
|
668
715
|
* @param runId - Run ID
|
|
@@ -784,9 +831,42 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
784
831
|
const run = await this.getRun(runId)
|
|
785
832
|
workflowName = run?.workflow
|
|
786
833
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
834
|
+
// Carry an explicit retry policy on the orchestrator job too. Orchestrator
|
|
835
|
+
// runs are idempotent (they replay from the snapshot, returning cached step
|
|
836
|
+
// results), so redelivery is always safe — and it's what lets a transient
|
|
837
|
+
// dispatch/infra failure recover: the job is rethrown and retried instead of
|
|
838
|
+
// the run hanging. Passing `attempts` per-job overrides the queue default, so
|
|
839
|
+
// this holds even when the orchestrator queue is configured `retry_limit 0`.
|
|
840
|
+
await queueService.add(
|
|
841
|
+
this.getOrchestratorQueueName(workflowName),
|
|
842
|
+
{ runId },
|
|
843
|
+
this.resolveStepJobOptions()
|
|
844
|
+
)
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Resolve a step's retry policy into queue job options. The workflow is the
|
|
849
|
+
* sole source of truth for retries: an explicitly-set `retries` (including 0)
|
|
850
|
+
* is always honored, an unset one defaults to {@link DEFAULT_STEP_RETRIES},
|
|
851
|
+
* and we ALWAYS pass `attempts` so the queue can never fall back to its own
|
|
852
|
+
* default — which would re-run a step the workflow said not to retry. Backoff
|
|
853
|
+
* defaults to exponential whenever there's at least one retry, so retries ride
|
|
854
|
+
* out a transient outage instead of firing instantly.
|
|
855
|
+
*/
|
|
856
|
+
protected resolveStepJobOptions(
|
|
857
|
+
stepOptions?: WorkflowStepOptions
|
|
858
|
+
): JobOptions {
|
|
859
|
+
const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES
|
|
860
|
+
const retryDelay = stepOptions?.retryDelay
|
|
861
|
+
const backoff =
|
|
862
|
+
typeof retryDelay === 'number'
|
|
863
|
+
? { type: 'fixed', delay: retryDelay }
|
|
864
|
+
: retryDelay === 'exponential'
|
|
865
|
+
? 'exponential'
|
|
866
|
+
: retries > 0
|
|
867
|
+
? 'exponential'
|
|
868
|
+
: undefined
|
|
869
|
+
return { attempts: retries + 1, ...(backoff ? { backoff } : {}) }
|
|
790
870
|
}
|
|
791
871
|
|
|
792
872
|
public async queueStepWorker(
|
|
@@ -794,32 +874,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
794
874
|
stepName: string,
|
|
795
875
|
rpcName: string,
|
|
796
876
|
data: any,
|
|
797
|
-
stepOptions?: WorkflowStepOptions
|
|
877
|
+
stepOptions?: WorkflowStepOptions,
|
|
878
|
+
fromStepName?: string
|
|
798
879
|
): Promise<void> {
|
|
799
880
|
const queueService = this.verifyQueueService()
|
|
800
|
-
const retries = stepOptions?.retries ?? 0
|
|
801
|
-
const retryDelay = stepOptions?.retryDelay
|
|
802
881
|
await queueService.add(
|
|
803
882
|
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
|
|
883
|
+
JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })),
|
|
884
|
+
this.resolveStepJobOptions(stepOptions)
|
|
823
885
|
)
|
|
824
886
|
}
|
|
825
887
|
|
|
@@ -877,7 +939,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
877
939
|
stepName: string,
|
|
878
940
|
rpcName: string,
|
|
879
941
|
data: unknown,
|
|
880
|
-
stepOptions?: WorkflowStepOptions
|
|
942
|
+
stepOptions?: WorkflowStepOptions,
|
|
943
|
+
fromStepName?: string
|
|
881
944
|
): Promise<boolean> {
|
|
882
945
|
// Step execution is decided purely by the function's `inline` flag (default
|
|
883
946
|
// true). Only a function explicitly marked `inline: false` dispatches via
|
|
@@ -903,28 +966,21 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
903
966
|
)
|
|
904
967
|
return false
|
|
905
968
|
}
|
|
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
|
-
)
|
|
969
|
+
try {
|
|
970
|
+
await getSingletonServices()!.queueService!.add(
|
|
971
|
+
this.getStepWorkerQueueName(rpcName),
|
|
972
|
+
JSON.parse(
|
|
973
|
+
JSON.stringify({ runId, stepName, rpcName, data, fromStepName })
|
|
974
|
+
),
|
|
975
|
+
this.resolveStepJobOptions(stepOptions)
|
|
976
|
+
)
|
|
977
|
+
} catch (cause) {
|
|
978
|
+
// The queue is down/unreachable — NOT a step failure. Surface it as a
|
|
979
|
+
// transient dispatch error so the caller leaves the step `pending` and the
|
|
980
|
+
// orchestrator job is retried (replayed from snapshot) rather than the run
|
|
981
|
+
// being marked failed. `add` already throws on failure in every adapter.
|
|
982
|
+
throw new WorkflowDispatchException(runId, stepName, { cause })
|
|
983
|
+
}
|
|
928
984
|
return true
|
|
929
985
|
}
|
|
930
986
|
|
|
@@ -1037,7 +1093,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1037
1093
|
if (
|
|
1038
1094
|
error.name !== 'WorkflowAsyncException' &&
|
|
1039
1095
|
error.name !== 'WorkflowCancelledException' &&
|
|
1040
|
-
error.name !== 'WorkflowSuspendedException'
|
|
1096
|
+
error.name !== 'WorkflowSuspendedException' &&
|
|
1097
|
+
// Transient queue failure — leave the run resumable, don't fail it.
|
|
1098
|
+
error.name !== 'WorkflowDispatchException'
|
|
1041
1099
|
) {
|
|
1042
1100
|
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
1043
1101
|
name: error.name,
|
|
@@ -1050,6 +1108,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1050
1108
|
)
|
|
1051
1109
|
throw error
|
|
1052
1110
|
}
|
|
1111
|
+
if (error.name === 'WorkflowDispatchException') {
|
|
1112
|
+
// Rethrow so the caller (poll loop / starter) sees the transient
|
|
1113
|
+
// failure; the run stays running and can be resumed.
|
|
1114
|
+
throw error
|
|
1115
|
+
}
|
|
1053
1116
|
} finally {
|
|
1054
1117
|
this.inlineRuns.delete(runId)
|
|
1055
1118
|
}
|
|
@@ -1092,7 +1155,56 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1092
1155
|
}
|
|
1093
1156
|
}
|
|
1094
1157
|
|
|
1158
|
+
// Per-run, per-replay ordinal counters (runId → stepName → count).
|
|
1159
|
+
private stepOrdinals = new Map<string, Map<string, number>>()
|
|
1160
|
+
// Previous step key reached in the current DSL walk (runId → stepName), so a
|
|
1161
|
+
// new step records where it came from. Rebuilt each replay alongside ordinals.
|
|
1162
|
+
private stepLineage = new Map<string, string>()
|
|
1163
|
+
|
|
1164
|
+
private resetStepOrdinals(runId: string): void {
|
|
1165
|
+
this.stepOrdinals.set(runId, new Map())
|
|
1166
|
+
this.stepLineage.delete(runId)
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** The step the DSL walk last reached (the predecessor for the next step). */
|
|
1170
|
+
private lastStepName(runId: string): string | undefined {
|
|
1171
|
+
return this.stepLineage.get(runId)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Physical, replay-stable key for the Nth reach of `logicalStepName` in a run:
|
|
1176
|
+
* bare name for the first reach (ordinal 0, unchanged behavior), `name#N` for
|
|
1177
|
+
* repeats — so the same literal step name can be invoked multiple times without
|
|
1178
|
+
* the rows clobbering. Deterministic given a deterministic DSL body.
|
|
1179
|
+
*/
|
|
1180
|
+
private nextStepKey(runId: string, logicalStepName: string): string {
|
|
1181
|
+
let perRun = this.stepOrdinals.get(runId)
|
|
1182
|
+
if (!perRun) {
|
|
1183
|
+
perRun = new Map()
|
|
1184
|
+
this.stepOrdinals.set(runId, perRun)
|
|
1185
|
+
}
|
|
1186
|
+
const ordinal = perRun.get(logicalStepName) ?? 0
|
|
1187
|
+
perRun.set(logicalStepName, ordinal + 1)
|
|
1188
|
+
const stepName =
|
|
1189
|
+
ordinal === 0 ? logicalStepName : `${logicalStepName}#${ordinal}`
|
|
1190
|
+
this.stepLineage.set(runId, stepName)
|
|
1191
|
+
return stepName
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1095
1194
|
public async runWorkflowJob(runId: string, rpcService: any): Promise<void> {
|
|
1195
|
+
// Fresh ordinal counters per replay so step keys are deterministic.
|
|
1196
|
+
this.resetStepOrdinals(runId)
|
|
1197
|
+
try {
|
|
1198
|
+
await this.runWorkflowJobInner(runId, rpcService)
|
|
1199
|
+
} finally {
|
|
1200
|
+
this.stepOrdinals.delete(runId)
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
private async runWorkflowJobInner(
|
|
1205
|
+
runId: string,
|
|
1206
|
+
rpcService: any
|
|
1207
|
+
): Promise<void> {
|
|
1096
1208
|
const run = await this.getRun(runId)
|
|
1097
1209
|
if (!run) {
|
|
1098
1210
|
throw new WorkflowRunNotFoundError(runId)
|
|
@@ -1335,17 +1447,25 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1335
1447
|
const isGraphWorkflow =
|
|
1336
1448
|
workflowMeta?.source === 'graph' ||
|
|
1337
1449
|
workflowMeta?.source === 'dynamic-workflow'
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1450
|
+
// Map the physical step key back to its logical node: a revisit instance
|
|
1451
|
+
// is `node#N` (ordinal), which isn't a literal key in `nodes`.
|
|
1452
|
+
let graphNodeId: string | undefined
|
|
1453
|
+
if (isGraphWorkflow && workflowMeta?.nodes) {
|
|
1454
|
+
if (stepName in workflowMeta.nodes) {
|
|
1455
|
+
graphNodeId = stepName
|
|
1456
|
+
} else {
|
|
1457
|
+
const hash = stepName.lastIndexOf('#')
|
|
1458
|
+
const base = hash > 0 ? stepName.slice(0, hash) : undefined
|
|
1459
|
+
if (base && base in workflowMeta.nodes) graphNodeId = base
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
if (graphNodeId) {
|
|
1343
1463
|
result = await executeGraphStep(
|
|
1344
1464
|
this,
|
|
1345
1465
|
rpcService,
|
|
1346
1466
|
runId,
|
|
1347
1467
|
stepState.stepId,
|
|
1348
|
-
|
|
1468
|
+
graphNodeId,
|
|
1349
1469
|
rpcName,
|
|
1350
1470
|
data,
|
|
1351
1471
|
run.workflow
|
|
@@ -1393,7 +1513,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1393
1513
|
workflowStep: {
|
|
1394
1514
|
runId,
|
|
1395
1515
|
stepId: stepState.stepId,
|
|
1516
|
+
invocationId: deriveInvocationId(runId, stepName),
|
|
1396
1517
|
attemptCount: stepState.attemptCount,
|
|
1518
|
+
fromInvocationId: stepState.fromStepName
|
|
1519
|
+
? deriveInvocationId(runId, stepState.fromStepName)
|
|
1520
|
+
: undefined,
|
|
1397
1521
|
},
|
|
1398
1522
|
})
|
|
1399
1523
|
}
|
|
@@ -1424,7 +1548,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1424
1548
|
// Store error and mark failed
|
|
1425
1549
|
await this.setStepError(stepState.stepId, error)
|
|
1426
1550
|
|
|
1427
|
-
const maxAttempts = (stepState.retries ??
|
|
1551
|
+
const maxAttempts = (stepState.retries ?? DEFAULT_STEP_RETRIES) + 1
|
|
1428
1552
|
const retriesExhausted = stepState.attemptCount >= maxAttempts
|
|
1429
1553
|
|
|
1430
1554
|
if (retriesExhausted) {
|
|
@@ -1458,6 +1582,17 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1458
1582
|
return
|
|
1459
1583
|
}
|
|
1460
1584
|
|
|
1585
|
+
if (error.name === 'WorkflowDispatchException') {
|
|
1586
|
+
// Transient: the queue was unreachable, not a workflow failure. Leave the
|
|
1587
|
+
// run running and rethrow so the orchestrator job is redelivered and the
|
|
1588
|
+
// workflow replays from its snapshot. Do NOT mark the run failed.
|
|
1589
|
+
getSingletonServices()!.logger.warn(
|
|
1590
|
+
`Workflow run ${runId} could not dispatch a step (queue unavailable); leaving run for orchestrator retry`,
|
|
1591
|
+
error
|
|
1592
|
+
)
|
|
1593
|
+
throw error
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1461
1596
|
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
1462
1597
|
message: error.message,
|
|
1463
1598
|
stack: error.stack,
|
|
@@ -1480,12 +1615,23 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1480
1615
|
|
|
1481
1616
|
private async rpcStep(
|
|
1482
1617
|
runId: string,
|
|
1483
|
-
|
|
1618
|
+
logicalStepName: string,
|
|
1484
1619
|
rpcName: string,
|
|
1485
1620
|
data: any,
|
|
1486
1621
|
rpcService: any,
|
|
1487
1622
|
stepOptions?: WorkflowStepOptions
|
|
1488
1623
|
): Promise<any> {
|
|
1624
|
+
// Capture the predecessor before nextStepKey advances the lineage to us.
|
|
1625
|
+
const fromStepName = this.lastStepName(runId)
|
|
1626
|
+
const stepName = this.nextStepKey(runId, logicalStepName)
|
|
1627
|
+
// Resolve the retry policy ONCE here so the value persisted on the step
|
|
1628
|
+
// (which drives `retriesExhausted`) is the same one the queue dispatch turns
|
|
1629
|
+
// into `attempts`. Without this the queue could retry N times while the
|
|
1630
|
+
// engine thinks retries are already exhausted (or vice-versa).
|
|
1631
|
+
const resolvedStepOptions: WorkflowStepOptions = {
|
|
1632
|
+
retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
|
|
1633
|
+
retryDelay: stepOptions?.retryDelay,
|
|
1634
|
+
}
|
|
1489
1635
|
// Check if step already exists
|
|
1490
1636
|
let stepState: StepState
|
|
1491
1637
|
try {
|
|
@@ -1497,7 +1643,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1497
1643
|
stepName,
|
|
1498
1644
|
rpcName,
|
|
1499
1645
|
data,
|
|
1500
|
-
|
|
1646
|
+
resolvedStepOptions,
|
|
1647
|
+
fromStepName
|
|
1501
1648
|
)
|
|
1502
1649
|
}
|
|
1503
1650
|
|
|
@@ -1524,26 +1671,30 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1524
1671
|
throw new WorkflowAsyncException(runId, stepName)
|
|
1525
1672
|
}
|
|
1526
1673
|
|
|
1527
|
-
// Step is pending - schedule it
|
|
1528
|
-
await this.setStepScheduled(stepState.stepId)
|
|
1529
|
-
|
|
1530
1674
|
// Hand off to subclass-overridable transport. Default behavior enqueues
|
|
1531
1675
|
// via the queue service; DO-style subclasses RPC to a step worker.
|
|
1676
|
+
// Dispatch BEFORE marking the step `scheduled`: if the queue is down,
|
|
1677
|
+
// dispatchStep throws WorkflowDispatchException and the step stays `pending`,
|
|
1678
|
+
// so the orchestrator's next replay re-dispatches it. Marking `scheduled`
|
|
1679
|
+
// first would strand the step (replay sees `scheduled`, pauses, never
|
|
1680
|
+
// re-enqueues the job that was never created).
|
|
1532
1681
|
const dispatched = await this.dispatchStep(
|
|
1533
1682
|
runId,
|
|
1534
1683
|
stepName,
|
|
1535
1684
|
rpcName,
|
|
1536
1685
|
data,
|
|
1537
|
-
|
|
1686
|
+
resolvedStepOptions,
|
|
1687
|
+
fromStepName
|
|
1538
1688
|
)
|
|
1539
1689
|
if (dispatched) {
|
|
1690
|
+
await this.setStepScheduled(stepState.stepId)
|
|
1540
1691
|
throw new WorkflowAsyncException(runId, stepName)
|
|
1541
1692
|
}
|
|
1542
1693
|
|
|
1543
1694
|
{
|
|
1544
1695
|
// Inline (no transport available) - execute locally with retry loop
|
|
1545
|
-
const retries =
|
|
1546
|
-
const retryDelay =
|
|
1696
|
+
const retries = resolvedStepOptions.retries ?? this.getConfig().retries
|
|
1697
|
+
const retryDelay = resolvedStepOptions.retryDelay
|
|
1547
1698
|
let currentStepState = stepState
|
|
1548
1699
|
|
|
1549
1700
|
while (true) {
|
|
@@ -1592,7 +1743,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1592
1743
|
workflowStep: {
|
|
1593
1744
|
runId,
|
|
1594
1745
|
stepId: currentStepState.stepId,
|
|
1746
|
+
invocationId: deriveInvocationId(runId, stepName),
|
|
1595
1747
|
attemptCount: currentStepState.attemptCount,
|
|
1748
|
+
fromInvocationId: currentStepState.fromStepName
|
|
1749
|
+
? deriveInvocationId(runId, currentStepState.fromStepName)
|
|
1750
|
+
: undefined,
|
|
1596
1751
|
},
|
|
1597
1752
|
})
|
|
1598
1753
|
}
|
|
@@ -1636,10 +1791,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1636
1791
|
|
|
1637
1792
|
private async inlineStep(
|
|
1638
1793
|
runId: string,
|
|
1639
|
-
|
|
1794
|
+
logicalStepName: string,
|
|
1640
1795
|
fn: Function,
|
|
1641
1796
|
stepOptions?: WorkflowStepOptions
|
|
1642
1797
|
): Promise<any> {
|
|
1798
|
+
const fromStepName = this.lastStepName(runId)
|
|
1799
|
+
const stepName = this.nextStepKey(runId, logicalStepName)
|
|
1643
1800
|
// Check if step already exists
|
|
1644
1801
|
let stepState: StepState
|
|
1645
1802
|
try {
|
|
@@ -1651,7 +1808,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1651
1808
|
stepName,
|
|
1652
1809
|
null,
|
|
1653
1810
|
null,
|
|
1654
|
-
stepOptions
|
|
1811
|
+
stepOptions,
|
|
1812
|
+
fromStepName
|
|
1655
1813
|
)
|
|
1656
1814
|
}
|
|
1657
1815
|
|
|
@@ -1728,16 +1886,27 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1728
1886
|
}
|
|
1729
1887
|
}
|
|
1730
1888
|
|
|
1731
|
-
private async sleepStep(
|
|
1889
|
+
private async sleepStep(
|
|
1890
|
+
runId: string,
|
|
1891
|
+
logicalStepName: string,
|
|
1892
|
+
duration: number
|
|
1893
|
+
) {
|
|
1894
|
+
const fromStepName = this.lastStepName(runId)
|
|
1895
|
+
const stepName = this.nextStepKey(runId, logicalStepName)
|
|
1732
1896
|
// Check if step already exists
|
|
1733
1897
|
let stepState: StepState
|
|
1734
1898
|
try {
|
|
1735
1899
|
stepState = await this.getStepState(runId, stepName)
|
|
1736
1900
|
} catch {
|
|
1737
1901
|
// Step doesn't exist - create it (sleep step, no RPC)
|
|
1738
|
-
stepState = await this.insertStepState(
|
|
1739
|
-
|
|
1740
|
-
|
|
1902
|
+
stepState = await this.insertStepState(
|
|
1903
|
+
runId,
|
|
1904
|
+
stepName,
|
|
1905
|
+
null,
|
|
1906
|
+
{ duration },
|
|
1907
|
+
undefined,
|
|
1908
|
+
fromStepName
|
|
1909
|
+
)
|
|
1741
1910
|
}
|
|
1742
1911
|
|
|
1743
1912
|
if (stepState.status === 'succeeded') {
|
|
@@ -1750,18 +1919,19 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1750
1919
|
throw new WorkflowAsyncException(runId, stepName)
|
|
1751
1920
|
}
|
|
1752
1921
|
|
|
1753
|
-
// Step is pending - schedule it
|
|
1754
|
-
await this.setStepScheduled(stepState.stepId)
|
|
1755
|
-
|
|
1756
1922
|
// Hand off to subclass-overridable transport. Default behavior schedules
|
|
1757
1923
|
// 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
|
-
|
|
1924
|
+
// override to use native timer primitives (e.g. setAlarm). Schedule BEFORE
|
|
1925
|
+
// marking `scheduled` so a scheduler outage leaves the step `pending` for
|
|
1926
|
+
// re-scheduling on replay instead of stranding it (see rpcStep).
|
|
1927
|
+
let scheduled: boolean
|
|
1928
|
+
try {
|
|
1929
|
+
scheduled = await this.scheduleSleep(runId, stepState.stepId, duration)
|
|
1930
|
+
} catch (cause) {
|
|
1931
|
+
throw new WorkflowDispatchException(runId, stepName, { cause })
|
|
1932
|
+
}
|
|
1764
1933
|
if (scheduled) {
|
|
1934
|
+
await this.setStepScheduled(stepState.stepId)
|
|
1765
1935
|
throw new WorkflowAsyncException(runId, stepName)
|
|
1766
1936
|
}
|
|
1767
1937
|
|
|
@@ -1788,7 +1958,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1788
1958
|
}
|
|
1789
1959
|
|
|
1790
1960
|
private async suspendStep(runId: string, reason: string): Promise<void> {
|
|
1791
|
-
const
|
|
1961
|
+
const fromStepName = this.lastStepName(runId)
|
|
1962
|
+
const suspendStepName = this.nextStepKey(
|
|
1963
|
+
runId,
|
|
1964
|
+
this.getSuspendStepName(reason)
|
|
1965
|
+
)
|
|
1792
1966
|
await this.withStepLock(runId, suspendStepName, async () => {
|
|
1793
1967
|
let stepState: StepState
|
|
1794
1968
|
try {
|
|
@@ -1800,7 +1974,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1800
1974
|
'pikkuWorkflowSuspend',
|
|
1801
1975
|
{
|
|
1802
1976
|
reason,
|
|
1803
|
-
}
|
|
1977
|
+
},
|
|
1978
|
+
undefined,
|
|
1979
|
+
fromStepName
|
|
1804
1980
|
)
|
|
1805
1981
|
}
|
|
1806
1982
|
if (!stepState.stepId) {
|
|
@@ -1810,7 +1986,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1810
1986
|
'pikkuWorkflowSuspend',
|
|
1811
1987
|
{
|
|
1812
1988
|
reason,
|
|
1813
|
-
}
|
|
1989
|
+
},
|
|
1990
|
+
undefined,
|
|
1991
|
+
fromStepName
|
|
1814
1992
|
)
|
|
1815
1993
|
}
|
|
1816
1994
|
|
|
@@ -1900,7 +2078,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1900
2078
|
const singletonServices = getSingletonServices()
|
|
1901
2079
|
const workflow = singletonServices.config?.workflow
|
|
1902
2080
|
return {
|
|
1903
|
-
retries: workflow?.retries ??
|
|
2081
|
+
retries: workflow?.retries ?? DEFAULT_STEP_RETRIES,
|
|
1904
2082
|
retryDelay: workflow?.retryDelay ?? 0,
|
|
1905
2083
|
orchestratorQueueName:
|
|
1906
2084
|
workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',
|