Транзакционная команда
Один оператор UPDATE из урока 16 атомарен, но предметная команда может включать несколько записей. Допустим, при изменении числа уроков нужно одновременно сохранить запись аудита: какой курс изменился и какое значение было назначено. Если выполнить эти действия независимыми командами, получится новое число без аудита либо аудит изменения, которое не состоялось. Объединим их транзакцией.
Для этой темы используется отдельная консольная ветка lesson-18/after архива продолжения. Она работает с теми же курсами, но не заменяет HTTP-проект урока 17. Начало — база урока 16 с четырьмя курсами и суммой 60; дополнительный SQL создаёт пустую таблицу course_audit. Запуск, компиляция и SQL при подготовке не выполнялись. Состояния ниже — ожидаемый контракт, а не отчёт об исполнении.
Одна команда владеет соединением
Транзакция относится к конкретному соединению PostgreSQL. Поэтому сервис сначала открывает соединение из общего data source, затем начинает транзакцию и создаёт обе команды именно на этом соединении. Если каждую команду отправить через dataSource.CreateCommand, они могут использовать разные соединения пула и не составят общий предметный шаг.
Существенная часть полного метода CourseTransaction.ChangeLessonsAsync выглядит так:
await using var connection = await source.OpenConnectionAsync(token);
await using var transaction = await connection.BeginTransactionAsync(token);
await using var update = new NpgsqlCommand(
"UPDATE courses SET lessons=$2 WHERE id=$1", connection, transaction);
update.Parameters.AddWithValue(id);
update.Parameters.AddWithValue(lessons);
var affected = await update.ExecuteNonQueryAsync(token);
До SQL проверяется допустимый диапазон числа. Если UPDATE не затронул строку, метод возвращает false; транзакция не фиксируется и освобождается. Для существующего курса следующая команда добавляет аудит. Обратите внимание: мы записываем назначенное число, а не вычисляем разницу из ранее прочитанного HTTP-клиентом значения. Такое решение делает смысл учебной команды однозначным.
await using ограничивает время жизни ресурсов. Оно не является волшебным способом восстановить потерянный ответ, зато не оставляет соединение навсегда занятым при обычном исключении. Транзакция без успешного commit откатывается при освобождении. В полном исходнике команды не удерживают открытые readers, потому что обе операции возвращают число затронутых строк.
Аудит участвует в том же решении
Таблица аудита содержит отдельный ID записи, ID курса, назначенное число и время базы. Внешний ключ связывает её с курсом. Поле числа имеет то же ограничение 1–500. Схема создаётся только вручную в отдельной учебной базе; приложение не выполняет DDL при старте и не очищает таблицы.
Вторая операция и фиксация:
await using var audit = new NpgsqlCommand(
"INSERT INTO course_audit(course_id, assigned_lessons) VALUES ($1,$2)",
connection, transaction);
audit.Parameters.AddWithValue(id);
audit.Parameters.AddWithValue(lessons);
await audit.ExecuteNonQueryAsync(token);
await transaction.CommitAsync(token);
return true;
При успешном изменении JavaScript с двадцати на двадцать два урока ожидаются четыре курса с общей суммой 62 и одна запись аудита со значением 22. Метод сообщает true только после завершения commit. Клиенту не следует отдавать окончательный успех сразу после UPDATE, когда судьба второй части команды ещё не определена.
Теперь представим, что INSERT отклонён ограничением базы. В учебном методе есть отдельно обозначенный параметр сценария, позволяющий передать в аудит ноль после допустимого UPDATE. Он предназначен только для демонстрации отката и не является параметром публичного HTTP API. Вставка должна завершиться ошибкой; число JavaScript останется двадцать, сумма — 60, аудит — пустым. Именно такой несовпадающий со схемой второй шаг делает общий результат наблюдаемым.
Читатель выбирает успешный или ошибочный сценарий в свежем состоянии. Если сначала выполнить успешный, а потом ошибочный, откат вернёт состояние перед второй транзакцией, то есть двадцать два. Он не возвращает базу к исходному набору из статьи. Разделение начал важно: транзакция отменяет свои изменения, а не всю историю приложения.
Атомарность отличается от изоляции
Транзакция гарантирует согласованную фиксацию наших двух SQL-действий. Но она не запрещает другие транзакции и не защищает автоматически от устаревшего решения редактора. Два клиента могут последовательно назначить двадцать два и двадцать четыре. Обе команды будут атомарными, однако последняя заменит первое число. Если требуется обнаружить устаревшее редактирование, нужен дополнительный контракт версии, который разберём в следующем уроке.
По умолчанию PostgreSQL использует Read Committed. Каждый оператор читает доступное ему состояние, а конкурентные записи согласуются блокировками строк. Нельзя предполагать, что несколько SELECT внутри такой транзакции обязательно наблюдают один неизменный снимок. Для других предметных требований существуют более сильные уровни изоляции, но их выбор сопровождается обработкой конфликтов и возможных повторов.
Не удерживайте эту транзакцию во время внешнего HTTP-запроса, отправки письма или ожидания действий человека. Долгая транзакция занимает соединение и может удерживать блокировки. Отправка письма всё равно не откатывается вместе с PostgreSQL. Для согласования изменения с внешним действием обычно сохраняют намерение в базе, а отдельный обработчик выполняет его позже. В нашем случае аудит находится в той же базе, поэтому граница действительно охватывает оба эффекта.
Неопределённый результат фиксации
CancellationToken передаётся в открытие соединения, команды и commit. Однако отмена ожидания не доказывает, что сервер не успел зафиксировать транзакцию. Если связь потерялась около commit, клиент может не знать окончательный исход. Нельзя автоматически повторять команду только потому, что не пришёл ответ: аудит получит ещё одну запись, даже если назначенное число совпадает.
Учебная реализация ещё не имеет идентификатора предметной операции и дедупликации. Для надёжных повторов понадобится уникальный ключ команды, сохранённый вместе с изменением. Тогда повтор сможет узнать уже принятый результат. Это отдельное свойство, которое не возникает из самого BeginTransactionAsync.
Также транзакция не заменяет авторизацию. Пользователь с правом подключения может выполнить SQL напрямую, а открытый HTTP-handler из ранней части серии остаётся Development-песочницей. Ветка этого урока намеренно не предоставляет внешний изменяющий endpoint. Подготовленный консольный сценарий нужен для понимания границы базы, а не для публикации административной команды в интернете.
При последующем выполнении сравните три наблюдения: число JavaScript, общую сумму и содержимое аудита. Для успешного случая они должны согласоваться; для намеренно ошибочного — оставаться исходными. Если проверить только возвращённый текст программы, можно пропустить частичную запись. В этом и состоит результат урока: предметный успех теперь означает согласованное состояние двух таблиц, а не завершение первой строки SQL.
API транзакций приведён в документации Npgsql, уровни изоляции — в документации PostgreSQL 17.