Skip to content

3.2.5 · Multi-tenant SourceDB sharding

บทนี้เจาะ pattern ที่เป็นแก่นของ cu-central-apiSourceDB sharding ที่ทุก association ต้อง scope ด้วย WHERE Employee.SourceDB = X.SourceDB pattern นี้ไม่มีใน vtrc-api และเป็นสาเหตุที่ทำให้ Employee model ใหญ่ถึง 458 บรรทัด


ทำไมต้อง sharding

cu-central-api ให้บริการ HR data ของหน่วยงาน Red Cross หลายหน่วย (Chula, Somdej, ...) ที่แต่ละหน่วยมี MSSQL shard ของตัวเอง ตัวอย่าง shard names

dbHRMI_RC_B_rep        ← Chulalongkorn (หลัก)
dbHRMI_RC_SOMD         ← Somdej
dbHRMI_RC_xxx          ← หน่วยงานอื่น ๆ
dbHRMI_CU_SC_rep       ← เห็นใน payrollSlip resolver ที่ special-case

ทุก HR table มี column SourceDB ที่บอกว่า row นี้มาจาก shard ไหน ถ้า join ข้าม shard โดยไม่กรอง จะได้ row ที่ผิดพลาด (employee ของ Chula ไป match working profile ของ Somdej)


Pattern ใน Sequelize

cu-central-api แก้ปัญหานี้ด้วย scoped association ที่ force WHERE Employee.SourceDB = X.SourceDB ในทุก join

    models.Employee.hasOne(models.EmployeeWorkingProfile, {
      as: 'workingProfile',
      foreignKey: 'EmpID',
      scope: {
        [sequelize.Op.and]: sequelize.where(
          sequelize.col('Employee.SourceDB'),
          sequelize.Op.eq,
          sequelize.col('workingProfile.SourceDB')
        )
      }
    })

แปลเป็น SQL ที่ Sequelize generate คือ

sql
SELECT * FROM Employee e
LEFT JOIN EmployeeWorkingProfile w
  ON e.EmpID = w.EmpID
  AND e.SourceDB = w.SourceDB   -- ← scope constraint

scope block ทำให้ทุก include: [{ model: WorkingProfile }] มี constraint นี้ติดมาโดยอัตโนมัติ

Variant สำหรับ nested include

    // Join -> Working Profile (multiple include[] or include from Person before Employee)
    models.Employee.hasOne(models.EmployeeWorkingProfile, {
      as: 'workingProfileMultiple',
      foreignKey: 'EmpID',
      scope: {
        [sequelize.Op.and]: sequelize.where(
          sequelize.col('Employee.SourceDB'),
          sequelize.Op.eq,
          sequelize.col('employee->workingProfileMultiple.SourceDB')
        )
      }
    })

pattern workingProfileMultiple ใช้เมื่อ include ผ่าน model อื่นก่อน (เช่น Person → Employee → WorkingProfile) — Sequelize generate column reference ที่ต่างกัน (employee->workingProfileMultiple.SourceDB แทน workingProfile.SourceDB) เป็น workaround สำหรับ Sequelize 6 alias naming


Pattern ที่ซ้ำใน Employee.associate ทั้งหมด

  Employee.associate = (models) => {
    // Join -> Working Profile (include[] from Employee)
    models.Employee.hasOne(models.EmployeeWorkingProfile, { ..., scope: { SourceDB match } })

    models.Employee.hasOne(models.EmployeeWorkingProfile, { ..., scope: { ... } })

    models.Employee.hasOne(models.Address, { ..., scope: { SourceDB match } })

    models.Employee.hasOne(models.MeisEmpDivision, { ..., scope: { SourceDB match } })

    models.Employee.hasMany(models.HrTimeAttendance, { as: 'timeAttendance', foreignKey: 'EmpID' })  // ← ไม่มี scope!

    models.Employee.belongsTo(models.Personal, { as: 'personInfo', ..., scope: { SourceDB match } })

    models.Employee.belongsTo(models.Personal, { as: 'personInfoLeave', ..., scope: { ... } })

    models.Employee.belongsTo(models.EmployeeFinance, { ..., scope: { SourceDB match } })

    models.Employee.belongsTo(models.EmployeeEmplGroup, { as: 'emplGroup', ..., scope: { SourceDB match } })

    models.Employee.belongsTo(models.EmployeeEmplGroup, { as: 'emplGroupLeave', ..., scope: { ... } })

    models.Employee.belongsTo(models.EnumMasterDataReStructure, { as: 'refCarpark', ..., scope: { SourceDB match } })

    models.Employee.belongsTo(models.EmployeeImage, { as: 'image', ..., scope: { SourceDB match } })

    models.Employee.belongsTo(models.OrganizationUnitNewReStruc, { as: 'originalAffiliationOrg', ..., scope: { SourceDB match } })

    models.Employee.hasOne(models.HrPerEmployContract, { as: 'contractProfile', ..., scope: { SourceDB match } })

    models.Employee.hasOne(models.HrPerEmployContract, { as: 'contractProfileMultiple', ..., scope: { ... } })

    models.Employee.hasMany(models.TimeEmpWorkSche, { as: 'empWorkSchedule', ..., scope: { SourceDB match } })

  }

15 association block ที่ copy-paste scope constraint ซ้ำ ๆ — ถ้าลืม scope ใน association ใหม่ = join ข้าม shard ทันที (silent data corruption)

🔴 สังเกตHrTimeAttendance (บรรทัด 308) ไม่มี scope constraint ต่างจาก association อื่น — เป็น evidence ของ pattern miss ที่อาจทำให้เห็น time attendance ข้าม shard ในบาง query


ปัญหาของ pattern นี้

1. Boilerplate มหาศาล

Employee model มี 458 บรรทัด — ส่วนใหญ่คือ 15+ association block ที่ต้อง copy-paste scope constraint ซ้ำ ๆ

2. ไม่มี test ที่ catch miss

ไม่มี integration test ที่ verify ว่าทุก association มี scope constraint — ถ้ามีคนเพิ่ม association ใหม่โดยไม่ใส่ scope bug จะผ่าน code review ได้

3. Query plan หนัก

constraint WHERE Employee.SourceDB = X.SourceDB เพิ่ม complexity ให้ query plan — MSSQL optimizer ต้อง evaluate cross-column comparison ในทุก join

โดยทั่วไปจะเริ่ม debug "HR query ช้า" ที่นี่ก่อน — index บน (EmpID, SourceDB) ช่วยได้บ้างแต่ไม่หมด

4. SourceDB เป็น string ไม่ใช่ enum

SourceDB type เป็น STRING (ไม่ใช่ ENUM) — ทำให้มี risk ของ typo (dbHRMI_RC_B_rep vs dbHRMI_RC_B_REP) ที่ทำให้ join ไม่ match

5. Pattern miss ใน HrTimeAttendance

    models.Employee.hasMany(models.HrTimeAttendance, { as: 'timeAttendance', foreignKey: 'EmpID' })

association นี้ ไม่มี scope constraint — ต่างจาก sibling ทั้งหมด เป็น silent bug ที่อาจทำให้ time attendance ของ shard A ปรากฏใน employee ของ shard B ในบาง query


ทางเลือกอื่น

Option A — shard-per-connection (ที่ควรทำ)

แทนที่จะรวมหลาย shard ใน connection เดียว — เปิด connection แยกต่อ shard แล้ว route query ตาม org context

connection_dbHRMI_RC_B_rep     ← Chula
connection_dbHRMI_RC_SOMD      ← Somdej

ข้อดี

  • ไม่ต้อง scope constraint
  • connection pool แยก ไม่กิน resource กัน
  • query plan ง่ายขึ้น

ข้อเสีย

  • cross-shard query (rare) ต้อง union ใน app layer
  • ต้อง maintain connection หลายตัว

Option B — tenant_id column + index

แทนที่จะ scope ทุก association — ใช้ tenant_id column เดียวที่ทุก table มี แล้ว query WHERE tenant_id = ? ที่ app layer

Option C — Go rewrite

ใน Go target (Volume 19 Roadmap) แต่ละ bounded context เป็น service แยก และแต่ละ tenant เป็น deployment แยก — pattern SourceDB หายไปทั้งหมด


sourceDb ใน GraphQL API

cu-central-api expose sourceDb parameter ใน GraphQL query ส่วนใหญ่

getLeaveDocumentList(sourceDb: String, ...): [LeaveDocument]
fetchEmpWithOrgnize(sourceDb: String, ...): [Employee]
employeeDetailProfile(sourceDb: String, ...): EmployeeDetail

caller (vtrc-api) ต้อง pass sourceDb ที่ถูกต้อง — ถ้าผิด จะได้ข้อมูลของ shard ผิด

Risk (cross-tenant data leak ในทางทฤษฎี)

ถ้า vtrc-api pass sourceDb จาก user-controlled input โดยไม่ validate — user สามารถ query ข้อมูล HR ของ shard อื่น

ยังไม่มี evidence ว่ามี exploit จริง แต่เป็น risk ที่ต้อง audit ที่ vtrc-api side


Employee model breakdown

458 บรรทัดของ models/hrmi/employee/employee.js

Sectionบรรทัดขนาด
define + attributes (40+ columns)1-250250
associations (15+ blocks)252-454200
helpers (scopes)455-4584

scopes ที่น่าสนใจ

javascript
scopes: {
  replaceAttrs: (attrs) => ({ attributes: attrs }),
  workingOnly: { where: { isActive: 1 } }
}

replaceAttrs scope อนุญาตให้ caller override attributes แบบ dynamic — เป็น pattern ที่ fragile (เปิดทางเลือก column)


Models ที่มี SourceDB

ทุก model ใน models/hrmi/ มี SourceDB column — ไม่ใช่แค่ Employee

SubdirModels
hrmi/employee/Employee, WorkingProfile, Family, Address, BookBank, Finance, ...
hrmi/organization/Organization, Unit, Position, ...
hrmi/financial/Tax, Compensation, FundPolicy, ProvidentFund, ...
hrmi/bank/Bank, BankBranch
hrmi/childTuition/ChildTuitionHistory
hrmi/conicle/LongTerm, ShortTerm, MigrateConicle
hrmi/healCheck/HealCheckEmployee, HealCheckFamily
hrmi/salary/(DB views — uvi3PaySlipHD.view.js)
hrmi/shiftSchedule/HrTimeEmpWorkSche, HrTimeShift
hrmi/sso/SsoAppUsername
hrmi/timeAttendance/HrTimeTempImport
hrmi/empIdentity/ViewEmpIdentity (× 2 versions)

buildOrderBySourceDb — pattern สำหรับ deterministic order

export const getEmployeeByEmpCode = async (EmpCode) => {
  if (typeof EmpCode !== 'undefined') {
    const clause = {
      where: { EmpCode },
      order: [
        [Models.Sequelize.literal(buildOrderBySourceDb())]
      ],
      include: [
        // ...
      ]
    }
    return fetchEmployees(clause, ['workingOnly'])
  }

buildOrderBySourceDb() เป็น helper ที่สร้าง ORDER BY clause ตามลำดับ shard priority (เพื่อให้ profiles[0] ใน login flow มาจาก shard หลักเสมอ) — เป็น pattern ที่ช่วยลด ambiguity เมื่อ EmpCode มีอยู่ในหลาย shard


mergeProfileKey — composite identifier ข้าม shard

export const formatWorkprofileFromEmployee = (empProfile) => {
  const sourceDb = empProfile.workingProfile.sourceDb
  return {
    profileKey: mergeProfileKey(empProfile.empId, sourceDb),
    empCode: empProfile.empCode,
    divisions: [],
    profileLevel: 'MAIN',
    sourceDb
  }

profileKey คือ composite ของ empId + ':' + sourceDb — เป็น identifier ที่ unique ข้าม shard (ต่างจาก empCode ที่อาจซ้ำกันข้าม shard)

migration จาก empCodeprofileKey เห็นได้ใน dead file payrollSlip_removed.js ที่ใช้ empCode (legacy signature) แต่ live version ใช้ profileKey


การ debugging sharding issues

"Employee ของ org X login ได้แต่เห็นข้อมูลของ org Y"

อาการ — join ข้าม shard

สาเหตุ

  1. association ใหม่ลืม scope constraint (เช่น HrTimeAttendance ข้างต้น)
  2. caller pass sourceDb ผิด
  3. typo ใน SourceDB value

จุดที่ตรวจ

  • Employee.associate block ใหม่ — มี scope: { SourceDB match } ไหม
  • GraphQL resolver — sourceDb มาจากไหน
  • MSSQL — SELECT DISTINCT SourceDB FROM Employee เพื่อดูค่าจริง

"HR query ช้าขึ้นเรื่อย ๆ"

อาการ — query plan ซับซ้อนเพราะ cross-column comparison

สาเหตุ

  • ไม่มี composite index บน (EmpID, SourceDB)
  • มี association ใหม่เพิ่ม join

จุดที่ตรวจ

  • MSSQL execution plan — มี index seek หรือ table scan
  • เพิ่ม index (EmpID, SourceDB) ในทุก table ที่ join

สรุป

SourceDB sharding เป็น pattern ที่เกิดจาก constraint ของ HR data topology (หลาย shard ต่อหน่วยงาน) — เป็นสิ่งที่ต้องมี แต่ implementation ใน cu-central-api มี debt

AspectสถานะSeverity
Pattern เอง (scoped association)ทำงานได้แต่ boilerplate มาก🟡 High
Test coverage ที่ catch missไม่มี🔴 Critical
Index coverage บน (EmpID, SourceDB)ไม่ครบ (ต้อง audit ที่ MSSQL)🔴 Critical
Type safety (SourceDB เป็น string)Typo risk🟡 High
Pattern miss HrTimeAttendanceassociation ไม่มี scope🔴 Critical
Modernization pathGo rewrite ลบ pattern นี้ทั้งหมด🟢 Good

บทถัดไปจะ catalog SDL และ resolvers ทั้งหมด เพื่อให้เห็นว่า SourceDB กระจายไปทั้ง schema


อ่านต่อ → 3.2.6 TypeDefs + resolvers catalog