Выдача с курсора: надгробия, их срок и снимок для нового телефона
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Сервер-партнёр части V отдавал с курсора операции: у него был журнал, и свернуть его он не мог — операции с курсора нельзя свернуть, не зная, что у клиента уже есть. Сервер этой главы журнала не держит вовсе. У него итог: строка, отметки [at, opId] по её полям и номер последней правки — тот самый, что выдаёт строка-счётчик пользователя. Свернуть он поэтому может: не хвост операций с курсора, а всю строку, и отдать её записями по отметкам. Из этого сразу получаются три вещи, которых часть V ждала от части VI: снимок для нового телефона, отставший на любой срок и срок жизни надгробия. Срок окажется странным — бессрочно, — и защищать его придётся запуском. А ещё выяснится, что клиента части V всё-таки придётся поправить в одном месте: без этой правки опечатка в чужом подходе расходит устройства навсегда.
Что сервер делает с пачкой, решено в прошлых статьях. Номер выдаёт строка-счётчик человека, и номер получает только операция, сдвинувшая хоть одну отметку: правка, проигравшая по времени всем полям, принята, но никому не нужна. Осталось сказать, где этот номер живёт. В части V он стоял на операции в журнале. Здесь он стоит на строке: sync_rows держит поля, отметки полей и seq — номер последней правки именно этой строки.
Выдача из этого получается короткой: where user_id = $1 and seq > $2 order by seq — строки, которые менялись после курсора, по порядку номера. Каждая строка уходит записями Entry по отметкам: поля с общей отметкой — одной записью с её исходными at и opId, и у всех записей строки один номер, её seq. PullInput { cursor, limit } и PullOutput { changes, cursor, hasMore } при этом не меняются: клиент части V применяет записи тем же слиянием по полям и итога от истории не отличает.
Почему записи, а не строка целиком? Соблазн ровно обратный: раз у сервера готовая строка, отдадим её одной записью — одно имя, одна отметка, меньше байтов. Проверим три формы одной и той же страницы на одном сюжете. Строку завёл телефон; потом веб-версия подняла вес; потом она же поправила повторы, но отметкой более ранней, чем у веса, — так бывает, когда операция сделана раньше, а уехала позже. Забирает всё это планшет, у которого есть своя неотправленная правка веса. Прогноз: какая форма страницы сойдётся с сервером, какая нет и на каком поле?
Записи по отметкам сошлись. Строка одной записью с самой новой отметкой — нет: повторы у планшета остались прежними. Причина видна в отметках: первая же такая страница проштамповала все поля планшета отметкой веса — самой новой в строке. После этого правка повторов, сделанная раньше веса, оказалась для планшета старее того, что у него записано, и слияние её отбросило. Сервер прав, планшет по своим отметкам тоже прав, расхождение молчит и повторным pull не лечится.
Третья форма — строка без отметок вовсе — ломается ещё раньше. В правиле слияния по полям поле, у которого отметки нет, применяется всегда — сравнивать не с чем, — но поле, у которого она есть, страницу без отметки побеждает: у планшета остался свой вес 85, у сервера — чужие 90. Новому телефону такая страница, кстати, ляжет верно: у него отметок ещё нет. Это и есть худший вид ошибки — та, что проявляется не у всех и не сразу.
Абзац части V, с которого начинается статья, при этом не опровергнут. Свернуть хвост операций с курсора действительно нельзя: для этого надо знать, что у клиента уже есть. Сервер главы сворачивает не хвост, а всю строку и отдаёт её со всеми отметками — и знать, что у клиента есть, ему не нужно: слияние по полям идемпотентно, лишняя запись с прежней отметкой ничего не меняет. Ровно поэтому одна и та же выдача годится и новому телефону, и отставшему, и тому, кто зашёл дважды подряд.
| форма страницы | записей на строку | чем платит |
|---|---|---|
| записи по отметкам | по одной на отметку | лишние записи и эхо на странице; сходится у любого устройства и при любом курсоре |
| одна запись, отметка самая новая | одна | штампует все поля получателя чужой отметкой: правка, сделанная раньше, отбрасывается навсегда |
| одна запись, без отметок | одна | новому телефону ляжет верно, а у того, у кого свои отметки есть, отберёт его правку |
Отметки полей — единственное, что делает страницу применимой дважды. Строка без отметок — это не строка, а мнение сервера о ней на момент отправки.
У страницы теперь две единицы измерения, и путать их дорого. Курсор — номер строки, а не записи. limit — тоже строки: записей на странице может оказаться до limit, умноженного на число полей, потому что строка с четырьмя разными отметками уедет четырьмя записями. Считать предел в записях выглядит честнее по трафику, но тогда страница рвёт строку пополам, и клиент получает ровно тот обрубок, из-за которого часть V и не сворачивала операции.
Отсюда два правила. Строка целиком всегда на одной странице. И limit сервер урезает сам — до 500 строк, кодом, а не отказом схемы: телефон, попросивший тысячу, получит пятьсот и hasMore. Отказ здесь был бы наказанием за то, что не запрещено, а старый бандл, для которого тысяча была нормой, перестал бы синхронизироваться совсем.
В модели строки лежат в памяти, поэтому «ещё есть» она считает сравнением длин, а базе для того же нужна лишняя строка в выборке. Разница только в этом. Прогноз к запуску: у человека три строки, у первой четыре поля с четырьмя разными отметками — сколько будет страниц при limit 1, какие номера пройдёт курсор и сколько записей придёт на первой странице?
Первая страница — одна строка и четыре записи: столько у неё отметок. Курсор идёт по номерам строк — 4, 5, 6, — и номер 4 у первой строки не потому, что она четвёртая, а потому, что её последняя правка была четвёртой по счёту у этого человека. Обрубков нет ни на одной странице, и проверено это не глазами: пример сверяет поля каждой пришедшей строки с полями итога. Шестьсот строк при limit 1000 превратились в пятьсот и «ещё есть».
Соблазн спрятать на выдаче то, что клиенту не нужно, живёт долго: свои же записи (эхо), строки моложе водяного знака, надгробия за горизонтом. Любое такое сокрытие обязано двигать курсор за спрятанным, иначе цикл while (hasMore) клиента части V крутится вечно — этот случай уже разобран запуском: пятьдесят первый pull с тем же нулевым курсором.
Поэтому сервер главы не прячет ничего, а эхо отсекает клиент — по префиксу opId, как он это и делал. Поля устройства в PullInput нет и не понадобится: если серверу когда-нибудь нужно будет имя устройства — для журнала или метрик, — он прочитает его из префикса opId пришедших операций.
Курсор измеряется в строках, а трафик — в записях. Как только одно начинают считать другим, на странице появляется обрубок или бесконечный цикл.
Третья полка проекта части V ждала от сервера трёх вещей: горизонта курсора, срока надгробий и снимка для нового телефона. Снимок закрывается первым и без нового механизма: первая синхронизация — обычный pull с нуля, а с нуля сервер отдаёт все строки человека, каждую записями по отметкам. Отставший на месяц — тот же pull со своего курсора: выбрасывать сервер ничего не будет (об этом следующий раздел), значит и сбрасывать нечего.
Теперь честная цена, потому что «снимок вместо истории» звучит как обещание трафика. Прогноз: неделя дневника из той же таблицы части V — три подхода, правки веса, правки повторов и одно удаление, девять операций. Сколько записей получит новый телефон? И сколько он получит, если вес одной строки правили двадцать раз?
Девять операций — девять записей. Ровно столько отдал бы и журнал: у каждой правки своё поле и своя отметка, сворачивать нечего. Выигрыш появляется там, где правок в одно поле много: двадцать одна операция по одной строке ушла двумя записями — по числу отметок, а не по числу правок. Так об итоге и надо думать: он сворачивает повторные правки одного поля, а не историю вообще.
Ещё две строки вывода — про цену. Планшет, отставший на одну правку веса, получил три записи, то есть всю строку: номер стоит на строке, и отдаётся она целиком. И эхо: за пять кругов на двух устройствах телефону приехало тридцать записей, двадцать одна из которых — его собственные, вернувшиеся вместе со строкой. На строке из трёх полей, два из которых правил сам телефон, это две записи из трёх; в сводном опыте на десяти устройствах и тысяче раундов эхо вышло около 23 % записей. Журнал отдал бы меньше.
Значит, выигрыш итога — не байты. Выигрыш в том, чего в сервере нет: отдельной процедуры снимка, сброса, горизонта и второго пути выдачи, который пришлось бы проверять наравне с первым. Список вопросов, который часть V передала части VI, глава закрывает без второго пути выдачи — а у одной выдачи один набор ошибок, а не два.
Снимок — это не новая процедура, а нулевой курсор. Всякий раз, когда для особого случая просят второй путь выдачи, стоит проверить, не тот ли это путь, что уже есть.
Удаление в этом протоколе — операция с полем deleted, и на сервере она ничем не выделена: та же строка, те же отметки, тот же номер. Отдельной процедуры удаления нет, DELETE по строке нет, «вернуть» — операция { deleted: false }. Правило «удаление побеждает» из части V тоже не меняется, и по той же причине: deleted для слияния — обычное поле, а правка другого поля его не снимает — она прилипает к надгробию и остаётся невидимой.
А вот срок надгробия часть V прямо оставила серверу: убирать можно не раньше, чем его увидели все устройства, то есть за горизонтом самого отставшего курсора. Ответ этой главы: не убирать вовсе. Прогноз к запуску: в первой половине сервер не чистит ничего, во второй — выбросил надгробие до того, как спавший планшет проснулся. Что покажет планшет в каждом случае и что случится, если человек допишет в увиденный подход ещё один?
Без чистки всё скучно и правильно: планшет получил надгробие, подход исчез с экрана, строк у сервера осталось две. «Вернуть» приехало такой же обычной записью, и видно снова два. С чисткой скучного нет: надгробие выбросили до того, как планшет проснулся, — и он показывает удалённый подход как живой. Стоит человеку дописать в него подход целиком, и удалённое возвращается ко всем: сервер видит вставку строки, которой у него нет, и принимает её. Это тот же сюжет, которым кончалась статья про надгробия, только данные испортил не забывчивый клиент, а аккуратный сервер.
Чистку по горизонту живых курсоров сервер главы не строит, и это не лень. Ей нужны сразу четыре вещи, которых в контракте части V нет: личность устройства на pull (там только cursor и limit); календарная граница живости вместо самого горизонта — часть V просила ответ про устройства («увидели все»), а не про срок; сброс, который клиент части V не прочитает, потому что читает только changes, cursor и hasMore; и запись курсора на pull, после которой выдача перестаёт быть чтением. Каждая — правка контракта и клиента ради килобайтов.
Килобайты, кстати, посчитаны: надгробия года дневника — около 145 КиБ, удалённые строки — 5 % кортежей за год и 4 % за пять лет. Цена решения в другом, и назвать её надо прямо: содержимое удалённых подходов остаётся на сервере навсегда. «Удалить дневник везде» ставит надгробия на все строки и данных не стирает; стереть их — отдельная операция над всеми строками человека, и к синхронизации она не относится.
Путь понятен, и он не собирался и не проверялся — поэтому в главе его нет, а не потому, что он невозможен. Устройство берётся не из тела запроса, а из сессии входа. Входной курсор каждого устройства пишется в отдельную таблицу через greatest(), чтобы отставший запрос не откатил горизонт назад. Граница живости — срок refresh-токена: календарь, но обоснованный. Сброс — поле в PullOutput и фолд на клиенте, который умеет забыть всё и пойти с нуля, обойдя фильтр эха. Четыре правки в четырёх местах ради 145 КиБ в год.
Решение «не чистить» дороже места и дешевле механизма. Оно обязано быть названным: «мы не чистим никогда» — это ответ, а «мы пока не чистим» — долг, о котором забудут.
Остался отказ. Клиент части V на rejected пересобирает строку свёрткой своего журнала, и это работает, пока строку правил только он. Чужие поля в его журнал не попадали — их применяли мимо журнала, прямо в хранилище. Значит, точную строку после отказа может дать только сервер.
Дать её выдачей не получится: курсор клиента уже за этой строкой, а второго входа в pull мы не заводим. Поэтому строка приезжает в самом вердикте: Verdict.row — записи строки по отметкам, прочитанные после разбора всей пачки, когда строка уже окончательная. Пустой массив значит «строки у сервера нет»: вставку отвергли — или имя строки принадлежит другому человеку, и под ключом-парой её для отправителя не существует. Повтор той же пачки получит тот же отказ со свежей строкой, а поле необязательное: клиент, который про него не знает, ведёт себя как раньше.
Прогноз к запуску: в обеих сценах телефон правит строку, которую завёл планшет, и сервер отказывает — «повторов больше сотни». В первой сцене у телефона по этой строке одна операция, во второй — две, и первую сервер принял. Что останется на экране у клиента части V и у клиента с правкой?
Три поломки в двух строках вывода. Первая: отвергли единственную операцию строки — значит отвергли и первую операцию строки, а на этот случай у клиента части V есть store.remove. Строка исчезла с экрана, хотя у сервера она есть, и вернуть её нечем: курсор за ней, а отметки полей на телефоне остались — перевыданная запись с той же отметкой будет пропущена. Вторая: во второй сцене выброшена не первая операция, и клиент собирает строку свёрткой того, что осталось, то есть из одной своей правки веса. На экране обрубок в одно поле. Третья спрятана в отметках: повторы не вернулись и после pull, потому что отметка отвергнутой правки новее пришедшей записи, а вес вернулся только потому, что планшет поднял его позже.
Клиент с правкой сошёлся с сервером в обеих сценах. Порядок у него такой: забыть строку вместе с отметками, применить записи из отказа, а поверх — свои операции этой строки, которые не отвергнуты. Забыть отметки обязательно: без этого перевыданная строка со своими исходными отметками проиграет тем, что уже лежат на телефоне, и не применится вовсе.
Правка клиента части V за всю главу одна, и вот она целиком. Ветка без row — дословно прежний reject(), поэтому примеры части V на её миниатюре печатают то же, что печатали: там в отказе строки нет, и путь выполнения тот же.
Почему не проще. «Переиздать строку свежей отметкой» с сервера соблазнительно и неверно: свежая отметка перебьёт чужую правку, которая на самом деле новее, и потеря будет молчаливой. «Прислать в PullInput список нужных строк» — это второй путь выдачи со своим набором ошибок, и клиенту пришлось бы хранить этот список на диске атомарно с курсором. Строка в отказе приезжает в том же ответе, что вердикт, повторяется вместе с повтором пачки — и нового состояния на диске у клиента не появляется.
И последнее про сам отказ: он остаётся обычным ответом, а не ошибкой запроса. Код ответа тут про запрос целиком — нет токена, тело не разобралось, — а вердикт по операции едет внутри успешного ответа. Клиенту это важно: 401 ставит паузу, а любая другая неудача — отсрочку, и отказ по операции ни того, ни другого не вызывает.
Сервер, который отказывает, обязан приложить к отказу то, чем его лечить. Отказ без состояния — приглашение клиенту угадать состояние, а угадывает он по своему журналу, в котором чужого нет.
- Сервер главы хранит итог, а не журнал: строка, отметки
[at, opId]по её полям иseq— номер последней правки этой строки. Номер получает только операция, сдвинувшая отметку, а выдача отдаёт строки с номером больше курсора, по порядку номера. - Строка уходит записями по отметкам: поля с общей отметкой — одной записью с её исходными
atиopId, и у всех записей строки её номер. Контракт части V не меняется, клиент применяет записи тем же слиянием по полям и итога от истории не отличает. - Свернуть строку в одну запись нельзя. С самой новой отметкой она штампует ею все поля получателя, и правка, сделанная раньше, отбрасывается навсегда; без отметок — проигрывает собственным отметкам получателя. Сходится только форма по отметкам, и это показывает запуск.
- Снимок для нового телефона и отставший на любой срок — это один и тот же
pullс курсора. Выигрыш не в трафике (неделя дневника — те же девять записей, а эхо возвращает автору его же правки), а в том, что второго пути выдачи, сброса и горизонта в сервере нет. - Надгробия не чистятся никогда. Чистка по сроку воскрешает удалённое у планшета, который спал, а чистка по горизонту требует личности устройства на
pull, календарной границы живости, сброса и записи курсора на чтение. Цена решения названа: содержимое удалённых строк остаётся на сервере, и «удалить везде» данных не стирает. - Отказ несёт строку целиком:
Verdict.row— записи по отметкам после разбора всей пачки, пустой массив у строки, которой у сервера нет. Без неё клиент части V убирает чужую строку с экрана или оставляет обрубок в одно поле — это единственная правка клиента за всю главу.
1/4Сервер хранит итог: строку, отметки по её полям и номер последней правки. Правка приехала, но проиграла по отметке всем полям строки. Что она получит?
2/4Почему страница отдаёт строку записями по отметкам, а не одной записью с самой новой отметкой строки?
3/4Телефон попросил limit 1000, а у человека 600 строк. Что вернёт сервер?
4/4Клиент части V получил rejected на единственную свою правку строки, которую завёл его же планшет. Строку сервер не присылает. Что будет с ней на этом телефоне?
Предел, посчитанный в записях
Трафик посчитали и решили, что limit в строках непредсказуем: строка с десятью отметками — это десять записей. Выдачу переписали так, чтобы страница набиралась до limit записей.
Строка целиком на странице при этом сохранена: цикл не обрывает её, а прекращает набор. Что всё-таки сломается и почему одной этой заботы недостаточно?
Страница действительно не рвётся, и потому правка выглядит безобидной. Ломается смысл предела. Строку, у которой записей больше limit, условие page.length > 0 пропускает целиком — иначе страница вышла бы пустой и цикл клиента встал бы на месте. Значит, при limit 1 клиент всё равно получит четыре записи: предел в записях не соблюдён. А предел в строках, который был, потерян: одна строка с десятью отметками съедает страницу целиком, и у догоняющего устройства число заходов вырастает во столько же раз.
Второе — цена в базе. Предел в записях одним limit в SQL не выражается: сколько записей даст строка, видно только после чтения её отметок, поэтому выборку придётся брать с запасом и досчитывать в приложении. Правильный ответ на вопрос о трафике — не менять единицу измерения курсора, а уменьшить limit в строках: его присылает сам клиент, а сервер только урезает сверху.
Отказ без строки, чтобы сэкономить байты
На код-ревью заметили, что Verdict.row приезжает в каждом отказе, и отказов у пачки может быть много. Предложение: строку не прикладывать, потому что клиент всё равно заберёт её следующим pull.
Разберите предложение: что именно получит клиент следующим pull и что покажет человеку?
Следующим pull клиент не получит ничего. Выдача отдаёт строки с номером больше курсора, а отказ строку не менял — её номер остался прежним и меньше курсора. Строка приедет только если её потом кто-то поправит.
Но и тогда она приедет не целиком в смысле применения: записи придут со своими исходными отметками, а на телефоне лежит отметка отвергнутой правки — она новее. Слияние по полям пропустит такую запись, и обрубок останется на экране. Ровно это и печатает прогон раздела: у клиента без строки в отказе на экране одно поле, а вернулось только то, что чужое устройство поправило позже отвергнутой правки.
Особая ветка для нулевого курсора
Раз новому телефону отметки всё равно не с чем сравнивать, для нулевого курсора сделали отдельную ветку: строка отдаётся одной записью, отметки не выписываются.
Назовите случай, в котором эта ветка испортит данные, и скажите, чем такая оптимизация плоха сама по себе.
Нулевой курсор не доказывает, что у телефона нет отметок. Курсор и отметки лежат в разных местах и теряются по-разному: курсор сохраняется после применения страницы, поэтому убитый процесс оставляет применённые строки с прежним курсором; кнопка «пересинхронизировать» сбрасывает курсор, а не дневник; восстановление приложения из резервной копии может вернуть базу и не вернуть курсор. В любом из этих случаев телефон придёт с нулём, имея свои отметки, — и страница без отметок отберёт у него его же правку, а страница с одной самой новой отметкой проштампует ею все поля.
Плоха она и сама по себе: это второй путь выдачи. У него свои ошибки, и проверять его надо наравне с первым, а срабатывает он редко — значит, ошибка в нём будет найдена не тестом, а человеком с новым телефоном.