@zudojs/lifecycle 1.1.1 → 1.2.1
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 +7 -0
- package/dist/lifecycleComponent/lifecycleComponent.type.d.ts +8 -1
- package/dist/lifecycleExecutor/lifecycleExecutor.core.d.ts +9 -1
- package/dist/lifecycleExecutor/lifecycleExecutor.core.js +37 -5
- package/dist/lifecycleInternal/asyncUtils.core.js +9 -3
- package/dist/lifecycleInternal/topologicalSort.core.d.ts +7 -0
- package/dist/lifecycleInternal/topologicalSort.core.js +8 -1
- package/dist/lifecycleManager/lifecycleManager.shutdown.js +8 -0
- package/dist/lifecycleRegistry/lifecycleRegistry.core.js +11 -3
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -124,6 +124,13 @@ createLifecycleManager({
|
|
|
124
124
|
Per-component: `id`, `dependsOn`, `priority`, `critical`, `timeout`,
|
|
125
125
|
`retry: { attempts, delay, maxDelay, backoff }`.
|
|
126
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
|
+
|
|
127
134
|
`timeout` and `shutdownTimeout` accept `Infinity` for "no bound"; NaN and
|
|
128
135
|
negative values throw a `RangeError` when registered or constructed, and
|
|
129
136
|
finite values above 2^31-1 ms are clamped to the largest timer delay.
|
|
@@ -28,7 +28,14 @@ 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;
|
|
@@ -37,7 +37,15 @@ export declare class LifecycleExecutor {
|
|
|
37
37
|
*/
|
|
38
38
|
execute(registration: LifecycleRegistration, phase: LifecyclePhase, context: LifecycleContext): Promise<ExecutionResult>;
|
|
39
39
|
/**
|
|
40
|
-
* 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.
|
|
41
49
|
*/
|
|
42
50
|
executeStage(registrations: readonly LifecycleRegistration[], phase: LifecyclePhase, context: LifecycleContext, concurrency: number): Promise<readonly ExecutionResult[]>;
|
|
43
51
|
}
|
|
@@ -96,17 +96,49 @@ export class LifecycleExecutor {
|
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
/**
|
|
99
|
-
* 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.
|
|
100
108
|
*/
|
|
101
109
|
async executeStage(registrations, phase, context, concurrency) {
|
|
102
110
|
const results = [];
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
}
|
|
107
117
|
return results;
|
|
108
118
|
}
|
|
109
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
|
+
}
|
|
110
142
|
/** Calculates retry delay with backoff. */
|
|
111
143
|
function calculateDelay(config, attempt) {
|
|
112
144
|
const base = config.delay ?? 500;
|
|
@@ -3,7 +3,7 @@
|
|
|
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
7
|
import { assertTimeoutBudget, isBounded, toTimerDelay, } from "./timeoutBudget.core.js";
|
|
8
8
|
/**
|
|
9
9
|
* Executes an async operation with a timeout.
|
|
@@ -49,11 +49,11 @@ export async function withTimeout(fn, timeoutMs, componentId, phase) {
|
|
|
49
49
|
*/
|
|
50
50
|
export async function withAbort(fn, signal) {
|
|
51
51
|
if (signal.aborted) {
|
|
52
|
-
throw
|
|
52
|
+
throw abortError();
|
|
53
53
|
}
|
|
54
54
|
return new Promise((resolve, reject) => {
|
|
55
55
|
const onAbort = () => {
|
|
56
|
-
reject(
|
|
56
|
+
reject(abortError());
|
|
57
57
|
};
|
|
58
58
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
59
59
|
// A synchronously throwing `fn` rejected via the Promise executor
|
|
@@ -79,6 +79,12 @@ export async function withAbort(fn, signal) {
|
|
|
79
79
|
});
|
|
80
80
|
});
|
|
81
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
|
+
}
|
|
82
88
|
/**
|
|
83
89
|
* Executes async operations with a concurrency limit.
|
|
84
90
|
*/
|
|
@@ -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
|
|
@@ -66,6 +66,14 @@ async function runShutdown(ctx) {
|
|
|
66
66
|
catch {
|
|
67
67
|
// Shutdown must continue even if individual components fail.
|
|
68
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));
|
|
69
77
|
}
|
|
70
78
|
ctx.state.forceState(LifecycleState.DISPOSED);
|
|
71
79
|
ctx.events.emit("application:stopped", {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { LIFECYCLE_DEFAULT_TIMEOUT } from "@zudojs/constants";
|
|
7
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,14 +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;
|
|
22
23
|
if (options.timeout !== undefined) {
|
|
23
24
|
assertTimeoutBudget(`Component "${id}" timeout`, options.timeout);
|
|
24
25
|
}
|
|
25
26
|
if (this._registrations.has(id)) {
|
|
26
|
-
throw new
|
|
27
|
+
throw new LifecycleError(`Component "${id}" is already registered`, {
|
|
28
|
+
code: ErrorCode.LIFECYCLE_COMPONENT,
|
|
29
|
+
componentId: id,
|
|
30
|
+
});
|
|
27
31
|
}
|
|
28
32
|
const registration = {
|
|
29
33
|
id,
|
|
@@ -51,7 +55,11 @@ export class LifecycleRegistry {
|
|
|
51
55
|
for (const [id, reg] of this._registrations) {
|
|
52
56
|
for (const dep of reg.dependsOn) {
|
|
53
57
|
if (!this._registrations.has(dep)) {
|
|
54
|
-
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
|
+
});
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/lifecycle",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.1",
|
|
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,13 +24,13 @@
|
|
|
24
24
|
"!dist/.tsbuildinfo"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@zudojs/errors": "1.
|
|
28
|
-
"@zudojs/constants": "1.1.
|
|
27
|
+
"@zudojs/errors": "1.3.0",
|
|
28
|
+
"@zudojs/constants": "1.1.2"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@types/node": "^26.
|
|
31
|
+
"@types/node": "^26.6.2",
|
|
32
32
|
"typescript": "7.0.2",
|
|
33
|
-
"vitest": "^
|
|
33
|
+
"vitest": "^5.0.1"
|
|
34
34
|
},
|
|
35
35
|
"engines": {
|
|
36
36
|
"node": ">=24.0.0"
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"startup",
|
|
46
46
|
"shutdown"
|
|
47
47
|
],
|
|
48
|
-
"homepage": "https://
|
|
48
|
+
"homepage": "https://zudojs.oyinlola.site/docs/packages-lifecycle",
|
|
49
49
|
"bugs": {
|
|
50
50
|
"url": "https://github.com/oyinlola-tech/zudo/issues"
|
|
51
51
|
},
|