@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 +36 -0
- package/dist/types/core.types.d.ts +1 -1
- package/dist/wirings/rpc/rpc-runner.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +4 -2
- 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/package.json +1 -1
- package/src/types/core.types.ts +1 -1
- package/src/wirings/rpc/rpc-runner.test.ts +6 -1
- package/src/wirings/rpc/rpc-runner.ts +1 -1
- package/src/wirings/workflow/pikku-workflow-service.ts +3 -0
- 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
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-
|
|
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-
|
|
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
|
|
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
package/src/types/core.types.ts
CHANGED
|
@@ -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-
|
|
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(
|
|
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-
|
|
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
|
|
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
|
+
})
|