Транзакция изменения и конфликт version
Сохранение заявки состоит из нескольких связанных действий: найти текущую строку, проверить version, изменить данные и добавить историю. Если разделить их независимыми запросами, часть может завершиться без другой. Построим patch на одном pg client и одной транзакции. Результат — подтверждение только после COMMIT, конфликт старой основы 409 и совместное состояние tickets/ticket_events при ожидаемом успехе. Это центральная серверная граница общего «Диспетчера».
Version означает номер подтверждённого состояния. Клиент присылает номер, по которому начал редактирование. Сервер не доверяет ему как новому номеру: он сравнивает основу с текущей строкой и сам увеличивает значение. Такая схема отделяет содержательный черновик от серверной версии. Если два клиента получили v1, нельзя принимать оба изменения как будто каждый работал с последним содержимым. Один успешный PATCH создаёт v2, а второй должен осознанно решить конфликт.
Один client на всю операцию
Ниже полный метод patch из repository. Database и функция ticket уже существуют, PatchTicketDto получен после ValidationPipe, actorId передан service из server actor. Pool.connect выделяет client. Все BEGIN, SELECT, UPDATE, INSERT, COMMIT и ROLLBACK выполняются на нём. Именно эта связь делает их одной транзакцией; последовательные pool.query не гарантировали бы одинаковое подключение. Finally освобождает ресурс независимо от успешной или ошибочной ветви.
async patch(id: string, value: PatchTicketDto, actorId: string): Promise<Ticket> {
const client = await this.db.pool.connect();
try {
await client.query('BEGIN');
const current = await client.query<Row>('SELECT id,title,status,version,updated_at FROM tickets WHERE id=$1 FOR UPDATE',[id]);
if (!current.rows[0]) throw new NotFoundException('Заявка не найдена');
if (current.rows[0].version !== value.version) throw new ConflictException('Заявка изменена другим диспетчером');
const result = await client.query<Row>(
'UPDATE tickets SET title=$2,status=$3,version=version+1,updated_at=clock_timestamp() WHERE id=$1 AND version=$4 RETURNING id,title,status,version,updated_at',
[id,value.title,value.status,value.version]
);
if (!result.rows[0]) throw new ConflictException('Версия заявки изменилась');
const row = ticket(result.rows[0]);
await client.query('INSERT INTO ticket_events(ticket_id,actor_id,version,title,status) VALUES($1,$2,$3,$4,$5)',[id,actorId,row.version,row.title,row.status]);
await client.query('COMMIT'); return row;
} catch (error) {
await client.query('ROLLBACK'); throw error;
} finally { client.release(); }
}
SELECT FOR UPDATE берёт row lock на найденную заявку. Если строка отсутствует, выбрасывается 404 и catch откатывает транзакцию. Если версия отличается, выбрасывается 409 до UPDATE. При совпадении SQL дополнительно включает version в WHERE и увеличивает её на единицу. Текущий lock уже сериализует конкурирующие изменения этой строки, а условие UPDATE явно удерживает тот же optimistic contract. Returned row преобразуется в Ticket, затем её подтверждённое содержание добавляется в историю.
Ожидаемый успешный PATCH rq-101 v1 создаёт tickets v2 и одно ticket_events для v2. Если INSERT истории завершится отказом, ROLLBACK отменит UPDATE, поэтому клиент не получит successful Ticket с отсутствующим обязательным событием. Именно атомарность связывает эти две таблицы. Logger interceptor не заменяет историю: trace может зафиксировать завершение отказа, а транзакционная запись отражает только подтверждённое предметное изменение. Разные журналы имеют разные гарантии и назначение.
Два диспетчера
Представьте A и B с одной основой v1. A получает row lock, проверяет версию, пишет v2 и commit. B ждёт освобождения нужной строки, затем видит актуальную версию и получает 409. Мы не утверждаем конкретные времена ожидания или порядок, если оба запроса начались одновременно. Ожидаемый инвариант — оба не могут успешно записать одну старую основу как две независимые актуальные операции. Фактическая конкурентная проверка PostgreSQL в этой сессии не выполнялась.
Lock удерживается только в течение короткой серверной операции, а не пока пользователь открывает форму. GET не резервирует заявку. Это позволяет другим диспетчерам работать и делает conflict частью обычного протокола, а не неожиданным исключением «редактор занят». UI сохраняет черновик после 409 и предлагает явное чтение новой основы. Такая связь двух курсов показывает, почему серверный механизм должен иметь понятное пользовательское следствие, а не только правильный SQL.
Статусы в лаборатории меняются свободно между new, active и done. Транзакция не запрещает done → new, потому что такой предметный автомат не задан. Если бизнес добавит разрешённые переходы, правило должно проверяться по текущей строке внутри той же атомарной операции. Проверка только по присланному status или только в Angular не докажет допустимость перехода от реального серверного состояния. Сейчас мы не выдумываем эти правила и не называем UPDATE полноценным workflow согласования.
Ошибка и освобождение
Catch выполняет ROLLBACK, затем передаёт ошибку вверх. Finally вызывает release. Если соединение само разрушилось, rollback также способен отказать; production-версия потребует аккуратного сохранения исходной ошибки и оценки пригодности client. Лаборатория показывает основной путь и называет эту непроверенную аварийную границу. Нельзя обещать, что любое network failure всегда удачно выполняет rollback-код пользователя. PostgreSQL имеет собственные правила разорванной транзакции, которые нужно воспроизвести в выбранной среде.
Commit выполняется до return row. Поэтому успешный HTTP-ответ должен описывать подтверждённую запись. Но связь между COMMIT и доставкой ответа остаётся внешней: после фиксации клиент может потерять соединение. Тогда он не знает, произошло ли изменение. Повтор PATCH с прежним version способен дать 409, хотя первый запрос был успешным на сервере. Это не доказательство ошибочной транзакции, а неоднозначный outcome транспорта. Idempotency key и reconciliation не включены в архив.
Нельзя автоматически повторять любой patch после network error с увеличенным guessed version. Такой повтор способен затереть чужие изменения и присвоить старому черновику новую основу. Надёжное восстановление должно сначала выяснить текущий предметный результат или использовать заранее определённый протокол идемпотентности. В лаборатории UI сообщает отказ и оставляет черновик; дальнейшее явное чтение даёт актуальную основу. Эта политика проста, но ограничена, и её нужно честно передать следующему разработчику.
Уникальная пара ticket_id/version в истории дополнительно удерживает одну запись события на версию. Она не является полной защитой от повторной операции с другим version и не создаёт distributed transaction с внешними письмами или очередью. Здесь все обязательные записи находятся в одной PostgreSQL. Если позже добавить уведомление, отправленное после COMMIT, понадобится отдельное решение доставки, например outbox. Текущий курс не включает внешние эффекты, поэтому не приписывает SQL-транзакции гарантию их выполнения.
Читатель может проверить два одновременных PATCH старой версии и сопоставить ответы с SELECT обеих таблиц. Ожидается одно подтверждение, один 409 и согласованная новая история. В этой сессии SQL и HTTP не запускались. Учебный результат задан через конкретные строки client, lock, version и commit, а не через выдуманный журнал выполненного теста. Следующий урок оформит ожидаемые отказы для клиента единым envelope, сохранив причину конфликта и скрыв неожиданную внутреннюю диагностику.
Статус примера — not_run. Зависимости, приложение, SQL, HTTP и тесты не запускались. Описанные ответы и изменения интерфейса ожидаемые; чтение документации не является проверкой выполнением. Полная конечная версия находится в архиве исходников, а её ограничения и действия читателя — в README и CONTRACT.md.
Перейдите к оглавлению серии или общему маршруту интерактивной веб-разработки. Механизм этой главы сверяется с официальным первичным источником; конкретные версии и непроверенные части перечислены в договоре курса.