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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user