@pikku/core 0.12.77 → 0.12.79
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 +92 -0
- package/dist/services/in-memory-workflow-service.d.ts +11 -0
- package/dist/services/in-memory-workflow-service.js +43 -0
- package/dist/utils/node-host-resolver.d.ts +12 -0
- package/dist/utils/node-host-resolver.js +16 -0
- package/dist/utils/safe-fetch.d.ts +18 -0
- package/dist/utils/safe-fetch.js +167 -29
- package/dist/wirings/gateway/gateway-runner.js +32 -3
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +87 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +155 -0
- package/knowledge/decisions/security/gateway-handlers-run-through-the-function-runner-gate.md +13 -3
- package/package.json +2 -1
- package/src/services/in-memory-workflow-service.ts +52 -0
- package/src/utils/node-host-resolver.ts +20 -0
- package/src/utils/safe-fetch.test.ts +143 -1
- package/src/utils/safe-fetch.ts +200 -25
- package/src/wirings/gateway/gateway-authorization.test.ts +131 -0
- package/src/wirings/gateway/gateway-runner.ts +35 -3
- package/src/wirings/secret/validate-secret-definitions.test.ts +47 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +198 -0
- package/src/wirings/workflow/workflow-dispatch-relay.test.ts +128 -0
- package/src/wirings/workflow/workflow-stalled-recovery.test.ts +106 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -275,6 +275,31 @@ const WORKFLOW_TERMINAL_STATES: ReadonlySet<string> = new Set([
|
|
|
275
275
|
'cancelled',
|
|
276
276
|
])
|
|
277
277
|
|
|
278
|
+
/** Idle window before a `running` run with nothing in flight is treated as stalled. */
|
|
279
|
+
const DEFAULT_STALLED_RUN_MS = 5 * 60_000
|
|
280
|
+
|
|
281
|
+
/** Runs re-driven per `recoverStalledRuns` call, so one sweep is bounded. */
|
|
282
|
+
const DEFAULT_STALLED_RUN_LIMIT = 100
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* How long a step may sit `pending` before the relay assumes its dispatch was
|
|
286
|
+
* lost. This is a bet that no healthy queue takes this long to move a job from
|
|
287
|
+
* `pending` to `running`; set it above the observed p99 of that latency.
|
|
288
|
+
*/
|
|
289
|
+
const DEFAULT_UNDISPATCHED_STEP_MS = 30_000
|
|
290
|
+
|
|
291
|
+
/** Steps re-driven per `relayUndispatchedSteps` call, so one tick is bounded. */
|
|
292
|
+
const DEFAULT_UNDISPATCHED_STEP_LIMIT = 100
|
|
293
|
+
|
|
294
|
+
/** First wait before a step already re-dispatched once is re-dispatched again. */
|
|
295
|
+
const REDISPATCH_BACKOFF_MS = 30_000
|
|
296
|
+
|
|
297
|
+
/** Ceiling on the doubling backoff, so a permanently stuck step still gets swept. */
|
|
298
|
+
const REDISPATCH_BACKOFF_MAX_MS = 10 * 60_000
|
|
299
|
+
|
|
300
|
+
/** Bound on the in-process backoff map, so a long-lived process cannot grow it without bound. */
|
|
301
|
+
const REDISPATCH_BACKOFF_MAX_ENTRIES = 10_000
|
|
302
|
+
|
|
278
303
|
const WORKFLOW_POLL_MIN_MS = 10
|
|
279
304
|
|
|
280
305
|
const WORKFLOW_POLL_FACTOR = 1.6
|
|
@@ -908,6 +933,179 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
908
933
|
)
|
|
909
934
|
}
|
|
910
935
|
|
|
936
|
+
/**
|
|
937
|
+
* Ids of runs that are stalled: still `running`, with no step in a state that
|
|
938
|
+
* something is expected to complete (`running`, `scheduled`, `suspended`),
|
|
939
|
+
* and no step activity since `before`.
|
|
940
|
+
*
|
|
941
|
+
* Returns nothing by default so a store that cannot express the query keeps
|
|
942
|
+
* working unchanged; a store that overrides it gains crash recovery through
|
|
943
|
+
* `recoverStalledRuns`.
|
|
944
|
+
*/
|
|
945
|
+
protected async findStalledRunIds(
|
|
946
|
+
_before: Date,
|
|
947
|
+
_limit: number
|
|
948
|
+
): Promise<string[]> {
|
|
949
|
+
return []
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Re-drive runs whose next move was lost, and report which were resumed.
|
|
954
|
+
*
|
|
955
|
+
* Arming a step is two writes to two systems — the step row, then the queue
|
|
956
|
+
* or scheduler job — so a process that dies between them leaves a run that is
|
|
957
|
+
* `running` with nothing in flight. Nothing notices: the run parks on a step
|
|
958
|
+
* that will never complete and never error, so it neither finishes nor fails.
|
|
959
|
+
* (Seen on a `workflow.sleep()`: a deploy restart landed between the sleep
|
|
960
|
+
* step's insert and its timer, parking the run permanently.)
|
|
961
|
+
*
|
|
962
|
+
* Replay is the recovery — `resumeWorkflow` re-orchestrates from persisted
|
|
963
|
+
* step state, and every settled step is memoized, so resuming a run that was
|
|
964
|
+
* not actually stuck costs an orchestration pass and changes nothing. That
|
|
965
|
+
* idempotence is what makes an idle-time heuristic safe here; a run that is
|
|
966
|
+
* legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
|
|
967
|
+
*
|
|
968
|
+
* This is not self-starting. Call it from a scheduled task at whatever
|
|
969
|
+
* interval suits the workload.
|
|
970
|
+
*/
|
|
971
|
+
public async recoverStalledRuns(options?: {
|
|
972
|
+
stalledAfterMs?: number
|
|
973
|
+
limit?: number
|
|
974
|
+
}): Promise<{ resumed: string[] }> {
|
|
975
|
+
const before = new Date(
|
|
976
|
+
Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS)
|
|
977
|
+
)
|
|
978
|
+
const runIds = await this.findStalledRunIds(
|
|
979
|
+
before,
|
|
980
|
+
options?.limit ?? DEFAULT_STALLED_RUN_LIMIT
|
|
981
|
+
)
|
|
982
|
+
const resumed: string[] = []
|
|
983
|
+
for (const runId of runIds) {
|
|
984
|
+
try {
|
|
985
|
+
await this.resumeWorkflow(runId)
|
|
986
|
+
resumed.push(runId)
|
|
987
|
+
} catch (err) {
|
|
988
|
+
// One unresumable run must not stop the sweep from recovering the rest.
|
|
989
|
+
getSingletonServices()?.logger?.error(
|
|
990
|
+
`Failed to resume stalled workflow run ${runId}: ${err instanceof Error ? err.message : String(err)}`
|
|
991
|
+
)
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return { resumed }
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Runs holding a step that has sat `pending` since before `before`, paired
|
|
999
|
+
* with the step that flagged them.
|
|
1000
|
+
*
|
|
1001
|
+
* Returns nothing by default so a store that cannot express the query keeps
|
|
1002
|
+
* working unchanged — and, because it does not opt in, gains no re-dispatches
|
|
1003
|
+
* either. A store must have an atomic `withStepLock` before overriding this,
|
|
1004
|
+
* or no concurrency for one to exclude: the relay makes duplicate dispatch
|
|
1005
|
+
* routine, and the claim in `executeWorkflowStepInner` is what keeps a
|
|
1006
|
+
* duplicate from becoming a second execution. `kysely-postgres` and
|
|
1007
|
+
* `kysely-mysql` qualify on the lock, `in-memory` on being inline and
|
|
1008
|
+
* single-process; `mongodb` and `kysely-sqlite` qualify on neither.
|
|
1009
|
+
*/
|
|
1010
|
+
protected async findUndispatchedSteps(
|
|
1011
|
+
_before: Date,
|
|
1012
|
+
_limit: number
|
|
1013
|
+
): Promise<Array<{ runId: string; stepId: string }>> {
|
|
1014
|
+
return []
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Re-drive steps whose dispatch was lost, and report which runs were nudged.
|
|
1019
|
+
*
|
|
1020
|
+
* Arming a step is two writes to two systems: the step row lands `pending`,
|
|
1021
|
+
* then a queue or scheduler job is published. Nothing spans both, so a crash
|
|
1022
|
+
* in between leaves a durable row that nothing will ever pick up — the run
|
|
1023
|
+
* neither finishes nor fails. (Seen on a `workflow.sleep()`: a deploy restart
|
|
1024
|
+
* landed between the sleep step's insert and its timer.)
|
|
1025
|
+
*
|
|
1026
|
+
* The row is the outbox record and this is the relay. Age is the only signal
|
|
1027
|
+
* available — a step `pending` because its dispatch was lost is
|
|
1028
|
+
* indistinguishable from one whose job is merely still queued — so a step
|
|
1029
|
+
* past `undispatchedAfterMs` is re-dispatched regardless, and correctness
|
|
1030
|
+
* rests on the claim rather than on the guess being right. A redundant
|
|
1031
|
+
* dispatch costs one queue message: the loser reads `running` and returns
|
|
1032
|
+
* without invoking anything.
|
|
1033
|
+
*
|
|
1034
|
+
* Re-dispatches back off per step (doubling from 30s, capped at 10m) so a
|
|
1035
|
+
* genuine queue backlog is not amplified by a tick that keeps firing at the
|
|
1036
|
+
* steps the backlog is already delaying. The backoff is per process and
|
|
1037
|
+
* advisory — losing it on restart costs extra dispatches, never correctness.
|
|
1038
|
+
*
|
|
1039
|
+
* This is not self-starting. Call it from a scheduled task; ~30s suits a
|
|
1040
|
+
* queue whose `pending`→`running` latency is well under that.
|
|
1041
|
+
*/
|
|
1042
|
+
public async relayUndispatchedSteps(options?: {
|
|
1043
|
+
undispatchedAfterMs?: number
|
|
1044
|
+
limit?: number
|
|
1045
|
+
}): Promise<{ redispatched: string[] }> {
|
|
1046
|
+
const before = new Date(
|
|
1047
|
+
Date.now() -
|
|
1048
|
+
(options?.undispatchedAfterMs ?? DEFAULT_UNDISPATCHED_STEP_MS)
|
|
1049
|
+
)
|
|
1050
|
+
const steps = await this.findUndispatchedSteps(
|
|
1051
|
+
before,
|
|
1052
|
+
options?.limit ?? DEFAULT_UNDISPATCHED_STEP_LIMIT
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
// Backoff is keyed by run, not step, because the run is the unit of
|
|
1056
|
+
// re-drive: `resumeWorkflow` replays the whole run and re-dispatches every
|
|
1057
|
+
// step still owed a job. Holding off a single step while resuming its run
|
|
1058
|
+
// would not suppress anything.
|
|
1059
|
+
const now = Date.now()
|
|
1060
|
+
const runIds = new Set<string>()
|
|
1061
|
+
for (const { runId } of steps) {
|
|
1062
|
+
const eligibleAt = this.redispatchBackoff.get(runId)
|
|
1063
|
+
if (eligibleAt !== undefined && eligibleAt > now) {
|
|
1064
|
+
continue
|
|
1065
|
+
}
|
|
1066
|
+
this.noteRedispatch(runId, now)
|
|
1067
|
+
runIds.add(runId)
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const redispatched: string[] = []
|
|
1071
|
+
for (const runId of runIds) {
|
|
1072
|
+
try {
|
|
1073
|
+
await this.resumeWorkflow(runId)
|
|
1074
|
+
redispatched.push(runId)
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
// One unresumable run must not stop the tick from relaying the rest.
|
|
1077
|
+
getSingletonServices()?.logger?.error(
|
|
1078
|
+
`Failed to re-dispatch workflow run ${runId}: ${err instanceof Error ? err.message : String(err)}`
|
|
1079
|
+
)
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return { redispatched }
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** Advisory, per-process record of when a run may next be re-dispatched. */
|
|
1086
|
+
private readonly redispatchBackoff = new Map<string, number>()
|
|
1087
|
+
|
|
1088
|
+
private readonly redispatchDelays = new Map<string, number>()
|
|
1089
|
+
|
|
1090
|
+
private noteRedispatch(runId: string, now: number): void {
|
|
1091
|
+
const previous = this.redispatchDelays.get(runId)
|
|
1092
|
+
const delay = Math.min(
|
|
1093
|
+
previous === undefined ? REDISPATCH_BACKOFF_MS : previous * 2,
|
|
1094
|
+
REDISPATCH_BACKOFF_MAX_MS
|
|
1095
|
+
)
|
|
1096
|
+
// A run that settles is never returned again, so entries are only evicted by
|
|
1097
|
+
// this bound — oldest first, which is also least recently re-dispatched.
|
|
1098
|
+
if (this.redispatchBackoff.size >= REDISPATCH_BACKOFF_MAX_ENTRIES) {
|
|
1099
|
+
const oldest = this.redispatchBackoff.keys().next()
|
|
1100
|
+
if (!oldest.done) {
|
|
1101
|
+
this.redispatchBackoff.delete(oldest.value)
|
|
1102
|
+
this.redispatchDelays.delete(oldest.value)
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
this.redispatchDelays.set(runId, delay)
|
|
1106
|
+
this.redispatchBackoff.set(runId, now + delay)
|
|
1107
|
+
}
|
|
1108
|
+
|
|
911
1109
|
protected resolveStepJobOptions(
|
|
912
1110
|
stepOptions?: WorkflowStepOptions
|
|
913
1111
|
): JobOptions {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
|
+
import { pikkuState } from '../../pikku-state.js'
|
|
6
|
+
|
|
7
|
+
const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `relayUndispatchedSteps` re-drives a run by putting it back on the
|
|
11
|
+
* orchestrator queue, so the queue is the observation point.
|
|
12
|
+
*/
|
|
13
|
+
function trackResumes(): { runIds: string[] } {
|
|
14
|
+
const seen: string[] = []
|
|
15
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
16
|
+
queueService: {
|
|
17
|
+
add: async (_queue: string, data: { runId: string }) => {
|
|
18
|
+
seen.push(data.runId)
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
logger: silentLogger,
|
|
22
|
+
} as any)
|
|
23
|
+
return { runIds: seen }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Backdates a step's timestamp so it reads as undispatched. */
|
|
27
|
+
async function backdateSteps(
|
|
28
|
+
ws: InMemoryWorkflowService,
|
|
29
|
+
runId: string,
|
|
30
|
+
ms: number
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const past = new Date(Date.now() - ms)
|
|
33
|
+
for (const step of await ws.getRunHistory(runId)) {
|
|
34
|
+
;(step as any).updatedAt = past
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('undispatched step relay', () => {
|
|
39
|
+
test('re-dispatches a step whose arming was lost', async () => {
|
|
40
|
+
const resumes = trackResumes()
|
|
41
|
+
const ws = new InMemoryWorkflowService()
|
|
42
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
43
|
+
type: 'test',
|
|
44
|
+
})
|
|
45
|
+
// The row committed; the process died before the timer was armed.
|
|
46
|
+
await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
|
|
47
|
+
await backdateSteps(ws, runId, 60_000)
|
|
48
|
+
|
|
49
|
+
const { redispatched } = await ws.relayUndispatchedSteps()
|
|
50
|
+
|
|
51
|
+
assert.deepEqual(redispatched, [runId], 'the run is re-dispatched')
|
|
52
|
+
assert.deepEqual(resumes.runIds, [runId], 'it is put back on the queue')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('leaves a freshly written step alone', async () => {
|
|
56
|
+
const resumes = trackResumes()
|
|
57
|
+
const ws = new InMemoryWorkflowService()
|
|
58
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
59
|
+
type: 'test',
|
|
60
|
+
})
|
|
61
|
+
// Written milliseconds ago — its dispatch is almost certainly in flight.
|
|
62
|
+
await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
|
|
63
|
+
|
|
64
|
+
const { redispatched } = await ws.relayUndispatchedSteps()
|
|
65
|
+
|
|
66
|
+
assert.deepEqual(redispatched, [], 'nothing is re-dispatched')
|
|
67
|
+
assert.deepEqual(resumes.runIds, [], 'nothing is queued')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('backs off rather than re-dispatching the same step every tick', async () => {
|
|
71
|
+
const resumes = trackResumes()
|
|
72
|
+
const ws = new InMemoryWorkflowService()
|
|
73
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
74
|
+
type: 'test',
|
|
75
|
+
})
|
|
76
|
+
await ws.insertStepState(runId, 'Charge card', 'charge', {})
|
|
77
|
+
await backdateSteps(ws, runId, 60_000)
|
|
78
|
+
|
|
79
|
+
const first = await ws.relayUndispatchedSteps()
|
|
80
|
+
// The step is still pending — a real queue backlog looks exactly like this.
|
|
81
|
+
const second = await ws.relayUndispatchedSteps()
|
|
82
|
+
|
|
83
|
+
assert.deepEqual(first.redispatched, [runId], 'the first tick relays it')
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
second.redispatched,
|
|
86
|
+
[],
|
|
87
|
+
'the next tick is held off by the backoff'
|
|
88
|
+
)
|
|
89
|
+
assert.deepEqual(resumes.runIds, [runId], 'only one queue message')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('ignores steps belonging to a settled run', async () => {
|
|
93
|
+
const resumes = trackResumes()
|
|
94
|
+
const ws = new InMemoryWorkflowService()
|
|
95
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
96
|
+
type: 'test',
|
|
97
|
+
})
|
|
98
|
+
await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
|
|
99
|
+
await backdateSteps(ws, runId, 60_000)
|
|
100
|
+
await ws.updateRunStatus(runId, 'completed', {})
|
|
101
|
+
|
|
102
|
+
const { redispatched } = await ws.relayUndispatchedSteps()
|
|
103
|
+
|
|
104
|
+
assert.deepEqual(redispatched, [], 'a finished run is not re-dispatched')
|
|
105
|
+
assert.deepEqual(resumes.runIds, [], 'nothing is queued')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('re-dispatches each stuck run once per tick', async () => {
|
|
109
|
+
const resumes = trackResumes()
|
|
110
|
+
const ws = new InMemoryWorkflowService()
|
|
111
|
+
const runIds: string[] = []
|
|
112
|
+
for (const n of [1, 2]) {
|
|
113
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
114
|
+
type: 'test',
|
|
115
|
+
})
|
|
116
|
+
// Two orphaned steps in one run must still produce a single resume.
|
|
117
|
+
await ws.insertStepState(runId, `Wait ${n}a`, null, { duration: 1000 })
|
|
118
|
+
await ws.insertStepState(runId, `Wait ${n}b`, null, { duration: 1000 })
|
|
119
|
+
await backdateSteps(ws, runId, 60_000)
|
|
120
|
+
runIds.push(runId)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { redispatched } = await ws.relayUndispatchedSteps()
|
|
124
|
+
|
|
125
|
+
assert.deepEqual(redispatched.sort(), runIds.sort(), 'both runs relayed')
|
|
126
|
+
assert.equal(resumes.runIds.length, 2, 'one queue message per run')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
|
+
import { pikkuState } from '../../pikku-state.js'
|
|
6
|
+
|
|
7
|
+
const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `recoverStalledRuns` re-drives a run by putting it back on the orchestrator
|
|
11
|
+
* queue, so the queue is the observation point.
|
|
12
|
+
*/
|
|
13
|
+
function trackResumes(): { runIds: string[] } {
|
|
14
|
+
const seen: string[] = []
|
|
15
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
16
|
+
queueService: {
|
|
17
|
+
add: async (_queue: string, data: { runId: string }) => {
|
|
18
|
+
seen.push(data.runId)
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
logger: silentLogger,
|
|
22
|
+
} as any)
|
|
23
|
+
return { runIds: seen }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Backdates every persisted timestamp so the run reads as idle. */
|
|
27
|
+
async function backdate(
|
|
28
|
+
ws: InMemoryWorkflowService,
|
|
29
|
+
runId: string,
|
|
30
|
+
ms: number
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const past = new Date(Date.now() - ms)
|
|
33
|
+
const run = await ws.getRun(runId)
|
|
34
|
+
;(run as any).updatedAt = past
|
|
35
|
+
for (const step of await ws.getRunHistory(runId)) {
|
|
36
|
+
;(step as any).updatedAt = past
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('stalled run recovery', () => {
|
|
41
|
+
test('resumes a running run left with a pending step and nothing in flight', async () => {
|
|
42
|
+
const resumes = trackResumes()
|
|
43
|
+
const ws = new InMemoryWorkflowService()
|
|
44
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
45
|
+
type: 'test',
|
|
46
|
+
})
|
|
47
|
+
// A sleep whose timer was never armed: the step row exists, nothing will
|
|
48
|
+
// ever complete it.
|
|
49
|
+
await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
|
|
50
|
+
await backdate(ws, runId, 10 * 60_000)
|
|
51
|
+
|
|
52
|
+
const { resumed } = await ws.recoverStalledRuns()
|
|
53
|
+
|
|
54
|
+
assert.deepEqual(resumed, [runId], 'the orphaned run is resumed')
|
|
55
|
+
assert.deepEqual(resumes.runIds, [runId], 'it is put back on the queue')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('leaves a run alone while a step is still scheduled', async () => {
|
|
59
|
+
trackResumes()
|
|
60
|
+
const ws = new InMemoryWorkflowService()
|
|
61
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
62
|
+
type: 'test',
|
|
63
|
+
})
|
|
64
|
+
const step = await ws.insertStepState(runId, 'Wait 1h', null, {
|
|
65
|
+
duration: 3_600_000,
|
|
66
|
+
})
|
|
67
|
+
await ws.setStepScheduled(step.stepId)
|
|
68
|
+
await backdate(ws, runId, 10 * 60_000)
|
|
69
|
+
|
|
70
|
+
const { resumed } = await ws.recoverStalledRuns()
|
|
71
|
+
|
|
72
|
+
assert.deepEqual(
|
|
73
|
+
resumed,
|
|
74
|
+
[],
|
|
75
|
+
'a legitimately sleeping run is not a stalled run'
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('leaves a run alone until it has actually gone idle', async () => {
|
|
80
|
+
trackResumes()
|
|
81
|
+
const ws = new InMemoryWorkflowService()
|
|
82
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
83
|
+
type: 'test',
|
|
84
|
+
})
|
|
85
|
+
await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
|
|
86
|
+
|
|
87
|
+
const { resumed } = await ws.recoverStalledRuns()
|
|
88
|
+
|
|
89
|
+
assert.deepEqual(resumed, [], 'a run that just moved is not swept')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('leaves a finished run alone', async () => {
|
|
93
|
+
trackResumes()
|
|
94
|
+
const ws = new InMemoryWorkflowService()
|
|
95
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
96
|
+
type: 'test',
|
|
97
|
+
})
|
|
98
|
+
await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
|
|
99
|
+
await ws.updateRunStatus(runId, 'completed')
|
|
100
|
+
await backdate(ws, runId, 10 * 60_000)
|
|
101
|
+
|
|
102
|
+
const { resumed } = await ws.recoverStalledRuns()
|
|
103
|
+
|
|
104
|
+
assert.deepEqual(resumed, [], 'only running runs are swept')
|
|
105
|
+
})
|
|
106
|
+
})
|