Цикл синхронизации: отправить, забрать, не задвоить

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

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

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

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

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

шагчто делает телефон
отправитьвся исходящая очередь уходит одной пачкой в sync.push
разобрать ответиз очереди уходит только то, на что пришёл вердикт по этой самой операции
забрать чужоеsync.pull страницами, пока hasMore не погаснет
сохранить курсорномер последней применённой операции — на диск, рядом с журналом

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

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

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

Дневник доехал: веб-версия видит подход с исправленным весом, ничего не потеряно и не задвоено. А в журнале телефона после первой синхронизации четыре операции вместо двух. Это эхо: свои же операции вернулись с курсора, и journal.change записал их как новые — с новыми именами, новым временем и местом в исходящей очереди.

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

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

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

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

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

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

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

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

На диске с конца главы 6 лежат две таблицы — строки и журнал («expo-sqlite: настоящая база в телефоне»). Теперь видно, зачем: чужие операции ложатся в строки мимо журнала, и свёртка одного журнала после перезапуска их бы не вернула.

Курсор — это обещание: «всё, что до него, я уже применил». Обещание надо выполнять в правильном порядке, а порядок виден только тогда, когда что-то ломается посередине.

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

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
Курсор, сохранённый до применения, забрал у телефона второй подход навсегда: страница считается пройденной, а операция не применилась, и pull её больше не отдаст — курсор только растёт. Никакой ошибки при этом никто не увидит: приложение откроется, покажет дневник, и в нём просто не будет одного подхода.

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

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

Пришедшая операция несёт только изменённые поля — ровно как своя. Записать её тянет одной строкой: store.write({ id: entry.rowId, ...entry.fields }). Пока устройство одно, разницы не видно, и ошибка живёт в коде месяцами.

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

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

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

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

Вердикт rejected — нормальный ответ, а не сбой: так решено в прошлой статье. Для телефона он значит две вещи. Первая очевидна: операцию больше не отправлять. Вторая — нет: на экране осталась строка, собранная в том числе из этой операции, а сервер только что сказал, что такой строки у него не будет никогда.

Значит, строку надо пересобрать без отвергнутой операции — свёрткой тех, что остались. А если не осталось ни одной, строку надо убрать совсем: у хранилища главы 6 появляется единственный новый метод, remove. Раньше он был не нужен — записи только добавлялись.

В очереди три операции: хороший подход, подход с опечаткой (800 повторов подряд человек не делает) и правка веса в нём же. Прогноз: что останется на экране, если отвергнутую операцию просто не отправлять больше, и что — если пересобрать строку?

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

Тот, кто ограничился очередью, оставил на экране подход с 800 повторами — ровно тот, которого на сервере нет и не будет. Дальше он живёт в дневнике как настоящий: попадёт в статистику, уедет на второе устройство — нет, не уедет, и в этом вся беда: человек видит запись, которой нет ни у кого, кроме него, и никогда об этом не узнает.

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

В части IV похожее делал useOptimistic («useOptimistic: показать результат заранее»): показать заранее, откатить при ошибке. Здесь откатывать нечего — запись не предварительная, она настоящая и давно лежит на диске. Отменяется не показ, а операция: её выбрасывают из журнала, и строка пересобирается из того, что осталось.

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

Три состояния экрана из части IV — загрузка, ошибка, данные («loading.tsx, error.tsx и not-found.tsx») — здесь показывать нечего: подход сохранён в момент нажатия, читать его неоткуда, ждать некого. Но человеку всё-таки важно знать, где его дневник: только в этом телефоне или уже везде. Это и показывает индикатор — состояние дневника, а не состояние запроса.

Состояний четыре: «сохранено на телефоне», «ждут отправки: N», «сети нет» и «отвергнуто: 1» с причиной человеческими словами. Лента подписана на хранилище через useSyncExternalStore («Свой хук: логика без разметки» части IV) — заново это не объясняется, — а результат последней синхронизации держится обычным состоянием компонента: его меняет нажатие, а не хранилище. Прогноз: что напечатает индикатор на каждом из пяти шагов — запись, попытка без сети, попытка с сетью, подход с опечаткой и ещё одна отправка?

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

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

Кнопка «синхронизировать» — для примера. В приложении цикл запускают по поводам (запись, возвращение из фона, появление сети), а сетевую ошибку повторяют с растущей отсрочкой, не трогая очередь:

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

Наличие сети — повод попробовать, а не разрешение: Wi-Fi в зале с окном входа отвечает на любой запрос страницей провайдера, и «сеть есть» здесь означает только «не сиди без дела». Откуда берутся поводы — возвращение приложения из фона и сообщения NetInfo — в статье «Свернули и вернулись: процесс вместо вкладки».

Новое устройство отличается от старого только курсором: он нулевой. Значит, первая синхронизация — это обычный pull с нуля, и показывать его надо не спиннером, а числом перенесённого: у дневника за год операций тысячи, и страница здесь не одна.

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

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

  • Цикл синхронизации — четыре шага: очередь уходит одной пачкой, из очереди убирается только то, на что пришёл вердикт, чужое забирается страницами с курсора, курсор сохраняется после применения.
  • Свои операции возвращаются с курсора вместе с чужими: pull не разбирает, кто их прислал. Без фильтра по префиксу opId телефон записывает их как новые — две операции превращаются в четыре, потом в шесть.
  • Чужое применяется мимо журнала: журнал — это очередь на отправку, а отправлять чужое некуда. Поэтому на диске телефона лежат и строки, и журнал, а не один журнал.
  • Курсор, сохранённый до применения страницы, теряет операции навсегда — курсор только растёт. Курсор после применения стоит лишь повторной страницы: операция «поле равно значению» одинаково выдерживает одно наложение и два.
  • Пришедшая операция накладывается полями, а не записывается строкой: перезапись строкой стирает поля, о которых в операции не было ни слова, включая свою неотправленную правку.
  • Отказ отменяет операцию, а не показ: её выбрасывают из журнала, строку пересобирают из оставшихся, а если не осталось ни одной — убирают с экрана. Человеку про это говорит индикатор, который показывает состояние дневника («ждут отправки: N», «сети нет», «отвергнуто: 1»), а не состояние запроса.

1/4Телефон синхронизировался, на экране всё правильно, обе копии дневника сошлись — но в журнале операций стало вдвое больше. Что произошло?

2/4Когда сохранять курсор — до применения страницы или после?

3/4С сервера пришла операция { reps: 12 } для строки, в которой на этом телефоне лежит неотправленная правка веса. Как её применить?

4/4В какой момент операция уходит из исходящей очереди?

Очередь, которая пустеет слишком рано

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

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

Очередь пустеет по вердикту, а вердикт бывает только у доставленной пачки:

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

Тогда неудачная попытка ничего не уносит: в зале: сети нет | в очереди 1, а дома — на экране 1 | в очереди 0 | у сервера строк 1.

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

Отказ вставки и хвост очереди

Здесь отвергнутая операция выбрасывается, а строка пересобирается из оставшихся — как в разделе про отказ. Но пачка со вставкой уже ушла, и, пока ответ был в пути, человек поправил в том же подходе вес: эта правка в пачку не попала. Посмотрите, что окажется на экране и что поедет следующей пачкой, и почините reject().

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

Если отвергнута первая операция строки — вставка, — строки нет ни у сервера, ни у нас, и все её операции уходят вместе с ней:

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

Вывод становится на экране: [] | в очереди 0, следующая пачка: пусто и вердикт по ней: отправлять нечего.

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