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

Событие бронирования и транзакционный outbox

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

После создания брони может потребоваться дополнительная реакция: обновить журнал, подготовить уведомление или передать событие другому компоненту. В учебном «Окне» получатель только печатает событие в stdout; настоящие письма и внешние аккаунты не используются. Главная задача главы — сохранить факт реакции в той же транзакции, что и бронь, и объяснить, почему фоновый вызов процесса не даёт надёжной доставки сам по себе.

FastAPI BackgroundTasks удобно запускает дополнительную работу после отправки ответа. Однако это работа того же процесса. Если он остановится между commit брони и выполнением задачи, один вызов может потеряться. Также фоновая функция не должна пользоваться Session уже завершившегося запроса. Поэтому добавим outbox: таблицу событий, которые ожидают обработки. В ней хранится факт, а фоновый вызов становится лишь возможностью быстрее начать чтение.

Одна фиксация двух фактов

При первом успешном создании сервис добавляет в outbox UUID, kind booking_created и payload с booking_id. Вставка выполняется до общего db.commit. Бронь, запись идемпотентности и событие сохраняются вместе. Если exclusion constraint отказал или commit не завершился успешно, частичного подтверждённого события не должно остаться. Так получатель не реагирует на намерение, которое база не приняла. Успешный повтор идемпотентного запроса возвращает прежний body и не создаёт новое событие.

Payload содержит только нужный номер. Мы не копируем весь HTTP-запрос, токен учебного входа и настройки процесса. Outbox не является бесплатным местом для любых диагностических данных. Событие должно иметь ясный смысл: какая операция произошла и что требуется получателю. В дальнейшем контракт payload можно версионировать, однако в этом снимке он минимален. UUID позволяет отличать событие от брони и служит ключом возможного подавления повторной доставки.

Столбец delivered_at остаётся NULL до подтверждения учебного обработчика. Частичный индекс выбирает ожидающие события. Он не запускает работу автоматически и не является планировщиком. Если никто не выполняет drain_once, записи просто остаются в базе. Такое поведение полезнее исчезнувшей памяти процесса: следующий запуск собственного обработчика всё ещё может увидеть ожидающий факт. В этой главе не устанавливаем отдельный демон или расписание, поскольку предметный результат относится к договору хранения и чтения.

Полные файлы шага

Показанные файлы полностью совпадают с выбранным состоянием архива. Остальные согласованные файлы находятся в examples/fastapi-booking-lab/lesson-14/.

migrations/005_outbox.sql

CREATE TABLE outbox (
    id uuid PRIMARY KEY,
    kind varchar(40) NOT NULL,
    payload jsonb NOT NULL,
    delivered_at timestamptz
);
CREATE INDEX outbox_pending ON outbox (id) WHERE delivered_at IS NULL;

drain.py

from datetime import datetime, timezone
import json

from sqlalchemy import text

from db import SessionFactory


def drain_once() -> None:
    with SessionFactory.begin() as session:
        row = session.execute(text(
            "SELECT id, kind, payload FROM outbox "
            "WHERE delivered_at IS NULL ORDER BY id "
            "FOR UPDATE SKIP LOCKED LIMIT 1"
        )).mappings().first()
        if row is None:
            return
        # Educational recipient: stdout, not email or an external account.
        print(json.dumps({"event_id": str(row["id"]), "payload": row["payload"]}))
        session.execute(text(
            "UPDATE outbox SET delivered_at = :now WHERE id = :id"
        ), {"now": datetime.now(timezone.utc), "id": row["id"]})


if __name__ == "__main__":
    drain_once()

Ожидаемый результат

Это ожидаемый договор для самостоятельного исследования; пример не выполнялся.

Новая успешная бронь → одна запись outbox
Идемпотентный повтор → нового события нет
drain_once → stdout с event_id, затем delivered_at
Сбой между реакцией и commit допускает повтор.

Получатель с собственной сессией

drain_once создаёт SessionFactory.begin отдельно от HTTP-зависимости. Он выбирает одну pending-запись с FOR UPDATE SKIP LOCKED. Блокировка закрепляет обрабатываемую строку за транзакцией, а SKIP LOCKED позволяет другому экземпляру выбрать другую доступную запись. Это не выдаёт события в строгом порядке совершения броней: UUID-порядок здесь только способ устойчиво выбрать следующую строку. Для обязательной последовательности потребовался бы другой договор.

В учебном получателе print формирует JSON с event_id и payload. Затем UPDATE ставит delivered_at. Если выполнение печати прошло, но commit базы после неё не завершился, событие может быть прочитано снова. Таким образом, возможна повторная реакция. Даже наличие блокировки не превращает внешнее действие и транзакцию PostgreSQL в одну атомарную операцию. Это принципиальная граница, которую надо объяснить прежде, чем заменить stdout настоящим внешним сервисом.

Для действительного получателя обычно нужен идемпотентный эффект по event_id или собственная запись обработанных событий. Тогда повтор с тем же идентификатором не должен создавать второе письмо или изменение. Мы не обещаем «ровно один раз» показанному print. Учебный результат честнее: факт не теряется после успешной фиксации брони, обработчик может вернуться к нему, а повторная доставка остаётся частью контракта. Блокировка удерживается во время реакции, что также ограничивает пригодность длинных внешних действий.

BackgroundTasks как ускорение

Маршрут получает BackgroundTasks и добавляет drain_once только после успешного возврата сервиса. Этот порядок означает, что outbox уже зафиксирован. Фоновая функция открывает новое соединение и может обработать любую pending-запись, включая оставшуюся от прежнего запроса. Не передаём ей ORM-объект row или db текущего запроса: их жизненный цикл уже относится к другой операции. Достаточно функции, которая умеет прочитать сохранённый договор.

Если ответ потеряется и клиент повторит POST с тем же ключом, маршрут снова может предложить drain_once. При этом нового события не появится, поскольку service возвращает ранее сохранённый ответ. Повторное ускорение чтения harmless относительно единственного сохранённого события, но не доказывает отсутствие повторной реакции после сбоя. Полезно различать создание факта, предложение выполнить обработку и успешную доставку. Эти действия расположены в разных местах кода и имеют разные гарантии.

Ожидаемый положительный сценарий: одна новая бронь создаёт одну pending-запись, drain_once печатает её и помечает доставленной. Если фон не был выполнен, pending остаётся. Повторное создание по тому же ключу не увеличивает число событий. Отмена пока не создаёт booking_cancelled: она является прежней операцией изменения состояния. Если продукту нужно уведомление отмены, его следует добавить отдельным событием в транзакцию отмены и объяснить новый контракт, а не предположить, что оно уже есть.

Полные SQL и drain.py приведены на этой странице; service.py и маршрут находятся в lesson-14. Никакие события фактически не отправлялись, а данные stdout являются ожидаемыми. Для самостоятельного разбора найдите точку между print и commit и объясните повтор после сбоя именно там. Далее опишем публичный контракт OpenAPI, чтобы человек, читающий только схему, увидел DTO, заголовок ключа, учебный вход и возможные предметные ответы без догадок по исходникам.

Идентификатор события отличается от ключа запроса

Idempotency-Key выбирает клиент и связывает с намерением создания. event_id выбирает сервер и передаёт получателю конкретного факта. Эти значения нельзя просто объединить, потому что один предметный запрос в более богатой системе может породить несколько разных событий. Также ключ принадлежит владельцу, а получатель может иметь собственную область подавления повторов. В текущем примере одна новая бронь создаёт одно событие с UUID, что делает различие видимым без дополнительной инфраструктуры.

Отметка delivered_at относится к реакции учебного drain_once, а не к просмотру человеком. Если stdout успешно получил текст, код может подтвердить этот шаг; он не знает, прочитал ли кто-то строку. Для электронной почты различались бы приём провайдером, доставка и чтение, но таких интеграций здесь нет. Поэтому выбираем название и пояснение в пределах имеющегося наблюдаемого действия. Outbox хранит договор дополнительной работы, а не универсальное доказательство любого конечного человеческого результата.

Документация и исходники

Механизмы главы сверены с первичными источниками: документация 1, документация 2, документация 3. Полный архив «Окна» содержит все шестнадцать состояний. Пакеты, приложение и миграции не запускались.