From e068aafe3a59c588b59810df9feb0774f961803c Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Wed, 28 Jan 2026 17:22:10 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20=E0=B9=80=E0=B8=9E=E0=B8=B4=E0=B9=88?= =?UTF-8?q?=E0=B8=A1=20Graceful=20Shutdown=20-=20=E0=B8=9B=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B8=81=E0=B8=B1=E0=B8=99=20connection=20in?= =?UTF-8?q?=20app=20file,=20Log=20Mnddleware=20+=20Memory=20Store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.ts | 46 ++++++++++++++++++- src/middlewares/logs.ts | 38 +++++----------- src/utils/log-memory-store.ts | 86 ++++++++++++++++++++++++++++++++--- 3 files changed, 137 insertions(+), 33 deletions(-) diff --git a/src/app.ts b/src/app.ts index 7f0e0220..0225f162 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,7 @@ import { AppDataSource } from "./database/data-source"; import { RegisterRoutes } from "./routes"; import { OrganizationController } from "./controllers/OrganizationController"; import logMiddleware from "./middlewares/logs"; +import { logMemoryStore } from "./utils/log-memory-store"; import { CommandController } from "./controllers/CommandController"; import { ProfileSalaryController } from "./controllers/ProfileSalaryController"; import { DateSerializer } from "./interfaces/date-serializer"; @@ -20,6 +21,9 @@ import { initWebSocket } from "./services/webSocket"; async function main() { await AppDataSource.initialize(); + // Initialize LogMemoryStore after database is ready + logMemoryStore.initialize(); + // Setup custom Date serialization for local timezone 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( + const server = app.listen( APP_PORT, APP_HOST, () => ( @@ -111,6 +115,46 @@ async function main() { } 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(); diff --git a/src/middlewares/logs.ts b/src/middlewares/logs.ts index 70020810..99e7e31a 100644 --- a/src/middlewares/logs.ts +++ b/src/middlewares/logs.ts @@ -1,9 +1,6 @@ import { NextFunction, Request, Response } from "express"; import { Client } from "@elastic/elasticsearch"; -import { AppDataSource } from "../database/data-source"; -import { PosMaster } from "../entities/PosMaster"; -import { OrgRevision } from "../entities/OrgRevision"; -import { Profile } from "../entities/Profile"; +import { logMemoryStore } from "../utils/log-memory-store"; if (!process.env.ELASTICSEARCH_INDEX) { 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(); let data: any; - const repoPosmaster = AppDataSource.getRepository(PosMaster); - const repoProfile = AppDataSource.getRepository(Profile); - const repoRevision = AppDataSource.getRepository(OrgRevision); const originalJson = res.json; @@ -42,13 +36,6 @@ async function logMiddleware(req: Request, res: Response, next: NextFunction) { req.app.locals.logData = {}; - const revision = await repoRevision.findOne({ - where: { - orgRevisionIsCurrent: true, - orgRevisionIsDraft: false, - }, - }); - res.on("finish", async () => { try { if (!req.url.startsWith("/api/")) return; @@ -69,7 +56,7 @@ async function logMiddleware(req: Request, res: Response, next: NextFunction) { if (req.url.startsWith("/api/v1/org/profile/")) system = "registry"; if (req.url.startsWith("/api/v1/org/profile-employee/")) system = "registry"; if (req.url.startsWith("/api/v1/org/profile-temp/")) system = "registry"; - + if (req.url.startsWith("/api/v1/org/commandType/admin")) system = "admin"; if (req.url.startsWith("/api/v1/org/commandSys/")) system = "admin"; if (req.url.startsWith("/api/v1/org/commandSalary/")) system = "admin"; @@ -79,16 +66,15 @@ async function logMiddleware(req: Request, res: Response, next: NextFunction) { if (req.url.startsWith("/api/v1/org/keycloak/")) system = "registry"; const level = LOG_LEVEL_MAP[process.env.LOG_LEVEL ?? "debug"] || 4; - const profileByKeycloak = await repoProfile.findOne({ - where: { keycloak: req.app.locals.logData.userId }, - }); - const rootId = await repoPosmaster.findOne({ - where: { - current_holderId: profileByKeycloak?.id, - orgRevisionId: revision?.id, - }, - select: ["orgRootId"], - }); + + // Get profile from cache + const profileByKeycloak = await logMemoryStore.getProfileByKeycloak( + req.app.locals.logData.userId, + ); + + // Get rootId from cache + const rootId = await logMemoryStore.getRootIdByProfileId(profileByKeycloak?.id); + // console.log("ancestorDNA:", rootId); if (level === 1 && res.statusCode < 500) return; if (level === 2 && res.statusCode < 400) return; @@ -96,7 +82,7 @@ async function logMiddleware(req: Request, res: Response, next: NextFunction) { const obj = { logType: res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warning" : "info", ip: req.ip, - rootId: rootId?.orgRootId ?? null, + rootId: rootId ?? null, systemName: system, startTimeStamp: timestamp, endTimeStamp: new Date().toISOString(), diff --git a/src/utils/log-memory-store.ts b/src/utils/log-memory-store.ts index bcaa4bec..456c06c8 100644 --- a/src/utils/log-memory-store.ts +++ b/src/utils/log-memory-store.ts @@ -1,17 +1,23 @@ import { AppDataSource } from "../database/data-source"; import { OrgRevision } from "../entities/OrgRevision"; +import { Profile } from "../entities/Profile"; +import { PosMaster } from "../entities/PosMaster"; interface LogCacheData { currentRevision: OrgRevision | null; + profileCache: Map; // keycloak → Profile + rootIdCache: Map; // profileId → rootId updatedAt: Date; } class LogMemoryStore { private cache: LogCacheData = { currentRevision: null, + profileCache: new Map(), + rootIdCache: new Map(), updatedAt: new Date(), }; - private readonly REFRESH_INTERVAL = 5 * 60 * 1000; // 5 นาที + private readonly REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes private isRefreshing = false; private isInitialized = false; private refreshTimer: NodeJS.Timeout | null = null; @@ -33,7 +39,11 @@ class LogMemoryStore { this.refreshCache(); }, 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() { @@ -44,6 +54,7 @@ class LogMemoryStore { this.isRefreshing = true; try { + // Refresh revision cache const repoRevision = AppDataSource.getRepository(OrgRevision); const revision = await repoRevision.findOne({ where: { @@ -52,11 +63,13 @@ class LogMemoryStore { }, }); 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(); - console.log( - "[LogMemoryStore] Cache refreshed at", - this.cache.updatedAt.toISOString(), - ); + console.log("[LogMemoryStore] Cache refreshed at", this.cache.updatedAt.toISOString()); } catch (error) { console.error("[LogMemoryStore] Error refreshing cache:", error); } finally { @@ -72,6 +85,67 @@ class LogMemoryStore { return this.cache.updatedAt; } + /** + * Get Profile by keycloak ID with caching + */ + async getProfileByKeycloak(keycloak: string): Promise { + // 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 { + 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 destroy() { if (this.refreshTimer) {