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ũ.
POST /api/v1/posts/:publicId/replynotification:pending:post:{authorId}:{postPublicId}:{type}notificationProducer adds repeat job every 40_000mscreateMany(), no group update, no count increment against existing rowSơ đồ 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.
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. |
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.
sAdd(actorsKey, userId)Trả 1 nếu actor mới, 0 nếu actor đã có trong set.
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.
set queued NX EX 60Nế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" } và {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.
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.hGetAll(key) và sMembers(`${key}:actors`).!data.authorId, worker continue. Đây là branch đã làm batch rỗng trong log.authorId, postPublicId, notificationType, targetType, lastActorId, actorIds, count.key, {key}:queued, {key}:actors.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.
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ũ.
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 */);
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, targetId là postPublicId gốc,
targetType là REPLY 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: POST và targetType: 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ị
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.hSet metadata; worker cleanup full khi payload invalid; skip DB/Pusher nếu batch rỗng.like.worker.ts.