Threads API v2 · Notification Grouping Analysis · 2026-05-16

Notification grouping job: luồng kỹ thuật và lỗi count không xuống DB

Report này đi từ route POST /api/v1/posts/:publicId/reply, qua postService.reply(), Redis pending group, BullMQ repeat job, worker initSyncNotificationBatch, rồi tới bảng notification_groups. Mục tiêu là chỉ rõ vì sao Redis count tăng nhưng DB không update, và nên sửa theo mức nào.

Kết luận nhanh: log của bạn cho thấy Redis hash của pending notification chỉ còn { count: "8" }, thiếu authorId, postPublicId, notificationType, targetType, lastActorId. Worker có guard if (!data?.authorId) nên bỏ qua key đó, batch thành [], sau đó createMany([]) không ghi gì xuống DB.

Lỗi làm count cứ tăng tiếp: khi gặp key thiếu metadata, worker hiện chỉ xóa ${key}:queued, nhưng không xóa hash key và set ${key}:actors. Nếu actor cũ reply tiếp, sAdd trả 0, code lại không hSet metadata vì metadata chỉ được set khi actor là actor mới. Kết quả là một pending key bị "kẹt độc": count cứ tăng, worker cứ pop, rồi lại bỏ qua.

Lỗi thiết kế thứ hai: DB path hiện là notificationGroup.createMany(..., skipDuplicates: true). Trong schema chỉ có index @@index([recipientId, type, targetType, targetId]), không có unique composite, nên skipDuplicates không biến insert thành update. Nếu metadata đầy đủ thì hệ thống vẫn chỉ insert row mới, không upsert/increment group cũ.

Entry point
POST /api/v1/posts/:publicId/reply
Redis group key
notification:pending:post:{authorId}:{postPublicId}:{type}
Scheduler
notificationProducer adds repeat job every 40_000ms
Current DB write
createMany(), no group update, no count increment against existing row

Sơ đồ luồng kỹ thuật

Sơ đồ này vẽ đúng luồng hiện tại cho case post replies. Click từng node để xem file, hàm, dữ liệu vào ra, và điểm có thể hỏng.

HTTP request path Redis pending state Async worker path Read / realtime path Express route create reply after transaction hIncrBy / sAdd / hSet lPush key every 40s rPopCount + hGetAll missing authorId -> skipped createMany public trigger Client reply post postRouter /:publicId/reply postController.replyPost req.user.sub -> service postService.reply() create reply in DB then notify Redis Redis hash + set count, metadata actors, queued Pending list notification: pending:keys BullMQ repeat job notificationWorker.runBatchNotification() notification_groups createMany, no upsert GET /notification findByRecipientId Pusher global
HTTP/service Redis state BullMQ/list DB read/write Needs attention Confirmed bug path

Redis contract hiện tại

notificationService.addPostNotificationGroup() đang dùng ba loại key để gom event trước khi worker flush. Contract này chưa được đóng gói atomic, nên khi một key bị thiếu metadata, nó có thể tự hồi phục hoặc kẹt tùy actor. Với code hiện tại, trường hợp kẹt là trường hợp log của bạn đang gặp.

Redis key Type Data mong đợi Vai trò
notification:pending:post:{authorId}:{postPublicId}:{type} Hash count, authorId, postPublicId, notificationType, targetType, lastActorId, updatedAt Bucket chính cho một notification group pending.
{key}:actors Set Các userId đã tạo event trong batch. Lưu unique actors để UI có thể hiển thị "A, B và N người khác".
{key}:queued String TTL 60s "1" Chống push cùng một pending key vào list quá nhiều lần trong 60 giây.
notification:pending:keys List Danh sách các pending hash key. Worker rPopCount(..., 10) để lấy tối đa 10 key mỗi job.
01 · count
hIncrBy(key, "count", 1)

Luôn tăng count. Nếu hash chưa tồn tại, Redis tự tạo hash chỉ có field count.

02 · actors
sAdd(actorsKey, userId)

Trả 1 nếu actor mới, 0 nếu actor đã có trong set.

03 · metadata
if (added === 1) hSet(...)

Đây là điểm yếu. Metadata chỉ được set khi actor mới. Actor cũ không thể repair hash thiếu field.

04 · enqueue
set queued NX EX 60

Nếu marker chưa tồn tại thì lPush pending list để worker xử lý sau.

Đoạn code producer gây kẹt metadata notification.service.ts:76-88
await redisService.hIncrBy(key, "count", 1);
const added = await redisService.sAdd(actorsKey, userId);
if (added === 1) {
  await redisService.hSet(key, {
    authorId,
    postPublicId,
    notificationType,
    targetType,
    lastActorId: userId,
    userId: userId,
    updatedAt: Date.now().toString(),
  });
}

Nếu hash đang ở trạng thái { count: "8" }{key}:actors đã có user đó, sAdd trả 0, nên hSet không chạy. Count tăng, metadata vẫn thiếu.

Worker batch hiện tại

Worker lấy key từ list, đọc hash và actors set, rồi build batch cho repository. Nếu hash thiếu authorId, worker chỉ xóa queued marker và bỏ qua. Vì không xóa hash và actors set, key bị lỗi có thể quay lại lần sau.

01
Pop pending key
rPopCount("notification:pending:keys", 10). Log BEFORE POP hiện đang in sau khi pop, nên tên log gây hiểu nhầm.
02
Read payload
hGetAll(key)sMembers(`${key}:actors`).
03
Guard metadata
Nếu !data.authorId, worker continue. Đây là branch đã làm batch rỗng trong log.
04
Push batch row
Khi metadata đủ, batch có authorId, postPublicId, notificationType, targetType, lastActorId, actorIds, count.
05
Delete successful Redis state
Chỉ khi push batch thành công mới xóa key, {key}:queued, {key}:actors.
06
Persist and trigger
addNotificationPostAllBatch(batch) gọi createMany, sau đó trigger Pusher channel notifications.
Branch worker bỏ qua key thiếu metadata notification.worker.ts:49-51
if (!data?.authorId) {
  await redisService.del(`${key}:queued`);
  continue;
}

Branch này nên được coi là poison payload. Nếu đã quyết định không xử lý được, cần xóa toàn bộ state liên quan hoặc đưa vào dead-letter/debug key. Chỉ xóa queued marker làm key có cơ hội được enqueue lại nhưng không có cơ chế tự sửa metadata.

Đọc log lỗi của bạn

Đây là diễn giải sát log hiện tại. Phần quan trọng nhất là data chỉ có count. Điều đó khớp hoàn toàn với branch skip trong worker.

BEFORE POP: { listLength: 0, listType: 'none' }
[
  'notification:pending:post:cmp6mx9xl000bm4tk006umolu:cmp6myjs4064jkotkllxyleuj:POST'
]
PROCESS: {
  key: 'notification:pending:post:cmp6mx9xl000bm4tk006umolu:cmp6myjs4064jkotkllxyleuj:POST',
  data: { count: '8' },
  actorIds: [
    'cmp6mxa5b001wm4tkngyv80vq',
    'cmp6mxa5o001zm4tkmhcmoqbe'
  ]
}
INFO: Adding post notification group batch to Redis []
INFO: Processed initSyncNotificationBatch: []

Vì sao listLength: 0?

Trong worker, code pop list trước rồi mới log lLen. Vì vậy label BEFORE POP đang sai thời điểm. Log này thực ra là "sau pop". List có thể vừa được pop hết nên length bằng 0, không có nghĩa là worker không lấy được key.

observability misleading log

Vì sao DB không update?

Worker thấy !data.authorId, bỏ qua key, nên notificationBatch vẫn là []. Service gọi createMany([]), Prisma commit một transaction rỗng, DB không có row mới và cũng không update row cũ.

confirmed batch empty

Root cause và potential risks

Mình tách lỗi thành lỗi confirmed từ log và rủi ro thiết kế. Hai lỗi đầu là nguyên nhân trực tiếp cho "Redis count tăng nhưng DB không update". Lỗi DB upsert là nguyên nhân cho việc notification grouping chưa thật sự là grouping bền vững.

Vấn đề Mức Bằng chứng Tác động Hướng xử lý
Redis hash thiếu metadata HIGH Log data: { count: '8' }, không có authorId. Worker không build batch row, DB không ghi. Producer phải hSet metadata mỗi event, không phụ thuộc sAdd.
Worker cleanup thiếu khi payload invalid HIGH Branch !data.authorId chỉ xóa {key}:queued. Hash và actors set stale, actor cũ reply tiếp vẫn không repair được metadata. Xóa key, {key}:actors, {key}:queued; log dead-letter trước khi xóa nếu cần debug.
DB dùng insert batch, không upsert group HIGH createMany(... skipDuplicates: true) nhưng schema chỉ có index, không có unique composite. Không update count của group cũ; có thể tạo nhiều row cho cùng target. Thêm unique hoặc dùng repository upsert/update theo recipientId + type + targetType + targetId.
Redis write không atomic MED hIncrBy, sAdd, hSet, set NX, lPush là nhiều round-trip. Crash giữa chừng có thể tạo hash chỉ có count hoặc queued state lệch. Dùng Lua script hoặc MULTI cho phần count/metadata/actors; giữ enqueue có kiểm soát.
Pusher trigger global channel MED Worker trigger pusher.trigger("notifications", ...). Mọi client subscribe public channel đều nhận tín hiệu chung, count là tổng batch, không theo user. Trigger theo private-user-{recipientId} hoặc channel helper riêng cho từng recipient.
Notification worker phụ thuộc Redis connect gián tiếp MED notification.worker.ts không connect redisService; hiện có thể sống nhờ like.worker.ts connect lúc import. Nếu chạy notification worker riêng, có thể gặp client closed hoặc lỗi timing. Thêm redisReady ngay trong notification worker hoặc tạo helper connect chung.
Log bị lẫn với like sync job LOW Log Starting like sync job... xuất hiện cạnh notification output. Dễ tưởng notification job chạy mỗi 5s. Thực tế like repeat là 5s, notification repeat là 40s. Thêm prefix [like-worker], [notification-worker] cho mọi log.
Notification type chưa rõ nghĩa INFO Reply đang gọi notificationType: POST, targetType: REPLY. FE có thể khó map text nếu type là hành động nhưng đang là domain rộng. Quyết định convention: type là action (REPLY) hay target (POST), rồi dùng nhất quán.

Hướng sửa đề xuất

Có hai lớp sửa. Lớp đầu là vá nhanh để hết mất batch và hết trạng thái kẹt Redis. Lớp hai là sửa đúng semantics grouping ở DB để count thật sự update cùng một notification group.

1. Vá nhanh để hết mất batch

Producer: luôn ghi metadata

Không đặt hSet trong if (added === 1). Count và metadata là hai loại dữ liệu khác nhau: actorIds có thể unique, nhưng metadata phải luôn có và nên luôn được refresh.

Worker: invalid payload phải cleanup hết

Khi payload thiếu authorId, xóa cả hash, queued marker, actors set. Nếu cần forensic, lưu một record nhỏ vào key debug có TTL trước khi xóa.

await redisService.hSet(key, {
  authorId,
  postPublicId,
  notificationType,
  targetType,
  lastActorId: userId,
  userId,
  updatedAt: Date.now().toString(),
});

await redisService.hIncrBy(key, "count", 1);
await redisService.sAdd(actorsKey, userId);
if (!data?.authorId) {
  baseLogger.warn(`[notification-worker] Invalid pending notification payload: ${key} ${JSON.stringify(data)}`);
  await redisService.del(key);
  await redisService.del(`${key}:queued`);
  await redisService.del(`${key}:actors`);
  continue;
}
if (notificationBatch.length === 0) {
  baseLogger.info("[notification-worker] No valid pending notifications after filtering");
  return;
}

await notificationService.addNotificationPostAllBatch(notificationBatch);
await pusher.trigger(/* only after there is a valid batch */);
NOTE
Minimal patch này sẽ giải quyết đúng lỗi bạn đang thấy trong log. Nhưng nó chưa giải quyết triệt để câu "group count trong DB phải update row cũ". Muốn đúng grouping lâu dài, cần đổi repository từ insert-only sang upsert/update.

2. Sửa đúng grouping ở DB

Group identity nên là recipientId + type + targetType + targetId. Với reply vào post, recipientId là chủ post gốc, targetIdpostPublicId gốc, targetTypeREPLY hoặc POST tùy convention UX, và type nên là action mà FE dùng để render text.

Schema option: unique composite cho một group đang active prisma/schema.prisma
model NotificationGroup {
  id          Int              @id @default(autoincrement())
  publicId    String           @unique @default(cuid()) @map("public_id")
  recipientId String           @map("recipient_id")
  type        NotificationType
  targetType  String           @map("target_type")
  targetId    String           @map("target_id")
  actorIds    Json             @map("actor_ids")
  count       Int              @default(1)
  isRead      Boolean          @default(false) @map("is_read")
  lastActorId String           @map("last_actor_id")
  lastEventAt DateTime         @map("last_event_at")

  @@unique([recipientId, type, targetType, targetId], map: "uq_notification_group_target")
  @@index([recipientId, isRead, lastEventAt])
  @@map("notification_groups")
}

Nếu muốn gom theo time window, unique key cần thêm field window bucket, ví dụ groupWindowKey. Với MVP hiện tại, một target một group là cách đơn giản nhất.

Repository option: upsert/update thay vì createMany insert-only notification.repository.ts
async upsertPostNotificationGroup(item: NotificationBatchItem, tx = prisma) {
  const existing = await tx.notificationGroup.findFirst({
    where: {
      recipientId: item.authorId,
      type: item.notificationType,
      targetType: item.targetType,
      targetId: item.postPublicId,
    },
    select: { id: true, actorIds: true, count: true },
  });

  const mergedActorIds = mergeUniqueJsonArray(existing?.actorIds, item.actorIds);

  if (!existing) {
    return tx.notificationGroup.create({ data: {
      recipientId: item.authorId,
      type: item.notificationType,
      targetType: item.targetType,
      targetId: item.postPublicId,
      actorIds: mergedActorIds,
      count: item.count,
      lastActorId: item.lastActorId,
      lastEventAt: new Date(),
      isRead: false,
    }});
  }

  return tx.notificationGroup.update({
    where: { id: existing.id },
    data: {
      actorIds: mergedActorIds,
      count: { increment: item.count },
      lastActorId: item.lastActorId,
      lastEventAt: new Date(),
      isRead: false,
    },
  });
}

Nếu thêm unique composite, có thể dùng upsert theo unique key. Nếu chưa thêm unique, dùng findFirst + update/create trong transaction, nhưng cần chú ý race condition khi nhiều worker cùng xử lý một group.

3. Nên dùng transaction hoặc Lua cho Redis write

Minimal patch đã đủ để repair metadata khi actor cũ bắn lại event. Nhưng để giảm trạng thái nửa vời khi process crash giữa các Redis command, nên gom phần write chính thành Lua script hoặc MULTI. Lua tiện hơn nếu muốn SET queued NX EX rồi chỉ LPUSH khi set thành công.

-- KEYS[1] = hash key
-- KEYS[2] = actors key
-- KEYS[3] = queued key
-- KEYS[4] = pending list
-- ARGV = metadata + userId

redis.call("HSET", KEYS[1],
  "authorId", ARGV[1],
  "postPublicId", ARGV[2],
  "notificationType", ARGV[3],
  "targetType", ARGV[4],
  "lastActorId", ARGV[5],
  "userId", ARGV[5],
  "updatedAt", ARGV[6]
)
redis.call("HINCRBY", KEYS[1], "count", 1)
redis.call("SADD", KEYS[2], ARGV[5])

if redis.call("SET", KEYS[3], "1", "NX", "EX", 60) then
  redis.call("LPUSH", KEYS[4], KEYS[1])
end
const tx = redisService.multi();
tx.hSet(key, metadata);
tx.hIncrBy(key, "count", 1);
tx.sAdd(actorsKey, userId);
const result = await tx.exec();

const queued = await redisService.set(queuedKey, "1", { EX: 60, NX: true });
if (queued === "OK") {
  await redisService.lPush(pendingListKey, key);
}

4. Pusher nên fan-out theo recipient

Worker hiện trigger notifications public/global channel. Với notification cá nhân, nên gom batch theo authorId/recipientId rồi trigger private-user-{recipientId}. Repo đã có pusherChannel.privateUser(userId) và auth endpoint chỉ cho đúng user subscribe channel đó.

const byRecipient = groupBy(notificationBatch, (item) => item.authorId);

await Promise.all([...byRecipient.entries()].map(([recipientId, items]) =>
  pusher.trigger(pusherChannel.privateUser(recipientId), "notification:new", {
    count: items.reduce((sum, item) => sum + item.count, 0),
    targetIds: items.map((item) => item.postPublicId),
  })
));

Test checklist

Sau khi sửa, đây là các case nên chạy thủ công hoặc viết integration test để bắt đúng lỗi hiện tại.

Case Setup Kỳ vọng Redis Kỳ vọng DB/API
1 actor reply 1 lần User B reply post của User A. Hash đủ metadata, count 1, actors có B, pending list có key. Worker tạo hoặc update group count +1 cho A.
1 actor reply nhiều lần trong cùng window User B reply 3 lần cùng post của A. Count 3, actors vẫn chỉ có B. DB count tăng 3 nếu count là số event; actorIds unique chỉ có B.
2 actors reply cùng post User B và C reply post của A. Count bằng số reply, actors có B và C. Một notification group cho A, count đúng, actorIds merge đủ.
Self reply User A reply post của chính A. Không tạo pending key vì userId === authorId. Không có notification.
Poison key thiếu metadata Manual set hash chỉ có count và actors set. Worker xóa hash, queued, actors hoặc đưa vào dead-letter TTL. Không loop count tăng mãi; log warn rõ key bị invalid.
Existing DB group Đã có group cho A + post X, rồi có reply mới. Batch flush một item count N. Update row cũ, không tạo duplicate row.
Worker notification chạy độc lập Chạy chỉ notification worker, không import like worker. Redis client đã connect trước khi gọi rPopCount. Không lỗi client closed.
Realtime per user A và D online, chỉ A là recipient. Batch recipient A. Chỉ private-user-A nhận event, D không nhận.

Open questions cần bạn chốt

Phần này không chặn việc vá bug. Nó giúp quyết định semantics lâu dài để DB và FE không hiểu khác nhau.

Count là event hay unique actor?

Hiện Redis count tăng theo số lần reply, còn actorIds là unique actors. Nếu UI muốn "8 replies", giữ như hiện tại. Nếu muốn "2 people replied", count nên là actor count hoặc có thêm field riêng.

Type nên là POST hay REPLY?

Reply hiện đang dùng notificationType: POSTtargetType: REPLY. Nếu type là action, nên đổi sang REPLY. Nếu type là namespace domain, giữ POST nhưng FE phải đọc thêm targetType.

Gom vĩnh viễn hay theo window?

Unique theo target sẽ gom vĩnh viễn một post thành một group. Nếu muốn "5 phút một group", schema cần thêm window bucket hoặc close/open group state.

Thứ tự sửa khuyến nghị

A
Dọn Redis state bị kẹt
Xóa các key dạng notification:pending:post:*:actors, hash thiếu metadata, queued marker liên quan trong môi trường dev/local để không bị log cũ gây nhiễu.
B
Patch producer và worker cleanup
Luôn hSet metadata; worker cleanup full khi payload invalid; skip DB/Pusher nếu batch rỗng.
C
Thêm Redis connect rõ ràng trong notification worker
Không phụ thuộc vào import order của like.worker.ts.
D
Đổi DB persistence sang upsert/update
Chốt unique key hoặc find/update strategy, migrate/dedupe existing notification groups nếu đã có dữ liệu trùng.
E
Pusher theo private user channel
Sau khi DB ổn, realtime nên gửi đúng recipient để count không leak và không gây refresh thừa ở client khác.