Files
progcode/web/scripts/upgrade-2021-01.mjs
T
huncode 323d80b188
Build and deploy / deploy (push) Successful in 14s
revise January 2021 transaction articles
2026-07-31 12:13:15 +03:00

554 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
const source = Array.isArray(lines) ? lines.join('\n') : String(lines);
return '<pre><code>' + escapeHtml(source.trim()) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + escapeHtml(caption) + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(
content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''),
);
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) {
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
}
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
const length = bodyText(contentHtml).length;
if (length < 5000 || length > 15000) {
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + length);
}
return {
...meta,
contentHtml,
};
}
const postgres131Release = {
title: 'PostgreSQL 13.1 released, 12 November 2020',
url: 'https://www.postgresql.org/about/news/postgresql-131-125-1110-1015-9620-and-9524-released-2111/',
note: 'к январю 2021 ветка 13 уже имела минорный релиз 13.1; статьи фиксируют именно документацию PostgreSQL 13, а не современное поведение другой версии',
};
const transactionIsolation = {
title: 'PostgreSQL 13: Transaction Isolation',
url: 'https://www.postgresql.org/docs/13/transaction-iso.html',
note: 'описывает Read Committed как default, snapshot-границы, PostgreSQL Repeatable Read, Serializable и необходимость повторять отменённую транзакцию',
};
const explicitLocking = {
title: 'PostgreSQL 13: Explicit Locking',
url: 'https://www.postgresql.org/docs/13/explicit-locking.html',
note: 'описывает row-level lock modes, конфликтующие операции, освобождение lock при завершении transaction и риск deadlock при разном порядке',
};
const applicationConsistency = {
title: 'PostgreSQL 13: Data Consistency Checks at the Application Level',
url: 'https://www.postgresql.org/docs/13/applevel-consistency.html',
note: 'разделяет Serializable-подход и explicit blocking locks; отдельно предупреждает, что SELECT FOR UPDATE не сохраняет строку после окончания transaction сам по себе',
};
const setTransaction = {
title: 'PostgreSQL 13: SET TRANSACTION',
url: 'https://www.postgresql.org/docs/13/sql-set-transaction.html',
note: 'задаёт синтаксис isolation level и ограничение: уровень нельзя менять после первого query или data-modification statement текущей transaction',
};
const constraints = {
title: 'PostgreSQL 13: Constraints',
url: 'https://www.postgresql.org/docs/13/ddl-constraints.html',
note: 'объясняет, что CHECK не предназначен для постоянного контроля других строк таблицы; cross-row правило требует иной формулировки и защиты',
};
const pgLocks = {
title: 'PostgreSQL 13: pg_locks',
url: 'https://www.postgresql.org/docs/13/view-pg-locks.html',
note: 'описывает системное представление активных lockable objects, requested modes и процессов; его вывод нужно читать вместе с конкретным statement и проверяемым protocol',
};
const trainingNotice = 'Все имена дежурных, schedule, версии, результаты и SQL-фрагменты ниже учебные. Revision-модуль работает только в памяти: он не открывает PostgreSQL, не выполняет SQL, не создаёт настоящее lock, не измеряет wait и не описывает реальную нагрузку.';
function copyDuty(state) {
return { anna: state.anna, boris: state.boris, version: state.version };
}
function countOnDuty(state) {
return Number(state.anna === 'on') + Number(state.boris === 'on');
}
function canLeaveDuty(snapshot) {
return countOnDuty(snapshot) >= 2;
}
function invariantHolds(state) {
return countOnDuty(state) >= 1;
}
function appendEvent(events, step, transaction, detail) {
events.push({ step, transaction, detail });
}
/**
* Детерминированный учебный scheduler. Он показывает shape конфликта,
* но не моделирует MVCC, SQL planner, SSI или PostgreSQL lock manager.
*/
export function runTransactionFixture() {
const initial = { anna: 'on', boris: 'on', version: 0 };
const naiveState = copyDuty(initial);
const t1Snapshot = copyDuty(initial);
const t2Snapshot = copyDuty(initial);
const naiveEvents = [];
appendEvent(naiveEvents, 1, 'T1', 'read activeCount=2 and plan anna=off');
appendEvent(naiveEvents, 2, 'T2', 'read activeCount=2 and plan boris=off');
naiveState.anna = 'off';
naiveState.version += 1;
appendEvent(naiveEvents, 3, 'T1', 'commit planned write anna=off');
naiveState.boris = 'off';
naiveState.version += 1;
appendEvent(naiveEvents, 4, 'T2', 'commit planned write boris=off');
const lockOrder = ['anna', 'boris'];
const lockedState = copyDuty(initial);
const lockedEvents = [];
appendEvent(lockedEvents, 1, 'T1', 'acquire teaching boundary [anna, boris]');
const t2BlockedBeforeT1Finishes = true;
appendEvent(lockedEvents, 2, 'T2', 'request [anna, boris] and wait in teaching schedule');
const lockedT1Snapshot = copyDuty(lockedState);
const t1CanLeave = canLeaveDuty(lockedT1Snapshot);
if (t1CanLeave) {
lockedState.anna = 'off';
lockedState.version += 1;
}
appendEvent(lockedEvents, 3, 'T1', 'read 2, write anna=off, release boundary');
const lockedT2Snapshot = copyDuty(lockedState);
const t2CanLeaveAfterWake = canLeaveDuty(lockedT2Snapshot);
appendEvent(lockedEvents, 4, 'T2', t2CanLeaveAfterWake ? 'unexpected write' : 'read 1, reject request, write nothing');
const retryState = copyDuty(initial);
const retryEvents = [];
const retryT1Snapshot = copyDuty(retryState);
const retryT2Snapshot = copyDuty(retryState);
const retryT2ReadVersion = retryT2Snapshot.version;
const retryT1CanLeave = canLeaveDuty(retryT1Snapshot);
if (retryT1CanLeave) {
retryState.anna = 'off';
retryState.version += 1;
}
appendEvent(retryEvents, 1, 'T1', 'commit anna=off at model version 1');
const retryDetectedConflict = retryT2ReadVersion !== retryState.version;
appendEvent(retryEvents, 2, 'T2', 'detect stale model version and restart whole teaching operation');
const retryT2SnapshotAfterRestart = copyDuty(retryState);
const retryT2CanLeave = canLeaveDuty(retryT2SnapshotAfterRestart);
appendEvent(retryEvents, 3, 'T2', retryT2CanLeave ? 'unexpected write' : 'restart sees 1 and rejects request');
const checks = {
bothOperationsReadSameInitialState: countOnDuty(t1Snapshot) === 2 && countOnDuty(t2Snapshot) === 2,
bothNaiveDecisionsPassTheirOwnPrecondition: canLeaveDuty(t1Snapshot) && canLeaveDuty(t2Snapshot),
naiveOperationsBothCommitDifferentRows: naiveState.anna === 'off' && naiveState.boris === 'off',
naiveScheduleViolatesInvariant: invariantHolds(naiveState) === false,
teachingBoundaryUsesOneFixedOrder: lockOrder.join(',') === 'anna,boris',
secondOperationIsBlockedInTeachingSchedule: t2BlockedBeforeT1Finishes,
secondOperationSeesStateAfterFirstCommit: countOnDuty(lockedT2Snapshot) === 1,
secondOperationRejectsAfterWake: t2CanLeaveAfterWake === false,
teachingBoundaryPreservesInvariant: invariantHolds(lockedState),
restartModelDetectsVersionConflict: retryDetectedConflict,
restartModelRetriesWholeOperationAndRejects: retryT2CanLeave === false,
restartModelPreservesInvariant: invariantHolds(retryState),
};
return {
fixture: 'Deterministic in-memory teaching model. It is not a PostgreSQL execution, lock trace, SQL engine simulator, latency measurement, or production contention test.',
initial,
naive: { events: naiveEvents, finalState: naiveState, activeCount: countOnDuty(naiveState) },
boundary: { events: lockedEvents, lockOrder, finalState: lockedState, activeCount: countOnDuty(lockedState) },
retry: { events: retryEvents, finalState: retryState, activeCount: countOnDuty(retryState) },
checks,
};
}
export function verifyFixture() {
const fixture = runTransactionFixture();
if (!Object.values(fixture.checks).every(Boolean)) {
throw new Error('transaction teaching fixture did not preserve its documented contract');
}
return fixture;
}
const unsafeReadWriteExample = [
'-- Учебный anti-pattern: два session могут пройти условие до commits друг друга.',
'BEGIN;',
'SELECT count(*) AS active_count',
'FROM on_call',
"WHERE shift = 'night' AND enabled = true;",
'-- Если результат = 2, приложение отдельно решает снять себя с дежурства.',
"UPDATE on_call SET enabled = false WHERE shift = 'night' AND doctor = :current_doctor;",
'COMMIT;',
'',
'-- Две разные UPDATE-строки не доказывают правило active_count >= 1.',
];
const rowBoundaryExample = [
'-- Учебный protocol для fixed row set; команды этой статьёй не запускались.',
'BEGIN;',
'SELECT doctor, enabled',
'FROM on_call',
"WHERE shift = 'night'",
'ORDER BY doctor',
'FOR UPDATE;',
'-- Проверяем invariant по возвращённому набору в этой же transaction.',
"UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor;",
'COMMIT;',
'',
'-- Все writers этого правила должны брать тот же scope и тот же order.',
];
const serializableExample = [
'BEGIN;',
'SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;',
'-- После этого идут reads, проверка правила и write одной business operation.',
'SELECT doctor, enabled FROM on_call WHERE shift = :shift;',
'UPDATE on_call SET enabled = false WHERE doctor = :current_doctor;',
'COMMIT;',
'',
'-- При SQLSTATE 40001 повторяют весь BEGIN..COMMIT, а не только UPDATE.',
];
const fixtureExample = [
'# Только in-memory schedule из revision-модуля; PostgreSQL не запускается.',
'node scripts/upgrade-2021-01.mjs --verify-fixture',
'',
'# Ожидаемые свойства:',
'# naiveScheduleViolatesInvariant: true',
'# secondOperationIsBlockedInTeachingSchedule: true',
'# teachingBoundaryPreservesInvariant: true',
'# restartModelRetriesWholeOperationAndRejects: true',
];
const lockInspectionExample = [
'-- Команды для отдельного разрешённого стенда; в этой работе не выполнялись.',
'SHOW transaction_isolation;',
'SELECT locktype, mode, granted',
'FROM pg_locks',
'WHERE pid = pg_backend_pid()',
'ORDER BY locktype, mode;',
'',
'-- Результат надо сверять с конкретным SQL, версией и планом проверки.',
];
const practiceArticle = createRevision(
{
slug: 'editorial-2021-01-practice-transactions',
title: 'Границы транзакции PostgreSQL: как не оставить смену без дежурного',
categories: ['PostgreSQL', 'Данные', 'Практика'],
cover: '/assets/editorial/2021/transaction-timeline-2021.svg',
excerpt: 'Фиксируем cross-row инвариант, разбираем два конкурентных запроса и выбираем между явной границей строк и полным retry без выдуманных claims о реальной нагрузке.',
readingMinutes: 15,
},
[
paragraph('Проблема начинается не с команды <code>BEGIN</code>, а с правила, которое лежит между строками. Пусть в ночной смене два дежурных. Каждый может снять только себя, но после любой подтверждённой операции должен остаться хотя бы один активный. Два запроса читают одно состояние <code>activeCount = 2</code>, каждый считает действие допустимым и меняет разные строки. Если оба успевают сохранить решение, смена остаётся без дежурного. Цена ошибки конкретна: интерфейс покажет два успешных ответа, а правило, на котором держится процесс, уже ложно.'),
paragraph('Ниже не разбор настоящего инцидента и не обещание, что одна SQL-конструкция лечит любую конкуренцию. Я соберу фиксированную учебную модель из двух transaction-like операций: T1 выключает Анну, T2 выключает Бориса. Сначала они проходят небезопасное расписание. Затем T2 ждёт общую границу строк, читает состояние после T1 и отказывается от записи. В конце есть вариант с учебным retry. Он нужен, чтобы отличить инвариант, scope и результат проверки от названия isolation level.'),
heading('Историческая рамка: январь 2021 и PostgreSQL 13'),
paragraph('Для января 2021 беру документацию PostgreSQL 13. Major release 13 вышел 24 сентября 2020 года, а 13.1 — 12 ноября 2020 года. Это важно не ради даты в подвале. Поведение изоляции и формулировки о row-level locks следует проверять по версии, с которой работает приложение. В PostgreSQL 13 <code>Read Committed</code> — default; <code>Read Uncommitted</code> ведёт себя как <code>Read Committed</code>; <code>Repeatable Read</code> и <code>Serializable</code> дают более стабильную картину, но для конфликтов могут потребовать retry.'),
paragraph('Слово «транзакция» здесь означает границу для чтений, проверки и записей, которые вместе поддерживают один инвариант. Оно не означает, что надо обернуть в один блок HTTP-вызов, ожидание пользователя или отправку письма. Чем шире такой блок, тем дольше удерживаются ресурсы и тем труднее объяснить конфликт. Сначала выписываем данные, которые участвуют в правиле. Потом решаем, как одна операция увидит и изменит их как единое действие. Только после этого выбираем SQL и isolation level.'),
heading('Сначала формулируем invariant и его scope'),
paragraph('В нашем упражнении invariant звучит так: <code>count(enabled rows for one shift) &gt;= 1</code>. Он не принадлежит одной строке Анны и не помещается в поле <code>enabled</code>. Поэтому проверка <code>CHECK (enabled)</code> не решает задачу. Документация PostgreSQL 13 прямо предупреждает: <code>CHECK</code> не должен постоянно ссылаться на другие строки таблицы. Для ограничений между строками иногда подходит <code>UNIQUE</code>, <code>EXCLUDE</code> или <code>FOREIGN KEY</code>; если правило не выражается ими, его нужно защищать согласованной transaction strategy.'),
dataTable(
'Контракт учебной операции «снять себя с дежурства»',
['Часть контракта', 'Фиксированное значение', 'Почему это входит в границу', 'Как проверяем'],
[
['Инвариант', '<code>activeCount &gt;= 1</code>', 'решение затрагивает весь набор дежурных смены', 'после commit считаем только enabled rows нужной смены'],
['Scope', 'Анна и Борис одной смены', 'одна строка не доказывает состояние второй', 'predicate <code>shift = :shift</code> записан рядом с проверкой'],
['Write', 'выключить только текущего дежурного', 'операция не должна менять чужую строку', 'target ID совпадает с авторизованным участником сценария'],
['Конфликт', 'два решения от snapshot с count = 2', 'writes различаются, но выводы несовместимы', 'фиксированный schedule fixture показывает оба commit'],
['Ожидаемый итог', 'либо один off, либо отказ/retry', 'два off запрещены самим правилом', 'fixture возвращает <code>activeCount</code> и assertions'],
],
),
figure(
'/assets/editorial/2021/transaction-timeline-2021.svg',
'Вертикальная шкала учебной модели с двумя дежурными: оба запроса читают activeCount равный двум и выключают разные строки, после чего инвариант нарушается; ниже показана общая граница строк, где вторая операция ждёт, читает единицу и отказывается от записи.',
'Сверху показан небезопасный schedule, снизу — тот же инвариант с фиксированной общей границей. Это схема in-memory модели, а не результат запуска PostgreSQL.',
),
heading('Почему read → решение → write без связи не годится'),
paragraph('Небезопасный фрагмент ниже выглядит разумно, если смотреть на один запрос. Он читает количество, затем меняет одну строку. Ошибка проявляется между запросами. T1 и T2 могут увидеть один и тот же committed набор до изменения другого. Обе операции сохранят разные строки, поэтому простой конфликт записи «одна строка против одной строки» не обязан их остановить. Это не lost update одной колонки; это write skew — несовместимые решения на общем predicate.'),
codeBlock(unsafeReadWriteExample),
paragraph('Дополнительный <code>SELECT count(*)</code> перед <code>COMMIT</code> не исправляет конфликт сам по себе: это ещё один statement со своей видимостью. Проверка нужна внутри границы, где operation ещё можно отменить, а все writers инварианта соблюдают один protocol.'),
heading('Явная граница строк: что она обещает и что не обещает'),
paragraph('Для маленького и известного набора строк можно взять их в одном порядке через <code>SELECT ... FOR UPDATE</code>, посчитать состояние и выполнить изменение в той же transaction. В PostgreSQL 13 <code>FOR UPDATE</code> блокирует другие <code>UPDATE</code>, <code>DELETE</code> и lock-запросы на возвращённых строках до завершения текущей transaction. Обычный <code>SELECT</code> этот row-level lock не блокирует. Поэтому читателю нужно видеть ровно тот predicate, который возвращает scope, а не красивое слово «пессимистичная блокировка».'),
codeBlock(rowBoundaryExample),
paragraph('У этого приёма есть две жёсткие предпосылки. Первая: все операции, которые могут изменить это правило, берут тот же набор строк. Если другой путь изменит Бориса без этой границы, доказательство разорвётся. Вторая: несколько объектов берутся в одном стабильном порядке. PostgreSQL 13 предупреждает, что разный порядок блокировок может привести к deadlock; сервер отменит одну transaction, но это не заменяет договор о порядке. В учебном SQL порядок задаёт <code>ORDER BY doctor</code>; в приложении он должен быть частью протокола, а не привычкой одного метода.'),
paragraph('Даже корректный <code>SELECT FOR UPDATE</code> не означает «строка навсегда защищена». PostgreSQL отдельно отмечает: после commit или rollback ожидающая конфликтная transaction продолжит работу, если удерживавшая операция не сделала фактического <code>UPDATE</code> строки. Поэтому реальное решение надо проверять по своему write path. Здесь мы не делаем такой запуск; мы только формулируем, какую гарантию следует подтвердить на разрешённом стенде.'),
heading('Фикстура: один schedule, три честных результата'),
paragraph('Revision-модуль не пытается сыграть PostgreSQL. У него нет SQL parser, MVCC, lock manager, драйвера или сети. Он хранит два флага в объекте памяти и выполняет заранее записанные шаги. В наивной ветке оба snapshot содержат два активных дежурных; затем T1 и T2 выключают разные значения, и <code>activeCount</code> становится нулём. Во второй ветке scheduler помечает T2 ожидающим до завершения T1. После wake T2 читает уже единицу и возвращает отказ без write.'),
codeBlock(fixtureExample),
paragraph('Третья ветка fixture показывает другой shape: T2 запомнил version учебного состояния, T1 успел изменить её, а T2 обнаружил несовпадение и повторяет всю operation от нового snapshot. После restart precondition становится ложной. Это не реализация PostgreSQL Serializable Snapshot Isolation и не утверждение о том, какой именно SQLSTATE вернёт сервер. Модель проверяет только дисциплину: конфликт не лечат повтором одного <code>UPDATE</code>, а заново получают данные, снова проверяют invariant и затем либо пишут, либо отказываются.'),
dataTable(
'Как читать assertions fixture',
['Assertion', 'Что модель подтверждает', 'Чего модель не подтверждает', 'Следующий реальный тест'],
[
['<code>naiveScheduleViolatesInvariant</code>', 'два независимых решения могут оставить ноль active', 'конкретный план PostgreSQL или wait', 'воспроизвести два session на отдельной БД'],
['<code>secondOperationIsBlockedInTeachingSchedule</code>', 'chosen schedule удерживает T2 до T1', 'настоящий row lock и его duration', 'проверить lock conflict выбранного SQL'],
['<code>secondOperationRejectsAfterWake</code>', 'после T1 precondition становится false', 'все альтернативные writers приложения', 'найти и покрыть каждый write path'],
['<code>restartModelRetriesWholeOperationAndRejects</code>', 'retry начинается с нового snapshot модели', 'обработку SQLSTATE драйвером', 'добавить integration test и policy retry'],
],
),
heading('Нумерованный маршрут для одной операции'),
orderedList([
'Запишите правило одним проверяемым предложением: что должно быть истинно после успешного commit. Для примера — «в смене остаётся минимум один enabled дежурный».',
'Выпишите все rows и predicate, от которых зависит правило. Не подменяйте их названием таблицы или одним target ID.',
'Найдите каждый write path, который может изменить эти rows. Если paths используют разные протоколы, сначала выравнивайте protocol, а не isolation level.',
'Выберите небольшой механизм: атомарный statement, constraints, Serializable с full retry или явную блокировку возвращённых rows. Зафиксируйте, почему он покрывает именно этот invariant.',
'Поставьте read, проверку и write внутри одной короткой transaction. Не держите её открытой во время HTTP-вызова, ожидания пользователя или тяжёлой фоновой работы.',
'Запустите учебную fixture, затем отдельно подготовьте integration test на разрешённой PostgreSQL версии с двумя sessions и фиксированным schedule.',
'Зафиксируйте результат: какой conflict ожидается, где выполняется rollback/retry и какие внешние действия нельзя публиковать до успешного commit.',
]),
heading('Граница заканчивается там, где заканчивается состояние БД'),
paragraph('Транзакция PostgreSQL удерживает согласованность данных, которыми она владеет. Она не отменяет уже отправленное письмо, не забирает сообщение из внешней очереди и не делает обратимым ответ другого HTTP-сервиса. Если такое side effect произошло до commit, retry способен повторить не только SQL, но и внешний результат. В январе 2021 для этого материала достаточно назвать границу: сначала формируем решение в БД, затем отдельным проектным механизмом публикуем внешний эффект. Не надо объявлять здесь готовую распределённую платформу.'),
paragraph('Итог практики короткий. Не выбирайте «самый сильный» isolation level наугад. Назовите invariant, его rows, все writers и момент, когда решение становится необратимым. Для фиксированного набора строк явная блокировка может дать ясный protocol. Для сложных read/write зависимостей Serializable может быть удобнее, но тогда полный retry является частью контракта. После этого уже есть что проверять двумя сессиями, а не только что объяснять в код-ревью.'),
paragraph(trainingNotice),
],
[postgres131Release, transactionIsolation, explicitLocking, applicationConsistency, constraints],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2021-01-mechanism-transactions',
title: 'Механика транзакции PostgreSQL 13: snapshot, блокировка и полный retry',
categories: ['PostgreSQL', 'Конкурентность', 'Архитектура'],
cover: '/assets/editorial/2021/transaction-isolation-matrix-2021.svg',
excerpt: 'Разбираем, когда PostgreSQL 13 создаёт snapshot, что реально защищает SELECT FOR UPDATE и почему Serializable не является рейтингом качества, а требует обработать отменённую transaction.',
readingMinutes: 16,
},
[
paragraph('Механическая ошибка часто выглядит как «в транзакции же всё было». Разработчик открывает <code>BEGIN</code>, выполняет два <code>SELECT</code>, а потом один <code>UPDATE</code>. Другой запрос делает то же самое. В логах нет грязного чтения, поэтому решение кажется безопасным. Но оба запроса могли увидеть допустимое состояние в разные моменты и записать несовместимый общий итог. Цена — скрытое расхождение между тем, что проверяло приложение, и тем, что реально стало committed. Такая ошибка не лечится названием метода <code>transactional()</code>.'),
paragraph('Разберём механизм на PostgreSQL 13, который был актуален в январе 2021. Здесь важны три разных предмета: snapshot определяет, какие committed rows видит statement или transaction; row-level lock ограничивает конфликтующие writers и lockers на конкретных возвращённых rows; Serializable отслеживает опасные read/write зависимости и может отменить одну transaction. Они пересекаются, но не равны. Когда эти слова смешивают, появляются либо лишние lock на таблицу, либо retry, который повторяет только последнюю команду и ломает исходный смысл операции.'),
heading('Три уровня PostgreSQL 13 и их реальные границы'),
paragraph('В PostgreSQL 13 по умолчанию работает <code>Read Committed</code>. Обычный <code>SELECT</code> видит rows, которые были committed до начала самого statement; два следующих <code>SELECT</code> в одной transaction могут увидеть разные committed состояния. <code>Repeatable Read</code> фиксирует snapshot при первом query или data-modification statement и даёт стабильную картину transaction. <code>Serializable</code> строится на этой же основе, но не позволяет успешно commit набору transaction, для которого нельзя подобрать последовательный порядок выполнения. За это приходится быть готовым к rollback и повтору.'),
figure(
'/assets/editorial/2021/transaction-isolation-matrix-2021.svg',
'Вертикальная матрица PostgreSQL 13: Read Committed берёт snapshot на statement, Repeatable Read — после первого запроса транзакции, Serializable сохраняет только результаты, эквивалентные некоторому последовательному порядку; внизу отдельно показан SELECT FOR UPDATE для возвращённых строк.',
'Матрица не ранжирует уровни. Она показывает, какой факт каждый из них даёт и какое действие добавляет разработчику: protocol блокировок или full retry.',
),
dataTable(
'Выбор средства по типу конкурентного правила',
['Средство PostgreSQL 13', 'Что оно даёт', 'Что остаётся на приложении', 'Когда остановиться и проверить'],
[
['<code>Read Committed</code>', 'новый committed snapshot для каждого statement; это default', 'одна граница для нескольких reads/writes и cross-row invariant', 'если решение зависит от нескольких statements или rows'],
['<code>Repeatable Read</code>', 'стабильный transaction snapshot; в PostgreSQL нет phantom reads на этом уровне', 'обработка serialization failure и защита business rule от anomaly', 'если stable read ещё не доказывает корректный общий результат'],
['<code>Serializable</code>', 'успешные concurrent commits эквивалентны некоторому serial order', 'полный retry на <code>40001</code>, контроль side effects и scope операции', 'если transaction нельзя безопасно повторить целиком'],
['<code>SELECT ... FOR UPDATE</code>', 'конфликтующие writers/lockers ждут на returned rows до конца transaction', 'полный row set, одинаковый order и discipline всех writers', 'если predicate шире выбранных rows или routes используют другой protocol'],
['Constraint', 'движок отвергает выражаемое нарушение данных', 'моделирование только того правила, которое constraint действительно описывает', 'если правило относится к другим rows, а CHECK его не поддерживает'],
],
),
paragraph('«Лучшего» уровня нет: одна row может требовать атомарный update, несколько rows — lock protocol или Serializable, выражаемое правило — constraint. Цена: wait, retry или изменение модели.'),
heading('Snapshot: момент, который нельзя увидеть по имени метода'),
paragraph('Пусть T1 и T2 начинают почти одновременно. В <code>Read Committed</code> T1 делает <code>SELECT count(*)</code> и видит два дежурных. Пока приложение готовит следующую команду, T2 меняет Бориса и commit. Второй <code>SELECT</code> T1 уже может увидеть одного. Это не dirty read и не ошибка PostgreSQL: новый statement получает новую картину committed данных. Если код складывает результаты нескольких queries, он обязан назвать, допустима ли такая смена картины. Если недопустима, рамка операции выбрана неверно.'),
paragraph('В <code>Repeatable Read</code> T1 сохраняет snapshot первого запроса. Это удобно для согласованного чтения, но не превращает любой cross-row check в serial execution. Документация PostgreSQL 13 говорит, что applications на этом уровне должны быть готовы к serialization failures; stable snapshot не обещает, что набор успешно committed operations можно объяснить по одному. Особенно опасно сделать вывод «я увидел всё правильно, значит могу безопасно записать». Сначала отделяем видимость от права менять общий invariant.'),
codeBlock([
'BEGIN;',
'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;',
'-- До первого SELECT/UPDATE задаём границу видимости текущей transaction.',
'SELECT doctor, enabled FROM on_call WHERE shift = :shift ORDER BY doctor;',
'-- Здесь ещё нет доказательства, что другой writer соблюдает тот же invariant.',
'COMMIT;',
'',
'-- SET TRANSACTION после первого query PostgreSQL 13 уже не примет.',
]),
paragraph('Isolation задают до первого query. После «безобидного» <code>SELECT</code> граница уже выбрана; это входной параметр operation, не настройка в середине.'),
heading('Row-level lock: scope важнее названия режима'),
paragraph('<code>SELECT ... FOR UPDATE</code> в PostgreSQL 13 ставит lock на rows, возвращённые запросом. Другой <code>UPDATE</code>, <code>DELETE</code> или lock-запрос на тот же row будет ждать до завершения удерживающей transaction. Но сервер не читает ваш бизнес-инвариант: он видит только rows, которые попали в result. Если invariant зависит от Анны и Бориса, а query зафиксировал лишь строку текущего врача, Борис остаётся вне доказательства. Если set строится через неустойчивый predicate, concurrent insert может дать новую ситуацию, которую исходный lock не описывал.'),
codeBlock([
'BEGIN;',
'SELECT doctor, enabled',
'FROM on_call',
"WHERE shift = :shift AND role = 'primary'",
'ORDER BY doctor',
'FOR UPDATE;',
'-- Считаем invariant только по тому set, который именно этот query определил.',
"UPDATE on_call SET enabled = false WHERE doctor = :current_doctor;",
'COMMIT;',
'',
'-- Это пример protocol, не команда, исполненная при подготовке статьи.',
]),
paragraph('Все writers инварианта должны брать один set в одном order. PostgreSQL обнаружит deadlock и отменит одну transaction, но это не замена protocol. Стабильный <code>ORDER BY</code> и короткая transaction нужно подтвердить реальным test.'),
paragraph('<code>SELECT FOR UPDATE</code> действует только до commit/rollback. После этого ожидающий writer продолжит работу, поэтому разбор обязан покрыть actual update, commit и paths, которые могут обойти выбранную границу.'),
heading('Serializable: сильная гарантия для успешного commit, но с ценой retry'),
paragraph('В PostgreSQL 13 <code>Serializable</code> обеспечивает: успешно committed concurrent transactions имеют эффект, эквивалентный некоторому запуску по одной. Это ровно то, что нужно для части сложных business rules, когда нельзя заранее назвать маленький set строк для lock. Но гарантия действует не как щит, через который всегда проходят оба запроса. При опасном read/write pattern PostgreSQL отменяет одну transaction с serialization failure. Код должен уметь вернуть работу к началу, взять новый snapshot, повторно выполнить checks и только после удачного commit считать данные действительными.'),
codeBlock(serializableExample),
paragraph('Full retry означает именно <code>BEGIN → reads → decision → writes → COMMIT</code> ещё раз. Повторять один <code>UPDATE</code> нельзя: он использует вывод, который был сделан на старой картине. Нельзя и бездумно retry-ить любой exception: deadlock, uniqueness error, network failure и нарушение validation имеют разные причины. Для Serializable PostgreSQL 13 документирует SQLSTATE <code>40001</code>; это полезный конкретный сигнал для policy retry. Сам policy — число попыток, пауза, логирование причины и поведение при исчерпании — зависит от операции и остаётся проектным решением.'),
heading('Учебная fixture связывает три понятия, не изображая движок'),
paragraph('В модуле один фиксированный object-state: Анна и Борис включены, version равен нулю. Наивный schedule даёт T1 и T2 одинаковый snapshot, обе writes проходят и invariant нарушается. Boundary schedule добавляет условную очередь: T2 ждёт, пока T1 закончит, затем видит новое состояние. Retry schedule использует version только как учебный маркер stale decision. Из этой модели нельзя вывести, какие locks создаст PostgreSQL, когда сервер выберет serialization failure или сколько продлится ожидание. Зато она не даёт тексту спрятать сам конфликт за общим словом «конкурентность».'),
codeBlock(fixtureExample),
paragraph('Красная fixture означает противоречие schedule; зелёная подтверждает только модель. Нужен integration test с двумя sessions, который эта партия не запускала.'),
heading('Нумерованный маршрут выбора механизма'),
orderedList([
'Назовите invariant после commit и различите его с валидацией одной строки. Если он cross-row, выпишите full predicate.',
'Определите, нужны ли стабильные reads внутри операции или достаточно одного атомарного statement. Не открывайте broad transaction без причины.',
'Сверьте версию PostgreSQL и момент задания isolation. <code>SET TRANSACTION</code> должен быть до первого query текущей transaction.',
'Если используете <code>FOR UPDATE</code>, зафиксируйте returned rows, sort order и все writers, которые обязаны идти тем же маршрутом.',
'Если выбираете Serializable, сделайте whole-operation retry только для подтверждённого serialization failure и отделите внешние side effects от retryable части.',
'Создайте deterministic fixture для объяснения invariant, затем integration test, который проверяет реальный SQL и версию сервера.',
'После проверки запишите ограничение рядом с кодом: какая transaction boundary покрыта и какой новый predicate потребует пересмотра protocol.',
]),
heading('Что не следует обещать этой схемой'),
paragraph('Нельзя сделать из этой статьи claim о latency, throughput или зрелой платформе данных. PostgreSQL 13 описывает гарантии и конфликты, но стоимость конкретного path зависит от indexes, plan, размера rows, времени удержания transaction и состава concurrent work. Нельзя также объявить <code>Repeatable Read</code> «почти Serializable» и забыть о serialization anomaly: документация отделяет эти режимы. Верный следующий шаг скромнее — проверить одну business operation с одним fixed schedule и сохранить результат как часть её контракта.'),
paragraph('Механика становится понятной, когда каждый термин привязан к наблюдаемому факту. Snapshot отвечает, что операция может видеть. Row-level lock отвечает, какие returned rows конфликтующие writers временно не меняют. Serializable отвечает, какие committed outcomes разрешены, и требует retry после отмены. Инвариант всё равно формулирует приложение. Если эти четыре слоя не смешивать, код-ревью обсуждает не вкусы к уровню изоляции, а конкретную границу и способ её проверить.'),
paragraph(trainingNotice),
],
[postgres131Release, transactionIsolation, explicitLocking, applicationConsistency, setTransaction, constraints],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2021-01-field-transactions',
title: 'Разбор конфликта транзакций: где заканчивается граница PostgreSQL',
categories: ['PostgreSQL', 'Отладка', 'Данные'],
cover: '/assets/editorial/2021/transaction-conflict-diagnosis-2021.svg',
excerpt: 'Учебный разбор двух несовместимых решений: как отличить неполный scope, ожидаемую блокировку, deadlock и serialization failure, не выдавая fixture за production-исследование.',
readingMinutes: 16,
},
[
paragraph('Симптом в поле звучит неприятно именно потому, что оба запроса вернули успех. Один дежурный выключил Анну, другой — Бориса. Каждая команда изменила «свою» строку, ошибок SQL не видно, но ночная смена осталась без активного человека. Цена здесь выше локальной ошибки формы: следующий процесс считает данные допустимыми и может принять решение на ложном состоянии. Если в разборе просто написать «поставим транзакцию», команда получит ещё один туман вместо ответа, какая строка, какой predicate и какой момент commit были потеряны.'),
paragraph('Ниже — controlled разбор, а не рассказ об измеренном production-инциденте. У нас фиксированы две строки, один invariant и один schedule. Это делает диагноз проверяемым: оба requests стартуют от двух active rows, T1 выключает Анну, T2 — Бориса. Затем мы смотрим четыре причины, которые в настоящем проекте нельзя смешивать: check оказался вне transaction, lock покрывает неполный set, locks взяты в разном order либо Serializable отменил operation. Для каждой причины есть отдельная проверка и безопасное следующее действие.'),
heading('Фиксируем evidence до первой правки'),
paragraph('Первый артефакт — минимальный contract: invariant, scope, order, write и outcome. Без predicate нельзя проверить, что <code>FOR UPDATE</code> вернул нужные rows; без момента side effect нельзя оценить безопасность retry.'),
dataTable(
'Evidence для учебного разбора конфликтующей операции',
['Артефакт', 'Содержимое в примере', 'Диагностический вопрос', 'Не является доказательством'],
[
['Invariant', '<code>activeCount &gt;= 1</code>', 'какой committed result запрещён', 'формы UI или имени endpoint'],
['Scope query', 'два primary rows для одной смены', 'какие rows участвуют в решении', 'того, что все другие writers используют same query'],
['Lock order', '<code>ORDER BY doctor</code>', 'в каком порядке операции просят несколько rows', 'того, что deadlock невозможен во всём приложении'],
['Retry contract', 'whole operation after confirmed failure', 'какие reads и decisions повторяются', 'безопасности уже отправленного внешнего эффекта'],
['Fixture schedule', 'T1 read, T2 read, T1 commit, T2 commit/wake/retry', 'какое взаимное положение шагов проверяем', 'настоящего wait, lock graph или latency сервера'],
],
),
paragraph('В разборе используется PostgreSQL 13 — актуальная ветка января 2021 после 13.1. Версия, driver и exact SQL path всё равно должны быть входами integration test; учебная схема не переносит гарантию на любое окружение.'),
heading('Диагностика начинается с формы конфликта'),
figure(
'/assets/editorial/2021/transaction-conflict-diagnosis-2021.svg',
'Вертикальная схема диагностики с четырьмя карточками: оба запроса успешно нарушили правило, SELECT FOR UPDATE покрывает неполный scope, операции берут объекты в разном порядке и Serializable возвращает SQLSTATE 40001; у каждой карточки указаны причина, проверка и действие.',
'Схема не пытается назвать виновника по одной ошибке. Она переводит симптом в проверяемый predicate, порядок и retry contract.',
),
paragraph('Первый shape — write skew: T1 и T2 пишут разные rows, но оба решили по <code>activeCount = 2</code>. Локально writes допустимы, вместе оставляют ноль. Повтор старого решения не помогает; нужно заново читать и проверять invariant.'),
paragraph('Второй shape — неполный scope. <code>SELECT FOR UPDATE</code> блокирует returned rows, а не сущности приложения. Сравните predicate invariant и locking query. Если sets различаются, сузьте правило до одной row или расширьте protocol.'),
paragraph('Третий shape — wait или deadlock. Ожидание одного row может быть штатным. Deadlock возникает, если T1 держит A и ждёт B, а T2 — наоборот. Зафиксируйте один acquisition order и retry-те только отменённую transaction.'),
paragraph('Четвёртый shape — <code>SQLSTATE 40001</code>. Serializable отверг dangerous read/write pattern. Убедитесь, что failure подтверждён и side effect не ушёл до <code>COMMIT</code>; затем повторите entire operation с новым snapshot.'),
heading('Две проверяемые SQL-формы, но ни одной выполненной команды'),
paragraph('Первый пример показывает protocol rows для известной смены. Он не утверждает, что этот exact query подходит любому schema. В одном проекте scope может быть ограничен role и region, в другом появятся временные интервалы или новые rows во время операции. SQL нужен здесь как предмет ревью: можно увидеть <code>WHERE</code>, <code>ORDER BY</code>, момент <code>FOR UPDATE</code> и write. Выполнять его стоит только на разрешённой контрольной БД, где есть две sessions и заранее согласованный cleanup.'),
codeBlock(rowBoundaryExample),
paragraph('Второй пример — минимальная диагностика контекста session. <code>SHOW transaction_isolation</code> отвечает, с каким уровнем вообще началась текущая transaction. <code>pg_locks</code> может показать locks, удерживаемые текущим backend, но сам по себе не объясняет business invariant и не строит историю всех sessions. Поэтому вывод из него всегда сопоставляют с exact SQL, временем schedule и ожидаемым lock mode. В этой партии команда не выполнялась и никаких реальных PID, locks или waits мы не заявляем.'),
codeBlock(lockInspectionExample),
heading('Фиксированный schedule позволяет проверить текст без сервера'),
paragraph('Fixture запускает три ветки. Первая намеренно плохая: T1 и T2 читают одно initial state и оба commit разные off-flags. Вторая вводит teaching boundary для rows <code>[anna, boris]</code> в одном fixed order. T2 отмечается как blocked до T1, затем читает уже одно enabled значение и отказывается от write. Третья ветка добавляет model version: T2 видит, что T1 изменил state после её read, делает full restart и снова получает precondition false. Assertions фиксируют результат каждой ветки.'),
codeBlock(fixtureExample),
dataTable(
'Симптом → причина → проверка → действие',
['Симптом', 'Вероятная причина', 'Точная проверка', 'Безопасное действие'],
[
['Два success, invariant false', 'two reads приняли решение по одному старому условию', 'воспроизвести fixed interleaving и посчитать result after both commits', 'объединить check/write в protocol, затем тестировать real SQL'],
['<code>FOR UPDATE</code> есть, ошибка остаётся', 'locked set меньше invariant set', 'сравнить predicate checks и predicate locking query', 'расширить scope или изменить модель правила'],
['Запрос ждёт/получает deadlock', 'multi-row paths имеют разный acquisition order', 'выписать order для каждого writer', 'единый order, short transaction, retry only aborted work'],
['<code>40001</code>', 'Serializable выявил dangerous dependency', 'проверить transaction outcome и отсутствие premature side effect', 'full retry с новым snapshot либо controlled rejection'],
['Повтор создал duplicate outside effect', 'retry пересёк границу БД', 'найти момент публикации effect относительно commit', 'вынести publication из retryable boundary и задать отдельный contract'],
],
),
heading('Полный retry — не цикл вокруг одной команды'),
paragraph('Когда operation зависит от прочитанных rows, retry обязан заново получить эти rows и заново решить, разрешено ли действие. В первом run T2 решила «можно выключить Бориса», потому что увидела двух active. После T1 такого решения больше нет. Повтор только <code>UPDATE on_call SET enabled = false</code> обходит новый check и делает именно то, от чего мы защищаемся. Это можно увидеть даже без сервера: version fixture изменился, значит cached decision непригоден. В PostgreSQL причина может выражаться иначе, но правило для business logic остаётся тем же.'),
codeBlock([
'async function retryWholeOperation(runOnce) {',
' for (let attempt = 1; attempt <= 3; attempt += 1) {',
' try {',
' return await runOnce(); // BEGIN, reads, check, writes, COMMIT together',
' } catch (error) {',
" if (error.code !== '40001' || attempt === 3) throw error;",
' }',
' }',
'}',
'',
'// Учебный shape. Он не является готовым driver adapter или retry policy.',
]),
paragraph('Фрагмент задаёт только shape retry. Перед его применением нужно подтвердить, что <code>runOnce</code> не публикует внешний эффект до commit; jitter, deadline и policy остальных ошибок зависят от operation.'),
heading('Нумерованный маршрут разбора'),
orderedList([
'Сохраните invariant и exact predicate. Начните не с ошибки драйвера, а с запрещённого committed state.',
'Запишите fixed schedule двух operations: что каждая прочла, когда решила писать и в каком порядке commits или waits произошли.',
'Проверьте version PostgreSQL, isolation и границу первого statement. Не меняйте уровень после уже выполненного query.',
'Сравните scope locking query с scope invariant. Для multi-row paths зафиксируйте один acquisition order.',
'Разделите outcome: normal wait, deadlock abort, serialization failure и business rejection требуют разных действий.',
'Для confirmed <code>40001</code> повторите всю transaction и заново вычислите решение; не повторяйте blind write.',
'Отдельно проверьте side effects после transaction. Если их нельзя повторить, они не должны жить внутри blind retry.',
'Покройте выбранный case integration test на разрешённой БД, а fixture оставьте как быстрый test контракта текста.',
]),
heading('Где разбор останавливается'),
paragraph('Эта статья не утверждает, что обнаружила actual blocker, настроила deadlock timeout или измерила contention. Не запускались PostgreSQL, две session, SQL-план, driver, HTTP, queue или browser. Диаграмма и fixture помогают разобрать модель, но не заменяют server evidence. Это не слабость материала: граница доказательства защищает от ложной уверенности, когда красивый narrative выдаёт учебный schedule за трассу системы.'),
paragraph('Хороший итог разбора звучит так: «инвариант зависит от двух rows, наивный schedule допускает два conflicting decisions; выбран protocol описывает scope, order и handling конкретного failure; остаётся проверить его на PostgreSQL 13 в двух sessions». Такой итог меньше похож на громкий incident report, но его можно отдать следующему инженеру и проверить. Для автора начала 2021 это более полезная экспертиза: видеть не только SQL-команду, но и границу, в которой она действительно что-то доказывает.'),
paragraph(trainingNotice),
],
[postgres131Release, transactionIsolation, explicitLocking, applicationConsistency, setTransaction, constraints, pgLocks],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
const isMainModule = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
} else if (process.argv.includes('--verify-fixture')) {
process.stdout.write(JSON.stringify(verifyFixture(), null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2021-01.mjs --print-revisions | --verify-fixture\n');
}
}