Курсор — это номер, а не время

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

Здесь будет встроен видео-разбор темы (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 с нулевого курсора, пока пачка телефона ещё в пути, каким станет курсор — и что придёт с этим курсором, когда зафиксируются обе?

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

Клиент забрал единственную видимую запись — вторую, с номером 2, — и подвинул курсор на 2. Через миг фиксируется пачка телефона, и её запись с номером 1 оказывается позади курсора. Второй pull отдаёт пустоту. В журнале сервера обе записи, у клиента одна, и жаловаться некому: ни ошибки, ни отказа, ни повтора.

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

Ловушка не выдуманная. У автора в боевом проекте номер seq выдаёт одна последовательность на всех — nextval('message_seq') стоит и в умолчании колонки, и в $onUpdate, — и рядом с догоном пропущенного лежит длинный комментарий о том, почему курсор там двигают не на самый большой номер страницы. Этот комментарий и лечение, которое он описывает, разберём в последнем разделе.

Номер, выданный раньше, не значит «увиденный раньше». Курсор верит номерам, а видит фиксации — и это разные порядки.

Лечение отменяет саму причину: если два номера не могут быть выданы одновременно, порядок номеров и порядок фиксаций совпадут. Для этого номер выдаёт не последовательность, а строка — по строке на человека, таблица sync_heads с ключом user_id и колонкой seq. И занимает эту строку пачка первой своей командой, ещё до разбора операций.

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

Строку заводит регистрация, а on conflict do nothing — страховка на случай, когда первая пачка пришла раньше, чем строка появилась. Взятая for update строка держится до конца транзакции, поэтому вторая пачка того же человека стоит на первой же команде и дожидается фиксации первой. Пачки остальных людей не ждут ничего: у каждого своя строка.

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

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

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

Веб-версия не получила номера вовсе — она стоит на строке-счётчике и даже не начала разбирать свою пачку. 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 — это отдельный запрос отдельного клиента, и видно ему только зафиксированное.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 28 сентября 2026
Последовательность против строки-счётчика: что отдаёт 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()]);
node visibility.mjs

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

Вторая сцена — строка-счётчик первой командой. О том, чем занят второй клиент, прогон спрашивает саму базу: в pg_stat_activity у его процесса wait_event_type равен Lock — он ждёт замка, а не думает над операциями. pull в этот момент пуст, после обеих фиксаций записи идут 1 и 2 в порядке фиксации. Уровень изоляции прогон печатает сразу под адресом: read committed, умолчание Postgres, и всё сказанное верно именно для него.

Третья сцена — новый человек, у которого строки-счётчика ещё нет: два устройства шлют по три пачки разом. Номера выходят 1..6 без дыр и без повторов, хотя insert … on conflict do nothing в начале каждой пачки выполняется шесть раз. Первая пачка заводит строку, остальные пять на ней же и выстраиваются.

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

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 28 сентября 2026
Две пачки одного человека берут замки в разном порядке, потом в одном
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()]);
node order.mjs

Круг база разрывает сама: одна из пачек падает с кодом 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?

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

Строки сравниваются посимвольно, и «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 в каждом случае и где окажется курсор?

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

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

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

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

Читать это надо с оговоркой: в том контракте nextCursor объявлен как number | null, и null означает «подтверждать нечего». В контракте части V курсор — просто число, и пустая страница его не сбрасывает: сервер возвращает тот же курсор, который пришёл. Третья задача статьи про сервер-партнёр как раз про это.

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

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

Две строчки return 'deferred' — это недоверие к серверу, вписанное в клиента: курсор двигается только по значению сервера и только если оно выросло. Плюс потолок страниц, из-за которого цикл кончается всегда, каким бы ни был ответ. Клиент части V ни того, ни другого не делает, и добавлять их глава 11 не станет — значит, сервер обязан не врать.

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

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

Первый ответ — горизонт: отдавать только то, что старше самой старой незавершённой транзакции. Номер транзакции у Postgres можно положить прямо в строку (xid8 и pg_current_xact_id()), а границу спросить у pg_snapshot_xmin(pg_current_snapshot()). Звучит надёжно, но с курсором по одному seq горизонт не спасает — и вот почему.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 28 сентября 2026
Горизонт по самой старой транзакции: курсор по номеру журнала и курсор парой
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()]);
node horizon.mjs

Пачка телефона началась раньше — первой командой она заняла память вердиктов, и номер транзакции ей достался меньший. В журнал она написала позже веб-версии, поэтому её 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, ни печать в конце.

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

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

Лечение — то же, что у настоящего сервера: строку-счётчик надо занять первой командой пачки, и тогда вторая пачка дождётся фиксации первой. В модели замок — это очередь промисов на пользователя:

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

Номера выходят 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.

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

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

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

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

Теперь Боря с нулевого курсора получает одну свою запись, курсор становится 1, а следующий заход приносит второй подход. В базе это where user_id = $1 and seq > $2 и индекс (user_id, seq) — тот же порядок колонок, что в условии.

Условие видимости, написанное в одном запросе и забытое в соседнем, — знакомая беда: так же терялось условие в счётчике страниц в главе про запросы. Разница в том, что здесь забытая колонка отдаёт чужие данные, а такие вещи держат в одном месте — в репозитории, а не в обработчике.

Курсор убежал вперёд страницы

В журнале пять записей, limit у клиента 2, и цикл послушно крутится, пока hasMore не станет ложью. На веб-версии оказываются две записи из пяти. Найдите строку, из-за которой три записи не приехали, и починьте её.

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

Курсор страницы взят из журнала, а не из страницы: log[log.length - 1].seq — это номер последней записи сервера, а клиент получил только первые две. Обещание «дальше начнём после этого номера» сервер выдал за записи, которых не отдавал.

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

Теперь страниц три, курсор идёт 2, 4, 5, и на вебе оказываются все пять записей. Пустая страница курсор не двигает: changes пуст — возвращается тот курсор, который пришёл.

Тот же вопрос стоит задать и в обратную сторону: курсор обязан быть номером последней отданной записи, а не «последней плюс один». Сравнение на сервере строгое (seq > cursor), поэтому повторный заход с этим курсором начнёт ровно со следующей записи — и повторный pull с тем же курсором безопасен, чем клиент части V и пользуется при каждом возвращении в приложение.