Курсор — это номер, а не время
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В части V сервер-партнёр выдавал номера строкой seq += 1 и жил в той же памяти, что телефон. Теперь под ним настоящий Postgres из главы про данные, и первое, что меняется, — кто выдаёт номер. Курсор при этом не меняется вовсе: клиент по-прежнему спрашивает «что появилось после вот этого числа». Ловушка здесь одна, зато глазами её не увидеть: номер база выдаёт при вставке, а видимым изменение становится при фиксации, и две одновременные пачки фиксируются не в том порядке, в каком получили номера. Клиент, забравший вторую, переступает первую курсором — и не увидит её никогда. Поймаем это запуском, вылечим строкой-счётчиком пользователя и разберёмся, почему hasMore без сдвига курсора — это бесконечный цикл.
Что такое курсор и почему им не может быть время, вы уже выяснили на телефоне: часы двух устройств совпадают до миллисекунды, а отстающие уносят правку в прошлое, тогда как номер сервера растёт по одному и ни с кем не спорит. Контракт оттуда переезжает в часть VI почти как есть — двух необязательных полей коснутся следующие статьи главы, а курсора правки не тронули вовсе: Op с opId, rowId, fields и at, Entry — та же операция плюс seq, у pull на входе cursor и limit, на выходе changes, cursor и hasMore.
Меняется одно: seq больше не переменная в памяти процесса. Сервер — это несколько экземпляров за прокси, и единственное общее у них — база. Напрашивается самое простое: колонка bigserial или своя последовательность, и номер приезжает из nextval вместе со вставкой записи в журнал.
Прежде чем звать pg, соберём это моделью — как и всё в главе про данные. Модель здесь ровно одна новая деталь: пачка операций живёт в транзакции, и её записи не видны никому, пока она не зафиксирована. У таблицы главы 9 такого не было: версии строк и снимок читателя — это то, чего модель не умела и что настоящая база делает сама. Поэтому begin, write и commit в примере написаны руками.
Телефон и веб-версия одного человека отправили пачки почти одновременно; пачка веб-версии короче и фиксируется первой. Прогноз: что отдаст pull с нулевого курсора, пока пачка телефона ещё в пути, каким станет курсор — и что придёт с этим курсором, когда зафиксируются обе?
Клиент забрал единственную видимую запись — вторую, с номером 2, — и подвинул курсор на 2. Через миг фиксируется пачка телефона, и её запись с номером 1 оказывается позади курсора. Второй pull отдаёт пустоту. В журнале сервера обе записи, у клиента одна, и жаловаться некому: ни ошибки, ни отказа, ни повтора.
Причина — в двух разных моментах. Номер выдаётся, когда операция вставляется, а видимой она становится, когда транзакция фиксируется. Между этими двумя моментами лежит вся остальная работа пачки: проверки, память вердиктов, запись строк. Чем пачка длиннее, тем шире окно, и порядок номеров с порядком фиксаций расходится тем чаще.
Ловушка не выдуманная. У автора в боевом проекте номер seq выдаёт одна последовательность на всех — nextval('message_seq') стоит и в умолчании колонки, и в $onUpdate, — и рядом с догоном пропущенного лежит длинный комментарий о том, почему курсор там двигают не на самый большой номер страницы. Этот комментарий и лечение, которое он описывает, разберём в последнем разделе.
Номер, выданный раньше, не значит «увиденный раньше». Курсор верит номерам, а видит фиксации — и это разные порядки.
Лечение отменяет саму причину: если два номера не могут быть выданы одновременно, порядок номеров и порядок фиксаций совпадут. Для этого номер выдаёт не последовательность, а строка — по строке на человека, таблица sync_heads с ключом user_id и колонкой seq. И занимает эту строку пачка первой своей командой, ещё до разбора операций.
Строку заводит регистрация, а on conflict do nothing — страховка на случай, когда первая пачка пришла раньше, чем строка появилась. Взятая for update строка держится до конца транзакции, поэтому вторая пачка того же человека стоит на первой же команде и дожидается фиксации первой. Пачки остальных людей не ждут ничего: у каждого своя строка.
Обратите внимание, чего здесь нет: условия на прежнее значение. Это ожидание блокировки, а не сравнение с обменом, и разницу между ними глава про данные показала на двух клиентах: прибавка на месте после ожидания перечитывает строку и прибавляет к новому значению, а условие на версию вернуло бы ноль строк и потребовало повтора. Сквозной номер без дыр делают именно прибавкой.
В модели замок превращается в ожидание, а ожидание в JavaScript — это промис. Очередь промисов на пользователя и есть та самая строка: следующая пачка получает свой begin только после commit предыдущей. Прогноз: какой номер достанется веб-версии, пока пачка телефона не зафиксирована, что отдаст pull в этот момент и в каком порядке лягут записи после обеих фиксаций?
Веб-версия не получила номера вовсе — она стоит на строке-счётчике и даже не начала разбирать свою пачку. pull в этот момент отдаёт пустую страницу и не двигает курсор: переступать нечего. После обеих фиксаций записи лежат по порядку — 1 у телефона, 2 у веб-версии, — и клиент с нулевого курсора забирает обе.
У номеров, выданных строкой пользователя, есть следствие, которое легко упустить: они больше не уникальны на всю базу. Первый подход Ани и первый подход Бори оба получают номер 1, и отбирать страницу приходится сразу по двум колонкам — where user_id = $1 and seq > $2, индекс (user_id, seq). Курсор одного человека про другого не говорит ничего; что бывает, если про user_id забыть, разбирает вторая задача, а статья про владение ставит это правилом.
Плата за очередь честная: все пачки одного человека идут по одной, и зависшая держит остальные. Поэтому транзакции пачки ставят lock_timeout и statement_timeout — ждать чужой фиксации разумно секунды, а не минуты. И ещё одно место, где счётчик надо помнить: база, поднятая из резервной копии, начинает номера заново, а курсоры телефонов уже впереди. В регламенте восстановления sync_heads.seq сдвигают за любой выданный номер — это работа статьи про эксплуатацию.
Строка-счётчик — единственное место, где пачки одного человека встречаются. Взять её первой командой дешевле, чем потом разбираться, кто кого дождался.
Модель собрана — проверим обе половины на живой базе. Прогонщик поднимает временный кластер PostgreSQL на свободном порту и отдаёт примеру адрес пустой базы; адрес печатается первой строкой и без пароля. Соединений три: две пачки и читатель. Читатель тут обязателен: pull — это отдельный запрос отдельного клиента, и видно ему только зафиксированное.
import pg from 'pg'; // адрес базы — первой строкой и без пароля: видно, куда ушли запросы const url = new URL(process.env.DATABASE_URL); url.password = ''; console.log('база:', url.href); const cfg = { connectionString: process.env.DATABASE_URL }; const phone = new pg.Client(cfg); const web = new pg.Client(cfg); const reader = new pg.Client(cfg); await Promise.all([phone.connect(), web.connect(), reader.connect()]); console.log('уровень изоляции:', (await phone.query('SHOW transaction_isolation')).rows[0].transaction_isolation); await reader.query(`CREATE SEQUENCE changes_seq; CREATE TABLE changes (user_id text NOT NULL, seq bigint NOT NULL, op_id text NOT NULL, PRIMARY KEY (user_id, seq)); CREATE TABLE sync_heads (user_id text PRIMARY KEY, seq bigint NOT NULL DEFAULT 0)`); // pull — чтение отдельным соединением и без транзакции: ему видно только // зафиксированное, как и всякому читателю const pull = async (user, cursor) => (await reader.query('SELECT seq, op_id FROM changes WHERE user_id = $1 AND seq > $2 ORDER BY seq', [user, cursor])).rows.map( (row) => `${row.seq}:${row.op_id}`, ); // о том, чем занят второй клиент, спрашиваем саму базу — по номеру его // обслуживающего процесса const waitsForLock = async (pid) => { for (let i = 0; i < 400; i += 1) { const { rows } = await reader.query('SELECT wait_event_type FROM pg_stat_activity WHERE pid = $1', [pid]); if (rows[0]?.wait_event_type === 'Lock') return 'ждёт замка'; await new Promise((resolve) => setTimeout(resolve, 25)); } return 'замка так и не дождались'; }; const pidOf = async (client) => (await client.query('SELECT pg_backend_pid() AS pid')).rows[0].pid; // Сцена 1. Номер приезжает из последовательности вместе со вставкой записи await phone.query('BEGIN'); const first = await phone.query( `INSERT INTO changes (user_id, seq, op_id) VALUES ('u1', nextval('changes_seq'), 'A:1') RETURNING seq`, ); console.log('номер первой пачки:', first.rows[0].seq, '| тип значения от драйвера:', typeof first.rows[0].seq); await web.query('BEGIN'); await web.query(`INSERT INTO changes (user_id, seq, op_id) VALUES ('u1', nextval('changes_seq'), 'B:1')`); await web.query('COMMIT'); const page = await pull('u1', 0); console.log('последовательность: pull при курсоре 0, пачка телефона в пути →', page); const cursor = Number(page.at(-1).split(':')[0]); await phone.query('COMMIT'); console.log(`последовательность: pull при курсоре ${cursor}, обе зафиксированы →`, await pull('u1', cursor)); console.log('последовательность: а в журнале →', await pull('u1', 0)); // Сцена 2. Номер приезжает из строки-счётчика пользователя, а строку пачка // занимает первой своей командой. Строку завела регистрация, вставка с // ON CONFLICT — страховка await reader.query(`INSERT INTO sync_heads (user_id) VALUES ('u2')`); const takeHead = async (client, user) => { await client.query('BEGIN'); await client.query(`INSERT INTO sync_heads (user_id) VALUES ($1) ON CONFLICT DO NOTHING`, [user]); await client.query(`SELECT seq FROM sync_heads WHERE user_id = $1 FOR UPDATE`, [user]); }; const number = async (client, user) => (await client.query(`UPDATE sync_heads SET seq = seq + 1 WHERE user_id = $1 RETURNING seq`, [user])).rows[0].seq; await takeHead(phone, 'u2'); await phone.query(`INSERT INTO changes (user_id, seq, op_id) VALUES ('u2', $1, 'A:2')`, [await number(phone, 'u2')]); const webPid = await pidOf(web); const webBatch = (async () => { await takeHead(web, 'u2'); await web.query(`INSERT INTO changes (user_id, seq, op_id) VALUES ('u2', $1, 'B:2')`, [await number(web, 'u2')]); await web.query('COMMIT'); })(); console.log('счётчик: пока первая пачка не зафиксирована, вторая', await waitsForLock(webPid)); console.log('счётчик: pull при курсоре 0 →', await pull('u2', 0)); await phone.query('COMMIT'); await webBatch; console.log('счётчик: после обеих фиксаций pull при курсоре 0 →', await pull('u2', 0)); // Сцена 3. Первые пачки нового пользователя: строки-счётчика ещё нет const batch = async (client, device, n) => { await takeHead(client, 'u3'); await client.query(`INSERT INTO changes (user_id, seq, op_id) VALUES ('u3', $1, $2)`, [ await number(client, 'u3'), `${device}:${n}`, ]); await client.query('COMMIT'); }; await Promise.all([ (async () => { for (const n of [1, 2, 3]) await batch(phone, 'A', n); })(), (async () => { for (const n of [1, 2, 3]) await batch(web, 'B', n); })(), ]); const numbers = (await pull('u3', 0)).map((entry) => Number(entry.split(':')[0])); const gapless = numbers.every((seq, i) => seq === i + 1); console.log('новый пользователь: номера первых шести пачек →', numbers.join(','), '| без дыр и повторов:', gapless); await Promise.all([phone.end(), web.end(), reader.end()]);
база: postgres://postgres@127.0.0.1:<порт-базы>/app уровень изоляции: read committed номер первой пачки: 1 | тип значения от драйвера: string последовательность: pull при курсоре 0, пачка телефона в пути → [ '2:B:1' ] последовательность: pull при курсоре 2, обе зафиксированы → [] последовательность: а в журнале → [ '1:A:1', '2:B:1' ] счётчик: пока первая пачка не зафиксирована, вторая ждёт замка счётчик: pull при курсоре 0 → [] счётчик: после обеих фиксаций pull при курсоре 0 → [ '1:A:2', '2:B:2' ] новый пользователь: номера первых шести пачек → 1,2,3,4,5,6 | без дыр и повторов: true
Первая сцена повторяет модель на настоящих транзакциях: пока пачка телефона в пути, pull с нулевого курсора отдаёт только запись с номером 2, курсор уезжает на 2, и запись с номером 1 после фиксации уже не догнать. В журнале обе, у клиента одна — те же номера и та же потеря, что в примере выше.
Вторая сцена — строка-счётчик первой командой. О том, чем занят второй клиент, прогон спрашивает саму базу: в pg_stat_activity у его процесса wait_event_type равен Lock — он ждёт замка, а не думает над операциями. pull в этот момент пуст, после обеих фиксаций записи идут 1 и 2 в порядке фиксации. Уровень изоляции прогон печатает сразу под адресом: read committed, умолчание Postgres, и всё сказанное верно именно для него.
Третья сцена — новый человек, у которого строки-счётчика ещё нет: два устройства шлют по три пачки разом. Номера выходят 1..6 без дыр и без повторов, хотя insert … on conflict do nothing в начале каждой пачки выполняется шесть раз. Первая пачка заводит строку, остальные пять на ней же и выстраиваются.
Соблазн понятный: строка-счётчик нужна только в момент выдачи номера, значит и брать её можно там же — а до этого спокойно разбирать операции и править строки дневника. Беда в том, что замки при этом берутся в разном порядке: одна пачка уже держит строку дневника и просит счётчик, другая держит счётчик и просит ту же строку дневника.
import pg from 'pg'; const url = new URL(process.env.DATABASE_URL); url.password = ''; console.log('база:', url.href); const cfg = { connectionString: process.env.DATABASE_URL }; const phone = new pg.Client(cfg); const web = new pg.Client(cfg); const reader = new pg.Client(cfg); await Promise.all([phone.connect(), web.connect(), reader.connect()]); await reader.query(`CREATE TABLE sync_rows (user_id text NOT NULL, row_id text NOT NULL, weight int NOT NULL, PRIMARY KEY (user_id, row_id)); CREATE TABLE sync_heads (user_id text PRIMARY KEY, seq bigint NOT NULL DEFAULT 0); INSERT INTO sync_heads (user_id) VALUES ('u1'); INSERT INTO sync_rows VALUES ('u1', 'жим', 80), ('u1', 'присед', 100)`); const pidOf = async (client) => (await client.query('SELECT pg_backend_pid() AS pid')).rows[0].pid; // номера обслуживающих процессов спрашиваем заранее: запросы одного соединения // драйвер выстраивает в очередь, и у занятого ожиданием клиента спросить нечего const pids = { phone: await pidOf(phone), web: await pidOf(web) }; const waitsForLock = async (pid) => { for (let i = 0; i < 400; i += 1) { const { rows } = await reader.query('SELECT wait_event_type FROM pg_stat_activity WHERE pid = $1', [pid]); if (rows[0]?.wait_event_type === 'Lock') return true; await new Promise((resolve) => setTimeout(resolve, 25)); } return false; }; const editRow = (client, rowId) => client.query(`UPDATE sync_rows SET weight = weight + 5 WHERE user_id = 'u1' AND row_id = $1`, [rowId]); const takeNumber = (client) => client.query(`UPDATE sync_heads SET seq = seq + 1 WHERE user_id = 'u1' RETURNING seq`); const numbers = async () => (await reader.query(`SELECT seq FROM sync_heads WHERE user_id = 'u1'`)).rows[0].seq; // Сцена 1. Счётчик берётся не первой командой, а когда пачка дошла до номера const failed = []; await phone.query('BEGIN'); await editRow(phone, 'жим'); await web.query('BEGIN'); await editRow(web, 'присед'); await takeNumber(web); const phoneWait = takeNumber(phone).catch((error) => failed.push(`${error.code} ${error.message}`)); await waitsForLock(pids.phone); const webWait = editRow(web, 'жим').catch((error) => failed.push(`${error.code} ${error.message}`)); await Promise.all([phoneWait, webWait]); for (const client of [phone, web]) await client.query('COMMIT').catch(() => client.query('ROLLBACK')); console.log('счётчик в середине пачки: упало пачек', failed.length, '|', failed.join(' | ')); console.log('счётчик в середине пачки: номер счётчика после обеих пачек', await numbers()); // Сцена 2. Тот же порядок правок, но строку-счётчик каждая пачка занимает первой await reader.query(`UPDATE sync_heads SET seq = 0 WHERE user_id = 'u1'`); const clean = []; await phone.query('BEGIN'); await takeNumber(phone); await editRow(phone, 'жим'); const second = (async () => { await web.query('BEGIN'); await takeNumber(web).catch((error) => clean.push(`${error.code} ${error.message}`)); await editRow(web, 'присед'); await editRow(web, 'жим'); await web.query('COMMIT'); })(); console.log('счётчик первой командой: вторая пачка ждёт замка:', await waitsForLock(pids.web)); await phone.query('COMMIT'); await second; console.log('счётчик первой командой: упало пачек', clean.length, '| номер счётчика после обеих пачек', await numbers()); // Сцена 3. Тот же счётчик первой командой, но пачки открыты как repeatable read await reader.query(`UPDATE sync_heads SET seq = 0 WHERE user_id = 'u1'`); const strict = []; await phone.query('BEGIN ISOLATION LEVEL REPEATABLE READ'); await takeNumber(phone); const third = (async () => { await web.query('BEGIN ISOLATION LEVEL REPEATABLE READ'); await takeNumber(web).catch((error) => strict.push(`${error.code} ${error.message}`)); await web.query('COMMIT').catch(() => web.query('ROLLBACK')); })(); await waitsForLock(pids.web); await phone.query('COMMIT'); await third; console.log('repeatable read: упало пачек', strict.length, '|', strict.join(' | ')); console.log('repeatable read: номер счётчика после обеих пачек', await numbers()); await Promise.all([phone.end(), web.end(), reader.end()]);
база: postgres://postgres@127.0.0.1:<порт-базы>/app счётчик в середине пачки: упало пачек 1 | 40P01 deadlock detected счётчик в середине пачки: номер счётчика после обеих пачек 1 счётчик первой командой: вторая пачка ждёт замка: true счётчик первой командой: упало пачек 0 | номер счётчика после обеих пачек 2 repeatable read: упало пачек 1 | 40001 could not serialize access due to concurrent update repeatable read: номер счётчика после обеих пачек 1
Круг база разрывает сама: одна из пачек падает с кодом 40P01 и словами «deadlock detected», и всё, что она успела сделать, откатывается вместе с ней — прибавка счётчика в том числе, в отличие от последовательности, которая номер сжигает на откате. Номер остаётся только у выжившей: после обеих пачек счётчик стоит на 1, а не на 2. Какую из двух валить, решает база, и заранее это не известно. Клиент получит отказ и повторит пачку позже — но легче от этого не станет: взаимные блокировки приходят от порядка взятия замков, а не от данных, и на повторе порядок может сложиться точно так же.
Та же пара пачек, но счётчик занят первой командой: вторая пачка честно ждёт замка с самого начала, обе доходят до конца, счётчик показывает 2. Ни одна не упала — не потому, что стало быстрее, а потому, что порядок взятия замков у всех пачек теперь один.
Третья сцена прогона — тот же счётчик, но пачки открыты как repeatable read. Ожидание там кончается не номером и не нулём строк, а ошибкой 40001: «could not serialize access due to concurrent update». Повторять после неё придётся всю транзакцию целиком, и это ещё одна причина держать пачку на read committed.
Замок, взятый первым, дешевле замка, взятого вовремя. Порядок взятия — то немногое в базе, что целиком в ваших руках.
В выводе прогона есть строка, которая к порядку фиксаций отношения не имеет, но стоит целого раздела: тип значения, вернувшегося из returning seq, — string. Драйвер pg отдаёт bigint строкой, и делает он это не из вредности: bigint шире, чем целое, которое JavaScript хранит без потерь, и молча округлять номер драйвер не хочет. То же самое вы видели у чисел, приезжающих из базы: ломается на них не равенство, а порядок.
Пока номера сравнивает SQL, строка сходит с рук: в where seq > $1 Postgres приведёт её к bigint сам. Ломается всё, что сервер решает в JavaScript: курсор страницы, признак «ещё есть», отбор строк в модели и любая проверка «этот номер больше того». Прогноз: что отдаст страница с курсором «9», если номера пришли от драйвера как есть, — и что, если на границе чтения их пропустить через Number?
Строки сравниваются посимвольно, и «10» меньше «9» — первая цифра решает всё. Страница вышла пустой, hasMore — ложью, курсор остался прежним: сервер честно сказал клиенту «ничего нового», и клиент застыл на девятом номере навсегда. В главе про данные тот же тип ломал только порядок: страницу отбирала база, а курсор, посчитанный строками, всего лишь отставал и привозил лишнее. Здесь в JavaScript переехал сам отбор — и вместо лишнего вышла пустота. Ни исключения, ни нуля строк, ни записи в журнале — ровно тот класс ошибок, который видно только по недовольному человеку.
Лечение — одно приведение на границе чтения: Number(row.seq) в репозитории или bigint({ mode: 'number' }) в описании колонки — этот вид колонки так и переводит значение драйвера в число. Без приведения выходит ровно тот зазор, который разбирала статья про схему Drizzle: тип обещает про описание схемы, а не про то, что придёт. Контракту это и нужно: в PullInput курсор объявлен числом, и схема на границе процедуры поймает строку, если она проскочит. Безопасный предел у Number — 9 007 199 254 740 991; счётчику одного человека до него не дойти, а вот общая последовательность растёт на всех сразу, и там об этом пределе думают всерьёз.
Номер из базы — это не число, пока его не привели к числу. Приводить надо в одном месте, иначе окажется, что в одном запросе курсор число, а в другом строка.
Клиент части V устроен просто: он крутит цикл, пока hasMore не станет ложью, и каждый оборот двигает курсор на тот, что вернул сервер. Пока сервер отдаёт всё, что у него есть, это работает. А отдаёт он не всё — как только в нём появляется причина что-то придержать, цикл превращается в ловушку.
Причины бывают разные: горизонт по самой старой открытой транзакции, фильтр эха, водяной знак. Водяной знак — самое частое лечение сервера, который выдаёт номера последовательностью: он не отдаёт самые свежие записи, пока не убедится, что за ними не прячется транзакция в полёте с меньшим номером. Наденем его обёрткой на сервер из раздела про строку-счётчик и позовём настоящий клиент части V — тот самый, с эхом по префиксу opId; цикл в нём не тронут, изменилась одна механика: push и pull теперь возвращают промис, и sync() их ждёт.
Три способа посчитать курсор и «ещё есть» у придерживающего сервера. Первый: курсор — номер последней отданной строки, «ещё есть» — есть ли что-нибудь дальше. Второй: то же, но «ещё есть» говорится только вместе со сдвигом курсора. Третий: ничего не придерживать. Прогноз: сколько раз клиент позовёт pull в каждом случае и где окажется курсор?
Первый случай — вечный цикл: страница пуста, курсор не двинулся, «ещё есть» истинно, и клиент заходит снова с тем же курсором. Пятьдесят первый вызов остановила обёртка своим предохранителем — в настоящем приложении его роль играет счётчик страниц, а без него это разряженная за час батарея и трафик. Второй случай честен: сервер придержал строки и сам сказал «на этом всё», хвост доберёт следующий заход. Третий — наш: придерживать нечего, один вызов, курсор 2.
Именно вторым путём идёт боевой сервер автора. Там номера даёт общая последовательность, поэтому у страницы есть две границы: строки уезжают клиенту все, а подтвердить курсором можно только устоявшиеся — те, что старше запаса времени.
Читать это надо с оговоркой: в том контракте nextCursor объявлен как number | null, и null означает «подтверждать нечего». В контракте части V курсор — просто число, и пустая страница его не сбрасывает: сервер возвращает тот же курсор, который пришёл. Третья задача статьи про сервер-партнёр как раз про это.
Вторая половина лечения живёт на клиенте — и она показательна тем, что делает с ответом сервера:
Две строчки return 'deferred' — это недоверие к серверу, вписанное в клиента: курсор двигается только по значению сервера и только если оно выросло. Плюс потолок страниц, из-за которого цикл кончается всегда, каким бы ни был ответ. Клиент части V ни того, ни другого не делает, и добавлять их глава 11 не станет — значит, сервер обязан не врать.
Третье лечение — то, которое выбрала эта глава: не придерживать вовсе. Строка-счётчик делает и горизонт, и водяной знак ненужными, потому что номера видны ровно в том порядке, в каком выданы. Эхо тоже не дело сервера: свои операции клиент узнаёт по префиксу opId и пропускает их сам, а курсор при этом двигается по номерам сервера, а не по тому, что осталось после фильтра.
Строка-счётчик работает, пока журнал у каждого человека свой. Как только через синхронизацию поедут общие данные — справочник, лента команды, — счётчик на них один, и очередь из всех пачек сразу станет узким местом. Тогда возвращается последовательность, а вместе с ней вопрос: как отдавать страницы, если порядок номеров не равен порядку фиксаций?
Первый ответ — горизонт: отдавать только то, что старше самой старой незавершённой транзакции. Номер транзакции у Postgres можно положить прямо в строку (xid8 и pg_current_xact_id()), а границу спросить у pg_snapshot_xmin(pg_current_snapshot()). Звучит надёжно, но с курсором по одному seq горизонт не спасает — и вот почему.
import pg from 'pg'; const url = new URL(process.env.DATABASE_URL); url.password = ''; console.log('база:', url.href); const cfg = { connectionString: process.env.DATABASE_URL }; const phone = new pg.Client(cfg); const web = new pg.Client(cfg); const reader = new pg.Client(cfg); await Promise.all([phone.connect(), web.connect(), reader.connect()]); // xid8 транзакции — прямо в строке: по нему и считается горизонт await reader.query(`CREATE SEQUENCE changes_seq; CREATE TABLE changes (seq bigint PRIMARY KEY, op_id text NOT NULL, xid xid8 NOT NULL DEFAULT pg_current_xact_id()); CREATE TABLE sync_verdicts (op_id text PRIMARY KEY)`); const insert = (client, opId) => client.query(`INSERT INTO changes (seq, op_id) VALUES (nextval('changes_seq'), $1)`, [opId]); const xidOf = async (client) => (await client.query('SELECT pg_current_xact_id()::text AS x')).rows[0].x; const names = (rows) => rows.map((row) => `${row.seq}:${row.op_id}`); // курсор по одному seq: отдаём то, что новее курсора и старше самой старой // незавершённой транзакции const bySeq = async (cursor) => ( await reader.query( `SELECT seq, op_id FROM changes WHERE seq > $1 AND xid < pg_snapshot_xmin(pg_current_snapshot()) ORDER BY seq`, [cursor], ) ).rows; // курсор парой (xid, seq) и порядок выдачи по той же паре const byPair = async (xid, seq) => ( await reader.query( `SELECT seq, op_id, xid::text AS xid FROM changes WHERE xid < pg_snapshot_xmin(pg_current_snapshot()) AND (xid::text::numeric, seq) > ($1::numeric, $2::bigint) ORDER BY xid::text::numeric, seq`, [xid, seq], ) ).rows; // Телефон начинает раньше: первой командой пачки он занимает память вердиктов, // и номер транзакции ему достаётся раньше. В журнал он пишет позже веб-версии await phone.query('BEGIN'); await phone.query(`INSERT INTO sync_verdicts VALUES ('A:1')`); const phoneXid = await xidOf(phone); await web.query('BEGIN'); await web.query(`INSERT INTO sync_verdicts VALUES ('B:1')`); await insert(web, 'B:1'); const webXid = await xidOf(web); await insert(phone, 'A:1'); console.log('номер транзакции телефона меньше, чем у веб-версии:', BigInt(phoneXid) < BigInt(webXid)); await phone.query('COMMIT'); const first = await bySeq(0); console.log('курсор по seq: pull, пока пачка веб-версии в пути →', names(first)); const cursor = first.length > 0 ? Number(first.at(-1).seq) : 0; await web.query('COMMIT'); console.log(`курсор по seq: pull при курсоре ${cursor}, обе зафиксированы →`, names(await bySeq(cursor))); // та же пара транзакций, но курсором служит пара await reader.query(`TRUNCATE changes, sync_verdicts; ALTER SEQUENCE changes_seq RESTART`); await phone.query('BEGIN'); await phone.query(`INSERT INTO sync_verdicts VALUES ('A:2')`); await web.query('BEGIN'); await web.query(`INSERT INTO sync_verdicts VALUES ('B:2')`); await insert(web, 'B:2'); await insert(phone, 'A:2'); await phone.query('COMMIT'); const pairPage = await byPair('0', '0'); console.log('курсор парой: pull, пока пачка веб-версии в пути →', names(pairPage)); const pair = pairPage.length > 0 ? [pairPage.at(-1).xid, pairPage.at(-1).seq] : ['0', '0']; await web.query('COMMIT'); console.log('курсор парой: pull при том же курсоре, обе зафиксированы →', names(await byPair(pair[0], pair[1]))); console.log('в журнале:', names((await reader.query('SELECT seq, op_id FROM changes ORDER BY seq')).rows)); await Promise.all([phone.end(), web.end(), reader.end()]);
база: postgres://postgres@127.0.0.1:<порт-базы>/app номер транзакции телефона меньше, чем у веб-версии: true курсор по seq: pull, пока пачка веб-версии в пути → [ '2:A:1' ] курсор по seq: pull при курсоре 2, обе зафиксированы → [] курсор парой: pull, пока пачка веб-версии в пути → [ '2:A:2' ] курсор парой: pull при том же курсоре, обе зафиксированы → [ '1:B:2' ] в журнале: [ '1:B:2', '2:A:2' ]
Пачка телефона началась раньше — первой командой она заняла память вердиктов, и номер транзакции ей достался меньший. В журнал она написала позже веб-версии, поэтому её seq больше. Горизонт пропускает её первой (она зафиксирована, её транзакция старше), клиент двигает курсор на её seq — и запись веб-версии с меньшим номером, зафиксированная позже, остаётся позади курсора. Та же потеря, что и без горизонта.
Вторая половина прогона показывает лечение: курсором становится пара «номер транзакции, номер журнала», и выдача идёт по той же паре. Тогда пропущенная запись приходит следующим заходом — курсор просто ещё не дошёл до её транзакции. Цена — другой контракт: пара в cursor: number не помещается, а хранить её клиенту придётся строкой и сравнивать по частям.
Третий путь — водяной знак из боевого сервера: не отдавать номера моложе запаса времени. Он проще пары, но платит задержкой и требует, чтобы запас покрывал самую долгую пишущую транзакцию с запасом, а часы сравнения принадлежали базе, а не экземпляру сервера.
Глава 11 не берёт ни один из трёх, потому что общих данных через sync не гоняет: общий справочник живёт кешем запросов, а не журналом. Появятся общие данные — контракт придётся менять, и первым делом курсор.
На этом номер устроен, и дальше глава разбирает, что сервер делает с пачкой: память вердиктов и повтор без дублей, а затем итог строк вместо журнала — там номер переезжает с операции на строку. Журнал, который вы сейчас собрали, до того места живёт как есть.
Правило короткое: сервер, спрятавший строку, обязан либо сдвинуть курсор, либо сказать «на этом всё». Третьего клиент не выдержит.
- Курсор синхронизации — номер сервера, а не время устройства, и в части VI этот номер выдаёт база. Сам курсор в контракте части V не меняется:
cursorиlimitна входе,changes,cursorиhasMoreна выходе. - Номер из последовательности выдаётся при вставке, а видимой запись становится при фиксации. Две одновременные пачки фиксируются не в порядке номеров, и клиент, забравший запись с большим номером, теряет меньшую навсегда — без ошибки, отказа и повтора.
- Лечение — строка-счётчик на человека, занятая первой командой пачки:
select … for update, затемupdate … set seq = seq + 1 returning seq. Пачки одного человека идут по одной, порядок номеров совпадает с порядком фиксаций, чужие пачки не ждут никого. Взятая позже, та же строка даёт взаимные блокировки (40P01), аrepeatable readломает счётчик ошибкой40001. - Номера, выданные строкой пользователя, уникальны только внутри него: страница отбирается по
user_idиseqсразу, индексом(user_id, seq). Курсор одного человека про другого не говорит ничего. bigintприезжает от драйвера строкой, и приводить его к числу надо на границе чтения: в JavaScript «10» меньше «9», и на этом молча ломаются курсор страницы и признак «ещё есть».hasMoreчестен только вместе со сдвигом курсора. Сервер, придержавший строки — горизонтом, водяным знаком или фильтром эха, — обязан либо двинуть курсор, либо сказать «на этом всё»; иначе клиент, крутящий цикл поhasMore, заходит с тем же курсором без конца.
1/4Сервер выдаёт номера последовательностью при вставке. Почему клиент теряет изменение, хотя номера строго растут?
2/4Строку-счётчик пачка занимает первой командой. Что это даёт против «занять её, когда дошли до выдачи номера»?
3/4pull вернул пустую страницу, тот же курсор, что пришёл, и hasMore: true. Что сделает клиент части V?
4/4Драйвер pg вернул seq строкой. Где это выстрелит?
Два устройства, один номер
Этот сервер выдаёт номера из строки-счётчика, но берёт её по-простому: читает значение, потом пишет увеличенное. Между чтением и записью — поход в базу, то есть await. Две пачки одного человека приходят разом. Почините выдачу номеров, не меняя ни log, ни печать в конце.
Оба вызова прочитали счётчик до того, как кто-то из них записал новое значение, — то самое потерянное обновление из главы про данные, только потеряли тут не правку, а номер. Обе пачки получили номер 1, и вторая запись вытеснила первую из журнала: ключ у записи — номер, и он оказался занят.
Лечение — то же, что у настоящего сервера: строку-счётчик надо занять первой командой пачки, и тогда вторая пачка дождётся фиксации первой. В модели замок — это очередь промисов на пользователя:
Номера выходят 1 и 2, в журнале обе записи. Обратите внимание, что чтение счётчика переехало внутрь замка: снаружи оно устаревало ровно так же, как раньше.
В SQL у этого два слоя. Строку занимает select seq from sync_heads where user_id = $1 for update первой командой пачки, а номер даёт update sync_heads set seq = seq + 1 … returning seq — прибавка на месте, без отдельного чтения. Второй слой важен сам по себе: даже без for update прибавка на месте номера не теряет, потому что читает и пишет одной командой. Терять их начинает именно «прочитать, подумать, записать».
Страница с чужими подходами
Номера тут выдаёт строка-счётчик каждого человека, а страницу сервер отбирает только по курсору. У Ани два подхода, у Бори один. Посмотрите, что приедет на веб-версию Бори, и починьте pull.
Бедствий сразу два. Первое: Боря получил чужой дневник — два подхода Ани приехали к нему как свои. Второе тише и хуже: курсор Бори уехал на 2, потому что последней на странице оказалась запись Ани с номером 2. Свой второй подход, которому строка-счётчик выдала тот же номер 2, Боря с этого курсора уже не получит никогда — на другом его устройстве подхода просто не будет.
Номера, выданные строкой пользователя, уникальны только внутри этого пользователя. Значит, и условие должно быть по двум колонкам:
Теперь Боря с нулевого курсора получает одну свою запись, курсор становится 1, а следующий заход приносит второй подход. В базе это where user_id = $1 and seq > $2 и индекс (user_id, seq) — тот же порядок колонок, что в условии.
Условие видимости, написанное в одном запросе и забытое в соседнем, — знакомая беда: так же терялось условие в счётчике страниц в главе про запросы. Разница в том, что здесь забытая колонка отдаёт чужие данные, а такие вещи держат в одном месте — в репозитории, а не в обработчике.
Курсор убежал вперёд страницы
В журнале пять записей, limit у клиента 2, и цикл послушно крутится, пока hasMore не станет ложью. На веб-версии оказываются две записи из пяти. Найдите строку, из-за которой три записи не приехали, и починьте её.
Курсор страницы взят из журнала, а не из страницы: log[log.length - 1].seq — это номер последней записи сервера, а клиент получил только первые две. Обещание «дальше начнём после этого номера» сервер выдал за записи, которых не отдавал.
Теперь страниц три, курсор идёт 2, 4, 5, и на вебе оказываются все пять записей. Пустая страница курсор не двигает: changes пуст — возвращается тот курсор, который пришёл.
Тот же вопрос стоит задать и в обратную сторону: курсор обязан быть номером последней отданной записи, а не «последней плюс один». Сравнение на сервере строгое (seq > cursor), поэтому повторный заход с этим курсором начнёт ровно со следующей записи — и повторный pull с тем же курсором безопасен, чем клиент части V и пользуется при каждом возвращении в приложение.