Сервер — партнёр, а не хозяин: журнал, курсор, приём пачки
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Дневник целиком лежит в телефоне: хранилище, журнал операций, диск под памятью и честный первый кадр — всё это глава 6 собрала и оставила работать без всякой сети. Теперь у дневника появляется вторая копия: веб-версия дома, второй телефон, резервная копия на случай утонувшего в раздевалке айфона. Сервер для этого читатель писать ещё не умеет — он придёт в части VI, — поэтому здесь сервер собирается миниатюрой. И собирается он партнёром, а не хозяином: принимает пачки операций, выдаёт чужие с курсора, выносит вердикт по каждой операции — и ни одной строки на экране не решает. Контракты у миниатюры названы sync.push и sync.pull — теми же именами, что у процедур настоящего API в части VI: на её место потом встанет настоящий сервер, а экраны не поменяются.
Второй копии дневника нужен ровно один вопрос к серверу: «что появилось с прошлого раза?» Ответ на него называется курсором — отметкой, докуда клиент уже дошёл. Напрашивается взять временем: у каждой операции журнала из главы 6 есть поле at, и «дай всё, что новее» звучит естественно. Проверим на трёх операциях, дошедших до сервера от двух устройств. У первых двух показания часов совпали до миллисекунды, а у третьей часы телефона отстают — такое бывает не в теории, а в каждом втором телефоне с разрядившейся батареей.
Веб-версия успела забрать только первую операцию и запомнила, на чём остановилась. Прогноз: какие имена вернут три способа спросить «что нового» — по at строго больше курсора, по at больше либо равно и по номеру сервера?
Строгое сравнение по времени не вернуло ничего: B:1 случилась в ту же миллисекунду, что уже виденная A:1, и не прошла условие «строго новее», а A:2 с отстающих часов оказалась и вовсе «в прошлом». Обе потеряны навсегда — не до следующей синхронизации, а навсегда, потому что курсор дальше только растёт. Нестрогое сравнение спасло B:1, но ценой повтора: A:1 приехала второй раз, и так будет при каждом заходе. И A:2 оно всё равно не вернуло.
Номер сервера отдал ровно две невиданные операции и ни одной лишней. Причина простая: seq выдаёт один и тот же сервер, по одному на операцию, строго по возрастанию — у него нет ни вторых часов, ни совпадающих значений. Время в операции при этом никуда не девается: at ещё понадобится, но не для курсора, а для разрешения конфликтов — в статье «Кто прав, если оба правы».
Курсор — это не «сколько я скачал» и не «когда я синхронизировался». Это отметка «докуда я дошёл по журналу сервера». Число операций у клиента с ним не связано: своё он в журнал сервера не добавляет.
Партнёр обязан уметь немного: принять пачку операций и сказать по каждой, что с ней стало; отдать чужие операции с курсора. Внутри он устроен как телефон из главы 6 — журнал операций плюс строки, собранные тем же apply. Разница одна, и она главная: номер операции выдаёт он, и этот номер и есть курсор.
Хранилище, часы-счётчик, apply и журнал устройства из главы 6 уже лежат в подложке — их писать заново не нужно. Сценарий пока гоняет сервер руками: пачка составлена прямо в примере, а клиентский цикл будет в следующей статье. Прогноз: какие вердикты вернёт push, что увидит веб-версия с нулевого курсора и какая строка окажется у сервера?
Обе операции приняты и получили номера 1 и 2 — это и есть журнал сервера. Веб-версия с курсора 0 забрала обе и подвинула курсор на 2; hasMore честно говорит, что больше ничего нет. А строка у сервера собралась свёрткой тех же операций: вес 82.5, хотя во второй операции лежало только поле weightKg. Сервер не хранит «правильную версию строки», присланную клиентом, — он накладывает операции ровно так же, как телефон.
В типе вердикта три исхода: applied, duplicate и rejected. Это контракт, а не список того, что умеет сегодняшний сервер: duplicate он не вынесет ни разу — узнавать повторы он научится в статье «Ответ потерялся: повтор без дублей и без лжи», и там же выяснится, что помнить надо вердикт, а не факт. Отказ разберём прямо в следующем разделе.
Контракт полезно держать отдельным файлом — тем самым, который в части VI станет описанием настоящих процедур. Имена в нём те же, что у миниатюры:
Экран сервера не ждёт. Лента подписана на локальное хранилище через useSyncExternalStore («Свой хук: логика без разметки» части IV), и ответ push в состояние компонента не попадает вовсе: он меняет журнал, а экран перерисовывается от хранилища. Поэтому здесь нет ни спиннера, ни трёх состояний запроса — показывать нечего, запись уже сохранена.
Привычка HTTP подсказывает: данные не прошли проверку — ответь 4xx. Для пачки это решение оказывается ловушкой. В очереди телефона накопились три операции, и одна из них ядовитая: 800 повторов в подходе — след залипшей кнопки или опечатки, но лежит она в журнале наравне с остальными и уедет с ближайшей пачкой.
Сравним два сервера на одной и той же пачке. Первый проверяет её целиком и на плохую операцию отвечает 422; клиент у него разумный — ловит ошибку и пробует ещё раз. Второй выносит вердикт по каждой операции внутри обычного ответа. Прогноз: сколько операций окажется в журнале у каждого и что останется в очереди телефона?
У строгого сервера три попытки дали три отказа, журнал пуст, очередь как была из трёх операций, так и осталась. Четвёртая попытка будет такой же, и сотая тоже: пачка не проходит целиком, а выбросить из неё телефон ничего не может — он не знает, какая именно операция мешает. Две совершенно нормальные записи заперты третьей. Это и называется ядовитой операцией: одна строка мусора останавливает синхронизацию устройства навсегда.
У партнёра те же три операции дали два applied и один rejected с названной причиной. Хорошие записи на сервере, плохая названа поимённо, и очередь телефона пустеет. Обратите внимание, что ответ при этом обычный, а не ошибка: отказ по одной операции — это не сбой запроса, а его нормальный результат. Что клиент делает с каждым вердиктом:
| вердикт | что это значит | что делает телефон |
|---|---|---|
applied | операция легла в журнал сервера и получила номер | убирает её из исходящей очереди |
rejected | сервер её не принял и назвал причину | убирает из очереди и пересобирает строку без неё — «Цикл синхронизации» |
duplicate | эта операция у сервера уже была | убирает из очереди как принятую — «Ответ потерялся» |
Отсюда правило для части VI: коды ошибок HTTP — про запрос целиком (нет токена, нет прав, тело не разобралось). Всё, что относится к отдельной операции, едет внутри успешного ответа. Иначе одна плохая запись выключает пользователю синхронизацию, и чинить это придётся руками в базе.
Сервер отказывает — значит, он что-то проверяет. Вопрос в том, что именно. Операция из главы 6 несёт только изменённые поля: правка веса — это { weightKg: 50 } и больше ничего. Проверять её саму по себе почти нечего: вес в диапазоне, и операция выглядит безупречно.
Возьмём пачку из двух операций одного телефона: вставка подхода, в которой 800 повторов, и следом правка веса в той же строке. Первый сервер проверяет поля операции, второй — строку, получившуюся после её применения. Прогноз: какие вердикты вынесет каждый и, главное, что останется у них в строках?
Наивный сервер отверг вставку — 800 повторов он увидел — и тут же принял правку веса: в ней-то придраться не к чему. В итоге у него появилась строка D-1, у которой есть вес и нет ни упражнения, ни повторов. Такого подхода никто никогда не делал: это обрубок, собранный сервером из половинки отвергнутой записи. Он приедет на другое устройство, встанет в ленту и будет там жить.
Партнёр отверг обе операции, и причина второго отказа названа прямо: нет полей: exercise, reps. Он не проверял правку отдельно — он применил её к тому, что у него есть, и посмотрел на результат. А строки D-1 у него нет, потому что вставку он отверг; значит, правка несуществующей строки и даёт неполную строку. Одно правило закрывает сразу два случая: и «подход с восемьюстами повторами», и «правка того, чего нет».
Тогда она будет отвергнута ровно так же — строки ещё нет, результат неполный. Поэтому порядок операций в пачке не декоративный: он тот же, в котором человек нажимал кнопки, и переставлять его нельзя. Сервер, который решит «отсортировать пачку по времени», сам себе устроит отказы — это первая задача в конце статьи.
Отсюда же следует, что пачка не обязана быть транзакцией. Операции применяются по одной, и вердикт у каждой свой; «или всё, или ничего» здесь не нужно, потому что каждая операция самодостаточна — она несёт свой opId, свою строку и свои поля.
Отдавать всё разом сервер не может: у телефона, который месяц не выходил в сеть, накопятся тысячи операций, а у нового устройства — вся история целиком. Поэтому pull принимает limit и отдаёт страницу, новый курсор и признак hasMore. Клиент крутит цикл, пока hasMore не станет ложью.
В журнале сервера три операции, и две из них про одну и ту же строку. Веб-версия открывается впервые: своего у неё нет, курсор нулевой, limit поставим 2, чтобы страниц было заведомо больше одной. Прогноз: сколько будет вызовов, как поедет курсор и что окажется на вебе?
Две страницы, курсор двигается по номеру последней операции страницы — 2, потом 3, — и на второй странице hasMore честно гаснет. Повторный заход с тем же курсором возвращает пустоту и не двигает курсор никуда: pull — чтение, его можно звать сколько угодно раз подряд без последствий. Это пригодится, когда синхронизация начнёт запускаться при каждом возвращении в приложение.
Заметьте, чем сервер платит за страницы: pull отдаёт операции, а не готовые строки. Свёртки здесь нет намеренно — операции с курсора нельзя свернуть, не зная, что у клиента уже есть: страница со второй операцией даст строку без упражнения и повторов, ровно тот же обрубок, что в прошлом разделе. Поэтому чужое применяется по одной операции поверх своей строки, тем же apply. Снимок всего дневника для нового телефона — тот же pull с нуля, а как сервер отдаёт его строками целиком, не теряя отметок полей, — в части VI.
Сети в песочнице нет и не будет, а вот её отсутствие нужно уметь изображать — иначе про оффлайн нельзя сказать ничего проверяемого. У миниатюры два рубильника. Первый очевиден: offline — запрос не уходит вовсе. Второй куда интереснее: loseResponse — сервер принимает пачку, всё применяет и после этого теряет ответ. Так выглядит обрыв связи в метро или сон телефона посреди запроса: для клиента это неотличимо от «ничего не произошло».
Телефон записывает один подход и пробует отправить его дважды — сначала без сети, потом с потерянным ответом. После этого он, ничего не услышав, шлёт ту же пачку ещё раз. Прогноз: что останется в очереди после каждого рубильника и что окажется у сервера после повтора?
С первым рубильником всё честно: запрос не ушёл, операция в очереди, повторить её можно когда угодно. А вот второй развёл телефон и сервер по разные стороны правды. У сервера запись есть и строка создана; телефон об этом не знает и держит операцию в очереди как неотправленную. Ни одна сторона не соврала — просто ответ не доехал.
Повтор прошёл и вернул applied. Строка у сервера по-прежнему одна — и это не везение: операция «поле = значение» при повторном применении даёт тот же результат. А вот записей в журнале стало две, и вторая — призрак, которого никогда не было на телефоне. Она поедет на другие устройства, займёт номер и сдвинет им курсор.
Пока цена — лишняя запись в журнале. Но представьте на её месте операцию, которая при повторе не даёт тот же результат: «добавить подход» с номером от сервера, увеличение счётчика, обновление по версии строки. Там повтор — это уже вторая строка или ложный конфликт. Чем сервер должен помнить, что эту операцию он уже видел, и почему помнить надо вердикт, а не факт, — следующая статья, «Ответ потерялся: повтор без дублей и без лжи».
Рубильники — не украшение песочницы. «Сервер всё сделал, а ответ не доехал» — обычное состояние мобильной сети, и любой протокол синхронизации обязан считать его нормой, а не аварией. Если про повтор в протоколе ничего не сказано, значит, он просто не проверялся.
- Сервер синхронизации — партнёр, а не хозяин: он принимает пачки операций, отдаёт чужие с курсора и выносит вердикты, но не решает, что показано на экране. Главная копия дневника остаётся на телефоне.
- Курсор — это номер, выданный сервером, а не время с телефона. Часы устройств расходятся, а две операции легко попадают в одну миллисекунду: по времени операции либо теряются навсегда, либо приезжают повторно.
- Вердикт выносится по каждой операции внутри обычного ответа. Отказ всей пачке делает одну плохую операцию ядовитой: телефон не знает, какую выбросить, и очередь стоит вечно — в прогоне три попытки дали три отказа и пустой журнал.
- Проверяется строка, получившаяся после применения операции, а не поля самой операции. Иначе отказ вставки плюс принятая правка оставляют на сервере обрубок — строку с весом, но без упражнения и повторов.
pullотдаёт страницами:limit, новый курсор иhasMore. Он отдаёт операции, а не готовые строки, и звать его с тем же курсором можно сколько угодно — это чтение без последствий.- Два рубильника показывают оффлайн честно. «Сети нет» оставляет очередь нетронутой, а «ответ потерялся» разводит телефон и сервер: у сервера запись есть, телефон о ней не знает. Повтор даёт лишнюю запись в журнале — с этого начинается следующая статья.
1/4Почему курсор — это номер, выданный сервером, а не время операции с телефона?
2/4В пачке из трёх операций одна не проходит проверку. Что должен вернуть сервер?
3/4Сервер проверяет пришедшую операцию. Что именно он должен проверять?
4/4Сервер применил пачку, а ответ до телефона не дошёл. Телефон повторяет ту же пачку. Что произойдёт у сервера этой статьи?
Сервер, который сортирует пачку по времени
Автору сервера показалось разумным применять операции «по порядку» — по их времени. Телефон прислал пачку из двух операций: вставку подхода и правку веса в той же строке, — но часы между двумя нажатиями подвели, и у правки время меньше. Почините pushSorted так, чтобы прошли обе операции и вес стал 82.5. А потом ответьте: почему сортировка вообще оказалась вредной, если время в операциях настоящее?
Сортировку надо убрать целиком: порядок пачки — это порядок, в котором человек нажимал кнопки, и сервер не вправе его пересматривать.
Вывод становится вердикты: A:1 applied, A:2 applied, а строка — {"id":"A-1","exercise":"жим","weightKg":82.5,"reps":5}.
Почему сортировка вредна: до неё правка стояла после вставки, а после — перед ней. Правка пришла к строке, которой ещё нет, дала неполную строку и была отвергнута; вставка потом создала строку с весом 80, и правка веса просто пропала. Причём заметить это трудно: ни одна операция не выглядит подозрительно, отказ получает правильная операция, а виноват порядок. Время в операциях действительно настоящее — но оно снято часами устройства, а они врут (об этом статья «Кто прав, если оба правы»). Порядок внутри одного устройства знает только само устройство, и он уже выражен порядком операций в очереди.
hasMore, который всегда врёт
В выдаче с курсора забыли сказать, что страница не последняя: hasMore прибит к false. В журнале сервера три операции, клиент просит страницами по две и останавливается, как только hasMore погас. Сделайте так, чтобы веб-версия дошла до конца журнала.
hasMore — это ответ на вопрос «остались ли за новым курсором операции»:
Вывод становится вызовов: 2 | курсор: 3.
Сравнивать надо именно с next — новым курсором, а не с тем, что пришёл в запросе: иначе последняя страница объявит, что за ней ещё что-то есть, и клиент будет крутить цикл впустую. Проверка «страница заполнилась целиком» (changes.length === limit) тоже работает, но даёт лишний пустой вызов, когда операций ровно на целое число страниц.
Пустая страница сбрасывает курсор
Здесь курсор считается так: последний номер страницы, а если страница пуста — ноль. Выглядит безобидно, пока нового действительно нет. Посмотрите на второй и третий заходы и почините pullPage так, чтобы третий заход не приносил ничего.
Пустая страница означает «нового нет», а не «начни сначала», поэтому курсор остаётся прежним:
Вывод становится второй заход: пришло 0 | курсор 2 | ещё есть false и третий заход: пришло 0.
Со сбросом в ноль получалась вечная петля: пустая страница возвращала курсор 0, hasMore снова становился истиной, и клиент скачивал весь журнал заново — при каждой синхронизации, то есть при каждом возвращении в приложение. На телефоне это выглядело бы не как ошибка, а как «приложение почему-то жрёт трафик и батарею», и искали бы причину долго.