@pikku/core 0.12.88 → 0.12.89

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 CHANGED
@@ -1,3 +1,39 @@
1
+ ## 0.12.89
2
+
3
+ ### Patch Changes
4
+
5
+ - 32616af: Carry the trace id across a remote RPC hop
6
+
7
+ `ContextAwareRPCService` sent the wire's trace id as `x-trace-id`, but the HTTP
8
+ runner on the receiving end reads `x-request-id` — the header every other sender
9
+ uses, including `buildRemoteHeaders`, which every deployment service goes
10
+ through. The receiving side therefore ignored the incoming id and generated a
11
+ fresh one, so a trace broke at each remote RPC boundary instead of spanning it.
12
+ Remote RPC now sends `x-request-id` too.
13
+
14
+ - 6848cd9: fix(workflow): back off the stalled-run sweep, and skip runs that cannot move
15
+
16
+ `sweepUndispatchedSteps` has always consulted a per-run backoff so a genuine
17
+ queue backlog is not amplified by a tick that keeps firing at the steps the
18
+ backlog is already delaying. `sweepStalledRuns` — its sibling, doing the same
19
+ re-drive through the same orchestrator queue — had none, and re-resumed every
20
+ stalled run on every tick. A resume does not clear whatever wedged a run, so
21
+ the same runs came back on the next tick and the next: in production seven
22
+ permanently stuck runs refilled a purged orchestrator queue at seven messages a
23
+ minute, and a backlog of six thousand could never drain because each pass added
24
+ work the previous pass had not finished. It now takes the same backoff, and
25
+ both sweeps share one instance — the record belongs to the re-drive, not to the
26
+ signal that asked for it, so a run the relay nudged a moment ago is not nudged
27
+ again by the sweep.
28
+
29
+ `runWorkflowJob` also now returns immediately for a run in a terminal state
30
+ instead of taking the run lock and replaying the workflow body. The orchestrator
31
+ queue is at-least-once and the relay re-dispatches on purpose, so a message for
32
+ a run that already settled is routine — and replaying one could park the body on
33
+ a wait that nothing would ever satisfy, holding the run lock, and the pooled
34
+ connection under it, until something external gave up. `suspended` is
35
+ deliberately not included: it ends a pass, not the run.
36
+
1
37
  ## 0.12.88
2
38
 
3
39
  ### Patch Changes
@@ -182,7 +182,7 @@ export type PikkuWire<In = unknown, Out = unknown, HasInitialSession extends boo
182
182
  * sets it; services that log fall back to the singleton logger.
183
183
  */
184
184
  logger: Logger;
185
- /** Trace ID for distributed tracing — propagated across remote RPC calls via x-trace-id header */
185
+ /** Trace ID for distributed tracing — propagated across remote RPC calls via the x-request-id header */
186
186
  traceId: string;
187
187
  functionId: string;
188
188
  addonNamespace: string;
@@ -245,7 +245,7 @@ export class ContextAwareRPCService {
245
245
  headers.authorization = `Bearer ${token}`;
246
246
  }
247
247
  if (this.wire.traceId) {
248
- headers['x-trace-id'] = this.wire.traceId;
248
+ headers['x-request-id'] = this.wire.traceId;
249
249
  }
250
250
  const base = serverUrl.replace(/\/+$/, '');
251
251
  const res = await fetch(`${base}/remote/rpc/${encodeURIComponent(remoteFn)}`, {
@@ -8,7 +8,7 @@ import { RPCNotFoundError } from '../rpc/rpc-runner.js';
8
8
  import { deriveInvocationId } from './workflow-invocation-id.js';
9
9
  import { approvalDeciderFrom } from './workflow-approval-policy.js';
10
10
  import { buildRunTimeline, reconstructStateAt, } from './run-timeline.js';
11
- import { DEFAULT_STEP_RETRIES, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, } from './workflow-constants.js';
11
+ import { DEFAULT_STEP_RETRIES, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, isRunSettled, } from './workflow-constants.js';
12
12
  import { WorkflowAsyncException, WorkflowCancelledException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotFoundError, WorkflowStepNameNotString, WorkflowSuspendedException, } from './workflow-errors.js';
13
13
  import { resolveWorkflowMeta } from './workflow-meta-resolver.js';
14
14
  import { jobGroupFor, orchestratorQueueName, resolveWorkflowConfig, stepJobOptions, stepWorkerQueueName, } from './workflow-queue-routing.js';
@@ -284,7 +284,7 @@ export class PikkuWorkflowService {
284
284
  * scheduled task at whatever interval suits the workload.
285
285
  */
286
286
  async recoverStalledRuns(options) {
287
- return sweepStalledRuns((before, limit) => this.findStalledRunIds(before, limit), options, this.sweepDeps);
287
+ return sweepStalledRuns((before, limit) => this.findStalledRunIds(before, limit), this.redispatchBackoff, options, this.sweepDeps);
288
288
  }
289
289
  /**
290
290
  * Re-drive steps whose dispatch was lost. Not self-starting — call it from a
@@ -546,6 +546,8 @@ export class PikkuWorkflowService {
546
546
  if (!run) {
547
547
  throw new WorkflowRunNotFoundError(runId);
548
548
  }
549
+ if (isRunSettled(run.status))
550
+ return;
549
551
  const resolved = resolveWorkflowMeta(run.workflow);
550
552
  const workflowMeta = resolved?.meta;
551
553
  const pkgName = resolved?.packageName ?? null;
@@ -3,6 +3,23 @@ export declare const DEFAULT_STEP_RETRIES = 5;
3
3
  export declare const WORKFLOW_END_STATES: ReadonlySet<string>;
4
4
  /** Statuses a run cannot leave at all. */
5
5
  export declare const WORKFLOW_TERMINAL_STATES: ReadonlySet<string>;
6
+ /**
7
+ * True for a run that will never move again, whatever arrives for it.
8
+ *
9
+ * Worth checking before doing anything with an orchestrator message, because
10
+ * such a message is routine rather than exceptional: the queue is
11
+ * at-least-once, the relay re-dispatches on purpose, and a run can settle
12
+ * while a message for it is still in flight. Replaying one is not free —
13
+ * `runWorkflowJob` takes the run lock and re-enters the workflow body, and a
14
+ * body re-entered after its run failed can park on a wait that nothing will
15
+ * ever satisfy, holding the lock and the connection under it until something
16
+ * external gives up. Every leaked advisory lock seen in production traced back
17
+ * to that: a granted lock, an idle session, and a run already `failed`.
18
+ *
19
+ * Note `suspended` is deliberately absent. It ends a run's *current* pass but
20
+ * not the run, which resumes when its approval or signal arrives.
21
+ */
22
+ export declare const isRunSettled: (status: string) => boolean;
6
23
  export declare const WORKFLOW_POLL_MIN_MS = 10;
7
24
  export declare const WORKFLOW_POLL_FACTOR = 1.6;
8
25
  export declare const WORKFLOW_CHILD_POLL_MAX_MS = 500;
@@ -12,6 +12,23 @@ export const WORKFLOW_TERMINAL_STATES = new Set([
12
12
  'failed',
13
13
  'cancelled',
14
14
  ]);
15
+ /**
16
+ * True for a run that will never move again, whatever arrives for it.
17
+ *
18
+ * Worth checking before doing anything with an orchestrator message, because
19
+ * such a message is routine rather than exceptional: the queue is
20
+ * at-least-once, the relay re-dispatches on purpose, and a run can settle
21
+ * while a message for it is still in flight. Replaying one is not free —
22
+ * `runWorkflowJob` takes the run lock and re-enters the workflow body, and a
23
+ * body re-entered after its run failed can park on a wait that nothing will
24
+ * ever satisfy, holding the lock and the connection under it until something
25
+ * external gives up. Every leaked advisory lock seen in production traced back
26
+ * to that: a granted lock, an idle session, and a run already `failed`.
27
+ *
28
+ * Note `suspended` is deliberately absent. It ends a run's *current* pass but
29
+ * not the run, which resumes when its approval or signal arrives.
30
+ */
31
+ export const isRunSettled = (status) => WORKFLOW_TERMINAL_STATES.has(status);
15
32
  export const WORKFLOW_POLL_MIN_MS = 10;
16
33
  export const WORKFLOW_POLL_FACTOR = 1.6;
17
34
  export const WORKFLOW_CHILD_POLL_MAX_MS = 500;
@@ -7,6 +7,15 @@ import type { Logger } from '../../services/logger.js';
7
7
  * owed a job, so holding off a single step while resuming its run would
8
8
  * suppress nothing.
9
9
  *
10
+ * For the same reason one instance is shared by every sweep rather than kept
11
+ * per sweep. The record is of the action, not of the signal that prompted it:
12
+ * a stalled run and an undispatched step are different observations, but both
13
+ * are answered by the one orchestrator message, so a run the relay re-drove a
14
+ * moment ago gains nothing from the stalled sweep re-driving it again. Sharing
15
+ * is what makes the guarantee a message-per-run-per-window instead of one per
16
+ * sweep, and no recovery is lost by it: whichever sweep gets there first
17
+ * performs the identical re-drive, and the cap keeps the delay at 10m.
18
+ *
10
19
  * Losing this on restart costs extra dispatches, never correctness.
11
20
  */
12
21
  export declare class RedispatchBackoff {
@@ -34,8 +43,16 @@ type SweepDeps = {
34
43
  * actually stuck costs an orchestration pass and changes nothing. That
35
44
  * idempotence is what makes an idle-time heuristic safe here; a run that is
36
45
  * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
46
+ *
47
+ * Idempotent is not free, though: a run stays stalled until something clears
48
+ * the reason it stalled, so an unconditional sweep re-queues the same runs on
49
+ * every tick forever. That is how a handful of wedged runs became a queue of
50
+ * thousands that could not drain — each pass added work the previous pass had
51
+ * not finished. The same per-run backoff the relay uses bounds it: a run is
52
+ * re-driven, then held off for a doubling delay, so a sweep costs at most one
53
+ * message per run per window rather than one per run per tick.
37
54
  */
38
- export declare const sweepStalledRuns: (findStalledRunIds: (before: Date, limit: number) => Promise<string[]>, options: {
55
+ export declare const sweepStalledRuns: (findStalledRunIds: (before: Date, limit: number) => Promise<string[]>, backoff: RedispatchBackoff, options: {
39
56
  stalledAfterMs?: number;
40
57
  limit?: number;
41
58
  } | undefined, deps: SweepDeps) => Promise<{
@@ -7,6 +7,15 @@ import { DEFAULT_STALLED_RUN_LIMIT, DEFAULT_STALLED_RUN_MS, DEFAULT_UNDISPATCHED
7
7
  * owed a job, so holding off a single step while resuming its run would
8
8
  * suppress nothing.
9
9
  *
10
+ * For the same reason one instance is shared by every sweep rather than kept
11
+ * per sweep. The record is of the action, not of the signal that prompted it:
12
+ * a stalled run and an undispatched step are different observations, but both
13
+ * are answered by the one orchestrator message, so a run the relay re-drove a
14
+ * moment ago gains nothing from the stalled sweep re-driving it again. Sharing
15
+ * is what makes the guarantee a message-per-run-per-window instead of one per
16
+ * sweep, and no recovery is lost by it: whichever sweep gets there first
17
+ * performs the identical re-drive, and the cap keeps the delay at 10m.
18
+ *
10
19
  * Losing this on restart costs extra dispatches, never correctness.
11
20
  */
12
21
  export class RedispatchBackoff {
@@ -61,10 +70,29 @@ const resumeEach = async (runIds, { resume, logger }, failure) => {
61
70
  * actually stuck costs an orchestration pass and changes nothing. That
62
71
  * idempotence is what makes an idle-time heuristic safe here; a run that is
63
72
  * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
73
+ *
74
+ * Idempotent is not free, though: a run stays stalled until something clears
75
+ * the reason it stalled, so an unconditional sweep re-queues the same runs on
76
+ * every tick forever. That is how a handful of wedged runs became a queue of
77
+ * thousands that could not drain — each pass added work the previous pass had
78
+ * not finished. The same per-run backoff the relay uses bounds it: a run is
79
+ * re-driven, then held off for a doubling delay, so a sweep costs at most one
80
+ * message per run per window rather than one per run per tick.
64
81
  */
65
- export const sweepStalledRuns = async (findStalledRunIds, options, deps) => {
82
+ export const sweepStalledRuns = async (findStalledRunIds, backoff, options, deps) => {
66
83
  const before = new Date(Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS));
67
- const runIds = await findStalledRunIds(before, options?.limit ?? DEFAULT_STALLED_RUN_LIMIT);
84
+ const found = await findStalledRunIds(before, options?.limit ?? DEFAULT_STALLED_RUN_LIMIT);
85
+ const now = Date.now();
86
+ const runIds = [];
87
+ for (const runId of found) {
88
+ if (!backoff.isEligible(runId, now))
89
+ continue;
90
+ // Noted before the resume, not after: a run whose resume throws is exactly
91
+ // the run most likely to still be here next tick, and re-driving it every
92
+ // tick is the amplification this backoff exists to stop.
93
+ backoff.note(runId, now);
94
+ runIds.push(runId);
95
+ }
68
96
  return {
69
97
  resumed: await resumeEach(runIds, deps, (runId, detail) => `Failed to resume stalled workflow run ${runId}: ${detail}`),
70
98
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.88",
3
+ "version": "0.12.89",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -240,7 +240,7 @@ export type PikkuWire<
240
240
  * sets it; services that log fall back to the singleton logger.
241
241
  */
242
242
  logger: Logger
243
- /** Trace ID for distributed tracing — propagated across remote RPC calls via x-trace-id header */
243
+ /** Trace ID for distributed tracing — propagated across remote RPC calls via the x-request-id header */
244
244
  traceId: string
245
245
  functionId: string
246
246
  addonNamespace: string
@@ -900,7 +900,12 @@ describe('wireRemoteAddon dispatch', () => {
900
900
  calls[0]!.init.headers.authorization,
901
901
  'Bearer secret-value-for-REGISTRY_TOKEN'
902
902
  )
903
- assert.equal(calls[0]!.init.headers['x-trace-id'], 'trace-r')
903
+ assert.equal(
904
+ calls[0]!.init.headers['x-request-id'],
905
+ 'trace-r',
906
+ 'a remote RPC must send the trace id under the header the receiving ' +
907
+ 'runner reads, or the trace chain breaks at the hop'
908
+ )
904
909
  } finally {
905
910
  restoreFetch()
906
911
  }
@@ -356,7 +356,7 @@ export class ContextAwareRPCService {
356
356
  headers.authorization = `Bearer ${token}`
357
357
  }
358
358
  if (this.wire.traceId) {
359
- headers['x-trace-id'] = this.wire.traceId
359
+ headers['x-request-id'] = this.wire.traceId
360
360
  }
361
361
 
362
362
  const base = serverUrl.replace(/\/+$/, '')
@@ -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,
@@ -672,6 +673,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
672
673
  }): Promise<{ resumed: string[] }> {
673
674
  return sweepStalledRuns(
674
675
  (before, limit) => this.findStalledRunIds(before, limit),
676
+ this.redispatchBackoff,
675
677
  options,
676
678
  this.sweepDeps
677
679
  )
@@ -1084,6 +1086,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1084
1086
  if (!run) {
1085
1087
  throw new WorkflowRunNotFoundError(runId)
1086
1088
  }
1089
+ if (isRunSettled(run.status)) return
1087
1090
 
1088
1091
  const resolved = resolveWorkflowMeta(run.workflow)
1089
1092
  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 runIds = await findStalledRunIds(
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
+ })