# Формат дневного слепка заказов на проводе Дата исследования: 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](https://nightlies.apache.org/flink/flink-docs-stable/docs/concepts/time/)). Debezium проводит ту же границу внутри одного сообщения: время изменения в исходной БД хранится отдельно от времени обработки коннектором, а их разность можно использовать как задержку ([документация коннектора PostgreSQL](https://debezium.io/documentation/reference/stable/connectors/postgresql.html#postgresql-create-events)). | Поле | Чьи часы | На какой вопрос отвечает | Форма | |---|---|---|---| | `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](https://docs.getdbt.com/docs/build/snapshots#timestamp-strategy-recommended)). Служебные метки ETL также являются отдельными метаданными процесса, а не бизнес-фактами ([Kimball Group: Audit Dimension](https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/audit-dimension/)). В этом проекте транспортная и складская оси уже разведены в [конвенции хранилища](../architecture/storage.md): `_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)` в [мастер-спеке](../specs/2026-07-30-stand-v2-realism.md) используется только как стабильный технический ключ партиции `dds.order`: это день создания строки источника, а не доказательство дня бизнес-события. Минимальное решение — не добавлять `ordered_at` на всякий случай. Пока модель создаёт исходную строку синхронно с покупкой, существующий ключ партиции можно оставить, но в описании называть его днём создания строки. Время покупки для сверки берётся из `purchase.UTCEventTime`. Отдельное поле в заказе понадобится только тогда, когда появится самостоятельный учебный запрос к бизнес-времени заказа или источник начнёт сохранять заказ асинхронно. Так различие остаётся честным, но не порождает поле без потребителя. ## Точность и строгий разбор RFC 3339 — профиль ISO 8601 для обмена датой и временем. Он разрешает дробную часть секунды переменной длины и как `Z`, так и числовое смещение ([RFC 3339, §5.6](https://www.rfc-editor.org/rfc/rfc3339#section-5.6)). Значит миллисекунды не следуют из названия стандарта сами по себе. Наш более узкий контракт фиксирует ровно три цифры и UTC: ```text YYYY-MM-DDTHH:mm:ss.SSSZ ``` Например: `2026-06-03T14:21:07.123Z`. Одинаковое число цифр дробной части и одинаковая зона дают хронологическую сортировку таких строк в лексикографическом порядке ([RFC 3339, §5.1](https://www.rfc-editor.org/rfc/rfc3339#section-5.1)). Три цифры выбраны потому, что источник моделирует миллисекунды. Сериализатор всегда выводит все три цифры, в том числе `.000` для значения точно на границе секунды. Нельзя только выдавать секундную модель за миллисекундную простым дополнением нулей. В ClickHouse `DateTime64(3, 'UTC')` хранит три десятичных знака долей секунды, то есть миллисекунды; пояс колонки используется при разборе и показе значения ([DateTime64](https://clickhouse.com/docs/sql-reference/data-types/datetime64)). Для этого узкого формата подходит обнуляемый разбор по точному шаблону: ```sql parseDateTime64InJodaSyntaxOrNull( value, 'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'', 'UTC' ) ``` `OrNull` возвращает `NULL` при несовпадении, а три `S` задают точность `DateTime64(3)` ([документация функции](https://clickhouse.com/docs/sql-reference/functions/type-conversion-functions#parsedatetime64injodasyntaxornull), [исходный код ClickHouse](https://github.com/ClickHouse/ClickHouse/blob/master/src/Functions/parseDateTime.cpp)). Это строже, чем `parseDateTime64BestEffortOrNull`: функция Best Effort по назначению принимает несколько представлений даты, тогда как здесь форма сама является частью учебного контракта ([документация Best Effort](https://clickhouse.com/docs/sql-reference/functions/type-conversion-functions#parsedatetime64besteffortornull)). На проектном ClickHouse 26.3.17.56 это выражение локально проверено. Оно возвращает `Nullable(DateTime64(3, 'UTC'))` для строки с `.123Z` и `NULL` для строки без миллисекунд, с четырьмя цифрами, со смещением `+00:00` вместо `Z` или с хвостовым мусором. Поэтому один и тот же результат разбора можно использовать и для типизированной строки, и для маршрутизации ошибки; нулевая дата не нужна. `updated_at` допустим как колонка версии `ReplacingMergeTree`: ClickHouse явно разрешает для `ver` тип `DateTime64` и оставляет строку с максимальной версией ([ReplacingMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replacingmergetree)). Если две версии одного заказа имеют одинаковый `updated_at`, среди них побеждает вставленная позже. Для учебной модели достаточно гарантировать монотонные миллисекундные `updated_at` на один заказ; отдельный счётчик версий без такого сценария был бы лишним. ## Дата слепка и константы мира Периодический слепок имеет зерно заранее заданного периода — например, дня, — а не отдельной транзакции ([Kimball Group: Periodic Snapshot Fact Tables](https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/periodic-snapshot-fact-table/)). Поэтому `snapshot_date` — значение пачки: дата завершившегося модельного дня D, состояние которого снято на границе D|D+1. Это не UTC-дата отправки и не глобальная константа. На один проход генератор вычисляет дату один раз и кладёт её во все записи; на следующем модельном дне значение меняется. Настоящие константы мира — начало модельной оси `ORIGIN`, пояс счётчика `COUNTER_TIMEZONE_MINUTES` и окно K = 7. Первые две уже заданы в [модели времени](../../generator/src/clickstream_generator/world.py), окно добавится в конфигурацию мира вместе с заказами. Глобальная `SNAPSHOT_DATE` смешала бы правило календаря с результатом его вычисления. На старте оси дня −1 нет, поэтому прогон дня 0 ничего не отправляет. Первый слепок с `snapshot_date = ORIGIN` уезжает прогоном дня 1. `as_of_date` тоже могло бы означать дату, по состоянию на которую показаны данные. Но `snapshot_date` уже является языком спеки, партиции ODS и ADR о приёме заказов. Переименование не добавляет урока и может спутать дату выгрузки с периодом бизнес-действия записи. Для этого стенда оставляем `snapshot_date`. ## JSON и сериализация В JSON массив и строка — разные типы значения: массив содержит значения непосредственно, а строка содержит последовательность символов ([RFC 8259, §§3, 5 и 7](https://www.rfc-editor.org/rfc/rfc8259)). Поэтому форма на проводе такая: ```json { "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 неразобранной строкой ([официальная документация](https://clickhouse.com/docs/sql-reference/functions/json-functions#jsonextractraw)). На проектной версии локальная проверка обычного внешнего JSON показала `JSONType(..., 'items') = 'Array'`, а `JSONExtractRaw` вернул компактный текст массива, пригодный для колонки ODS `String`. Строка с JSON внутри потребовала бы экранировать массив при первой сериализации и разбирать его второй раз, не меняя результат в ODS. `orjson.dumps` умеет сериализовать вложенные словари и списки напрямую и возвращает JSON в UTF-8 ([официальный репозиторий orjson](https://github.com/ijl/orjson)). Поэтому KISS-вариант для [существующего модуля сериализации](../../generator/src/clickstream_generator/serialize.py) — подготовить канонические строки времени и денег, положить `items` списком в общий словарь и сделать один внешний `dumps` на запись. Класс кодеков, реестр схем и повторный `dumps` для `items` здесь ничего не учат. Граница этого решения: `toDecimal64OrNull(..., 2)` проверяет числовую преобразуемость, но не лексическое правило «ровно два знака» — локально строки `1299.90`, `1299.9` и `1299.900` дали одно значение. Проверка денежного формата не нужна сериализатору этого слепка: он сам выпускает ровно два знака. Считать ли остальные формы браком при приёме, решает задача #80; это решение отдельного лексического валидатора не требует.