Files
bare/docs/decisions/036-resend-keeps-ulid.md
mayatnikovandClaude Opus 5 63a7a1ef52 Этап 2: чат 1:1 — устройства, очередь, SSE, шифрование сообщений
Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство,
очередь per-device с фан-аутом без эха отправителю, POST /api/messages
с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди,
ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении,
лимит 30 сообщений в минуту.

Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD,
sync.js как единственный писатель в IndexedDB, ACK строго после записи,
список чатов, экран чата по эталону, разделители дат и «новые»,
pending и failed с повтором, полоса «нет соединения».

ADR-033: у неотправленного есть текст отказа — clock_skew стало видно.
ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает
открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть
до стирания своего присланного, чего «удалить у всех не существует» не допускает.
ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel):
две вкладки отбирали поток друг у друга и оставались без живой доставки.
ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, —
иначе потерянный ответ давал у собеседника два сообщения вместо одного.

Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка,
копия на второе устройство, очередь офлайн-устройству, ACK, подмена from
игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста
в базе и WAL ноль вхождений.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-22 18:18:04 +03:00

7.9 KiB
Raw Permalink Blame History

ADR-036: Повтор отправки сохраняет ULID

Контекст

ADR-017 присваивает ULID в момент попытки отправки, а не набора: сервер принимает сообщение, только если время в идентификаторе расходится с его часами не больше чем на пять минут. docs/storage.md довёл это до правила «при каждой попытке отправки pending получает новый ULID»: старая запись удалялась, новая писалась.

У правила есть цена. POST /api/messages кладёт конверт в очередь и только потом отвечает 202. Ответ теряется: обрыв на мобильной сети, закрытая вкладка, 502 от прокси. Сервер сообщение принял и разослал, клиент считает его неотправленным, оставляет pending и после следующего ready шлёт заново — уже с другим идентификатором. Собеседник видит один и тот же текст дважды, двумя разными записями, и склеить их нечем: id у них разные. Обрыв сразу после отправки — обычное дело на телефоне, а дубль остаётся в истории навсегда.

Второй половины проблемы больше нет. ADR-034 заставил получателя игнорировать входящее с уже известным id целиком: ни записи, ни счётчика. Значит повтор с тем же идентификатором безвреден — сервер положит конверт в очередь (ON CONFLICT DO NOTHING либо новая строка, если прежнюю уже подтвердили), получатель его молча пропустит и подтвердит. Менять id нужно ровно тогда, когда прежний перестал годиться серверу.

У переиспользования есть своя цена. Возраст идентификатора клиент считает по своим часам, сервер — по своим. Клиент отстаёт на две минуты, сообщение пролежало pending три с половиной: клиент видит запас нетронутым, сервер видит пять с половиной и отвечает 400 clock_skew — тогда как прежнее правило дало бы свежий id с расхождением в две минуты и 202. Кнопка «повторить» при этом бесполезна первые минуты: она берёт тот же id и получает тот же отказ, пока возраст не перевалит за запас. Оставить это пользователю нельзя: часы отстают на пару минут у любого устройства, которое давно не сверялось со временем, а сообщение при этом не уходит вовсе.

Решение

  • Повтор отправки идёт с прежним ULID. Запись не удаляется и не заводится заново: меняется только её состояние.
  • Новый ULID берётся, когда время прежнего разошлось с текущим больше чем на четыре минуты. Тогда работает прежний порядок: старая запись удаляется, новая пишется.
  • Запас — минута под серверным окном ±5 минут: за неё успевают шифрование, очередь работ клиента и сама сеть, так что дошедший запрос застаёт окно ещё открытым.
  • Первая отправка ULID генерирует, как и раньше.
  • Клиент сравнивает время идентификатора со своими часами: других у него нет, и первый ULID берётся из них же.
  • clock_skew на переиспользованном идентификаторе отменяет переиспользование: прежний id снимается, попытка идёт второй раз со свежим. Ровно один раз — это та самая ситуация, ради которой id и меняется. Отказ на свежем id означает, что часы врут по-настоящему: сообщение становится failed с текстом про часы (ADR-033), второго круга нет.
  • Сохранённый id оставляет и прежнее ts: время показа идёт за идентификатором, пока 202 не принесёт серверное.
  • Правило записано строкой в docs/storage.md вместо прежнего.

Следствия

  • Потерянный ответ на POST больше не оборачивается дублем: повтор приходит собеседнику с тем же id и молча пропадает у него по ADR-034.
  • Остаточный случай остаётся. Если ответ потерялся, а повтор случился позже окна — вкладку закрыли на час, устройство ушло в офлайн, — идентификатор сменится, и дубль появится. Иначе нельзя: сервер такое сообщение не примет вовсе. Вероятность теперь ничтожна, а раньше дубль давал любой обрыв.
  • Экран не мигает. removed в уведомлении пуст, лента находит сообщение по прежнему id и перерисовывает одну строку вместо всей ленты.
  • Умеренно врущие часы пользователь не разбирает. Расхождение, которое сервер видит только из-за переиспользования, снимает вторая попытка со свежим id; полоса про часы остаётся за настоящим расхождением — тем, что больше пяти минут и от идентификатора не зависит.
  • Уточняется ADR-017: «ULID присваивается в момент попытки отправки» верно для идентификатора старше запаса. Более свежий переживает попытку, и время в нём — время первой из них.
  • Уточняется ADR-033: новая попытка заводит запись без поля error, но не обязательно с новым ULID.
  • Уточняется ADR-035. Правило «неотправленное повторяет только владелец потока» остаётся, но причина мельчает: две вкладки послали бы одно и то же сообщение дважды с одним id, а не два разных.
  • Сортировка исходящего перестаёт зависеть от числа попыток: сообщение остаётся на своём месте в ленте, а не переезжает в конец при каждом повторе.