Приём пачки: память вердиктов и отказ по каждой операции

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

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

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

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

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

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

Шире он ровно по той причине, по которой в этой главе вообще есть статья про владение: opId придумывает клиент. Приставка «A» в примерах — сокращение ради читаемости: настоящее имя устройства — случайный UUID, и случайно два телефона на одном имени не встретятся. Но имя приезжает в теле запроса, и проверить его сервер не может: прислать чужое — не взлом, а обычный POST. А законно повторяться этому имени положено: тот же opId приходит с двух устройств одного человека, и разбирается это ниже. Ключом из одного op_id сервер не отличил бы одно от другого — человек с чужим именем операции получил бы чужой вердикт и своей записи не дождался. Поэтому первичный ключ — пара (user_id, op_id), и дальше в статье «память» всегда значит «память этого человека».

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

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

Первая отправка разобрана обычным порядком. На второй ни одна операция заново не разбиралась: A:1 и A:3 вернулись как duplicate, а A:2 — тем же rejected и с той же причиной, слово в слово. Это важнее, чем кажется: причина отказа — единственное, что телефон может показать человеку, и меняться она между повторами не должна. А пачка Бори разобрана целиком, хотя имена операций в ней те же: ключ — пара, и строки в памяти легли рядом, не спорив друг с другом.

В описании схемы это выглядит так. Составной первичный ключ у Drizzle объявляется третьим аргументом pgTable — тем, где живут индексы и проверки:

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

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

Потому что чистки не будет. Память вердиктов не чистится — ни от applied, ни от rejected, никогда. Это прямое следствие обещания, которое часть V проверяла прогоном: файл дневника можно открыть на двух новых устройствах, и ни одна операция из него не станет второй записью — тот же вердикт по тому же opId сколько угодно раз. Слово «сколько угодно» стоит понимать буквально: файл могут открыть и через год.

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

Тогда чистить можно только applied и никогда rejected. Отвергнутая операция после стирания памяти разбирается заново, а правило проверки к тому времени могли и поменять — и тогда телефон получит на ту же операцию другой ответ, чем в первый раз. С applied так не выйдет при трёх условиях, и все три в этой главе выполнены: операции имеют вид «поле = значение» и несут отметки, строки не забываются и не чистятся, а у операции, которая ничего не сдвинула, вердикт выносится без проверки строки.

Цена всё равно есть, и платит её честность ответа: повтор операции, вытесненной из памяти, получит applied вместо duplicate. Данные от этого не разъедутся — телефон поступает с обоими вердиктами одинаково, убирает операцию из очереди, — но буква обещания «тот же вердикт по тому же opId» нарушится. Пока мегабайт в год никого не смущает, платить за это незачем.

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

Память вердиктов — единственное место, где сервер помнит про операцию. Всё остальное он помнит про строку.

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

Пачка приходит одной транзакцией. Первая её операция — повтор уже принятой A:1, вторая — новая A:2, с которой всё в порядке. Прогноз: что напечатает драйвер на первой вставке, что — на второй, чем ответит COMMIT и что останется в памяти вердиктов, когда пачка кончится?

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 28 сентября 2026
Повтор внутри пачки на настоящем Postgres: обычная вставка против ON CONFLICT DO NOTHING RETURNING
import pg from 'pg';

// адрес базы — первой строкой и без пароля: видно, куда ушли запросы
const url = new URL(process.env.DATABASE_URL);
url.password = '';
console.log('база:', url.href);

const c = new pg.Client({ connectionString: process.env.DATABASE_URL });
await c.connect();

await c.query(`CREATE TABLE sync_verdicts (
  user_id text NOT NULL,
  op_id text NOT NULL,
  status text NOT NULL,
  reason text,
  PRIMARY KEY (user_id, op_id)
)`);
// A:1 уже принята прошлой пачкой: ответ на неё телефон не получил
await c.query(`INSERT INTO sync_verdicts VALUES ('u1', 'A:1', 'applied', NULL)`);

const memory = async () =>
  (await c.query(`SELECT op_id, status FROM sync_verdicts WHERE user_id = 'u1' ORDER BY op_id`)).rows
    .map((row) => `${row.op_id}:${row.status}`)
    .join(',') || 'пусто';

// Сцена 1. Пачка — одна транзакция, а память вердиктов пишется обычной вставкой:
// дубль ловится по коду ошибки, как учит глава про свою таблицу
console.log('\n-- пачка [A:1 повтор, A:2 новая], память пишется обычной вставкой');
await c.query('BEGIN');
for (const [opId, status] of [['A:1', 'applied'], ['A:2', 'applied']]) {
  try {
    const put = await c.query(`INSERT INTO sync_verdicts VALUES ('u1', $1, $2, NULL)`, [opId, status]);
    console.log(`  ${opId}: вставлено строк ${put.rowCount}`);
  } catch (err) {
    const where = err.constraint ? ` (${err.constraint})` : '';
    console.log(`  ${opId}: ${err.code}${where} ${err.message}`);
  }
}
const ended = await c.query('COMMIT');
console.log('  COMMIT ответил:', ended.command);
console.log('  в памяти:', await memory());

// Сцена 2. Та же пачка, но вставка помечена ON CONFLICT DO NOTHING RETURNING
console.log('\n-- та же пачка через ON CONFLICT (user_id, op_id) DO NOTHING RETURNING');
await c.query('BEGIN');
for (const [opId, status] of [['A:1', 'applied'], ['A:2', 'applied']]) {
  const put = await c.query(
    `INSERT INTO sync_verdicts VALUES ('u1', $1, $2, NULL)
     ON CONFLICT (user_id, op_id) DO NOTHING RETURNING status`,
    [opId, status],
  );
  console.log(`  ${opId}: вставлено строк ${put.rowCount} | вернулось: ${put.rows[0]?.status ?? '—'}`);
}
await c.query('COMMIT');
console.log('  в памяти:', await memory());

// Сцена 3. Отметки [at, opId] сравниваются в JavaScript, а не в SQL: при равном
// времени порядок решает opId — обычная строка, а у строк есть правило сравнения
console.log('\n-- порядок двух имён операций: B:1 и a:1');
const collation = (await c.query(`SELECT datcollate FROM pg_database WHERE datname = current_database()`)).rows[0].datcollate;
console.log('  правило сравнения строк этой базы:', collation);
console.log('  в JavaScript "B:1" < "a:1" →', 'B:1' < 'a:1');
const compare = async (suffix) =>
  (await c.query(`SELECT 'B:1'${suffix} < 'a:1'${suffix} AS less`)).rows[0].less;
console.log('  в базе как есть →', await compare(''));
console.log('  в базе COLLATE "C" →', await compare(' COLLATE "C"'));
const other = (await c.query(
  `SELECT collname FROM pg_collation WHERE collname IN ('en_US.UTF-8', 'en_US.utf8') ORDER BY collname LIMIT 1`,
)).rows[0]?.collname;
console.log(`  в базе COLLATE "${other}" →`, await compare(` COLLATE "${other}"`));

// Сцена 4. Время отметки: в jsonb рядом со строкой и отдельной колонкой bigint
console.log('\n-- откуда приезжает at отметки');
await c.query(`CREATE TABLE sync_rows (row_id text PRIMARY KEY, marks jsonb NOT NULL, at_column bigint NOT NULL)`);
await c.query(`INSERT INTO sync_rows VALUES ('A-1', $1, 1757577000123)`, [
  JSON.stringify({ weightKg: { at: 1757577000123, opId: 'A:3' } }),
]);
const { rows: [stored] } = await c.query(`SELECT marks -> 'weightKg' -> 'at' AS from_jsonb, at_column FROM sync_rows`);
console.log('  из jsonb:', stored.from_jsonb, '| тип значения от драйвера:', typeof stored.from_jsonb);
console.log('  из колонки bigint:', stored.at_column, '| тип значения от драйвера:', typeof stored.at_column);

await c.end();
node push.mjs

Первая вставка дала ровно то, что обещала глава про таблицу: код 23505 и имя нарушенного ограничения. А вторая не дала ничего — 25P02, «current transaction is aborted, commands ignored until end of transaction block». После ошибки Postgres не выполняет в этом блоке ничего: команды принимаются и отбрасываются до конца транзакции. И COMMIT в такой транзакции коммитом не является — драйвер получил на него ROLLBACK. Пачка из двух операций, где сломана была первая, целиком не состоялась.

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

Вторая половина прогона — та же пачка, но каждая вставка помечена ON CONFLICT (user_id, op_id) DO NOTHING RETURNING. Повтор вставил ноль строк и ничего не вернул, соседняя операция прошла, в памяти стало две записи. Ноль возвращённых строк — это не ошибка и не отказ, это ответ: «такая запись уже есть». Ровно тот же приём, что ноль изменённых строк у обновления с условием, и читается он так же — не как сбой, а как развилка.

Заодно обратите внимание, чего в этой форме нет. Нет отдельного запроса «а нет ли уже такого вердикта?» перед вставкой. Он был бы не только лишним, но и неверным по той же причине, по которой не спасает проверка почты перед регистрацией: между вопросом и вставкой проходит время. Правда, под строкой-счётчиком пачки одного человека идут по одной, и разъехаться этим двум командам негде, — но форма записи всё равно одна, и она короче.

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

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

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

У шага есть уточнение, которое легко пропустить, а стоит оно целого правила. Если строка уже есть и ни одна её отметка не сдвинулась, вердикт — applied без всякой проверки: строка осталась той же, что уже проходила проверку, и гонять её второй раз не нужно. Номера такая операция тоже не получает — менять в строке нечего.

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

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

Разберём по строкам. A:1 завела строку — номер один. B:1 с планшета подняла вес до девяноста — номер два, отметка веса теперь её. A:2 правит то же поле — вес восемьдесят пять, — но пришла с меньшим временем и проиграла: ни одна отметка не сдвинулась, вердикт applied, номер не выдан. A:3 правит строку A-2, которой у сервера нет, — результат наложения неполон, и отказ называет отсутствующие поля. A:4 с восемьюстами повторами отвергнута по строке после применения, а строка при этом осталась целой: у неё по-прежнему пять повторов. И A:5, пришедшая сразу за ядовитой, разобрана как обычно — номер три.

Шаг второй — память. Вердикт вычислен, но ещё не записан; записывается он той самой вставкой с ON CONFLICT DO NOTHING RETURNING. Шаг третий — строка. Вернулась строка — операция новая: сервер берёт номер у счётчика (и только если отметка сдвинулась) и пишет строку итога. Ноль строк — операция уже была: сервер читает сохранённый вердикт, отдаёт applied как duplicate, rejected — тем же rejected, и строку не пишет.

На Drizzle весь приём операции — вот столько:

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

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

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

Прогноз к прогону: две копии одной пачки, ушедшие одна за другой, — какие вердикты получит каждая и каким станет номер счётчика? И вторая сцена: повтор отвергнутой операции после смягчения правила — что ответит сервер и останется ли у него строка при каждом из двух порядков?

прогон под базой 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()]);
console.log('уровень изоляции:', (await phone.query('SHOW transaction_isolation')).rows[0].transaction_isolation);

await reader.query(`CREATE TABLE sync_heads (user_id text PRIMARY KEY, seq bigint NOT NULL DEFAULT 0);
  CREATE TABLE sync_rows (user_id text, row_id text, fields jsonb NOT NULL, marks jsonb NOT NULL, seq bigint,
    PRIMARY KEY (user_id, row_id));
  CREATE TABLE sync_verdicts (user_id text, op_id text, status text NOT NULL, reason text,
    PRIMARY KEY (user_id, op_id))`);

// порядок правок полный: сначала время, потом opId — правило телефона, дословно
const newer = (a, b) => (a.at !== b.at ? a.at > b.at : a.opId > b.opId);
// слияние по полям: поле меняется, только если правка новее его отметки
const merge = (row, op, mark) => {
  const next = { ...row };
  for (const field of Object.keys(op.fields)) {
    const was = mark[field];
    if (was && !newer(op, was)) continue;
    next[field] = op.fields[field];
    mark[field] = { at: op.at, opId: op.opId };
  }
  return next;
};
// проверка строки ПОСЛЕ применения операции, а не полей самой операции
const strict = (row) => {
  const missing = ['exercise', 'weightKg', 'reps'].filter((field) => !(field in row));
  if (missing.length) return 'нет полей: ' + missing.join(', ');
  return Number(row.reps) > 100 ? 'повторов больше сотни' : null;
};
// выкат, смягчивший правило: до тысячи повторов теперь можно
const relaxed = (row) => {
  const reason = strict(row);
  return reason === 'повторов больше сотни' && Number(row.reps) <= 1000 ? null : reason;
};

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;
};

/*
 * Приём пачки. Порядок шагов — предмет опыта:
 *   'память' — вердикт вычислен в JS без записи, потом вставка памяти, и строка
 *              итога пишется, только если вставка вернула строку;
 *   'строка' — строка итога пишется до вставки памяти.
 */
async function push(client, user, batch, options = {}) {
  const { order = 'память', check = strict, afterLock, beforeCommit } = options;
  const verdicts = [];
  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]);
  if (afterLock) afterLock();

  const saved = async (opId) => {
    const { rows: [v] } = await client.query(
      'SELECT status, reason FROM sync_verdicts WHERE user_id = $1 AND op_id = $2', [user, opId]);
    return v.status === 'applied' ? { opId, status: 'duplicate' } : { opId, status: v.status, reason: v.reason };
  };

  for (const op of batch.slice(0, 1000)) {
    const { rows: [stored] } = await client.query(
      'SELECT fields, marks FROM sync_rows WHERE user_id = $1 AND row_id = $2', [user, op.rowId]);
    const before = stored?.marks ?? {};
    const marks = { ...before };
    const next = merge(stored?.fields, op, marks);
    let moved = JSON.stringify(marks) !== JSON.stringify(before);
    // у существующей строки ни одна отметка не сдвинулась — строка та же, что уже проверена
    const reason = stored && !moved ? null : check(next);
    if (reason) moved = false;
    const computed = reason ? { opId: op.opId, status: 'rejected', reason } : { opId: op.opId, status: 'applied' };

    const writeRow = async () => {
      if (!moved) return;
      const { rows: [head] } = await client.query(
        'UPDATE sync_heads SET seq = seq + 1 WHERE user_id = $1 RETURNING seq', [user]);
      await client.query(
        `INSERT INTO sync_rows (user_id, row_id, fields, marks, seq) VALUES ($1, $2, $3, $4, $5)
         ON CONFLICT (user_id, row_id) DO UPDATE SET fields = excluded.fields, marks = excluded.marks, seq = excluded.seq`,
        [user, op.rowId, next, marks, Number(head.seq)]);
    };
    const remember = () => client.query(
      `INSERT INTO sync_verdicts (user_id, op_id, status, reason) VALUES ($1, $2, $3, $4)
       ON CONFLICT DO NOTHING RETURNING status`,
      [user, op.opId, computed.status, computed.reason ?? null]);

    if (order === 'память') {
      const put = await remember();
      if (put.rowCount) { await writeRow(); verdicts.push(computed); } else verdicts.push(await saved(op.opId));
    } else {
      await writeRow();
      const put = await remember();
      verdicts.push(put.rowCount ? computed : await saved(op.opId));
    }
  }
  if (beforeCommit) await beforeCommit();
  await client.query('COMMIT');
  return verdicts;
}

const short = (list) => list.map((v) => v.status).join('/');
const withReason = (v) => v.status + (v.reason ? ' · ' + v.reason : '');
const sorted = (fields) => JSON.stringify(Object.fromEntries(Object.keys(fields).sort().map((k) => [k, fields[k]])));
const serverRows = async (user) =>
  (await reader.query('SELECT row_id, fields, seq FROM sync_rows WHERE user_id = $1 ORDER BY seq', [user])).rows
    .map((row) => `${row.row_id}#${row.seq} ${sorted(row.fields)}`)
    .join(' ') || '[]';
const head = async (user) =>
  (await reader.query('SELECT seq FROM sync_heads WHERE user_id = $1', [user])).rows[0]?.seq ?? 'строки нет';
const remembered = async (user, opId) =>
  (await reader.query('SELECT status FROM sync_verdicts WHERE user_id = $1 AND op_id = $2', [user, opId])).rows[0].status;

// Сцена 1. Две копии одной пачки одновременно: ответ на первую потерялся, и
// телефон отправил её заново, не дождавшись. Строку-счётчик завела регистрация
await reader.query(`INSERT INTO sync_heads (user_id) VALUES ('u1')`);
const batch = [
  { opId: 'A:1', rowId: 'A-1', fields: { exercise: 'жим', weightKg: 80, reps: 5 }, at: 1 },
  { opId: 'A:2', rowId: 'A-1', fields: { reps: 800 }, at: 2 },
  { opId: 'A:3', rowId: 'A-1', fields: { weightKg: 85 }, at: 3 },
];
let waited = false;
let letWebIn = () => {};
const webTurn = new Promise((resolve) => { letWebIn = resolve; });
// вторая копия отправляется следом за первой, а не наперегонки с ней: порядок
// двух отправок — предмет урока, и жребию его отдавать нельзя
const first = push(phone, 'u1', batch, {
  afterLock: () => letWebIn(),
  beforeCommit: async () => { waited = await waitsForLock(pids.web); },
});
const second = webTurn.then(() => push(web, 'u1', batch));
const [one, two] = await Promise.all([first, second]);
console.log('\n-- две копии пачки [A:1 вставка, A:2 опечатка, A:3 правка веса] одновременно');
console.log('  первая копия:', short(one), '| вторая копия:', short(two));
console.log('  причина отказа:', one[1].reason, '| та же у второй копии:', two[1].reason === one[1].reason);
console.log('  вторая ждала первую:', waited);
console.log('  номер счётчика:', await head('u1'), '| строки сервера:', await serverRows('u1'));

// Сцена 2. Отвергнутая операция, ответ на которую потерялся, а правило проверки
// успели смягчить выкатом. Сцена проходит дважды: порядком «вычислить → память →
// строка» и обратным — «вычислить → строка → память»
const late = { opId: 'B:1', rowId: 'B-1', fields: { exercise: 'присед', weightKg: 100, reps: 150 }, at: 1 };
for (const [user, order] of [['u2', 'память'], ['u3', 'строка']]) {
  const before = await push(phone, user, [late], { order, check: strict });
  const after = await push(phone, user, [late], { order, check: relaxed });
  console.log(`\n-- порядок «${order === 'память' ? 'вычислить → память → строка' : 'вычислить → строка → память'}»`);
  console.log('  при строгом правиле:', withReason(before[0]));
  console.log('  повтор после смягчения:', withReason(after[0]), '| в памяти:', await remembered(user, 'B:1'));
  console.log('  номер счётчика:', await head(user), '| строки сервера:', await serverRows(user));
}

await Promise.all([phone.end(), web.end(), reader.end()]);
node concurrent.mjs

Первая сцена вышла скучной, и это лучшая новость статьи: первая копия получила applied/rejected/applied, вторая — duplicate/rejected/duplicate с той же причиной, номер счётчика два, строка одна. Вторая копия при этом честно дождалась первой — строку-счётчик держала транзакция, и это видно по самой базе. Ни дублей, ни потерянных правок, ни взаимных блокировок.

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

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

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

Выбран не он по двум причинам, и обе не про корректность. Первая: вставка с ON CONFLICT DO NOTHING RETURNING — это буквально тот листинг, который часть V уже показала читателю, и повторять его в другом виде значило бы объяснять одно дважды. Вторая: «ноль строк» — единственный сигнал, который не зависит от того, один ли у базы писатель. Порядок с предварительным чтением опирается на замок счётчика; уберите замок — и он сломается, а форма с ON CONFLICT продолжит работать.

Сначала запомнить ответ, потом менять данные. Обратный порядок работает ровно до того выката, который поменяет правило проверки.

Форма ответа не меняется с части V: PushInput { batch } на входе, PushOutput { verdicts } на выходе, и каждый вердикт — про свою операцию. Код ошибки HTTP при этом остаётся про запрос целиком: нет токена, тело не разобралось, тело больше предела. Почему отказ по одной операции не 4xx, часть V показала прогоном: строгий сервер запирает две нормальные записи третьей, и очередь не проходит никогда.

Одну строку той таблицы вердиктов пора уточнить. Там сказано, что applied значит «операция легла в журнал сервера и получила номер», — и первое верно, а второе не всегда. Операция, все поля которой проиграли более новым отметкам, принята и никакого номера не получает: в строке от неё ничего не изменилось, и ни одному устройству эта операция не нужна. Для телефона разницы нет — он убирает из очереди и applied, и duplicate, — но для сервера разница есть, и именно она не даёт номерам расти на пустом месте.

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

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

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

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

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

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

Успех с пустым списком вердиктов клиент читает как приглашение зайти снова. Если разбирать нечего — это ошибка запроса, а не успех.

В первом прогоне статьи есть третья сцена, к памяти вердиктов отношения не имеющая, но объясняющая, почему вердикт считается в JavaScript, а не запросом. Правило порядка правок из части V — полное: сначала время at, а при равном времени имя операции. Имя — обычная строка, и сравнивают строки не все одинаково.

Прогон печатает четыре ответа на один вопрос: «B:1» меньше «a:1» или нет? В JavaScript — да, строки сравниваются по кодам знаков, и заглавная «B» идёт раньше строчной «a». В базе, поднятой с правилом сравнения C, — тоже да, по той же причине. С правилом en_US.UTF-8 — нет: обычные локали сравнивают буквы по алфавиту, а регистр учитывают в последнюю очередь. Один и тот же порядок, посчитанный двумя местами, выходит разным.

Отсюда правило: сравнение отметок живёт в JavaScript целиком. Сервер читает строку и её отметки, накладывает операцию тем же кодом, что телефон, и решает всё сам; база при этом отбирает строки и сортирует их по номеру — а номер целое число, и правило сравнения строк к нему не относится. Соблазн переложить слияние в SQL (jsonb, case, «взять самую новую отметку одним запросом») стоит пережить: тогда правило окажется зависящим от локали кластера, и сервер, поднятый в другой стране, начнёт расходиться с телефоном на операциях с одинаковым временем.

Четвёртая сцена того же прогона — про at. В отметке он лежит числом внутри jsonb, и оттуда приезжает числом: jsonb у драйвера разбирается как JSON, а в JSON число — это число. Но если тому же at отвести отдельную колонку bigint, приедет он строкой — так же, как номер в прошлой статье. И сломается на этом не равенство, а порядок: правило сравнит «10» с «9», получит ложь, и правка, сделанная позже, проиграет более ранней. Поэтому at и держат в отметках, а если он всё-таки оказался колонкой — приводят к числу на границе чтения, один раз.

Второе, что стоит сказать про границу: fields у операции — не «любой объект, приехавший от клиента». Пачка приходит с провода, и разбирает её тот же валидатор на границе каркаса, что и остальные входы: имя операции — строка, at — число, поля — известного вида. Всё, что описано в схеме, доезжает до приёма; всё, что не описано, не доезжает вовсе. Сервер, который получает at строкой от доверчивого разбора, ломается ровно так же, как на колонке bigint, — только искать причину придётся дольше.

Если сравнение решает, чья правка новее, сравнивать надо там, где правило сравнения выбрали вы, а не установщик кластера.

  • Память вердиктов — таблица sync_verdicts с первичным ключом (user_id, op_id): имя операции придумывает клиент, поэтому ключом оно работает только в паре с владельцем. Повтор принятой операции получает duplicate, повтор отвергнутой — тот же rejected с той же причиной.
  • Память не чистится ни от applied, ни от rejected. Цена — порядка одного-двух мегабайт на активного пользователя в год; выгода — буквальное «тот же вердикт по тому же opId сколько угодно раз», в том числе через год.
  • Обычная вставка с ловлей 23505 внутри пачки не годится: после ошибки Postgres отбрасывает команды до конца блока (25P02), а COMMIT отвечает ROLLBACK. Форма одна — INSERT … ON CONFLICT DO NOTHING RETURNING, и ноль возвращённых строк значит «уже было».
  • Порядок приёма операции: вычислить вердикт в JavaScript без единой записи, вставить память, и только если вставка вернула строку — писать строку итога. Номер получает лишь операция, сдвинувшая хоть одну отметку. Точек сохранения нет: до вставки памяти откатывать нечего.
  • Обратный порядок — сначала строка, потом память — проходит все тесты, пока правило проверки стоит на месте. После выката, смягчившего правило, у сервера остаётся строка отвергнутой операции, которой нет у телефона, — и второе устройство того же человека забирает её с курсора как чужую правку.
  • Отметки сравниваются в JavaScript, и это не случайность: у строк базы своё правило сравнения, и «B:1» меньше «a:1» в обычной локали — ложь. at из jsonb приезжает числом, из колонки bigint — строкой, и ломается на этом порядок правок, а не равенство.

1/4Пачка — одна транзакция, а память вердиктов сервер пишет обычной вставкой и ловит 23505. Первая операция пачки — повтор. Что станет с остальными?

2/4Почему первичный ключ памяти вердиктов — пара (user_id, op_id), а не один op_id?

3/4Вставка вердикта с ON CONFLICT DO NOTHING RETURNING вернула ноль строк. Что делает сервер?

4/4Операция принята, но ни одно поле строки не изменилось: все её поля проиграли более новым отметкам. Что получает клиент и что делает сервер?

Вердикт, который достался не тому

Память вердиктов на своей таблице, но ключ у неё — имя операции. Два человека, и телефоны обоих прислали операцию с одним и тем же именем. Почините память, не меняя ни печать в конце, ни сам приём «вставить и поймать 23505».

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

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

Лечение — ключ из двух частей, владелец и имя операции:

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

Теперь оба получают applied, а в памяти две строки. Склейка ключа в строку — особенность модели: у её индекса одна колонка. В настоящей таблице ключ так и объявляется парой — primary key (user_id, op_id), — и склеивать ничего не нужно.

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

Номер, выданный ни за что

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

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

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

Сдвиг отметок видно прямо в merge: в mark он пишет только те поля, которые победили. Значит, достаточно сравнить отметки до и после:

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

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

Пачка, которую сервер не берёт

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

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

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

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

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

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

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