3.2.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 ← หน่วยงานอื่น ๆ
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 คือ
SELECT * FROM Employee e
LEFT JOIN EmployeeWorkingProfile w
ON e.EmpID = w.EmpID
AND e.SourceDB = w.SourceDB -- ← scope constraintscope 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, ...): EmployeeDetailcaller (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-250 | 250 |
| associations (15+ blocks) | 252-454 | 200 |
| helpers (scopes) | 455-458 | 4 |
scopes ที่น่าสนใจ
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
| Subdir | Models |
|---|---|
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 จาก empCode → profileKey เห็นได้ใน dead file payrollSlip_removed.js ที่ใช้ empCode (legacy signature) แต่ live version ใช้ profileKey
การ debugging sharding issues
"Employee ของ org X login ได้แต่เห็นข้อมูลของ org Y"
อาการ — join ข้าม shard
สาเหตุ
- association ใหม่ลืม scope constraint (เช่น
HrTimeAttendanceข้างต้น) - caller pass
sourceDbผิด - typo ใน
SourceDBvalue
จุดที่ตรวจ
Employee.associateblock ใหม่ — มี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 HrTimeAttendance | association ไม่มี scope | 🔴 Critical |
| Modernization path | Go rewrite ลบ pattern นี้ทั้งหมด | 🟢 Good |
บทถัดไปจะ catalog SDL และ resolvers ทั้งหมด เพื่อให้เห็นว่า SourceDB กระจายไปทั้ง schema
อ่านต่อ → 3.2.6 TypeDefs + resolvers catalog