From ef17236eb0a2dc3e117a3fcf40d3705b0667e327 Mon Sep 17 00:00:00 2001 From: waruneeauy Date: Thu, 12 Feb 2026 13:16:43 +0700 Subject: [PATCH] fix: bug save posMasterHistory, tuning performance script --- src/controllers/OrganizationController.ts | 59 +++++++++---- ...70875727560-add_indexes_for_performance.ts | 62 ++++++++++++++ src/services/PositionService.ts | 83 +++++++++++++++++++ 3 files changed, 186 insertions(+), 18 deletions(-) create mode 100644 src/migration/1770875727560-add_indexes_for_performance.ts diff --git a/src/controllers/OrganizationController.ts b/src/controllers/OrganizationController.ts index b0a007f5..51159b91 100644 --- a/src/controllers/OrganizationController.ts +++ b/src/controllers/OrganizationController.ts @@ -53,6 +53,7 @@ import { } from "../keycloak"; // import { getPositionCounts, getCounts, getRootCounts } from "../services/OrganizationService"; import { + BatchSavePosMasterHistoryOfficer, CreatePosMasterHistoryEmployee, CreatePosMasterHistoryOfficer, SavePosMasterHistoryOfficer, @@ -8062,9 +8063,26 @@ export class OrganizationController extends Controller { // Clear current_holderId for positions that will have new holders const nextHolderIds = posMasterDraft .filter((x) => x.next_holderId != null) - .map((x) => x.next_holderId); + .map((x) => x.next_holderId) as string[]; if (nextHolderIds.length > 0) { + // FIX: Fetch positions first before updating (to avoid race condition) + const posMastersToUpdate = await queryRunner.manager.find(PosMaster, { + where: { + orgRevisionId: currentRevisionId, + current_holderId: In(nextHolderIds), + }, + }); + + // Save history BEFORE clearing current_holderId + const historyOps = posMastersToUpdate.map((pos) => ({ + posMasterDnaId: pos.ancestorDNA, + profileId: null, + pm: null, + })); + await BatchSavePosMasterHistoryOfficer(queryRunner, historyOps); + + // Now clear current_holderId await queryRunner.manager.update( PosMaster, { @@ -8112,11 +8130,12 @@ export class OrganizationController extends Controller { // Then delete posMaster records await queryRunner.manager.delete(PosMaster, toDeleteIds); - await Promise.all( - toDelete.map(async (pos) => { - await SavePosMasterHistoryOfficer(queryRunner, pos.ancestorDNA, null, null); - }), - ); + const deleteHistoryOps = toDelete.map((pos) => ({ + posMasterDnaId: pos.ancestorDNA, + profileId: null, + pm: null, + })); + await BatchSavePosMasterHistoryOfficer(queryRunner, deleteHistoryOps); } // 2.4 Process draft positions (UPDATE or INSERT) @@ -8705,17 +8724,18 @@ export class OrganizationController extends Controller { // Bulk DELETE if (allToDelete.length > 0) { await queryRunner.manager.delete(Position, allToDelete); - await Promise.all( - allToDeleteHistory.map(async (ancestorDNA) => { - await SavePosMasterHistoryOfficer(queryRunner, ancestorDNA, null, null); - }), - ); + const deleteOps = allToDeleteHistory.map((ancestorDNA) => ({ + posMasterDnaId: ancestorDNA, + profileId: null, + pm: null, + })); + await BatchSavePosMasterHistoryOfficer(queryRunner, deleteOps); deletedCount = allToDelete.length; } - // Bulk UPDATE (batch by 500 to avoid query size limits) + // Bulk UPDATE (batch by 100 to avoid query size limits) if (allToUpdate.length > 0) { - const batchSize = 500; + const batchSize = 100; for (let i = 0; i < allToUpdate.length; i += batchSize) { const batch = allToUpdate.slice(i, i + batchSize); await Promise.all( @@ -8727,7 +8747,7 @@ export class OrganizationController extends Controller { // Bulk INSERT if (allToInsert.length > 0) { - const batchSize = 500; + const batchSize = 100; for (let i = 0; i < allToInsert.length; i += batchSize) { const batch = allToInsert.slice(i, i + batchSize); await queryRunner.manager.save(Position, batch); @@ -8747,10 +8767,13 @@ export class OrganizationController extends Controller { // Save PosMasterHistory for updated positions if (historyCalls.length > 0) { - await Promise.all( - historyCalls.map(({ ancestorDNA, profileId, historyData }) => - SavePosMasterHistoryOfficer(queryRunner, ancestorDNA, profileId, historyData), - ), + await BatchSavePosMasterHistoryOfficer( + queryRunner, + historyCalls.map(({ ancestorDNA, profileId, historyData }) => ({ + posMasterDnaId: ancestorDNA, + profileId, + pm: historyData, + })), ); } diff --git a/src/migration/1770875727560-add_indexes_for_performance.ts b/src/migration/1770875727560-add_indexes_for_performance.ts new file mode 100644 index 00000000..c08e82ab --- /dev/null +++ b/src/migration/1770875727560-add_indexes_for_performance.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddIndexesForPerformance1770875727560 implements MigrationInterface { + name = "AddIndexesForPerformance1770875727560"; + + public async up(queryRunner: QueryRunner): Promise { + // Index for posMasterHistory lookups + await queryRunner.query(` + CREATE INDEX IDX_posMasterHistory_ancestorDNA + ON posMasterHistory(ancestorDNA, createdAt DESC) + `); + + // Index for org tables lookups + await queryRunner.query(` + CREATE INDEX IDX_orgRoot_ancestorDNA_revision + ON orgRoot(ancestorDNA, orgRevisionId) + `); + + await queryRunner.query(` + CREATE INDEX IDX_orgChild1_ancestorDNA_revision + ON orgChild1(ancestorDNA, orgRevisionId) + `); + + await queryRunner.query(` + CREATE INDEX IDX_orgChild2_ancestorDNA_revision + ON orgChild2(ancestorDNA, orgRevisionId) + `); + + await queryRunner.query(` + CREATE INDEX IDX_orgChild3_ancestorDNA_revision + ON orgChild3(ancestorDNA, orgRevisionId) + `); + + await queryRunner.query(` + CREATE INDEX IDX_orgChild4_ancestorDNA_revision + ON orgChild4(ancestorDNA, orgRevisionId) + `); + + // Index for posMaster lookups + await queryRunner.query(` + CREATE INDEX IDX_posMaster_revision_org + ON posMaster(orgRevisionId, orgRootId, orgChild1Id, orgChild2Id, orgChild3Id, orgChild4Id) + `); + + await queryRunner.query(` + CREATE INDEX IDX_posMaster_ancestorDNA + ON posMaster(ancestorDNA) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IDX_position_posMasterId ON position`); + await queryRunner.query(`DROP INDEX IDX_posMaster_ancestorDNA ON posMaster`); + await queryRunner.query(`DROP INDEX IDX_posMaster_revision_org ON posMaster`); + await queryRunner.query(`DROP INDEX IDX_orgChild4_ancestorDNA_revision ON orgChild4`); + await queryRunner.query(`DROP INDEX IDX_orgChild3_ancestorDNA_revision ON orgChild3`); + await queryRunner.query(`DROP INDEX IDX_orgChild2_ancestorDNA_revision ON orgChild2`); + await queryRunner.query(`DROP INDEX IDX_orgChild1_ancestorDNA_revision ON orgChild1`); + await queryRunner.query(`DROP INDEX IDX_orgRoot_ancestorDNA_revision ON orgRoot`); + await queryRunner.query(`DROP INDEX IDX_posMasterHistory_ancestorDNA ON posMasterHistory`); + } +} diff --git a/src/services/PositionService.ts b/src/services/PositionService.ts index 34631666..64fb58a4 100644 --- a/src/services/PositionService.ts +++ b/src/services/PositionService.ts @@ -1,3 +1,4 @@ +import { In } from "typeorm"; import { SavePosMasterHistory } from "./../interfaces/OrgMapping"; import { AppDataSource } from "../database/data-source"; import { EmployeePosMaster } from "../entities/EmployeePosMaster"; @@ -320,3 +321,85 @@ export async function SavePosMasterHistoryOfficer( return false; } } + +export interface BatchPosMasterHistoryOperation { + posMasterDnaId: string; + profileId: string | null; + pm: SavePosMasterHistory | null; +} + +export async function BatchSavePosMasterHistoryOfficer( + queryRunner: any, + operations: BatchPosMasterHistoryOperation[], +): Promise { + if (operations.length === 0) return true; + + try { + const repoPosMasterHistory = queryRunner.manager.getRepository(PosMasterHistory); + const dnaIds = operations.map((op) => op.posMasterDnaId); + + // Fetch all existing history records in ONE query + const existingHistory = await repoPosMasterHistory.find({ + where: { ancestorDNA: In(dnaIds) }, + order: { createdAt: "DESC" }, + }); + + // Build lookup map + const historyByDna = new Map(); + for (const h of existingHistory) { + if (!historyByDna.has(h.ancestorDNA)) { + historyByDna.set(h.ancestorDNA, []); + } + historyByDna.get(h.ancestorDNA)!.push(h); + } + + // Process operations and collect new records + const newRecords: PosMasterHistory[] = []; + const _null: any = null; + + for (const op of operations) { + const existing = historyByDna.get(op.posMasterDnaId)?.[0]; + const shouldInsert = !existing && op.profileId && op.pm; + const profileChanged = existing && existing.profileId !== op.profileId; + + if (shouldInsert || profileChanged) { + const newPmh = new PosMasterHistory(); + newPmh.ancestorDNA = op.posMasterDnaId; + newPmh.prefix = op.pm?.prefix ?? _null; + newPmh.firstName = op.pm?.firstName ?? _null; + newPmh.lastName = op.pm?.lastName ?? _null; + newPmh.position = op.pm?.position ?? _null; + newPmh.posType = op.pm?.posType ?? _null; + newPmh.posLevel = op.pm?.posLevel ?? _null; + newPmh.posExecutive = op.pm?.posExecutive ?? _null; + newPmh.profileId = op.profileId ?? _null; + newPmh.rootDnaId = op.pm?.rootDnaId ?? _null; + newPmh.child1DnaId = op.pm?.child1DnaId ?? _null; + newPmh.child2DnaId = op.pm?.child2DnaId ?? _null; + newPmh.child3DnaId = op.pm?.child3DnaId ?? _null; + newPmh.child4DnaId = op.pm?.child4DnaId ?? _null; + newPmh.shortName = op.pm?.shortName ?? _null; + newPmh.posMasterNoPrefix = op.pm?.posMasterNoPrefix ?? _null; + newPmh.posMasterNo = op.pm?.posMasterNo ?? _null; + newPmh.posMasterNoSuffix = op.pm?.posMasterNoSuffix ?? _null; + newPmh.createdUserId = "system"; + newPmh.createdFullName = "system"; + newPmh.lastUpdateUserId = "system"; + newPmh.lastUpdateFullName = "system"; + newPmh.createdAt = new Date(); + newPmh.lastUpdatedAt = new Date(); + newRecords.push(newPmh); + } + } + + // Batch insert all new records + if (newRecords.length > 0) { + await queryRunner.manager.save(PosMasterHistory, newRecords); + } + + return true; + } catch (err) { + console.error("BatchSavePosMasterHistoryOfficer error:", err); + return false; + } +}