Ответ потерялся: повтор без дублей и без лжи

синхронизация≈ 16 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Цикл синхронизации из прошлой статьи держится на одном правиле: операция уходит из очереди только тогда, когда по ней пришёл вердикт. Правило хорошее, но у него есть слепое пятно. Сервер может принять пачку, всё применить, всё записать — и не донести ответ: связь оборвалась в тоннеле, телефон уснул, Wi-Fi зала отдал страницу входа вместо ответа. Для телефона это неотличимо от «не дошло», и делать он будет единственное, что может, — повторять. Вопрос статьи в том, что при этом случится у сервера: иногда ничего, иногда вторая строка в дневнике, а иногда — тихое расхождение, которое не лечится уже никогда.

Разделы темы:·

Начнём с хорошей новости. Повтор пачки «Подхода» сам по себе безвреден, и это не случайность, а следствие двух решений главы 6. Первое: имя строке даёт телефон — A-1, «устройство плюс счётчик», а не сервер. Второе: операция несёт «поле равно значению» — { weightKg: 82.5 }, а не «прибавь два с половиной». Наложить такую операцию один раз и наложить её трижды — это одно и то же.

Проверим на рубильнике «ответ потерялся» из статьи про сервер-партнёра, только грубее: телефон шлёт одну и ту же пачку из двух операций три раза подряд, ни разу не услышав ответа. Прогноз: сколько у сервера окажется строк, сколько записей в журнале — и что после этого увидит веб-версия, пришедшая с нулевого курсора?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Строка одна, и она правильная: вес 82.5, упражнение на месте. Три повтора не создали ни одного дубля в дневнике — ровно то, на что мы рассчитывали. А вот журнал сервера вырос втрое: шесть записей на одну-единственную строку, и каждая получила свой номер.

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

Идемпотентна здесь операция, а не запрос. «Повторить запрос безопасно» — утверждение не про HTTP-метод и не про библиотеку повторов, а про то, что именно лежит в теле: «поле равно значению» переживает повтор, «прибавь один» — нет.

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

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Вес вернулся к 80 — правку отменил её же собственный повтор. Наложение «поле равно значению» идемпотентно, но не перестановочно: два раза подряд одно и то же — ничего, а вот старое поверх нового затирает новое. Разбирать, кто из двух записей новее, будет статья «Кто прав, если оба правы»: там у каждого поля появится отметка, и запись из прошлого проиграет. Но заметьте: сегодняшнее лечение закрывает и этот случай тоже — операцию, которую сервер уже видел, он просто не станет применять во второй раз.

Безвредность повтора — свойство не синхронизации вообще, а конкретной формы операции. Стоит форме измениться, и повтор перестаёт быть повтором. Три случая ниже — самые частые, и первый из них объясняет задним числом решение главы 6, которое тогда выглядело причудой.

Случай первый: имя строке выдаёт сервер. Так устроено большинство привычных API — POST /sets возвращает созданную запись с её новым id. Прогноз: что будет у такого сервера после повтора одной и той же записи?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Два подхода вместо одного, и различить их нельзя ничем: поля совпадают до последней цифры. Человек увидит в ленте два одинаковых жима подряд и решит, что это он сам нажал дважды. Хуже того, удалить лишний придётся вручную — программа не знает, какой из них настоящий. Именно ради этого случая идентификатор в главе 6 рождался на телефоне: имя, придуманное до отправки, переживает любое число повторов.

Случаи второй и третий живут уже внутри операции. «Прибавь один подход к счётчику недели» — привычная форма записи для счётчиков. А правка «по версии» — привычная защита от параллельных изменений: операция несёт номер версии строки, и сервер применяет её, только если версия совпала. Прогноз: что даст повтор каждой из них?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Счётчик насчитал два подхода вместо одного — обычная арифметика, никакой ошибки в коде нет. А правка по версии на повторе ответила конфликт: версия строки уже 2, а операция всё ещё ждёт 1. Это ложный конфликт, и он неприятнее дубля. Дубль хотя бы виден; ложный конфликт заставляет клиента разрешать расхождение, которого не существует — правку-то сделал он сам, и она уже применена. Дальше начинается ручное «перечитать строку и попробовать снова», и в оффлайне это превращается в петлю.

форма операциичто делает с ней повтор
«поле равно значению», имя строки с телефонаничего: строка та же, лишняя запись в журнале
вставка, имя строке выдаёт сервервторая строка, неотличимая от первой
«прибавь один»двойной счёт
правка по версии строкиложный конфликт: версия уже сдвинута

Соблазн прочитать таблицу так: «будем писать только операции из первой строки, и память не понадобится». Не выйдет. Счётчики, номера от сервера и проверка версии появляются в любом приложении, которое выросло: уже удаление с восстановлением или связь «подход принадлежит тренировке» выводят за пределы «поле равно значению».

Общее лекарство одно: сервер должен узнавать повтор. Для этого нужен ключ, который у операции уже есть — opId из главы 6. Он придуман телефоном до отправки, уникален в пределах устройства и, главное, не меняется при повторе: повторяется ровно та же операция, а не «такая же». В платёжных API этот приём называют ключом идемпотентности; там его придумывает клиент и присылает заголовком, у нас он просто лежит внутри операции.

Память — это Map из opId в уже выданный ответ. Сравним сервер с памятью и сервер без неё на одной и той же повторённой операции. Прогноз: сколько у каждого окажется строк, сколько записей в журнале и что получит телефон в ответ на повтор?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

У обоих по одной строке — этого мы и ждали после первого раздела. Разница в журнале: без памяти в нём две записи, с памятью одна. Лишний номер не выдан, курсор другим устройствам не сдвинут, чужой телефон не получит фантом. А телефон, приславший повтор, услышал duplicate — третий вердикт из контракта статьи про сервер-партнёра, который до сих пор не выносился ни разу. По таблице вердиктов телефон уносит такую операцию из очереди как принятую: она действительно принята, просто в прошлый раз.

Присмотритесь к памяти внимательнее: ответ в неё кладётся, а повтору отдаётся не он — повтору отдаётся duplicate, всегда и без разбора. Пока каждая операция проходит, разницы нет никакой. Всё «пока» этой фразы разбирается в следующем разделе.

Память нужна серверу, а не телефону. Телефон и так знает, что он повторяет: операция лежит в его очереди с неотвеченным вердиктом. Знать, что она уже применена, может только та сторона, которая её применила.

Теперь самое тонкое место статьи, и стоит оно ровно в одной строке кода: когда сервер записывает opId в память. Напрашивается записать сразу, как только операция пришла, — «видел, обработал, отметил». Разница между «сразу» и «после вердикта» не видна ни на одном успешном сценарии. Она видна только там, где сходятся две вещи: операцию отвергли и ответ об этом потерялся.

В очереди телефона одна операция — подход с опечаткой, 800 повторов, тот самый, который сервер обязан отвергнуть. Первая отправка доходит, ответ — нет. Телефон шлёт её снова. Прогноз: что ответит на повтор сервер, помнящий факт, и что — сервер, помнящий вердикт? И сколько у каждого строк?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Строк нет ни у того, ни у другого — оба отвергли операцию правильно. Но ответы разные, и вот что за ними стоит. Сервер, помнящий факт, сказал duplicate: «эту операцию я уже видел». Телефон по таблице вердиктов уносит её из очереди как принятую — и подход с 800 повторами навсегда остаётся на экране телефона, хотя у сервера его нет и никогда не будет. Отправлять больше нечего, спрашивать не о чем, и никакая следующая синхронизация этого уже не исправит: расхождение стало вечным.

Сервер, помнящий вердикт, ответил тем же отказом и той же причиной — повторов больше сотни. Потерянный ответ перестал быть потерянным: телефон получил его со второй попытки, слово в слово. Отсюда правило: в память кладётся ответ, а не отметка «был», и кладётся он после того, как вынесен, — вместе с применением, одним шагом. Повтор не обрабатывается заново, он перечитывает ответ.

Полезная формулировка: duplicate — это не «я это применил», а «у меня уже есть ответ по этой операции, и он был applied». Тогда сразу видно, почему отказ нельзя пересказывать словом duplicate: у него другой ответ, и телефону нужен именно он.

Память переезжает внутрь сервера-миниатюры: одна строка на саму память и три внутри push. Дальше он такой в подложке всех следующих статей:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обратите внимание на порядок в последних строках: рубильник «ответ потерялся» срабатывает уже после того, как вердикты вынесены и записаны. Иначе миниатюра изображала бы не потерю ответа, а неслучившийся запрос — и вся статья проверяла бы не то.

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

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

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

Повтор всё расставил по местам. Жим вернулся с duplicate и молча ушёл из очереди. Присед вернулся с тем же rejected и причиной — и здесь сработало правило отказа из прошлой статьи: операция выбрасывается, строка пересобирается из оставшихся, а так как не осталось ни одной, строка уходит с экрана. Очередь пуста, дневник сошёлся с сервером, причину можно показать человеку словами.

В части IV похожее поведение давал useOptimistic («useOptimistic: показать результат заранее») — заново это не объясняется, и здесь оно не нужно: отменяется не показ, а операция. Запись не была предварительной, она лежит на диске с момента нажатия; лента подписана на хранилище через useSyncExternalStore («Свой хук: логика без разметки»), и экран перерисуется сам, когда журнал изменится.

Проверка, которую стоит завести привычкой: выключить ответ сервера в середине сценария и прогнать его дважды. Всё, что после этого разъезжается, разъедется и в метро. Протокол, в котором про повтор ничего не сказано, просто не проверялся.

В части VI на место миниатюры встанет настоящий сервер, и память вердиктов станет таблицей: op_id — первичный ключ (там он станет парой с пользователем), рядом вердикт. База тогда сама не даст записать операцию дважды. Отсюда рождается соблазнительная мысль: ничего не проверять заранее, а просто ловить ошибку уникальности и считать её за «уже было». Посмотрим, что из этого выходит на настоящем Postgres, если пачка применяется одной транзакцией.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · psql, локальный PostgreSQL 18.4
CREATE TABLE
INSERT 0 1
BEGIN
psql:пачка.sql:6: ERROR: duplicate key value violates unique constraint "sync_ops_pkey"
ПОДРОБНОСТИ: Key (op_id)=(A:1) already exists.
psql:пачка.sql:7: ERROR: current transaction is aborted, commands ignored until end of transaction block
ROLLBACK
в журнале
-----------
A:1
(1 строка)
BEGIN
INSERT 0 0
INSERT 0 1
COMMIT
в журнале
-----------
A:1,A:2
(1 строка)

Дубль не просто вернул ошибку — он оборвал транзакцию целиком: «current transaction is aborted, commands ignored until end of transaction block». Вторая операция, с которой всё было в порядке, до базы не дошла вовсе, и после отката в журнале осталась одна A:1. Это ядовитая операция из первой статьи главы, только роль яда играет обычный повтор — и на телефоне с потерянным ответом он случается каждый день.

Вторая пачка — та же самая, но каждая вставка помечена on conflict (op_id) do nothing. Повтор тихо вставил ноль строк, соседняя операция прошла, и в журнале стало A:1,A:2. Ноль возвращённых строк — это и есть сигнал «операция уже была»: по нему сервер читает сохранённый вердикт и отдаёт его повтору вместо нового разбора.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Приём не выдуманный: в боевом коде автора вставка «создать или получить» сделана ровно так — частичный уникальный индекс плюс onConflictDoNothing() с returning(), а не проверка «есть ли уже» перед вставкой. Разница именно в том, что проверка и вставка разъезжаются во времени, а индекс — нет.

Один вопрос остаётся открытым честно: память вердиктов растёт вечно, а бесконечных таблиц не бывает. Сколько хранить opId, что отвечать на повтор, пришедший позже срока, и как связать это с горизонтом курсора — это уже работа сервера, и разбирается она в части VI.

Уникальный индекс по op_id стоит завести даже там, где память вердиктов уже написана в коде. Код обходят миграции, ручные правки и второй экземпляр сервера, поднятый рядом; индекс не обходит никто.

  • Потерянный ответ — обычное состояние мобильной сети, и единственное, что может сделать телефон, — повторить пачку. Поэтому вопрос «что будет при повторе» обязан иметь ответ в самом протоколе.
  • Повтор безвреден сам по себе ровно тогда, когда имя строке дал телефон, а операция несёт «поле равно значению». Но и он не бесплатен: в прогоне три отправки одной пачки дали одну строку и шесть записей в журнале сервера, а значит — шесть номеров и лишнюю работу другим устройствам.
  • Три формы операции повтор ломает: имя строки от сервера даёт вторую строку, «прибавь один» — двойной счёт, правка по версии — ложный конфликт. Поэтому сервер помнит opId — тот самый идентификатор с телефона, который при повторе не меняется.
  • Помнить надо вердикт, а не факт. Если opId записан до проверки, потерянный ответ об отказе превращается на повторе в duplicate; телефон считает операцию принятой, и отвергнутая запись остаётся на его экране навсегда — в прогоне при нуле строк у сервера.
  • Отказ, доехавший со второй попытки, отменяет не показ, а операцию: она выбрасывается из журнала, строка пересобирается из оставшихся, а если их нет — уходит с экрана. useOptimistic из части IV здесь ни при чём: запись не предварительная, она с самого начала лежит на диске.
  • На настоящем сервере память вердиктов — это таблица с уникальным op_id. Ловить ошибку уникальности внутри транзакции нельзя: в записанном сеансе psql дубль оборвал всю транзакцию и откатил нормальную соседнюю операцию. Правильный приём — on conflict (op_id) do nothing и сохранённый вердикт по нулю возвращённых строк.

1/4Телефон трижды отправил одну и ту же пачку, не услышав ответа. Дневник на всех устройствах сошёлся, дублей нет. Что всё-таки испортилось?

2/4Какая из операций при повторе даёт в дневнике вторую строку?

3/4Сервер записывает opId в память сразу, как только увидел операцию, — до проверки. Операцию он отверг, а ответ до телефона не дошёл. Что будет после повтора?

4/4Почему на настоящем сервере нельзя применять пачку одной транзакцией и ловить ошибку уникальности как «уже было»?

Сервер помнит имена, а не ответы

Память здесь есть, и выглядит она разумно: увидел операцию — записал имя. В очереди телефона лежит подход с опечаткой, который сервер обязан отвергнуть; первая отправка доходит, ответ теряется, телефон повторяет. Посмотрите, что телефон в итоге считает принятым, и почините pushRemembering так, чтобы отказ доехал со второй попытки.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

В память кладётся ответ, и кладётся он после того, как вынесен, — а повтор этот ответ перечитывает:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод становится повтор ответил: A:1 rejected и телефон считает принятыми: [] | у сервера строк: 0: телефон больше не считает принятой операцию, которой у сервера нет, а по вердикту rejected выбрасывает её из журнала и пересобирает строку.

Сломанный вариант коварен тем, что на всех успешных сценариях ведёт себя правильно: пока операции проходят, «помню имя» и «помню ответ» неразличимы. Расходятся они ровно один раз — когда отказ совпал с потерянным ответом, — и расходятся навсегда: повторять больше нечего, спрашивать не о чем, а на экране остаётся запись, которой нет ни у кого другого.

Операция, которая считает повторы

Счётчик подходов за неделю обновляется операцией «прибавь один». Пачка ушла, ответ не доехал, телефон отправил ту же операцию ещё раз. Почините applyOp так, чтобы повтор перестал быть виден в счётчике, — и ответьте, какой из двух возможных способов вы выбрали и почему.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Первый способ — изменить форму операции: пусть она несёт итог, а не прибавку. Тогда повтор кладёт то же самое число:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод становится после двух отправок: 1.

Второй способ — оставить «прибавь один», но дать серверу память вердиктов по opId: повтор тогда не применится вовсе. Оба работают, но первый дешевле и надёжнее — он не зависит от того, сохранилась ли память, не требует сроков хранения и переживает даже полную потерю памяти сервера.

У первого способа есть цена, и её надо знать: «стало столько» вычисляется на телефоне, а значит, два устройства, прибавившие по подходу без сети, запишут одно и то же число, и одна прибавка пропадёт. Это уже не про повтор, а про конфликт — и разбирается он в статье «Кто прав, если оба правы», где у счётчика появится третья форма: хранить вклад каждого устройства отдельно.

Ключ, взятый от строки

Память вердиктов здесь есть, но ключом в ней служит имя строки. Человек записал подход и тут же поправил вес — это две разные операции в одной строке. Посмотрите, что доехало до сервера, и почините pushOnce.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Ключ — это имя операции, а не строки:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод становится вердикты: A:1 applied, A:2 applied и строка: {"id":"A-1","exercise":"жим","weightKg":82.5,"reps":5}.

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