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,188 @@
|
|
|
1
|
+
# Guide — Fastify Integration
|
|
2
|
+
|
|
3
|
+
This guide walks you through integrating Immortal.js with a Fastify application.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @immortal/core @immortal/fastify fastify
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import Fastify from 'fastify';
|
|
19
|
+
import immortalPlugin from '@immortal/fastify';
|
|
20
|
+
|
|
21
|
+
const app = Fastify({ logger: true });
|
|
22
|
+
|
|
23
|
+
await app.register(immortalPlugin, {
|
|
24
|
+
circuitBreakers: true,
|
|
25
|
+
bulkheads: true,
|
|
26
|
+
metrics: true,
|
|
27
|
+
gracefulShutdown: true,
|
|
28
|
+
immortalConfig: {
|
|
29
|
+
logger: { level: 'info' },
|
|
30
|
+
memoryGuard: { enabled: true, warningThresholdMb: 400 },
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
app.get('/api/products', async (req, reply) => {
|
|
35
|
+
return { products: [] };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await app.listen({ port: 3000, host: '0.0.0.0' });
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Manual integration (full control)
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import Fastify from 'fastify';
|
|
47
|
+
import {
|
|
48
|
+
createImmortal,
|
|
49
|
+
CircuitBreaker,
|
|
50
|
+
BulkheadPool,
|
|
51
|
+
withRetry,
|
|
52
|
+
withTimeout,
|
|
53
|
+
RouteFallbackCache,
|
|
54
|
+
ChaosEngine,
|
|
55
|
+
asyncBoundary,
|
|
56
|
+
} from '@immortal/core';
|
|
57
|
+
|
|
58
|
+
const immortal = await createImmortal({
|
|
59
|
+
logger: { level: process.env.LOG_LEVEL ?? 'info' },
|
|
60
|
+
healthMonitor: { enabled: true },
|
|
61
|
+
gracefulShutdown: { enabled: true, timeoutMs: 30_000 },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const app = Fastify();
|
|
65
|
+
|
|
66
|
+
// Circuit breaker per downstream service
|
|
67
|
+
const dbBreaker = new CircuitBreaker('postgres', { threshold: 5, timeout: 30_000 });
|
|
68
|
+
const cacheBreaker = new CircuitBreaker('redis', { threshold: 3, timeout: 10_000 });
|
|
69
|
+
|
|
70
|
+
// Bulkheads per resource type
|
|
71
|
+
const dbPool = new BulkheadPool('database', { maxConcurrent: 10, maxQueueSize: 50 });
|
|
72
|
+
const apiPool = new BulkheadPool('external-api', { maxConcurrent: 5 });
|
|
73
|
+
|
|
74
|
+
// Fallback cache for stale data
|
|
75
|
+
const fallbackCache = new RouteFallbackCache({ ttlMs: 5 * 60_000 });
|
|
76
|
+
|
|
77
|
+
// Request tracing hook
|
|
78
|
+
app.addHook('onRequest', async (req) => {
|
|
79
|
+
req.immortalRequestId = req.headers['x-request-id'] as string ?? crypto.randomUUID();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Metrics hook
|
|
83
|
+
app.addHook('onResponse', async (req, reply) => {
|
|
84
|
+
// record metrics if needed
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ─────────────────────────────────────────────────────────
|
|
88
|
+
// Routes
|
|
89
|
+
// ─────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
app.get('/api/products/:id', asyncBoundary(async (req, reply) => {
|
|
92
|
+
const { id } = req.params as { id: string };
|
|
93
|
+
const cacheKey = `GET /products/${id}`;
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const product = await dbPool.run(() =>
|
|
97
|
+
dbBreaker.run(() =>
|
|
98
|
+
withRetry(() => db.findProduct(id), { maxAttempts: 2 })
|
|
99
|
+
)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
fallbackCache.set(cacheKey, product);
|
|
103
|
+
return product;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const cached = fallbackCache.get(cacheKey);
|
|
106
|
+
if (cached) return { ...cached, stale: true };
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
app.post('/api/orders', asyncBoundary(async (req, reply) => {
|
|
112
|
+
const body = req.body as CreateOrderDto;
|
|
113
|
+
|
|
114
|
+
const result = await dbPool.run(() =>
|
|
115
|
+
withTimeout(
|
|
116
|
+
() => dbBreaker.run(() => db.createOrder(body)),
|
|
117
|
+
10_000,
|
|
118
|
+
'create-order'
|
|
119
|
+
)
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
reply.code(201);
|
|
123
|
+
return result;
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
// Health endpoint (Kubernetes liveness/readiness)
|
|
127
|
+
app.get('/health', async () => ({
|
|
128
|
+
status: immortal.isHealthy() ? 'ok' : 'degraded',
|
|
129
|
+
timestamp: new Date().toISOString(),
|
|
130
|
+
metrics: immortal.getMetrics(),
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
// Metrics endpoint
|
|
134
|
+
app.get('/metrics', async () => immortal.getMetrics());
|
|
135
|
+
|
|
136
|
+
// ─────────────────────────────────────────────────────────
|
|
137
|
+
// Graceful shutdown
|
|
138
|
+
// ─────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
process.on('SIGTERM', async () => {
|
|
141
|
+
await app.close();
|
|
142
|
+
await immortal.shutdown('SIGTERM');
|
|
143
|
+
process.exit(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await app.listen({ port: 3000, host: '0.0.0.0' });
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Full example
|
|
152
|
+
|
|
153
|
+
See [`examples/fastify-microservice/index.ts`](../../examples/fastify-microservice/index.ts) for a production-ready example with:
|
|
154
|
+
|
|
155
|
+
- Circuit breakers for every downstream service
|
|
156
|
+
- Bulkheads for database and external API calls
|
|
157
|
+
- Retry with exponential backoff
|
|
158
|
+
- Fallback cache for stale data
|
|
159
|
+
- Chaos engineering endpoints
|
|
160
|
+
- Health and metrics routes
|
|
161
|
+
- Graceful shutdown
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Error handling
|
|
166
|
+
|
|
167
|
+
Immortal.js provides structured error types. Map them to HTTP status codes:
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
import { ImmortalError, BulkheadRejectedError, CircuitOpenError, classifyError } from '@immortal/core';
|
|
171
|
+
|
|
172
|
+
app.setErrorHandler((err, req, reply) => {
|
|
173
|
+
if (err instanceof BulkheadRejectedError) {
|
|
174
|
+
return reply.code(503).send({ error: 'Service overloaded', retryAfter: 1 });
|
|
175
|
+
}
|
|
176
|
+
if (err instanceof CircuitOpenError) {
|
|
177
|
+
return reply.code(503).send({ error: 'Dependency unavailable' });
|
|
178
|
+
}
|
|
179
|
+
if (err instanceof ImmortalError) {
|
|
180
|
+
const code = err.statusCode ?? (err.kind === 'programming' ? 500 : 503);
|
|
181
|
+
return reply.code(code).send({ error: err.message, kind: err.kind });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const kind = classifyError(err);
|
|
185
|
+
const code = kind === 'programming' ? 500 : 503;
|
|
186
|
+
return reply.code(code).send({ error: 'Internal error', kind });
|
|
187
|
+
});
|
|
188
|
+
```
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Guide — Koa Integration
|
|
2
|
+
|
|
3
|
+
This guide covers integrating Immortal.js with Koa.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @immortal/core @immortal/koa koa
|
|
11
|
+
npm install -D @types/koa
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Plugin setup
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
import Koa from 'koa';
|
|
20
|
+
import { immortalMiddleware } from '@immortal/koa';
|
|
21
|
+
|
|
22
|
+
const app = new Koa();
|
|
23
|
+
|
|
24
|
+
app.use(immortalMiddleware({
|
|
25
|
+
circuitBreakers: true,
|
|
26
|
+
bulkheads: true,
|
|
27
|
+
metrics: true,
|
|
28
|
+
immortalConfig: {
|
|
29
|
+
logger: { level: 'info' },
|
|
30
|
+
gracefulShutdown: { enabled: true, timeoutMs: 30_000 },
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Manual integration
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import Koa from 'koa';
|
|
41
|
+
import Router from '@koa/router';
|
|
42
|
+
import {
|
|
43
|
+
createImmortal,
|
|
44
|
+
CircuitBreaker,
|
|
45
|
+
BulkheadPool,
|
|
46
|
+
withRetry,
|
|
47
|
+
asyncBoundary,
|
|
48
|
+
classifyError,
|
|
49
|
+
} from '@immortal/core';
|
|
50
|
+
|
|
51
|
+
const immortal = await createImmortal({ logger: { level: 'info' } });
|
|
52
|
+
const app = new Koa();
|
|
53
|
+
const router = new Router();
|
|
54
|
+
|
|
55
|
+
const dbBreaker = new CircuitBreaker('database', { threshold: 5 });
|
|
56
|
+
const dbPool = new BulkheadPool('database', { maxConcurrent: 10 });
|
|
57
|
+
|
|
58
|
+
// Request tracing
|
|
59
|
+
app.use(async (ctx, next) => {
|
|
60
|
+
const requestId = ctx.get('x-request-id') || crypto.randomUUID();
|
|
61
|
+
ctx.set('x-request-id', requestId);
|
|
62
|
+
ctx.state.requestId = requestId;
|
|
63
|
+
await next();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Error handling
|
|
67
|
+
app.use(async (ctx, next) => {
|
|
68
|
+
try {
|
|
69
|
+
await next();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const kind = classifyError(err);
|
|
72
|
+
ctx.status = kind === 'programming' ? 500 : 503;
|
|
73
|
+
ctx.body = { error: (err as Error).message, kind };
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Route
|
|
78
|
+
router.get('/api/products/:id', async (ctx) => {
|
|
79
|
+
const { id } = ctx.params;
|
|
80
|
+
|
|
81
|
+
ctx.body = await dbPool.run(() =>
|
|
82
|
+
dbBreaker.run(() =>
|
|
83
|
+
withRetry(() => db.findProduct(id), { maxAttempts: 2 })
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
router.get('/health', (ctx) => {
|
|
89
|
+
ctx.body = { status: immortal.isHealthy() ? 'ok' : 'degraded' };
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
app.use(router.routes());
|
|
93
|
+
app.use(router.allowedMethods());
|
|
94
|
+
|
|
95
|
+
const server = app.listen(3000);
|
|
96
|
+
|
|
97
|
+
process.on('SIGTERM', async () => {
|
|
98
|
+
server.close();
|
|
99
|
+
await immortal.shutdown('SIGTERM');
|
|
100
|
+
process.exit(0);
|
|
101
|
+
});
|
|
102
|
+
```
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Guide — NestJS Integration
|
|
2
|
+
|
|
3
|
+
This guide covers integrating Immortal.js into a NestJS application using the `@immortal/nestjs` module and its method decorators.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @immortal/core @immortal/nestjs @nestjs/core @nestjs/common reflect-metadata rxjs
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Module setup
|
|
16
|
+
|
|
17
|
+
Import `ImmortalModule` in your root `AppModule`:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
// app.module.ts
|
|
21
|
+
import { Module } from '@nestjs/common';
|
|
22
|
+
import { ImmortalModule } from '@immortal/nestjs';
|
|
23
|
+
|
|
24
|
+
@Module({
|
|
25
|
+
imports: [
|
|
26
|
+
ImmortalModule.forRoot({
|
|
27
|
+
logger: { level: 'info' },
|
|
28
|
+
healthMonitor: { enabled: true },
|
|
29
|
+
memoryGuard: { enabled: true, warningThresholdMb: 400 },
|
|
30
|
+
gracefulShutdown: { enabled: true, timeoutMs: 30_000 },
|
|
31
|
+
}),
|
|
32
|
+
],
|
|
33
|
+
})
|
|
34
|
+
export class AppModule {}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Async configuration
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
ImmortalModule.forRootAsync({
|
|
41
|
+
useFactory: (configService: ConfigService) => ({
|
|
42
|
+
logger: { level: configService.get('LOG_LEVEL') },
|
|
43
|
+
gracefulShutdown: { enabled: true },
|
|
44
|
+
}),
|
|
45
|
+
inject: [ConfigService],
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Injecting the runtime
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { Injectable } from '@nestjs/common';
|
|
55
|
+
import { InjectImmortal, ImmortalService } from '@immortal/nestjs';
|
|
56
|
+
|
|
57
|
+
@Injectable()
|
|
58
|
+
export class MetricsController {
|
|
59
|
+
constructor(private readonly immortal: ImmortalService) {}
|
|
60
|
+
|
|
61
|
+
getHealth() {
|
|
62
|
+
return {
|
|
63
|
+
status: this.immortal.isHealthy() ? 'ok' : 'degraded',
|
|
64
|
+
metrics: this.immortal.getMetrics(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Method decorators
|
|
73
|
+
|
|
74
|
+
### `@Retry(options?)`
|
|
75
|
+
|
|
76
|
+
Wrap a service method with automatic retry:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { Retry } from '@immortal/nestjs';
|
|
80
|
+
|
|
81
|
+
@Injectable()
|
|
82
|
+
export class PaymentService {
|
|
83
|
+
@Retry({ maxAttempts: 3, baseDelayMs: 200, backoffFactor: 2 })
|
|
84
|
+
async chargeCustomer(customerId: string, amount: number) {
|
|
85
|
+
return this.paymentApi.charge(customerId, amount);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### `@Circuit(options?)`
|
|
91
|
+
|
|
92
|
+
Protect a method with a circuit breaker:
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { Circuit } from '@immortal/nestjs';
|
|
96
|
+
|
|
97
|
+
@Injectable()
|
|
98
|
+
export class OrdersService {
|
|
99
|
+
@Circuit({ name: 'orders-db', threshold: 5, timeout: 30_000 })
|
|
100
|
+
async findOrders(customerId: string) {
|
|
101
|
+
return this.db.orders.findByCustomer(customerId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The circuit breaker is shared across all calls to the same `name`. Use the same name for related methods that hit the same downstream service.
|
|
107
|
+
|
|
108
|
+
### `@Bulkhead(options?)`
|
|
109
|
+
|
|
110
|
+
Limit concurrency for a method:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
import { Bulkhead } from '@immortal/nestjs';
|
|
114
|
+
|
|
115
|
+
@Injectable()
|
|
116
|
+
export class ReportService {
|
|
117
|
+
@Bulkhead({ pool: 'reports', maxConcurrent: 2, maxQueueSize: 5 })
|
|
118
|
+
async generateReport(params: ReportParams) {
|
|
119
|
+
return this.reportEngine.generate(params);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Combining decorators
|
|
125
|
+
|
|
126
|
+
Decorators compose — apply multiple for layered resilience:
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
@Injectable()
|
|
130
|
+
export class InventoryService {
|
|
131
|
+
@Bulkhead({ pool: 'inventory', maxConcurrent: 5 })
|
|
132
|
+
@Circuit({ name: 'inventory-db', threshold: 3 })
|
|
133
|
+
@Retry({ maxAttempts: 2 })
|
|
134
|
+
async getStock(productId: string): Promise<StockLevel> {
|
|
135
|
+
return this.db.inventory.find(productId);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**Execution order** (outermost to innermost):
|
|
141
|
+
1. `@Bulkhead` — wait for a slot
|
|
142
|
+
2. `@Circuit` — check if circuit is open
|
|
143
|
+
3. `@Retry` — retry the actual DB call
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Health check endpoint
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
import { Controller, Get } from '@nestjs/common';
|
|
151
|
+
import { ImmortalService } from '@immortal/nestjs';
|
|
152
|
+
|
|
153
|
+
@Controller()
|
|
154
|
+
export class HealthController {
|
|
155
|
+
constructor(private readonly immortal: ImmortalService) {}
|
|
156
|
+
|
|
157
|
+
@Get('/health')
|
|
158
|
+
health() {
|
|
159
|
+
return {
|
|
160
|
+
status: this.immortal.isHealthy() ? 'ok' : 'degraded',
|
|
161
|
+
timestamp: new Date().toISOString(),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
@Get('/metrics')
|
|
166
|
+
metrics() {
|
|
167
|
+
return this.immortal.getMetrics();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Full example
|
|
175
|
+
|
|
176
|
+
See [`examples/nestjs-enterprise/`](../../examples/nestjs-enterprise/) for a complete application with:
|
|
177
|
+
|
|
178
|
+
- `ImmortalModule.forRoot` configuration
|
|
179
|
+
- `OrdersService` with `@Bulkhead`, `@Circuit`, `@Retry`
|
|
180
|
+
- `PaymentsService` with circuit breaker
|
|
181
|
+
- Health and metrics endpoints
|
|
182
|
+
- Graceful shutdown integration
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Testing Guide
|
|
2
|
+
|
|
3
|
+
This guide covers testing patterns for applications using Immortal.js.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Running the test suite
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# All packages
|
|
11
|
+
npm test --workspaces --if-present
|
|
12
|
+
|
|
13
|
+
# Core package only
|
|
14
|
+
cd packages/core && npm test
|
|
15
|
+
|
|
16
|
+
# Watch mode
|
|
17
|
+
cd packages/core && npm test -- --watch
|
|
18
|
+
|
|
19
|
+
# Coverage
|
|
20
|
+
cd packages/core && npm test -- --coverage
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Mocking the event bus
|
|
26
|
+
|
|
27
|
+
In unit tests, mock the event bus to avoid side effects:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { vi, describe, it, beforeEach } from 'vitest';
|
|
31
|
+
|
|
32
|
+
vi.mock('../src/event-bus.js', () => ({
|
|
33
|
+
getEventBus: () => ({ emit: vi.fn(), on: vi.fn(), onAny: vi.fn() }),
|
|
34
|
+
resetEventBus: vi.fn(),
|
|
35
|
+
}));
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Testing circuit breakers
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { CircuitBreaker } from '@immortal/core';
|
|
44
|
+
|
|
45
|
+
it('opens after threshold failures', async () => {
|
|
46
|
+
const cb = new CircuitBreaker('test', { threshold: 2, timeout: 100 });
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < 2; i++) {
|
|
49
|
+
await expect(cb.run(() => Promise.reject(new Error('fail')))).rejects.toThrow();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
expect(cb.getCurrentState()).toBe('open');
|
|
53
|
+
await expect(cb.run(() => Promise.resolve('ok'))).rejects.toThrow(/circuit.*open/i);
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Testing bulkheads
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import { BulkheadPool, BulkheadRejectedError } from '@immortal/core';
|
|
63
|
+
|
|
64
|
+
it('rejects when saturated', async () => {
|
|
65
|
+
const pool = new BulkheadPool('test', { maxConcurrent: 1, maxQueueSize: 0 });
|
|
66
|
+
|
|
67
|
+
const hold = pool.run(() => new Promise(resolve => setTimeout(resolve, 100)));
|
|
68
|
+
|
|
69
|
+
await expect(pool.run(() => Promise.resolve())).rejects.toBeInstanceOf(BulkheadRejectedError);
|
|
70
|
+
await hold;
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Testing with fake timers
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { vi } from 'vitest';
|
|
80
|
+
|
|
81
|
+
it('retries with backoff', async () => {
|
|
82
|
+
vi.useFakeTimers();
|
|
83
|
+
const fn = vi.fn().mockRejectedValueOnce(new Error('fail')).mockResolvedValue('ok');
|
|
84
|
+
|
|
85
|
+
const promise = withRetry(fn, { maxAttempts: 2, baseDelayMs: 1_000 });
|
|
86
|
+
await vi.advanceTimersByTimeAsync(1_100);
|
|
87
|
+
expect(await promise).toBe('ok');
|
|
88
|
+
|
|
89
|
+
vi.useRealTimers();
|
|
90
|
+
});
|
|
91
|
+
```
|