Перейти к содержанию

Порядок сообщений и повторная команда

Оглавление серии

Журнал задаёт порядок принятых сообщений, но повтор команды всё ещё увеличивает счётчик и создаёт новую запись. Рассмотрим случай разрыва после принятия: браузер не получил ack и повторяет отправку с тем же идентификатором. Добавим окно принятых команд. Сервер найдёт прежний результат и повторно подтвердит его, сохранив количество записей комнаты.

Десятый снимок stages/10 сочетает две независимые задачи: сервер подавляет повтор логического действия, а браузер не добавляет повторно уже показанное событие. Это не обещание exactly once для распределённой системы. Обе карты находятся в памяти одного процесса и имеют ограниченные окна. Статус примера not_run; описанные номера, подтверждения и строки — ожидаемые результаты разбора.

Ключ повторной команды

Ключ состоит из идентификатора участника, комнаты и clientMessageId. Идентификатор команды не обязан быть уникальным среди всех людей мира: Алиса и Борис могут прислать одинаковую строку, и это разные действия. Комната также входит в область действия. Такое правило нужно документировать до реализации, иначе одинаковые ID в разных обращениях могут случайно склеить несвязанные сообщения.

Вместо хранения булевого признака «уже видели» сохраняем сам результат — созданное сообщение. Тогда повторный ack возвращает исходный messageId и номер. Это полезно браузеру и диагностики: пользователь получает подтверждение именно прежнего действия. Если вернуть новый идентификатор без добавления записи, протокол стал бы противоречивым и нельзя было бы сопоставить подтверждение с историей комнаты.

Следующий фрагмент socket.js выполняется после проверки команды, права и подписки, но до добавления сообщения. findAccepted() импортирован из store.js, а client.actor задан серверной сессией.

const old = findAccepted(client.actor.id, command);
if (old) {
  if (old.text !== command.text) throw new Error('id_conflict');
  send(client, {
    type: 'ack',
    clientMessageId: command.clientMessageId,
    messageId: old.messageId,
    seq: old.seq,
    duplicate: true
  });
  return;
}

Проверка текста предотвращает использование одного ID для разных действий. Если человек действительно хочет отправить новую фразу, клиент создаёт новый идентификатор. При изменённом тексте и старом ID сервер возвращает id_conflict; он не обновляет старое сообщение и не создаёт новое. Команда отправки не является командой редактирования, поэтому нельзя приписывать ей скрытую возможность менять историю.

Номер защищает отображение

У браузера есть курсор последнего показанного номера комнаты и эпоха. Если приходит событие той же эпохи с номером меньше либо равным курсору, строка повторно не добавляется. Это защищает интерфейс от повторной выдачи истории или повторно доставленного события. Подавление на клиенте не заменяет серверное: без сервера журнал мог бы содержать два разных номера одного действия, и браузер законно показал бы обе записи.

Фрагмент из showMessage() в public/client.js использует ранее сохранённый cursor. Сначала сравнивается эпоха, затем номер; события нового процесса нельзя отбрасывать только потому, что их счётчик снова стал маленьким.

if (stage >= 10 && cursor.epoch === message.epoch && message.seq <= cursor.after) {
  return;
}

В одном открытом WebSocket сообщения идут в порядке транспорта, однако это не означает единого порядка между разными соединениями или эпохами. Номер комнаты назначает сервер в одной точке добавления. При переходе к нескольким процессам эту точку потребуется перенести в согласованное хранилище. Простые локальные счётчики двух процессов создадут одинаковые номера и разрушат правило курсора.

Рассмотрим ожидаемую отправку команды с demo-command-1 дважды. Первая создаёт сообщение номер 1 и ack с duplicate: false. Вторая возвращает ack с тем же messageId, тем же номером и duplicate: true. В журнале остаётся одна запись. Если изменить текст второй команды, ожидается id_conflict, а номер комнаты не увеличивается. Это разбор договора, не фактический вывод запуска.

Первая команда: запись 1, duplicate=false
Повтор той же команды: запись 1, duplicate=true
Другой текст с тем же ID: id_conflict

Дедупликация имеет срок применимости

Карта хранит последние две тысячи принятых команд по всему процессу. При превышении границы удаляется самая ранняя запись. Если клиент повторит старый ID после удаления, сервер уже не узнает его и создаст новое сообщение. Следовательно, гарантия относится к окну сохранённых результатов, а не к вечной уникальности. UI и политика повторов должны учитывать эту границу, иначе поздняя отправка будет выглядеть неожиданным дублем.

Окно дедупликации шире журнала комнаты: даже удалённая из двухсот последних сообщений запись некоторое время может оставаться результатом принятой команды. Повторный ack в таком случае законен, хотя история уже не содержит строку. Это две разные политики: журнал отвечает за чтение переписки, карта — за повтор изменения. В действующем сервисе их сроки следует согласовать с ожиданиями пользователя и долговечным хранением.

Перезапуск процесса удаляет обе карты. Новая эпоха помогает браузеру заменить представление, но не сохраняет старые результаты команд. Поэтому повтор после рестарта может создать новую запись. Для долговечной идемпотентности нужны уникальный ключ команды и запись результата в одной транзакции с сообщением. Хранение отметки отдельно от изменения оставляет окно, в котором сбой приводит либо к потере, либо к повторному эффекту.

Сейчас обработчик синхронный: поиск результата и добавление выполняются без await, поэтому другая команда не вклинивается между ними в этом процессе. Если заменить appendMessage() асинхронной записью, две одинаковые команды могут одновременно не найти результат. Проверка карты до await не устраняет гонку. В базе это решается ограничением уникальности и атомарной операцией, а не надеждой на порядок callbacks.

Смена комнаты на клиенте запрашивает полный снимок и удаляет курсор выбранного обращения. Поэтому повторное отображение истории заново является намеренной заменой представления, а не ошибкой дедупликации. Курсор полезен только относительно сохранённого списка. Нельзя оставить высокий курсор, очистить DOM и затем запросить дельту: интерфейс получит только новые события и потеряет видимую предыдущую переписку.

Важно также различать повтор команды и повтор подтверждения. Ack может прийти дважды для одного ID, и удаление отсутствующего ключа из pending остаётся безвредным. Но если каждый ack увеличивал бы счётчик отправленных фраз, такой интерфейс показывал бы лишнюю успешную отправку. Аналогично повтор события с прежним seq не должен объявляться доступной live-областью как новая реплика. Идемпотентность требуется не только операции хранения, но и применению результатов на клиенте. Проверенный идентификатор помогает сделать обновление состояния присваиванием или удалением конкретной записи, вместо безусловного прибавления очередного счётчика. Так восстановление сохраняет ясный смысл даже при повторной доставке уже принятого результата.

Транспортные свойства описаны в RFC 6455, но окно команд и последовательность комнаты задаёт приложение. После десятого состояния неопределённый ack можно безопасно повторить в живом процессе, пока результат сохранён. Следующим шагом добавим автоматическое переподключение, которое воспользуется этой границей и курсором истории.