Skip to content

Commit c39bf2a

Browse files
authored
Merge branch 'main' into feat/circuit-breaker-apis
2 parents 7cdfca4 + 665a2b7 commit c39bf2a

23 files changed

Lines changed: 1419 additions & 138 deletions

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ CORS_ORIGIN=http://localhost:3000,http://localhost:5173
1212
RATE_LIMIT_WINDOW_MS=900000
1313
RATE_LIMIT_MAX_REQUESTS=100
1414

15+
# Max milliseconds to wait for in-flight HTTP, Socket.IO, and DB work
16+
# before forcing process exit on SIGTERM/SIGINT (default: 30000).
17+
SHUTDOWN_TIMEOUT_MS=30000
18+
1519
# ─── Stellar / Soroban ─────────────────────────────────────────────────────────
1620
# Soroban RPC endpoint.
1721
# Testnet : https://soroban-testnet.stellar.org

package-lock.json

Lines changed: 99 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"express-rate-limit": "6.11.0",
2525
"helmet": "7.1.0",
2626
"http-status-codes": "2.3.0",
27+
"ioredis": "^5.3.2",
2728
"jsonwebtoken": "9.0.2",
2829
"mongoose": "^7.6.3",
2930
"multer": "^2.2.0",
@@ -43,6 +44,7 @@
4344
"@types/compression": "1.7.5",
4445
"@types/cors": "^2.8.17",
4546
"@types/express": "^4.17.21",
47+
"@types/ioredis": "^4.28.10",
4648
"@types/jest": "^30.0.0",
4749
"@types/jsonwebtoken": "9.0.5",
4850
"@types/mongodb-memory-server": "^1.8.0",

src/app.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import logger from './config/logger';
1313
import { connectDatabase } from './config/database';
1414
import errorHandler from './middleware/errorHandler';
1515
import requestLogger from './middleware/requestLogger';
16+
import { requestTracker } from './middleware/requestTracker';
1617
import env from './config/env';
1718
import swaggerSpec from './docs/swagger';
1819

@@ -26,6 +27,8 @@ app.set('trust proxy', 1);
2627

2728
app.use(helmet());
2829
app.use(compression());
30+
// Track in-flight requests and reject new ones during graceful shutdown.
31+
app.use(requestTracker);
2932
app.use(requestLogger);
3033

3134
// Swagger UI needs inline <script>/<style>, which the default Helmet CSP

src/config/database.ts

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,70 @@
1-
import mongoose from 'mongoose';
1+
import mongoose, { ClientSession } from 'mongoose';
22
import logger from './logger';
33
import env from './env';
44

5+
/** Active multi-document sessions started via `startTrackedSession`. */
6+
const activeSessions = new Set<ClientSession>();
7+
8+
export const getActiveTransactionCount = (): number => activeSessions.size;
9+
10+
/**
11+
* Start a mongoose session that is registered for graceful-shutdown
12+
* draining. Prefer this over `mongoose.startSession()` for any work that
13+
* must finish (or be waited on) before the process exits.
14+
*/
15+
export const startTrackedSession = async (): Promise<ClientSession> => {
16+
const session = await mongoose.startSession();
17+
activeSessions.add(session);
18+
19+
const originalEndSession = session.endSession.bind(session);
20+
session.endSession = (async (
21+
...args: Parameters<ClientSession['endSession']>
22+
): Promise<void> => {
23+
activeSessions.delete(session);
24+
await originalEndSession(...args);
25+
}) as ClientSession['endSession'];
26+
27+
return session;
28+
};
29+
30+
/**
31+
* Poll until all tracked sessions have ended, or until `timeoutMs` elapses.
32+
* Does not abort sessions — callers should finish or abort their own work.
33+
*/
34+
export const waitForActiveTransactions = async (timeoutMs: number): Promise<void> => {
35+
const deadline = Date.now() + timeoutMs;
36+
const pollMs = 100;
37+
38+
while (activeSessions.size > 0 && Date.now() < deadline) {
39+
logger.info(
40+
`[Database] Waiting for ${activeSessions.size} active transaction session(s)...`,
41+
);
42+
await new Promise((resolve) => setTimeout(resolve, pollMs));
43+
}
44+
45+
if (activeSessions.size > 0) {
46+
logger.warn(
47+
`[Database] Proceeding with ${activeSessions.size} active session(s) still open`,
48+
);
49+
} else {
50+
logger.info('[Database] Active transaction sessions drained');
51+
}
52+
};
53+
54+
/**
55+
* Close the mongoose connection after outstanding buffered operations
56+
* complete (`force = false`). Safe to call when already disconnected.
57+
*/
58+
export const disconnectDatabase = async (): Promise<void> => {
59+
if (mongoose.connection.readyState === 0) {
60+
logger.info('[Database] MongoDB already disconnected');
61+
return;
62+
}
63+
64+
await mongoose.connection.close(false);
65+
logger.info('[Database] MongoDB connection closed');
66+
};
67+
568
export const connectDatabase = async (): Promise<void> => {
669
try {
770
const mongoUri = env.MONGODB_URI;
@@ -31,11 +94,8 @@ export const connectDatabase = async (): Promise<void> => {
3194
logger.info(`MongoDB connected to ${mongoose.connection.host}`);
3295
});
3396

34-
process.on('SIGINT', async () => {
35-
await mongoose.connection.close();
36-
logger.info('MongoDB connection closed through app termination');
37-
process.exit(0);
38-
});
97+
// SIGINT / SIGTERM are handled centrally by GracefulShutdownService so
98+
// we do not register a competing process.exit handler here.
3999
} catch (error) {
40100
logger.error('❌ Failed to connect to MongoDB:', error);
41101
process.exit(1);

src/config/redis.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Redis from 'ioredis';
2+
import env from './env';
3+
import logger from './logger';
4+
5+
/**
6+
* Lazily-created Redis client singleton.
7+
*
8+
* The client is only instantiated when REDIS_URL is present in the
9+
* environment. When absent, `redisClient` is `null` and the idempotency
10+
* service falls back to the MongoDB-backed store automatically.
11+
*
12+
* Connection errors are logged but never crash the process — the fallback
13+
* path ensures the API keeps running even if Redis is temporarily unavailable.
14+
*/
15+
let redisClient: Redis | null = null;
16+
17+
if (env.REDIS_URL) {
18+
redisClient = new Redis(env.REDIS_URL, {
19+
// Retry with exponential back-off, capped at 10 s, up to 10 attempts.
20+
retryStrategy: (times: number): number | null => {
21+
if (times > 10) {
22+
logger.error('[Redis] Max reconnection attempts reached — giving up');
23+
return null; // stop retrying
24+
}
25+
return Math.min(times * 200, 10_000);
26+
},
27+
// Surface connection errors rather than swallowing them silently.
28+
enableOfflineQueue: false,
29+
lazyConnect: false,
30+
maxRetriesPerRequest: 3,
31+
});
32+
33+
redisClient.on('connect', () => logger.info('[Redis] Connected'));
34+
redisClient.on('ready', () => logger.info('[Redis] Ready'));
35+
redisClient.on('error', (err: Error) => logger.error('[Redis] Error:', err.message));
36+
redisClient.on('close', () => logger.warn('[Redis] Connection closed'));
37+
redisClient.on('reconnecting', () => logger.info('[Redis] Reconnecting…'));
38+
} else {
39+
logger.info('[Redis] REDIS_URL not set — idempotency will use MongoDB fallback store');
40+
}
41+
42+
export default redisClient;

0 commit comments

Comments
 (0)