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,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file bulkhead.test.ts
|
|
3
|
+
* @description Tests for BulkheadPool and BulkheadRegistry.
|
|
4
|
+
*
|
|
5
|
+
* Actual API (from source):
|
|
6
|
+
* new BulkheadPool(name: string, config?: BulkheadConfig)
|
|
7
|
+
* pool.run(task, priority?, timeoutMs?) — priority: "critical"|"high"|"medium"|"low"
|
|
8
|
+
* pool.getStatus() → { name, active, queued, maxConcurrent, maxQueueSize, rejected, totalExecuted }
|
|
9
|
+
* pool.drain() — rejects all queued items, synchronous
|
|
10
|
+
*
|
|
11
|
+
* new BulkheadRegistry(defaultConfig?)
|
|
12
|
+
* registry.get(name, config?) → BulkheadPool (creates or reuses)
|
|
13
|
+
* registry.getAllStatuses() → Record<string, BulkheadStatus>
|
|
14
|
+
* registry.drainAll()
|
|
15
|
+
*
|
|
16
|
+
* BulkheadStatus.rejected (NOT "totalRejected")
|
|
17
|
+
* BulkheadStatus.totalExecuted
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
21
|
+
import { BulkheadPool, BulkheadRegistry } from "../src/isolation/BulkheadPool.js";
|
|
22
|
+
import { BulkheadRejectedError } from "../src/types.js";
|
|
23
|
+
|
|
24
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function sleep(ms: number): Promise<void> {
|
|
27
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function slowTask<T>(value: T, holdMs: number): () => Promise<T> {
|
|
31
|
+
return () => sleep(holdMs).then(() => value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── BulkheadPool — basic concurrency ────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
describe("BulkheadPool — basic execution", () => {
|
|
37
|
+
it("executes a single task and returns its result", async () => {
|
|
38
|
+
const pool = new BulkheadPool("test", { maxConcurrent: 2, maxQueueSize: 5 });
|
|
39
|
+
const result = await pool.run(() => Promise.resolve(42));
|
|
40
|
+
expect(result).toBe(42);
|
|
41
|
+
pool.drain();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("runs up to maxConcurrent tasks simultaneously", async () => {
|
|
45
|
+
const pool = new BulkheadPool("concurrency", { maxConcurrent: 3, maxQueueSize: 10 });
|
|
46
|
+
let running = 0;
|
|
47
|
+
let maxObserved = 0;
|
|
48
|
+
|
|
49
|
+
const tasks = Array.from({ length: 3 }, () =>
|
|
50
|
+
pool.run(async () => {
|
|
51
|
+
running++;
|
|
52
|
+
maxObserved = Math.max(maxObserved, running);
|
|
53
|
+
await sleep(40);
|
|
54
|
+
running--;
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
await Promise.all(tasks);
|
|
59
|
+
expect(maxObserved).toBe(3);
|
|
60
|
+
pool.drain();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("propagates task errors without blocking the pool", async () => {
|
|
64
|
+
const pool = new BulkheadPool("errors", { maxConcurrent: 2, maxQueueSize: 5 });
|
|
65
|
+
await expect(pool.run(() => Promise.reject(new Error("boom")))).rejects.toThrow("boom");
|
|
66
|
+
// Pool should still be usable
|
|
67
|
+
const result = await pool.run(() => Promise.resolve("ok"));
|
|
68
|
+
expect(result).toBe("ok");
|
|
69
|
+
pool.drain();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("queues tasks beyond maxConcurrent and executes them in order", async () => {
|
|
73
|
+
const pool = new BulkheadPool("ordering", { maxConcurrent: 1, maxQueueSize: 5 });
|
|
74
|
+
const order: number[] = [];
|
|
75
|
+
|
|
76
|
+
const p1 = pool.run(async () => { await sleep(30); order.push(1); });
|
|
77
|
+
const p2 = pool.run(async () => { order.push(2); });
|
|
78
|
+
const p3 = pool.run(async () => { order.push(3); });
|
|
79
|
+
|
|
80
|
+
await Promise.all([p1, p2, p3]);
|
|
81
|
+
expect(order).toEqual([1, 2, 3]);
|
|
82
|
+
pool.drain();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ─── BulkheadPool — rejection ────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
describe("BulkheadPool — rejection when saturated", () => {
|
|
89
|
+
it("throws BulkheadRejectedError when queue is full", async () => {
|
|
90
|
+
// maxQueueSize: 0 → any task that can't run immediately is rejected
|
|
91
|
+
const pool = new BulkheadPool("saturated", { maxConcurrent: 1, maxQueueSize: 0 });
|
|
92
|
+
|
|
93
|
+
const blocker = pool.run(slowTask(null, 100));
|
|
94
|
+
await expect(pool.run(() => Promise.resolve("x"))).rejects.toBeInstanceOf(BulkheadRejectedError);
|
|
95
|
+
|
|
96
|
+
await blocker;
|
|
97
|
+
pool.drain();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("includes pool name in BulkheadRejectedError message", async () => {
|
|
101
|
+
const pool = new BulkheadPool("payments", { maxConcurrent: 1, maxQueueSize: 0 });
|
|
102
|
+
const blocker = pool.run(slowTask(null, 100));
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await pool.run(() => Promise.resolve("x"));
|
|
106
|
+
expect.fail("Should have thrown");
|
|
107
|
+
} catch (err) {
|
|
108
|
+
expect(err).toBeInstanceOf(BulkheadRejectedError);
|
|
109
|
+
expect((err as BulkheadRejectedError).message).toContain("payments");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await blocker;
|
|
113
|
+
pool.drain();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("accepts new tasks once a slot frees up", async () => {
|
|
117
|
+
const pool = new BulkheadPool("recover", { maxConcurrent: 1, maxQueueSize: 0 });
|
|
118
|
+
const p1 = pool.run(slowTask("first", 30));
|
|
119
|
+
await expect(pool.run(() => Promise.resolve("x"))).rejects.toBeInstanceOf(BulkheadRejectedError);
|
|
120
|
+
|
|
121
|
+
await p1;
|
|
122
|
+
const result = await pool.run(() => Promise.resolve("second"));
|
|
123
|
+
expect(result).toBe("second");
|
|
124
|
+
pool.drain();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("increments rejected count in getStatus()", async () => {
|
|
128
|
+
const pool = new BulkheadPool("count", { maxConcurrent: 1, maxQueueSize: 0 });
|
|
129
|
+
const blocker = pool.run(slowTask(null, 100));
|
|
130
|
+
|
|
131
|
+
try { await pool.run(() => Promise.resolve(1)); } catch { /* expected */ }
|
|
132
|
+
try { await pool.run(() => Promise.resolve(2)); } catch { /* expected */ }
|
|
133
|
+
|
|
134
|
+
const status = pool.getStatus();
|
|
135
|
+
expect(status.rejected).toBeGreaterThanOrEqual(2);
|
|
136
|
+
|
|
137
|
+
await blocker;
|
|
138
|
+
pool.drain();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ─── BulkheadPool — priority queues ──────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
describe("BulkheadPool — priority queuing", () => {
|
|
145
|
+
it("drains critical tasks before lower-priority ones", async () => {
|
|
146
|
+
const pool = new BulkheadPool("priority", { maxConcurrent: 1, maxQueueSize: 20 });
|
|
147
|
+
const completed: string[] = [];
|
|
148
|
+
|
|
149
|
+
// Occupy the one concurrent slot
|
|
150
|
+
const blocker = pool.run(slowTask(null, 80));
|
|
151
|
+
|
|
152
|
+
const promises = [
|
|
153
|
+
pool.run(async () => { completed.push("low"); }, "low"),
|
|
154
|
+
pool.run(async () => { completed.push("critical-1"); }, "critical"),
|
|
155
|
+
pool.run(async () => { completed.push("medium"); }, "medium"),
|
|
156
|
+
pool.run(async () => { completed.push("high"); }, "high"),
|
|
157
|
+
pool.run(async () => { completed.push("critical-2"); }, "critical"),
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
await blocker;
|
|
161
|
+
await Promise.all(promises);
|
|
162
|
+
|
|
163
|
+
const critIdx1 = completed.indexOf("critical-1");
|
|
164
|
+
const critIdx2 = completed.indexOf("critical-2");
|
|
165
|
+
const highIdx = completed.indexOf("high");
|
|
166
|
+
const medIdx = completed.indexOf("medium");
|
|
167
|
+
const lowIdx = completed.indexOf("low");
|
|
168
|
+
|
|
169
|
+
// critical before high
|
|
170
|
+
expect(Math.max(critIdx1, critIdx2)).toBeLessThan(highIdx);
|
|
171
|
+
// high before medium
|
|
172
|
+
expect(highIdx).toBeLessThan(medIdx);
|
|
173
|
+
// medium before low
|
|
174
|
+
expect(medIdx).toBeLessThan(lowIdx);
|
|
175
|
+
pool.drain();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ─── BulkheadPool — queue timeout ────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
describe("BulkheadPool — per-item queue timeout", () => {
|
|
182
|
+
it("rejects queued tasks that wait longer than queueTimeoutMs", async () => {
|
|
183
|
+
const pool = new BulkheadPool("timeout", {
|
|
184
|
+
maxConcurrent: 1,
|
|
185
|
+
maxQueueSize: 5,
|
|
186
|
+
queueTimeoutMs: 50,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const blocker = pool.run(slowTask(null, 300));
|
|
190
|
+
|
|
191
|
+
// This task will queue, but queue timeout is 50ms
|
|
192
|
+
await expect(
|
|
193
|
+
pool.run(() => Promise.resolve("queued-task"))
|
|
194
|
+
).rejects.toBeInstanceOf(BulkheadRejectedError);
|
|
195
|
+
|
|
196
|
+
await blocker;
|
|
197
|
+
pool.drain();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ─── BulkheadPool — status reporting ─────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
describe("BulkheadPool — getStatus()", () => {
|
|
204
|
+
it("reports correct active, queued, maxConcurrent counts", async () => {
|
|
205
|
+
const pool = new BulkheadPool("status", { maxConcurrent: 2, maxQueueSize: 10 });
|
|
206
|
+
|
|
207
|
+
const b1 = pool.run(slowTask(null, 200));
|
|
208
|
+
const b2 = pool.run(slowTask(null, 200));
|
|
209
|
+
const q1 = pool.run(slowTask(null, 10));
|
|
210
|
+
const q2 = pool.run(slowTask(null, 10));
|
|
211
|
+
|
|
212
|
+
await sleep(10); // let tasks settle
|
|
213
|
+
|
|
214
|
+
const status = pool.getStatus();
|
|
215
|
+
expect(status.active).toBe(2);
|
|
216
|
+
expect(status.queued).toBe(2);
|
|
217
|
+
expect(status.maxConcurrent).toBe(2);
|
|
218
|
+
expect(status.maxQueueSize).toBe(10);
|
|
219
|
+
expect(status.name).toBe("status");
|
|
220
|
+
|
|
221
|
+
await Promise.all([b1, b2, q1, q2]);
|
|
222
|
+
pool.drain();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("increments totalExecuted after successful runs", async () => {
|
|
226
|
+
const pool = new BulkheadPool("execcount", { maxConcurrent: 5, maxQueueSize: 10 });
|
|
227
|
+
await pool.run(() => Promise.resolve("a"));
|
|
228
|
+
await pool.run(() => Promise.resolve("b"));
|
|
229
|
+
await pool.run(() => Promise.resolve("c"));
|
|
230
|
+
const status = pool.getStatus();
|
|
231
|
+
expect(status.totalExecuted).toBeGreaterThanOrEqual(3);
|
|
232
|
+
pool.drain();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ─── BulkheadPool — drain() ───────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
describe("BulkheadPool — drain()", () => {
|
|
239
|
+
it("rejects all queued tasks immediately", async () => {
|
|
240
|
+
const pool = new BulkheadPool("drain-test", { maxConcurrent: 1, maxQueueSize: 10 });
|
|
241
|
+
const _blocker = pool.run(slowTask(null, 500));
|
|
242
|
+
|
|
243
|
+
const queued1 = pool.run(() => Promise.resolve("q1"));
|
|
244
|
+
const queued2 = pool.run(() => Promise.resolve("q2"));
|
|
245
|
+
|
|
246
|
+
// Drain immediately — queued tasks should be rejected
|
|
247
|
+
pool.drain();
|
|
248
|
+
|
|
249
|
+
await expect(queued1).rejects.toBeInstanceOf(BulkheadRejectedError);
|
|
250
|
+
await expect(queued2).rejects.toBeInstanceOf(BulkheadRejectedError);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ─── BulkheadRegistry ────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
describe("BulkheadRegistry", () => {
|
|
257
|
+
let registry: BulkheadRegistry;
|
|
258
|
+
|
|
259
|
+
beforeEach(() => {
|
|
260
|
+
registry = new BulkheadRegistry();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
afterEach(() => {
|
|
264
|
+
registry.drainAll();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("returns the same BulkheadPool instance for the same name", () => {
|
|
268
|
+
const p1 = registry.get("payments");
|
|
269
|
+
const p2 = registry.get("payments");
|
|
270
|
+
expect(p1).toBe(p2);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("returns different pool instances for different names", () => {
|
|
274
|
+
const payments = registry.get("payments");
|
|
275
|
+
const orders = registry.get("orders");
|
|
276
|
+
expect(payments).not.toBe(orders);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("applies custom config when creating a named pool", async () => {
|
|
280
|
+
const pool = registry.get("narrow", { maxConcurrent: 1, maxQueueSize: 0 });
|
|
281
|
+
const blocker = pool.run(slowTask(null, 50));
|
|
282
|
+
await expect(pool.run(() => Promise.resolve("x"))).rejects.toBeInstanceOf(BulkheadRejectedError);
|
|
283
|
+
await blocker;
|
|
284
|
+
pool.drain();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("getAllStatuses() includes all registered pool names", () => {
|
|
288
|
+
registry.get("svc-a", { maxConcurrent: 5 });
|
|
289
|
+
registry.get("svc-b", { maxConcurrent: 3 });
|
|
290
|
+
|
|
291
|
+
const all = registry.getAllStatuses();
|
|
292
|
+
expect(all).toHaveProperty("svc-a");
|
|
293
|
+
expect(all).toHaveProperty("svc-b");
|
|
294
|
+
expect(all["svc-a"]?.maxConcurrent).toBe(5);
|
|
295
|
+
expect(all["svc-b"]?.maxConcurrent).toBe(3);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("drainAll() does not throw even with empty registry", () => {
|
|
299
|
+
expect(() => registry.drainAll()).not.toThrow();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("uses default config for pools created without explicit config", () => {
|
|
303
|
+
const reg = new BulkheadRegistry({ maxConcurrent: 10, maxQueueSize: 50 });
|
|
304
|
+
const pool = reg.get("default-pool");
|
|
305
|
+
const status = pool.getStatus();
|
|
306
|
+
expect(status.maxConcurrent).toBe(10);
|
|
307
|
+
expect(status.maxQueueSize).toBe(50);
|
|
308
|
+
reg.drainAll();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file circuit-breaker.test.ts
|
|
3
|
+
* @description Tests for CircuitBreaker state machine
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
7
|
+
|
|
8
|
+
vi.mock("../src/event-bus.js", () => ({
|
|
9
|
+
getEventBus: () => ({ emit: vi.fn() }),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("../src/logger.js", () => ({
|
|
13
|
+
getLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const { CircuitBreaker, CircuitBreakerRegistry } = await import("../src/recovery/CircuitBreaker.js");
|
|
17
|
+
const { CircuitOpenError } = await import("../src/types.js");
|
|
18
|
+
|
|
19
|
+
describe("CircuitBreaker", () => {
|
|
20
|
+
it("should start in CLOSED state", () => {
|
|
21
|
+
const breaker = new CircuitBreaker("test", {});
|
|
22
|
+
expect(breaker.getCurrentState()).toBe("CLOSED");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should pass through operations when CLOSED", async () => {
|
|
26
|
+
const breaker = new CircuitBreaker("test", { threshold: 5, minimumRequests: 5 });
|
|
27
|
+
const result = await breaker.execute(async () => "hello");
|
|
28
|
+
expect(result).toBe("hello");
|
|
29
|
+
expect(breaker.getCurrentState()).toBe("CLOSED");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should OPEN after threshold failures", async () => {
|
|
33
|
+
const breaker = new CircuitBreaker("test", {
|
|
34
|
+
threshold: 3,
|
|
35
|
+
minimumRequests: 3,
|
|
36
|
+
failureRateThreshold: 50,
|
|
37
|
+
slidingWindowSize: 10,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const failFn = async () => { throw new Error("service down"); };
|
|
41
|
+
|
|
42
|
+
// Trigger enough failures to open the circuit
|
|
43
|
+
for (let i = 0; i < 5; i++) {
|
|
44
|
+
try { await breaker.execute(failFn); } catch { /* expected */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
expect(breaker.getCurrentState()).toBe("OPEN");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should use fallback when OPEN", async () => {
|
|
51
|
+
const breaker = new CircuitBreaker("test", {
|
|
52
|
+
threshold: 2,
|
|
53
|
+
minimumRequests: 2,
|
|
54
|
+
failureRateThreshold: 50,
|
|
55
|
+
slidingWindowSize: 5,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Open the circuit
|
|
59
|
+
for (let i = 0; i < 5; i++) {
|
|
60
|
+
try {
|
|
61
|
+
await breaker.execute(async () => { throw new Error("fail"); });
|
|
62
|
+
} catch { /* expected */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
expect(breaker.getCurrentState()).toBe("OPEN");
|
|
66
|
+
|
|
67
|
+
// Should use fallback without throwing
|
|
68
|
+
const result = await breaker.execute(
|
|
69
|
+
async () => "normal",
|
|
70
|
+
() => "fallback-value"
|
|
71
|
+
);
|
|
72
|
+
expect(result).toBe("fallback-value");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should throw CircuitOpenError when OPEN and no fallback", async () => {
|
|
76
|
+
const breaker = new CircuitBreaker("payment", {
|
|
77
|
+
threshold: 2,
|
|
78
|
+
minimumRequests: 2,
|
|
79
|
+
failureRateThreshold: 50,
|
|
80
|
+
slidingWindowSize: 5,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < 5; i++) {
|
|
84
|
+
try {
|
|
85
|
+
await breaker.execute(async () => { throw new Error("fail"); });
|
|
86
|
+
} catch { /* expected */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await expect(
|
|
90
|
+
breaker.execute(async () => "should not run")
|
|
91
|
+
).rejects.toBeInstanceOf(CircuitOpenError);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should reset to CLOSED after successful HALF_OPEN probe", async () => {
|
|
95
|
+
const breaker = new CircuitBreaker("test", {
|
|
96
|
+
threshold: 2,
|
|
97
|
+
minimumRequests: 2,
|
|
98
|
+
failureRateThreshold: 50,
|
|
99
|
+
slidingWindowSize: 5,
|
|
100
|
+
cooldownMs: 0, // Instant cooldown for testing
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Open circuit
|
|
104
|
+
for (let i = 0; i < 5; i++) {
|
|
105
|
+
try {
|
|
106
|
+
await breaker.execute(async () => { throw new Error("fail"); });
|
|
107
|
+
} catch { /* expected */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
expect(breaker.getCurrentState()).toBe("OPEN");
|
|
111
|
+
|
|
112
|
+
// Force state to HALF_OPEN (simulating cooldown elapsed)
|
|
113
|
+
breaker.forceState("HALF_OPEN");
|
|
114
|
+
expect(breaker.getCurrentState()).toBe("HALF_OPEN");
|
|
115
|
+
|
|
116
|
+
// Successful probe should close circuit
|
|
117
|
+
const result = await breaker.execute(async () => "probe-success");
|
|
118
|
+
expect(result).toBe("probe-success");
|
|
119
|
+
expect(breaker.getCurrentState()).toBe("CLOSED");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should correctly report status", () => {
|
|
123
|
+
const breaker = new CircuitBreaker("metrics-test", { threshold: 10 });
|
|
124
|
+
const status = breaker.getStatus();
|
|
125
|
+
|
|
126
|
+
expect(status.name).toBe("metrics-test");
|
|
127
|
+
expect(status.state).toBe("CLOSED");
|
|
128
|
+
expect(status.failures).toBe(0);
|
|
129
|
+
expect(status.failureRate).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("CircuitBreakerRegistry", () => {
|
|
134
|
+
it("should create and reuse named circuit breakers", () => {
|
|
135
|
+
const registry = new CircuitBreakerRegistry({ threshold: 5 });
|
|
136
|
+
|
|
137
|
+
const c1 = registry.get("payments");
|
|
138
|
+
const c2 = registry.get("payments");
|
|
139
|
+
|
|
140
|
+
expect(c1).toBe(c2); // Same instance
|
|
141
|
+
expect(c1.name).toBe("payments");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should return all statuses", () => {
|
|
145
|
+
const registry = new CircuitBreakerRegistry();
|
|
146
|
+
registry.get("service-a");
|
|
147
|
+
registry.get("service-b");
|
|
148
|
+
|
|
149
|
+
const statuses = registry.getAllStatuses();
|
|
150
|
+
expect(Object.keys(statuses)).toContain("service-a");
|
|
151
|
+
expect(Object.keys(statuses)).toContain("service-b");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file memory-guard.test.ts
|
|
3
|
+
* @description Tests for MemoryLeakGuard.
|
|
4
|
+
*
|
|
5
|
+
* Actual API (from source):
|
|
6
|
+
* new MemoryLeakGuard(config?: MemoryGuardConfig, bus: ImmortalEventBus)
|
|
7
|
+
* guard.start()
|
|
8
|
+
* guard.stop()
|
|
9
|
+
* guard.trackObject(obj, name, expectedGcMs?)
|
|
10
|
+
* guard["computeGrowthRate"](values) — private, tested via bracket access
|
|
11
|
+
*
|
|
12
|
+
* The class does NOT expose: isRunning(), getCurrentStats(), getTrackedNames()
|
|
13
|
+
* It uses the ImmortalEventBus for all emitted events.
|
|
14
|
+
* restartCooldownMs is not a config field; the field is "growthWindowCount".
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, vi } from "vitest";
|
|
18
|
+
import { MemoryLeakGuard } from "../src/monitoring/MemoryLeakGuard.js";
|
|
19
|
+
import { getEventBus } from "../src/event-bus.js";
|
|
20
|
+
import type { ImmortalEventBus } from "../src/event-bus.js";
|
|
21
|
+
|
|
22
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function sleep(ms: number): Promise<void> {
|
|
25
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Returns a real event bus instance (singleton) */
|
|
29
|
+
function bus(): ImmortalEventBus {
|
|
30
|
+
return getEventBus();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Construction ─────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
describe("MemoryLeakGuard — construction", () => {
|
|
36
|
+
it("creates an instance with default config", () => {
|
|
37
|
+
const guard = new MemoryLeakGuard({}, bus());
|
|
38
|
+
expect(guard).toBeDefined();
|
|
39
|
+
guard.stop();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("creates an instance with custom config", () => {
|
|
43
|
+
const guard = new MemoryLeakGuard(
|
|
44
|
+
{ checkIntervalMs: 5_000, growthWindowCount: 5, warningThresholdMb: 512 },
|
|
45
|
+
bus()
|
|
46
|
+
);
|
|
47
|
+
expect(guard).toBeDefined();
|
|
48
|
+
guard.stop();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not start monitoring automatically", () => {
|
|
52
|
+
// If it auto-started, we'd see interval handles — just verify no throw
|
|
53
|
+
expect(() => {
|
|
54
|
+
const g = new MemoryLeakGuard({}, bus());
|
|
55
|
+
g.stop(); // stop immediately — should be a no-op
|
|
56
|
+
}).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ─── Start / Stop lifecycle ───────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
describe("MemoryLeakGuard — start / stop lifecycle", () => {
|
|
63
|
+
it("start() does not throw", () => {
|
|
64
|
+
const guard = new MemoryLeakGuard({ checkIntervalMs: 60_000 }, bus());
|
|
65
|
+
expect(() => guard.start()).not.toThrow();
|
|
66
|
+
guard.stop();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("calling start() twice is idempotent (no throw)", () => {
|
|
70
|
+
const guard = new MemoryLeakGuard({ checkIntervalMs: 60_000 }, bus());
|
|
71
|
+
guard.start();
|
|
72
|
+
guard.start(); // second call should be a no-op
|
|
73
|
+
guard.stop();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("stop() before start() does not throw", () => {
|
|
77
|
+
const guard = new MemoryLeakGuard({}, bus());
|
|
78
|
+
expect(() => guard.stop()).not.toThrow();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("calling stop() twice is idempotent (no throw)", () => {
|
|
82
|
+
const guard = new MemoryLeakGuard({ checkIntervalMs: 60_000 }, bus());
|
|
83
|
+
guard.start();
|
|
84
|
+
guard.stop();
|
|
85
|
+
expect(() => guard.stop()).not.toThrow(); // second stop is a no-op
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ─── trackObject ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
describe("MemoryLeakGuard — trackObject", () => {
|
|
92
|
+
it("does not throw when object tracking is enabled", () => {
|
|
93
|
+
const guard = new MemoryLeakGuard({ enableObjectTracking: true }, bus());
|
|
94
|
+
const obj = { type: "db-conn" };
|
|
95
|
+
expect(() => guard.trackObject(obj, "pg-conn-1")).not.toThrow();
|
|
96
|
+
guard.stop();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("does nothing (no throw) when object tracking is disabled", () => {
|
|
100
|
+
const guard = new MemoryLeakGuard({ enableObjectTracking: false }, bus());
|
|
101
|
+
const obj = { type: "conn" };
|
|
102
|
+
expect(() => guard.trackObject(obj, "ignored-conn")).not.toThrow();
|
|
103
|
+
guard.stop();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("accepts optional expectedGcMs parameter", () => {
|
|
107
|
+
const guard = new MemoryLeakGuard({ enableObjectTracking: true }, bus());
|
|
108
|
+
const obj = { x: 1 };
|
|
109
|
+
expect(() => guard.trackObject(obj, "my-obj", 60_000)).not.toThrow();
|
|
110
|
+
guard.stop();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─── Internal: computeGrowthRate (linear regression) ─────────────────────────
|
|
115
|
+
|
|
116
|
+
describe("MemoryLeakGuard — computeGrowthRate (linear regression)", () => {
|
|
117
|
+
it("returns a positive rate for monotonically increasing samples", () => {
|
|
118
|
+
const guard = new MemoryLeakGuard({ checkIntervalMs: 1_000 }, bus());
|
|
119
|
+
// Use bracket access to test private method
|
|
120
|
+
const lr = (guard as unknown as { computeGrowthRate(v: number[]): number }).computeGrowthRate;
|
|
121
|
+
if (!lr) { guard.stop(); return; }
|
|
122
|
+
|
|
123
|
+
// y = 2i + 100 → slope = 2MB per interval → positive growth rate
|
|
124
|
+
const samples = [100, 102, 104, 106, 108, 110];
|
|
125
|
+
const rate = lr.call(guard, samples);
|
|
126
|
+
expect(rate).toBeGreaterThan(0);
|
|
127
|
+
guard.stop();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns near-zero rate for flat / sawtooth samples", () => {
|
|
131
|
+
const guard = new MemoryLeakGuard({ checkIntervalMs: 1_000 }, bus());
|
|
132
|
+
const lr = (guard as unknown as { computeGrowthRate(v: number[]): number }).computeGrowthRate;
|
|
133
|
+
if (!lr) { guard.stop(); return; }
|
|
134
|
+
|
|
135
|
+
// Flat
|
|
136
|
+
const flat = [200, 200, 200, 200, 200, 200];
|
|
137
|
+
const rate = lr.call(guard, flat);
|
|
138
|
+
expect(Math.abs(rate)).toBeLessThan(1);
|
|
139
|
+
guard.stop();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns 0 for a single-element array", () => {
|
|
143
|
+
const guard = new MemoryLeakGuard({ checkIntervalMs: 1_000 }, bus());
|
|
144
|
+
const lr = (guard as unknown as { computeGrowthRate(v: number[]): number }).computeGrowthRate;
|
|
145
|
+
if (!lr) { guard.stop(); return; }
|
|
146
|
+
|
|
147
|
+
expect(lr.call(guard, [200])).toBe(0);
|
|
148
|
+
guard.stop();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns 0 for empty array", () => {
|
|
152
|
+
const guard = new MemoryLeakGuard({ checkIntervalMs: 1_000 }, bus());
|
|
153
|
+
const lr = (guard as unknown as { computeGrowthRate(v: number[]): number }).computeGrowthRate;
|
|
154
|
+
if (!lr) { guard.stop(); return; }
|
|
155
|
+
|
|
156
|
+
expect(lr.call(guard, [])).toBe(0);
|
|
157
|
+
guard.stop();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ─── Event emission ───────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe("MemoryLeakGuard — sampling emits events", () => {
|
|
164
|
+
it("emits memory:warning or memory:critical from the bus after a forced sample", async () => {
|
|
165
|
+
const eventBus = bus();
|
|
166
|
+
const guard = new MemoryLeakGuard(
|
|
167
|
+
{
|
|
168
|
+
checkIntervalMs: 60_000,
|
|
169
|
+
// Set thresholds below current heap to force events
|
|
170
|
+
warningThresholdMb: 0.001,
|
|
171
|
+
restartThresholdMb: 0.001,
|
|
172
|
+
growthWindowCount: 1000, // prevent restart
|
|
173
|
+
adaptiveThresholds: false,
|
|
174
|
+
},
|
|
175
|
+
eventBus
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const events: string[] = [];
|
|
179
|
+
eventBus.on("memory:warning", () => events.push("warning"));
|
|
180
|
+
eventBus.on("memory:critical", () => events.push("critical"));
|
|
181
|
+
|
|
182
|
+
// Force a sample by calling the private method
|
|
183
|
+
const sampleFn = (guard as unknown as { sample(): void }).sample;
|
|
184
|
+
if (sampleFn) {
|
|
185
|
+
sampleFn.call(guard);
|
|
186
|
+
await sleep(10);
|
|
187
|
+
// We expect at least one event (warning or critical)
|
|
188
|
+
expect(events.length).toBeGreaterThan(0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
guard.stop();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ─── Default config values ────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
describe("MemoryLeakGuard — config merging", () => {
|
|
198
|
+
it("uses default checkIntervalMs if not provided", () => {
|
|
199
|
+
const guard = new MemoryLeakGuard({}, bus());
|
|
200
|
+
// Access private config via bracket — just verify it merged
|
|
201
|
+
const cfg = (guard as unknown as { config: { checkIntervalMs: number } }).config;
|
|
202
|
+
expect(typeof cfg.checkIntervalMs).toBe("number");
|
|
203
|
+
expect(cfg.checkIntervalMs).toBeGreaterThan(0);
|
|
204
|
+
guard.stop();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("overrides defaults with provided config", () => {
|
|
208
|
+
const guard = new MemoryLeakGuard({ checkIntervalMs: 99_000 }, bus());
|
|
209
|
+
const cfg = (guard as unknown as { config: { checkIntervalMs: number } }).config;
|
|
210
|
+
expect(cfg.checkIntervalMs).toBe(99_000);
|
|
211
|
+
guard.stop();
|
|
212
|
+
});
|
|
213
|
+
});
|