LOVEREC · INCIDENT POSTMORTEM · Internal · Engineering only
🇻🇳 Tiếng Việt
🔴 P0 · CRITICAL PRD-LIVE 4 BUGS 2026-05-19 | Engineering team

Duplicate notification
+ missing follower email

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.

TÓM TẮT
  • 4 bugs độc lập xếp chồng (3 fire-and-forget + 1 UPSERT bị comment out) → mọi tầng dedup đều fail
  • Owner: nhận ít nhất 2 notification + 2 email (gap mỗi lần đúng 300s — số thực tế có thể cao hơn, xem note §02)
  • Follower: 0 email đến SendGrid (lẽ ra 6,699)
  • Hotfix LRCC-2417 sáng nay không phải nguyên nhân — chỉ làm gap dịch từ 30s thành 300s
01 / SCOPE

Phạm vi ảnh hưởng

Số liệu thực tế trích từ DB query + SendGrid Activity quanh 2026-05-19 10:30 UTC.

≥×2*
Owner notification duplicate
gap mỗi lần đúng 300s = SQS visibility timeout. *Cận dưới — xem note §02
6,699
Follower mất email
SendGrid Activity = 0 record cho video này
~2× 1,482
Follower notification duplicate
~2,964 row trước cleanup; tech lead xoá xuống 1,482 distinct. UPSERT logic bị comment out (xem §04 Bug #4)
3
Bug độc lập đã identify
2 trong worker, 1 trong backend
Common root cause pattern
Cả 3 bug đều dùng fire-and-forget Promise trong môi trường auto-scale instance. Khi instance bị scale-down kill, các Promise in-flight chưa hoàn tất sẽ chết → SQS message không delete được / email Promise chưa kịp gọi SQS API.
02 / SYMPTOMS

Số liệu quan sát

Từ DB query trên PRD (read-only) + SendGrid Activity API.

TARGET vs ACTUAL
NotificationEmail
Target owner11
Target follower1,4826,699
Actual owner≥2 (dup)*≥2 (dup)*
Actual follower~2× 1,482 (dup)*0
QUERY: notifications table
redirect_typerowsdistinctdupspan_sec
13 OWNER211300
28 FOLLOWER1,482*1,482*0*0*
64 WAIT_OWNER1100

span_sec = 300 = smoking gun. Đúng bằng SQS visibility timeout default (sau hotfix LRCC-2417).

⚠ Note quan trọng — TOÀN BỘ số liệu duplicate là cận dưới (đã cleanup)
Trước khi query, tech lead đã xoá thủ công các record duplicate trên DB để giảm tác động UX. Cleanup này áp dụng cho cả:
  • redirect_type=13 (owner): chỉ còn ≥2 row residual
  • redirect_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)

→ Số lần SQS retry thực tế có thể nhiều hơn 1 (nhiều bội số 300s). → Mỗi follower cũng nhận nhiều thông báo trong app, không chỉ creator. → Mức độ thực tế của bug nghiêm trọng hơn nhiều con số đã ghi nhận. → Cần check CloudWatch logs / APM transaction để biết số retry chính xác (xem §06).
AUDIENCE DISTRIBUTION (loại trừ null setting hypothesis)
content_video settingfollower countInterpretation
1 · FOLLOWING (default)6,732Đáng lẽ pass filter checkFollower → gửi email
2 · OFF375Blocked đúng quy trình
3 · SUBSCRIBE14Cần check subscribe relation
NULL (no row)0Loại trừ hypothesis null setting throw
Total followers7,121
03 / FLOW DIAGRAM

Pipeline public video — vị trí 3 bug

Đỏ = bug. Vàng = race trigger nhưng không kích hoạt trên video này.

Admin accept video (backend) MediaConvert process (AWS) updateConvertVideo (backend consumer) ⚠ Bug #3: forEach(async) race delay until publicTime SQS · lrc-publishing-video handlePublishingVideo (worker) ⚠ Bug #1: forEach email không await ⚠ Bug #2: deleteMessageBatch callback fire-forget → SQS retry sau 300s nếu instance kill Owner: notif + email → duplicate ≥×2 do SQS retry lặp lại Partner: notif + email 0 partner trên video này Follower (1,482 ×) → notif app lặp nhiều lần → 0 email (6,699 mất)
04 / ROOT CAUSES

4 bug độc lập

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.

P0
1

Fire-and-forget email forEach

Explain "0 follower email"
worker/apps/video-service/src/video-publishing/service.ts ~line 640 · Introduced commit 190d69ad (2025-12-01, LRC-11501)
Code lỗi
// 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
});
Mechanism
  • 7,074 Promise bay vào event loop song song, mỗi cái internal await sqsService.send()
  • handlePublishingVideo return ngay sau forEach (không đợi)
  • Consumer mark SUCCESS → deleteMessageBatch
  • Auto-scale-down kill instance (CPU drop) → 7,074 Promise chết trước khi hit SQS API
  • 0 message vào lrc-send-mail queue → 0 SendGrid call
Tại sao owner email vẫn work?
Owner email là 1 Promise duy nhất (sendMailQueue.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.
Fix
await Promise.all(
  membersToSendEmail.map((subscriber) =>
    this.sendMailQueue.add({...})
  )
);
P0
2

deleteMessageBatch callback fire-and-forget

Explain "duplicate owner notif"
worker/common/queue/sqs-base.consumer.ts · Pre-existing trước LRCC-2417, không bị hotfix sửa
Code lỗi
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
}
Mechanism
  • AWS SDK deleteMessageBatch callback style, function return ngay
  • Nếu network blip / instance kill xảy ra trước khi SDK callback fire → request không gửi
  • SQS giữ message → sau 300s visibility timeout → redeliver
  • Mỗi 300s, handlePublishingVideo 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 DLQ
Quan hệ với LRCC-2417 (hotfix sáng nay)
Trước hotfix: visibility 30s, dedup cache TTL 20s → almost every retry duplicated.
Sau hotfix: visibility 300s, dedup TTL 300s → gap dịch chuyển từ 30s → 300s. Hotfix giảm tần suất nhưng KHÔNG sửa root cause.
Fix
async 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
}
P1
3

Backend outputObject.forEach(async) race

Preventive — chưa kích hoạt trên video này
backend/src/app/shared/queues/updateConvertVideo.queue.ts:137
Code lỗi
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 song
  • Nếu MediaConvert callback outputObject có ≥2 entries match cùng uniqueLink, race condition khiến nhiều iteration cùng pass status check
  • Cùng enqueue PUBLISHING_VIDEO → 2 SQS message khác MessageId → worker xử lý song song
  • Trên video bug này KHÔNG kích hoạt — gap=300s xác nhận SQS retry, không phải race <5s
Fix
for (const output of outputObject) {
  await processOutput(output);  // sequential await
}
P0
4

createCombineNotifications UPSERT bị comment out

Amplify Bug #2 — follower notif duplicate
worker/common/modules/notification/service.ts ~line 3289 · Phát hiện sau khi user point out follower notif cũng duplicate
Code lỗi (đoạn dedup bị disable)
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
}
  • Các redirect type khác (HEART, STAR, GIVE_GIFT, COMMENT...) VẪN GIỮ previousNotification lookup → upsert đúng
  • Riêng PUBLIC_VIDEO_FOR_FOLLOWER bị comment out → mỗi consumer run = INSERT 1,482 row mới, không dedup
  • Combined với Bug #2 (SQS retry 300s): mỗi retry = thêm 1,482 row → 2,964 row sau 1 retry, 4,446 sau 2 retry, etc.
  • Mỗi follower cảm nhận nhiều thông báo cho cùng 1 video trong app (UX rất tệ)
  • Lý do comment out CHƯA RÕ — cần check git blame; có thể do bug regression nào đó trong upsert logic được patch tạm bằng cách bỏ luôn
Fix
// 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.

05 / LRCC-2417 RELATIONSHIP

Hotfix sáng nay có liên quan không?

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.

BugLiê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 → 300sdedup 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).
Gap 300s = LRCC-2417 fingerprint
Quan sát thấy 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.
06 / VERIFICATION EVIDENCE

Bằng chứng confirm hypothesis

✅ Đã xác minh
  • DB query: notifications table cho video bug → owner duplicate (≥2 rows sau cleanup thủ công), follower 1,482 OK, gap chính xác 300s
  • SendGrid Activity: owner = 2 records (duplicate), follower = 0 records
  • Audience query: 0 follower thiếu setting row → loại trừ null setting throw hypothesis
  • Content_video distribution: 6,732 FOLLOWING + 14 SUBSCRIBE = ~6,746 lẽ ra pass filter
⏳ Còn cần check (optional)
  • CloudWatch metric lrc-send-mail · NumberOfMessagesSent quanh 10:30 ±5min — kỳ vọng < 100 thay vì 7,074
  • CloudWatch logs worker video-service filter SIGTERM hoặc "shutting down" quanh 10:30
  • ECS/EKS DescribeTasks events: instance terminate trong window 10:30–10:35 → confirm scale-down kill
  • APM Elastic transaction Handle message from queue queue=lrc-publishing-video — đếm số lần cùng videoId được xử lý (mỗi lần = 1 retry)
  • Audit DB cleanup: hỏi tech lead chính xác bao nhiêu row duplicate đã bị xoá thủ công trước khi điều tra — để tái dựng số retry thực sự
07 / FIX PRESCRIPTION

Đề xuất sửa theo priority

PriorityFileChangeBug fix
P0 video-publishing/service.ts ~640 forEachawait 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
Risk assessment
Tất cả fix P0 đều low-risk: không thay đổi schema/contract, chỉ chuyển fire-and-forget → await. Semantic giữ nguyên, chỉ chậm hơn vài giây (chấp nhận được vì worker là background async). Rollback dễ bằng revert commit.
08 / TIMELINE

Lịch đề xuất triển khai

  1. 2026-05-20 P0 fix dev

    Fix Bug #1 + #2 + #3 (idempotent guard) trên worker. 1 ngày dev + 0.5 ngày review.

  2. 2026-05-21 Staging test

    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.

  3. 2026-05-22 Deploy PRD + monitor

    Deploy PRD. Monitor 24h: CloudWatch metric, SendGrid Activity, notifications table cho mọi video public.

  4. 2026-05-23 Fix P1 + cleanup

    Fix Bug #3 backend (forEach race) + P2 category null-guard. Final post-mortem report.