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:
2026-09-29 20:31:59 +03:00
parent af4850bd6d
commit 9827b5df33
6 changed files with 141 additions and 22 deletions

View File

@@ -87,6 +87,8 @@ describe('multi-tab offline queue replay race', () => {
beforeEach(() => {
sharedQueue.length = 0;
replayedPosts = [];
// По умолчанию Web Locks НЕТ (fallback-путь) — гонка воспроизводится
delete (navigator as unknown as { locks?: unknown }).locks;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if ((init?.method ?? 'GET') === 'POST') {
@@ -99,7 +101,7 @@ describe('multi-tab offline queue replay race', () => {
}));
});
it('две вкладки реплеят одну и ту же запись очереди дважды (дубль сообщения)', async () => {
it('без Web Locks две вкладки реплеят одну запись дважды (исходная гонка)', async () => {
const useOfflineSyncA = await importFreshHook();
const useOfflineSyncB = await importFreshHook();
@@ -127,9 +129,57 @@ describe('multi-tab offline queue replay race', () => {
});
const messagePosts = replayedPosts.filter((p) => p.url === '/api/tasks/1988/messages');
console.log(`[race-test] POST /api/tasks/1988/messages выполнен ${messagePosts.length} раз(а) для 1 записи очереди`);
// БАГ: одна запись очереди уходит дважды (по разу на каждую открытую вкладку).
// После фикса (cross-tab lock / атомарное изъятие) ожидается 1.
console.log(`[race-test] без Web Locks: POST выполнен ${messagePosts.length} раз(а) для 1 записи`);
// Исходная гонка (документация бага): без кросс-вкладочной координации
// одна запись уходит по разу на каждую вкладку.
expect(messagePosts.length).toBe(2);
});
it('с Web Locks (кросс-вкладочный мьютекс) запись реплеится ровно один раз', async () => {
// Эмуляция navigator.locks: сериализация запросов по имени (как у браузера)
let lockChain: Promise<void> = Promise.resolve();
const mockLocks = {
request: async (_name: string, cb: () => Promise<unknown>) => {
const prev = lockChain;
let release!: () => void;
lockChain = new Promise<void>((r) => { release = r; });
await prev;
try {
return await cb();
} finally {
release();
}
},
};
Object.defineProperty(navigator, 'locks', { value: mockLocks, configurable: true });
const useOfflineSyncA = await importFreshHook();
const useOfflineSyncB = await importFreshHook();
sharedQueue.push({
id: 'q1',
method: 'POST',
url: '/api/tasks/1988/messages',
body: JSON.stringify({ message: 'тестовое сообщение' }),
token: null,
orgId: '1',
enqueuedAt: Date.now(),
});
const tabA = renderHook(() => useOfflineSyncA(1), { wrapper: makeWrapper() });
const tabB = renderHook(() => useOfflineSyncB(1), { wrapper: makeWrapper() });
await act(async () => {
await Promise.all([
tabA.result.current.sync(),
tabB.result.current.sync(),
]);
});
const messagePosts = replayedPosts.filter((p) => p.url === '/api/tasks/1988/messages');
console.log(`[race-test] с Web Locks: POST выполнен ${messagePosts.length} раз(а) для 1 записи`);
// ФИКС: первая вкладка под локом реплеит и удаляет запись, вторая видит
// пустую очередь — ровно один POST.
expect(messagePosts.length).toBe(1);
});
});