Пересечения броней и exclusion constraint
Создание одной брони уже имеет правильную границу commit, а время приводится к UTC. Но два клиента могут одновременно выбрать «Кабинет Лён» на один час. Эта глава добавляет главный инвариант расписания: подтверждённые интервалы одного ресурса не пересекаются. Инвариант — правило, которое должно оставаться истинным после любой допустимой операции, включая одновременные запросы и запись другим процессом.
Нельзя решать задачу только последовательностью SELECT, затем INSERT. Представьте два запроса A и B: оба читают пустой участок расписания до фиксации чужой вставки. Оба делают вывод, что время свободно, затем оба записывают бронь. Порядок строк в одной функции не превращает два процесса в очередь. Проверка полезна для предварительной подсказки, но окончательное правило должно учитывать конкурентные изменения на границе базы.
Сравнение двух условий
PostgreSQL умеет представлять временной интервал как tstzrange. Конструктор получает начало, конец и строку '[)', где квадратная скобка включает начало, а круглая исключает конец. Оператор && проверяет пересечение двух диапазонов. Для системы кабинетов этого недостаточно: одновременно использовать разные кабинеты допустимо. Поэтому exclusion constraint соединяет равенство resource_id и пересечение диапазона. Запрещается пара строк, для которой оба условия истинны.
btree_gist даёт нужную поддержку скалярного равенства в GiST. Миграция сначала добавляет расширение, затем ограничение bookings_no_overlap. Это имя важно: сервис распознаёт именно его при IntegrityError и переводит конфликт в slot_busy. Установка расширения требует подходящих прав в собственной базе. Если прав нет, схема не стала готовой автоматически; исключение из runner должно остановить применение истории, а не незаметно убрать главный инвариант.
Предикат WHERE state = 'confirmed' ограничивает правило активными бронями. Отменённая строка остаётся историей, но не занимает кабинет. В текущем уроке API отмены ещё отсутствует, однако схема уже выражает будущий переход. Предикат не позволяет передать любой статус: CHECK из первой миграции допускает только известные значения. Эти ограничения дополняют друг друга, поскольку одно описывает допустимую строку, а другое — допустимое совместное состояние нескольких строк.
Полные файлы шага
Показанные файлы полностью совпадают с выбранным состоянием архива. Остальные согласованные файлы находятся в examples/fastapi-booking-lab/lesson-09/.
migrations/002_no_overlap.sql
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING gist (
resource_id WITH =,
tstzrange(starts_at, ends_at, '[)') WITH &&
) WHERE (state = 'confirmed');
Ожидаемый результат
Это ожидаемый договор для самостоятельного исследования; пример не выполнялся.
Кабинет 1: [09:00,10:00) + [10:00,11:00) → допустимо
Кабинет 1: [09:00,10:00) + [09:30,10:30) → 409 slot_busy
Кабинет 2 в то же время → допустимо
Что происходит при конкуренции
При конфликтующих вставках база согласовывает результат относительно транзакций. Одна допустимая запись может зафиксироваться, другая должна получить нарушение ограничения; конкретный победитель не определяется названием клиента. Ожидание другой транзакции возможно, поэтому не называем отказ мгновенным. Важно обещание после фиксации: две пересекающиеся подтверждённые брони одного кабинета одновременно существовать не могут. Это сильнее, чем прежний предварительный SELECT в приложении.
Сервис уже оборачивает flush и commit в обработку IntegrityError. Из диагностических данных Psycopg извлекается constraint_name. После rollback код сравнивает имя с bookings_no_overlap. Если совпадение найдено, клиент получает 409 и slot_busy. Все прочие IntegrityError повторно поднимаются. Нарушенный внешний ключ не является занятым временем, поэтому широкий перевод любого сбоя базы в один предметный код сделал бы диагностику и поведение интерфейса неверными.
Rollback требуется и после конфликта, который обнаружился при flush. Он возвращает Session к завершённой границе неуспешной единицы работы. Не пытайтесь после ошибки сразу читать данные в той же незавершённой транзакции и считать, что она продолжилась нормально. Клиент получает объяснение конфликта уже после отмены попытки. Успешная ранее сохранённая бронь другого клиента остаётся действительной: наш rollback относится только к текущей транзакции.
Границы, которые не пересекаются
Рассмотрим вымышленные интервалы кабинета 1: 09:00Z — 10:00Z и 10:00Z — 11:00Z. Они соприкасаются, но не пересекаются при договоре '[)'. Оба могут существовать. Интервал 09:30Z — 10:30Z конфликтует с первым и вторым. Если сохранить тот же час для кабинета 2, равенство resource_id не выполнится, поэтому ограничение его не запрещает. Именно эти случаи показывают смысл каждой части выражения.
Не путайте этот договор с режимом уборки помещения. Если кабинет нужно освободить на пятнадцать минут между встречами, предметное занятое время должно расширяться или храниться отдельно. Изменение скобок не задаёт произвольный буфер. Также мы не делаем временные границы необязательными: бесконечные интервалы были бы другой моделью. DTO и CHECK по-прежнему требуют два конкретных момента и положительную длительность.
Миграция 002 применяется к существующим строкам. Если раннее учебное состояние уже содержит конфликтующие confirmed-брони, PostgreSQL не сможет установить ограничение поверх них. Runner не должен удалять данные за читателя. Для самостоятельного исследования используйте новую собственную базу либо явно разберите и исправьте вымышленные конфликты до применения. Так становится видно, что изменение схемы имеет предпосылки, а не только красивый SQL-текст.
Ни конкурентные запросы, ни миграция автором не выполнялись. Ожидаемый результат сформулирован из прочитанного договора PostgreSQL и кода сервиса. Полная миграция показана на этой странице, остальные файлы находятся в lesson-09. Теперь инвариант защищён на уровне хранения, однако повтор сетевого запроса ещё создаёт новое намерение. Следующая глава отличит повтор одной операции от второй самостоятельной брони и свяжет ключ запроса с сохранённым ответом.
Инвариант относится и к изменению строки
Ограничение базы действует не только при INSERT. Если иной разрешённый путь изменит кабинет или время подтверждённой строки, новое совместное состояние также должно удовлетворять запрету пересечения. Именно поэтому правило находится рядом с данными, а не только в функции создания. В нашем API перенос времени ещё отсутствует; если его добавить, нельзя считать, что прежняя проверка DTO сама обеспечит свободный участок.
Полезно также различать ресурс и владельца в выражении EXCLUDE. Если вместо resource_id включить actor_id, получилось бы правило личного календаря: одному участнику нельзя иметь пересечения, но разные участники могли бы занять один кабинет одновременно. Это другой предметный инвариант. В «Окне» физически неделимым объектом является кабинет, поэтому равенство сравнивается именно по нему. Пользовательский номер нужен для ownership и ключей запросов, но не подменяет ресурс расписания. Такое сравнение показывает, как маленькое изменение SQL меняет смысл всей системы без изменения названий кнопок.
Документация и исходники
Механизмы главы сверены с первичными источниками: документация 1, документация 2, документация 3, документация 4. Полный архив «Окна» содержит все шестнадцать состояний. Пакеты, приложение и миграции не запускались.