Skip to content

Commit 3d71815

Browse files
committed
ft:graceful-shutdown
1 parent 97b26f6 commit 3d71815

107 files changed

Lines changed: 3087 additions & 1333 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
**/node_modules
2+
**/dist
3+
temp_workspaces
4+
.git
5+
.DS_Store

.github/workflows/ci.yml

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,7 @@ jobs:
3838
uses: actions/checkout@v3
3939

4040
- name: Set up Docker Buildx
41-
uses: actions/setup-qemu-action@v2
42-
43-
- name: Set up Docker Build
44-
uses: actions/setup-buildx-action@v2
41+
uses: docker/setup-buildx-action@v3
4542

4643
- name: Build Core Microservice
4744
uses: docker/build-push-action@v4
@@ -63,6 +60,16 @@ jobs:
6360
cache-from: type=gha
6461
cache-to: type=gha,mode=max
6562

63+
- name: Build Node Evaluation Service
64+
uses: docker/build-push-action@v4
65+
with:
66+
context: .
67+
file: ./evaluation-service/Dockerfile
68+
push: false
69+
tags: codewarz/evaluation-service:latest
70+
cache-from: type=gha
71+
cache-to: type=gha,mode=max
72+
6673
- name: Build Leaderboard Service
6774
uses: docker/build-push-action@v4
6875
with:
@@ -82,3 +89,13 @@ jobs:
8289
tags: codewarz/api-gateway:latest
8390
cache-from: type=gha
8491
cache-to: type=gha,mode=max
92+
93+
- name: Build Web Frontend
94+
uses: docker/build-push-action@v4
95+
with:
96+
context: .
97+
file: ./web/Dockerfile
98+
push: false
99+
tags: codewarz/web:latest
100+
cache-from: type=gha
101+
cache-to: type=gha,mode=max

.gitignore

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Dependencies
2+
node_modules/
3+
.pnp/
4+
.pnp.js
5+
6+
# Build outputs
7+
dist/
8+
build/
9+
*.tsbuildinfo
10+
11+
# Environment files (CRITICAL: never commit secrets)
12+
.env
13+
.env.local
14+
.env.development
15+
.env.production
16+
.env.*.local
17+
*.env
18+
.env.bak
19+
20+
# Logs
21+
logs/
22+
*.log
23+
npm-debug.log*
24+
yarn-debug.log*
25+
yarn-error.log*
26+
27+
# Runtime / OS
28+
.DS_Store
29+
*.swp
30+
*.swo
31+
.idea/
32+
.vscode/*
33+
!.vscode/settings.json
34+
*.pid
35+
*.seed
36+
*.pid.lock
37+
38+
# Test coverage
39+
coverage/
40+
.nyc_output/
41+
42+
# Build artifacts
43+
*.tgz
44+
*.tar.gz
45+
46+
# Workspace / sandbox
47+
temp_workspaces/*
48+
!temp_workspaces/.gitkeep
49+
50+
# Docker
51+
Dockerfile.local
52+
docker-compose.override.yml

api-gateway/src/config/tracing.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ import { JaegerExporter } from '@opentelemetry/exporter-jaeger';
33
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
44
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
55
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
6+
import logger from './logger.config';
67

78
const SERVICE_NAME = process.env.SERVICE_NAME || 'api-gateway';
8-
const JAEGER_ENDPOINT = process.env.JAEGER_URL || 'http://localhost:14268/api/traces';
9+
function buildJaegerEndpoint(): string {
10+
const raw = process.env.JAEGER_URL || 'http://localhost:14268';
11+
return raw.endsWith('/api/traces') ? raw : `${raw.replace(/\/$/, '')}/api/traces`;
12+
}
13+
const JAEGER_ENDPOINT = buildJaegerEndpoint();
914

1015
let sdk: NodeSDK | null = null;
1116

@@ -34,14 +39,18 @@ export function initTracing(): NodeSDK | null {
3439

3540
sdk.start();
3641

37-
console.log(`[API-GATEWAY] OpenTelemetry tracing initialized`, { jaegerEndpoint: JAEGER_ENDPOINT });
38-
39-
process.on('SIGTERM', () => {
40-
sdk?.shutdown()
41-
.then(() => console.log('[API-GATEWAY] Tracing terminated'))
42-
.catch((error) => console.error('[API-GATEWAY] Error terminating tracing', error))
43-
.finally(() => process.exit(0));
44-
});
42+
logger.info(`[API-GATEWAY] OpenTelemetry tracing initialized`, { jaegerEndpoint: JAEGER_ENDPOINT });
4543

4644
return sdk;
4745
}
46+
47+
export async function shutdownTracing() {
48+
if (sdk) {
49+
try {
50+
await sdk.shutdown();
51+
logger.info('Tracing terminated');
52+
} catch (error: any) {
53+
logger.error('Error terminating tracing', { error: error.message });
54+
}
55+
}
56+
}

api-gateway/src/middlewares/bloomFilter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Request, Response, NextFunction } from "express";
2-
import { redis } from "../config/redis.conifg";
2+
import { redis } from "../config/redis.config";
33
import logger from "../config/logger.config";
44

55
export const BLOOM_FILTER_SIZE = 100000;

api-gateway/src/middlewares/cache.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
import { Request, Response, NextFunction } from "express";
2-
import { redis } from "../config/redis.conifg";
2+
import { redis } from "../config/redis.config";
33
import logger from "../config/logger.config";
44
import { EventEmitter } from "events";
5+
import LRUCache from "lru-cache";
56

67
// Singleflight tracker for cache stampede protection
78
const inFlightRequests = new Map<string, EventEmitter>();
89

9-
// L1 In-Memory Cache (Node.js Heap)
10-
const l1Cache = new Map<string, { data: any, expiresAt: number }>();
10+
// Bounded L1 in-memory cache. Capped at 5000 entries; entries older than
11+
// 5 minutes are evicted on read. This prevents the previous unbounded
12+
// Map that could OOM the process under diverse URL load.
13+
const L1_MAX_ENTRIES = 5000;
14+
const L1_TTL_MS = 5 * 60 * 1000;
15+
const l1Cache = new LRUCache<string, { data: any, expiresAt: number }>({
16+
max: L1_MAX_ENTRIES,
17+
ttl: L1_TTL_MS,
18+
ttlAutopurge: true,
19+
});
1120

1221
// Subscribe to invalidation events from the Leaderboard service
1322
const subscriber = redis.duplicate();
@@ -56,7 +65,7 @@ export const cacheMiddleware = (ttlSeconds: number) => {
5665
if (cachedResponse) {
5766
// Populate L1 cache on L2 hit
5867
l1Cache.set(cacheKey, { data: cachedResponse, expiresAt: now + ttlSeconds * 1000 });
59-
68+
6069
res.setHeader("X-Cache", "L2-HIT");
6170
res.setHeader("Content-Type", "application/json");
6271
return res.send(cachedResponse);
@@ -66,14 +75,14 @@ export const cacheMiddleware = (ttlSeconds: number) => {
6675
if (inFlightRequests.has(cacheKey)) {
6776
res.setHeader("X-Cache", "COALESCED");
6877
res.setHeader("Content-Type", "application/json");
69-
78+
7079
const emitter = inFlightRequests.get(cacheKey)!;
7180
return await new Promise<void>((resolve) => {
7281
emitter.once("done", (body: any) => {
7382
res.send(body);
7483
resolve();
7584
});
76-
85+
7786
emitter.once("error", () => {
7887
res.status(503).json({ success: false, message: "Service Unavailable" });
7988
resolve();
@@ -82,7 +91,7 @@ export const cacheMiddleware = (ttlSeconds: number) => {
8291
}
8392

8493
const emitter = new EventEmitter();
85-
emitter.setMaxListeners(1000);
94+
emitter.setMaxListeners(1000);
8695
inFlightRequests.set(cacheKey, emitter);
8796

8897
res.setHeader("X-Cache", "MISS");
@@ -94,15 +103,15 @@ export const cacheMiddleware = (ttlSeconds: number) => {
94103
redis.setex(cacheKey, ttlSeconds, body).catch(err => {
95104
logger.error("Failed to set L2 cache:", { error: err.message, key: cacheKey });
96105
});
97-
106+
98107
// Populate L1
99108
l1Cache.set(cacheKey, { data: body, expiresAt: Date.now() + ttlSeconds * 1000 });
100-
109+
101110
emitter.emit("done", body);
102111
} else {
103112
emitter.emit("error");
104113
}
105-
114+
106115
inFlightRequests.delete(cacheKey);
107116
return originalSend.call(this, body);
108117
};
@@ -115,3 +124,8 @@ export const cacheMiddleware = (ttlSeconds: number) => {
115124
};
116125
};
117126

127+
export function shutdownCacheMiddleware() {
128+
if (subscriber && subscriber.status === 'ready') {
129+
subscriber.quit().catch(() => undefined);
130+
}
131+
}

api-gateway/src/middlewares/rateLimiter.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Request, Response, NextFunction } from "express";
2-
import { redis } from "../config/redis.conifg";
2+
import { redis } from "../config/redis.config";
33
import logger from "../config/logger.config";
44

55
interface RateLimitConfig {
@@ -27,7 +27,7 @@ if data then
2727
local decoded = cjson.decode(data)
2828
currentTokens = decoded.tokens
2929
lastRefill = decoded.lastRefill
30-
30+
3131
local elapsed = now - lastRefill
3232
if elapsed > 0 then
3333
local added = elapsed * refillRate
@@ -46,15 +46,29 @@ else
4646
end
4747
`;
4848

49+
// Resolve the client IP. Prefer req.ip (which respects trust proxy settings
50+
// on the Express app). Only fall back to x-forwarded-for if no other IP is
51+
// available, and only take the leftmost entry.
52+
function resolveClientIp(req: Request): string {
53+
if (req.ip) return req.ip;
54+
const xff = req.headers["x-forwarded-for"];
55+
if (typeof xff === "string" && xff.length > 0) {
56+
return xff.split(",")[0].trim();
57+
}
58+
if (Array.isArray(xff) && xff.length > 0) {
59+
return xff[0].split(",")[0].trim();
60+
}
61+
return req.socket.remoteAddress || "unknown";
62+
}
63+
4964
export const rateLimiter = (config: RateLimitConfig = DEFAULT_CONFIG) => {
5065
return async (req: Request, res: Response, next: NextFunction) => {
51-
const ip = req.ip || req.headers["x-forwarded-for"] || req.socket.remoteAddress;
66+
const ip = resolveClientIp(req);
5267
const key = `ratelimit:${ip}`;
5368

5469
try {
5570
const now = Math.floor(Date.now() / 1000);
5671

57-
5872
// Skip rate limiting in test environment
5973
if (process.env.NODE_ENV === 'test') return next();
6074

api-gateway/src/routes/leaderboard.sse.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Request, Response, Router } from "express";
2-
import { redis } from "../config/redis.conifg";
2+
import { redis } from "../config/redis.config";
33
import logger from "../config/logger.config";
44
import EventEmitter from "events";
55

@@ -47,10 +47,6 @@ sseRouter.get("/:contestId", (req: Request, res: Response) => {
4747

4848
// 4. Listener for leaderboard updates
4949
const updateListener = () => {
50-
// We send an UPDATE event. The client can then either:
51-
// A) Consume this event and fetch the latest JSON via standard REST (which hits the L1 cache!)
52-
// B) We could fetch it here and push the full JSON.
53-
// We'll tell the client to refresh, which hits our perfectly optimized Singleflight L1 Cache.
5450
res.write(`data: {"type": "UPDATE", "timestamp": ${Date.now()}}\n\n`);
5551
};
5652

@@ -63,3 +59,10 @@ sseRouter.get("/:contestId", (req: Request, res: Response) => {
6359
sseEmitter.off(eventName, updateListener);
6460
});
6561
});
62+
63+
export function shutdownSse() {
64+
if (subscriber && subscriber.status === 'ready') {
65+
subscriber.quit().catch(() => undefined);
66+
}
67+
sseEmitter.removeAllListeners();
68+
}

0 commit comments

Comments
 (0)