Khi 1 video được public trên PRD, hệ thống tạo notification lặp cho cả creator lẫn 1,482 follower (mỗi cái nhiều lần) và 0 email tới 6,699 follower đáng lẽ phải nhận. Báo cáo này phân tích root cause, evidence và prescription fix.
Số liệu thực tế trích từ DB query + SendGrid Activity quanh 2026-05-19 10:30 UTC.
Từ DB query trên PRD (read-only) + SendGrid Activity API.
| Notification | ||
|---|---|---|
| Target owner | 1 | 1 |
| Target follower | 1,482 | 6,699 |
| Actual owner | ≥2 (dup)* | ≥2 (dup)* |
| Actual follower | ~2× 1,482 (dup)* | 0 |
| redirect_type | rows | distinct | dup | span_sec |
|---|---|---|---|---|
13 OWNER | 2 | 1 | 1 | 300 |
28 FOLLOWER | 1,482* | 1,482* | 0* | 0* |
64 WAIT_OWNER | 1 | 1 | 0 | 0 |
span_sec = 300 = smoking gun. Đúng bằng SQS visibility timeout default (sau hotfix LRCC-2417).
redirect_type=13 (owner): chỉ còn ≥2 row residualredirect_type=28 (follower): chỉ còn 1,482 distinct — trước cleanup khoảng ~2× 1,482 = ~2,964 row (Bug #4 — UPSERT bị comment out, mỗi consumer run INSERT mới)| content_video setting | follower count | Interpretation |
|---|---|---|
| 1 · FOLLOWING (default) | 6,732 | Đáng lẽ pass filter checkFollower → gửi email |
| 2 · OFF | 375 | Blocked đúng quy trình |
| 3 · SUBSCRIBE | 14 | Cần check subscribe relation |
| NULL (no row) | 0 | Loại trừ hypothesis null setting throw |
| Total followers | 7,121 |
Đỏ = bug. Vàng = race trigger nhưng không kích hoạt trên video này.
3 bug fire-and-forget Promise + 1 bug UPSERT logic bị disable. Mọi tầng dedup đều fail dẫn tới duplicate notification + email mất hàng loạt.
worker/apps/video-service/src/video-publishing/service.ts ~line 640
· Introduced commit 190d69ad (2025-12-01, LRC-11501)
// BUG: forEach là sync, sendMailQueue.add trả Promise không await membersToSendEmail.forEach((subscriber) => { this.sendMailQueue.add({ receiver: subscriber.email, subject: publicVideoForSubscribers.subject, content: format(...), summary: NotificationRedirectType[PUBLIC_VIDEO_FOR_FOLLOWER], memberId: subscriber.id, senderId: owner.id, emailType: EmailType.CONTENT_VIDEO, }); // ← Promise bay vào event loop, KHÔNG đợi });
await sqsService.send()handlePublishingVideo return ngay sau forEach (không đợi)deleteMessageBatchlrc-send-mail queue → 0 SendGrid callsendMailQueue.add(mail)) — μs–ms để hit SQS API → sống sót dù instance bị kill ngay sau đó. Follower email là 7,074 Promise song song → đại đa số không kịp hoàn thành.
await Promise.all( membersToSendEmail.map((subscriber) => this.sendMailQueue.add({...}) ) );
deleteMessageBatch callback fire-and-forgetworker/common/queue/sqs-base.consumer.ts
· Pre-existing trước LRCC-2417, không bị hotfix sửa
async deleteMessageBatch(messages: AWS.SQS.Message[]) { const producer = this.sqsService.producers.get(this.queueName); producer.sqs.deleteMessageBatch({...}, (err) => { if (err) console.error(...); else console.log(...); }); // ← callback style, không await, không retry }
deleteMessageBatch callback style, function return ngayhandlePublishingVideo chạy lại → duplicate owner notif (gap chính xác 300s — matched data). Cycle có thể lặp nhiều lần cho tới khi delete cuối cùng thành công hoặc message vào DLQasync deleteMessageBatch(messages: AWS.SQS.Message[]) { const producer = this.sqsService.producers.get(this.queueName); await producer.sqs.deleteMessageBatch({ QueueUrl: producer.queueUrl, Entries: messages.map(m => ({ Id: m.MessageId, ReceiptHandle: m.ReceiptHandle })), }).promise(); // ← promisify + await, surface error }
outputObject.forEach(async) racebackend/src/app/shared/queues/updateConvertVideo.queue.ts:137
outputObject.forEach(async (output) => { // ← forEach không await async callbacks const video = await videoRepository.findOne({ where: { uniqueLink, status: PROCESS_HLS_VIDEO } }); if (!!video && !!outputLink) { // transaction update + push notif + publishingVideoProducer.add } });
forEach không await async callbacks → iterations chạy song songoutputObject có ≥2 entries match cùng uniqueLink, race condition khiến nhiều iteration cùng pass status checkfor (const output of outputObject) { await processOutput(output); // sequential await }
createCombineNotifications UPSERT bị comment outworker/common/modules/notification/service.ts ~line 3289
· Phát hiện sau khi user point out follower notif cũng duplicate
if (redirectType === NotificationRedirectType.PUBLIC_VIDEO_FOR_FOLLOWER) { // ... build content ... Object.assign(notification, { content }); // const previousNotification = await this.findNotification( // ['id', 'memberId', 'metadata'], // { // redirectType: PUBLIC_VIDEO_FOR_FOLLOWER, // memberId, // createdAt: moment().subtract(NOTIFICATION_COMBINATION_EXPIRE_IN_HOUR, 'hours').toDate(), // }, // ); // if (previousNotification) { ... update existing row + uniqBy videos ... } // ← TOÀN BỘ logic UPSERT bị comment out }
previousNotification lookup → upsert đúngPUBLIC_VIDEO_FOR_FOLLOWER bị comment out → mỗi consumer run = INSERT 1,482 row mới, không dedup// Uncomment previousNotification lookup + update logic if (redirectType === NotificationRedirectType.PUBLIC_VIDEO_FOR_FOLLOWER) { // ... build content ... const previousNotification = await this.findNotification( ['id', 'memberId', 'metadata'], { redirectType: PUBLIC_VIDEO_FOR_FOLLOWER, memberId, createdAt: moment().subtract(NOTIFICATION_COMBINATION_EXPIRE_IN_HOUR, 'hours').toDate(), }, ); if (previousNotification) { metadata.videos.push(...videos); metadata.videos = _uniqBy(metadata.videos, 'id'); // ← key: dedup by video id Object.assign(notification, { id: previousNotification.id, metadata }); } }
Lưu ý: trước khi uncomment, cần verify lý do nó bị comment out ban đầu — có thể có bug regression. Nếu là regression đã fix → uncomment an toàn. Nếu không, cần re-test edge cases.
Cần làm rõ vì hotfix vừa merge 04:07 UTC sáng nay (2026-05-19), bug xảy ra lúc 10:30 UTC.
| Bug | Liên quan LRCC-2417? | Bằng chứng |
|---|---|---|
| #1 Fire-and-forget email | ❌ KHÔNG | Bug từ commit 190d69ad (LRC-11501, 2025-12-01). Hotfix không touch file video-publishing/service.ts. |
| #2 deleteMessageBatch | 🟡 Gián tiếp | Hotfix đổi visibilityTimeout: 30s → 300s và dedup TTL: 20s → 300s. Gap duplicate dịch chuyển 30s → 300s. Core bug callback-style không bị động vào. |
| #3 Backend forEach race | ❌ KHÔNG | Khác repo (backend vs worker). LRCC-2417 chỉ trong worker repo. |
| #4 UPSERT bị comment out | ❌ KHÔNG | Đoạn comment out đã tồn tại trong code từ lâu, không phải do LRCC-2417 (cần check git blame để xác định commit/người). |
span_seconds=300 trong Query 1 chính là bằng chứng gián tiếp hotfix đã deploy. Trước hotfix gap sẽ là ~30s. Sau hotfix đúng 300s. Bug duplicate notification tồn tại từ trước hotfix, hotfix chỉ thay đổi mức độ và biểu hiện.lrc-send-mail · NumberOfMessagesSent quanh 10:30 ±5min — kỳ vọng < 100 thay vì 7,074video-service filter SIGTERM hoặc "shutting down" quanh 10:30Handle message from queue queue=lrc-publishing-video — đếm số lần cùng videoId được xử lý (mỗi lần = 1 retry)| Priority | File | Change | Bug fix |
|---|---|---|---|
| P0 | video-publishing/service.ts ~640 |
forEach → await Promise.all(map) |
#1 follower email |
| P0 | sqs-base.consumer.ts |
deleteMessageBatch dùng .promise() + await |
#2 duplicate notif |
| P0 | video-publishing/service.ts đầu hàm |
Idempotent guard: setNX video_published_processed:{videoId} TTL 1h |
defense-in-depth cho #2 |
| P0 | notification/service.ts ~line 3289 |
Uncomment previousNotification + update logic cho PUBLIC_VIDEO_FOR_FOLLOWER (sau khi check git blame) |
#4 follower notif duplicate |
| P1 | updateConvertVideo.queue.ts:137 |
forEach(async) → for...of await |
#3 preventive |
| P2 | video-publishing/service.ts category block |
null-guard + sửa typo alias 'c.title tile' |
future-proof |
Fix Bug #1 + #2 + #3 (idempotent guard) trên worker. 1 ngày dev + 0.5 ngày review.
Test trên staging: publish video với >1,000 follower, verify (a) creator nhận đúng 1 notif/email, (b) toàn bộ follower nhận đủ, (c) không lặp khi auto-scale.
Deploy PRD. Monitor 24h: CloudWatch metric, SendGrid Activity, notifications table cho mọi video public.
Fix Bug #3 backend (forEach race) + P2 category null-guard. Final post-mortem report.