Files
clickstream-data-platform/docs/research/2026-08-16-order-snapshot-wire-format.md
T
ddadminandClaude Fable 5 0d9a83d6af docs(orders): устройство заказов переехало в живой набор architecture/orders
Зачем: собранная спека заказов стала базовой документацией сервиса, а
жанр спеки-события ей мал: дата в имени врёт, целиком в контекст агента
она не влезает, а трекер с резолюциями долговечным хранилищем не
считается. Решение владельца — держать детальное устройство компонента
связным набором живых документов (ADR 0011) и совместить переезд с
проходом на вычитание (#84).

Что: docs/architecture/orders/ — индекс README и файлы по частям
устройства: проекция, слепок и доставка, судьба, классы расхождений,
опись, мост к склейке, стартовый мир, правила кода; спека приёма
переехала в ingestion.md без содержательных правок. Резы вычитания по
итогам двух слепых линий: тела разделов о проводе и приёме сведены к
указателям на мастер-спеку, исследование формата и ADR (порядок строк
слепка — единственное правило, оставшееся на месте); замеры канонического
зерна и повторы-пояснения срезаны; списки отклонённых вариантов сохранены
как долговечная запись. Датированные файлы удалены, ссылки из мастер-спеки,
спеки генератора, ADR 0008/0010, исследования формата и storage.md
перенацелены; раздел «Структура» AGENTS.md дополнен правилом подпапки.

Проверка: grep по репозиторию не находит ссылок на удалённые файлы;
все относительные ссылки внутри набора разрешаются в существующие файлы;
впереди холодная сверка «ни одно решение не потеряно» и приёмка #88.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 23:50:13 +03:00

17 KiB
Raw Blame History

Формат дневного слепка заказов на проводе

Дата исследования: 2026-08-16.

Учебный результат: менти различает бизнес-время, время источника, доставки и загрузки, не разбирая ради этого лишнюю инфраструктуру. Цена — несколько явных правил контракта; новых полей и универсального сериализатора не требуется.

Короткий вывод

  • created_at и updated_at — аудит строки в источнике, а не время покупки. Оба поля передаются в UTC с настоящей точностью до миллисекунд: 2026-06-03T14:21:07.123Z.
  • В ClickHouse им соответствует DateTime64(3, 'UTC'). Неверная строка даёт NULL и уходит в *_errors, а не превращается в правдоподобную дату.
  • snapshot_date — дата завершившегося модельного дня, состояние которого снято на исходящей границе суток. Внутри одной выгрузки она одинакова, на следующем модельном дне меняется.
  • items на проводе — обычный массив JSON. Тип String в ODS означает, что из внешнего JSON извлекли сырой фрагмент массива, а не что источник дважды сериализовал JSON.
  • Генератору достаточно собрать один словарь с вложенным списком и один раз вызвать orjson.dumps. Отдельная иерархия кодеков урока не добавляет.

Оси времени

В потоковой обработке время события принадлежит самой записи и не зависит от часов обработчика; время обработки отвечает на другой вопрос (Apache Flink: Event Time и Processing Time). Debezium проводит ту же границу внутри одного сообщения: время изменения в исходной БД хранится отдельно от времени обработки коннектором, а их разность можно использовать как задержку (документация коннектора PostgreSQL).

Поле Чьи часы На какой вопрос отвечает Форма
UTCEventTime события purchase бизнес-событие, трекер когда покупатель подтвердил покупку отдельный контракт кликстрима; связь с заказом по purchaseID = order_id
created_at база источника когда строка заказа впервые создана в источнике RFC 3339 UTC с тремя знаками долей секунды
updated_at база источника когда эта строка в последний раз изменена в источнике тот же формат; версия состояния заказа
snapshot_date модельный календарь состояние какого завершившегося дня снято на границе суток YYYY-MM-DD, без времени
kafka_timestamp транспорт когда брокер пометил доставленное сообщение служебная колонка хранилища
_load_ts хранилище когда строка впервые приехала в хранилище DateTime64(3, 'UTC')

updated_at как метка последнего изменения исходной строки совпадает с рекомендованным смыслом updated_at в timestamp-стратегии dbt snapshots; время выполнения самого слепка dbt хранит отдельно (официальная документация dbt). Служебные метки ETL также являются отдельными метаданными процесса, а не бизнес-фактами (Kimball Group: Audit Dimension). В этом проекте транспортная и складская оси уже разведены в конвенции хранилища: _load_ts ставится один раз, а миллисекундный kafka_timestamp не округляется. created_at и updated_at приезжают в сообщении источника; ClickHouse их не создаёт и добавляет рядом собственную _load_ts.

Следствие для контракта: created_at нельзя называть временем покупки, а updated_at - created_at — длительностью бизнес-перехода. Это время между созданием и последним изменением строки в источнике. Бизнес-время живёт в событии purchase и связывается с заказом по уже существующему ключу.

Партиция и бизнес-время

created_at и updated_at — аудит строки источника. Поэтому toDate(created_at) в спецификации приёма заказов используется как стабильный технический ключ партиции ods.order_snapshot: это день создания строки источника, а не доказательство дня бизнес-события.

Минимальное решение — не добавлять ordered_at на всякий случай. Пока модель создаёт исходную строку синхронно с покупкой, существующий ключ партиции можно оставить, но в описании называть его днём создания строки. Время покупки для сверки берётся из purchase.UTCEventTime. Отдельное поле в заказе понадобится только тогда, когда появится самостоятельный учебный запрос к бизнес-времени заказа или источник начнёт сохранять заказ асинхронно. Так различие остаётся честным, но не порождает поле без потребителя.

Точность и строгий разбор

RFC 3339 — профиль ISO 8601 для обмена датой и временем. Он разрешает дробную часть секунды переменной длины и как Z, так и числовое смещение (RFC 3339, §5.6). Значит миллисекунды не следуют из названия стандарта сами по себе. Наш более узкий контракт фиксирует ровно три цифры и UTC:

YYYY-MM-DDTHH:mm:ss.SSSZ

Например: 2026-06-03T14:21:07.123Z. Одинаковое число цифр дробной части и одинаковая зона дают хронологическую сортировку таких строк в лексикографическом порядке (RFC 3339, §5.1). Три цифры выбраны потому, что источник моделирует миллисекунды. Сериализатор всегда выводит все три цифры, в том числе .000 для значения точно на границе секунды. Нельзя только выдавать секундную модель за миллисекундную простым дополнением нулей.

В ClickHouse DateTime64(3, 'UTC') хранит три десятичных знака долей секунды, то есть миллисекунды; пояс колонки используется при разборе и показе значения (DateTime64). Для этого узкого формата подходит обнуляемый разбор по точному шаблону:

parseDateTime64InJodaSyntaxOrNull(
    value,
    'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'',
    'UTC'
)

OrNull возвращает NULL при несовпадении, а три S задают точность DateTime64(3) (документация функции, исходный код ClickHouse). Это строже, чем parseDateTime64BestEffortOrNull: функция Best Effort по назначению принимает несколько представлений даты, тогда как здесь форма сама является частью учебного контракта (документация Best Effort).

На проектном ClickHouse 26.3.17.56 это выражение локально проверено. Оно возвращает Nullable(DateTime64(3, 'UTC')) для строки с .123Z и NULL для строки без миллисекунд, с четырьмя цифрами, со смещением +00:00 вместо Z или с хвостовым мусором. Поэтому один и тот же результат разбора можно использовать и для типизированной строки, и для маршрутизации ошибки; нулевая дата не нужна.

updated_at допустим как колонка версии ReplacingMergeTree: ClickHouse явно разрешает для ver тип DateTime64 и оставляет строку с максимальной версией (ReplacingMergeTree). Если две версии одного заказа имеют одинаковый updated_at, среди них побеждает вставленная позже. Для учебной модели достаточно гарантировать монотонные миллисекундные updated_at на один заказ; отдельный счётчик версий без такого сценария был бы лишним.

Дата слепка и константы мира

Периодический слепок имеет зерно заранее заданного периода — например, дня, — а не отдельной транзакции (Kimball Group: Periodic Snapshot Fact Tables). Поэтому snapshot_date — значение пачки: дата завершившегося модельного дня D, состояние которого снято на границе D|D+1. Это не UTC-дата отправки и не глобальная константа.

На один проход генератор вычисляет дату один раз и кладёт её во все записи; на следующем модельном дне значение меняется. Настоящие константы мира — начало модельной оси ORIGIN, пояс счётчика COUNTER_TIMEZONE_MINUTES и окно K = 7. Первые две уже заданы в модели времени, окно добавится в конфигурацию мира вместе с заказами. Глобальная SNAPSHOT_DATE смешала бы правило календаря с результатом его вычисления.

На старте оси дня −1 нет, поэтому прогон дня 0 ничего не отправляет. Первый слепок с snapshot_date = ORIGIN уезжает прогоном дня 1.

as_of_date тоже могло бы означать дату, по состоянию на которую показаны данные. Но snapshot_date уже является языком спеки и ADR о приёме заказов. Переименование не добавляет урока и может спутать дату выгрузки с периодом бизнес-действия записи. Для этого стенда оставляем snapshot_date.

JSON и сериализация

В JSON массив и строка — разные типы значения: массив содержит значения непосредственно, а строка содержит последовательность символов (RFC 8259, §§3, 5 и 7). Поэтому форма на проводе такая:

{
  "order_id": "20260603-0001",
  "user_id": 42,
  "status": "paid",
  "created_at": "2026-06-03T14:21:07.123Z",
  "updated_at": "2026-06-03T14:24:18.456Z",
  "items_total": "1299.90",
  "discount": "0.00",
  "delivery": "199.00",
  "total": "1498.90",
  "items": [
    {"sku": "sku-17", "qty": 1, "price": "1299.90"}
  ],
  "snapshot_date": "2026-06-07"
}

ClickHouse JSONExtractRaw(raw, 'items') возвращает выбранный фрагмент JSON неразобранной строкой (официальная документация). На проектной версии локальная проверка обычного внешнего JSON показала JSONType(..., 'items') = 'Array', а JSONExtractRaw вернул компактный текст массива, пригодный для колонки ODS String. Строка с JSON внутри потребовала бы экранировать массив при первой сериализации и разбирать его второй раз, не меняя результат в ODS.

orjson.dumps умеет сериализовать вложенные словари и списки напрямую и возвращает JSON в UTF-8 (официальный репозиторий orjson). Поэтому KISS-вариант для существующего модуля сериализации — подготовить канонические строки времени и денег, положить items списком в общий словарь и сделать один внешний dumps на запись. Класс кодеков, реестр схем и повторный dumps для items здесь ничего не учат.

Граница этого решения: toDecimal64OrNull(..., 2) проверяет числовую преобразуемость, но не лексическое правило «ровно два знака» — локально строки 1299.90, 1299.9 и 1299.900 дали одно значение. Проверка денежного формата не нужна сериализатору этого слепка: он сам выпускает ровно два знака. Приём ODS проверяет ту же каноническую форму и считает остальные формы браком; граница строгого приёма зафиксирована в спецификации заказов.