refactor(message): implement atomic message merging and patching to prevent race conditions
Introduce `mergeMessages` and `patchMessages` to the message store to handle updates atomically within Zustand's `set` callback. This replaces manual read-modify-write patterns in services, preventing message loss during concurrent WebSocket updates and synchronization processes. - Add `mergeMessages` for atomic merging of new messages into existing lists - Add `patchMessages` for efficient field updates (e.g., sender info, status) - Update `MessageSendService`, `MessageSyncService`, and `WSMessageHandler` to use these new atomic operations - Remove reliance on external `mergeMessagesById` in service layers to ensure state consistency
This commit is contained in:
@@ -7,7 +7,7 @@ import type { MessageResponse, MessageSegment, ConversationResponse } from '../.
|
|||||||
import { messageService } from '@/services/message';
|
import { messageService } from '@/services/message';
|
||||||
import { messageRepository } from '@/database';
|
import { messageRepository } from '@/database';
|
||||||
import type { IMessageSendService } from '../types';
|
import type { IMessageSendService } from '../types';
|
||||||
import { useMessageStore, mergeMessagesById } from '../store';
|
import { useMessageStore } from '../store';
|
||||||
|
|
||||||
export class MessageSendService implements IMessageSendService {
|
export class MessageSendService implements IMessageSendService {
|
||||||
private getCurrentUserId: () => string | null;
|
private getCurrentUserId: () => string | null;
|
||||||
@@ -36,8 +36,7 @@ export class MessageSendService implements IMessageSendService {
|
|||||||
if (response) {
|
if (response) {
|
||||||
const currentUserId = this.getCurrentUserId();
|
const currentUserId = this.getCurrentUserId();
|
||||||
|
|
||||||
// 添加到本地消息列表
|
// 原子性合并新消息到内存(防止并发 WS 消息丢失)
|
||||||
const existingMessages = store.getMessages(conversationId);
|
|
||||||
const newMessage: MessageResponse = {
|
const newMessage: MessageResponse = {
|
||||||
id: response.id,
|
id: response.id,
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
@@ -48,8 +47,7 @@ export class MessageSendService implements IMessageSendService {
|
|||||||
status: 'normal',
|
status: 'normal',
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedMessages = mergeMessagesById(existingMessages, [newMessage]);
|
store.mergeMessages(conversationId, [newMessage]);
|
||||||
store.setMessages(conversationId, updatedMessages);
|
|
||||||
|
|
||||||
// 更新会话最后消息
|
// 更新会话最后消息
|
||||||
this.updateConversationWithNewMessage(conversationId, newMessage);
|
this.updateConversationWithNewMessage(conversationId, newMessage);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
CONVERSATION_LIST_PAGE_SIZE,
|
CONVERSATION_LIST_PAGE_SIZE,
|
||||||
} from '../sources';
|
} from '../sources';
|
||||||
import type { IMessageSyncService, MessageManagerConversationListDeps, IUserCacheService } from '../types';
|
import type { IMessageSyncService, MessageManagerConversationListDeps, IUserCacheService } from '../types';
|
||||||
import { useMessageStore, normalizeConversationId, mergeMessagesById } from '../store';
|
import { useMessageStore, normalizeConversationId } from '../store';
|
||||||
import { ReadReceiptManager } from './ReadReceiptManager';
|
import { ReadReceiptManager } from './ReadReceiptManager';
|
||||||
|
|
||||||
export class MessageSyncService implements IMessageSyncService {
|
export class MessageSyncService implements IMessageSyncService {
|
||||||
@@ -190,15 +190,10 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
store.setLoadingMessages(conversationId, true);
|
store.setLoadingMessages(conversationId, true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingMessagesAtStart = store.getMessages(conversationId);
|
|
||||||
const hasInMemoryMessages = existingMessagesAtStart.length > 0;
|
|
||||||
let baselineMaxSeq = hasInMemoryMessages
|
|
||||||
? existingMessagesAtStart.reduce((max, m) => Math.max(max, m.seq || 0), 0)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (!afterSeq) {
|
if (!afterSeq) {
|
||||||
// 先从本地数据库加载
|
// 先从本地数据库加载(使用 merge 避免 WS 消息丢失)
|
||||||
if (!hasInMemoryMessages) {
|
const existingMessagesAtStart = store.getMessages(conversationId);
|
||||||
|
if (existingMessagesAtStart.length === 0) {
|
||||||
try {
|
try {
|
||||||
const localMessages = await messageRepository.getByConversation(conversationId, 20);
|
const localMessages = await messageRepository.getByConversation(conversationId, 20);
|
||||||
|
|
||||||
@@ -213,9 +208,8 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
created_at: m.createdAt,
|
created_at: m.createdAt,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
store.setMessages(conversationId, formattedMessages);
|
// 原子性合并本地缓存消息,防止并发 WS 消息丢失
|
||||||
} else {
|
store.mergeMessages(conversationId, formattedMessages);
|
||||||
store.setMessages(conversationId, []);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[MessageSyncService] 读取本地消息失败:', error);
|
console.warn('[MessageSyncService] 读取本地消息失败:', error);
|
||||||
@@ -224,7 +218,7 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
|
|
||||||
// 更新 baseline:合并本地DB后重新计算
|
// 更新 baseline:合并本地DB后重新计算
|
||||||
const currentMessages = store.getMessages(conversationId);
|
const currentMessages = store.getMessages(conversationId);
|
||||||
baselineMaxSeq = currentMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0);
|
const baselineMaxSeq = currentMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0);
|
||||||
|
|
||||||
// 策略:有本地数据时仅增量同步,无数据时拉快照
|
// 策略:有本地数据时仅增量同步,无数据时拉快照
|
||||||
if (baselineMaxSeq > 0) {
|
if (baselineMaxSeq > 0) {
|
||||||
@@ -234,9 +228,7 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
const newMessages = incrementalResp?.messages || [];
|
const newMessages = incrementalResp?.messages || [];
|
||||||
|
|
||||||
if (newMessages.length > 0) {
|
if (newMessages.length > 0) {
|
||||||
const existingMsgs = store.getMessages(conversationId);
|
store.mergeMessages(conversationId, newMessages);
|
||||||
const mergedMessages = mergeMessagesById(existingMsgs, newMessages);
|
|
||||||
store.setMessages(conversationId, mergedMessages);
|
|
||||||
|
|
||||||
messageRepository.saveMessagesBatch(
|
messageRepository.saveMessagesBatch(
|
||||||
MessageMapper.toCachedMessages(newMessages, conversationId)
|
MessageMapper.toCachedMessages(newMessages, conversationId)
|
||||||
@@ -254,7 +246,8 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
const snapshotMessages = snapshotResp?.messages || [];
|
const snapshotMessages = snapshotResp?.messages || [];
|
||||||
|
|
||||||
if (snapshotMessages.length > 0) {
|
if (snapshotMessages.length > 0) {
|
||||||
store.setMessages(conversationId, snapshotMessages);
|
// 快照来自分页接口,后端返回 DESC 顺序;mergeMessages 内部按 seq ASC 排序并合并
|
||||||
|
store.mergeMessages(conversationId, snapshotMessages);
|
||||||
|
|
||||||
messageRepository.saveMessagesBatch(
|
messageRepository.saveMessagesBatch(
|
||||||
MessageMapper.toCachedMessages(snapshotMessages, conversationId)
|
MessageMapper.toCachedMessages(snapshotMessages, conversationId)
|
||||||
@@ -272,10 +265,7 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
|
|
||||||
if (response?.messages && response.messages.length > 0) {
|
if (response?.messages && response.messages.length > 0) {
|
||||||
const newMessages = response.messages;
|
const newMessages = response.messages;
|
||||||
const existingMessages = store.getMessages(conversationId);
|
store.mergeMessages(conversationId, newMessages);
|
||||||
const mergedMessages = mergeMessagesById(existingMessages, newMessages);
|
|
||||||
|
|
||||||
store.setMessages(conversationId, mergedMessages);
|
|
||||||
|
|
||||||
messageRepository.saveMessagesBatch(
|
messageRepository.saveMessagesBatch(
|
||||||
MessageMapper.toCachedMessages(newMessages, conversationId)
|
MessageMapper.toCachedMessages(newMessages, conversationId)
|
||||||
@@ -288,13 +278,22 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
console.error('[MessageSyncService] 获取消息失败:', error);
|
console.error('[MessageSyncService] 获取消息失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
// 异步填充用户信息(不阻塞消息显示)
|
// 异步填充用户信息(不阻塞消息显示)
|
||||||
|
// 使用 merge 而非覆盖,防止异步期间新到的 WS 消息丢失
|
||||||
const currentMessages = store.getMessages(conversationId);
|
const currentMessages = store.getMessages(conversationId);
|
||||||
if (currentMessages.length > 0) {
|
if (currentMessages.length > 0) {
|
||||||
this.userCacheService.enrichMessagesWithSenderInfo(
|
this.userCacheService.enrichMessagesWithSenderInfo(
|
||||||
conversationId,
|
conversationId,
|
||||||
currentMessages,
|
currentMessages,
|
||||||
(convId, enrichedMessages) => {
|
(convId, enrichedMessages) => {
|
||||||
store.setMessages(convId, enrichedMessages);
|
const patches = new Map<string, Partial<MessageResponse>>();
|
||||||
|
for (const m of enrichedMessages) {
|
||||||
|
if (m.sender && m.sender_id) {
|
||||||
|
patches.set(String(m.id), { sender: m.sender });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patches.size > 0) {
|
||||||
|
useMessageStore.getState().patchMessages(convId, patches);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -323,9 +322,7 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
created_at: m.createdAt,
|
created_at: m.createdAt,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const existingMessages = store.getMessages(conversationId);
|
store.mergeMessages(conversationId, formattedMessages);
|
||||||
const mergedMessages = this.mergeOlderMessages(existingMessages, formattedMessages);
|
|
||||||
store.setMessages(conversationId, mergedMessages);
|
|
||||||
|
|
||||||
return formattedMessages;
|
return formattedMessages;
|
||||||
}
|
}
|
||||||
@@ -340,9 +337,7 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
MessageMapper.toCachedMessages(serverMessages, conversationId)
|
MessageMapper.toCachedMessages(serverMessages, conversationId)
|
||||||
);
|
);
|
||||||
|
|
||||||
const existingMessages = store.getMessages(conversationId);
|
store.mergeMessages(conversationId, serverMessages);
|
||||||
const mergedMessages = this.mergeOlderMessages(existingMessages, serverMessages);
|
|
||||||
store.setMessages(conversationId, mergedMessages);
|
|
||||||
|
|
||||||
return serverMessages;
|
return serverMessages;
|
||||||
}
|
}
|
||||||
@@ -612,19 +607,4 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private mergeOlderMessages(existing: MessageResponse[], incoming: MessageResponse[]): MessageResponse[] {
|
|
||||||
if (incoming.length === 0) return existing;
|
|
||||||
|
|
||||||
const incomingAsc = [...incoming].sort((a, b) => a.seq - b.seq);
|
|
||||||
if (existing.length === 0) return incomingAsc;
|
|
||||||
|
|
||||||
const existingMinSeq = existing[0]?.seq ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
const incomingMaxSeq = incomingAsc[incomingAsc.length - 1]?.seq ?? Number.MIN_SAFE_INTEGER;
|
|
||||||
|
|
||||||
if (incomingMaxSeq < existingMinSeq) {
|
|
||||||
return [...incomingAsc, ...existing];
|
|
||||||
}
|
|
||||||
|
|
||||||
return mergeMessagesById(existing, incomingAsc);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@@ -25,7 +25,7 @@ import type {
|
|||||||
HandleNewMessageOptions,
|
HandleNewMessageOptions,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { MAX_FLUSH_ITERATIONS } from '../constants';
|
import { MAX_FLUSH_ITERATIONS } from '../constants';
|
||||||
import { useMessageStore, normalizeConversationId, mergeMessagesById } from '../store';
|
import { useMessageStore, normalizeConversationId } from '../store';
|
||||||
|
|
||||||
export class WSMessageHandler implements IWSMessageHandler {
|
export class WSMessageHandler implements IWSMessageHandler {
|
||||||
private deduplication: IMessageDeduplication;
|
private deduplication: IMessageDeduplication;
|
||||||
@@ -326,13 +326,9 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
if (message.type === 'group_message' && sender_id && sender_id !== currentUserId && sender_id !== '10000') {
|
if (message.type === 'group_message' && sender_id && sender_id !== currentUserId && sender_id !== '10000') {
|
||||||
this.userCacheService.getSenderInfo(sender_id).then(user => {
|
this.userCacheService.getSenderInfo(sender_id).then(user => {
|
||||||
if (user) {
|
if (user) {
|
||||||
const currentMessages = store.getMessages(normalizedConversationId);
|
const patches = new Map<string, Partial<MessageResponse>>();
|
||||||
if (currentMessages) {
|
patches.set(String(id), { sender: user });
|
||||||
const updatedMessages = currentMessages.map(m =>
|
useMessageStore.getState().patchMessages(normalizedConversationId, patches);
|
||||||
m.id === id ? { ...m, sender: user } : m
|
|
||||||
);
|
|
||||||
store.setMessages(normalizedConversationId, updatedMessages);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.error('[WSMessageHandler] 获取发送者信息失败:', { userId: sender_id, error });
|
console.error('[WSMessageHandler] 获取发送者信息失败:', { userId: sender_id, error });
|
||||||
@@ -350,13 +346,11 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
status: 'normal',
|
status: 'normal',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 立即更新内存中的消息列表
|
// 立即更新内存中的消息列表 — 使用原子性 mergeMessages 防止并发写入丢失
|
||||||
const existingMessages = store.getMessages(normalizedConversationId);
|
const messageExists = useMessageStore.getState().getMessages(normalizedConversationId).some(m => String(m.id) === String(id));
|
||||||
const messageExists = existingMessages.some(m => String(m.id) === String(id));
|
|
||||||
|
|
||||||
if (!messageExists) {
|
if (!messageExists) {
|
||||||
const updatedMessages = mergeMessagesById(existingMessages, [newMessage]);
|
useMessageStore.getState().mergeMessages(normalizedConversationId, [newMessage]);
|
||||||
store.setMessages(normalizedConversationId, updatedMessages);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新会话信息
|
// 更新会话信息
|
||||||
@@ -496,13 +490,12 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 补一条系统消息到当前会话消息流
|
// 补一条系统消息到当前会话消息流(原子性 merge)
|
||||||
if (message_id && typeof seq === 'number' && seq > 0) {
|
if (message_id && typeof seq === 'number' && seq > 0) {
|
||||||
const conversationId = this.findConversationIdByGroupId(groupIdStr);
|
const conversationId = this.findConversationIdByGroupId(groupIdStr);
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
const existingMessages = store.getMessages(conversationId);
|
const messageExists = store.getMessages(conversationId).some(m => String(m.id) === String(message_id));
|
||||||
const exists = existingMessages.some(m => String(m.id) === String(message_id));
|
if (!messageExists) {
|
||||||
if (!exists) {
|
|
||||||
const noticeText = this.buildGroupNoticeText(notice_type, data);
|
const noticeText = this.buildGroupNoticeText(notice_type, data);
|
||||||
const systemNoticeMessage: MessageResponse = {
|
const systemNoticeMessage: MessageResponse = {
|
||||||
id: String(message_id),
|
id: String(message_id),
|
||||||
@@ -517,8 +510,7 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
created_at: (() => { const d = new Date(timestamp || Date.now()); return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); })(),
|
created_at: (() => { const d = new Date(timestamp || Date.now()); return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); })(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedMessages = [...existingMessages, systemNoticeMessage].sort((a, b) => a.seq - b.seq);
|
useMessageStore.getState().mergeMessages(conversationId, [systemNoticeMessage]);
|
||||||
store.setMessages(conversationId, updatedMessages);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -557,26 +549,9 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private markMessageAsRecalled(conversationId: string, messageId: string): void {
|
private markMessageAsRecalled(conversationId: string, messageId: string): void {
|
||||||
const store = useMessageStore.getState();
|
const patches = new Map<string, Partial<MessageResponse>>();
|
||||||
const messages = store.getMessages(conversationId);
|
patches.set(String(messageId), { status: 'recalled' as MessageResponse['status'], segments: [] });
|
||||||
if (!messages) return;
|
useMessageStore.getState().patchMessages(conversationId, patches);
|
||||||
|
|
||||||
let changed = false;
|
|
||||||
const updatedMessages: MessageResponse[] = messages.map((m): MessageResponse => {
|
|
||||||
if (String(m.id) !== String(messageId) || m.status === 'recalled') {
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
changed = true;
|
|
||||||
return {
|
|
||||||
...m,
|
|
||||||
status: 'recalled' as MessageResponse['status'],
|
|
||||||
segments: [],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!changed) return;
|
|
||||||
|
|
||||||
store.setMessages(conversationId, updatedMessages);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncConversationLastMessageOnRecall(conversationId: string, messageId: string): void {
|
private syncConversationLastMessageOnRecall(conversationId: string, messageId: string): void {
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export interface MessageActions {
|
|||||||
updateConversation: (conversation: ConversationResponse) => void;
|
updateConversation: (conversation: ConversationResponse) => void;
|
||||||
removeConversation: (conversationId: string) => void;
|
removeConversation: (conversationId: string) => void;
|
||||||
setMessages: (conversationId: string, messages: MessageResponse[]) => void;
|
setMessages: (conversationId: string, messages: MessageResponse[]) => void;
|
||||||
|
mergeMessages: (conversationId: string, incoming: MessageResponse[]) => void;
|
||||||
|
patchMessages: (conversationId: string, patches: Map<string, Partial<MessageResponse>>) => void;
|
||||||
setUnreadCount: (total: number, system: number) => void;
|
setUnreadCount: (total: number, system: number) => void;
|
||||||
setLastSystemMessageAt: (time: string | null) => void;
|
setLastSystemMessageAt: (time: string | null) => void;
|
||||||
setSSEConnected: (connected: boolean) => void;
|
setSSEConnected: (connected: boolean) => void;
|
||||||
@@ -331,6 +333,48 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子性合并消息到 messagesMap
|
||||||
|
* 在 zustand set 回调内完成 read-merge-write,消除并发竞态
|
||||||
|
*/
|
||||||
|
mergeMessages: (conversationId: string, incoming: MessageResponse[]) => {
|
||||||
|
if (incoming.length === 0) return;
|
||||||
|
const normalizedId = normalizeConversationId(conversationId);
|
||||||
|
set(state => {
|
||||||
|
const existing = state.messagesMap.get(normalizedId) || [];
|
||||||
|
const merged = mergeMessagesById(existing, incoming);
|
||||||
|
const newMessagesMap = new Map(state.messagesMap);
|
||||||
|
newMessagesMap.set(normalizedId, merged);
|
||||||
|
return { messagesMap: newMessagesMap };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子性 patch:按消息 ID 更新指定字段(如 sender、status)
|
||||||
|
* 不影响其他消息,不丢失并发写入
|
||||||
|
*/
|
||||||
|
patchMessages: (conversationId: string, patches: Map<string, Partial<MessageResponse>>) => {
|
||||||
|
if (patches.size === 0) return;
|
||||||
|
const normalizedId = normalizeConversationId(conversationId);
|
||||||
|
set(state => {
|
||||||
|
const existing = state.messagesMap.get(normalizedId);
|
||||||
|
if (!existing) return state;
|
||||||
|
let changed = false;
|
||||||
|
const updated = existing.map(m => {
|
||||||
|
const patch = patches.get(String(m.id));
|
||||||
|
if (patch) {
|
||||||
|
changed = true;
|
||||||
|
return { ...m, ...patch };
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
if (!changed) return state;
|
||||||
|
const newMessagesMap = new Map(state.messagesMap);
|
||||||
|
newMessagesMap.set(normalizedId, updated);
|
||||||
|
return { messagesMap: newMessagesMap };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
setUnreadCount: (total: number, system: number) => {
|
setUnreadCount: (total: number, system: number) => {
|
||||||
set({ totalUnreadCount: total, systemUnreadCount: system });
|
set({ totalUnreadCount: total, systemUnreadCount: system });
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user