Форма записи слепка на проводе #81

Closed
opened 2026-08-12 22:50:52 +03:00 by ddmitry · 1 comment
Owner

Part of #69.

Вопрос

Чем деньги, вложенный JSON позиций и даты выглядят в сообщении топика orders?

Мастер-спека (раздел 2) назвала запись слепка со стороны хранилища: деньги —
Decimal(18,2), itemsString с вложенным JSON, created_at/updated_at
DateTime, snapshot_dateDate. Чем та же запись выглядит на проводе,
не решено ни одним документом.

Цена ошибки известна заранее. Положит генератор в JSON число 1299.9 — хранилище
получит Decimal через Float, то есть ровно через то, от чего предостерегает урок
класса C (Float64 против Decimal, мастер-спека, раздел 4). С датами репозиторий
уже наступал на соседнюю граблю: JSONExtract возвращал NULL на строке с
суффиксом Z, и вылезло это на исполнении #43, а не на бумаге (спека генератора,
раздел 4).

Решается вместе: форма денег, форма дат, экранирование вложенного JSON позиций и
то, каким сериализатором слепок собирается — канонический сериализатор события у
генератора уже есть, и второго заводить не хочется.

Блокирует #80: он эту запись разбирает и делит на годное и брак.

Part of #69. ## Вопрос Чем деньги, вложенный JSON позиций и даты выглядят в сообщении топика `orders`? Мастер-спека (раздел 2) назвала запись слепка со стороны хранилища: деньги — `Decimal(18,2)`, `items` — `String` с вложенным JSON, `created_at`/`updated_at` — `DateTime`, `snapshot_date` — `Date`. Чем та же запись выглядит **на проводе**, не решено ни одним документом. Цена ошибки известна заранее. Положит генератор в JSON число `1299.9` — хранилище получит Decimal через Float, то есть ровно через то, от чего предостерегает урок класса C (Float64 против Decimal, мастер-спека, раздел 4). С датами репозиторий уже наступал на соседнюю граблю: `JSONExtract` возвращал NULL на строке с суффиксом `Z`, и вылезло это на исполнении #43, а не на бумаге (спека генератора, раздел 4). Решается вместе: форма денег, форма дат, экранирование вложенного JSON позиций и то, каким сериализатором слепок собирается — канонический сериализатор события у генератора уже есть, и второго заводить не хочется. Блокирует #80: он эту запись разбирает и делит на годное и брак.
ddmitry added the wayfinder:grilling label 2026-08-12 22:50:52 +03:00
ddmitry self-assigned this 2026-08-16 14:57:02 +03:00
Author
Owner

Решение

Учебный результат: менти видит на одной записи границы между бизнес-временем,
аудитом источника, датой слепка и временем загрузки, не разбирая ради этого
двойной 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"
}
  • Все деньги, включая items[].price, — строки с ровно двумя знаками после
    точки. Это сохраняет десятичную запись источника и не проводит сумму через
    JSON-число/Float. Разница с кликстримом намеренна: там значения
    purchaseRevenue приезжают JSON-числами и разбираются как Array(Float64),
    а бэкенд передаёт деньги строками для точного Decimal.
  • items — обычный массив JSON, не строка с JSON внутри. В ODS колонка остаётся
    String: пакетный приём извлекает массив как сырой фрагмент через
    JSONExtractRaw(raw, 'items'), а ODS → DDS разбирает его один раз.
  • Порядок внешних ключей — порядок полей контракта выше; у позиции — sku,
    qty, price. Это даёт воспроизводимые байты без сортировки ключей.

Время

created_at и updated_at — время аудита строки по часам базы источника:
создание и последнее изменение. Это не время покупки и не время загрузки в
ClickHouse. Поля приезжают в сообщении бэкенда, которое в стенде формирует
заказная часть генератора; ClickHouse добавляет рядом собственную _load_ts.
Оба поля имеют настоящую миллисекундную точность и одну узкую форму
RFC 3339 в UTC:

YYYY-MM-DDTHH:mm:ss.SSSZ

В ClickHouse им соответствует DateTime64(3, 'UTC'); updated_at остаётся
версией для ReplacingMergeTree. Сериализатор всегда выводит три цифры долей
секунды, включая .000 для честного значения на границе секунды. Секундную
модель нельзя выдавать за миллисекундную простым дополнением нулей. Последующие
версии одного заказа имеют монотонный updated_at.

Бизнес-время покупки остаётся в purchase.UTCEventTime и связывается с заказом
по purchaseID = order_id. Нового ordered_at не добавляем: отдельного
потребителя у него нет. toDate(created_at) в описании DDS следует называть
днём создания строки источника, а не днём бизнес-события.

snapshot_date — дата завершившегося модельного дня, состояние которого снято
на исходящей границе суток. Форма — YYYY-MM-DD. Она вычисляется один раз на выгрузку и повторяется во всех её
записях; на следующем модельном дне меняется. Это не глобальная константа и не
UTC-дата отправки. Константами мира остаются D0 (ORIGIN), пояс модельного
календаря и окно K = 7. На старте оси прогон дня 0 ничего не отправляет за
несуществующий день −1; первый слепок дня 0 уезжает прогоном дня 1.

Для следующего тикета: точный nullable-разбор
parseDateTime64InJodaSyntaxOrNull с шаблоном на три миллисекундных знака
проверен на проектном ClickHouse 26.3.17.56. toDecimal64OrNull(..., 2) сам по
себе не проверяет правило «ровно два знака». Считать ли формы 1299.9 и
1299.900 браком при приёме, решает #80; эта резолюция отдельного лексического
валидатора не требует.

Сериализация

Оставляем одну границу рождения байтов — существующий модуль serialize.py.
Для слепка нужна явная функция рядом с events(...): она собирает один словарь
с вложенным списком items и один раз вызывает orjson.dumps на запись.
Классы кодеков, реестр схем, общий универсальный сериализатор и отдельный
dumps для items не добавляем.

Отклонено

  • JSON-числа для денег — теряют выбранную десятичную форму и провоцируют путь
    через Float.
  • Строка с JSON в items — требует двойной сериализации и второго разбора.
  • Секундная точность — отбрасывает точность аудита источника.
  • Глобальная SNAPSHOT_DATE — смешивает настройку мира со значением пачки.
  • Новый ordered_at и универсальный слой кодеков — сложность без текущего
    учебного потребителя.

Исследование и ссылки на первичные источники:
docs/research/2026-08-16-order-snapshot-wire-format.md.

## Решение Учебный результат: менти видит на одной записи границы между бизнес-временем, аудитом источника, датой слепка и временем загрузки, не разбирая ради этого двойной JSON или универсальный сериализатор. ### Запись на проводе ```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" } ``` - Все деньги, включая `items[].price`, — строки с ровно двумя знаками после точки. Это сохраняет десятичную запись источника и не проводит сумму через JSON-число/Float. Разница с кликстримом намеренна: там значения `purchaseRevenue` приезжают JSON-числами и разбираются как `Array(Float64)`, а бэкенд передаёт деньги строками для точного `Decimal`. - `items` — обычный массив JSON, не строка с JSON внутри. В ODS колонка остаётся `String`: пакетный приём извлекает массив как сырой фрагмент через `JSONExtractRaw(raw, 'items')`, а ODS → DDS разбирает его один раз. - Порядок внешних ключей — порядок полей контракта выше; у позиции — `sku`, `qty`, `price`. Это даёт воспроизводимые байты без сортировки ключей. ### Время `created_at` и `updated_at` — время аудита строки по часам базы источника: создание и последнее изменение. Это не время покупки и не время загрузки в ClickHouse. Поля приезжают в сообщении бэкенда, которое в стенде формирует заказная часть генератора; ClickHouse добавляет рядом собственную `_load_ts`. Оба поля имеют настоящую миллисекундную точность и одну узкую форму RFC 3339 в UTC: ```text YYYY-MM-DDTHH:mm:ss.SSSZ ``` В ClickHouse им соответствует `DateTime64(3, 'UTC')`; `updated_at` остаётся версией для `ReplacingMergeTree`. Сериализатор всегда выводит три цифры долей секунды, включая `.000` для честного значения на границе секунды. Секундную модель нельзя выдавать за миллисекундную простым дополнением нулей. Последующие версии одного заказа имеют монотонный `updated_at`. Бизнес-время покупки остаётся в `purchase.UTCEventTime` и связывается с заказом по `purchaseID = order_id`. Нового `ordered_at` не добавляем: отдельного потребителя у него нет. `toDate(created_at)` в описании DDS следует называть днём создания строки источника, а не днём бизнес-события. `snapshot_date` — дата завершившегося модельного дня, состояние которого снято на исходящей границе суток. Форма — `YYYY-MM-DD`. Она вычисляется один раз на выгрузку и повторяется во всех её записях; на следующем модельном дне меняется. Это не глобальная константа и не UTC-дата отправки. Константами мира остаются D0 (`ORIGIN`), пояс модельного календаря и окно K = 7. На старте оси прогон дня 0 ничего не отправляет за несуществующий день −1; первый слепок дня 0 уезжает прогоном дня 1. Для следующего тикета: точный nullable-разбор `parseDateTime64InJodaSyntaxOrNull` с шаблоном на три миллисекундных знака проверен на проектном ClickHouse 26.3.17.56. `toDecimal64OrNull(..., 2)` сам по себе не проверяет правило «ровно два знака». Считать ли формы `1299.9` и `1299.900` браком при приёме, решает #80; эта резолюция отдельного лексического валидатора не требует. ### Сериализация Оставляем одну границу рождения байтов — существующий модуль `serialize.py`. Для слепка нужна явная функция рядом с `events(...)`: она собирает один словарь с вложенным списком `items` и один раз вызывает `orjson.dumps` на запись. Классы кодеков, реестр схем, общий универсальный сериализатор и отдельный `dumps` для `items` не добавляем. ### Отклонено - JSON-числа для денег — теряют выбранную десятичную форму и провоцируют путь через Float. - Строка с JSON в `items` — требует двойной сериализации и второго разбора. - Секундная точность — отбрасывает точность аудита источника. - Глобальная `SNAPSHOT_DATE` — смешивает настройку мира со значением пачки. - Новый `ordered_at` и универсальный слой кодеков — сложность без текущего учебного потребителя. Исследование и ссылки на первичные источники: [`docs/research/2026-08-16-order-snapshot-wire-format.md`](https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/research/2026-08-16-order-snapshot-wire-format.md).
Sign in to join this conversation.