Skip to content

4.5 · Multi-tenant SourceDB sharding

บทนี้เจาะ pattern ที่เป็นแก่นของ cu-central-api — SourceDB 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          ← หน่วยงานอื่น ๆ

ทุก 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

254:264:main-api/src/models/hrmi/employee/employee.js
    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 นี้ติดมาโดยอัตโนมัติ


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

1. Boilerplate มหาศาล

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

Employee.associate = (models) => {
  models.Employee.hasOne(models.EmployeeWorkingProfile, { ..., scope: { SourceDB match } })
  models.Employee.hasOne(models.Address, { ..., scope: { SourceDB match } })
  models.Employee.belongsTo(models.Person, { ..., scope: { SourceDB match } })
  models.Employee.belongsTo(models.EmployeeFinance, { ..., scope: { SourceDB match } })
  models.Employee.belongsTo(models.EmplGroup, { ..., scope: { SourceDB match } })
  models.Employee.belongsTo(models.RefCarpark, { ..., scope: { SourceDB match } })
  models.Employee.belongsTo(models.Image, { ..., scope: { SourceDB match } })
  models.Employee.belongsTo(models.OriginalAffiliationOrg, { ..., scope: { SourceDB match } })
  models.Employee.hasMany(models.EmpWorkSchedule, { ..., scope: { SourceDB match } })
  // ... 6 more
}

ถ้าลืม scope ใน association ใหม่ — join ข้าม shard ทันที (silent data corruption)

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


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

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 15) แต่ละ bounded context เป็น service แยก และแต่ละ tenant เป็น deployment แยก — pattern SourceDB หายไปทั้งหมด


sourceDb ใน GraphQL API

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

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

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

Risk

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


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)

การ debugging sharding issues

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

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

สาเหตุ

  1. association ใหม่ลืม scope constraint
  2. caller pass sourceDb ผิด
  3. enum/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สถานะ
Pattern เอง (scoped association)🟡 Works แต่ boilerplate มาก
Test coverage🔴 ไม่มี test catch miss
Index coverage🔴 ไม่ครบ
Type safety (SourceDB เป็น string)🟡 Typo risk
Modernization path🟢 Go rewrite ลบ pattern นี้ทั้งหมด

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


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