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
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 นี้ติดมาโดยอัตโนมัติ
ปัญหาของ 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 ส่วนใหญ่
getLeaveDocumentList(sourceDb: String, ...): [LeaveDocument]
fetchEmpWithOrgnize(sourceDb: String, ...): [Employee]
employeeDetailProfile(sourceDb: String, ...): EmployeeDetailcaller (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-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) |
การ debugging sharding issues
"Employee ของ org X login ได้แต่เห็นข้อมูลของ org Y"
อาการ — join ข้าม shard
สาเหตุ
- association ใหม่ลืม scope constraint
- caller pass
sourceDbผิด - enum/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 | สถานะ |
|---|---|
| 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