Phân tích toàn diện luồng xử lý từ lúc bắt đầu chỉnh sửa đến khi hiển thị kết quả, đối chiếu mã nguồn Frontend (Next.js), Backend (Kotlin/R2DBC) và hạ tầng AWS. Kết luận: lỗi có 2 tầng nguyên nhân, trong đó tầng kích hoạt mang tính đặc thù môi trường production.
Kết luận nhanh dành cho người đọc không kỹ thuật. Chi tiết triệu chứng xem mục 2 · Hiện tượng.
Mô tả chính xác những gì khách báo và bối cảnh quan sát được — cơ sở cho mọi phân tích phía sau.
queryKey → truy vấn mới + dựng lại toàn bộ cột timetable (kéo theo repaint). Việc chỉ cần fetch+render mới là khỏi (không cần sửa DB) khẳng định lỗi nằm ở render/đồng bộ state phía client, không phải mất dữ liệu.
Trích xuất từ caremaker-infra/terraform-backend và cấu hình app caremaker-alohomora/lib/mysql-base. Trọng tâm: tách writer/reader của Aurora.
alohomora-release-rds.cluster-……cluster-ro-… · readOnly| Tầng | Kết luận |
|---|---|
| Redis / ElastiCache | Chỉ cache auth/session, không cache kết quả search shift → loại trừ. |
| SQS / SNS / Step Function | Phục vụ job "statics" định kỳ, không liên quan tạo/hiển thị shift → loại trừ. |
| ALB / WAF / CloudFront | Search là POST → không bị cache phía CDN → loại trừ. |
| Xử lý ghi bất đồng bộ | Bulk upsert ghi đồng bộ trong 1 transaction, commit xong mới trả về → loại trừ. |
Sơ đồ tuần tự (UML sequence). Mỗi cột là một tác nhân với đường đời (lifeline) dọc; mũi tên là thông điệp trao đổi theo thời gian (trên → dưới). Nét liền = lệnh gọi, nét đứt = phản hồi. Vùng đỏ là nơi phát sinh lỗi.
activeQuickEdit bật. Dữ liệu shift của ngày đang xem được tải qua useShiftTimetable.dataSource (từ server) → filteredDataSource → effect tại index.tsx:920 sao chép vào state cục bộ dataSourceFilter (có chốt isSame bằng deepEqual).handleDropResultShiftItem / onDropStaffColumn chỉ chỉnh state cục bộ; shift mang id tạm (_RAW, CLONED, ACCOMPANY). Màn hình render theo state lạc quan này.onSubmitQuickEdit(recurrence) tại tools.tsx:1166: gom editedShifts, kiểm tra trùng lịch, dựng payload.POST /crm/shift-calendar/bulk/... → bulkUpsertShifts ghi đồng bộ vào WRITER trong transaction, trả response rỗng. DB đã đúng từ đây.onSuccess gọi invalidateShiftCalendarQueries → refetch useShiftTimetable. Truy vấn đọc đi qua ShiftRosterSecondaryDao.searchWithUnassigned = reader replica. Do replication lag, replica trả về trạng thái cũ thiếu shift vừa sửa.setActiveQuickEdit(false) + dữ liệu refetch (cũ) làm filteredDataSource tính lại; effect index.tsx:920 ghi đè dataSourceFilter → shift biến mất khỏi UI. Vì skipInvalidate:true không có lần invalidate dự phòng, và queryKey (ngày) không đổi nên không có cơ chế tự đồng bộ lại.queryKey → truy vấn mới; vài giây trôi qua đủ để replica bắt kịp → quay lại ngày cũ thấy shift hiển thị đúng.invalidateQueries (cơ chế đồng bộ duy nhất sau lưu)invalidateQueries không phải chi tiết phụ — nó chính là cây cầu nối bước 8 → 9 → 12 trong sơ đồ trên. Hiểu đúng nó là chìa khóa để hiểu vì sao màn hình "kẹt".
Đánh dấu các query khớp queryKey là stale; với query đang mounted (mặc định refetchActive:true) → refetch ngay lập tức. Đây chính là bước 8 (refetch sau lưu) trong sơ đồ tuần tự.
invalidateShiftCalendarQueries — hàm bao gom nhiều queryKeyToàn bộ các mutation về shift không gọi invalidateQueries trực tiếp mà đi qua hàm tiện ích này (hook/useShift.ts:95) — nơi gom 5 nhóm queryKey liên quan tới lịch và invalidate song song bằng Promise.all. Đây là điểm tập trung hóa: được tái sử dụng ở ~11 mutation (tạo/sửa/xóa/bulk-upsert/optimize… — các dòng 565, 690, 748, 788, 829, 859, 893, 937, 961, 1096, 1131), nên hành vi của nó chi phối cách mọi thao tác ghi shift đồng bộ lại UI.
Lưu ý: ["useShiftTimetable"] được invalidate theo prefix (không kèm officeId/filter) → khớp mọi biến thể của query timetable đang active, gồm đúng query ngày đang xem. Còn useSearchShiftTimetable được invalidate cả có date lẫn không date để phủ cache theo-ngày và cache tổng.
hook/useShift.ts:748 → gọi invalidateShiftCalendarQueries(queryClient, date) = đúng hàm bao mô tả ở trên (invalidate 5 nhóm queryKey song song). → Đây là refetch chính sau lưu (bước 8).
index.tsx:749-754: khi thoát quick-edit, nếu !skipInvalidate sẽ invalidate lần hai (useShiftTimetable + useNgDesireTimeOffice). Nhưng submit gọi onQuickEditChange(false,{skipInvalidate:true}) (index.tsx:1105) → khối này bị skip = mất "nhịp invalidate dự phòng".
filter ⇒ queryKey mới ⇒ query mới (đường khôi phục); còn invalidate cùng-key thì không tự sửa được trạng thái đã hỏng.invalidateShiftCalendarQueries bọc try/catch (useShift.ts:114-120) để lỗi refetch không làm mutation reject. Hệ quả: nếu refetch do invalidate kích hoạt bị hủy/treo trên iOS, mutation vẫn báo success nhưng cache không được cập nhật (chỉ bị đánh dấu stale).refetchOnWindowFocus:false (ManagementLayoutWrapper.tsx:21) → quay lại foreground không tự refetch → kẹt tới khi reload/đổi ngày. Đây chính là cơ chế giả thuyết B.invalidateQueries có được gọi hay không" — nó có được gọi (điểm a). Vấn đề là nó chạy một lần, trên cùng queryKey, không tự xác thực kết quả, và lỗi refetch bị nuốt; nên khi lần refetch đó lỡ (B) hoặc kết quả không repaint (A) thì không còn nhịp invalidate nào cứu (đã skip ở điểm b). Các fix S2/S3/P1 (mục 8) nhắm thẳng vào đây — đồng bộ tại chỗ (cùng queryKey): refetchQueries tường minh + keepPreviousData / guard chống ghi đè cũ + retry ngắn / bật refetch theo visibilitychange + repaint nudge.Lỗi xuất hiện ở "khe" giữa bước 8 (ghi vào writer) và bước 12 (đọc lại từ reader). Hai khiếm khuyết cộng hưởng.
dataSourceFilter bị ghi đè vô điều kiện bằng kết quả refetch (kể cả khi refetch thiếu shift vừa lưu).skipInvalidate:true bỏ nhịp invalidate dự phòng, refetchOnWindowFocus:false → không có đường tự khôi phục.queryKey theo ngày không đổi + WebKit có thể không repaint → màn kẹt đến khi reload/đổi ngày.ShiftRosterPrimaryDao (writer); đọc → ShiftRosterSecondaryDao (reader replica cluster-ro).Tóm tắt những gì đã rà soát và cách giả thuyết tiến triển — để kết luận ở mục 6 là kết quả truy vết qua mã nguồn + số liệu thực, không phải phỏng đoán.
onSubmitQuickEdit → mutateAsync → onFinishQuickEdit.invalidateShiftCalendarQueries, skipInvalidate, refetchOnWindowFocus, hình dạng queryKey.dataSourceFilter; layout timetable transform/sticky/React.memo.@AlohomoraMysqlPrimary/Secondary (writer/reader).bulkUpsertShifts ghi đồng bộ trong transaction qua primary.searchWithUnassigned đọc qua secondary (reader).alohomora-release-rds (2 instance) vs beta (1 instance).dataSourceFilter vô điều kiện, skipInvalidate:true, refetchOnWindowFocus:false, queryKey không đổi, layout transform/sticky + React.memo (xem bảng 🔎 Bằng chứng).Tầng kích hoạt & tầng khuếch đại, kèm số liệu thực đo, khác biệt môi trường và bảng truy vết mã nguồn (các mục hỗ trợ ◆/📊/🔎 ngay dưới).
Vì Frontend refetch gần như tức thì sau khi POST trả 200, truy vấn đọc rơi vào cửa sổ replication lag. Replica trả về snapshot cũ — không có shift vừa di chuyển (hoặc shift cũ đã chuyển trạng thái nhưng bản mới chưa replicate) → kết quả nhìn thấy là shift biến mất.
fix shift submit cache invalidation → Revert "Fix shift submit refetch ordering" → Revert "Revert ...". Đó là dấu hiệu kinh điển của một vấn đề thứ tự refetch / nhất quán đọc-ghi chưa được trị tận gốc — khớp với kết luận ở trên.
Phần này mô tả khác biệt writer/reader giữa các môi trường. Với thông tin hiện trường mới (Safari/iPad, cực hiếm), replica-lag đã được hạ cấp — giữ lại như rủi ro nền cho các luồng ghi-rồi-hiển-thị khác.
| Môi trường | Số instance Aurora | Primary (ghi) | Secondary (đọc) | Replication lag | Tái hiện lỗi? |
|---|---|---|---|---|---|
| Beta | 1 (db.t4g.medium, single-AZ) | …cluster-… | …cluster-ro-… → trỏ về cùng 1 node (không có replica thật) |
~0 ms | Không |
| Production (release) | 2 (writer + reader) | …cluster-… | …cluster-ro-… (replica thật) |
~15ms TB · đỉnh 49ms (đo thực) | Trên lý thuyết |
production/backend/main.tf:33: rds_instance_count=2 (cluster caremakerdb-production — khác cluster alohomora-release-rds của app; xem lưu ý ở bảng bằng chứng #6).alohomora-beta-rds-cluster chỉ có 1 instance Aurora (db.t4g.medium): tuy vẫn có reader endpoint cluster-ro nhưng nó trỏ về chính node writer duy nhất → đọc-sau-ghi luôn nhất quán → không thể tái hiện trên beta. (Local/test còn dễ hơn: primary & secondary cùng trỏ localhost.)Đo trực tiếp trên cluster alohomora-release-rds (writer instance-1 + reader instance-1-ap-northeast-1c, cùng AZ ap-northeast-1c). Nguồn: AWS CloudWatch AWS/RDS, profile read-only · cửa sổ 05–19/06/2026 (lag) & 12–19/06/2026 (throughput). Mục đích: lượng hóa replica-lag & lưu lượng để kiểm chứng/loại trừ các giả thuyết.
cluster-ro)| Chỉ số | Giá trị |
|---|---|
| Lag trung bình (14 ngày) | ~15.2–15.9 ms |
| Lag đỉnh theo ngày | 28–49 ms |
| Lag đỉnh tuyệt đối | 49 ms (12/06/2026) |
Lag ổn định & rất nhỏ — không có spike bất thường trong cả 14 ngày.
| Chỉ số | Trung bình | Đỉnh |
|---|---|---|
| Queries/s | ~20 | ~315 |
| DML (ghi)/s | ~1.4 | ~97 |
| Commit/s | ~0.7 | ~20 |
| Kết nối đồng thời | 13–15 | 25–42 |
Hệ thống lưu lượng thấp: trung bình <1 commit/giây, hiếm khi đông người dùng đồng thời.
alohomora-release-rds (2 instance: 1 writer + 1 reader, cùng AZ 1c, db.t3.large, MultiAZ=false). Cluster caremakerdb-production trong caremaker-infra là cluster khác — bằng chứng rds_instance_count=2 ở mục 8 chỉ chứng minh mẫu hình writer/reader, không phải đúng cluster của app. Cả hai cluster đều có 2 members (đã xác nhận qua describe-db-clusters).
Vì lag thực quá nhỏ, không thể tái hiện bằng cách đua replica-lag tự nhiên; và lỗi cực hiếm nên cũng khó ép tái hiện trên iPad. Hướng thực tế: fix phòng vệ S3 (mục 8) + telemetry nền để bắt lần kế tiếp tự động; soi Web Inspector chỉ là bước tùy cơ (mục 7).
Phải bơm lag nhân tạo (ép đọc reader ngay sau ghi với độ trễ giả lập) hoặc chạy refetch trong vòng lặp gắt sát thời điểm commit — vì lag tự nhiên ~15ms gần như không bao giờ lọt vào cửa sổ render. Đây là kiểm chứng học thuật, không phản ánh tải thực.
Bảng truy vết để đội phát triển kiểm chứng nhanh.
| # | Vị trí | Vai trò trong lỗi |
|---|---|---|
| 1 | caremaker-alohomora/lib/mysql-base/.../application-mysql.yml | Khai báo primary (cluster) & secondary (cluster-ro, readOnly) cho production → tách writer/reader. |
| 2 | mysql-base/.../MysqlConfiguration.kt | Định nghĩa 2 bean datasource: @AlohomoraMysqlPrimary & @AlohomoraMysqlSecondary. |
| 3 | mysql-base/.../dao/ShiftRosterDao.kt | Sealed class: ShiftRosterPrimaryDao (dòng 45, ghi) & ShiftRosterSecondaryDao (dòng 1318, đọc — chứa searchWithUnassigned). |
| 4 | shift-core/.../ShiftRosterInfoService.kt:45 | searchWithUnassigned gọi shiftRosterSecondaryDao → xác nhận luồng đọc timetable đi qua reader replica. |
| 5 | app/crm/.../ShiftCalendarModifyService.kt | bulkUpsertShifts: ghi đồng bộ qua primary/writer trong transaction (qua lớp ShiftRosterModifyService); trả ShiftUpdateResponse(isSucceed=true) (không kèm dữ liệu shift mới). |
| 6 | caremaker-infra/production/backend/main.tf:33 | rds_instance_count=2 → mẫu hình writer+reader trên production. ⚠ Lưu ý: cluster này là caremakerdb-production (database_id="caremakerdb", main.tf:28), không phải cluster alohomora-release-rds mà app alohomora thực sự kết nối — cluster alohomora không được định nghĩa trong caremaker-infra. Đây là bằng chứng gián tiếp cho mẫu hình, không phải cho đúng cluster của app. |
| 7 | caremaker-horizon/hook/useShift.ts:746 | useBulkUpsertShifts.onSuccess → invalidateShiftCalendarQueries → refetch ngay (đọc reader). |
| 8 | caremaker-horizon/.../shift/index.tsx:920 | Effect ghi đè dataSourceFilter bằng kết quả refetch (không phòng vệ dữ liệu cũ). |
| 9 | caremaker-horizon/.../shift/index.tsx:1104 | onFinishQuickEdit → onQuickEditChange(false,{skipInvalidate:true}): bỏ nhịp invalidate dự phòng. |
| 10 | caremaker-horizon/.../partials/tools.tsx:1166 | onSubmitQuickEdit: trình tự submit → mutate → finish. |
| 11 | caremaker-horizon/.../shift/ShiftTimetable.tsx:159 | CLIENT Cột ảo hóa định vị bằng transform: translateX → rủi ro repaint WebKit (giả thuyết A). |
| 12 | ShiftTimetable.tsx:184-232 | CLIENT React.memo so sánh chủ yếu bằng reference cho cột staff. |
| 13 | ShiftTimetable.tsx:505-516 | CLIENT Vá sẵn "avoid momentary blanking / width collapse" → lớp lỗi render đã tồn tại. |
| 14 | wrappers.tsx:158 · partials/index.tsx:496 | CLIENT position: sticky chồng trên ancestor có transform (rủi ro paint iOS). |
| 15 | ManagementLayoutWrapper.tsx:21 | CLIENT refetchOnWindowFocus:false → mất đường khôi phục refetch (giả thuyết B). |
| 16 | hook/useWindowDimension.ts:17,26 | CLIENT isMobile theo innerWidth ≤ MOBILE_BREAKPOINT (767) → iPad chạy layout desktop dưới WebKit cảm ứng. |
Lỗi cực hiếm (1 user · 2 lần) nên gần như không ép tái hiện được theo ý muốn. Do đó chiến lược thực tế là: không chờ chẩn đoán mà triển khai (1) một fix phòng vệ phủ cả hai giả thuyết + (2) telemetry chạy nền để xác nhận sau; việc soi Web Inspector chỉ là cơ hội nếu may bắt được lỗi đang xảy ra.
keepPreviousData, guard chống ghi đè cũ + retry ngắn, repaint nudge). Trị cả A lẫn B nên không cần chốt giả thuyết, và không nháy/không reset scroll (KHÔNG bump queryKey/không remount — xem cảnh báo UX ở mục 8).visibilitychange) — bắt lần xảy ra kế tiếp tự động, không cần cắm iPad chờ.transform + sticky → không repaint sau khi data đổitransform: translateX(start) — ShiftTimetable.tsx:159.position: sticky chồng trên ancestor có transform — partials/wrappers.tsx:158, partials/index.tsx:496.overscrollBehavior:none, momentum scroll — partials/wrappers.tsx:126.translateZ(0) / will-change / backface-visibility).React.memo so sánh chủ yếu bằng reference — ShiftTimetable.tsx:184-232.skipInvalidate:true → bỏ nhịp invalidate dự phòng — index.tsx:1104.refetchOnWindowFocus:false → quay lại trang không tự fetch lại — ManagementLayoutWrapper.tsx:21.isMobile = window.innerWidth ≤ MOBILE_BREAKPOINT (767) (useWindowDimension.ts:17,26). iPad (≥768px) chạy layout desktop transform/sticky đầy đủ nhưng dưới compositor WebKit cảm ứng — chính sự lệch pha này là nơi bug paint iOS (giả thuyết A) thường phát sinh.
Fix: thêm repaint nudge (translateZ(0)/will-change:transform cho cột, hoặc ép reflow sau khi data đổi); cân nhắc tách sticky khỏi ancestor có transform.
useShiftTimetableNếu cache cũ ⇒ refetch bị lỡ (B). Fix: đảm bảo refetch chắc chắn khi thoát quick-edit (bỏ skipInvalidate hoặc refetchQueries tường minh) + bật refetch theo visibilitychange/focus.
Mẹo bổ sung: khi đang "kẹt", thử vuốt nhẹ cuộn timetable — nếu shift hiện ra ngay khi scroll mà chưa fetch lại, đó là bằng chứng mạnh cho A (lỗi paint).
Vì chẩn đoán trực tiếp gần như bất khả thi (mục 7), ưu tiên giải pháp phòng vệ phủ cả hai giả thuyết — S3 — không phụ thuộc việc chốt A/B. S1/S2 chỉ áp bổ sung nếu sau này telemetry/Web Inspector chốt được nguyên nhân.
Mục tiêu: đạt đúng kết quả của thao tác "đổi ngày" (dữ liệu mới + vẽ lại đúng) nhưng làm tại chỗ nên không chớp trắng, không mất vị trí cuộn, không double-fetch giật. Gồm 3 việc nhẹ, đều giữ nguyên queryKey:
skipInvalidate hoặc refetchQueries(["useShiftTimetable"]) tường minh). Đặt keepPreviousData:true ⇒ data cũ vẫn hiển thị trong lúc tải → không nháy, không reset scroll. → đảm bảo luôn có một lần đọc mới (trị gốc B).dataSourceFilter khi nó đã chứa shift vừa lưu; nếu chưa (trúng khe lag) thì retry vài nhịp ~1s rồi mới đồng bộ. → không bao giờ "nháy mất shift", trị B chắc chắn kể cả khi lag.dataSourceFilter đổi, một nhịp requestAnimationFrame + ép reflow (đọc offsetHeight) hoặc toggle will-change trên cột. → ép WebKit vẽ lại (trị A) mà KHÔNG dựng lại bảng.queryKey hay remount component bằng key — nó tạo query mới (cache rỗng) ⇒ chớp trắng + reset scroll sau mỗi lần lưu (đúng lớp lỗi "avoid momentary blanking" mà đội đã từng phải vá). Đồng bộ tại chỗ ở trên cho cùng kết quả mà không có chi phí UX đó.Thêm GPU hint cho cột (will-change:transform / translateZ(0)) hoặc ép reflow một nhịp (rAF + đọc offsetHeight) sau khi dataSourceFilter đổi. Cân nhắc tách sticky khỏi ancestor có transform. Không remount ⇒ không ảnh hưởng UX.
Bỏ skipInvalidate / refetchQueries(["useShiftTimetable"]) tường minh — cùng queryKey + keepPreviousData nên không nháy/không reset scroll. Thêm refetch theo visibilitychange/focus để iOS quay lại foreground là tự đồng bộ (lưới an toàn cho iOS background).
Trong effect đồng bộ (index.tsx:920), không ghi đè dataSourceFilter bằng kết quả refetch nếu refetch chưa chứa thay đổi vừa lưu; chỉ thay khi đã khớp. Giữ state lạc quan đang đúng ⇒ không nháy. (P3 cũ trùng S3 đã gộp.)
Nếu refetch đầu chưa chứa shift vừa lưu (trúng khe lag ~15ms), retry có backoff ngắn (vài lần trong ~1–2s) rồi mới đồng bộ. Chạy ngầm, không chặn UI (data cũ vẫn hiển thị) ⇒ người dùng không thấy gì bất thường.
Với truy vấn timetable ngay sau thao tác ghi, ép đọc từ primary thay vì secondary (ví dụ tham số consistentRead, hoặc để bulkUpsert trả thẳng dữ liệu mới đọc từ writer). Loại bỏ tận gốc khe lag cho mọi luồng ghi-rồi-hiển-thị.
Đánh đổi: tăng tải writer (chấp nhận được vì tần suất thấp). Không khẩn cho ca này.
Log thời điểm refetch, độ dài kết quả search, sự kiện visibilitychange + lỗi/hủy fetch. Không cần tái hiện thủ công — bắt tự động lần xảy ra kế tiếp để xác nhận đã hết hoặc chốt A/B.
keepPreviousData (S2) · guard chống ghi đè cũ + retry ngắn (P2+P1) · repaint nudge tại chỗ (S1). Phủ cả A & B, không cần chốt nguyên nhân và không ảnh hưởng UX (không nháy, không reset scroll).Vì lỗi không ép tái hiện được, tiêu chí được đặt theo hướng kiểm chứng tất định hành vi của fix (không phụ thuộc việc làm lỗi xuất hiện).
queryKey (không tạo query mới); trong lúc tải data cũ vẫn hiển thị (keepPreviousData) — không chớp trắng; sau khi data về, cột được vẽ lại đúng. Guard: nếu cố tình trả kết quả thiếu shift vừa lưu (mock), UI không mất shift mà chờ retry.visibilitychange từ production (xác nhận cơ chế quan trắc hoạt động để theo dõi tái diễn).Giải thích ngắn gọn các thuật ngữ kỹ thuật dùng trong tài liệu. Mẹo: mọi thuật ngữ xuất hiện trong phần văn bản đều có gạch chân nét đứt — rê chuột (hover) lên để xem nhanh định nghĩa.