Зачем: датированная спека — событие истории, а устройство компонента — живой документ; большое полотно плохо грузится и агентом, и человеком (ADR 0011). Что: вычитание #84 слито с переустройством формы: набор docs/architecture/orders/ — индекс README и семь файлов по частям устройства (нарезка по правилу «семь плюс-минус два»); датированные файлы удалены, ссылки перенацелены, AGENTS.md дополнен правилом подпапки. Приёмка владельцем #88 пройдена, черновой статус снят из README. Проверка: холодная сверка миграции свежим тредом — потерь решений нет; обход относительных ссылок набора — битых нет. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
213 lines
17 KiB
Markdown
213 lines
17 KiB
Markdown
# Формат дневного слепка заказов на проводе
|
||
|
||
Дата исследования: 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)` в
|
||
[спецификации приёма заказов](../architecture/orders/ingestion.md)
|
||
используется как стабильный технический ключ партиции `ods.order_snapshot`:
|
||
это день создания строки
|
||
источника, а не доказательство дня бизнес-события.
|
||
|
||
Минимальное решение — не добавлять `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` уже является языком спеки и 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` дали одно значение. Проверка денежного формата
|
||
не нужна сериализатору этого слепка: он сам выпускает ровно два знака. Приём ODS
|
||
проверяет ту же каноническую форму и считает остальные формы браком; граница
|
||
строгого приёма зафиксирована в
|
||
[спецификации заказов](../architecture/orders/ingestion.md).
|