fix(chat): cross-tab lock реплея офлайн-очереди + идемпотентность мессенджера (conv_messages)
Клиентская часть гонки (вариант B): Web Locks API (navigator.locks) — кросс-вкладочный мьютекс iistwin-offline-sync-replay на replay-проход в useOfflineSync: первая вкладка под локом реплеит и удаляет записи, остальные видят пустую очередь. Fallback для сред без Web Locks — старое поведение (дубли гасит серверная идемпотентность). Race-тест: без locks → 2 POST, с locks → 1 POST для одной записи очереди. Мессенджер (conv_messages) — та же защита, что у task_messages: - conversation_messages.client_message_id + partial unique (миграция 0087); - POST /api/messenger/conversations/:id/messages: повтор с тем же clientMessageId → существующее сообщение без insert и без SSE/уведомлений, гонка insert'ов ловится по 23505; - useChatController шлёт crypto.randomUUID(), attachment-реплей мессенджера тоже использует id записи очереди.
This commit is contained in:
@@ -964,7 +964,7 @@ export function useChatController() {
|
||||
};
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: async (payload: { message: string; replyToId?: number; mentionedUserIds?: number[]; attachments?: ChatAttachment[] }) => {
|
||||
mutationFn: async (payload: { message: string; replyToId?: number; mentionedUserIds?: number[]; attachments?: ChatAttachment[]; clientMessageId?: string }) => {
|
||||
const res = await apiRequest('POST', `/api/messenger/conversations/${activeConvId}/messages`, payload);
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error);
|
||||
@@ -1180,6 +1180,9 @@ export function useChatController() {
|
||||
replyToId: replyTo?.id,
|
||||
mentionedUserIds: mentionedUserIds.length > 0 ? mentionedUserIds : undefined,
|
||||
attachments: undefined,
|
||||
// Ключ идемпотентности: реплеи офлайн-очереди (в т.ч. из разных вкладок)
|
||||
// идут с одним ключом → серверный дедуп по client_message_id (миграция 0087)
|
||||
clientMessageId: crypto.randomUUID(),
|
||||
});
|
||||
setMentionedUserIds([]);
|
||||
setMentionSearch(null);
|
||||
|
||||
@@ -86,6 +86,17 @@ function updateAppBadge(n: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// Кросс-вкладочный мьютекс на реплей офлайн-очереди (Web Locks API). Без него N
|
||||
// открытых вкладок реплеят одну и ту же IndexedDB-очередь НЕЗАВИСИМО (модульный
|
||||
// _syncLock свой у каждой вкладки) → дубли сообщений/мутаций на сервере.
|
||||
// Fallback для сред без Web Locks — старое поведение (дубли гасит серверная
|
||||
// идемпотентность по clientMessageId, миграции 0086/0087).
|
||||
async function withCrossTabSyncLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const locks = (navigator as unknown as { locks?: { request: (name: string, cb: () => Promise<T>) => Promise<T> } }).locks;
|
||||
if (!locks?.request) return fn();
|
||||
return locks.request('iistwin-offline-sync-replay', fn);
|
||||
}
|
||||
|
||||
async function replayRequest(item: OfflineQueueItem): Promise<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -257,10 +268,8 @@ async function trySendAttachmentMessage(
|
||||
};
|
||||
|
||||
// Идемпотентность: id записи очереди стабилен между реплеями (в т.ч. из разных
|
||||
// вкладок) — сервер дедупит повторы по client_message_id (миграция 0086).
|
||||
if (item.type === 'task') {
|
||||
payload.clientMessageId = (item as { clientMessageId?: string }).clientMessageId ?? item.id;
|
||||
}
|
||||
// вкладок) — сервер дедупит повторы по client_message_id (миграции 0086/0087).
|
||||
payload.clientMessageId = (item as { clientMessageId?: string }).clientMessageId ?? item.id;
|
||||
|
||||
if (item.type === 'messenger' && (item.replyToId || item.replyToMessageId)) {
|
||||
payload.replyToId = item.replyToId || item.replyToMessageId;
|
||||
@@ -355,7 +364,7 @@ export function useOfflineSync(userId?: number): UseOfflineSyncReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
_syncLock = (async () => {
|
||||
_syncLock = (async () => withCrossTabSyncLock(async () => {
|
||||
setStatus('syncing');
|
||||
|
||||
try {
|
||||
@@ -564,7 +573,7 @@ export function useOfflineSync(userId?: number): UseOfflineSyncReturn {
|
||||
} finally {
|
||||
_syncLock = null;
|
||||
}
|
||||
})();
|
||||
}))();
|
||||
await _syncLock;
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user