Перейти к содержанию

Транзакция заявки и истории

У заявки DeskQueue появляется история изменения состояния. Сотрудник переводит заявку из open в working, и в ticket_events должна остаться запись этого перехода. Две независимые команды создают опасное промежуточное состояние: заявка уже изменена, а история ещё не записана. Если второй запрос завершится ошибкой, сведения разойдутся. Транзакция объединит связанные операции и позволит считать переход завершённым только после успешной записи обеих частей.

Исходное состояние — stages/14, где SQL-хранилище уже поддерживает фильтрацию. В stages/15 заменим setStatus функцией transitionTicket. Принимаем идентификатор заявки, новое состояние и идентификатор действующего пользователя; пока последний может быть null, потому что вход пользователя появится далее. История учебной операции и история подтверждённого сотрудника являются разными уровнями доверия.

Один клиент на всю транзакцию

Транзакция относится к конкретной сессии PostgreSQL. Последовательные вызовы pool.query могут получить разные клиенты. Поэтому берём клиента один раз, выполняем BEGIN, все рабочие команды и затем COMMIT либо ROLLBACK. В любом случае возвращаем клиента через release. Это правило явно указано в документации транзакций node-postgres.

Полная функция из tickets.js читает текущую строку с блокировкой, изменяет её и добавляет событие. Отсутствующая заявка возвращает null; этот случай также завершает открытую транзакцию перед освобождением клиента.

import { pool } from './db.js';
export async function transitionTicket(id, status, actorId = null) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const current = await client.query(
      'SELECT id, status FROM tickets WHERE id = $1 FOR UPDATE', [id]
    );
    if (!current.rows[0]) {
      await client.query('ROLLBACK');
      return null;
    }
    const from = current.rows[0].status;
    const updated = await client.query(
      `UPDATE tickets SET status = $2 WHERE id = $1
       RETURNING id, title, status, author_id, created_at`, [id, status]
    );
    if (from !== status) {
      await client.query(
        `INSERT INTO ticket_events (ticket_id, actor_id, from_status, to_status)
         VALUES ($1, $2, $3, $4)`, [id, actorId, from, status]
      );
    }
    await client.query('COMMIT');
    return updated.rows[0];
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

FOR UPDATE блокирует выбранную строку для конкурирующих операций изменения до завершения транзакции. Два сотрудника не должны одновременно прочитать одинаковое старое состояние и независимо записать несовместимую историю. Второй запрос дождётся первого и затем будет работать с доступным состоянием согласно правилам изоляции. Важно, что блокировка защищает строку в базе, а не объект JavaScript одного процесса.

На этом этапе разрешены все переходы между тремя состояниями. Если предметная область требует запретить closed → working, такую проверку выполняют после чтения заблокированной строки и до обновления. Проверка исключительно в интерфейсе недостаточна: запрос можно отправить напрямую, а состояние способно измениться между показом формы и отправкой. База должна принять решение на основании актуальной строки.

Что наблюдает клиент

После успешного COMMIT ответ содержит новое состояние заявки. Если вставка события не удалась, обновление заявки откатывается. Читатель не должен видеть подтверждённое working без соответствующей истории этого перехода. Все изменения одной транзакции становятся видимыми как согласованный результат; при этом другие транзакции имеют собственные правила видимости. Базовое поведение описано в главе PostgreSQL об изоляции.

Представим начальное состояние open. Первый запрос переводит заявку в working, второй — в closed. При последовательном прохождении блокировки ожидаем две записи: open → working и working → closed. Порядок определяется фактическим прохождением операций, а не временем клика в браузере. Поэтому ответ интерфейса должен обновлять показанную заявку по полученным данным, а не по заранее предположенному состоянию.

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

Однако отсутствие дубликата события не делает всю операцию универсально идемпотентной. Между двумя повторами другой сотрудник может изменить состояние. Тогда повтор прежнего запроса действительно создаст новый переход назад. Для защиты от устаревшей формы понадобится версия строки или условие ожидаемого состояния. В расширенном варианте клиент передаёт прежнее состояние, а сервер возвращает 409, если оно уже изменилось.

Время удержания ресурса

Пока транзакция открыта, соединение и блокировка заняты. Внутри неё нельзя ждать загрузку файла, отправку почты или внешний HTTP-сервис. Такие операции непредсказуемы по времени и не откатываются вместе с PostgreSQL. Если письмо отправилось, а база затем откатилась, сообщение останется у получателя. Для надёжной связи внешнего действия с записью данных применяют отдельный договор, например таблицу исходящих событий и фоновый обработчик.

Учебная функция выполняет только короткие SQL-команды. Даже так следует учитывать тайм-ауты и ошибки сети. Когда соединение разрывается во время подтверждения, клиент может не знать, успел ли сервер выполнить COMMIT. Слепой повтор способен создать вторую операцию. Именно поэтому для финансовых или других значимых команд требуется отдельный идентификатор операции и устойчивое правило повторов; транзакция сама по себе не устраняет неопределённость сетевого ответа.

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

Итоговая граница теперь ясна: HTTP подтверждает переход после фиксации согласованного состояния, SQL-хранилище отвечает за ресурс и транзакцию, история содержит фактическое изменение. Эти функции станут основой проверки прав сотрудника. В следующей главе добавим пользователей и производную пароля, чтобы связать операцию с подтверждённой личностью.

Полезно отличать отмену транзакции от компенсации внешнего действия. ROLLBACK возвращает изменения таблиц в пределах базы, но не удаляет уже отправленное сообщение и не отменяет загруженный файл. В нашем переходе все обязательные изменения находятся в PostgreSQL, поэтому выбранный механизм соответствует задаче. При добавлении внешней реакции нужно заново определить границу подтверждения, а не считать любую последовательность между BEGIN и COMMIT одинаково обратимой.

Историю читают в устойчивом порядке, например по её первичному ключу. Время двух событий может совпасть по представлению, а точность часов не выражает разрешение конкуренции. Номер события также не является гарантией общего порядка любых транзакций, но для выбранного чтения заявки задаёт однозначную последовательность сохранённых строк. Если нужен порядок предметных версий, лучше добавить явную версию заявки.

Вариант с ожидаемым состоянием позволяет объяснить конфликт без случайной перезаписи. Сотрудник просит open → working, а база уже содержит closed; сервер отвергает устаревшую команду и показывает свежую запись. Это иной договор, чем безусловный перевод в указанное состояние. Выбирайте его согласно продукту и сообщайте клиенту конкретную причину конфликта.

Оглавление курса

Полные файлы учебного проекта. Примеры при подготовке текста не запускались; ответы и вывод в уроке описывают ожидаемый результат читателя.