@zudojs/lifecycle 1.2.2 → 1.3.0
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/README.md +44 -15
- package/dist/index.d.ts +2 -3
- package/dist/index.js +7 -1
- package/dist/lifecycleComponent/index.d.ts +0 -1
- package/dist/lifecycleComponent/index.js +0 -1
- package/dist/lifecycleComponent/lifecycleComponent.type.d.ts +27 -8
- package/dist/lifecycleComponent/lifecycleComponent.type.js +0 -1
- package/dist/lifecycleContext/index.d.ts +0 -1
- package/dist/lifecycleContext/index.js +0 -1
- package/dist/lifecycleContext/lifecycleContext.type.d.ts +0 -1
- package/dist/lifecycleContext/lifecycleContext.type.js +0 -1
- package/dist/lifecycleEvents/index.d.ts +0 -1
- package/dist/lifecycleEvents/index.js +0 -1
- package/dist/lifecycleEvents/lifecycleEvents.core.d.ts +20 -5
- package/dist/lifecycleEvents/lifecycleEvents.core.js +0 -1
- package/dist/lifecycleExecutor/index.d.ts +3 -3
- package/dist/lifecycleExecutor/index.js +2 -2
- package/dist/lifecycleExecutor/lifecycleExecutor.abandoned.d.ts +31 -0
- package/dist/lifecycleExecutor/lifecycleExecutor.abandoned.js +75 -0
- package/dist/lifecycleExecutor/lifecycleExecutor.core.d.ts +18 -24
- package/dist/lifecycleExecutor/lifecycleExecutor.core.js +64 -105
- package/dist/lifecycleExecutor/lifecycleExecutor.retry.d.ts +30 -0
- package/dist/lifecycleExecutor/lifecycleExecutor.retry.js +66 -0
- package/dist/lifecycleExecutor/lifecycleExecutor.type.d.ts +50 -0
- package/dist/lifecycleExecutor/lifecycleExecutor.type.js +6 -0
- package/dist/lifecycleInternal/asyncUtils.core.d.ts +0 -1
- package/dist/lifecycleInternal/asyncUtils.core.js +0 -1
- package/dist/lifecycleInternal/dependencyGraph.core.d.ts +34 -7
- package/dist/lifecycleInternal/dependencyGraph.core.js +55 -38
- package/dist/lifecycleInternal/dependencyGraph.cycle.d.ts +20 -0
- package/dist/lifecycleInternal/dependencyGraph.cycle.js +57 -0
- package/dist/lifecycleInternal/index.d.ts +1 -1
- package/dist/lifecycleInternal/index.js +0 -1
- package/dist/lifecycleInternal/timeoutBudget.core.d.ts +0 -1
- package/dist/lifecycleInternal/timeoutBudget.core.js +0 -1
- package/dist/lifecycleInternal/topologicalSort.core.d.ts +12 -2
- package/dist/lifecycleInternal/topologicalSort.core.js +41 -22
- package/dist/lifecycleManager/index.d.ts +0 -1
- package/dist/lifecycleManager/index.js +0 -1
- package/dist/lifecycleManager/lifecycleManager.context.d.ts +15 -7
- package/dist/lifecycleManager/lifecycleManager.context.js +0 -1
- package/dist/lifecycleManager/lifecycleManager.core.d.ts +12 -2
- package/dist/lifecycleManager/lifecycleManager.core.js +30 -3
- package/dist/lifecycleManager/lifecycleManager.deadline.d.ts +29 -0
- package/dist/lifecycleManager/lifecycleManager.deadline.js +79 -0
- package/dist/lifecycleManager/lifecycleManager.shutdown.d.ts +0 -1
- package/dist/lifecycleManager/lifecycleManager.shutdown.js +60 -69
- package/dist/lifecycleManager/lifecycleManager.startup.d.ts +0 -1
- package/dist/lifecycleManager/lifecycleManager.startup.js +12 -4
- package/dist/lifecyclePhase/index.d.ts +0 -1
- package/dist/lifecyclePhase/index.js +0 -1
- package/dist/lifecyclePhase/lifecyclePhase.core.d.ts +0 -1
- package/dist/lifecyclePhase/lifecyclePhase.core.js +0 -1
- package/dist/lifecyclePlan/index.d.ts +0 -1
- package/dist/lifecyclePlan/index.js +0 -1
- package/dist/lifecyclePlan/lifecyclePlan.core.d.ts +0 -1
- package/dist/lifecyclePlan/lifecyclePlan.core.js +0 -2
- package/dist/lifecycleRegistry/index.d.ts +0 -1
- package/dist/lifecycleRegistry/index.js +0 -1
- package/dist/lifecycleRegistry/lifecycleRegistry.core.d.ts +0 -1
- package/dist/lifecycleRegistry/lifecycleRegistry.core.js +3 -2
- package/dist/lifecycleSignal/index.d.ts +0 -1
- package/dist/lifecycleSignal/index.js +0 -1
- package/dist/lifecycleSignal/lifecycleSignal.handler.d.ts +0 -1
- package/dist/lifecycleSignal/lifecycleSignal.handler.js +0 -1
- package/dist/lifecycleState/index.d.ts +0 -1
- package/dist/lifecycleState/index.js +0 -1
- package/dist/lifecycleState/lifecycleState.machine.d.ts +0 -1
- package/dist/lifecycleState/lifecycleState.machine.js +0 -1
- package/package.json +3 -3
|
@@ -6,93 +6,90 @@
|
|
|
6
6
|
import { withTimeout, withConcurrency } from "../lifecycleInternal/index.js";
|
|
7
7
|
import { getComponentMethod } from "../lifecyclePhase/index.js";
|
|
8
8
|
import { LifecycleComponentError, LifecycleTimeoutError, } from "@zudojs/errors";
|
|
9
|
+
import { AbandonedHooks, isShutdownPhase, isStartupPhase, } from "./lifecycleExecutor.abandoned.js";
|
|
10
|
+
import { calculateDelay, groupByPriority, sleep } from "./lifecycleExecutor.retry.js";
|
|
9
11
|
/**
|
|
10
12
|
* Executes lifecycle component hooks with timeout, retry, and concurrency support.
|
|
13
|
+
*
|
|
14
|
+
* Every invocation gets its own AbortSignal, derived from the run
|
|
15
|
+
* signal and aborted when the component's `timeout` elapses, so a hook
|
|
16
|
+
* that honours `context.signal` unwinds promptly. One that ignores it
|
|
17
|
+
* keeps running and is tracked per component: before that component's
|
|
18
|
+
* `stop()`/`dispose()` the executor waits for it — a drain that overran
|
|
19
|
+
* its stop timeout is waited for until the global deadline, a startup
|
|
20
|
+
* hook that ignored its timeout only for one more `timeout` — so hooks
|
|
21
|
+
* of one component never overlap.
|
|
11
22
|
*/
|
|
12
23
|
export class LifecycleExecutor {
|
|
13
|
-
|
|
14
|
-
|
|
24
|
+
abandoned = new AbandonedHooks();
|
|
25
|
+
onRetry;
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
this.onRetry = options.onRetry;
|
|
28
|
+
}
|
|
15
29
|
/**
|
|
16
|
-
* Resolves once every hook abandoned by a timeout has settled
|
|
17
|
-
*
|
|
18
|
-
* Shutdown waits on this before stopping components, so `stop()`
|
|
19
|
-
* never overlaps a `start()` that is still running.
|
|
30
|
+
* Resolves once every hook abandoned by a timeout has settled — those
|
|
31
|
+
* of one component when `id` is given, otherwise all of them.
|
|
20
32
|
*/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
await Promise.allSettled([...this.abandoned]);
|
|
24
|
-
}
|
|
33
|
+
settleAbandoned(id) {
|
|
34
|
+
return this.abandoned.settle(id);
|
|
25
35
|
}
|
|
26
36
|
/**
|
|
27
37
|
* Executes a single component hook.
|
|
28
38
|
*/
|
|
29
39
|
async execute(registration, phase, context) {
|
|
30
|
-
const
|
|
31
|
-
const hook = registration.component[
|
|
40
|
+
const { id } = registration;
|
|
41
|
+
const hook = registration.component[getComponentMethod(phase)];
|
|
32
42
|
if (typeof hook !== "function") {
|
|
33
|
-
return {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
success: true,
|
|
38
|
-
};
|
|
43
|
+
return { id, phase, duration: 0, success: true };
|
|
44
|
+
}
|
|
45
|
+
if (isShutdownPhase(phase)) {
|
|
46
|
+
await this.abandoned.settle(id, isShutdownPhase, context.signal);
|
|
39
47
|
}
|
|
40
48
|
const startTime = Date.now();
|
|
49
|
+
const maxAttempts = 1 + (registration.retry.attempts ?? 0);
|
|
41
50
|
let lastError;
|
|
42
|
-
|
|
43
|
-
const maxAttempts = 1 + (retryConfig.attempts ?? 0);
|
|
51
|
+
let timedOut = false;
|
|
44
52
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
45
|
-
// A hook must not be retried (or even started) once the run has
|
|
46
|
-
// been cancelled — the context signal is aborted by the shutdown
|
|
47
|
-
// deadline and by startup rollback.
|
|
48
53
|
if (context.signal.aborted) {
|
|
49
|
-
lastError ??= new LifecycleComponentError(
|
|
54
|
+
lastError ??= new LifecycleComponentError(id, phase, context.signal.reason);
|
|
50
55
|
break;
|
|
51
56
|
}
|
|
57
|
+
const controller = new AbortController();
|
|
52
58
|
let invocation;
|
|
53
59
|
try {
|
|
54
60
|
await withTimeout(() => {
|
|
55
|
-
invocation = (
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
invocation = this.invoke(registration, phase, hook, {
|
|
62
|
+
...context,
|
|
63
|
+
signal: AbortSignal.any([context.signal, controller.signal]),
|
|
64
|
+
});
|
|
58
65
|
return invocation;
|
|
59
|
-
}, registration.timeout,
|
|
60
|
-
return {
|
|
61
|
-
id: registration.id,
|
|
62
|
-
phase,
|
|
63
|
-
duration: Date.now() - startTime,
|
|
64
|
-
success: true,
|
|
65
|
-
};
|
|
66
|
+
}, registration.timeout, id, phase);
|
|
67
|
+
return { id, phase, duration: Date.now() - startTime, success: true };
|
|
66
68
|
}
|
|
67
69
|
catch (error) {
|
|
68
70
|
lastError = error;
|
|
69
|
-
// A timed-out hook is still running; withTimeout cannot cancel
|
|
70
|
-
// it. Retrying would run the same start() concurrently (three
|
|
71
|
-
// listen() calls on one port), so a timeout is final and the
|
|
72
|
-
// abandoned invocation is tracked for shutdown to wait on.
|
|
73
71
|
if (error instanceof LifecycleTimeoutError && invocation) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
timedOut = true;
|
|
73
|
+
controller.abort(error);
|
|
74
|
+
this.abandoned.track(id, phase, invocation);
|
|
77
75
|
break;
|
|
78
76
|
}
|
|
79
77
|
if (attempt < maxAttempts - 1) {
|
|
80
|
-
const delay = calculateDelay(
|
|
78
|
+
const delay = calculateDelay(registration.retry, attempt);
|
|
79
|
+
this.notifyRetry({ id, phase, attempt: attempt + 1, delay, error });
|
|
81
80
|
await sleep(delay, context.signal);
|
|
82
81
|
}
|
|
83
82
|
}
|
|
84
83
|
}
|
|
85
84
|
return {
|
|
86
|
-
id
|
|
85
|
+
id,
|
|
87
86
|
phase,
|
|
88
87
|
duration: Date.now() - startTime,
|
|
89
|
-
// LifecycleComponentError was imported but never constructed, so
|
|
90
|
-
// callers received a bare hook error with no indication of which
|
|
91
|
-
// component or phase produced it.
|
|
92
88
|
error: lastError instanceof LifecycleComponentError
|
|
93
89
|
? lastError
|
|
94
|
-
: new LifecycleComponentError(
|
|
90
|
+
: new LifecycleComponentError(id, phase, lastError),
|
|
95
91
|
success: false,
|
|
92
|
+
...(timedOut && { timedOut: true }),
|
|
96
93
|
};
|
|
97
94
|
}
|
|
98
95
|
/**
|
|
@@ -101,75 +98,37 @@ export class LifecycleExecutor {
|
|
|
101
98
|
* The stage arrives already ordered by priority (descending for
|
|
102
99
|
* startup, ascending for shutdown). Components sharing a priority run
|
|
103
100
|
* together, limited by `concurrency`; the next priority group only
|
|
104
|
-
* begins once the previous one has settled.
|
|
105
|
-
* concurrently made `priority` observable only at `concurrency: 1`,
|
|
106
|
-
* so a `priority: 100` component documented as starting first lost
|
|
107
|
-
* the race to any sibling with a faster hook.
|
|
101
|
+
* begins once the previous one has settled.
|
|
108
102
|
*/
|
|
109
103
|
async executeStage(registrations, phase, context, concurrency) {
|
|
110
104
|
const results = [];
|
|
111
105
|
for (const batch of groupByPriority(registrations)) {
|
|
112
106
|
await withConcurrency(batch, concurrency, async (reg) => {
|
|
113
|
-
|
|
114
|
-
results.push(result);
|
|
107
|
+
results.push(await this.execute(reg, phase, context));
|
|
115
108
|
});
|
|
116
109
|
}
|
|
117
110
|
return results;
|
|
118
111
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
let current;
|
|
131
|
-
let currentPriority;
|
|
132
|
-
for (const reg of registrations) {
|
|
133
|
-
if (current === undefined || reg.priority !== currentPriority) {
|
|
134
|
-
current = [];
|
|
135
|
-
currentPriority = reg.priority;
|
|
136
|
-
batches.push(current);
|
|
112
|
+
async invoke(registration, phase, hook, context) {
|
|
113
|
+
if (isShutdownPhase(phase)) {
|
|
114
|
+
await this.abandoned.settle(registration.id, isStartupPhase, context.signal);
|
|
115
|
+
// The wait was cut short by the component timeout (or the run
|
|
116
|
+
// deadline): the earlier hook is still running, so this one must
|
|
117
|
+
// not start on top of it.
|
|
118
|
+
if (context.signal.aborted) {
|
|
119
|
+
throw context.signal.reason instanceof Error
|
|
120
|
+
? context.signal.reason
|
|
121
|
+
: new LifecycleComponentError(registration.id, phase, context.signal.reason);
|
|
122
|
+
}
|
|
137
123
|
}
|
|
138
|
-
|
|
124
|
+
await hook.call(registration.component, context);
|
|
139
125
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
function calculateDelay(config, attempt) {
|
|
144
|
-
const base = config.delay ?? 500;
|
|
145
|
-
const max = config.maxDelay ?? 10_000;
|
|
146
|
-
const backoff = config.backoff ?? "exponential";
|
|
147
|
-
if (backoff === "exponential") {
|
|
148
|
-
return Math.min(base * 2 ** attempt, max);
|
|
149
|
-
}
|
|
150
|
-
return base;
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* Sleeps for the given duration, waking early when the run is aborted.
|
|
154
|
-
*
|
|
155
|
-
* An unconditional timer would keep the process alive for a full retry
|
|
156
|
-
* backoff after shutdown had already been requested.
|
|
157
|
-
*/
|
|
158
|
-
function sleep(ms, signal) {
|
|
159
|
-
return new Promise((resolve) => {
|
|
160
|
-
if (signal.aborted) {
|
|
161
|
-
resolve();
|
|
162
|
-
return;
|
|
126
|
+
notifyRetry(notice) {
|
|
127
|
+
try {
|
|
128
|
+
this.onRetry?.(notice);
|
|
163
129
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
resolve();
|
|
167
|
-
}, ms);
|
|
168
|
-
function onAbort() {
|
|
169
|
-
clearTimeout(timer);
|
|
170
|
-
resolve();
|
|
130
|
+
catch {
|
|
131
|
+
// A listener must not break the retry loop.
|
|
171
132
|
}
|
|
172
|
-
|
|
173
|
-
});
|
|
133
|
+
}
|
|
174
134
|
}
|
|
175
|
-
//# sourceMappingURL=lifecycleExecutor.core.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/lifecycle/executor/retry
|
|
3
|
+
*
|
|
4
|
+
* Retry delay, cancellable sleep and priority batching for the executor.
|
|
5
|
+
*/
|
|
6
|
+
import type { LifecycleRegistration, LifecycleRetryOptions } from "../lifecycleComponent/lifecycleComponent.type.js";
|
|
7
|
+
/**
|
|
8
|
+
* Calculates the delay before retry number `attempt + 1`.
|
|
9
|
+
*
|
|
10
|
+
* `attempt` is 0 for the first retry. Exponential backoff doubles the
|
|
11
|
+
* base delay per retry and is capped at `maxDelay`; fixed backoff
|
|
12
|
+
* always waits `delay`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function calculateDelay(config: LifecycleRetryOptions, attempt: number): number;
|
|
15
|
+
/**
|
|
16
|
+
* Sleeps for the given duration, waking early when the run is aborted.
|
|
17
|
+
*
|
|
18
|
+
* An unconditional timer would keep the process alive for a full retry
|
|
19
|
+
* backoff after shutdown had already been requested.
|
|
20
|
+
*/
|
|
21
|
+
export declare function sleep(ms: number, signal: AbortSignal): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Splits an already-ordered stage into runs of equal priority.
|
|
24
|
+
*
|
|
25
|
+
* Consecutive grouping preserves whatever order the execution plan
|
|
26
|
+
* produced, so a caller that does not care about priority (every
|
|
27
|
+
* component at the default 0) still gets a single fully concurrent
|
|
28
|
+
* batch.
|
|
29
|
+
*/
|
|
30
|
+
export declare function groupByPriority(registrations: readonly LifecycleRegistration[]): readonly (readonly LifecycleRegistration[])[];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/lifecycle/executor/retry
|
|
3
|
+
*
|
|
4
|
+
* Retry delay, cancellable sleep and priority batching for the executor.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Calculates the delay before retry number `attempt + 1`.
|
|
8
|
+
*
|
|
9
|
+
* `attempt` is 0 for the first retry. Exponential backoff doubles the
|
|
10
|
+
* base delay per retry and is capped at `maxDelay`; fixed backoff
|
|
11
|
+
* always waits `delay`.
|
|
12
|
+
*/
|
|
13
|
+
export function calculateDelay(config, attempt) {
|
|
14
|
+
const base = config.delay ?? 500;
|
|
15
|
+
const max = config.maxDelay ?? 10_000;
|
|
16
|
+
const backoff = config.backoff ?? "exponential";
|
|
17
|
+
if (backoff === "exponential") {
|
|
18
|
+
return Math.min(base * 2 ** attempt, max);
|
|
19
|
+
}
|
|
20
|
+
return base;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Sleeps for the given duration, waking early when the run is aborted.
|
|
24
|
+
*
|
|
25
|
+
* An unconditional timer would keep the process alive for a full retry
|
|
26
|
+
* backoff after shutdown had already been requested.
|
|
27
|
+
*/
|
|
28
|
+
export function sleep(ms, signal) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
if (signal.aborted) {
|
|
31
|
+
resolve();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const timer = setTimeout(() => {
|
|
35
|
+
signal.removeEventListener("abort", onAbort);
|
|
36
|
+
resolve();
|
|
37
|
+
}, ms);
|
|
38
|
+
function onAbort() {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
resolve();
|
|
41
|
+
}
|
|
42
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Splits an already-ordered stage into runs of equal priority.
|
|
47
|
+
*
|
|
48
|
+
* Consecutive grouping preserves whatever order the execution plan
|
|
49
|
+
* produced, so a caller that does not care about priority (every
|
|
50
|
+
* component at the default 0) still gets a single fully concurrent
|
|
51
|
+
* batch.
|
|
52
|
+
*/
|
|
53
|
+
export function groupByPriority(registrations) {
|
|
54
|
+
const batches = [];
|
|
55
|
+
let current;
|
|
56
|
+
let currentPriority;
|
|
57
|
+
for (const reg of registrations) {
|
|
58
|
+
if (current === undefined || reg.priority !== currentPriority) {
|
|
59
|
+
current = [];
|
|
60
|
+
currentPriority = reg.priority;
|
|
61
|
+
batches.push(current);
|
|
62
|
+
}
|
|
63
|
+
current.push(reg);
|
|
64
|
+
}
|
|
65
|
+
return batches;
|
|
66
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/lifecycle/executor/types
|
|
3
|
+
*
|
|
4
|
+
* Result and option types for the lifecycle executor.
|
|
5
|
+
*/
|
|
6
|
+
import type { LifecyclePhase } from "@zudojs/constants";
|
|
7
|
+
/** Result of executing a component hook. */
|
|
8
|
+
export interface ExecutionResult {
|
|
9
|
+
/** Component ID. */
|
|
10
|
+
readonly id: string;
|
|
11
|
+
/** The phase that was executed. */
|
|
12
|
+
readonly phase: LifecyclePhase;
|
|
13
|
+
/** Duration in ms. */
|
|
14
|
+
readonly duration: number;
|
|
15
|
+
/** Error if the hook failed. */
|
|
16
|
+
readonly error?: unknown;
|
|
17
|
+
/** Whether the operation succeeded. */
|
|
18
|
+
readonly success: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* True when the operation exceeded the component's `timeout`.
|
|
21
|
+
*
|
|
22
|
+
* For a startup phase that is the hook itself; for `stop`/`dispose`
|
|
23
|
+
* it also covers the wait for the component's own earlier hook that
|
|
24
|
+
* is still running after its timeout. A timed-out hook keeps running
|
|
25
|
+
* (it cannot be cancelled), is never retried, and is tracked so the
|
|
26
|
+
* component's next hook does not overlap it.
|
|
27
|
+
*/
|
|
28
|
+
readonly timedOut?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** Details of a retry the executor is about to perform. */
|
|
31
|
+
export interface LifecycleRetryNotice {
|
|
32
|
+
/** Component ID. */
|
|
33
|
+
readonly id: string;
|
|
34
|
+
/** The phase being retried. */
|
|
35
|
+
readonly phase: LifecyclePhase;
|
|
36
|
+
/** 1-based number of the retry about to run (1 = first retry). */
|
|
37
|
+
readonly attempt: number;
|
|
38
|
+
/** Milliseconds the executor waits before that retry. */
|
|
39
|
+
readonly delay: number;
|
|
40
|
+
/** The error that triggered the retry. */
|
|
41
|
+
readonly error: unknown;
|
|
42
|
+
}
|
|
43
|
+
/** Options for creating a lifecycle executor. */
|
|
44
|
+
export interface LifecycleExecutorOptions {
|
|
45
|
+
/**
|
|
46
|
+
* Called before each retry delay. The lifecycle manager forwards it
|
|
47
|
+
* as the `component:retrying` event. Exceptions are swallowed.
|
|
48
|
+
*/
|
|
49
|
+
readonly onRetry?: (notice: LifecycleRetryNotice) => void;
|
|
50
|
+
}
|
|
@@ -20,4 +20,3 @@ export declare function withAbort<T>(fn: (signal: AbortSignal) => Promise<T>, si
|
|
|
20
20
|
* Executes async operations with a concurrency limit.
|
|
21
21
|
*/
|
|
22
22
|
export declare function withConcurrency<T>(items: readonly T[], concurrency: number, fn: (item: T, index: number) => Promise<void>): Promise<void>;
|
|
23
|
-
//# sourceMappingURL=asyncUtils.core.d.ts.map
|
|
@@ -3,27 +3,54 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Directed acyclic graph for component dependency tracking.
|
|
5
5
|
*/
|
|
6
|
+
/** Options for {@link DependencyGraph.validate}. */
|
|
7
|
+
export interface DependencyGraphValidationOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Also reject nodes that only appear as an edge endpoint and were
|
|
10
|
+
* never passed to `addNode`. Off by default, because `addEdge` has
|
|
11
|
+
* always created its endpoints; turn it on when the node set is
|
|
12
|
+
* known up front (the registry does its own equivalent check on
|
|
13
|
+
* `dependsOn` and names the missing registration).
|
|
14
|
+
*/
|
|
15
|
+
readonly requireDeclared?: boolean;
|
|
16
|
+
}
|
|
6
17
|
/**
|
|
7
18
|
* A directed acyclic graph of component dependencies.
|
|
19
|
+
*
|
|
20
|
+
* `addEdge` creates any endpoint it has not seen, so an undeclared
|
|
21
|
+
* node (a typo in a dependency id, say) sorts as a leaf in the first
|
|
22
|
+
* stage. The graph remembers which nodes were declared with `addNode`:
|
|
23
|
+
* inspect them with `getUndeclaredNodes()` or reject them with
|
|
24
|
+
* `validate({ requireDeclared: true })`.
|
|
8
25
|
*/
|
|
9
26
|
export declare class DependencyGraph {
|
|
10
27
|
private readonly _edges;
|
|
11
28
|
private readonly _reverseEdges;
|
|
12
|
-
|
|
29
|
+
private readonly _declared;
|
|
30
|
+
/** Declares a node in the graph. */
|
|
13
31
|
addNode(id: string): void;
|
|
14
32
|
/** Adds a directed edge: from depends on to. */
|
|
15
33
|
addEdge(from: string, to: string): void;
|
|
16
|
-
/** Returns all nodes. */
|
|
34
|
+
/** Returns all nodes, declared or not, in insertion order. */
|
|
17
35
|
getNodes(): readonly string[];
|
|
18
36
|
/** Returns the nodes that the given node depends on. */
|
|
19
37
|
getDependencies(id: string): readonly string[];
|
|
20
38
|
/** Returns the nodes that depend on the given node. */
|
|
21
39
|
getDependents(id: string): readonly string[];
|
|
40
|
+
/** Returns the nodes referenced by an edge but never passed to `addNode`. */
|
|
41
|
+
getUndeclaredNodes(): readonly string[];
|
|
42
|
+
/**
|
|
43
|
+
* Validates that the graph has no circular dependencies, throwing
|
|
44
|
+
* LifecycleDependencyError with the cycle path if one is found. With
|
|
45
|
+
* `requireDeclared` it first throws LifecycleError
|
|
46
|
+
* (LIFECYCLE_DEPENDENCY) for an edge to a node that was never added.
|
|
47
|
+
*/
|
|
48
|
+
validate(options?: DependencyGraphValidationOptions): void;
|
|
22
49
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
50
|
+
* Returns one dependency cycle as a closed path (first id repeated
|
|
51
|
+
* last), or undefined when the graph is acyclic.
|
|
25
52
|
*/
|
|
26
|
-
|
|
27
|
-
private
|
|
53
|
+
findCycle(): readonly string[] | undefined;
|
|
54
|
+
private ensureNode;
|
|
55
|
+
private assertDeclared;
|
|
28
56
|
}
|
|
29
|
-
//# sourceMappingURL=dependencyGraph.core.d.ts.map
|
|
@@ -3,30 +3,34 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Directed acyclic graph for component dependency tracking.
|
|
5
5
|
*/
|
|
6
|
-
import { LifecycleDependencyError } from "@zudojs/errors";
|
|
6
|
+
import { ErrorCode, LifecycleDependencyError, LifecycleError, } from "@zudojs/errors";
|
|
7
|
+
import { findDependencyCycle } from "./dependencyGraph.cycle.js";
|
|
7
8
|
/**
|
|
8
9
|
* A directed acyclic graph of component dependencies.
|
|
10
|
+
*
|
|
11
|
+
* `addEdge` creates any endpoint it has not seen, so an undeclared
|
|
12
|
+
* node (a typo in a dependency id, say) sorts as a leaf in the first
|
|
13
|
+
* stage. The graph remembers which nodes were declared with `addNode`:
|
|
14
|
+
* inspect them with `getUndeclaredNodes()` or reject them with
|
|
15
|
+
* `validate({ requireDeclared: true })`.
|
|
9
16
|
*/
|
|
10
17
|
export class DependencyGraph {
|
|
11
18
|
_edges = new Map();
|
|
12
19
|
_reverseEdges = new Map();
|
|
13
|
-
|
|
20
|
+
_declared = new Set();
|
|
21
|
+
/** Declares a node in the graph. */
|
|
14
22
|
addNode(id) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
if (!this._reverseEdges.has(id)) {
|
|
19
|
-
this._reverseEdges.set(id, new Set());
|
|
20
|
-
}
|
|
23
|
+
this._declared.add(id);
|
|
24
|
+
this.ensureNode(id);
|
|
21
25
|
}
|
|
22
26
|
/** Adds a directed edge: from depends on to. */
|
|
23
27
|
addEdge(from, to) {
|
|
24
|
-
this.
|
|
25
|
-
this.
|
|
28
|
+
this.ensureNode(from);
|
|
29
|
+
this.ensureNode(to);
|
|
26
30
|
this._edges.get(from).add(to);
|
|
27
31
|
this._reverseEdges.get(to).add(from);
|
|
28
32
|
}
|
|
29
|
-
/** Returns all nodes. */
|
|
33
|
+
/** Returns all nodes, declared or not, in insertion order. */
|
|
30
34
|
getNodes() {
|
|
31
35
|
return [...this._edges.keys()];
|
|
32
36
|
}
|
|
@@ -38,37 +42,50 @@ export class DependencyGraph {
|
|
|
38
42
|
getDependents(id) {
|
|
39
43
|
return [...(this._reverseEdges.get(id) ?? [])];
|
|
40
44
|
}
|
|
45
|
+
/** Returns the nodes referenced by an edge but never passed to `addNode`. */
|
|
46
|
+
getUndeclaredNodes() {
|
|
47
|
+
return this.getNodes().filter((id) => !this._declared.has(id));
|
|
48
|
+
}
|
|
41
49
|
/**
|
|
42
|
-
* Validates that the graph has no circular dependencies
|
|
43
|
-
*
|
|
50
|
+
* Validates that the graph has no circular dependencies, throwing
|
|
51
|
+
* LifecycleDependencyError with the cycle path if one is found. With
|
|
52
|
+
* `requireDeclared` it first throws LifecycleError
|
|
53
|
+
* (LIFECYCLE_DEPENDENCY) for an edge to a node that was never added.
|
|
44
54
|
*/
|
|
45
|
-
validate() {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
55
|
+
validate(options = {}) {
|
|
56
|
+
if (options.requireDeclared === true) {
|
|
57
|
+
this.assertDeclared();
|
|
58
|
+
}
|
|
59
|
+
const cycle = this.findCycle();
|
|
60
|
+
if (cycle !== undefined) {
|
|
61
|
+
throw new LifecycleDependencyError(cycle);
|
|
53
62
|
}
|
|
54
63
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
if (!visited.has(dep)) {
|
|
67
|
-
this._detectCycle(dep, visited, inStack, path);
|
|
68
|
-
}
|
|
64
|
+
/**
|
|
65
|
+
* Returns one dependency cycle as a closed path (first id repeated
|
|
66
|
+
* last), or undefined when the graph is acyclic.
|
|
67
|
+
*/
|
|
68
|
+
findCycle() {
|
|
69
|
+
return findDependencyCycle(this);
|
|
70
|
+
}
|
|
71
|
+
ensureNode(id) {
|
|
72
|
+
if (!this._edges.has(id)) {
|
|
73
|
+
this._edges.set(id, new Set());
|
|
69
74
|
}
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
if (!this._reverseEdges.has(id)) {
|
|
76
|
+
this._reverseEdges.set(id, new Set());
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
assertDeclared() {
|
|
80
|
+
const undeclared = this.getUndeclaredNodes();
|
|
81
|
+
const missing = undeclared[0];
|
|
82
|
+
if (missing === undefined)
|
|
83
|
+
return;
|
|
84
|
+
const dependent = this.getDependents(missing)[0];
|
|
85
|
+
throw new LifecycleError(`Component "${dependent ?? missing}" depends on "${missing}" which was never added to the graph.`, {
|
|
86
|
+
code: ErrorCode.LIFECYCLE_DEPENDENCY,
|
|
87
|
+
componentId: dependent,
|
|
88
|
+
metadata: { dependency: missing, undeclared },
|
|
89
|
+
});
|
|
72
90
|
}
|
|
73
91
|
}
|
|
74
|
-
//# sourceMappingURL=dependencyGraph.core.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/lifecycle/internal/dependency-graph/cycle
|
|
3
|
+
*
|
|
4
|
+
* Iterative cycle search over a dependency graph.
|
|
5
|
+
*/
|
|
6
|
+
/** The part of a graph the search needs. */
|
|
7
|
+
export interface CycleSearchGraph {
|
|
8
|
+
getNodes(): readonly string[];
|
|
9
|
+
getDependencies(id: string): readonly string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Returns one dependency cycle as a closed path (first id repeated
|
|
13
|
+
* last), or undefined when the graph is acyclic.
|
|
14
|
+
*
|
|
15
|
+
* The search is iterative so a very deep graph cannot overflow the
|
|
16
|
+
* call stack, and it reports the actual loop: the topological sort
|
|
17
|
+
* used to list every node that was still blocked, which for one
|
|
18
|
+
* three-node loop meant naming the whole application.
|
|
19
|
+
*/
|
|
20
|
+
export declare function findDependencyCycle(graph: CycleSearchGraph): readonly string[] | undefined;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/lifecycle/internal/dependency-graph/cycle
|
|
3
|
+
*
|
|
4
|
+
* Iterative cycle search over a dependency graph.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Returns one dependency cycle as a closed path (first id repeated
|
|
8
|
+
* last), or undefined when the graph is acyclic.
|
|
9
|
+
*
|
|
10
|
+
* The search is iterative so a very deep graph cannot overflow the
|
|
11
|
+
* call stack, and it reports the actual loop: the topological sort
|
|
12
|
+
* used to list every node that was still blocked, which for one
|
|
13
|
+
* three-node loop meant naming the whole application.
|
|
14
|
+
*/
|
|
15
|
+
export function findDependencyCycle(graph) {
|
|
16
|
+
const visited = new Set();
|
|
17
|
+
for (const start of graph.getNodes()) {
|
|
18
|
+
if (visited.has(start))
|
|
19
|
+
continue;
|
|
20
|
+
const cycle = findCycleFrom(graph, start, visited);
|
|
21
|
+
if (cycle !== undefined)
|
|
22
|
+
return cycle;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
function findCycleFrom(graph, start, visited) {
|
|
27
|
+
const stack = [
|
|
28
|
+
{ id: start, dependencies: graph.getDependencies(start), next: 0 },
|
|
29
|
+
];
|
|
30
|
+
const inPath = new Set([start]);
|
|
31
|
+
const path = [start];
|
|
32
|
+
while (stack.length > 0) {
|
|
33
|
+
const frame = stack[stack.length - 1];
|
|
34
|
+
if (frame.next >= frame.dependencies.length) {
|
|
35
|
+
stack.pop();
|
|
36
|
+
path.pop();
|
|
37
|
+
inPath.delete(frame.id);
|
|
38
|
+
visited.add(frame.id);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const dependency = frame.dependencies[frame.next];
|
|
42
|
+
frame.next += 1;
|
|
43
|
+
if (inPath.has(dependency)) {
|
|
44
|
+
return Object.freeze([...path.slice(path.indexOf(dependency)), dependency]);
|
|
45
|
+
}
|
|
46
|
+
if (visited.has(dependency))
|
|
47
|
+
continue;
|
|
48
|
+
stack.push({
|
|
49
|
+
id: dependency,
|
|
50
|
+
dependencies: graph.getDependencies(dependency),
|
|
51
|
+
next: 0,
|
|
52
|
+
});
|
|
53
|
+
inPath.add(dependency);
|
|
54
|
+
path.push(dependency);
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|