Versioning через valid_from: единая модель для цен, скидок, шаблонов и правил

Веб Штурм
  • PostgreSQL
  • versioning
  • Supabase
  • архитектура
  • audit

В Круизном флоте и Прогулочный флот один и тот же паттерн versioning через valid_from обслуживает discounts, prices, booking_rules, promotions и document templates. Insert-only, один резолвер, никаких race conditions при обновлениях, бесплатный audit trail. После двух лет в продакшене — лучшее решение для бизнес-данных, у которых есть «срок действия».

Контекст

Любая система бронирования рано или поздно ловит классическую боль: «Цена на тур поменялась 1 марта. У клиента бронь от 28 февраля. По какой цене считать?». Стандартные подходы — valid_to + is_active поля — ломаются на race conditions: два менеджера одновременно «закрывают» старую версию и «открывают» новую, и в БД остаётся либо две активных, либо ноль.

Versioning через единое поле valid_from решает это структурно. Старые записи никогда не правятся (insert-only), новая версия добавляется новым INSERT, резолвер выбирает max(valid_from) ≤ effective_date.

Базовая схема

CREATE TABLE prices (
 id bigserial PRIMARY KEY,
 tour_id bigint NOT NULL REFERENCES tours(id),
 cabin_class text NOT NULL,
 price_rub numeric NOT NULL,
 valid_from date NOT NULL,
 -- НЕТ valid_to. НЕТ is_active. НЕТ updated_at.
 created_at timestamptz NOT NULL DEFAULT now(),
 created_by bigint REFERENCES users(id)
);

CREATE INDEX idx_prices_lookup ON prices (tour_id, cabin_class, valid_from DESC);

Никаких UPDATE-ов на prices. Изменилась цена — новый INSERT с valid_from = '2026-03-01'. Старая запись с valid_from = '2025-12-01' остаётся жить вечно.

Резолвер на дату эффективности

Бронь от 28 февраля 2026 года должна посчитаться по цене, действующей на 28 февраля. SQL-резолвер:

-- Активная цена на конкретную дату для конкретного тура+класса
SELECT price_rub
FROM prices
WHERE tour_id = $1
 AND cabin_class = $2
 AND valid_from <= $3 -- effective_date (дата заявки или дата старта тура)
ORDER BY valid_from DESC
LIMIT 1;

Главное правило: effective_date — это дата бизнес-события, не now(). Для цены тура — дата тура. Для скидки — дата заявки. Для правила бронирования — дата заявки. Иначе теряется смысл historical-резолвинга.

LATERAL JOIN для list-fetching

Когда нужно показать список туров с актуальной ценой на конкретную дату — приходит LATERAL JOIN:

SELECT
 t.id,
 t.title,
 t.start_date,
 p.price_rub
FROM tours t
LEFT JOIN LATERAL (
 SELECT price_rub
 FROM prices
 WHERE tour_id = t.id
 AND cabin_class = 'standard'
 AND valid_from <= t.start_date
 ORDER BY valid_from DESC
 LIMIT 1
) p ON true
WHERE t.start_date BETWEEN $1 AND $2
ORDER BY t.start_date;

Один SQL-запрос — корректные цены на даты каждого тура. Без N+1 проблем, без фильтрации в коде, без valid_to-логики.

Seed для исторических сущностей

Когда в БД заливаются исторические данные (миграция legacy), у некоторых сущностей нет «даты начала действия» — они существовали всегда. Решение — seed с valid_from = '2000-01-01':

-- Миграция тарифов 2018-2025 годов из MariaDB
INSERT INTO prices (tour_id, cabin_class, price_rub, valid_from)
SELECT
 new_tour_id_map[old.tour_id],
 old.cabin_class,
 old.price,
 COALESCE(old.effective_from, DATE '2000-01-01')
FROM legacy.aa_prices old;

Любая будущая «эталонная» цена с valid_from > '2000-01-01' корректно перекроет seed.

Где применили

В river-sb эта схема используется для:

  • prices — цены кают по теплоходу/дате
  • discount_types — справочник скидок (пенсионер, агентский, ранний бронь)
  • booking_rules — правила оформления (минимальное число пассажиров, депозит)
  • promotion_rules — правила акций (по периоду продаж и периоду тура)
  • document_templates — шаблоны ваучеров и расписаний (templates_v2)

Из 211 SQL миграций в production около 40 — это insert-only версионные обновления тарифов и правил, никогда не приводившие к race condition или потере истории.

Преимущества (которые получаешь бесплатно)

  1. Audit trail — кто и когда сменил цену, видно из created_at + created_by. Без отдельной audit-таблицы.
  2. Time-travel queries — «покажи цены, действовавшие 1 января 2025» — один WHERE-clause.
  3. Откат без потери данных — отменить ошибочное обновление = INSERT новой версии с актуальной ценой и valid_from = now(). Старое (неправильное) обновление видно в истории, но не активно.
  4. Параллельные обновления безопасны — два менеджера могут одновременно создать две новые версии. Резолвер выберет ту, у которой valid_from позже. Никаких блокировок.

Что мы из этого вынесли

  • Не пишите valid_to, is_active, updated_at на бизнес-данных. Эти три поля — источник 80% багов с историей. Insert-only решает структурно.
  • Effective_date — это бизнес-параметр, не now(). На запросе цены тура передавайте дату тура, не текущий момент.
  • LATERAL JOIN — естественный паттерн для list-fetching по версионированной таблице. Без него уйдёшь в N+1 или сложную виндовую функцию.

Ссылки

Часто задаваемые вопросы

Что такое valid_from-паттерн и чем отличается от valid_to / is_active?
Insert-only схема с единственным полем `valid_from DATE` (или TIMESTAMP). Актуальная версия — та, у которой max(valid_from) ≤ effective_date. В отличие от пары valid_from + valid_to, не требуется UPDATE прошлой записи при добавлении новой — только INSERT. Audit trail бесплатный, race conditions исключены.
Как это работает на цены и скидки в проде?
Каждое изменение цены = новая строка с valid_from = дата вступления в силу. Резолвер: `SELECT * FROM prices WHERE product_id = ? AND valid_from <= ? ORDER BY valid_from DESC LIMIT 1`. С composite-индексом (product_id, valid_from DESC) — O(log N). LATERAL JOIN при выборе нескольких товаров — один запрос на список заказа.
Можно ли применять паттерн на legacy MySQL?
Да. Window-функции (ROW_NUMBER OVER) есть в MySQL 8+, LATERAL JOIN — нет, но заменяется correlated subquery с LIMIT 1. На MySQL 5.7 — через triggers + materialized current_value. Главное — добавить composite-индекс (entity_id, valid_from DESC) для производительности резолвера.
Когда valid_from-паттерн избыточен?
Если данные редко меняются и аудит не нужен (например, статичный справочник стран) — overhead. Если требуется удалять прошлые версии по GDPR/152-ФЗ (право на забвение) — insert-only усложняет: нужен отдельный механизм tombstones. Для high-frequency данных (тикерные котировки) — лучше time-series БД.