immortal-js 1.0.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/LICENSE +21 -0
- package/README.md +577 -0
- package/docs/CHANGELOG.md +21 -0
- package/docs/CONTRIBUTING.md +41 -0
- package/docs/api/chaos.md +179 -0
- package/docs/api/dashboard.md +109 -0
- package/docs/api/isolation.md +191 -0
- package/docs/api/lifecycle.md +187 -0
- package/docs/api/monitoring.md +313 -0
- package/docs/api/plugins.md +217 -0
- package/docs/api/recovery.md +267 -0
- package/docs/api/safe-zone.md +236 -0
- package/docs/api/supervision.md +285 -0
- package/docs/guides/configuration.md +168 -0
- package/docs/guides/express.md +171 -0
- package/docs/guides/fastify.md +188 -0
- package/docs/guides/koa.md +102 -0
- package/docs/guides/nestjs.md +182 -0
- package/docs/guides/testing.md +91 -0
- package/examples/express-basic/index.ts +462 -0
- package/examples/express-basic/package.json +21 -0
- package/examples/express-basic/tsconfig.json +24 -0
- package/examples/fastify-microservice/index.ts +342 -0
- package/examples/fastify-microservice/package.json +22 -0
- package/examples/fastify-microservice/tsconfig.json +24 -0
- package/examples/invoice-service/data/invoices.db +0 -0
- package/examples/invoice-service/data/invoices.db-shm +0 -0
- package/examples/invoice-service/data/invoices.db-wal +0 -0
- package/examples/invoice-service/package.json +25 -0
- package/examples/invoice-service/public/index.html +5025 -0
- package/examples/invoice-service/src/db.ts +608 -0
- package/examples/invoice-service/src/pdf.ts +358 -0
- package/examples/invoice-service/src/server.ts +527 -0
- package/examples/invoice-service/src/store.ts +159 -0
- package/examples/invoice-service/src/types.ts +193 -0
- package/examples/invoice-service/tsconfig.json +23 -0
- package/examples/nestjs-enterprise/app.module.ts +561 -0
- package/examples/nestjs-enterprise/main.ts +67 -0
- package/examples/nestjs-enterprise/package.json +26 -0
- package/examples/nestjs-enterprise/tsconfig.json +27 -0
- package/immortal-js-1.0.0.tgz +0 -0
- package/package.json +33 -0
- package/packages/adapter-express/package.json +34 -0
- package/packages/adapter-express/src/index.ts +349 -0
- package/packages/adapter-express/tsconfig.json +14 -0
- package/packages/adapter-fastify/package.json +56 -0
- package/packages/adapter-fastify/src/plugin.ts +226 -0
- package/packages/adapter-fastify/tsconfig.json +14 -0
- package/packages/adapter-koa/package.json +55 -0
- package/packages/adapter-koa/src/index.ts +207 -0
- package/packages/adapter-koa/tsconfig.json +14 -0
- package/packages/adapter-nestjs/package.json +61 -0
- package/packages/adapter-nestjs/src/immortal.module.ts +313 -0
- package/packages/adapter-nestjs/src/index.ts +14 -0
- package/packages/adapter-nestjs/tsconfig.json +16 -0
- package/packages/core/package.json +56 -0
- package/packages/core/src/chaos/ChaosEngine.ts +249 -0
- package/packages/core/src/config/defaults.ts +200 -0
- package/packages/core/src/config/schema.ts +199 -0
- package/packages/core/src/event-bus.ts +168 -0
- package/packages/core/src/index.ts +164 -0
- package/packages/core/src/isolation/BulkheadPool.ts +279 -0
- package/packages/core/src/isolation/WorkerSandbox.ts +306 -0
- package/packages/core/src/isolation/index.ts +8 -0
- package/packages/core/src/lifecycle/GracefulShutdown.ts +161 -0
- package/packages/core/src/logger.ts +104 -0
- package/packages/core/src/monitoring/DiagnosticsChannel.ts +248 -0
- package/packages/core/src/monitoring/HealthMonitor.ts +191 -0
- package/packages/core/src/monitoring/MemoryLeakGuard.ts +340 -0
- package/packages/core/src/monitoring/MetricsCollector.ts +219 -0
- package/packages/core/src/monitoring/index.ts +10 -0
- package/packages/core/src/plugins/BuiltinPlugins.ts +269 -0
- package/packages/core/src/recovery/CircuitBreaker.ts +334 -0
- package/packages/core/src/recovery/FallbackCache.ts +328 -0
- package/packages/core/src/recovery/RetryEngine.ts +225 -0
- package/packages/core/src/recovery/Timeout.ts +97 -0
- package/packages/core/src/recovery/index.ts +11 -0
- package/packages/core/src/runtime.ts +242 -0
- package/packages/core/src/safe-zone/AsyncBoundary.ts +114 -0
- package/packages/core/src/safe-zone/ErrorTrap.ts +347 -0
- package/packages/core/src/safe-zone/SafeWrapper.ts +317 -0
- package/packages/core/src/safe-zone/index.ts +23 -0
- package/packages/core/src/supervision/ClusterManager.ts +243 -0
- package/packages/core/src/supervision/RestartStrategy.ts +68 -0
- package/packages/core/src/supervision/Supervisor.ts +311 -0
- package/packages/core/src/supervision/index.ts +11 -0
- package/packages/core/src/types.ts +470 -0
- package/packages/core/test/bulkhead.test.ts +310 -0
- package/packages/core/test/circuit-breaker.test.ts +153 -0
- package/packages/core/test/memory-guard.test.ts +213 -0
- package/packages/core/test/retry.test.ts +110 -0
- package/packages/core/test/safe-zone.test.ts +271 -0
- package/packages/core/test/supervisor.test.ts +310 -0
- package/packages/core/tsconfig.json +13 -0
- package/packages/dashboard/package.json +56 -0
- package/packages/dashboard/server/DashboardServer.ts +454 -0
- package/packages/dashboard/tsconfig.json +14 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Supervisor.ts
|
|
3
|
+
* @description Erlang/OTP-inspired Worker Supervisor
|
|
4
|
+
*
|
|
5
|
+
* Engineering honesty about the restart limit:
|
|
6
|
+
* If a worker keeps crashing repeatedly within a time window, it's a
|
|
7
|
+
* programming error or unresolvable runtime condition. Infinite restart
|
|
8
|
+
* loops waste CPU and hide the real problem. After maxRestartsInWindow,
|
|
9
|
+
* the supervisor ESCALATES (stops retrying, fires an alert) instead of
|
|
10
|
+
* continuing to crash-loop.
|
|
11
|
+
*
|
|
12
|
+
* This is exactly what Erlang's supervisor does with max_restart / max_period.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { SupervisorConfig, WorkerStatus } from "../types.js";
|
|
16
|
+
import { WorkerEscalatedError } from "../types.js";
|
|
17
|
+
import { DEFAULT_SUPERVISOR } from "../config/defaults.js";
|
|
18
|
+
import { getEventBus } from "../event-bus.js";
|
|
19
|
+
import { getLogger } from "../logger.js";
|
|
20
|
+
import { computeRestartDecision } from "./RestartStrategy.js";
|
|
21
|
+
import type { WorkerEntry } from "./RestartStrategy.js";
|
|
22
|
+
import { calculateBackoff } from "../recovery/RetryEngine.js";
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// TYPES
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export type SpawnFunction = () => { terminate?: () => Promise<void> | void };
|
|
29
|
+
|
|
30
|
+
export interface ManagedWorker {
|
|
31
|
+
id: string;
|
|
32
|
+
index: number;
|
|
33
|
+
spawn: SpawnFunction;
|
|
34
|
+
instance?: ReturnType<SpawnFunction>;
|
|
35
|
+
status: WorkerStatus["state"];
|
|
36
|
+
restartCount: number;
|
|
37
|
+
restartTimestamps: number[]; // timestamps within current window
|
|
38
|
+
startedAt: number;
|
|
39
|
+
pid?: number;
|
|
40
|
+
memoryMb?: number;
|
|
41
|
+
cpuPercent?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SupervisorOptions {
|
|
45
|
+
config?: SupervisorConfig;
|
|
46
|
+
/** Called when a worker is escalated (for alerting) */
|
|
47
|
+
onEscalation?: (workerId: string, error: WorkerEscalatedError) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
// SUPERVISOR CLASS
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export class Supervisor {
|
|
55
|
+
private readonly workers = new Map<string, ManagedWorker>();
|
|
56
|
+
private readonly config: Required<SupervisorConfig>;
|
|
57
|
+
private readonly onEscalation: ((workerId: string, error: WorkerEscalatedError) => void) | undefined;
|
|
58
|
+
private stopped = false;
|
|
59
|
+
|
|
60
|
+
constructor(options: SupervisorOptions = {}) {
|
|
61
|
+
this.config = { ...DEFAULT_SUPERVISOR, ...options.config };
|
|
62
|
+
this.onEscalation = options.onEscalation;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Register a worker with the supervisor.
|
|
67
|
+
* The supervisor will automatically restart it if it fails.
|
|
68
|
+
*
|
|
69
|
+
* @param id - Unique worker identifier
|
|
70
|
+
* @param spawnFn - Factory function to create/restart the worker
|
|
71
|
+
* @returns The initial worker instance
|
|
72
|
+
*/
|
|
73
|
+
register(id: string, spawnFn: SpawnFunction): ReturnType<SpawnFunction> {
|
|
74
|
+
if (this.workers.has(id)) {
|
|
75
|
+
throw new Error(`Worker '${id}' is already registered with this supervisor`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const worker: ManagedWorker = {
|
|
79
|
+
id,
|
|
80
|
+
index: this.workers.size,
|
|
81
|
+
spawn: spawnFn,
|
|
82
|
+
status: "running",
|
|
83
|
+
restartCount: 0,
|
|
84
|
+
restartTimestamps: [],
|
|
85
|
+
startedAt: Date.now(),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const instance = spawnFn();
|
|
89
|
+
worker.instance = instance;
|
|
90
|
+
this.workers.set(id, worker);
|
|
91
|
+
|
|
92
|
+
getEventBus().emit("worker:started", {
|
|
93
|
+
workerId: id,
|
|
94
|
+
data: { index: worker.index, strategy: this.config.strategy },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
getLogger().info(`Supervisor: Worker '${id}' registered and started`);
|
|
98
|
+
return instance;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Notify the supervisor that a worker has exited/crashed.
|
|
103
|
+
* This triggers the restart logic based on the configured strategy.
|
|
104
|
+
*
|
|
105
|
+
* @param workerId - ID of the crashed worker
|
|
106
|
+
* @param exitCode - Exit code (0 = clean, non-zero = crash)
|
|
107
|
+
*/
|
|
108
|
+
onWorkerExit(workerId: string, exitCode = 1): void {
|
|
109
|
+
if (this.stopped) return;
|
|
110
|
+
|
|
111
|
+
const worker = this.workers.get(workerId);
|
|
112
|
+
if (!worker) {
|
|
113
|
+
getLogger().warn(`Supervisor: Unknown worker '${workerId}' exited`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const bus = getEventBus();
|
|
118
|
+
const logger = getLogger();
|
|
119
|
+
|
|
120
|
+
bus.emit("worker:crashed", {
|
|
121
|
+
workerId,
|
|
122
|
+
data: { exitCode, restartCount: worker.restartCount },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
logger.warn(`Supervisor: Worker '${workerId}' crashed (exit: ${exitCode})`);
|
|
126
|
+
|
|
127
|
+
// Clean up expired restart timestamps
|
|
128
|
+
const windowStart = Date.now() - this.config.windowMs;
|
|
129
|
+
worker.restartTimestamps = worker.restartTimestamps.filter((t) => t > windowStart);
|
|
130
|
+
|
|
131
|
+
// Check restart limit
|
|
132
|
+
if (worker.restartTimestamps.length >= this.config.maxRestartsInWindow) {
|
|
133
|
+
this.escalate(workerId, worker);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Determine which workers to restart
|
|
138
|
+
const workerEntries: WorkerEntry[] = Array.from(this.workers.values()).map((w) => ({
|
|
139
|
+
id: w.id,
|
|
140
|
+
index: w.index,
|
|
141
|
+
restart: () => this.restartWorker(w.id),
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
const decision = computeRestartDecision(workerId, workerEntries, this.config.strategy);
|
|
145
|
+
|
|
146
|
+
// Execute restarts with exponential backoff
|
|
147
|
+
for (const idToRestart of decision.workersToRestart) {
|
|
148
|
+
const restartCount = this.workers.get(idToRestart)?.restartCount ?? 0;
|
|
149
|
+
const delay = calculateBackoff(
|
|
150
|
+
restartCount + 1,
|
|
151
|
+
this.config.baseRestartDelayMs,
|
|
152
|
+
30_000 // max 30s restart delay
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
logger.info(`Supervisor: Restarting '${idToRestart}' in ${delay}ms (attempt #${restartCount + 1})`);
|
|
156
|
+
|
|
157
|
+
setTimeout(() => {
|
|
158
|
+
if (!this.stopped) {
|
|
159
|
+
this.restartWorker(idToRestart);
|
|
160
|
+
}
|
|
161
|
+
}, delay).unref();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Gracefully stop a managed worker
|
|
167
|
+
*/
|
|
168
|
+
async stop(workerId: string): Promise<void> {
|
|
169
|
+
const worker = this.workers.get(workerId);
|
|
170
|
+
if (!worker) return;
|
|
171
|
+
|
|
172
|
+
worker.status = "stopped";
|
|
173
|
+
|
|
174
|
+
if (worker.instance?.terminate) {
|
|
175
|
+
await worker.instance.terminate();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.workers.delete(workerId);
|
|
179
|
+
getLogger().info(`Supervisor: Worker '${workerId}' stopped`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Stop all workers (graceful shutdown)
|
|
184
|
+
*/
|
|
185
|
+
async stopAll(): Promise<void> {
|
|
186
|
+
this.stopped = true;
|
|
187
|
+
|
|
188
|
+
const stopPromises = Array.from(this.workers.keys()).map((id) => this.stop(id));
|
|
189
|
+
await Promise.allSettled(stopPromises);
|
|
190
|
+
|
|
191
|
+
getLogger().info("Supervisor: All workers stopped");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get current status of all managed workers
|
|
196
|
+
*/
|
|
197
|
+
getWorkerStatuses(): WorkerStatus[] {
|
|
198
|
+
return Array.from(this.workers.values()).map((w) => {
|
|
199
|
+
const status: WorkerStatus = {
|
|
200
|
+
id: w.id,
|
|
201
|
+
state: w.status,
|
|
202
|
+
restartCount: w.restartCount,
|
|
203
|
+
uptime: Date.now() - w.startedAt,
|
|
204
|
+
};
|
|
205
|
+
if (w.pid !== undefined) status.pid = w.pid;
|
|
206
|
+
const lastRestart = w.restartTimestamps.at(-1);
|
|
207
|
+
if (lastRestart !== undefined) status.lastRestartTime = lastRestart;
|
|
208
|
+
if (w.memoryMb !== undefined) status.memoryMb = w.memoryMb;
|
|
209
|
+
if (w.cpuPercent !== undefined) status.cpuPercent = w.cpuPercent;
|
|
210
|
+
return status;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Update resource metrics for a worker (called by HealthMonitor)
|
|
216
|
+
*/
|
|
217
|
+
updateWorkerMetrics(
|
|
218
|
+
workerId: string,
|
|
219
|
+
metrics: { memoryMb?: number; cpuPercent?: number; pid?: number }
|
|
220
|
+
): void {
|
|
221
|
+
const worker = this.workers.get(workerId);
|
|
222
|
+
if (!worker) return;
|
|
223
|
+
|
|
224
|
+
if (metrics.memoryMb !== undefined) worker.memoryMb = metrics.memoryMb;
|
|
225
|
+
if (metrics.cpuPercent !== undefined) worker.cpuPercent = metrics.cpuPercent;
|
|
226
|
+
if (metrics.pid !== undefined) worker.pid = metrics.pid;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Request graceful restart of a specific worker (from HealthMonitor)
|
|
231
|
+
*/
|
|
232
|
+
requestGracefulRestart(workerId: string, reason = "health-threshold"): void {
|
|
233
|
+
const worker = this.workers.get(workerId);
|
|
234
|
+
if (!worker || worker.status !== "running") return;
|
|
235
|
+
|
|
236
|
+
getLogger().info(`Supervisor: Graceful restart requested for '${workerId}' (reason: ${reason})`);
|
|
237
|
+
getEventBus().emit("memory:proactive-restart", {
|
|
238
|
+
workerId,
|
|
239
|
+
data: { reason },
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Graceful restart: stop current, allow supervisor to restart
|
|
243
|
+
if (worker.instance?.terminate) {
|
|
244
|
+
Promise.resolve(worker.instance.terminate()).catch(() => void 0);
|
|
245
|
+
}
|
|
246
|
+
// Simulate exit to trigger restart logic
|
|
247
|
+
this.onWorkerExit(workerId, 0);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
251
|
+
// PRIVATE
|
|
252
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
private restartWorker(workerId: string): void {
|
|
255
|
+
const worker = this.workers.get(workerId);
|
|
256
|
+
if (!worker || this.stopped) return;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
worker.status = "restarting";
|
|
260
|
+
worker.restartCount++;
|
|
261
|
+
worker.restartTimestamps.push(Date.now());
|
|
262
|
+
worker.startedAt = Date.now();
|
|
263
|
+
|
|
264
|
+
const instance = worker.spawn();
|
|
265
|
+
worker.instance = instance;
|
|
266
|
+
worker.status = "running";
|
|
267
|
+
|
|
268
|
+
getEventBus().emit("worker:restarted", {
|
|
269
|
+
workerId,
|
|
270
|
+
data: {
|
|
271
|
+
restartCount: worker.restartCount,
|
|
272
|
+
strategy: this.config.strategy,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
getLogger().info(`Supervisor: Worker '${workerId}' restarted (count: ${worker.restartCount})`);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
getLogger().error(`Supervisor: Failed to restart worker '${workerId}'`, {
|
|
279
|
+
error: (err as Error).message,
|
|
280
|
+
});
|
|
281
|
+
worker.status = "stopped";
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private escalate(workerId: string, worker: ManagedWorker): void {
|
|
286
|
+
const bus = getEventBus();
|
|
287
|
+
const logger = getLogger();
|
|
288
|
+
|
|
289
|
+
worker.status = "escalated";
|
|
290
|
+
const error = new WorkerEscalatedError(workerId, worker.restartCount);
|
|
291
|
+
|
|
292
|
+
bus.emit("worker:escalated", {
|
|
293
|
+
workerId,
|
|
294
|
+
data: {
|
|
295
|
+
restartCount: worker.restartCount,
|
|
296
|
+
windowMs: this.config.windowMs,
|
|
297
|
+
maxRestartsInWindow: this.config.maxRestartsInWindow,
|
|
298
|
+
},
|
|
299
|
+
error: { name: error.name, message: error.message, isOperational: false },
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
logger.error(
|
|
303
|
+
`Supervisor: Worker '${workerId}' ESCALATED — ` +
|
|
304
|
+
`crashed ${worker.restartCount} times in ${this.config.windowMs}ms window. ` +
|
|
305
|
+
`Manual intervention required.`,
|
|
306
|
+
{ workerId, restartCount: worker.restartCount }
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
this.onEscalation?.(workerId, error);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file supervision/index.ts
|
|
3
|
+
* @description Supervision layer public exports
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { Supervisor } from "./Supervisor.js";
|
|
7
|
+
export type { ManagedWorker, SupervisorOptions, SpawnFunction } from "./Supervisor.js";
|
|
8
|
+
export { ClusterManager } from "./ClusterManager.js";
|
|
9
|
+
export type { ClusterManagerOptions } from "./ClusterManager.js";
|
|
10
|
+
export { computeRestartDecision } from "./RestartStrategy.js";
|
|
11
|
+
export type { WorkerEntry, RestartDecision } from "./RestartStrategy.js";
|