Files
clickstream-data-platform/generator/src/clickstream_generator/serialize.py
T
ddadmin 8e21381e13 feat(generator): добавлена команда слепков заказов
- Зачем:
  - задача #92 добавляет второй источник и показывает слепок как чистую функцию зерна и дня.
- Что:
  - добавлены окно, состояние на границе суток и канонические байты с настоящими миллисекундами.
  - добавлены команда snapshot, сдвиг D → D−1 и переиспользование проигранных дней.
  - опись дополнена хешами слепков и поведенческими тестами.
- Проверка:
  - в generator выполнены make lint, make typecheck и make test: 427 тестов.
2026-08-18 17:12:49 +03:00

160 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Канонический сериализатор: единственное место, где событие целиком → JSON.
Правило «сериализатор один» (спека генератора, разделы 4 и 6) — не про
экономию строк, а про канон: два прогона одного дня обязаны дать те же байты,
а байты рождаются здесь. Второе место, собирающее событие руками, разошлось бы
с этим по экранированию, порядку ключей или записи чисел — и разошлось бы
молча. Граница правила проходит по событию, а не по всякому JSON: вложенный
блок `ecommerce` собирает `commerce`, и это часть содержимого колонки, а не
второй сериализатор.
Что делает канон:
- **Порядок ключей — порядок контракта схемы.** Он берётся из `schema.COLUMNS`
и нигде не повторяется: два источника порядка разъехались бы при первой же
вставке колонки.
- **Все 47 ключей всегда.** Пусто по контракту — пустое значение: пустой
массив, пустая строка, ноль. Пропавший ключ увёл бы событие в брак целиком:
строгий приём хранилища сверяет набор ключей (ADR 0005).
- **Даты и время — ISO-8601** (спека, раздел 4): `EventDate` уезжает как
`2026-06-01`, `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость
сырья: менти открывает колонку `raw` обычным клиентом и разбирает событие
глазами, а число эпохи этот урок убивает.
- **Одно событие — один документ JSON**, без перевода строки внутри: приёмник
сам решает, чем их разделить.
Колонки переводятся в питоновские значения целиком, а не по строкам: numpy
делает это одним вызовом на колонку, и на дне в полсотни тысяч событий разница
заметна. Обратная сторона — день лежит в памяти дважды; проигрыватель поэтому
и берёт его днями, а не горизонтом целиком.
**Второй контракт провода — слепок заказов** (мастер-спека, раздел 2). Он не
похож на событие: одиннадцать ключей вместо сорока семи, деньги строками, а
не числами, времена с миллисекундами. Общее у них одно, зато главное: байты
рождаются здесь и только здесь. Запись слепка — один словарь с вложенным
списком и один `orjson.dumps`.
"""
from collections.abc import Sequence
from datetime import timedelta
from typing import Any
import numpy as np
import orjson
from numpy.typing import NDArray
from clickstream_generator import catalog, schema, world
from clickstream_generator.day import Day
from clickstream_generator.orders import Orders, at_boundary
_ARRAY_PREFIX = "Array("
def events(day: Day, limit: int | None = None) -> list[bytes]:
"""Канонические байты событий дня: по документу JSON на событие.
`limit` берёт первые события дня и на этом останавливается — срез для
того, кто смотрит на конвейер и не хочет ждать целый день (спека,
раздел 9). Ограничение считается до сериализации: платить за то, что не
поедет, незачем.
"""
count = len(day) if limit is None else min(limit, len(day))
names = tuple(column.name for column in schema.COLUMNS)
values = [
_values(column, day.columns[column.name][:count]) for column in schema.COLUMNS
]
return [
orjson.dumps(dict(zip(names, row, strict=True)))
for row in zip(*values, strict=True)
]
def orders(window: Sequence[Orders], day: int) -> list[bytes]:
"""Канонические байты слепка дня `day`: по документу JSON на заказ.
`window` — заказы дней окна, от раннего дня к позднему: слепок несёт их
подряд, и порядок строк выходит порядком рождения заказов, он же
возрастание `order_id`. Какие это дни, решает `orders.window`.
Деньги уезжают строками с ровно двумя знаками, а не числами: у заказа они
станут `Decimal`, и дробь двоичного числа была бы потерей точности до
всякого разбора. Времена — метки UTC с миллисекундами; `snapshot_date`
одинакова во всей выгрузке — это дата дня, состояние которого снято.
"""
goods = catalog.catalog()
sku = goods.sku.tolist()
prices = [_money(price) for price in goods.price.tolist()]
snapshot_date = (world.ORIGIN + timedelta(days=day)).isoformat()
payloads = []
for rows in window:
status, updated = at_boundary(rows, day)
created_at = _moments(rows.created_at)
updated_at = _moments(updated)
user_id = rows.user_id.tolist()
items_total = rows.items_total.tolist()
discount = rows.discount.tolist()
delivery = rows.delivery.tolist()
total = rows.total.tolist()
for number, order_id in enumerate(rows.order_id):
payloads.append(
orjson.dumps(
{
"order_id": order_id,
"user_id": user_id[number],
"status": status[number],
"created_at": created_at[number],
"updated_at": updated_at[number],
"items_total": _money(items_total[number]),
"discount": _money(discount[number]),
"delivery": _money(delivery[number]),
"total": _money(total[number]),
"items": [
{"sku": sku[item], "qty": count, "price": prices[item]}
for item, count in zip(
rows.product[number].tolist(),
rows.quantity[number].tolist(),
strict=True,
)
],
"snapshot_date": snapshot_date,
}
)
)
return payloads
def _money(kopecks: int) -> str:
"""Копейки — строкой с ровно двумя знаками: `129990` → `1299.90`."""
return f"{kopecks // 100}.{kopecks % 100:02d}"
def _moments(values: NDArray[np.datetime64]) -> list[str]:
"""Метки времени — строками RFC 3339 в UTC с миллисекундами.
Три знака стоят всегда, в том числе `.000`: одинаковая длина дробной части
и одинаковая зона дают хронологическую сортировку простым сравнением строк,
а разбор в хранилище идёт по точному шаблону.
"""
ms: NDArray[np.datetime64] = values.astype("datetime64[ms]")
return np.datetime_as_string(ms, unit="ms", timezone="UTC").tolist()
def _values(column: schema.Column, values: NDArray[Any]) -> list[Any]:
"""Колонка питоновскими значениями — в той записи, в какой уедет на провод.
Массив узнаётся по типу ClickHouse, а не по `numpy_dtype`: у колонки-массива
там записан тип элемента (`uint32`), и от скалярной колонки её этим не
отличить.
"""
if column.clickhouse_type.startswith(_ARRAY_PREFIX):
# Колонка-массив: в ячейке лежит свой массив, пустой у события,
# которому эта колонка не по смыслу.
return [cell.tolist() for cell in values]
if column.numpy_dtype == "datetime64[D]":
return np.datetime_as_string(values, unit="D").tolist()
if column.numpy_dtype == "datetime64[s]":
return np.datetime_as_string(values, unit="s", timezone="UTC").tolist()
return values.tolist()