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

Переподключение и восстановление истории

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

Открыть новый WebSocket после разрыва недостаточно: пока связи не было, в комнате могли появиться сообщения. Добавим восстановление «Линии» по эпохе и курсору. Браузер сохранит последний показанный номер, а сервер вернёт доступную дельту либо потребует заменить историю ограниченным снимком. Одновременно клиент повторит неопределённые команды с прежними идентификаторами.

Состояние stages/11 включает reconnect только в памяти текущей вкладки. Перезагрузка страницы, новая учебная сессия и остановка процесса имеют другие границы. Пример not_run; таймеры, разрывы и ответы не выполнялись при подготовке. Рассмотрим ожидаемую последовательность и причины выбора каждого перехода, чтобы автоматический повтор не скрывал потерю данных.

Пауза между попытками

Когда канал неожиданно закрывается, браузер ждёт перед новой попыткой. Задержка растёт как степень двойки, ограничивается десятью секундами и получает небольшой случайный добавочный интервал. Такая схема уменьшает поток одновременных подключений после общей неисправности. Случайность здесь не защищает данные и не влияет на порядок сообщений; она лишь распределяет попытки клиентов во времени.

Коды нормального закрытия 1000 и нарушения политики 1008 не вызывают автоматическое открытие. При нормальном завершении пользователь или программа намеренно остановили старый канал. При истёкшей сессии повтор handshake с той же cookie не решает причину отказа. Интерфейс должен дать возможность создать новую учебную сессию, а не бесконечно повторять запрос, который сервер обязан отклонять.

Фрагмент обработчика close из public/client.js использует token поколения сессии и счётчик попыток. Переменные объявлены в полном файле снимка.

if (stage >= 11 && ![1000, 1008].includes(event.code)) {
  const delay = Math.min(1000 * 2 ** attempt++, 10000) + Math.random() * 500;
  retryTimer = setTimeout(() => openSocket(token), delay);
}

Счётчик сбрасывается после успешного open. Это означает, что очередной короткий разрыв снова начинает небольшую паузу. Для production может быть полезнее сбрасывать его после устойчивого периода связи, чтобы быстро открывающийся и падающий сервер не создавал постоянный частый поток. В учебном клиенте сохраняем понятный механизм и явно называем это ограничение стратегии, вместо обещания универсально правильных задержек.

Курсор относится к видимой истории

Команда resume содержит epoch, after и roomId. Значение after равно последнему номеру, который уже представлен в сохранённом списке комнаты. Клиент не выводит этот курсор из текущего времени и не увеличивает его после собственного send. Только серверное событие или ответ истории подтверждают номер принятого состояния. Набранная фраза и серверный журнал по-прежнему имеют разный жизненный цикл.

На сервере snapshot() решает, возможно ли продолжение. Полный store.js этого снимка показывает условие сброса: другая эпоха, курсор впереди сервера или выход за начало доступного окна требуют полного снимка.

import { randomUUID } from 'node:crypto';
import { roomIds } from './contract.js';

export const epoch = randomUUID();
const rooms = new Map(roomIds.map(id => [id, { seq: 0, messages: [] }]));
const accepted = new Map();

export function findAccepted(actorId, command) {
  return accepted.get(`${actorId}|${command.roomId}|${command.clientMessageId}`);
}

export function appendMessage(actor, command, suppressDuplicates) {
  const room = rooms.get(command.roomId);
  const message = {
    version: 1,
    type: 'message',
    epoch,
    roomId: command.roomId,
    seq: ++room.seq,
    messageId: randomUUID(),
    clientMessageId: command.clientMessageId,
    actorId: actor.id,
    text: command.text,
    createdAt: new Date().toISOString()
  };
  room.messages.push(message);
  if (room.messages.length > 200) room.messages.shift();
  if (suppressDuplicates) {
    const key = `${actor.id}|${command.roomId}|${command.clientMessageId}`;
    accepted.set(key, message);
    if (accepted.size > 2000) accepted.delete(accepted.keys().next().value);
  }
  return message;
}

export function snapshot(roomId, cursor) {
  const room = rooms.get(roomId);
  const first = room.messages[0]?.seq || 1;
  const reset = !cursor || cursor.epoch !== epoch ||
    cursor.after > room.seq || cursor.after < first - 1;
  return {
    version: 1,
    type: 'history',
    roomId,
    epoch,
    reset,
    messages: reset ? room.messages :
      room.messages.filter(message => message.seq > cursor.after),
    through: room.seq
  };
}

Допустим, клиент видел номер 40, а журнал хранит 31–45. Сервер может вернуть номера 41–45 и reset: false. Если журнал уже хранит 81–100, продолжить от 40 невозможно: удалённые записи не восстановить. Сервер возвращает reset: true и доступные 81–100. Это не полная история обращения, а явная замена текущего окна. Пользовательский интерфейс большого сервиса должен сообщить границу доступных данных.

Если after больше текущего счётчика, нельзя просто вернуть пустой массив и признать клиента актуальным. Такой курсор может относиться к старому процессу, ошибке клиента или повреждённому локальному состоянию. В «Линии» он вызывает сброс. Аналогично новая эпоха требует замены даже при совпадающем номере: одинаковые числа не означают одинаковые сообщения после перезапуска.

Повтор отправки после восстановления

После истории сервер отправляет joined, а браузер повторяет pending-команды активной комнаты. Их clientMessageId не меняется. Если процесс ранее принял команду и сохранил результат в окне дедупликации, вернётся прежний ack. Если команда не дошла, появится новая запись. Именно сочетание журнала и окна принятых команд делает этот повтор полезным; один автоматический reconnect не устранял бы дубли.

Ожидаемый сценарий: Алиса видела номер 4, отправила фразу с ID X и потеряла соединение. Сервер успел принять её как номер 5. После открытия клиент просит продолжение от 4 и получает запись 5. Затем повторяет X из pending и получает duplicate: true. Видимая история содержит одну строку, а pending освобождается. Каждый шаг имеет свой источник истины: дельта подтверждает историю, ack завершает локальное действие.

resume после 4 → history с записью 5
повтор команды X → ack duplicate=true, seq=5
pending X удалён; запись 5 показана один раз

Если сервер перезапустился, старая сессия исчезла вместе с журналом. Handshake будет отклонён, и браузер не получит прикладной history. Пользователь должен создать новую учебную сессию. Перезапуск — отдельная граница, которую курс не маскирует автоматическим восстановлением из несуществующей базы. Для действующего чата нужны долговечные сессии или проверяемый вход, журнал и результаты команд, согласованные одним контрактом.

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

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

Отключение сети и перезапуск вкладки приводят к разным исходным состояниям. В первом случае DOM, cursor и pending остаются в памяти, поэтому дельта может продолжить существующий разговор. Во втором их уже нет, даже если браузер сохранил cookie, и требуется полный снимок комнаты. Сессия отвечает за доступ, а локальная история отвечает за возможность продолжения. Не стоит сохранять только after без соответствующих сообщений: после новой загрузки такой курсор заставит сервер выдать одни поздние события и скрыть предыдущую переписку. Если проекту действительно нужно переживать перезагрузку с дельтой, хранение локального представления и курсора должно быть согласовано, версионировано и привязано к тому же проверенному владельцу.

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