Конкурентное обновление объекта
Транзакция предыдущего урока сохраняет курс и аудит вместе. Но остаётся другая проблема: редактор может отправить команду, основанную на устаревшем состоянии. Один человек открыл JavaScript с двадцатью уроками, другой уже изменил его на двадцать два, а первый отправляет двадцать четыре, не увидев изменение. Простая замена по ID молча потеряет решение второго редактора. Введём версию объекта и условную запись.
Полная отдельная консольная ветка находится в lesson-19/after архива продолжения. Она начинается с базы урока 16, а не с выполненного сценария 18. Дополнительный SQL добавляет положительное целое version с начальным значением один. Снимки не являются накопительной миграцией общего production-проекта. Ни SQL, ни программа при подготовке не запускались; приводимые числа и исходы ожидаются при указанном начале.
Версия относится к прочитанному состоянию
Команда изменения должна сообщать не только новый заголовок, но и версию, которую видел пользователь. Сервер не доверяет ей как новой версии: он использует число только в условии сравнения. Следующая версия вычисляется базой при успешной записи.
Учебная команда для JavaScript выглядит так:
{
"id": "javascript",
"title": "JavaScript: основной курс",
"expectedVersion": 1
}
Название по-прежнему проходит знакомые правила нормализации и длины. expectedVersion должно быть положительным. Отрицательное число или отсутствие обязательного поля — неверная команда, которую следует отклонять до SQL. Это отличается от вполне корректной, но уже устаревшей версии один.
Нельзя сначала выполнить SELECT, сравнить версию в C#, а затем независимо сделать UPDATE только по ID. Между этими действиями другой запрос успеет записать новое состояние. Проверка и изменение должны составлять одну условную операцию базы. В нашем примере это один UPDATE с дополнительным предикатом.
Сравнение и запись выполняются вместе
Полный метод передаёт значения параметрами:
UPDATE courses
SET title = $2, version = version + 1
WHERE id = $1 AND version = $3
RETURNING version;
Если исходная версия равна одному, команда меняет название и возвращает два. Если версия уже изменилась, ни одна строка не подходит и записи нет. PostgreSQL согласует конкурентные UPDATE строковыми блокировками; после ожидания условие проверяется относительно актуальной строки. Поэтому две команды с ожидаемой версией один не могут обе успешно изменить одну и ту же строку по этому условию.
Для демонстрации консольная программа отправляет две команды последовательно, имитируя два решения, принятых из одного старого экрана. Первая назначает JavaScript: основной курс и ожидает один. Вторая назначает JavaScript: расширенный курс и тоже ожидает один. Ожидаемый вывод:
first: applied, version=2
second: precondition-not-met
После сценария JavaScript имеет первое название и версию два; число уроков остаётся двадцать, общая сумма — 60. Второе название не должно появиться в таблице. Здесь важен не порядок вывода сам по себе, а соответствие строки в базе только одному принятому решению.
Отсутствие возвращённой строки может означать как устаревшую версию, так и удалённый ID. Учебный repository сознательно объединяет эти исходы в precondition-not-met. Он не делает дополнительный SELECT ради утверждения, которое могло бы снова устареть. Если продукт хочет различать отсутствие и конфликт, это нужно согласовать отдельно, включая допустимость раскрытия существования чужого ресурса.
Связь с HTTP-предусловием
В HTTP похожую роль играет ETag, полученный при чтении, и If-Match, переданный при изменении. Важно связать тег с конкретным представлением, а не объявлять любое число универсальным ETag. Если публичный DTO включает версию, а все изменения его данных обязательно увеличивают её, можно построить сильный тег вида "course-javascript-v2" для согласованного представления.
Полный HTTP-парсер и обработчик If-Match в этой ветке не реализованы. Это решение границы, а не обещание готового условного API. При интеграции сервер должен решить, допускает ли список тегов и *, как обрабатывает слабые теги и что делает без заголовка. Для обязательного предусловия обычно различают отсутствие требуемого условия и его невыполнение; нельзя выдавать любой из этих случаев за успешный 200.
Для предметной команды с полем expectedVersion приложение может выбрать 409. Для невыполненного HTTP If-Match используется 412. Эти варианты обозначают разные публичные договорённости. Копирование числа версии из JSON в произвольный заголовок без определения синтаксиса и правил сравнения не создаёт корректную реализацию протокола.
На стороне интерфейса конфликт должен привести к повторному чтению и осмысленному решению человека. Автоматически подставить свежую версию и повторить старое тело — значит снова затереть чужую работу, только уже после формального успешного сравнения. Можно показать оба названия и предложить объединение, но выбор должен учитывать предметный смысл.
Все писатели соблюдают одно правило
Версионная защита работает лишь тогда, когда каждое изменение защищаемого состояния увеличивает версию. Старый PUT из урока 16 и прямой SQL без version = version + 1 обходят этот контракт. Поэтому во время самостоятельного сценария не запускается ранний HTTP-сервер и не выполняются посторонние изменения таблицы. При реальной миграции старых писателей нужно изменить или отключить вместе с новым контрактом.
Удаление и повторное создание ID тоже требуют решения. Простая версия, сброшенная к одному, может сделать старое предусловие снова подходящим. В нашей учебной последовательности ID не переиспользуется после удаления. Для более общего продукта подойдут отдельная идентичность экземпляра, монотонная история или иной механизм, который предотвращает такое совпадение.
Число хранится в PostgreSQL как bigint и читается в .NET как long. Переполнение в очень долгой истории не должно оборачиваться в разрешённую старую версию. Оно останется ошибкой записи, которую необходимо обслужить отдельным решением. У маленькой песочницы это не практическая нагрузка, но тип и граница всё равно должны совпадать.
Упражнение для последующего выполнения: прочитайте версию два и отправьте третью команду с этой версией. Ожидается успешная запись и версия три. Затем повторите ту же команду с прежним числом два: условие уже не подходит. Сравнение защищает от устаревшего изменения, но не делает повтор команды после потерянного ответа идемпотентным и не заменяет право пользователя редактировать этот курс.
Теперь у каталога есть механизм обнаружения устаревшего решения. Следующий урок отделит идентичность редактора от доменных объектов, чтобы право изменения не зависело от произвольного ID в теле команды. Поведение конкурентного UPDATE разобрано в документации PostgreSQL 17, семантика предусловий — в HTTP Semantics, RFC 9110.