@pikku/core 0.12.88 → 0.12.90
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 +196 -0
- package/dist/services/http-personas.js +8 -0
- package/dist/types/core.types.d.ts +8 -1
- package/dist/wirings/rpc/rpc-runner.js +5 -6
- package/dist/wirings/virtual-user/index.d.ts +3 -1
- package/dist/wirings/virtual-user/index.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-agents.d.ts +7 -2
- package/dist/wirings/virtual-user/virtual-user-agents.js +8 -2
- package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +32 -1
- package/dist/wirings/virtual-user/virtual-user-schedule-store.d.ts +89 -0
- package/dist/wirings/virtual-user/virtual-user-schedule-store.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-schedule.d.ts +71 -0
- package/dist/wirings/virtual-user/virtual-user-schedule.js +101 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -2
- package/dist/wirings/workflow/pikku-workflow-service.js +7 -4
- package/dist/wirings/workflow/workflow-constants.d.ts +17 -0
- package/dist/wirings/workflow/workflow-constants.js +17 -0
- package/dist/wirings/workflow/workflow-recovery.d.ts +18 -1
- package/dist/wirings/workflow/workflow-recovery.js +30 -2
- package/knowledge/decisions/internals/a-virtual-user-cadence-is-a-row-not-a-timer.md +66 -0
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +8 -3
- package/knowledge/decisions/internals/index.md +1 -0
- package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +7 -6
- package/package.json +1 -1
- package/src/app-leaf-surface.test.ts +2 -2
- package/src/ecosystem-tier-removed.test.ts +69 -0
- package/src/public-surface.json +6 -0
- package/src/services/http-personas-converse.test.ts +16 -2
- package/src/services/http-personas.ts +8 -0
- package/src/types/core.types.ts +8 -1
- package/src/wirings/rpc/rpc-runner.test.ts +106 -1
- package/src/wirings/rpc/rpc-runner.ts +9 -6
- package/src/wirings/virtual-user/index.ts +18 -0
- package/src/wirings/virtual-user/virtual-user-agents.test.ts +8 -4
- package/src/wirings/virtual-user/virtual-user-agents.ts +8 -3
- package/src/wirings/virtual-user/virtual-user-run-store.ts +33 -0
- package/src/wirings/virtual-user/virtual-user-schedule-store.ts +93 -0
- package/src/wirings/virtual-user/virtual-user-schedule.test.ts +280 -0
- package/src/wirings/virtual-user/virtual-user-schedule.ts +156 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +6 -2
- package/src/wirings/workflow/workflow-constants.ts +19 -0
- package/src/wirings/workflow/workflow-recovery.ts +31 -1
- package/src/wirings/workflow/workflow-stalled-recovery.test.ts +46 -0
- package/src/wirings/workflow/workflow-terminal-run-guard.test.ts +105 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { VirtualUserRunStore } from './virtual-user-run-store.js'
|
|
2
|
+
import type {
|
|
3
|
+
VirtualUserScheduleRecord,
|
|
4
|
+
VirtualUserScheduleStore,
|
|
5
|
+
} from './virtual-user-schedule-store.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How long a run may sit at `running` before it is read as dead rather than
|
|
9
|
+
* busy.
|
|
10
|
+
*
|
|
11
|
+
* A run holds no process across a restart — see {@link VirtualUserRunRecord} —
|
|
12
|
+
* so a deploy mid-run strands the record, and a stranded record would block its
|
|
13
|
+
* persona's schedule for good. Twice the longest duration budget anyone sets in
|
|
14
|
+
* practice, because the failure this guards against is cheap to recover from
|
|
15
|
+
* and expensive to trigger early: reaping a run that was still working loses
|
|
16
|
+
* its findings.
|
|
17
|
+
*/
|
|
18
|
+
export const STALE_RUN_AFTER_MS = 2 * 60 * 60 * 1000
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The cadence a schedule gets when it is written without one: roughly a run a
|
|
22
|
+
* day, at an hour nobody can predict.
|
|
23
|
+
*
|
|
24
|
+
* Sparse on purpose. Every tick spends model budget with no caller present to
|
|
25
|
+
* notice, so the default is the one an app can leave switched on and forget,
|
|
26
|
+
* not the one that finds the most.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_MIN_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
29
|
+
export const DEFAULT_MAX_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
30
|
+
|
|
31
|
+
/** Whether a row is the tick's business, for stores that cannot ask in a query. */
|
|
32
|
+
export const isDue = (schedule: VirtualUserScheduleRecord, now: Date) =>
|
|
33
|
+
schedule.enabled && schedule.nextRunAt.getTime() <= now.getTime()
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* When this persona should next appear, drawn from its own interval.
|
|
37
|
+
*
|
|
38
|
+
* Uniform between the two bounds. Reversed bounds are read as a range rather
|
|
39
|
+
* than rejected, because a schedule is configuration and a swapped pair is a
|
|
40
|
+
* typo, not an attack.
|
|
41
|
+
*/
|
|
42
|
+
export const nextRunAt = (
|
|
43
|
+
schedule: Pick<VirtualUserScheduleRecord, 'minIntervalMs' | 'maxIntervalMs'>,
|
|
44
|
+
now: Date,
|
|
45
|
+
random: () => number
|
|
46
|
+
) => {
|
|
47
|
+
const low = Math.max(
|
|
48
|
+
0,
|
|
49
|
+
Math.min(schedule.minIntervalMs, schedule.maxIntervalMs)
|
|
50
|
+
)
|
|
51
|
+
const high = Math.max(
|
|
52
|
+
0,
|
|
53
|
+
Math.max(schedule.minIntervalMs, schedule.maxIntervalMs)
|
|
54
|
+
)
|
|
55
|
+
return new Date(now.getTime() + low + random() * (high - low))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type VirtualUserSkipReason =
|
|
59
|
+
'in-flight' | 'dispatch-failed' | 'claimed-elsewhere'
|
|
60
|
+
|
|
61
|
+
export interface VirtualUserTickResult {
|
|
62
|
+
dispatched: { persona: string; runId: string }[]
|
|
63
|
+
skipped: { persona: string; reason: VirtualUserSkipReason }[]
|
|
64
|
+
/** Runs found stranded at `running` and marked failed. */
|
|
65
|
+
reaped: string[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface VirtualUserTickParams {
|
|
69
|
+
schedules: VirtualUserScheduleStore
|
|
70
|
+
runs: VirtualUserRunStore
|
|
71
|
+
/** Starts one run and answers with its id. Nothing here knows how. */
|
|
72
|
+
dispatch: (schedule: VirtualUserScheduleRecord) => Promise<string>
|
|
73
|
+
now?: Date
|
|
74
|
+
random?: () => number
|
|
75
|
+
staleAfterMs?: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Acts on whichever personas are due, once.
|
|
80
|
+
*
|
|
81
|
+
* The whole cadence lives in this one call, so what schedules it is the host's
|
|
82
|
+
* choice — a cron wiring, a platform scheduler, or a person clicking a button.
|
|
83
|
+
* Pikku does not start a timer on an app's behalf; a scaffold that did would
|
|
84
|
+
* begin spending model budget the moment a project ran `pikku all`.
|
|
85
|
+
*
|
|
86
|
+
* A persona is skipped, not queued, while its previous run is still going. Two
|
|
87
|
+
* copies of the same user acting at once is not a heavier test, it is a
|
|
88
|
+
* different one, and every finding it produces is unreproducible. Two ticks
|
|
89
|
+
* running at once are held to the same rule by the claim, which only lands for
|
|
90
|
+
* whichever of them still sees the `nextRunAt` it read.
|
|
91
|
+
*/
|
|
92
|
+
export const tickVirtualUserSchedules = async ({
|
|
93
|
+
schedules,
|
|
94
|
+
runs,
|
|
95
|
+
dispatch,
|
|
96
|
+
now = new Date(),
|
|
97
|
+
random = Math.random,
|
|
98
|
+
staleAfterMs = STALE_RUN_AFTER_MS,
|
|
99
|
+
}: VirtualUserTickParams): Promise<VirtualUserTickResult> => {
|
|
100
|
+
const result: VirtualUserTickResult = {
|
|
101
|
+
dispatched: [],
|
|
102
|
+
skipped: [],
|
|
103
|
+
reaped: [],
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const schedule of await schedules.due(now)) {
|
|
107
|
+
const [latest] = await runs.list({ persona: schedule.persona, limit: 1 })
|
|
108
|
+
if (latest?.status === 'running') {
|
|
109
|
+
if (now.getTime() - latest.createdAt.getTime() < staleAfterMs) {
|
|
110
|
+
result.skipped.push({ persona: schedule.persona, reason: 'in-flight' })
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
await runs.fail(
|
|
114
|
+
latest.runId,
|
|
115
|
+
`Abandoned: still running ${Math.round((now.getTime() - latest.createdAt.getTime()) / 60000)}m after it started, which is longer than any budget allows.`
|
|
116
|
+
)
|
|
117
|
+
result.reaped.push(latest.runId)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const due = nextRunAt(schedule, now, random)
|
|
121
|
+
const acquired = await schedules.claim(schedule.persona, {
|
|
122
|
+
from: schedule.nextRunAt,
|
|
123
|
+
nextRunAt: due,
|
|
124
|
+
runId: null,
|
|
125
|
+
at: now,
|
|
126
|
+
})
|
|
127
|
+
if (!acquired) {
|
|
128
|
+
result.skipped.push({
|
|
129
|
+
persona: schedule.persona,
|
|
130
|
+
reason: 'claimed-elsewhere',
|
|
131
|
+
})
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let runId: string
|
|
136
|
+
try {
|
|
137
|
+
runId = await dispatch(schedule)
|
|
138
|
+
} catch {
|
|
139
|
+
result.skipped.push({
|
|
140
|
+
persona: schedule.persona,
|
|
141
|
+
reason: 'dispatch-failed',
|
|
142
|
+
})
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await schedules.claim(schedule.persona, {
|
|
147
|
+
from: due,
|
|
148
|
+
nextRunAt: due,
|
|
149
|
+
runId,
|
|
150
|
+
at: now,
|
|
151
|
+
})
|
|
152
|
+
result.dispatched.push({ persona: schedule.persona, runId })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return result
|
|
156
|
+
}
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
WORKFLOW_POLL_FACTOR,
|
|
60
60
|
WORKFLOW_POLL_MIN_MS,
|
|
61
61
|
WORKFLOW_TERMINAL_STATES,
|
|
62
|
+
isRunSettled,
|
|
62
63
|
} from './workflow-constants.js'
|
|
63
64
|
import {
|
|
64
65
|
WorkflowAsyncException,
|
|
@@ -643,8 +644,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
643
644
|
* overriding this, or no concurrency for one to exclude: the relay makes
|
|
644
645
|
* duplicate dispatch routine, and the claim is what keeps a duplicate from
|
|
645
646
|
* becoming a second execution. Every `@pikku/kysely` dialect qualifies on its
|
|
646
|
-
* status-guarded claim, `
|
|
647
|
-
* `
|
|
647
|
+
* status-guarded claim, `mongodb` on the same claim expressed as a
|
|
648
|
+
* single-document update, `in-memory` on being inline and single-process —
|
|
649
|
+
* none of them overrides this yet.
|
|
648
650
|
*/
|
|
649
651
|
protected async findUndispatchedSteps(
|
|
650
652
|
_before: Date,
|
|
@@ -672,6 +674,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
672
674
|
}): Promise<{ resumed: string[] }> {
|
|
673
675
|
return sweepStalledRuns(
|
|
674
676
|
(before, limit) => this.findStalledRunIds(before, limit),
|
|
677
|
+
this.redispatchBackoff,
|
|
675
678
|
options,
|
|
676
679
|
this.sweepDeps
|
|
677
680
|
)
|
|
@@ -1084,6 +1087,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1084
1087
|
if (!run) {
|
|
1085
1088
|
throw new WorkflowRunNotFoundError(runId)
|
|
1086
1089
|
}
|
|
1090
|
+
if (isRunSettled(run.status)) return
|
|
1087
1091
|
|
|
1088
1092
|
const resolved = resolveWorkflowMeta(run.workflow)
|
|
1089
1093
|
const workflowMeta = resolved?.meta
|
|
@@ -15,6 +15,25 @@ export const WORKFLOW_TERMINAL_STATES: ReadonlySet<string> = new Set([
|
|
|
15
15
|
'cancelled',
|
|
16
16
|
])
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* True for a run that will never move again, whatever arrives for it.
|
|
20
|
+
*
|
|
21
|
+
* Worth checking before doing anything with an orchestrator message, because
|
|
22
|
+
* such a message is routine rather than exceptional: the queue is
|
|
23
|
+
* at-least-once, the relay re-dispatches on purpose, and a run can settle
|
|
24
|
+
* while a message for it is still in flight. Replaying one is not free —
|
|
25
|
+
* `runWorkflowJob` takes the run lock and re-enters the workflow body, and a
|
|
26
|
+
* body re-entered after its run failed can park on a wait that nothing will
|
|
27
|
+
* ever satisfy, holding the lock and the connection under it until something
|
|
28
|
+
* external gives up. Every leaked advisory lock seen in production traced back
|
|
29
|
+
* to that: a granted lock, an idle session, and a run already `failed`.
|
|
30
|
+
*
|
|
31
|
+
* Note `suspended` is deliberately absent. It ends a run's *current* pass but
|
|
32
|
+
* not the run, which resumes when its approval or signal arrives.
|
|
33
|
+
*/
|
|
34
|
+
export const isRunSettled = (status: string): boolean =>
|
|
35
|
+
WORKFLOW_TERMINAL_STATES.has(status)
|
|
36
|
+
|
|
18
37
|
export const WORKFLOW_POLL_MIN_MS = 10
|
|
19
38
|
|
|
20
39
|
export const WORKFLOW_POLL_FACTOR = 1.6
|
|
@@ -17,6 +17,15 @@ import {
|
|
|
17
17
|
* owed a job, so holding off a single step while resuming its run would
|
|
18
18
|
* suppress nothing.
|
|
19
19
|
*
|
|
20
|
+
* For the same reason one instance is shared by every sweep rather than kept
|
|
21
|
+
* per sweep. The record is of the action, not of the signal that prompted it:
|
|
22
|
+
* a stalled run and an undispatched step are different observations, but both
|
|
23
|
+
* are answered by the one orchestrator message, so a run the relay re-drove a
|
|
24
|
+
* moment ago gains nothing from the stalled sweep re-driving it again. Sharing
|
|
25
|
+
* is what makes the guarantee a message-per-run-per-window instead of one per
|
|
26
|
+
* sweep, and no recovery is lost by it: whichever sweep gets there first
|
|
27
|
+
* performs the identical re-drive, and the cap keeps the delay at 10m.
|
|
28
|
+
*
|
|
20
29
|
* Losing this on restart costs extra dispatches, never correctness.
|
|
21
30
|
*/
|
|
22
31
|
export class RedispatchBackoff {
|
|
@@ -88,19 +97,40 @@ const resumeEach = async (
|
|
|
88
97
|
* actually stuck costs an orchestration pass and changes nothing. That
|
|
89
98
|
* idempotence is what makes an idle-time heuristic safe here; a run that is
|
|
90
99
|
* legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
|
|
100
|
+
*
|
|
101
|
+
* Idempotent is not free, though: a run stays stalled until something clears
|
|
102
|
+
* the reason it stalled, so an unconditional sweep re-queues the same runs on
|
|
103
|
+
* every tick forever. That is how a handful of wedged runs became a queue of
|
|
104
|
+
* thousands that could not drain — each pass added work the previous pass had
|
|
105
|
+
* not finished. The same per-run backoff the relay uses bounds it: a run is
|
|
106
|
+
* re-driven, then held off for a doubling delay, so a sweep costs at most one
|
|
107
|
+
* message per run per window rather than one per run per tick.
|
|
91
108
|
*/
|
|
92
109
|
export const sweepStalledRuns = async (
|
|
93
110
|
findStalledRunIds: (before: Date, limit: number) => Promise<string[]>,
|
|
111
|
+
backoff: RedispatchBackoff,
|
|
94
112
|
options: { stalledAfterMs?: number; limit?: number } | undefined,
|
|
95
113
|
deps: SweepDeps
|
|
96
114
|
): Promise<{ resumed: string[] }> => {
|
|
97
115
|
const before = new Date(
|
|
98
116
|
Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS)
|
|
99
117
|
)
|
|
100
|
-
const
|
|
118
|
+
const found = await findStalledRunIds(
|
|
101
119
|
before,
|
|
102
120
|
options?.limit ?? DEFAULT_STALLED_RUN_LIMIT
|
|
103
121
|
)
|
|
122
|
+
|
|
123
|
+
const now = Date.now()
|
|
124
|
+
const runIds: string[] = []
|
|
125
|
+
for (const runId of found) {
|
|
126
|
+
if (!backoff.isEligible(runId, now)) continue
|
|
127
|
+
// Noted before the resume, not after: a run whose resume throws is exactly
|
|
128
|
+
// the run most likely to still be here next tick, and re-driving it every
|
|
129
|
+
// tick is the amplification this backoff exists to stop.
|
|
130
|
+
backoff.note(runId, now)
|
|
131
|
+
runIds.push(runId)
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
return {
|
|
105
135
|
resumed: await resumeEach(
|
|
106
136
|
runIds,
|
|
@@ -89,6 +89,52 @@ describe('stalled run recovery', () => {
|
|
|
89
89
|
assert.deepEqual(resumed, [], 'a run that just moved is not swept')
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
+
test('backs off rather than re-resuming the same run every tick', 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 backdate(ws, runId, 10 * 60_000)
|
|
100
|
+
|
|
101
|
+
const first = await ws.recoverStalledRuns()
|
|
102
|
+
// Still stalled: a resume does not clear whatever wedged the run, so an
|
|
103
|
+
// unconditional sweep would re-queue it on this tick and on every tick
|
|
104
|
+
// after it.
|
|
105
|
+
const second = await ws.recoverStalledRuns()
|
|
106
|
+
|
|
107
|
+
assert.deepEqual(first.resumed, [runId], 'the first sweep resumes it')
|
|
108
|
+
assert.deepEqual(
|
|
109
|
+
second.resumed,
|
|
110
|
+
[],
|
|
111
|
+
'the next sweep is held off by the backoff'
|
|
112
|
+
)
|
|
113
|
+
assert.deepEqual(resumes.runIds, [runId], 'only one queue message')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('does not re-drive a run the relay has just re-dispatched', async () => {
|
|
117
|
+
const resumes = trackResumes()
|
|
118
|
+
const ws = new InMemoryWorkflowService()
|
|
119
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
120
|
+
type: 'test',
|
|
121
|
+
})
|
|
122
|
+
// Old enough to read as both an undispatched step and a stalled run.
|
|
123
|
+
await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
|
|
124
|
+
await backdate(ws, runId, 10 * 60_000)
|
|
125
|
+
|
|
126
|
+
const relayed = await ws.relayUndispatchedSteps()
|
|
127
|
+
const swept = await ws.recoverStalledRuns()
|
|
128
|
+
|
|
129
|
+
assert.deepEqual(relayed.redispatched, [runId], 'the relay re-drives it')
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
swept.resumed,
|
|
132
|
+
[],
|
|
133
|
+
'the sweep adds nothing the relay has not already queued'
|
|
134
|
+
)
|
|
135
|
+
assert.deepEqual(resumes.runIds, [runId], 'only one queue message')
|
|
136
|
+
})
|
|
137
|
+
|
|
92
138
|
test('leaves a finished run alone', async () => {
|
|
93
139
|
trackResumes()
|
|
94
140
|
const ws = new InMemoryWorkflowService()
|
|
@@ -0,0 +1,105 @@
|
|
|
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, resetPikkuState } from '../../pikku-state.js'
|
|
6
|
+
|
|
7
|
+
const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
8
|
+
|
|
9
|
+
/** Records every run lock taken, which is the whole point of the guard. */
|
|
10
|
+
class LockSpyWorkflowService extends InMemoryWorkflowService {
|
|
11
|
+
public readonly locked: string[] = []
|
|
12
|
+
|
|
13
|
+
public override async withRunLock<T>(
|
|
14
|
+
id: string,
|
|
15
|
+
fn: () => Promise<T>
|
|
16
|
+
): Promise<T> {
|
|
17
|
+
this.locked.push(id)
|
|
18
|
+
return super.withRunLock(id, fn)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A registered, fully described workflow, so nothing else can short-circuit. */
|
|
23
|
+
const startRun = async (): Promise<{
|
|
24
|
+
service: LockSpyWorkflowService
|
|
25
|
+
runId: string
|
|
26
|
+
entered: () => boolean
|
|
27
|
+
}> => {
|
|
28
|
+
resetPikkuState()
|
|
29
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
30
|
+
logger: silentLogger,
|
|
31
|
+
queueService: { add: async () => 'job-1' },
|
|
32
|
+
} as any)
|
|
33
|
+
|
|
34
|
+
let bodyEntered = false
|
|
35
|
+
const func = async () => {
|
|
36
|
+
bodyEntered = true
|
|
37
|
+
}
|
|
38
|
+
pikkuState(null, 'workflows', 'meta', {
|
|
39
|
+
flow: { name: 'flow', pikkuFuncId: 'flow', source: 'dsl' },
|
|
40
|
+
} as any)
|
|
41
|
+
pikkuState(null, 'workflows', 'registrations').set('flow', {
|
|
42
|
+
name: 'flow',
|
|
43
|
+
func,
|
|
44
|
+
} as any)
|
|
45
|
+
pikkuState(null, 'function', 'meta', {
|
|
46
|
+
flow: {
|
|
47
|
+
pikkuFuncId: 'flow',
|
|
48
|
+
inputSchemaName: null,
|
|
49
|
+
outputSchemaName: null,
|
|
50
|
+
sessionless: true,
|
|
51
|
+
},
|
|
52
|
+
} as any)
|
|
53
|
+
pikkuState(null, 'function', 'functions').set('flow', { func } as any)
|
|
54
|
+
|
|
55
|
+
const service = new LockSpyWorkflowService()
|
|
56
|
+
const runId = await service.createRun('flow', {}, false, '', { type: 'test' })
|
|
57
|
+
return { service, runId, entered: () => bodyEntered }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The leak this guards, read straight off production: every granted advisory
|
|
62
|
+
* lock held by an idle session mapped to a run that was already `failed`. The
|
|
63
|
+
* orchestrator queue is at-least-once, so a message for a settled run is
|
|
64
|
+
* routine — and answering it by taking the run lock and replaying the body is
|
|
65
|
+
* how a run that can never move again ends up holding a lock and a pooled
|
|
66
|
+
* connection while it waits on something that will never arrive.
|
|
67
|
+
*/
|
|
68
|
+
describe('an orchestrator message for a run that already settled', () => {
|
|
69
|
+
for (const status of ['failed', 'completed', 'cancelled'] as const) {
|
|
70
|
+
test(`a ${status} run is answered without taking its lock`, async () => {
|
|
71
|
+
const { service, runId, entered } = await startRun()
|
|
72
|
+
await service.updateRunStatus(runId, status)
|
|
73
|
+
|
|
74
|
+
await service.runWorkflowJob(runId, {} as any)
|
|
75
|
+
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
service.locked,
|
|
78
|
+
[],
|
|
79
|
+
'the run lock was taken for a run that can never move again'
|
|
80
|
+
)
|
|
81
|
+
assert.equal(
|
|
82
|
+
entered(),
|
|
83
|
+
false,
|
|
84
|
+
'the workflow body was replayed after the run had settled'
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Guards the tests above: they would pass just as well for a service that
|
|
91
|
+
* refused to orchestrate anything at all.
|
|
92
|
+
*/
|
|
93
|
+
test('a suspended run is still orchestrated', async () => {
|
|
94
|
+
const { service, runId } = await startRun()
|
|
95
|
+
await service.updateRunStatus(runId, 'suspended')
|
|
96
|
+
|
|
97
|
+
await service.runWorkflowJob(runId, {} as any)
|
|
98
|
+
|
|
99
|
+
assert.deepEqual(
|
|
100
|
+
service.locked,
|
|
101
|
+
[runId],
|
|
102
|
+
'suspended ends a pass, not the run — it resumes when its signal arrives'
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
})
|