@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.
@@ -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
+ })