docs(specs): спека генератора (этап 2) из решений карты #26 #35
@@ -87,6 +87,12 @@ Airflow) и названия из кода. Если для понятия ес
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Развилки решений
|
||||
|
||||
Существенные развилки — в грилинге, на тикетах wayfinder-карт и вне их —
|
||||
вести через скилл `brainstorm-with-docs`: сначала веер вариантов, потом
|
||||
конвергенция. Состав веера и запись отклонённых вариантов — в самом скилле.
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Задачи — в Gitea на `git.dementev.space`, все операции через CLI `tea`.
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Кликстрим-платформа (стенд v2)
|
||||
|
||||
Словарь понятий проекта: одни и те же слова для одних и тех же вещей —
|
||||
у владельца, кода, документов и агентов. Только язык, никаких решений.
|
||||
|
||||
## Язык
|
||||
|
||||
**Хранилище**:
|
||||
Аналитическая база стенда — кластер ClickHouse со слоями STG/ODS/DDS/DM.
|
||||
Принимающая сторона границы «трекер | хранилище»: нормализует имена и стили
|
||||
источников, строится по их документации.
|
||||
_Избегать_: склад, склад данных
|
||||
|
||||
**Состав мира**:
|
||||
Постоянная часть мира генератора — популяция посетителей с календарём их
|
||||
появления, привычки, календарь двухкуковых пар. Чистая функция зерна:
|
||||
вычисляется при старте любого процесса, между прогонами не хранится.
|
||||
_Избегать_: состояние мира
|
||||
|
||||
**Ось модельного времени**:
|
||||
Собственный календарь мира генератора. Дни считаются от фиксированного
|
||||
первого дня D0; реальный календарь в модели не участвует. Между прогонами
|
||||
живут только зерно и позиция на оси.
|
||||
|
||||
**Пошаговый режим**:
|
||||
Базовый способ движения по оси модельного времени: «прожить следующий
|
||||
день» — явное действие.
|
||||
|
||||
**Живой день**:
|
||||
Проигрывание текущего модельного дня в реальном времени с ускорением;
|
||||
включается по требованию, не постоянный фон.
|
||||
|
||||
**Пакетный режим**:
|
||||
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
|
||||
стенда, пересборки и проверки.
|
||||
|
||||
**Граница суток**:
|
||||
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
|
||||
снимается на ней, день проживается только целиком.
|
||||
|
||||
**Контракт схемы**:
|
||||
Python-модуль с описателями колонок события — собственность генератора.
|
||||
Из него выводятся генератор, валидация и документация формата; хранилище
|
||||
строится по документации, не по модулю.
|
||||
|
||||
**Канонический сериализатор**:
|
||||
Единственное место, где событие превращается в байты. Фиксированный порядок
|
||||
ключей и строк — основа побайтовой воспроизводимости.
|
||||
|
||||
**Проигрыватель**:
|
||||
Компонент доставки готового потока дня в приёмник. Два режима: пакетный
|
||||
(пачкой, без темпа) и живой день.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Скорость батчевой генерации событийных данных в Python: порядки величин
|
||||
|
||||
Дата: 2026-08-01. Тикет: #31 (часть карты #26). Потребитель: развилка
|
||||
производительности генератора (#30, спека v2, разделы 9 и 11).
|
||||
|
||||
## Вопрос
|
||||
|
||||
Какой скорости (строк или событий в секунду) реально ждать от батчевой
|
||||
генерации событий в Python и что для неё берут: numpy/векторная генерация
|
||||
против посточной, orjson против stdlib json, multiprocessing по дням?
|
||||
Ответ — по первоисточникам (документация и бенчмарки авторов библиотек).
|
||||
|
||||
## Краткий вывод
|
||||
|
||||
Порядки величин на одно ядро CPU:
|
||||
|
||||
| Подход | Порядок скорости | Опора |
|
||||
|---|---|---|
|
||||
| Случайные значения numpy (векторно) | ~10^8 значений/с | таблица numpy |
|
||||
| Векторная сборка колонок события | ~10^6–10^7 строк/с | локальная проверка |
|
||||
| Посточная генерация (random + dict) | ~10^4–10^5 строк/с | локальная проверка |
|
||||
| Посточная генерация через Faker | ~10^3–10^4 значений/с | бенчмарк mimesis |
|
||||
| Сериализация orjson (запись ~50 КБ) | ~10^5 док/с | README orjson |
|
||||
| Сериализация stdlib json (та же запись) | ~10^4 док/с | README orjson |
|
||||
|
||||
Ключевые множители: векторная генерация быстрее посточной на 1–2 порядка;
|
||||
orjson быстрее stdlib json в 5–14 раз на сериализации; multiprocessing
|
||||
умножает всё на число ядер, потому что обходит GIL подпроцессами.
|
||||
|
||||
Узкое место конвейера «сгенерировать → JSON в Kafka» — не случайные числа
|
||||
(они почти бесплатны), а сборка посточных словарей и их сериализация:
|
||||
это ~2–5×10^5 событий/с на ядро, дальше масштабирование только процессами.
|
||||
|
||||
## Находки по первоисточникам
|
||||
|
||||
### 1. orjson против stdlib json — бенчмарки автора
|
||||
|
||||
Источник: README orjson, раздел Performance —
|
||||
<https://github.com/ijl/orjson#performance>. Среда автора: Python 3.11.10,
|
||||
Fedora 42, x86-64-v4; скрипт `pybench` в репозитории.
|
||||
|
||||
Сериализация (медиана, операций в секунду):
|
||||
|
||||
| Файл | orjson | json | Ускорение |
|
||||
|---|---|---|---|
|
||||
| twitter.json (~600 КБ) | 8 453 | 765 | 11,1x |
|
||||
| github.json (~55 КБ) | 103 693 | 7 648 | 13,6x |
|
||||
| citm_catalog.json | 3 975 | 338 | 11,8x |
|
||||
| canada.json | 399 | 33 | 11,9x |
|
||||
|
||||
Десериализация ускоряется скромнее: 2,2–6x на тех же файлах.
|
||||
|
||||
Отдельно важное для генератора: orjson сериализует `numpy.ndarray` нативно
|
||||
(опция `OPT_SERIALIZE_NUMPY`). Числа автора: массив float64 на 92 МиБ —
|
||||
105 мс против 1 481 мс у json (14,2x); int32 на 100 МиБ — 68 мс против
|
||||
684 мс (10,1x). `datetime` тоже сериализуется нативно в RFC 3339, без
|
||||
обёрток и `default=`.
|
||||
|
||||
Документация stdlib json заявлений о скорости не делает вовсе
|
||||
(<https://docs.python.org/3/library/json.html>). Факт «чистый Python
|
||||
с C-ускорителем» виден только в исходниках CPython: `Lib/json/encoder.py`
|
||||
импортирует `c_make_encoder` из `_json` с откатом на Python-реализацию
|
||||
(<https://github.com/python/cpython/blob/main/Lib/json/encoder.py>).
|
||||
|
||||
### 2. Векторная генерация случайных значений — документация numpy
|
||||
|
||||
Источник: официальная страница производительности генераторов —
|
||||
<https://numpy.org/doc/stable/reference/random/performance.html>.
|
||||
Среда: Linux, AMD Ryzen 9 3900X. Единицы — наносекунды на одно значение.
|
||||
|
||||
| Распределение | PCG64 | SFC64 | MT19937 |
|
||||
|---|---|---|---|
|
||||
| uint32 | 1,9 | 1,8 | 3,3 |
|
||||
| uint64 | 3,2 | 2,5 | 5,6 |
|
||||
| равномерное | 3,1 | 2,6 | 5,9 |
|
||||
| нормальное | 10,8 | 8,3 | 13,9 |
|
||||
| пуассоновское | 103,4 | 90,7 | 111,7 |
|
||||
|
||||
То есть 3 нс на равномерное значение — это ~3×10^8 значений/с на ядро.
|
||||
Даже для широкого события в ~47 колонок чистая генерация значений даёт
|
||||
миллионы строк в секунду: не она ограничивает конвейер. Рекомендация
|
||||
numpy — PCG64 (или PCG64DXSM для сильно параллельных сценариев).
|
||||
|
||||
### 3. multiprocessing — документация Python
|
||||
|
||||
Источник: <https://docs.python.org/3/library/multiprocessing.html>.
|
||||
Дословно: пакет «effectively side-stepping the Global Interpreter Lock by
|
||||
using subprocesses instead of threads» — то есть генерация по независимым
|
||||
модельным дням масштабируется числом ядер, а не упирается в GIL.
|
||||
|
||||
Практические указания из тех же доков: `Pool.map` режет итерируемое на
|
||||
куски (`chunksize`), а для длинных последовательностей «using a large value
|
||||
for chunksize can make the job complete much faster than using the default
|
||||
value of 1» (про `imap`). Для нашей схемы «один процесс — один день» это
|
||||
неактуально: единица работы и так крупная.
|
||||
|
||||
### 4. Faker и цена посточной генерации
|
||||
|
||||
Сам Faker чисел не публикует, но признаёт цену взвешенного выбора:
|
||||
конструктор принимает `use_weighting`, и при `False` «the selection process
|
||||
is much faster» (<https://faker.readthedocs.io/en/master/index.html>,
|
||||
раздел Optimizations).
|
||||
|
||||
Числа даёт авторский бенчмарк mimesis против Faker —
|
||||
<https://mimesis.name/master/benchmarks.html> (mimesis 19.0.0, MacBook Pro
|
||||
M1 Pro). На 47 сопоставимых операциях mimesis выиграл все сравнения,
|
||||
суммарное ускорение ~24x; по категориям — от 5x (Datetime) до 69x (Finance).
|
||||
Порядок абсолютных цифр: сотни микросекунд на вызов Faker — то есть
|
||||
~10^3–10^4 значений/с. Оговорка: единицы в таблицах страницы внутренне
|
||||
противоречивы (µs против ms не сходятся с заявленным 24x), надёжны именно
|
||||
коэффициенты ускорения, абсолютные значения — с осторожностью.
|
||||
|
||||
Вывод для нас: Faker в горячем цикле на десятки миллионов событий — это
|
||||
узкое место на 2–3 порядка хуже numpy. Его место — генерация небольших
|
||||
справочников (каталог, имена), не потока событий.
|
||||
|
||||
### 5. Кросс-проверка: msgspec
|
||||
|
||||
Бенчмарки автора msgspec (<https://msgspec.dev/benchmarks>, CPython 3.11):
|
||||
msgspec со схемами быстрее всех, без схем — «on-par with orjson (the next
|
||||
fastest JSON library)». Это подтверждает, что orjson — верхняя планка
|
||||
скорости JSON в Python без введения схем; выигрыш msgspec — скорее память
|
||||
(на декоде 77 МиБ: 67,6 МиБ пика против 406,3 МиБ у orjson).
|
||||
|
||||
## Локальная проверка порядков (не первоисточник)
|
||||
|
||||
Микробенчмарк на машине стенда (8 логических ядер, Python 3.14, `uv run
|
||||
--with numpy --with orjson`), 200 тыс. строк по 15 колонок:
|
||||
|
||||
- numpy векторно: ~6,4 млн строк/с;
|
||||
- посточно (stdlib random + dict): ~71 тыс. строк/с — разрыв ~90x;
|
||||
- orjson.dumps по строке: ~560 тыс. строк/с; json.dumps: ~120 тыс. (4,7x);
|
||||
- транспонирование «колонки → посточные dict» (нужно для JSON в Kafka):
|
||||
~275 тыс. строк/с — именно оно, а не RNG, становится узким местом
|
||||
векторного пути;
|
||||
- multiprocessing, 4 процесса на независимых «днях»: x1,9 на маленькой
|
||||
нагрузке (старт пула съедает долю; на крупных днях доля падает).
|
||||
|
||||
Локальные числа сходятся с первоисточниками по порядку величины: разрыв
|
||||
orjson/json на маленьких записях меньше README-шного (4–5x против 11–14x —
|
||||
у автора записи крупнее), остальное совпадает.
|
||||
|
||||
## Что это значит для генератора стенда
|
||||
|
||||
Без принятия решений — решает тикет #30; здесь только опора для него.
|
||||
|
||||
- Реалистичная планка конвейера «генерация + orjson посточно» —
|
||||
~2–5×10^5 событий/с на ядро, с multiprocessing по дням — умножить на
|
||||
число ядер. Например, 10 млн событий — это десятки секунд на одном ядре
|
||||
и секунды на восьми.
|
||||
- Случайные числа брать векторно из numpy (PCG64) — их стоимость можно не
|
||||
учитывать в бюджете. Посточный цикл оставить только там, где логика
|
||||
действительно посточная (цепочки сессий), и не звать в нём Faker.
|
||||
- Живой поток ×60: даже 100 тыс. событий модельного дня при ×60 — это
|
||||
~70 событий/с реального времени; на 3–4 порядка ниже планки, скоростью
|
||||
не ограничен.
|
||||
- Довод спеки «компилируемый язык только если замеры покажут, что Python
|
||||
не тянет» получает численную опору: до ~10^6 событий/с суммарно Python
|
||||
с батчевой архитектурой укладывается, дальше — территория Rust/Go.
|
||||
@@ -56,7 +56,7 @@ JSON-поле `ecommerce`. Сессий в потоке нет — их мент
|
||||
- **Имена колонок — как в облачной выгрузке Метрики** (`ClientID`,
|
||||
`UTCEventTime`, `purchaseID`…). Сырой слой хранит имена источника; свои
|
||||
snake_case-имена появляются в DDS/DM. Это учебный пункт: у каждого
|
||||
источника — свой стиль, нормализует его склад, а не трекер.
|
||||
источника — свой стиль, нормализует его хранилище, а не трекер.
|
||||
- **Идентификаторы — числовые UInt64** (`WatchID`, `VisitID`, `ClientID`),
|
||||
UUID уходят. Исследование советовало UUID не трогать, но тот совет исходил
|
||||
из цены переделки текущего генератора; v2 пишет генератор заново, цена
|
||||
@@ -163,12 +163,18 @@ Ecommerce (заполнены только у торговых событий):
|
||||
|
||||
### 1.4 Схема как контракт
|
||||
|
||||
Одно машинное описание схемы события (python-модуль или YAML) — источник
|
||||
истины: из него выводятся DDL и валидация генератора, а не наоборот. 47
|
||||
колонок повторяются примерно в семи местах (генератор, DDL, SELECT матвью,
|
||||
трансформации, витрины, манифест, доки) — без контракта они расходятся
|
||||
молча. Заодно это учебный артефакт: менти видит на живом примере, что такое
|
||||
«схема как контракт».
|
||||
Машинное описание схемы события — python-модуль с чистыми данными,
|
||||
собственность генератора (data contract; решение развилки «Архитектура»
|
||||
карты #26, подробности — [спека генератора](2026-08-01-generator.md),
|
||||
раздел 3). Из контракта выводятся сам генератор, его валидация и
|
||||
рендеренное «описание выгрузки» в доках — аналог документации Метрики.
|
||||
Сторона хранилища (DDL, SELECT матвью, трансформации, витрины) пишется по
|
||||
этой документации на своих этапах, как в бою хранилище адаптируется к
|
||||
источнику; границу сторожат строгий приём (раздел 6) и contract-тест в
|
||||
smoke — сравнение `system.columns` поднятого стенда со схемой генератора.
|
||||
Без контракта 47 колонок, повторяясь примерно в семи местах, расходятся
|
||||
молча. Заодно это учебный артефакт: менти видит на живом примере, что
|
||||
такое data contract.
|
||||
|
||||
## 2. Заказы бэкенда
|
||||
|
||||
@@ -375,7 +381,8 @@ ReplacingMergeTree. Таблицы `stg.*_raw` хранят виртуальны
|
||||
(`_topic`, `_partition`, `_offset`, `_timestamp`) — без них урок «какая нода
|
||||
читала топик» ненаблюдаем. `stg.hits_raw` дополнительно хранит извлечённый
|
||||
`event_date` — им кормится переобработка дня X (при исчерпании retention
|
||||
Kafka переобработка возможна только из эталонного артефакта).
|
||||
Kafka день переигрывается генератором заново: снимок — кэш чистой функции,
|
||||
см. [спеку генератора](2026-08-01-generator.md)).
|
||||
|
||||
`dds.v_event` — первый на стенде пример правила «слой — это контракт, а не
|
||||
обязательно копия данных».
|
||||
@@ -425,9 +432,15 @@ Kafka переобработка возможна только из эталон
|
||||
|
||||
## 8. Эталонный мир и манифест
|
||||
|
||||
Пересборка артефакта `data/startup_history/` неизбежна и оплачена решением
|
||||
#18 один раз — все изменения генератора съезжаются в одну пересборку.
|
||||
Манифест расширяется контрольными числами:
|
||||
В git хранится только манифест эталонного мира; сам снимок (14 модельных
|
||||
дней) генерируется на месте — при `make up` и при проверках (решение
|
||||
развилки «Производительность» карты #26, подробности —
|
||||
[спека генератора](2026-08-01-generator.md), раздел 5). Манифест несёт
|
||||
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
|
||||
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
||||
сравни хеш» живут на нём. Политика версионирования артефакта (бывший туман
|
||||
карты #10) закрыта этим же ходом: версионируется манифест.
|
||||
Контрольные числа манифеста:
|
||||
|
||||
- заказная сторона: заказы и выручка по дням; манифест хранит точные
|
||||
счётчики по каждому классу расхождений (отмены, потери, дубли, дельты сумм) —
|
||||
@@ -435,9 +448,8 @@ Kafka переобработка возможна только из эталон
|
||||
- идентичность: uniq кук, uniq известных пользователей, число двухкуковых
|
||||
покупателей — лаба склейки получает самопроверку.
|
||||
|
||||
Артефакт вырастет (ecommerce-массивы, заказы) — размер проверить при
|
||||
пересборке. Политика версионирования артефакта здесь не решается (туман
|
||||
карты #10).
|
||||
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
|
||||
при пересборке.
|
||||
|
||||
## 9. Оценка объёма исполнения
|
||||
|
||||
@@ -451,7 +463,7 @@ v2 стартует пустым, поэтому объём ниже — это
|
||||
| SQL | 5 DDL-файлов (ON CLUSTER, Replicated*, Distributed) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
|
||||
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка, ожидание дневного батча заказов — сенсор/Datasets), world_init/next_day, helpers | M — ~5–6 файлов |
|
||||
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
|
||||
| Эталонный мир | сборка артефакта v2, манифест-счётчики, чек-скрипты | M–L |
|
||||
| Эталонный мир | пересборка снимка на месте, манифест-счётчики, чек-скрипты | M–L |
|
||||
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
|
||||
| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR |
|
||||
|
||||
@@ -496,7 +508,8 @@ v2 стартует пустым, поэтому объём ниже — это
|
||||
5. Airflow: `etl_pipeline` (партиционная переобработка, ожидание дневного
|
||||
батча заказов — сенсор/Datasets).
|
||||
6. Расхождения B+D и опоздания; счётчики манифеста.
|
||||
7. Эталонный мир: пересборка артефакта, чек-скрипты.
|
||||
7. Эталонный мир: манифест и пересборка снимка, чек-скрипты; CI-генерация
|
||||
на amd64 и arm64.
|
||||
8. Superset-дашборд v2.
|
||||
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
||||
и границы — ADR 0002.
|
||||
@@ -510,7 +523,6 @@ smoke-проверки, а не «дашборд зелёный». Это мин
|
||||
|
||||
- «Грязь» в данных: боты, дубли на транспорте, опоздавшие мобильные батчи,
|
||||
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
|
||||
- Политика версионирования эталонного артефакта — туман карты.
|
||||
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
|
||||
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
|
||||
переносится. Спека даёт будущим лабам только опорные точки — контрольные
|
||||
@@ -550,10 +562,11 @@ smoke-проверки, а не «дашборд зелёный». Это мин
|
||||
(батчевая генерация вместо посточной, быстрая JSON-сериализация,
|
||||
распараллеливание по модельным дням). Читаемость генератора для менти —
|
||||
не довод при выборе языка: он в любом случае сложнее уровня DE-джуна.
|
||||
До этапа 3 зафиксировать требования производительности (пересборка
|
||||
эталонного мира, живой поток ×60); переход на компилируемый язык
|
||||
(Rust/Go) — только если замеры покажут, что Python приемлемой скорости
|
||||
не даёт.
|
||||
Числовые требования производительности и способ замера зафиксированы
|
||||
[спекой генератора](2026-08-01-generator.md), раздел 5 (порядки величин —
|
||||
[исследование](../research/2026-08-01-python-batch-generation-speed.md));
|
||||
переход на компилируемый язык (Rust/Go) — только если живые замеры
|
||||
этапа 2 выйдут за её порог.
|
||||
|
||||
## 12. Влияние на документацию
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
# Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости
|
||||
|
||||
Статус: Proposed — ждёт приёмки владельцем (тикет #33).
|
||||
Дата: 2026-08-01. Мандат — тикет #14 (этап 2, родитель #4), карта #26.
|
||||
Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода
|
||||
(#29), производительность (#30); исследование скорости (#31).
|
||||
Источники: мастер-спека
|
||||
[«Боевой реализм стенда (v2)»](2026-07-30-stand-v2-realism.md) — разделы 1.4,
|
||||
5, 9–11; заметка
|
||||
[«Скорость батчевой генерации в Python»](../research/2026-08-01-python-batch-generation-speed.md).
|
||||
|
||||
## Зачем
|
||||
|
||||
Мастер-спека решила, **что** генерирует стенд: широкое событие в 47 колонок,
|
||||
таксономию, анонимность, двухкуковых покупателей, заказы слепками. Четыре
|
||||
развилки о том, **как** генератор устроен, она отложила: модель мира,
|
||||
границы детерминизма, источник истины схемы с разделением потока и пакета,
|
||||
числовые требования скорости. Карта #26 эти развилки прошла; спека собирает
|
||||
решения в одну картину. По ней этап 2 режется на тикеты (#34).
|
||||
|
||||
Здесь не переоткрывается решённое мастер-спекой: модель данных события,
|
||||
таксономия, анонимность, механика заказов (границы мандата #14). Генератор
|
||||
слепков заказов — этап 3: эта спека лишь не должна ему мешать. Один
|
||||
осознанный выход за границы: решение о хранении снимка (раздел 5) формально
|
||||
касается этапа 7 — расширение подтверждено владельцем в резолюции
|
||||
«Производительности».
|
||||
|
||||
## Целевая картина одним взглядом
|
||||
|
||||
- **Мир — функция, не состояние.** Состав мира — чистая функция зерна;
|
||||
модельный день D — функция (зерно, D). Между прогонами живут только зерно
|
||||
и позиция на оси времени.
|
||||
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
|
||||
хешами манифеста. Транспорт (офсеты Kafka, темп) — вне обещания.
|
||||
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
|
||||
хранилище строится по рендеренной документации, границу сторожит
|
||||
contract-тест.
|
||||
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
|
||||
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
|
||||
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок — 14 дней;
|
||||
в git — только манифест; автоматический порог один — день ≤ 30 с.
|
||||
|
||||
## 1. Модель мира
|
||||
|
||||
Резолюция развилки [«Модель мира»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/27).
|
||||
|
||||
- **Мир функциональный, ничего не мутирует.** Постоянный состав мира —
|
||||
популяция посетителей, их привычки, календарь двухкуковых пар — чистая
|
||||
функция зерна, вычисляется при старте любого процесса. День D — функция
|
||||
(зерно, D); межднёвные связи (окно заказов K, опоздания) выводятся из
|
||||
плана состава, а не копятся в состоянии. Глобальные инварианты («каждый
|
||||
двухкуковый покупатель заказал с обеих кук») гарантируются планом —
|
||||
счётчики манифеста известны до генерации событий.
|
||||
- **Состав не замкнут: посетители появляются и затухают.** План состава
|
||||
задаёт календарь появления — у каждого посетителя есть дата первого
|
||||
визита и профиль возвратов, включая затухание: заметная доля кук
|
||||
одноразовая, как в живом трафике. Новые посетители появляются на всём
|
||||
протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная
|
||||
аудитории не сходятся в одно число. Приток — часть плана, а не мутация:
|
||||
счётчики манифеста по-прежнему известны до генерации (уточнение по
|
||||
вычитке владельца, 2026-08-01); его числа — раздел 9.
|
||||
- **Своя ось модельного времени.** Мир рождается в фиксированный день D0
|
||||
(понедельник — см. раздел 5); реальный календарь в модели не участвует.
|
||||
В `EventDate`/`UTCEventTime` дни оси ложатся конкретными датами, но это
|
||||
константа мира, от даты запуска не зависящая (значение — раздел 9).
|
||||
Между прогонами живут только зерно и позиция на оси: выключенный ноутбук —
|
||||
мир замер, потом продолжил.
|
||||
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
||||
с эталонного снимка, дальше «прожить следующий день» — явное действие.
|
||||
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
|
||||
«дышат»; включается по требованию, не постоянный фон.
|
||||
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
|
||||
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
|
||||
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
|
||||
переигрывается (раздел 4).
|
||||
- **Поток и пакет совместимы по построению.** День-функция выдаёт один
|
||||
упорядоченный поток событий; режимы отличаются только способом
|
||||
проигрывания — пачкой или с темпом.
|
||||
|
||||
Отклонено с доводами:
|
||||
|
||||
- *Мутирующее состояние мира* («мир стареет»): ломает параллельность по
|
||||
дням, требует чекпоинтов, счётчики манифеста узнаваемы только постфактум;
|
||||
ни один урок стенда на старении не стоит.
|
||||
- *Чистая функция без слоя состава*: глобальные инварианты пришлось бы
|
||||
выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
|
||||
- *Привязка модельного времени к реальному календарю* (T-1 с догоном):
|
||||
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает
|
||||
даты эталонного мира зависимыми от даты запуска, манифест теряет
|
||||
воспроизводимость.
|
||||
|
||||
## 2. Детерминизм от зерна
|
||||
|
||||
Резолюция развилки [«Детерминизм от зерна»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/28).
|
||||
|
||||
- **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же
|
||||
набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени.
|
||||
Снимок при пересборке побайтово совпадает: канонический порядок ключей и
|
||||
строк; сверка — по хешам манифеста, а два локально пересобранных снимка
|
||||
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне
|
||||
обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала,
|
||||
`_ingested_at`, темп живого дня.
|
||||
- **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock`
|
||||
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL
|
||||
тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет
|
||||
скрипт «пересгенерируй день N — сравни хеш с манифестом». Расхождение на
|
||||
любой платформе — баг генератора, а не допуск.
|
||||
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → состав мира;
|
||||
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
|
||||
торговые события, расхождения, опоздания — в фиксированном порядке.
|
||||
По построению: параллельный прогон равен последовательному; продление
|
||||
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет
|
||||
только его часть снимка — в манифесте меняются хеши только затронутых
|
||||
дней, дифф двух локальных пересборок читаем.
|
||||
- **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через
|
||||
Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей
|
||||
расширением `spawn_key`, потомок полностью определяется парой
|
||||
(entropy, spawn_key) — позицией в дереве, а не порядком вычислений. Это
|
||||
ровно то свойство, на котором держатся три гарантии предыдущего пункта.
|
||||
Каждый подпоток кормит `PCG64` — генератор, рекомендованный numpy.
|
||||
- **Дисциплина целочисленной случайности.** Случайность тянется целыми
|
||||
числами: диапазоны, выбор из таблиц. Плавающие распределения из системной
|
||||
математики не используются — это снимает межархитектурные расхождения
|
||||
amd64/arm64. Деньги считаются в целых копейках; Float64 — только
|
||||
представление в клиентском `purchase` (урок мастер-спеки о расхождениях).
|
||||
- **Канонический seed и паспорт мира.** Эталонный мир собирается одним
|
||||
каноническим зерном — константой репозитория; свои зёрна менти крутит без
|
||||
гарантий манифеста. Манифест хранит паспорт мира — зерно и версию
|
||||
генератора; чек-скрипты сверяют паспорт раньше счётчиков.
|
||||
- **Суточный профиль интенсивности задаёт день-функция.** Форма — волны:
|
||||
ночной провал, обеденный и вечерний пики, различие будней и выходных;
|
||||
пики — до ~2× среднего. С детерминизмом профиль совместим: это часть
|
||||
функции дня, а не внешний шум.
|
||||
|
||||
Отклонено с доводами:
|
||||
|
||||
- *Воспроизводимы только состав мира и счётчики*: ломает доигрывание дня
|
||||
через дедуп (другие `WatchID` — дубли вместо склейки) и воспроизводимую
|
||||
отладку.
|
||||
- *События те же, байты не обещаем*: экономия копеечная, а честный diff
|
||||
снимка и тесты «хеш совпал» теряются.
|
||||
- *Общий RNG-поток на все дни*: порядок исполнения менял бы результат —
|
||||
«параллельно равно последовательно» недостижимо.
|
||||
- *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно
|
||||
улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет
|
||||
словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная
|
||||
пересборка манифеста одним PR.
|
||||
|
||||
## 3. Контракт схемы
|
||||
|
||||
Резолюция развилки [«Архитектура»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/29), часть первая.
|
||||
|
||||
- **Граница вывода — по шву «трекер | хранилище», как data contract.**
|
||||
Контракт схемы — собственность генератора, как формат выгрузки —
|
||||
собственность Метрики. Из контракта выводятся: сам генератор, его
|
||||
валидация и публичное «описание выгрузки» в доках — рендеренная таблица
|
||||
колонок, аналог документации Метрики.
|
||||
- **Форма контракта — импортируемый python-модуль с чистыми данными**:
|
||||
описатели колонок (имя Метрики, тип ClickHouse, тип numpy, snake_case-имя
|
||||
для DDS, группа полей, порядок), никакой логики. Читаемость для менти
|
||||
несёт рендеренная таблица в доках, не модуль.
|
||||
- **Сторона хранилища пишется по документации, не генерируется.** DDL
|
||||
`ods.event`, SELECT матвью, `dds.v_event`, трансформации — работа
|
||||
следующих этапов по «описанию выгрузки», как в бою хранилище адаптируется
|
||||
к источнику. Границу сторожат два боевых механизма: строгий приём
|
||||
(`input_format_skip_unknown_fields = 0`, таблицы `*_errors` — раздел 6
|
||||
мастер-спеки) и contract-тест в smoke — сравнение `system.columns`
|
||||
поднятого стенда со схемой генератора.
|
||||
|
||||
Отклонено с доводами:
|
||||
|
||||
- *Автогенерация DDL хранилища из контракта* (буква раздела 1.4
|
||||
мастер-спеки до правки): пересекает границу ответственности компонент —
|
||||
в бою хранилище адаптируется к источнику руками, менти пришлось бы
|
||||
объяснять приём, которого в жизни нет. Data contract даёт тот же щит от
|
||||
дрейфа без этой условности.
|
||||
- *YAML как форма контракта*: красота ценой загрузчика и «схемы для схемы»;
|
||||
потребителя вне Python нет — хранилище читает рендеренную документацию,
|
||||
не машинный файл.
|
||||
|
||||
## 4. Поток и пакет: сериализатор, проигрыватель, приёмники
|
||||
|
||||
Резолюция развилки [«Архитектура»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/29), часть вторая.
|
||||
|
||||
- **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт
|
||||
упорядоченный поток канонических байтов — единственное место, где событие
|
||||
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш
|
||||
для пересборки и проверок манифеста), Kafka пачкой — пакетный режим,
|
||||
Kafka с темпом ×60 — живой день. Новых топиков нет.
|
||||
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
|
||||
numpy-массивы и datetime сериализует нативно (заметка исследования #31).
|
||||
Смена библиотеки меняет канонические байты, поэтому проходит как
|
||||
обновление зависимости: осознанная пересборка манифеста одним PR.
|
||||
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
|
||||
потерял — пересчитал. В git снимок не попадает (раздел 5).
|
||||
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
|
||||
повторы: `WatchID` детерминированы, повтор — та же строка для
|
||||
ReplacingMergeTree.
|
||||
- **Эталонный снимок при старте стенда — через Kafka, пакетным режимом
|
||||
проигрывателя.** Отдельный механизм заливки не строится: каждый `make up`
|
||||
бесплатно прогоняет весь конвейер и contract-тест на настоящих данных.
|
||||
Оговорка «если заливка уйдёт в десятки минут — вернуться к прямой
|
||||
загрузке» проверена при фиксации чисел: 14 × 50 тыс. ≈ 700 тыс. событий —
|
||||
расчётно минута-две, запас есть.
|
||||
|
||||
Отклонено с доводами:
|
||||
|
||||
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
|
||||
обещания детерминизма, retention конечен, в git топик не положишь, хеш с
|
||||
манифестом не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
|
||||
мастер-спеки).
|
||||
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
|
||||
через дедуп, канон держит единственный сериализатор, а не диск; файл
|
||||
остаётся только там, где нужен кэш.
|
||||
- *Прямая загрузка снимка в ClickHouse* (и гибрид с ручным заполнением
|
||||
сырого слоя): второй путь приёма, пустой либо поддельный `stg.hits_raw` —
|
||||
теряются переобработка дня X по `event_date` и урок виртуальных колонок
|
||||
«какая нода читала топик».
|
||||
|
||||
## 5. Числа: объёмы, режимы, бюджеты
|
||||
|
||||
Резолюция развилки [«Производительность»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/30);
|
||||
порядки величин — [исследование #31](../research/2026-08-01-python-batch-generation-speed.md).
|
||||
|
||||
- **Средний модельный день — ~50 тыс. событий**; суточные волны с пиками до
|
||||
~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей —
|
||||
правдоподобный средний магазин.
|
||||
- **Эталонный снимок — 14 дней**: две полные календарные недели, D0 —
|
||||
понедельник. Самая короткая длина, при которой есть замороженная зона за
|
||||
окном K = 7, дышащая зона и две волны недельной сезонности. Удлинение до
|
||||
месяца — дешёвый ход (пересборка манифеста), если понадобится.
|
||||
- **В git — только манифест, снимок не хранится.** Снимок генерируется при
|
||||
`make up` и при проверках: артефакт — кэш чистой функции, кэш в git не
|
||||
хранят. Манифест несёт паспорт мира, счётчики и хеши по дням; проверки
|
||||
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на нём.
|
||||
Каждый `make up` — живая демонстрация детерминизма. Честная потеря —
|
||||
страховка на случай платформенного бага: раньше менти с расходящимися
|
||||
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек
|
||||
манифеста; смягчение — CI гоняет генерацию на amd64 и arm64.
|
||||
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
|
||||
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
|
||||
в пиковые часы сильных дней — до ~100. Число —
|
||||
значение по умолчанию, переопределяется флагом проигрывателя: ускорение —
|
||||
свойство транспорта, вне обещания воспроизводимости, константой мира не
|
||||
делается.
|
||||
|
||||
### Бюджеты и способ замера
|
||||
|
||||
Схема двухъярусная — урок ADR 0004: пороги впритык к расчёту на разном
|
||||
железе кончаются ритуальным удалением проверки.
|
||||
|
||||
Ориентиры на референсной машине — в спеке, без автоматики:
|
||||
|
||||
| Операция | Ориентир |
|
||||
|---|---|
|
||||
| Генерация одного дня | секунды |
|
||||
| Пересборка эталонного мира (14 дней + манифест, без транспорта) | до минуты |
|
||||
| Заливка снимка при `make up` (Kafka → матвью → ODS) | минуты |
|
||||
| Лаг живого дня | секунды |
|
||||
|
||||
**Автоматический порог один: полный день (50 тыс. событий) генерируется
|
||||
≤ 30 с.** Расчёт по планке исследования (~2–5×10⁵ событий/с на ядро) — доли
|
||||
секунды; порог держит машинный разброс ×2–5 и ловит деградацию на 1–2
|
||||
порядка: Faker в горячем цикле, случайная квадратичность. Реализация —
|
||||
pytest-тест с маркером `perf` и таймаутом-обрубанием: обязателен в CI,
|
||||
исключён из быстрой локальной петли, зовётся отдельной целью при правках
|
||||
горячего цикла.
|
||||
|
||||
Остальное — наблюдаемость без порогов: `make up` и smoke печатают тайминги
|
||||
(генерация и доставка отдельно), проигрыватель логирует лаг. Прототип-замер
|
||||
до этапа 2 не нужен: числа назначены с запасом порядок и больше от планки
|
||||
исследования, планка подтверждена локальной проверкой на машине стенда;
|
||||
первый замер настоящего кода — порог этапа 2.
|
||||
|
||||
Отклонено с доводами:
|
||||
|
||||
- *Четыре жёстких CI-ворот на все бюджеты*: машинный разброс против порогов
|
||||
впритык — повторение истории с памятью (ADR 0004); порог оставлен один,
|
||||
грубый, между «×5 шума» и «×100 беды».
|
||||
- *Снимок в git* (статус-кво раздела 8 мастер-спеки): основание из v1 —
|
||||
медленный генератор — съедено детерминизмом и скоростью; остаётся только
|
||||
раздутый репозиторий. *Снимок вложением релиза Gitea*: страховка без
|
||||
раздутия, но лишняя машинерия и вторая правда.
|
||||
- *Снимок 7 дней*: ни одного замороженного дня, сезонность без сравнения.
|
||||
*30 дней*: нового урока не даёт — отложено как дешёвое удлинение.
|
||||
- *×120 / ×1440*: волна смазывается в перемотку либо превращается в пакет с
|
||||
анимацией — живой режим теряет смысл.
|
||||
|
||||
## 6. Правила кода этапа 2
|
||||
|
||||
Хвосты резолюций, обязательные для реализации:
|
||||
|
||||
- **Целочисленная случайность** — правило кода, а не пожелание: диапазоны и
|
||||
выбор из таблиц целыми, плавающие распределения системной математики не
|
||||
звать; деньги — в целых копейках.
|
||||
- **Случайные значения — векторно из numpy (PCG64)**; посточный цикл — лишь
|
||||
там, где логика действительно посточная (цепочки сессий).
|
||||
- **Посточные фейкеры (Faker, mimesis) — только для справочников** (каталог
|
||||
и прочие справочные строки), не в горячем цикле: они медленнее numpy на
|
||||
2–3 порядка. Выбор библиотеки — этапу 2: справочники генерируются один
|
||||
раз, скорость безразлична; возможно, хватит таблиц-литералов в коде и
|
||||
фейкер не понадобится вовсе.
|
||||
- **Сериализация — через единственный канонический сериализатор** (orjson);
|
||||
прямых `json.dumps` по коду нет.
|
||||
- **Распараллеливание — multiprocessing по модельным дням**; внутри дня —
|
||||
однопоточно, единица работы и так крупная.
|
||||
|
||||
## 7. Расхождения с мастер-спекой
|
||||
|
||||
Внесены в мастер-спеку тем же коммитом, что и эта спека:
|
||||
|
||||
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
|
||||
contract — хранилище пишется по документации, границу сторожит
|
||||
contract-тест (раздел 3 здесь).
|
||||
- **Раздел 8**: артефакт `data/startup_history/` в git заменён манифестом;
|
||||
снимок генерируется на месте (раздел 5 здесь). Туман «политика
|
||||
версионирования артефакта» закрыт этим же ходом: версионируется манифест.
|
||||
- **Раздел 11**: пункт «до этапа 3 зафиксировать требования
|
||||
производительности» закрыт числами раздела 5.
|
||||
- Мелкие согласования там, где текст опирался на артефакт в git: источник
|
||||
переобработки при исчерпании retention Kafka (раздел 7), формулировка
|
||||
этапа 7 (раздел 9).
|
||||
|
||||
## 8. Хвосты следующим этапам
|
||||
|
||||
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
|
||||
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
||||
- **Этап 7 (эталонный мир)**: пересборка — это манифест, не артефакт;
|
||||
CI-генерация на amd64 и arm64.
|
||||
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
|
||||
готовая демонстрация идемпотентности конвейера.
|
||||
|
||||
## 9. Решается при нарезке этапа 2 (#34)
|
||||
|
||||
Осталось из тумана карты — вопросы уровня тикетов, не развилок:
|
||||
|
||||
- интерфейс запуска генератора (CLI / цели make) и как он делит режимы
|
||||
проигрывателя; кто его зовёт в стенде — даги `world_init`/`next_day` из
|
||||
оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт;
|
||||
- числа притока посетителей: доля одноразовых кук и темп появления новых
|
||||
(раздел 1);
|
||||
- календарная дата-константа D0: каким числом дни оси ложатся в
|
||||
`EventDate`/`UTCEventTime`;
|
||||
- формат описания мира и конфигурации (что константа кода, что параметр);
|
||||
- как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки):
|
||||
с манифестным решением напрашивается мини-манифест зернового мира — форму
|
||||
выбрать при нарезке.
|
||||
Reference in New Issue
Block a user