Этап 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
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# 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`, а не два разных.
|
||||
- Сортировка исходящего перестаёт зависеть от числа попыток: сообщение остаётся на своём месте в ленте, а не переезжает в конец при каждом повторе.
|
||||
Reference in New Issue
Block a user