@zudojs/lifecycle 1.1.0 → 1.2.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 +27 -2
- package/dist/lifecycleComponent/lifecycleComponent.type.d.ts +12 -2
- package/dist/lifecycleExecutor/lifecycleExecutor.core.d.ts +18 -1
- package/dist/lifecycleExecutor/lifecycleExecutor.core.js +67 -11
- package/dist/lifecycleInternal/asyncUtils.core.d.ts +4 -0
- package/dist/lifecycleInternal/asyncUtils.core.js +19 -4
- package/dist/lifecycleInternal/index.d.ts +1 -0
- package/dist/lifecycleInternal/index.js +1 -0
- package/dist/lifecycleInternal/timeoutBudget.core.d.ts +30 -0
- package/dist/lifecycleInternal/timeoutBudget.core.js +38 -0
- package/dist/lifecycleInternal/topologicalSort.core.d.ts +7 -0
- package/dist/lifecycleInternal/topologicalSort.core.js +8 -1
- package/dist/lifecycleManager/lifecycleManager.core.d.ts +12 -2
- package/dist/lifecycleManager/lifecycleManager.core.js +37 -12
- package/dist/lifecycleManager/lifecycleManager.shutdown.js +20 -1
- package/dist/lifecycleRegistry/lifecycleRegistry.core.js +15 -4
- package/dist/lifecycleSignal/lifecycleSignal.handler.d.ts +9 -0
- package/dist/lifecycleSignal/lifecycleSignal.handler.js +9 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
Application and component lifecycle orchestration with a state machine,
|
|
4
4
|
dependency ordering, graceful shutdown, rollback, and signal handling.
|
|
5
5
|
|
|
6
|
+
<!-- zudo-docs:start -->
|
|
7
|
+
|
|
8
|
+
**Documentation:** [zudojs.oyinlola.site/docs/packages-lifecycle](https://zudojs.oyinlola.site/docs/packages-lifecycle) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-lifecycle.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
|
|
9
|
+
|
|
10
|
+
<!-- zudo-docs:end -->
|
|
11
|
+
|
|
6
12
|
## Installation
|
|
7
13
|
|
|
8
14
|
```bash
|
|
@@ -109,8 +115,8 @@ lifecycle.
|
|
|
109
115
|
```typescript
|
|
110
116
|
createLifecycleManager({
|
|
111
117
|
concurrency: 10, // parallel component operations per stage
|
|
112
|
-
shutdownTimeout: 30_000, // global shutdown deadline (ms)
|
|
113
|
-
handleSignals: true, // install
|
|
118
|
+
shutdownTimeout: 30_000, // global shutdown deadline (ms); Infinity = none
|
|
119
|
+
handleSignals: true, // install signal handlers on start()
|
|
114
120
|
signals: ["SIGINT", "SIGTERM"], // defaults to DEFAULT_SHUTDOWN_SIGNALS
|
|
115
121
|
});
|
|
116
122
|
```
|
|
@@ -118,6 +124,25 @@ createLifecycleManager({
|
|
|
118
124
|
Per-component: `id`, `dependsOn`, `priority`, `critical`, `timeout`,
|
|
119
125
|
`retry: { attempts, delay, maxDelay, backoff }`.
|
|
120
126
|
|
|
127
|
+
`priority` orders components that share a dependency level, and it is a
|
|
128
|
+
barrier rather than a hint: every component at one priority completes the
|
|
129
|
+
phase before the next priority starts, so `priority: 10` really does start
|
|
130
|
+
before `priority: 0`. Components sharing a priority still run together, up
|
|
131
|
+
to `concurrency`. Shutdown mirrors it — within a level the lowest priority
|
|
132
|
+
stops first and the highest stops last.
|
|
133
|
+
|
|
134
|
+
`timeout` and `shutdownTimeout` accept `Infinity` for "no bound"; NaN and
|
|
135
|
+
negative values throw a `RangeError` when registered or constructed, and
|
|
136
|
+
finite values above 2^31-1 ms are clamped to the largest timer delay.
|
|
137
|
+
`retry` covers hooks that fail; a hook that times out is not retried,
|
|
138
|
+
because it is still running and a second call would overlap it.
|
|
139
|
+
`shutdown()` waits (within its deadline) for such an abandoned hook to
|
|
140
|
+
settle before calling `stop()`.
|
|
141
|
+
|
|
142
|
+
With `handleSignals`, SIGINT/SIGTERM listeners are installed by `start()`,
|
|
143
|
+
not by the constructor, and removed once shutdown finishes. A second
|
|
144
|
+
signal while shutdown is running exits the process with code 1.
|
|
145
|
+
|
|
121
146
|
## Use Cases
|
|
122
147
|
|
|
123
148
|
- Coordinating service startup and shutdown
|
|
@@ -28,11 +28,21 @@ export interface LifecycleRegistrationOptions {
|
|
|
28
28
|
readonly id?: string;
|
|
29
29
|
/** IDs of components that must start before this one. */
|
|
30
30
|
readonly dependsOn?: readonly string[];
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Priority for ordering within the same dependency level. Higher = earlier.
|
|
33
|
+
*
|
|
34
|
+
* Priority is a barrier, not a hint: every component at one priority
|
|
35
|
+
* finishes the phase before the next priority begins, and shutdown
|
|
36
|
+
* runs the mirror image (lowest priority stops first). Components
|
|
37
|
+
* sharing a priority still run concurrently.
|
|
38
|
+
*/
|
|
32
39
|
readonly priority?: number;
|
|
33
40
|
/** If true, application startup fails when this component fails. Defaults to true. */
|
|
34
41
|
readonly critical?: boolean;
|
|
35
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Timeout in ms for individual component operations. `Infinity` means
|
|
44
|
+
* no bound; NaN and negative values are rejected at registration.
|
|
45
|
+
*/
|
|
36
46
|
readonly timeout?: number;
|
|
37
47
|
/** Retry configuration for failed operations. */
|
|
38
48
|
readonly retry?: LifecycleRetryOptions;
|
|
@@ -23,12 +23,29 @@ export interface ExecutionResult {
|
|
|
23
23
|
* Executes lifecycle component hooks with timeout, retry, and concurrency support.
|
|
24
24
|
*/
|
|
25
25
|
export declare class LifecycleExecutor {
|
|
26
|
+
/** Hook invocations still running after their timeout fired. */
|
|
27
|
+
private readonly abandoned;
|
|
28
|
+
/**
|
|
29
|
+
* Resolves once every hook abandoned by a timeout has settled.
|
|
30
|
+
*
|
|
31
|
+
* Shutdown waits on this before stopping components, so `stop()`
|
|
32
|
+
* never overlaps a `start()` that is still running.
|
|
33
|
+
*/
|
|
34
|
+
settleAbandoned(): Promise<void>;
|
|
26
35
|
/**
|
|
27
36
|
* Executes a single component hook.
|
|
28
37
|
*/
|
|
29
38
|
execute(registration: LifecycleRegistration, phase: LifecyclePhase, context: LifecycleContext): Promise<ExecutionResult>;
|
|
30
39
|
/**
|
|
31
|
-
* Executes a stage of components
|
|
40
|
+
* Executes a stage of components, honouring priority as a barrier.
|
|
41
|
+
*
|
|
42
|
+
* The stage arrives already ordered by priority (descending for
|
|
43
|
+
* startup, ascending for shutdown). Components sharing a priority run
|
|
44
|
+
* together, limited by `concurrency`; the next priority group only
|
|
45
|
+
* begins once the previous one has settled. Launching the whole stage
|
|
46
|
+
* concurrently made `priority` observable only at `concurrency: 1`,
|
|
47
|
+
* so a `priority: 100` component documented as starting first lost
|
|
48
|
+
* the race to any sibling with a faster hook.
|
|
32
49
|
*/
|
|
33
50
|
executeStage(registrations: readonly LifecycleRegistration[], phase: LifecyclePhase, context: LifecycleContext, concurrency: number): Promise<readonly ExecutionResult[]>;
|
|
34
51
|
}
|
|
@@ -5,11 +5,24 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { withTimeout, withConcurrency } from "../lifecycleInternal/index.js";
|
|
7
7
|
import { getComponentMethod } from "../lifecyclePhase/index.js";
|
|
8
|
-
import { LifecycleComponentError } from "@zudojs/errors";
|
|
8
|
+
import { LifecycleComponentError, LifecycleTimeoutError, } from "@zudojs/errors";
|
|
9
9
|
/**
|
|
10
10
|
* Executes lifecycle component hooks with timeout, retry, and concurrency support.
|
|
11
11
|
*/
|
|
12
12
|
export class LifecycleExecutor {
|
|
13
|
+
/** Hook invocations still running after their timeout fired. */
|
|
14
|
+
abandoned = new Set();
|
|
15
|
+
/**
|
|
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.
|
|
20
|
+
*/
|
|
21
|
+
async settleAbandoned() {
|
|
22
|
+
while (this.abandoned.size > 0) {
|
|
23
|
+
await Promise.allSettled([...this.abandoned]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
13
26
|
/**
|
|
14
27
|
* Executes a single component hook.
|
|
15
28
|
*/
|
|
@@ -36,12 +49,13 @@ export class LifecycleExecutor {
|
|
|
36
49
|
lastError ??= new LifecycleComponentError(registration.id, phase, context.signal.reason);
|
|
37
50
|
break;
|
|
38
51
|
}
|
|
52
|
+
let invocation;
|
|
39
53
|
try {
|
|
40
|
-
await withTimeout(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
await withTimeout(() => {
|
|
55
|
+
invocation = (async () => {
|
|
56
|
+
await hook.call(registration.component, context);
|
|
57
|
+
})();
|
|
58
|
+
return invocation;
|
|
45
59
|
}, registration.timeout, registration.id, phase);
|
|
46
60
|
return {
|
|
47
61
|
id: registration.id,
|
|
@@ -52,6 +66,16 @@ export class LifecycleExecutor {
|
|
|
52
66
|
}
|
|
53
67
|
catch (error) {
|
|
54
68
|
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
|
+
if (error instanceof LifecycleTimeoutError && invocation) {
|
|
74
|
+
const abandoned = invocation.catch(() => undefined);
|
|
75
|
+
this.abandoned.add(abandoned);
|
|
76
|
+
void abandoned.finally(() => this.abandoned.delete(abandoned));
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
55
79
|
if (attempt < maxAttempts - 1) {
|
|
56
80
|
const delay = calculateDelay(retryConfig, attempt);
|
|
57
81
|
await sleep(delay, context.signal);
|
|
@@ -72,17 +96,49 @@ export class LifecycleExecutor {
|
|
|
72
96
|
};
|
|
73
97
|
}
|
|
74
98
|
/**
|
|
75
|
-
* Executes a stage of components
|
|
99
|
+
* Executes a stage of components, honouring priority as a barrier.
|
|
100
|
+
*
|
|
101
|
+
* The stage arrives already ordered by priority (descending for
|
|
102
|
+
* startup, ascending for shutdown). Components sharing a priority run
|
|
103
|
+
* together, limited by `concurrency`; the next priority group only
|
|
104
|
+
* begins once the previous one has settled. Launching the whole stage
|
|
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.
|
|
76
108
|
*/
|
|
77
109
|
async executeStage(registrations, phase, context, concurrency) {
|
|
78
110
|
const results = [];
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
111
|
+
for (const batch of groupByPriority(registrations)) {
|
|
112
|
+
await withConcurrency(batch, concurrency, async (reg) => {
|
|
113
|
+
const result = await this.execute(reg, phase, context);
|
|
114
|
+
results.push(result);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
83
117
|
return results;
|
|
84
118
|
}
|
|
85
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Splits an already-ordered stage into runs of equal priority.
|
|
122
|
+
*
|
|
123
|
+
* Consecutive grouping preserves whatever order the execution plan
|
|
124
|
+
* produced, so a caller that does not care about priority (every
|
|
125
|
+
* component at the default 0) still gets a single fully concurrent
|
|
126
|
+
* batch.
|
|
127
|
+
*/
|
|
128
|
+
function groupByPriority(registrations) {
|
|
129
|
+
const batches = [];
|
|
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);
|
|
137
|
+
}
|
|
138
|
+
current.push(reg);
|
|
139
|
+
}
|
|
140
|
+
return batches;
|
|
141
|
+
}
|
|
86
142
|
/** Calculates retry delay with backoff. */
|
|
87
143
|
function calculateDelay(config, attempt) {
|
|
88
144
|
const base = config.delay ?? 500;
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
/**
|
|
7
7
|
* Executes an async operation with a timeout.
|
|
8
8
|
* Throws LifecycleTimeoutError if the timeout is exceeded.
|
|
9
|
+
*
|
|
10
|
+
* `Infinity` runs the operation unbounded; values above the largest
|
|
11
|
+
* timer delay are clamped to it. NaN or a negative value rejects with a
|
|
12
|
+
* RangeError instead of arming a 1 ms timer.
|
|
9
13
|
*/
|
|
10
14
|
export declare function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, componentId: string, phase: string): Promise<T>;
|
|
11
15
|
/**
|
|
@@ -3,16 +3,25 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Async utilities for timeout, abort, and concurrency control.
|
|
5
5
|
*/
|
|
6
|
-
import { LifecycleTimeoutError } from "@zudojs/errors";
|
|
6
|
+
import { ErrorCode, LifecycleError, LifecycleTimeoutError, } from "@zudojs/errors";
|
|
7
|
+
import { assertTimeoutBudget, isBounded, toTimerDelay, } from "./timeoutBudget.core.js";
|
|
7
8
|
/**
|
|
8
9
|
* Executes an async operation with a timeout.
|
|
9
10
|
* Throws LifecycleTimeoutError if the timeout is exceeded.
|
|
11
|
+
*
|
|
12
|
+
* `Infinity` runs the operation unbounded; values above the largest
|
|
13
|
+
* timer delay are clamped to it. NaN or a negative value rejects with a
|
|
14
|
+
* RangeError instead of arming a 1 ms timer.
|
|
10
15
|
*/
|
|
11
16
|
export async function withTimeout(fn, timeoutMs, componentId, phase) {
|
|
17
|
+
assertTimeoutBudget("timeout", timeoutMs);
|
|
18
|
+
if (!isBounded(timeoutMs)) {
|
|
19
|
+
return fn();
|
|
20
|
+
}
|
|
12
21
|
return new Promise((resolve, reject) => {
|
|
13
22
|
const timer = setTimeout(() => {
|
|
14
23
|
reject(new LifecycleTimeoutError(componentId, phase, timeoutMs));
|
|
15
|
-
}, timeoutMs);
|
|
24
|
+
}, toTimerDelay(timeoutMs));
|
|
16
25
|
// The timer must be cleared on EVERY exit path. A synchronously
|
|
17
26
|
// throwing `fn` used to escape before `.catch` was attached,
|
|
18
27
|
// leaving an armed timer that kept the event loop alive for the
|
|
@@ -40,11 +49,11 @@ export async function withTimeout(fn, timeoutMs, componentId, phase) {
|
|
|
40
49
|
*/
|
|
41
50
|
export async function withAbort(fn, signal) {
|
|
42
51
|
if (signal.aborted) {
|
|
43
|
-
throw
|
|
52
|
+
throw abortError();
|
|
44
53
|
}
|
|
45
54
|
return new Promise((resolve, reject) => {
|
|
46
55
|
const onAbort = () => {
|
|
47
|
-
reject(
|
|
56
|
+
reject(abortError());
|
|
48
57
|
};
|
|
49
58
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
50
59
|
// A synchronously throwing `fn` rejected via the Promise executor
|
|
@@ -70,6 +79,12 @@ export async function withAbort(fn, signal) {
|
|
|
70
79
|
});
|
|
71
80
|
});
|
|
72
81
|
}
|
|
82
|
+
/** The typed error surfaced when a `withAbort` operation is cancelled. */
|
|
83
|
+
function abortError() {
|
|
84
|
+
return new LifecycleError("Operation aborted", {
|
|
85
|
+
code: ErrorCode.LIFECYCLE_COMPONENT,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
73
88
|
/**
|
|
74
89
|
* Executes async operations with a concurrency limit.
|
|
75
90
|
*/
|
|
@@ -7,4 +7,5 @@ export { DependencyGraph } from "./dependencyGraph.core.js";
|
|
|
7
7
|
export { topologicalSort, reverseTopologicalSort, } from "./topologicalSort.core.js";
|
|
8
8
|
export type { TopologicalStage } from "./topologicalSort.core.js";
|
|
9
9
|
export { withTimeout, withAbort, withConcurrency } from "./asyncUtils.core.js";
|
|
10
|
+
export { MAX_TIMER_DELAY, assertTimeoutBudget, isBounded, toTimerDelay, } from "./timeoutBudget.core.js";
|
|
10
11
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -6,4 +6,5 @@
|
|
|
6
6
|
export { DependencyGraph } from "./dependencyGraph.core.js";
|
|
7
7
|
export { topologicalSort, reverseTopologicalSort, } from "./topologicalSort.core.js";
|
|
8
8
|
export { withTimeout, withAbort, withConcurrency } from "./asyncUtils.core.js";
|
|
9
|
+
export { MAX_TIMER_DELAY, assertTimeoutBudget, isBounded, toTimerDelay, } from "./timeoutBudget.core.js";
|
|
9
10
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/lifecycle/internal/timeout-budget
|
|
3
|
+
*
|
|
4
|
+
* Validation and normalisation for millisecond budgets handed to timers.
|
|
5
|
+
*/
|
|
6
|
+
/** Largest delay `setTimeout` can represent (2^31 - 1 ms). */
|
|
7
|
+
export declare const MAX_TIMER_DELAY = 2147483647;
|
|
8
|
+
/**
|
|
9
|
+
* Validates a millisecond budget.
|
|
10
|
+
*
|
|
11
|
+
* `Infinity` is accepted and means "no bound". NaN, negative values and
|
|
12
|
+
* non-numbers are rejected up front: `setTimeout` silently turns them
|
|
13
|
+
* (and `Infinity`) into a 1 ms timer, and `LifecycleTimeoutError`'s
|
|
14
|
+
* constructor then threw inside that timer callback, which crashed the
|
|
15
|
+
* process instead of failing the component.
|
|
16
|
+
*
|
|
17
|
+
* @param name - Option name, used in the error message.
|
|
18
|
+
* @param value - The budget to check.
|
|
19
|
+
* @throws RangeError when the value is not a non-negative number.
|
|
20
|
+
*/
|
|
21
|
+
export declare function assertTimeoutBudget(name: string, value: unknown): void;
|
|
22
|
+
/**
|
|
23
|
+
* Whether a validated budget actually bounds anything.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isBounded(value: number): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Clamps a finite budget to what a timer can represent.
|
|
28
|
+
*/
|
|
29
|
+
export declare function toTimerDelay(value: number): number;
|
|
30
|
+
//# sourceMappingURL=timeoutBudget.core.d.ts.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/lifecycle/internal/timeout-budget
|
|
3
|
+
*
|
|
4
|
+
* Validation and normalisation for millisecond budgets handed to timers.
|
|
5
|
+
*/
|
|
6
|
+
/** Largest delay `setTimeout` can represent (2^31 - 1 ms). */
|
|
7
|
+
export const MAX_TIMER_DELAY = 2_147_483_647;
|
|
8
|
+
/**
|
|
9
|
+
* Validates a millisecond budget.
|
|
10
|
+
*
|
|
11
|
+
* `Infinity` is accepted and means "no bound". NaN, negative values and
|
|
12
|
+
* non-numbers are rejected up front: `setTimeout` silently turns them
|
|
13
|
+
* (and `Infinity`) into a 1 ms timer, and `LifecycleTimeoutError`'s
|
|
14
|
+
* constructor then threw inside that timer callback, which crashed the
|
|
15
|
+
* process instead of failing the component.
|
|
16
|
+
*
|
|
17
|
+
* @param name - Option name, used in the error message.
|
|
18
|
+
* @param value - The budget to check.
|
|
19
|
+
* @throws RangeError when the value is not a non-negative number.
|
|
20
|
+
*/
|
|
21
|
+
export function assertTimeoutBudget(name, value) {
|
|
22
|
+
if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
|
|
23
|
+
throw new RangeError(`${name} must be a non-negative number of milliseconds (Infinity for no bound), got ${String(value)}.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Whether a validated budget actually bounds anything.
|
|
28
|
+
*/
|
|
29
|
+
export function isBounded(value) {
|
|
30
|
+
return Number.isFinite(value);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Clamps a finite budget to what a timer can represent.
|
|
34
|
+
*/
|
|
35
|
+
export function toTimerDelay(value) {
|
|
36
|
+
return Math.min(Math.max(0, value), MAX_TIMER_DELAY);
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=timeoutBudget.core.js.map
|
|
@@ -14,6 +14,13 @@ export type TopologicalStage = readonly string[];
|
|
|
14
14
|
export declare function topologicalSort(graph: DependencyGraph, priorities?: ReadonlyMap<string, number>): readonly TopologicalStage[];
|
|
15
15
|
/**
|
|
16
16
|
* Performs reverse topological sort for shutdown ordering.
|
|
17
|
+
*
|
|
18
|
+
* Both the stage list AND each stage's contents are reversed, so a
|
|
19
|
+
* shutdown is the exact mirror of the startup order: within a stage the
|
|
20
|
+
* lowest-priority component is torn down first and the highest-priority
|
|
21
|
+
* one last. Only the stage list used to be reversed, which left every
|
|
22
|
+
* stage in descending-priority order — harmless while stages ran fully
|
|
23
|
+
* concurrently, but wrong now that priority is a real sub-stage barrier.
|
|
17
24
|
*/
|
|
18
25
|
export declare function reverseTopologicalSort(graph: DependencyGraph, priorities?: ReadonlyMap<string, number>): readonly TopologicalStage[];
|
|
19
26
|
//# sourceMappingURL=topologicalSort.core.d.ts.map
|
|
@@ -42,9 +42,16 @@ export function topologicalSort(graph, priorities) {
|
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* Performs reverse topological sort for shutdown ordering.
|
|
45
|
+
*
|
|
46
|
+
* Both the stage list AND each stage's contents are reversed, so a
|
|
47
|
+
* shutdown is the exact mirror of the startup order: within a stage the
|
|
48
|
+
* lowest-priority component is torn down first and the highest-priority
|
|
49
|
+
* one last. Only the stage list used to be reversed, which left every
|
|
50
|
+
* stage in descending-priority order — harmless while stages ran fully
|
|
51
|
+
* concurrently, but wrong now that priority is a real sub-stage barrier.
|
|
45
52
|
*/
|
|
46
53
|
export function reverseTopologicalSort(graph, priorities) {
|
|
47
54
|
const stages = topologicalSort(graph, priorities);
|
|
48
|
-
return Object.freeze([...stages].reverse().map((stage) => Object.freeze([...stage])));
|
|
55
|
+
return Object.freeze([...stages].reverse().map((stage) => Object.freeze([...stage].reverse())));
|
|
49
56
|
}
|
|
50
57
|
//# sourceMappingURL=topologicalSort.core.js.map
|
|
@@ -13,9 +13,16 @@ import { LifecycleEventEmitter } from "../lifecycleEvents/lifecycleEvents.core.j
|
|
|
13
13
|
export interface LifecycleManagerOptions {
|
|
14
14
|
/** Maximum concurrent component operations. */
|
|
15
15
|
readonly concurrency?: number;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Global shutdown timeout in ms. `Infinity` means no deadline; NaN
|
|
18
|
+
* and negative values are rejected by the constructor.
|
|
19
|
+
*/
|
|
17
20
|
readonly shutdownTimeout?: number;
|
|
18
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Whether to install SIGINT/SIGTERM handlers. They are installed by
|
|
23
|
+
* `start()` (not the constructor) and removed once shutdown finishes,
|
|
24
|
+
* and a second signal during shutdown exits with code 1.
|
|
25
|
+
*/
|
|
19
26
|
readonly handleSignals?: boolean;
|
|
20
27
|
/** Signals to listen for. */
|
|
21
28
|
readonly signals?: readonly NodeJS.Signals[];
|
|
@@ -37,6 +44,8 @@ export declare class LifecycleManager {
|
|
|
37
44
|
private readonly _ctx;
|
|
38
45
|
private _startPromise?;
|
|
39
46
|
private _removeSignalHandlers?;
|
|
47
|
+
private readonly _handleSignals;
|
|
48
|
+
private readonly _signals;
|
|
40
49
|
constructor(options?: LifecycleManagerOptions);
|
|
41
50
|
/** Registers a component with the lifecycle manager. */
|
|
42
51
|
register(component: LifecycleComponent, options?: LifecycleRegistrationOptions): void;
|
|
@@ -50,6 +59,7 @@ export declare class LifecycleManager {
|
|
|
50
59
|
* Idempotent — returns the same promise if called multiple times.
|
|
51
60
|
*/
|
|
52
61
|
shutdown(): Promise<void>;
|
|
62
|
+
private releaseSignalHandlers;
|
|
53
63
|
/** Returns the current application state. */
|
|
54
64
|
get state(): LifecycleState;
|
|
55
65
|
/** Returns the event emitter for lifecycle events. */
|
|
@@ -12,6 +12,7 @@ import { LifecycleEventEmitter } from "../lifecycleEvents/lifecycleEvents.core.j
|
|
|
12
12
|
import { installSignalHandlers } from "../lifecycleSignal/lifecycleSignal.handler.js";
|
|
13
13
|
import { performStartup } from "./lifecycleManager.startup.js";
|
|
14
14
|
import { performShutdown } from "./lifecycleManager.shutdown.js";
|
|
15
|
+
import { assertTimeoutBudget } from "../lifecycleInternal/index.js";
|
|
15
16
|
/**
|
|
16
17
|
* Orchestrates application and component lifecycle.
|
|
17
18
|
*
|
|
@@ -29,7 +30,12 @@ export class LifecycleManager {
|
|
|
29
30
|
_ctx;
|
|
30
31
|
_startPromise;
|
|
31
32
|
_removeSignalHandlers;
|
|
33
|
+
_handleSignals;
|
|
34
|
+
_signals;
|
|
32
35
|
constructor(options = {}) {
|
|
36
|
+
if (options.shutdownTimeout !== undefined) {
|
|
37
|
+
assertTimeoutBudget("shutdownTimeout", options.shutdownTimeout);
|
|
38
|
+
}
|
|
33
39
|
this._ctx = {
|
|
34
40
|
registry: new LifecycleRegistry(),
|
|
35
41
|
state: new LifecycleStateMachine("application"),
|
|
@@ -43,14 +49,12 @@ export class LifecycleManager {
|
|
|
43
49
|
startTime: 0,
|
|
44
50
|
controller: new AbortController(),
|
|
45
51
|
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
});
|
|
53
|
-
}
|
|
52
|
+
// Installing in the constructor disabled Ctrl-C for the whole
|
|
53
|
+
// process as soon as a manager existed (tests, libraries), and the
|
|
54
|
+
// listener outlived shutdown, so a process with a leaked handle
|
|
55
|
+
// could no longer be interrupted.
|
|
56
|
+
this._handleSignals = options.handleSignals !== false;
|
|
57
|
+
this._signals = options.signals;
|
|
54
58
|
}
|
|
55
59
|
/** Registers a component with the lifecycle manager. */
|
|
56
60
|
register(component, options = {}) {
|
|
@@ -70,7 +74,20 @@ export class LifecycleManager {
|
|
|
70
74
|
if (this._startPromise) {
|
|
71
75
|
return this._startPromise;
|
|
72
76
|
}
|
|
73
|
-
this.
|
|
77
|
+
if (this._handleSignals && this._removeSignalHandlers === undefined) {
|
|
78
|
+
this._removeSignalHandlers = installSignalHandlers({
|
|
79
|
+
signals: this._signals,
|
|
80
|
+
handler: () => {
|
|
81
|
+
void this.shutdown();
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
this._startPromise = performStartup(this._ctx).catch(async (error) => {
|
|
86
|
+
// A failed startup rolls back through the shared shutdown.
|
|
87
|
+
await this._ctx.shutdownPromise?.catch(() => undefined);
|
|
88
|
+
this.releaseSignalHandlers();
|
|
89
|
+
throw error;
|
|
90
|
+
});
|
|
74
91
|
return this._startPromise;
|
|
75
92
|
}
|
|
76
93
|
/**
|
|
@@ -80,7 +97,16 @@ export class LifecycleManager {
|
|
|
80
97
|
async shutdown() {
|
|
81
98
|
// performShutdown is itself single-flight, so a shutdown started by
|
|
82
99
|
// startup rollback and one started here are the SAME run.
|
|
83
|
-
|
|
100
|
+
try {
|
|
101
|
+
await performShutdown(this._ctx);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
this.releaseSignalHandlers();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
releaseSignalHandlers() {
|
|
108
|
+
this._removeSignalHandlers?.();
|
|
109
|
+
this._removeSignalHandlers = undefined;
|
|
84
110
|
}
|
|
85
111
|
/** Returns the current application state. */
|
|
86
112
|
get state() {
|
|
@@ -112,8 +138,7 @@ export class LifecycleManager {
|
|
|
112
138
|
* component teardown — call `shutdown()` first for that.
|
|
113
139
|
*/
|
|
114
140
|
dispose() {
|
|
115
|
-
this.
|
|
116
|
-
this._removeSignalHandlers = undefined;
|
|
141
|
+
this.releaseSignalHandlers();
|
|
117
142
|
this._ctx.events.clear();
|
|
118
143
|
}
|
|
119
144
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { LifecyclePhase, LifecycleState } from "@zudojs/constants";
|
|
7
7
|
import { buildExecutionPlan } from "../lifecyclePlan/lifecyclePlan.core.js";
|
|
8
8
|
import { createLifecycleContext } from "../lifecycleContext/lifecycleContext.type.js";
|
|
9
|
+
import { isBounded, toTimerDelay } from "../lifecycleInternal/index.js";
|
|
9
10
|
import { emitComponentFailed, recordResult, transitionComponent, wasAttempted, } from "./lifecycleManager.context.js";
|
|
10
11
|
/** Shutdown phases in execution order. */
|
|
11
12
|
const SHUTDOWN_PHASES = [LifecyclePhase.STOP, LifecyclePhase.DISPOSE];
|
|
@@ -45,6 +46,9 @@ async function runShutdown(ctx) {
|
|
|
45
46
|
if (ctx.inFlight !== undefined) {
|
|
46
47
|
await raceDeadline(ctx, ctx.inFlight.then(() => undefined, () => undefined), Math.max(deadline - Date.now(), 1));
|
|
47
48
|
}
|
|
49
|
+
// Hooks abandoned by a component timeout are still running; stopping
|
|
50
|
+
// their component now would overlap its own start().
|
|
51
|
+
await raceDeadline(ctx, ctx.executor.settleAbandoned(), Math.max(deadline - Date.now(), 1));
|
|
48
52
|
// The shutdown deadline used to be checked only BETWEEN the two
|
|
49
53
|
// phases, so a single hook that never settled hung shutdown (and the
|
|
50
54
|
// process) forever. Race the whole phase against the remaining
|
|
@@ -62,6 +66,14 @@ async function runShutdown(ctx) {
|
|
|
62
66
|
catch {
|
|
63
67
|
// Shutdown must continue even if individual components fail.
|
|
64
68
|
}
|
|
69
|
+
// A stop()/dispose() hook that blew its own component timeout joins
|
|
70
|
+
// the abandoned set DURING this phase, so the pre-phase settle above
|
|
71
|
+
// cannot have covered it. Without this wait, DISPOSE ran on top of a
|
|
72
|
+
// stop() that was still draining and shutdown() resolved (reporting
|
|
73
|
+
// DISPOSED) while the hook kept running — the exact overlap the
|
|
74
|
+
// pre-phase settle was added to prevent. Still bounded by the global
|
|
75
|
+
// shutdown deadline.
|
|
76
|
+
await raceDeadline(ctx, ctx.executor.settleAbandoned(), Math.max(deadline - Date.now(), 1));
|
|
65
77
|
}
|
|
66
78
|
ctx.state.forceState(LifecycleState.DISPOSED);
|
|
67
79
|
ctx.events.emit("application:stopped", {
|
|
@@ -79,12 +91,19 @@ async function runShutdown(ctx) {
|
|
|
79
91
|
* so it can never hold the event loop open.
|
|
80
92
|
*/
|
|
81
93
|
async function raceDeadline(ctx, phase, remainingMs) {
|
|
94
|
+
// An unbounded budget (shutdownTimeout: Infinity) waits for the phase.
|
|
95
|
+
// Handing Infinity to setTimeout fired after 1 ms and abandoned every
|
|
96
|
+
// stop()/dispose() while reporting the application DISPOSED.
|
|
97
|
+
if (!isBounded(remainingMs)) {
|
|
98
|
+
await phase.catch(() => { });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
82
101
|
let timer;
|
|
83
102
|
const expiry = new Promise((resolve) => {
|
|
84
103
|
timer = setTimeout(() => {
|
|
85
104
|
ctx.controller.abort(new Error(`Lifecycle shutdown exceeded its ${ctx.shutdownTimeout}ms deadline.`));
|
|
86
105
|
resolve();
|
|
87
|
-
}, remainingMs);
|
|
106
|
+
}, toTimerDelay(remainingMs));
|
|
88
107
|
});
|
|
89
108
|
try {
|
|
90
109
|
await Promise.race([phase, expiry]);
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Lifecycle registry — manages component registration, validation, and lookup.
|
|
5
5
|
*/
|
|
6
6
|
import { LIFECYCLE_DEFAULT_TIMEOUT } from "@zudojs/constants";
|
|
7
|
-
import { DependencyGraph } from "../lifecycleInternal/index.js";
|
|
7
|
+
import { DependencyGraph, assertTimeoutBudget, } from "../lifecycleInternal/index.js";
|
|
8
|
+
import { ErrorCode, LifecycleError } from "@zudojs/errors";
|
|
8
9
|
/**
|
|
9
10
|
* Registry for lifecycle components.
|
|
10
11
|
* Validates registration, builds dependency graph, and freezes on demand.
|
|
@@ -16,11 +17,17 @@ export class LifecycleRegistry {
|
|
|
16
17
|
/** Registers a component with optional configuration. */
|
|
17
18
|
register(component, options = {}) {
|
|
18
19
|
if (this._frozen) {
|
|
19
|
-
throw new
|
|
20
|
+
throw new LifecycleError("Cannot register components after registry is frozen", { code: ErrorCode.LIFECYCLE_COMPONENT });
|
|
20
21
|
}
|
|
21
22
|
const id = options.id ?? component.name;
|
|
23
|
+
if (options.timeout !== undefined) {
|
|
24
|
+
assertTimeoutBudget(`Component "${id}" timeout`, options.timeout);
|
|
25
|
+
}
|
|
22
26
|
if (this._registrations.has(id)) {
|
|
23
|
-
throw new
|
|
27
|
+
throw new LifecycleError(`Component "${id}" is already registered`, {
|
|
28
|
+
code: ErrorCode.LIFECYCLE_COMPONENT,
|
|
29
|
+
componentId: id,
|
|
30
|
+
});
|
|
24
31
|
}
|
|
25
32
|
const registration = {
|
|
26
33
|
id,
|
|
@@ -48,7 +55,11 @@ export class LifecycleRegistry {
|
|
|
48
55
|
for (const [id, reg] of this._registrations) {
|
|
49
56
|
for (const dep of reg.dependsOn) {
|
|
50
57
|
if (!this._registrations.has(dep)) {
|
|
51
|
-
throw new
|
|
58
|
+
throw new LifecycleError(`Component "${id}" depends on "${dep}" which is not registered`, {
|
|
59
|
+
code: ErrorCode.LIFECYCLE_DEPENDENCY,
|
|
60
|
+
componentId: id,
|
|
61
|
+
metadata: { dependency: dep },
|
|
62
|
+
});
|
|
52
63
|
}
|
|
53
64
|
}
|
|
54
65
|
}
|
|
@@ -9,6 +9,15 @@ export interface SignalHandlerOptions {
|
|
|
9
9
|
readonly signals?: readonly NodeJS.Signals[];
|
|
10
10
|
/** Function to call when a signal is received. */
|
|
11
11
|
readonly handler: () => void;
|
|
12
|
+
/**
|
|
13
|
+
* Whether a second signal, received while the first is still being
|
|
14
|
+
* handled, exits the process with code 1. Defaults to `true`: the
|
|
15
|
+
* installed listener replaces Node's default exit, so without this a
|
|
16
|
+
* wedged shutdown could not be interrupted short of SIGKILL.
|
|
17
|
+
*/
|
|
18
|
+
readonly forceExitOnSecondSignal?: boolean;
|
|
19
|
+
/** Exit hook, injected for testing. Defaults to `process.exit`. */
|
|
20
|
+
readonly exit?: (code: number) => void;
|
|
12
21
|
}
|
|
13
22
|
/** Default signal configuration for graceful shutdown. */
|
|
14
23
|
export declare const DEFAULT_SHUTDOWN_SIGNALS: readonly NodeJS.Signals[];
|
|
@@ -15,9 +15,18 @@ export function installSignalHandlers(options) {
|
|
|
15
15
|
// changing the constant had no effect on the actual default.
|
|
16
16
|
const signals = options.signals ?? DEFAULT_SHUTDOWN_SIGNALS;
|
|
17
17
|
const handler = options.handler;
|
|
18
|
+
const forceExit = options.forceExitOnSecondSignal ?? true;
|
|
19
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
20
|
+
let received = false;
|
|
18
21
|
const installed = [];
|
|
19
22
|
for (const signal of signals) {
|
|
20
23
|
const listener = () => {
|
|
24
|
+
if (received) {
|
|
25
|
+
if (forceExit)
|
|
26
|
+
exit(1);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
received = true;
|
|
21
30
|
handler();
|
|
22
31
|
};
|
|
23
32
|
process.on(signal, listener);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/lifecycle",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Application and component lifecycle orchestration with state machine, dependency ordering, graceful shutdown, rollback, and signals.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"!dist/.tsbuildinfo"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@zudojs/errors": "1.0
|
|
28
|
-
"@zudojs/constants": "1.
|
|
27
|
+
"@zudojs/errors": "1.2.0",
|
|
28
|
+
"@zudojs/constants": "1.1.1"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "^26.4.1",
|