fix: เพิ่ม Graceful Shutdown - ป้องกัน connection in app file, Log Mnddleware + Memory Store

This commit is contained in:
Warunee Tamkoo 2026-01-28 17:22:10 +07:00
parent a194d8594b
commit e068aafe3a
3 changed files with 137 additions and 33 deletions

View file

@ -11,6 +11,7 @@ import { AppDataSource } from "./database/data-source";
import { RegisterRoutes } from "./routes"; import { RegisterRoutes } from "./routes";
import { OrganizationController } from "./controllers/OrganizationController"; import { OrganizationController } from "./controllers/OrganizationController";
import logMiddleware from "./middlewares/logs"; import logMiddleware from "./middlewares/logs";
import { logMemoryStore } from "./utils/log-memory-store";
import { CommandController } from "./controllers/CommandController"; import { CommandController } from "./controllers/CommandController";
import { ProfileSalaryController } from "./controllers/ProfileSalaryController"; import { ProfileSalaryController } from "./controllers/ProfileSalaryController";
import { DateSerializer } from "./interfaces/date-serializer"; import { DateSerializer } from "./interfaces/date-serializer";
@ -20,6 +21,9 @@ import { initWebSocket } from "./services/webSocket";
async function main() { async function main() {
await AppDataSource.initialize(); await AppDataSource.initialize();
// Initialize LogMemoryStore after database is ready
logMemoryStore.initialize();
// Setup custom Date serialization for local timezone // Setup custom Date serialization for local timezone
DateSerializer.setupDateSerialization(); DateSerializer.setupDateSerialization();
@ -93,7 +97,7 @@ async function main() {
}); });
// app.listen(APP_PORT, APP_HOST, () => console.log(`Listening on: http://localhost:${APP_PORT}`)); // app.listen(APP_PORT, APP_HOST, () => console.log(`Listening on: http://localhost:${APP_PORT}`));
app.listen( const server = app.listen(
APP_PORT, APP_PORT,
APP_HOST, APP_HOST,
() => ( () => (
@ -111,6 +115,46 @@ async function main() {
} }
runMessageQueue(); runMessageQueue();
// Graceful Shutdown
const gracefulShutdown = async (signal: string) => {
console.log(`\n[APP] ${signal} received. Starting graceful shutdown...`);
// Stop accepting new connections
server.close(() => {
console.log("[APP] HTTP server closed");
});
// Force close after timeout
const shutdownTimeout = setTimeout(() => {
console.error("[APP] Forced shutdown after timeout");
process.exit(1);
}, 30000); // 30 seconds timeout
try {
// Close database connections
if (AppDataSource.isInitialized) {
await AppDataSource.destroy();
console.log("[APP] Database connections closed");
}
// Destroy LogMemoryStore
logMemoryStore.destroy();
console.log("[APP] LogMemoryStore destroyed");
clearTimeout(shutdownTimeout);
console.log("[APP] Graceful shutdown completed");
process.exit(0);
} catch (error) {
console.error("[APP] Error during shutdown:", error);
clearTimeout(shutdownTimeout);
process.exit(1);
}
};
// Listen for shutdown signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
} }
main(); main();

View file

@ -1,9 +1,6 @@
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import { Client } from "@elastic/elasticsearch"; import { Client } from "@elastic/elasticsearch";
import { AppDataSource } from "../database/data-source"; import { logMemoryStore } from "../utils/log-memory-store";
import { PosMaster } from "../entities/PosMaster";
import { OrgRevision } from "../entities/OrgRevision";
import { Profile } from "../entities/Profile";
if (!process.env.ELASTICSEARCH_INDEX) { if (!process.env.ELASTICSEARCH_INDEX) {
throw new Error("Require ELASTICSEARCH_INDEX to store log."); throw new Error("Require ELASTICSEARCH_INDEX to store log.");
@ -27,9 +24,6 @@ async function logMiddleware(req: Request, res: Response, next: NextFunction) {
if (!req.url.startsWith("/api/")) return next(); if (!req.url.startsWith("/api/")) return next();
let data: any; let data: any;
const repoPosmaster = AppDataSource.getRepository(PosMaster);
const repoProfile = AppDataSource.getRepository(Profile);
const repoRevision = AppDataSource.getRepository(OrgRevision);
const originalJson = res.json; const originalJson = res.json;
@ -42,13 +36,6 @@ async function logMiddleware(req: Request, res: Response, next: NextFunction) {
req.app.locals.logData = {}; req.app.locals.logData = {};
const revision = await repoRevision.findOne({
where: {
orgRevisionIsCurrent: true,
orgRevisionIsDraft: false,
},
});
res.on("finish", async () => { res.on("finish", async () => {
try { try {
if (!req.url.startsWith("/api/")) return; if (!req.url.startsWith("/api/")) return;
@ -79,16 +66,15 @@ async function logMiddleware(req: Request, res: Response, next: NextFunction) {
if (req.url.startsWith("/api/v1/org/keycloak/")) system = "registry"; if (req.url.startsWith("/api/v1/org/keycloak/")) system = "registry";
const level = LOG_LEVEL_MAP[process.env.LOG_LEVEL ?? "debug"] || 4; const level = LOG_LEVEL_MAP[process.env.LOG_LEVEL ?? "debug"] || 4;
const profileByKeycloak = await repoProfile.findOne({
where: { keycloak: req.app.locals.logData.userId }, // Get profile from cache
}); const profileByKeycloak = await logMemoryStore.getProfileByKeycloak(
const rootId = await repoPosmaster.findOne({ req.app.locals.logData.userId,
where: { );
current_holderId: profileByKeycloak?.id,
orgRevisionId: revision?.id, // Get rootId from cache
}, const rootId = await logMemoryStore.getRootIdByProfileId(profileByKeycloak?.id);
select: ["orgRootId"], // console.log("ancestorDNA:", rootId);
});
if (level === 1 && res.statusCode < 500) return; if (level === 1 && res.statusCode < 500) return;
if (level === 2 && res.statusCode < 400) return; if (level === 2 && res.statusCode < 400) return;
@ -96,7 +82,7 @@ async function logMiddleware(req: Request, res: Response, next: NextFunction) {
const obj = { const obj = {
logType: res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warning" : "info", logType: res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warning" : "info",
ip: req.ip, ip: req.ip,
rootId: rootId?.orgRootId ?? null, rootId: rootId ?? null,
systemName: system, systemName: system,
startTimeStamp: timestamp, startTimeStamp: timestamp,
endTimeStamp: new Date().toISOString(), endTimeStamp: new Date().toISOString(),

View file

@ -1,17 +1,23 @@
import { AppDataSource } from "../database/data-source"; import { AppDataSource } from "../database/data-source";
import { OrgRevision } from "../entities/OrgRevision"; import { OrgRevision } from "../entities/OrgRevision";
import { Profile } from "../entities/Profile";
import { PosMaster } from "../entities/PosMaster";
interface LogCacheData { interface LogCacheData {
currentRevision: OrgRevision | null; currentRevision: OrgRevision | null;
profileCache: Map<string, Profile>; // keycloak → Profile
rootIdCache: Map<string, string>; // profileId → rootId
updatedAt: Date; updatedAt: Date;
} }
class LogMemoryStore { class LogMemoryStore {
private cache: LogCacheData = { private cache: LogCacheData = {
currentRevision: null, currentRevision: null,
profileCache: new Map(),
rootIdCache: new Map(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
private readonly REFRESH_INTERVAL = 5 * 60 * 1000; // 5 นาที private readonly REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes
private isRefreshing = false; private isRefreshing = false;
private isInitialized = false; private isInitialized = false;
private refreshTimer: NodeJS.Timeout | null = null; private refreshTimer: NodeJS.Timeout | null = null;
@ -33,7 +39,11 @@ class LogMemoryStore {
this.refreshCache(); this.refreshCache();
}, this.REFRESH_INTERVAL); }, this.REFRESH_INTERVAL);
console.log("[LogMemoryStore] Initialized with", this.REFRESH_INTERVAL / 1000, "second refresh interval"); console.log(
"[LogMemoryStore] Initialized with",
this.REFRESH_INTERVAL / 1000,
"second refresh interval",
);
} }
private async refreshCache() { private async refreshCache() {
@ -44,6 +54,7 @@ class LogMemoryStore {
this.isRefreshing = true; this.isRefreshing = true;
try { try {
// Refresh revision cache
const repoRevision = AppDataSource.getRepository(OrgRevision); const repoRevision = AppDataSource.getRepository(OrgRevision);
const revision = await repoRevision.findOne({ const revision = await repoRevision.findOne({
where: { where: {
@ -52,11 +63,13 @@ class LogMemoryStore {
}, },
}); });
this.cache.currentRevision = revision; this.cache.currentRevision = revision;
// Clear on-demand caches (they will be rebuilt as needed)
this.cache.profileCache.clear();
this.cache.rootIdCache.clear();
this.cache.updatedAt = new Date(); this.cache.updatedAt = new Date();
console.log( console.log("[LogMemoryStore] Cache refreshed at", this.cache.updatedAt.toISOString());
"[LogMemoryStore] Cache refreshed at",
this.cache.updatedAt.toISOString(),
);
} catch (error) { } catch (error) {
console.error("[LogMemoryStore] Error refreshing cache:", error); console.error("[LogMemoryStore] Error refreshing cache:", error);
} finally { } finally {
@ -72,6 +85,67 @@ class LogMemoryStore {
return this.cache.updatedAt; return this.cache.updatedAt;
} }
/**
* Get Profile by keycloak ID with caching
*/
async getProfileByKeycloak(keycloak: string): Promise<Profile | null> {
// Check cache first
if (this.cache.profileCache.has(keycloak)) {
return this.cache.profileCache.get(keycloak)!;
}
// Fetch from database
const repoProfile = AppDataSource.getRepository(Profile);
const profile = await repoProfile.findOne({
where: { keycloak },
});
// Cache the result
if (profile) {
this.cache.profileCache.set(keycloak, profile);
}
return profile;
}
/**
* Get RootId by profileId with caching
*/
async getRootIdByProfileId(profileId: string | undefined): Promise<string | null> {
if (!profileId) return null;
// Check cache first
if (this.cache.rootIdCache.has(profileId)) {
return this.cache.rootIdCache.get(profileId)!;
}
// Fetch from database
const repoPosmaster = AppDataSource.getRepository(PosMaster);
const revision = this.getCurrentRevision();
//
const posMaster = await repoPosmaster.findOne({
where: {
current_holderId: profileId,
orgRevisionId: revision?.id,
},
relations: ["orgRoot"],
select: {
orgRoot: {
ancestorDNA: true,
},
},
});
const rootId = posMaster?.orgRoot?.ancestorDNA ?? null;
// Cache the result
if (rootId) {
this.cache.rootIdCache.set(profileId, rootId);
}
return rootId;
}
// สำหรับ shutdown // สำหรับ shutdown
destroy() { destroy() {
if (this.refreshTimer) { if (this.refreshTimer) {