Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453950a639 | ||
|
|
c26b4530f3 | ||
|
|
eed5b4b4e6 | ||
|
|
4fd872f296 | ||
|
|
22bf09af9d | ||
|
|
502d705ca1 | ||
|
|
d22156a7da | ||
|
|
6174536cc1 | ||
|
|
989ff71495 | ||
|
|
cd82911906 | ||
|
|
3ffce6387e | ||
|
|
c999153acb | ||
|
|
c3a105a0f8 | ||
|
|
9b1826f5ff | ||
|
|
8b7987c5c2 | ||
|
|
c1b61d81b9 | ||
|
|
f93c924b33 | ||
|
|
c68a451698 | ||
|
|
8db1c58c12 | ||
|
|
58e944aa87 | ||
|
|
c28f724706 |
No files matched your search
@@ -160,7 +160,7 @@ Airflow) и названия из кода. Если для понятия ес
|
|||||||
- Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис
|
- Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис
|
||||||
(`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных
|
(`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных
|
||||||
заданий. Ни даты, ни номера: задание — не событие истории, а живой документ,
|
заданий. Ни даты, ни номера: задание — не событие истории, а живой документ,
|
||||||
и правится он вместе с тем, о чем говорит. Слаг называет витрину задания.
|
и правится он вместе с тем, о чем говорит. Слаг называет предмет задания.
|
||||||
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
|
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
|
||||||
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
|
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
|
||||||
в имени не нужна.
|
в имени не нужна.
|
||||||
|
|||||||
+2
-2
@@ -118,8 +118,8 @@ ClickHouse. Форма файла решена, длина — нет: стро
|
|||||||
|
|
||||||
**Стартовый мир**:
|
**Стартовый мир**:
|
||||||
Первые восемь дней оси (понедельник по понедельник), которыми стенд
|
Первые восемь дней оси (понедельник по понедельник), которыми стенд
|
||||||
наполняется при каждом `make up`. Маленький кусок эталонного мира, всегда
|
наполняется при каждом `make up`. Он же эталонный мир, всегда один и тот же:
|
||||||
один и тот же: на нём принимаются следующие этапы.
|
на нем принимаются следующие этапы.
|
||||||
_Избегать_: зерновой мир
|
_Избегать_: зерновой мир
|
||||||
|
|
||||||
**Опись мира**:
|
**Опись мира**:
|
||||||
|
|||||||
+11
-5
@@ -151,11 +151,17 @@ def build_classes_vs_inventory() -> list[str]:
|
|||||||
raise AirflowException("classes_vs_inventory: в описи нет закрытых дней")
|
raise AirflowException("classes_vs_inventory: в описи нет закрытых дней")
|
||||||
|
|
||||||
query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
|
query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
|
||||||
actual = {
|
# GROUP BY закономерно не возвращает группу из нуля строк.
|
||||||
(data_date, mismatch_class): count
|
actual = {key: 0 for key, count in expected.items() if count == 0}
|
||||||
for data_date, mismatch_class, count in _clickhouse_hook().get_records(query)
|
actual.update(
|
||||||
if (data_date, mismatch_class) in expected
|
{
|
||||||
}
|
(data_date, mismatch_class): count
|
||||||
|
for data_date, mismatch_class, count in _clickhouse_hook().get_records(
|
||||||
|
query
|
||||||
|
)
|
||||||
|
if (data_date, mismatch_class) in expected
|
||||||
|
}
|
||||||
|
)
|
||||||
summaries = []
|
summaries = []
|
||||||
for data_date in sorted({day for day, _ in expected}):
|
for data_date in sorted({day for day, _ in expected}):
|
||||||
details = []
|
details = []
|
||||||
|
|||||||
+25
-23
@@ -6,92 +6,94 @@
|
|||||||
{
|
{
|
||||||
"day": 0,
|
"day": 0,
|
||||||
"date": "2026-06-01",
|
"date": "2026-06-01",
|
||||||
"events": 50626,
|
"events": 50628,
|
||||||
"sha256": "81ad7f402bdd4c8b56425f6439a024b9996e5ca95dd207d444290d20a70ed407",
|
"sha256": "d09704ed99e45824e451e2f6bbbbd5c547df382f7a582699955010f3807eb0ee",
|
||||||
"orders": {
|
"orders": {
|
||||||
"match": 197,
|
"match": 192,
|
||||||
"cancelled": 20,
|
"cancelled": 20,
|
||||||
|
"lost_event": 3,
|
||||||
|
"duplicate_event": 2,
|
||||||
"amount_delta": 6
|
"amount_delta": 6
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 1,
|
"day": 1,
|
||||||
"date": "2026-06-02",
|
"date": "2026-06-02",
|
||||||
"events": 49722,
|
"events": 49727,
|
||||||
"sha256": "5f7aa0301832008db612eff05899b1210af958c888ac2e9e89f4b7a552569eff"
|
"sha256": "8005d2934eb353478e35331d7ae7ae6876c7934b2d9032fde92cf6ce86d6460f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 2,
|
"day": 2,
|
||||||
"date": "2026-06-03",
|
"date": "2026-06-03",
|
||||||
"events": 52361,
|
"events": 52363,
|
||||||
"sha256": "10ac9ce65ca6dc171689edf78ef5cd785e8c8a80e54d236bf2b9d6dbf6997070"
|
"sha256": "e13f6f3f3977386a94d0e63b6849675cb5d2486a277d559fd415923cfc82e53a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 3,
|
"day": 3,
|
||||||
"date": "2026-06-04",
|
"date": "2026-06-04",
|
||||||
"events": 53157,
|
"events": 53159,
|
||||||
"sha256": "7bf4deadd8f69a6c479fceabfe2d1e8ea8db76919b10a8b9e780006708da31c3"
|
"sha256": "91c713add62222adc3f89c04e6cc087d8c44e2385d14d0464e2c016b6952b9fd"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 4,
|
"day": 4,
|
||||||
"date": "2026-06-05",
|
"date": "2026-06-05",
|
||||||
"events": 50078,
|
"events": 50087,
|
||||||
"sha256": "b9e758fb72f937aa5ad1f2e90dee5ec147b16cfd89e49e51a00582f909458c29"
|
"sha256": "b7fc27d73fc157a48bece0189c7983f6bc672f052e0703cd799b9774da4b904e"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 5,
|
"day": 5,
|
||||||
"date": "2026-06-06",
|
"date": "2026-06-06",
|
||||||
"events": 47164,
|
"events": 47169,
|
||||||
"sha256": "f08a9f9a439f1da9e4ad8d94b9f65b7f3329256e41446d22d900cc4825f2480c"
|
"sha256": "82cb8017d53d6dee8438d3683b7a901f1ace139f3b8ae836215722bb580fe207"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 6,
|
"day": 6,
|
||||||
"date": "2026-06-07",
|
"date": "2026-06-07",
|
||||||
"events": 48683,
|
"events": 48686,
|
||||||
"sha256": "7e45d9dd59c902067fa6847d813669b1ab9a4ee88d2f192382577a5ed8d590a7"
|
"sha256": "b50ad0fcc7d1ea0c637cfcba98e5c2b55d65215cffa3693bc77edeaa83e6c783"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 7,
|
"day": 7,
|
||||||
"date": "2026-06-08",
|
"date": "2026-06-08",
|
||||||
"events": 49394,
|
"events": 49394,
|
||||||
"sha256": "286ccc2451847ac8de215b5e902c328371021ad5502bd4afca0494b6f313aef7"
|
"sha256": "358d51b36221d822960237702fa6327fb663a57cf49cc4415a859b16e5aa05a2"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"snapshots": [
|
"snapshots": [
|
||||||
{
|
{
|
||||||
"day": 0,
|
"day": 0,
|
||||||
"date": "2026-06-01",
|
"date": "2026-06-01",
|
||||||
"sha256": "6c3e3c1c8689ec23f53f0953db1b941e6f010e9e56a74d8d017f40195d3dec17"
|
"sha256": "93b39f261cbbe894402ebe0525460fed1809e5712639be4b73b79dac2b39f5bd"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 1,
|
"day": 1,
|
||||||
"date": "2026-06-02",
|
"date": "2026-06-02",
|
||||||
"sha256": "9acb523cfd47f3d97d55122025852982ba5a1d8362036308ce72a4d9fb767f43"
|
"sha256": "4fef382cd84868ccfb61a9d8610cdd26f9a749bbae8b20800c45e520e9ce414e"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 2,
|
"day": 2,
|
||||||
"date": "2026-06-03",
|
"date": "2026-06-03",
|
||||||
"sha256": "b81482491c4c0b874ea84d5f3f05144100c906dacc059c068b331df258b97172"
|
"sha256": "caf9ed2e77d2fcc99066a548dcba60f61f1ff0d4402de31457a06f23fd2d8306"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 3,
|
"day": 3,
|
||||||
"date": "2026-06-04",
|
"date": "2026-06-04",
|
||||||
"sha256": "9d1ab326c4bf8b78c9fcaf7015d8029f9ad4cff5558b89c730eb428d175cc9ee"
|
"sha256": "cdb9e4e00b57c04985866b5a851a8e2f23b3e9205ea131c7a5076672ee8b5669"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 4,
|
"day": 4,
|
||||||
"date": "2026-06-05",
|
"date": "2026-06-05",
|
||||||
"sha256": "0e30d0e192e121121003c0cc9aaa55e24924afb983caff15ab105705fbf2bd00"
|
"sha256": "c58d2c20fe3b4cee2973f34c322090dbb66d3793bfaab0337c22b53fb5a3ec7f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 5,
|
"day": 5,
|
||||||
"date": "2026-06-06",
|
"date": "2026-06-06",
|
||||||
"sha256": "9a2ad812fcff9a40a988f24f8aa4fc7a379873d173a95ddacd438592aed0bfb5"
|
"sha256": "bbf28b1d375e4815695c72a5915a22a3c9b36195b97ec4cc7c739466dbe70b53"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 6,
|
"day": 6,
|
||||||
"date": "2026-06-07",
|
"date": "2026-06-07",
|
||||||
"sha256": "e1706a27ff55de489a505489148c7a959329d95ad6597d92d3f78505f67bb221"
|
"sha256": "8ca077576bcf014c0d492c7d7190e38c0e46ec95951ad840df93eb75ee5a00d3"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -46,8 +46,9 @@
|
|||||||
пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md).
|
пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md).
|
||||||
|
|
||||||
Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит
|
Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит
|
||||||
от выполненных домашек. Расширение дашборда визуализациями по своим
|
от выполненных домашек. Свой дашборд по витринам менти —
|
||||||
витринам — отдельная домашка после этапа 8.
|
[необязательная лаба](../labs/superset-dashboard.md) после этапа 8. Устройство
|
||||||
|
стендового дашборда — в [доке зоны Superset](superset.md).
|
||||||
|
|
||||||
## Ритм пересборки
|
## Ритм пересборки
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@
|
|||||||
|
|
||||||
Зерно — заказ. Покупки из `dds.event_v` сначала сворачиваются по
|
Зерно — заказ. Покупки из `dds.event_v` сначала сворачиваются по
|
||||||
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную
|
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную
|
||||||
сумму, а отдельный счет событий оставляет будущий класс `duplicate_event`
|
сумму, а отдельный счет событий оставляет класс `duplicate_event`
|
||||||
наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны
|
наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны
|
||||||
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая
|
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая
|
||||||
Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда
|
Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда
|
||||||
@@ -176,8 +177,8 @@ Distributed-таблица вернула бы каждый непарный з
|
|||||||
`dds.session_v`;
|
`dds.session_v`;
|
||||||
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
|
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
|
||||||
`orders` в описи мира, объект — число заказов класса в
|
`orders` в описи мира, объект — число заказов класса в
|
||||||
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled` и
|
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
|
||||||
`amount_delta` только для покрытых описью закрытых дней;
|
`duplicate_event` и `amount_delta` только для покрытых описью закрытых дней;
|
||||||
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
|
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
|
||||||
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
|
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
|
||||||
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
|
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
|
||||||
@@ -214,7 +215,8 @@ business_key`.
|
|||||||
## Даг самопроверки
|
## Даг самопроверки
|
||||||
|
|
||||||
Домашки контролирует `homework_check` — отдельный даг без расписания: менти
|
Домашки контролирует `homework_check` — отдельный даг без расписания: менти
|
||||||
запускает его рукой. По задаче на каждое из пяти [заданий](../labs/README.md).
|
запускает его рукой. По задаче на каждое из
|
||||||
|
[пяти заданий по витринам](../labs/README.md).
|
||||||
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
|
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
|
||||||
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
|
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
|
||||||
золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
|
золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
├── TRAFFIC
|
├── TRAFFIC
|
||||||
├── COMMERCE
|
├── COMMERCE
|
||||||
├── ORDERS
|
├── ORDERS
|
||||||
└── LATECOMERS — место будущей событийной стороны
|
└── BEACONS — потеря и повторная отправка событий
|
||||||
```
|
```
|
||||||
|
|
||||||
Отрицательному дню нельзя отдать отрицательную позицию, поэтому предыстория
|
Отрицательному дню нельзя отдать отрицательную позицию, поэтому предыстория
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
|
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
|
||||||
[судьба заказа](fate.md).
|
[судьба заказа](fate.md).
|
||||||
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
|
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
|
||||||
заказная и событийная стороны; броски независимы, пересечения выходят
|
`ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
|
||||||
арифметикой, приоритет классов работает по-настоящему —
|
пересечения выходят арифметикой, приоритет классов работает по-настоящему —
|
||||||
[классы расхождений и опоздание](fate.md).
|
[классы расхождений и опоздание](fate.md).
|
||||||
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
|
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
|
||||||
каждого слепка и счётчики наблюдаемых классов —
|
каждого слепка и счётчики наблюдаемых классов —
|
||||||
@@ -51,14 +51,6 @@
|
|||||||
|
|
||||||
## Открытые решения
|
## Открытые решения
|
||||||
|
|
||||||
- **Имя подпотока событийной стороны** — вместе с её содержимым на этапе 6;
|
|
||||||
из мёртвых имён никто не бросает, переименование ничего не сдвигает (#72).
|
|
||||||
Заказная сторона уже названа — подпоток `ORDERS` ([судьба
|
|
||||||
заказа](fate.md)).
|
|
||||||
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
|
|
||||||
доли классов, стоимость доставки — калибровка при реализации; финальная
|
|
||||||
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
|
|
||||||
потребуют только числа, не устройство.
|
|
||||||
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
|
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
|
||||||
остаётся в тумане карты #69; естественное место разговора — этап 4.
|
остаётся в тумане карты #69; естественное место разговора — этап 4.
|
||||||
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
|
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
|
||||||
|
|||||||
@@ -16,11 +16,10 @@
|
|||||||
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
|
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
|
||||||
компонент называет часть мира; новые стороны занимают те же позиции.
|
компонент называет часть мира; новые стороны занимают те же позиции.
|
||||||
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
|
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
|
||||||
событийная получит имя вместе с содержимым на этапе 6, до тех пор позиция 3
|
событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо
|
||||||
занята мёртвым именем прежней нарезки. Отклонено: *один компонент на всю
|
отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка
|
||||||
судьбу* — правка событийной механики молча меняла бы байты слепка;
|
событийной механики молча меняла бы байты слепка; *компонент на класс* —
|
||||||
*компонент на класс* — пять имён под ручки калибровки, которые крутятся
|
пять имён под ручки калибровки, которые крутятся разом.
|
||||||
разом.
|
|
||||||
|
|
||||||
**Правило формы, без которого разделение не работает: броски заказной стороны
|
**Правило формы, без которого разделение не работает: броски заказной стороны
|
||||||
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
||||||
@@ -56,8 +55,11 @@
|
|||||||
часа покупки, и менти нашёл бы этот наклон первым же разрезом.
|
часа покупки, и менти нашёл бы этот наклон первым же разрезом.
|
||||||
|
|
||||||
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
|
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
|
||||||
она делится между двумя дорогами — строки таблицы исходов. Точные числа —
|
она делится между двумя дорогами — строки таблицы исходов. Замер на
|
||||||
калибровка этапа 7; проверок вида «отмен от 4 до 6 процентов» не заводим.
|
каноническом мире (28 дней, 2026-08-30, docs/architecture/testing.md, «Что
|
||||||
|
проверено»): отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12%
|
||||||
|
отменен неоплаченным; в пределах ориентира. Проверок вида «отмен от 4 до 6
|
||||||
|
процентов» не заводим.
|
||||||
|
|
||||||
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
|
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
|
||||||
версия; различает их история версий: сырьё STG и физические версии
|
версия; различает их история версий: сырьё STG и физические версии
|
||||||
@@ -108,12 +110,13 @@
|
|||||||
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
|
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
|
||||||
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
|
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
|
||||||
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
|
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
|
||||||
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где
|
один в один — посчитал наивно, удвоил выручку. Дубль случается только там,
|
||||||
до следующего визита куки остаётся запас сверх таймаута: иначе сборка сессий у
|
где пауза до следующего визита куки длиннее таймаута и задержки дубля вместе:
|
||||||
менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID`
|
иначе сборка сессий у менти разошлась бы с `VisitID` — сломался бы эталон,
|
||||||
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита,
|
ради которого `VisitID` в потоке лежит. Задержка дубля —
|
||||||
поэтому `VisitID` тот же. Полночь режет дубль парой — просмотр вместе с
|
секунды-минуты, короче таймаута визита, поэтому `VisitID` тот же. Полночь
|
||||||
покупкой, по тому же правилу, что у подтверждения с торговым хвостом.
|
режет дубль парой — просмотр вместе с покупкой, по тому же правилу, что у
|
||||||
|
подтверждения с торговым хвостом.
|
||||||
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
|
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
|
||||||
потеря перестаёт быть видна со стороны трекера.
|
потеря перестаёт быть видна со стороны трекера.
|
||||||
|
|
||||||
@@ -127,18 +130,20 @@
|
|||||||
подделывается — строка создана, когда заказ родился, — но в слепках дней
|
подделывается — строка создана, когда заказ родился, — но в слепках дней
|
||||||
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
|
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
|
||||||
дожил: отменённый на второй день и опоздавший на третий приедет в первом же
|
дожил: отменённый на второй день и опоздавший на третий приедет в первом же
|
||||||
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Задержка — таблица
|
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Таблица задержки
|
||||||
весов из трёх строк: 0 на подавляющем весе, 1 и 2 — это и есть «D+1/D+2»
|
состоит из трёх строк: δ = 0/1/2 с весами 90/5/5; 1 и 2 — это D+1 и D+2
|
||||||
мастер-спеки. Меряется она в днях снятия слепка, а не отправки: иначе сдвиг
|
мастер-спеки. Бросок идёт последним в подпотоке `ORDERS`; затем δ назначенного
|
||||||
отправки удвоился бы, и обещанные D+1/D+2 стали бы D+2/D+3. Дальше таблица не
|
планом заказа обнуляется. Меряется задержка в днях снятия слепка, а не
|
||||||
идёт: заказ с δ = 6 приехал бы ровно в одном слепке, и обещание «пропущенный
|
отправки: иначе сдвиг отправки удвоился бы, и обещанные D+1/D+2 стали бы
|
||||||
день ничего не ломает» на нём перестало бы быть верным; при δ ≤ 2 у всякого
|
D+2/D+3. Дальше таблица не идёт: заказ с δ = 6 приехал бы ровно в одном
|
||||||
заказа слепков не меньше пяти. Легенда: заказ ушёл в ручную обработку и попал
|
слепке, и обещание «пропущенный день ничего не ломает» на нём перестало бы
|
||||||
в выгрузку позже. Отклонено: *сдвиг `created_at`* — подделка аудита источника:
|
быть верным; при δ ≤ 2 у всякого заказа слепков не меньше пяти.
|
||||||
день создания строки разошёлся бы с днём покупки, чьё равенство держит
|
Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже. Отклонено:
|
||||||
синхронная модель ([откуда берётся заказ](snapshot.md)), заказ уехал бы в
|
*сдвиг `created_at`* — подделка аудита источника: день создания строки
|
||||||
чужую партицию, и «выручка дня D» перестала бы отвечать покупкам дня D —
|
разошёлся бы с днём покупки, чьё равенство держит синхронная модель
|
||||||
сломалась бы та самая сверка, ради которой всё строится.
|
([откуда берётся заказ](snapshot.md)), заказ уехал бы в чужую партицию,
|
||||||
|
и «выручка дня D» перестала бы отвечать покупкам дня D — сломалась бы та
|
||||||
|
самая сверка, ради которой всё строится.
|
||||||
|
|
||||||
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
|
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
|
||||||
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
|
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
|
||||||
|
|||||||
@@ -60,12 +60,14 @@
|
|||||||
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
|
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
|
||||||
топиком `hits`* — убивает два режима приёма.
|
топиком `hits`* — убивает два режима приёма.
|
||||||
|
|
||||||
**Сборка окна.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и
|
**Сборка окна.** Слепок дня D берет заказы, рожденные в дни D−6…D, а несет из
|
||||||
собирается переигровкой этих семи дней: заказы дня — производная всей воронки
|
них те, что уже попали в выгрузку: механика опоздания живет в
|
||||||
дня, дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; у
|
[судьбе заказа](fate.md). Окно собирается переигровкой этих семи дней: заказы
|
||||||
начала оси окно усекается само. Отклонено: *кэш заказов на томе* — состояние
|
дня — производная всей воронки дня, дешёвого пути к ним нет. Цена — семь
|
||||||
между прогонами; *окно держит хранилище* — топик перестаёт нести слепок;
|
проигрышей дня (~14 с) на слепок; у начала оси окно усекается само. Отклонено:
|
||||||
*K = 1* — это страховочный срез 1 мастер-спеки, он в резерве.
|
*кэш заказов на томе* — состояние между прогонами; *окно держит хранилище* —
|
||||||
|
топик перестаёт нести слепок; *K = 1* — это страховочный срез 1 мастер-спеки,
|
||||||
|
он в резерве.
|
||||||
|
|
||||||
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим
|
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим
|
||||||
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
|
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Superset: витрины глазами
|
||||||
|
|
||||||
|
Документ описывает зону BI: куда Superset ходит за данными и за своими
|
||||||
|
метаданными, из чего собран стендовый дашборд, как снять свежий экспорт после
|
||||||
|
правки в интерфейсе и почему правка руками живет только до перезапуска.
|
||||||
|
|
||||||
|
Состав сюжетов и колонок витрин здесь не повторяется: он живет в
|
||||||
|
[спеке](../specs/2026-07-30-stand-v2-realism.md), раздел 7, и в
|
||||||
|
[доке слоя DM](dm.md).
|
||||||
|
|
||||||
|
## Устройство зоны
|
||||||
|
|
||||||
|
Superset читает ClickHouse пользователем `bi` **на ноде 2** — так задано
|
||||||
|
подключением `ClickHouse` (`clickhousedb://bi@clickhouse-02:8123`). Нода 2
|
||||||
|
здесь — ловушка правильных ошибок из
|
||||||
|
[спеки](../specs/2026-07-30-stand-v2-realism.md), раздел «Роли нод»: DDL идет
|
||||||
|
с ноды 1, и забытый `ON CLUSTER` виден в дашборде.
|
||||||
|
|
||||||
|
Пароля в подключении нет. Его отдает штатное хранилище паролей Superset
|
||||||
|
(`SQLALCHEMY_CUSTOM_PASSWORD_STORE` в `infra/superset/superset_config.py`),
|
||||||
|
которое читает `CLICKHOUSE_BI_PASSWORD` из окружения. Поэтому файл подключения
|
||||||
|
безопасно лежит в git, и экспорт из интерфейса не должен возвращать пароль
|
||||||
|
обратно в YAML.
|
||||||
|
|
||||||
|
Свои метаданные — подключения, датасеты, графики, дашборды, пользователи —
|
||||||
|
Superset держит в базе `superset` служебного `postgres-metadata`, рядом с базой
|
||||||
|
Airflow. В ClickHouse он не пишет ничего.
|
||||||
|
|
||||||
|
## Дерево импорта
|
||||||
|
|
||||||
|
`infra/superset/import/` — это дашборд стенда в виде файлов. Каталог
|
||||||
|
смонтирован в контейнер как `/app/import`.
|
||||||
|
|
||||||
|
| Файл | Что описывает |
|
||||||
|
|---|---|
|
||||||
|
| `metadata.yaml` | версия формата, `1.0.0`; больше в нем ничего нет |
|
||||||
|
| `databases/clickhouse.yaml` | подключение к ClickHouse: URI без пароля и постоянный uuid |
|
||||||
|
| `datasets/ClickHouse/*.yaml` | три витрины: `revenue_daily_v`, `purchase_vs_orders_v`, `daily_traffic_v` |
|
||||||
|
| `charts/*.yaml` | три графика: столбики выручки, сводная таблица сверки, линии трафика |
|
||||||
|
| `dashboards/business_stories.yaml` | дашборд «Бизнес-сюжеты» и раскладка панелей |
|
||||||
|
|
||||||
|
Связаны файлы не путями, а uuid. Датасет называет `database_uuid`, график —
|
||||||
|
`dataset_uuid`, а дашборд не называет графики вовсе: единственная связь с ними
|
||||||
|
лежит в его `position`, где узел `CHART-*` несет `meta.uuid` нужного графика.
|
||||||
|
График, которого нет в `position`, импортируется молча и не появляется нигде.
|
||||||
|
|
||||||
|
Uuid у стенда розданы рукой: общее начало
|
||||||
|
`19600000-0000-4000-8000-`, дальше номер объекта в последнем блоке — `1`–`3`
|
||||||
|
у датасетов, `101`–`103` у графиков, `201` у дашборда
|
||||||
|
(`…-000000000101`). Экспорт из интерфейса приносит свои, сгенерированные;
|
||||||
|
их заменяют на эти, иначе повторный импорт заведет вторую копию объекта вместо
|
||||||
|
обновления первой.
|
||||||
|
|
||||||
|
Датасет несет русские имена колонок, формат денег и метрику — а график только
|
||||||
|
ссылается на них по имени. Значит, подпись или формат правятся в одном месте, а
|
||||||
|
не в каждом графике.
|
||||||
|
|
||||||
|
Датасеты объявлены физическими (`schema: dm`, `sql: null`), хотя за именем стоит
|
||||||
|
представление ClickHouse. Импорт в источник не ходит и разницы между таблицей и
|
||||||
|
представлением не знает, а виртуальный датасет со своим SQL просто повторил бы
|
||||||
|
запрос, который уже живет в `sql/ddl/60-dm-views.sql`.
|
||||||
|
|
||||||
|
## Импорт при каждой инициализации
|
||||||
|
|
||||||
|
Последняя строка `infra/superset/init.sh`:
|
||||||
|
|
||||||
|
```
|
||||||
|
superset import-directory /app/import --overwrite
|
||||||
|
```
|
||||||
|
|
||||||
|
Служба `superset-init` отрабатывает перед подъемом самого Superset, то есть при
|
||||||
|
каждом `make up`. Без `--overwrite` второй прогон падал бы на первом же
|
||||||
|
знакомом uuid; с ним объект обновляется на месте, и повторная инициализация
|
||||||
|
проходит без ошибок.
|
||||||
|
|
||||||
|
Отсюда правило зоны: **стендовый дашборд не расширяют руками в интерфейсе.**
|
||||||
|
Правка живет до следующей инициализации, а потом молча заменяется тем, что
|
||||||
|
лежит в git. Пробовать интерфейс менти никто не мешает — но все, что должно
|
||||||
|
пережить перезапуск, приезжает файлом.
|
||||||
|
|
||||||
|
Это правило относится только к стендовым объектам. Свои датасеты, графики и
|
||||||
|
дашборды менти живут в той же базе метаданных Superset, а импорт трогает
|
||||||
|
только объекты с известными ему uuid. Поэтому свои объекты переживают
|
||||||
|
`make up` и `make rebuild-storage`. Их стирает только `make clean` или
|
||||||
|
адресный сброс базы метаданных по рецепту ниже.
|
||||||
|
|
||||||
|
## Как снять свежий экспорт
|
||||||
|
|
||||||
|
Маршрут один: правишь на живом стенде, забираешь экспорт, кладешь дерево в git.
|
||||||
|
|
||||||
|
1. Собрать или поправить график и дашборд в интерфейсе — на стенде, а не в
|
||||||
|
файлах. Ключи `params` графиков велики, недокументированы и меняются от
|
||||||
|
версии к версии; неверный ключ не ошибается, а рисует пустоту. Интерфейс
|
||||||
|
здесь единственный надежный оракул.
|
||||||
|
2. Снять экспорт и достать его из контейнера:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose exec superset superset export-dashboards -f /tmp/export.zip
|
||||||
|
docker compose cp superset:/tmp/export.zip /tmp/export.zip
|
||||||
|
unzip -d /tmp/superset-export /tmp/export.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Внутри архива — один каталог `dashboard_export_<дата>` ровно той же формы,
|
||||||
|
что и дерево импорта. Команда выгружает все дашборды сразу; на стенде он
|
||||||
|
один.
|
||||||
|
3. Вычистить экспортный сор перед коммитом:
|
||||||
|
- выбросить экспортный `databases/` — наш `clickhouse.yaml` остается как
|
||||||
|
есть, с постоянным uuid и без пароля;
|
||||||
|
- оставить в `metadata.yaml` одну строку `version: 1.0.0`;
|
||||||
|
- убрать из графиков `params.datasource`, `params.slice_id` и
|
||||||
|
`params.dashboards` — это идентификаторы строк того Superset, где
|
||||||
|
снимали, а импорт выводит связи из uuid;
|
||||||
|
- убрать `query_context` — снимок запроса оттуда же. Импорт его не
|
||||||
|
требует и обратно не заполняет: после переимпорта он остается пустым, на
|
||||||
|
отрисовку это не влияет;
|
||||||
|
- перезаписать русские строки читаемыми. Экспорт кодирует их
|
||||||
|
`\uXXXX`-последовательностями, и после каждого круга их приходится
|
||||||
|
раскодировать заново: имена графиков и подписи колонок читает менти, и в
|
||||||
|
файле, и в диффе. Годится любой повторный дамп YAML с
|
||||||
|
`allow_unicode=True`;
|
||||||
|
- вернуть постоянные uuid из схемы выше.
|
||||||
|
4. Скопировать вычищенное дерево в `infra/superset/import/` и проверить
|
||||||
|
импортом **на пустых метаданных** — иначе прогон проверит только обновление
|
||||||
|
уже стоящих объектов, а молчаливый пропуск нового спрячется за старым.
|
||||||
|
Активы Superset лежат в базе `superset` службы `postgres-metadata`, поэтому
|
||||||
|
адресный сброс — эта база:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose stop superset
|
||||||
|
docker compose exec postgres-metadata sh -c \
|
||||||
|
'psql -U "$POSTGRES_USER" -d postgres \
|
||||||
|
-c "DROP DATABASE superset WITH (FORCE)" \
|
||||||
|
-c "CREATE DATABASE superset OWNER \"$SUPERSET_METADATA_USER\""'
|
||||||
|
docker compose run --rm superset-init
|
||||||
|
docker compose up -d superset
|
||||||
|
```
|
||||||
|
|
||||||
|
Базу приходится создавать тут же: скрипт `infra/postgres/init/` заводит ее
|
||||||
|
один раз, при первой инициализации тома Postgres. `make rebuild-storage`
|
||||||
|
для этой проверки не годится — он гоняет `world_recreate` в ClickHouse и
|
||||||
|
метаданных Superset не касается. `make clean` сбросит и их, но заодно снесет
|
||||||
|
весь стенд, включая метаданные Airflow в той же службе.
|
||||||
|
|
||||||
|
После импорта дашборд смотрят глазами: это единственное доказательство —
|
||||||
|
см. ловушку ниже.
|
||||||
|
|
||||||
|
## Ловушки
|
||||||
|
|
||||||
|
**Импорт молчит.** График, чей `dataset_uuid` не разрешился, отбрасывается без
|
||||||
|
единого слова; дашборд, у которого не сошлись ссылки на графики,
|
||||||
|
пропускается. Ошибки уходят в журнал, и `init.sh`
|
||||||
|
завершается нулем в любом случае. Поэтому импорт проверяют глазами или
|
||||||
|
запросом к API — но не тем, что команда отработала.
|
||||||
|
|
||||||
|
**`--overwrite` синхронизирует датасет разрушительно.** Колонки и метрики
|
||||||
|
берутся из YAML целиком: добавленная в интерфейсе метрика или подпись колонки
|
||||||
|
исчезнет при следующем старте контейнера. Правка датасета — это правка файла.
|
||||||
|
|
||||||
|
## Что проверяет стенд
|
||||||
|
|
||||||
|
`make check-services` входит в Superset, находит подключение `ClickHouse`,
|
||||||
|
сверяет его uuid и безопасный URI и требует схему `dm` — то есть подтверждает
|
||||||
|
живое подключение и хранилище пароля. Сами графики и дашборд ни одна цель не
|
||||||
|
проверяет: их смотрят глазами после переинициализации. Полная карта целей — в
|
||||||
|
[доке проверок](testing.md).
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
|
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
|
||||||
`make smoke` и `make check-services` перемерены 28 августа 2026 года,
|
`make smoke` и `make check-services` перемерены 28 августа 2026 года,
|
||||||
`make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
|
`make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
|
||||||
корневой `make lint` — 9 августа, `make test` — 7 августа; остальные стоят с
|
корневой `make lint` — 9 августа, `make test` — 30 августа; остальные стоят с
|
||||||
замера 6 августа.
|
замера 6 августа.
|
||||||
|
|
||||||
| Цель | Откуда | Что утверждает | Стенд | Цена |
|
| Цель | Откуда | Что утверждает | Стенд | Цена |
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
|
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
|
||||||
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||||
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
||||||
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 71 с |
|
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
|
||||||
|
|
||||||
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
|
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
|
||||||
разными дверями охватывают разное: корневая не видит ни одного файла
|
разными дверями охватывают разное: корневая не видит ни одного файла
|
||||||
@@ -121,10 +121,11 @@ ClickHouse отвечает сразу.
|
|||||||
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
|
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
|
||||||
дорогая. Правки дагов и Superset — корневой `make lint`.
|
дорогая. Правки дагов и Superset — корневой `make lint`.
|
||||||
|
|
||||||
Тринадцать секунд из её семидесяти одной — пересборка восьми модельных дней
|
Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
|
||||||
для сверки с описью мира. Дешевле хеши не сравнить: чтобы узнать, тот ли
|
для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
|
||||||
получается мир, его надо получить. Место выбрано по той же оси «кого
|
узнать, тот ли получается мир, его надо получить. Место выбрано по той же
|
||||||
спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и
|
оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не
|
||||||
|
нужен, — и
|
||||||
краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
|
краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
|
||||||
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
|
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
|
||||||
поломкой хранилища, а не забытой пересборкой.
|
поломкой хранилища, а не забытой пересборкой.
|
||||||
@@ -238,6 +239,77 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
|||||||
|
|
||||||
## Что проверено
|
## Что проверено
|
||||||
|
|
||||||
|
**Калибровка заказной стороны 30 августа 2026 года при исполнении #194.**
|
||||||
|
Пересборки эталонного мира не будет (#192), поэтому доли заказной стороны
|
||||||
|
сверены замером внутренностей генератора — окно 28 модельных дней,
|
||||||
|
канонический seed, без транспорта, — тем же приемом, каким `test_plan.py` и
|
||||||
|
`test_commerce.py` меряют мир. Команда (из `generator/`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python - <<'PY'
|
||||||
|
from collections import Counter
|
||||||
|
from clickstream_generator import day, inventory, orders, world
|
||||||
|
from clickstream_generator.seeds import CANONICAL_SEED
|
||||||
|
|
||||||
|
WINDOW = 28
|
||||||
|
class_totals: Counter[str] = Counter()
|
||||||
|
outcome_totals: Counter[int] = Counter()
|
||||||
|
delivery_totals: Counter[int] = Counter()
|
||||||
|
delay_totals: Counter[int] = Counter()
|
||||||
|
total_orders = 0
|
||||||
|
|
||||||
|
for d in range(WINDOW):
|
||||||
|
today = day.stream(CANONICAL_SEED, d)
|
||||||
|
for key, value in inventory._order_class_counts(today).items():
|
||||||
|
class_totals[key] += value
|
||||||
|
money = today.orders
|
||||||
|
total_orders += len(money)
|
||||||
|
for value in money.outcome.tolist():
|
||||||
|
outcome_totals[value] += 1
|
||||||
|
for value in money.delivery.tolist():
|
||||||
|
delivery_totals[value] += 1
|
||||||
|
for value in money.snapshot_delay.tolist():
|
||||||
|
delay_totals[value] += 1
|
||||||
|
|
||||||
|
print(total_orders, dict(class_totals), dict(outcome_totals),
|
||||||
|
dict(delivery_totals), dict(delay_totals))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
6973 заказов за 28 дней. Доли из таблицы сверки (после приоритета классов):
|
||||||
|
отменено 5,15% (ориентир мастер-спеки ~5%, в пределах; из них 3,03%
|
||||||
|
оплачен-потом-отменен и 2,12% отменен неоплаченным — веса `world.py`
|
||||||
|
95/3/2); дубль `purchase` 1,89% (ориентир ~2%, в пределах); дельта суммы
|
||||||
|
1,32% (ориентир 1–2%, в пределах); потеря `purchase` 1,78% при броске 3% —
|
||||||
|
вычеты штатные: назначенные планом покупки из броска исключены (мост
|
||||||
|
склейки), часть случаев уходит в отмену приоритетом; наблюдаемая доля
|
||||||
|
принята решением владельца (#194). Стоимость доставки — 35,5% / 49,2%
|
||||||
|
/ 15,4%, совпадает с весами `DELIVERY_KOPECKS_WEIGHTS`. Опоздание слепков
|
||||||
|
(`ORDER_SNAPSHOT_DELAY_WEIGHTS`) — доля заказов с δ>0 составила 8,39%
|
||||||
|
(ориентир мастер-спеки ~10%, в пределах). Скрипт замера в репозиторий не
|
||||||
|
входит; вывод — в отчете issue #194.
|
||||||
|
|
||||||
|
**Перезамер 30 августа 2026 года при исполнении #192.** Команды запускались
|
||||||
|
из `generator/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
time make inventory
|
||||||
|
uv run python -m clickstream_generator batch --day 0 --days 8 \
|
||||||
|
--file /tmp/stage7-events.jsonl
|
||||||
|
for snapshot_day in 1 2 3 4 5 6 7; do
|
||||||
|
uv run python -m clickstream_generator snapshot --day "$snapshot_day" \
|
||||||
|
--file "/tmp/stage7-snapshot-${snapshot_day}.jsonl"
|
||||||
|
done
|
||||||
|
wc -c /tmp/stage7-events.jsonl /tmp/stage7-snapshot-{1,2,3,4,5,6,7}.jsonl
|
||||||
|
time make test
|
||||||
|
```
|
||||||
|
|
||||||
|
Опись пересобралась за 20,515 с. Файловый приемник записал 460 731 812 байт:
|
||||||
|
458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0
|
||||||
|
за 176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов
|
||||||
|
стало 437 против 407, и потяжелел сам мир — та же пересборка восьми дней
|
||||||
|
стоила 13 с, теперь 20,5. Эта правка добавила один тест — проигрыш дня.
|
||||||
|
|
||||||
**Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал
|
**Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал
|
||||||
28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел
|
28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел
|
||||||
30 секунд почти целиком уходил на запуск второго приложения. Увеличивать его
|
30 секунд почти целиком уходил на запуск второго приложения. Увеличивать его
|
||||||
|
|||||||
+22
-17
@@ -1,9 +1,13 @@
|
|||||||
# Учебные задания: витрины слоя DM
|
# Учебные задания: витрины слоя DM и дашборд в Superset
|
||||||
|
|
||||||
Пять домашек. Стенд привозит слой DM наполовину: витрины трех сюжетов
|
Пять домашних заданий — про витрины слоя DM. Стенд привозит слой
|
||||||
дашборда и контроль качества построены, остальные витрины строит менти.
|
наполовину: витрины трех сюжетов дашборда и контроль качества построены,
|
||||||
Каждое задание — самостоятельное ТЗ: что за витрина, какое у нее зерно,
|
остальные витрины строит менти. Каждое из них — самостоятельное ТЗ: что за
|
||||||
из чего она собирается и как проверить, что собралась верно.
|
витрина, какое у нее зерно, из чего она собирается и как проверить, что
|
||||||
|
собралась верно.
|
||||||
|
|
||||||
|
Шестое задание — необязательная лаба в Superset. В ней менти собирает свой
|
||||||
|
дашборд поверх пяти витрин.
|
||||||
|
|
||||||
Общее устройство слоя — [дока DM](../architecture/dm.md). Она отвечает на
|
Общее устройство слоя — [дока DM](../architecture/dm.md). Она отвечает на
|
||||||
вопросы «почему так», задание отвечает на вопрос «что сделать».
|
вопросы «почему так», задание отвечает на вопрос «что сделать».
|
||||||
@@ -13,18 +17,19 @@
|
|||||||
Задания идут по нарастанию: каждое следующее опирается на то, что уже
|
Задания идут по нарастанию: каждое следующее опирается на то, что уже
|
||||||
усвоено.
|
усвоено.
|
||||||
|
|
||||||
| № | Витрина | Чему учит |
|
| № | Результат | Чему учит |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | [`events_enriched_v`](events-enriched.md) | слой — это контракт, а не копия данных |
|
| 1 | [`events_enriched_v`](events-enriched.md) | слой — это контракт, а не копия данных |
|
||||||
| 2 | [`top_pages_daily`](top-pages-daily.md) | зерно сменилось — витрина материализуется и встает в конвейер |
|
| 2 | [`top_pages_daily`](top-pages-daily.md) | зерно сменилось — витрина материализуется и встает в конвейер |
|
||||||
| 3 | [`session_overview`](session-overview.md) | зерно решает, на какие вопросы витрина уже не ответит |
|
| 3 | [`session_overview`](session-overview.md) | зерно решает, на какие вопросы витрина уже не ответит |
|
||||||
| 4 | [`dq_errors_daily`](dq-errors-daily.md) | у брака свой день и свой срок жизни |
|
| 4 | [`dq_errors_daily`](dq-errors-daily.md) | у брака свой день и свой срок жизни |
|
||||||
| 5 | [`utm_effectiveness`](utm-effectiveness.md) | `declared_` в имени — предупреждение, а не украшение |
|
| 5 | [`utm_effectiveness`](utm-effectiveness.md) | `declared_` в имени — предупреждение, а не украшение |
|
||||||
|
| 6 | [Свой дашборд в Superset](superset-dashboard.md) — необязательная лаба | свои объекты BI живут отдельно от стендовых |
|
||||||
|
|
||||||
## Общие правила
|
## Общие правила
|
||||||
|
|
||||||
Они одни на все пять заданий, поэтому записаны здесь, а не повторяются в
|
Они одни для пяти заданий по витринам, поэтому записаны здесь, а не
|
||||||
каждом ТЗ.
|
повторяются в каждом ТЗ.
|
||||||
|
|
||||||
**Читать — через `_v`.** Публичный договор сущности DDS — представление с
|
**Читать — через `_v`.** Публичный договор сущности DDS — представление с
|
||||||
суффиксом `_v`: `dds.event_v`, `dds.session_v`. Витрина называет только
|
суффиксом `_v`: `dds.event_v`, `dds.session_v`. Витрина называет только
|
||||||
@@ -74,18 +79,18 @@ DDS, и задания остаются независимыми. Исключе
|
|||||||
собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет:
|
собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет:
|
||||||
это инструмент лабы, а не история качества стенда.
|
это инструмент лабы, а не история качества стенда.
|
||||||
|
|
||||||
Проверка у всех заданий устроена одинаково и золотых констант не содержит:
|
Проверка пяти заданий по витринам устроена одинаково и золотых констант не
|
||||||
рядом с витриной независимым запросом собирается тот же ответ — прямо из
|
содержит: рядом с витриной независимым запросом собирается тот же ответ —
|
||||||
источника витрины, минуя ее саму. У четырех заданий источник эталона тот
|
прямо из источника витрины, минуя ее саму. У четырех заданий источник эталона
|
||||||
же, что у витрины, `dds.session_v` или `dds.event_v`; у задания 4 это
|
тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания 4 это
|
||||||
таблицы брака ODS, потому что в DDS у брака представительства нет. Обе
|
таблицы брака ODS, потому что в DDS у брака представительства нет. Обе стороны
|
||||||
стороны соединяются по деловому ключу через `FULL OUTER JOIN`, и считается
|
соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число строк,
|
||||||
число строк, где стороны разошлись или одной стороны нет. Ноль — зеленый.
|
где стороны разошлись или одной стороны нет. Ноль — зеленый.
|
||||||
Полное соединение здесь обязательно: при обычном пропавший день или
|
Полное соединение здесь обязательно: при обычном пропавший день или
|
||||||
пропавший ключ просто не попал бы в результат и остался бы незамечен.
|
пропавший ключ просто не попал бы в результат и остался бы незамечен.
|
||||||
|
|
||||||
Раздел «Как проверить себя» каждого ТЗ называет деловой ключ и сравниваемые
|
Раздел «Как проверить себя» каждого ТЗ по витрине называет деловой ключ и
|
||||||
величины. Тот же прием в стендовом исполнении — [даг
|
сравниваемые величины. Тот же прием в стендовом исполнении — [даг
|
||||||
`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq).
|
`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq).
|
||||||
|
|
||||||
**Предупреждение.** Запросы самой проверки лежат в `sql/dq/` открыто, и
|
**Предупреждение.** Запросы самой проверки лежат в `sql/dq/` открыто, и
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Задание 6 (необязательное): свой дашборд в Superset
|
||||||
|
|
||||||
|
**Чему учит:** довести свои витрины до потребителя — завести в Superset
|
||||||
|
датасеты поверх публичных представлений, подобрать графики под вопросы и
|
||||||
|
собрать из них один связный экран.
|
||||||
|
|
||||||
|
Эта лаба идет после заданий 1–5 и опирается на их результат. Ее можно
|
||||||
|
пропустить: основной предмет стенда — ClickHouse, а здесь вся работа — в
|
||||||
|
интерфейсе BI.
|
||||||
|
|
||||||
|
## Роль дашборда
|
||||||
|
|
||||||
|
Собери обзор трафика и поведения посетителей. Он должен отвечать на три
|
||||||
|
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
|
||||||
|
каналы дали результат, были ли ошибки приема данных.
|
||||||
|
|
||||||
|
Стендовый дашборд «Бизнес-сюжеты» собран из готовых витрин. Свой дашборд
|
||||||
|
собери из пяти витрин, которые ты построил в заданиях. Копировать раскладку
|
||||||
|
стендового дашборда не нужно: выбери форму, в которой ответы читаются без
|
||||||
|
пояснений рядом с экраном.
|
||||||
|
|
||||||
|
## Свои объекты — отдельно
|
||||||
|
|
||||||
|
Не добавляй графики в стендовый дашборд и не правь его датасеты. При
|
||||||
|
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
|
||||||
|
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
|
||||||
|
таких объектов описан в [доке Superset](../architecture/superset.md), «Импорт
|
||||||
|
при каждой инициализации».
|
||||||
|
|
||||||
|
Заведи отдельные датасеты и графики, собери из них свой дашборд. Назови его,
|
||||||
|
например, «Мои витрины DM». Это то же правило, по которому DDL домашек лежит
|
||||||
|
в `sql/ddl/70-labs.sql`: стенд привозит свои объекты, менти создает свои.
|
||||||
|
|
||||||
|
## Датасеты
|
||||||
|
|
||||||
|
Заведи в подключении `ClickHouse` пять физических датасетов схемы `dm`:
|
||||||
|
|
||||||
|
- `events_enriched_v`;
|
||||||
|
- `top_pages_daily_v`;
|
||||||
|
- `session_overview_v`;
|
||||||
|
- `dq_errors_daily_v`;
|
||||||
|
- `utm_effectiveness_v`.
|
||||||
|
|
||||||
|
## Состав дашборда
|
||||||
|
|
||||||
|
Каждая из пяти витрин должна появиться хотя бы в одном графике. Точный тип
|
||||||
|
графика выбери сам. На дашборде покажи следующее:
|
||||||
|
|
||||||
|
| Витрина | Что показать |
|
||||||
|
|---|---|
|
||||||
|
| `events_enriched_v` | число событий по часу и типу события |
|
||||||
|
| `top_pages_daily_v` | страницы с просмотрами, посетителями и добавлениями в корзину |
|
||||||
|
| `session_overview_v` | сравнение устройств по визитам, отказам и визитам с покупкой |
|
||||||
|
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
|
||||||
|
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
|
||||||
|
|
||||||
|
Для долей используй слагаемые из витрин. Например, доля отказов считается
|
||||||
|
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
|
||||||
|
долям для такого графика не подходит — урок задания 3 остается в силе.
|
||||||
|
|
||||||
|
Названия графиков, осей и показателей должны объяснять экран без чтения SQL.
|
||||||
|
|
||||||
|
## Как проверить себя
|
||||||
|
|
||||||
|
Эта лаба проверяется глазами. У расположения панелей и понятных подписей нет
|
||||||
|
счетного инварианта, поэтому задачи для нее в `homework_check` нет.
|
||||||
|
|
||||||
|
Открой список дашбордов и проверь:
|
||||||
|
|
||||||
|
- «Бизнес-сюжеты» остался отдельным стендовым дашбордом;
|
||||||
|
- твой дашборд открывается без ошибок и содержит график от каждого из пяти
|
||||||
|
датасетов;
|
||||||
|
- подписи, единицы измерения и легенды читаются без обращения к SQL;
|
||||||
|
- прямой трафик не исчез из графика UTM, а заявленная выручка не выдана за
|
||||||
|
серверную;
|
||||||
|
- пустой график ошибок нормален, если в таблицах брака сейчас нет строк.
|
||||||
|
|
||||||
|
Последний проход сделай как читатель: найди на экране самый заметный канал,
|
||||||
|
страницу и устройство, затем проверь, понятно ли из подписей, что именно
|
||||||
|
сравнивается. Если ответ приходится угадывать, поправь график или его название.
|
||||||
|
|
||||||
|
## Приемка
|
||||||
|
|
||||||
|
- [ ] пять датасетов читают публичные представления витрин заданий 1–5;
|
||||||
|
- [ ] создан отдельный дашборд, стендовые объекты не изменены;
|
||||||
|
- [ ] каждая витрина представлена хотя бы одним графиком;
|
||||||
|
- [ ] дашборд проверен глазами по разделу выше.
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# Инвентаризация сюжетов лаб по слоям стенда
|
||||||
|
|
||||||
|
Это материал для спеки программы лаб (#202): полный список сюжетов без отбора. Спека решит, что оставить и в каком порядке поставить. Здесь для каждого участка стенда записано, чему на нем научится менти. Тикет — #201, карта — #182.
|
||||||
|
|
||||||
|
Каждый сюжет описан четырьмя строками: **урок**, **материал**, **форма** и **проверка**. Возможны три формы:
|
||||||
|
|
||||||
|
- *построить свое по ТЗ* — как в пяти домашних заданиях DM: рядом есть образец, но нет готового ответа;
|
||||||
|
- *вычитание* — удалить SQL готовой трансформации и написать его заново; даги остаются без изменений (уточнение владельца в комментарии к #148);
|
||||||
|
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а менти ищет причину и объясняет ее.
|
||||||
|
|
||||||
|
Существующие шесть заданий из `docs/labs/` входят в список как готовое ядро слоя DM. Идеи из тикетов #143, #145, #148, #149, #186, #187, #188 разнесены по подходящим участкам. В конце отдельно проверяется гипотеза «вычитание + DQ» для каждого потока нижних слоев.
|
||||||
|
|
||||||
|
## STG — прием и сырье
|
||||||
|
|
||||||
|
**1. Push против pull: два способа принять топик.**
|
||||||
|
Урок: способ приема зависит от источника. Поток постоянно читает подписанная матвью, а слепок забирает отдельный запрос по команде.
|
||||||
|
Материал: оба чтеца в `sql/ddl/10-stg-tables.sql` (у `orders_raw_kafka` нет ни матвью, ни `ON CLUSTER`, зато есть `kafka_commit_on_select`), матвью приема `sql/ddl/40-stg-views.sql`, забор `sql/stg/orders_raw_load.sql`, `dags/orders_ingest.py`; сравнение цены и наблюдаемости — заготовка спеки (`docs/specs/2026-07-30-stand-v2-realism.md`, раздел 12).
|
||||||
|
Форма: расследование двух живых путей на одном стенде.
|
||||||
|
Проверка: офсеты групп `clickstream_hits` и `clickstream_orders`, счет строк сырья по `_load_id`.
|
||||||
|
|
||||||
|
**2. Кто читал топик и куда легли данные.**
|
||||||
|
Урок: нода, которая читает топик, может меняться от прогона к прогону. Раскладка данных не меняется: ее определяет ключ шардирования — свойство самих данных.
|
||||||
|
Материал: колонка `consumer_host` в `stg.hits_raw_dist`; `docs/architecture/storage.md`, «Раскладка по шардам».
|
||||||
|
Форма: расследование данных.
|
||||||
|
Проверка: пересчет ключа шардирования по `hostName()` — строк на чужом шарде нет.
|
||||||
|
|
||||||
|
**3. Вторая consumer group — дубли.**
|
||||||
|
Урок: на кластер нужна одна группа потребителей. Если завести вторую, каждая нода получит полную копию топика.
|
||||||
|
Материал: `kafka_group_name` в `sql/ddl/10-stg-tables.sql`; сцена названа спекой (раздел 12).
|
||||||
|
Форма: расследование-опыт «сломай — увидь — верни» на переигранном дне.
|
||||||
|
Проверка: счет событий против описи мира (`make check-clickhouse`); уборка — снятием партиции `_load_ts` (правило из `docs/architecture/testing.md`).
|
||||||
|
|
||||||
|
**4. Дырка в офсетах.**
|
||||||
|
Урок: сырье может сохранить не все сообщения, которые пришли из источника. Пустое сообщение или зазор после пересоздания матвью видны только по разрыву в `kafka_offset`, поэтому пропажу не нужно искать глазами.
|
||||||
|
Материал: `docs/architecture/storage.md`, «Что проверено» (опыты 5 и 7 августа); колонка `kafka_offset` в сырье.
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: непрерывность офсетов внутри партиции.
|
||||||
|
|
||||||
|
**5. Две оси времени.**
|
||||||
|
Урок: переобработка данных и очистка диска живут по разным часам. Сырье разбивается на части и удаляется по `_load_ts`, а модельный день появляется только в ODS. Поэтому TTL по модельной дате работал бы неверно.
|
||||||
|
Материал: `sql/ddl/10-stg-tables.sql` (TTL трое суток, `ttl_only_drop_parts`), `docs/architecture/storage.md`, «Срок жизни сырья».
|
||||||
|
Форма: расследование-вопрос (почему нельзя нарезать сырье по `EventDate`).
|
||||||
|
Проверка: ответ сверяется с доводом доки; счетного инварианта нет.
|
||||||
|
|
||||||
|
## ODS — типизация и брак
|
||||||
|
|
||||||
|
**6. Предикат и его буквальное отрицание.**
|
||||||
|
Урок: годные строки и брак должны делить поток без пропусков и пересечений. NULL в предикате незаметно теряет строку, а исключение роняет вставку и останавливает чтение топика.
|
||||||
|
Материал: `sql/ddl/30-ods-views.sql` (пара матвью событий), `sql/ods/order_parse.sql` (макрос `order_wire_contract`), ADR 0005.
|
||||||
|
Форма: расследование — подложить в топик по строке каждого класса брака и найти их в `ods.event_errors_dist` / `ods.order_errors_dist`.
|
||||||
|
Проверка: «сырье среза = годные + брак» на управляемой пачке; строки опыта убрать за собой.
|
||||||
|
|
||||||
|
**7. Три времени и версия-наблюдение.**
|
||||||
|
Урок: у строки заказа есть четыре временные координаты. Версию сущности задает дата слепка источника, а не время загрузки. `FINAL` живет ровно в одном месте — за представлением `_v`.
|
||||||
|
Материал: `sql/ddl/20-ods-tables.sql`, `sql/ddl/30-ods-views.sql`, ADR 0010 и 0014, `docs/architecture/orders/ingestion.md` («Роль ODS»).
|
||||||
|
Форма: расследование — физические версии `ods.order_dist` против `ods.order_v`.
|
||||||
|
Проверка: эталон `argMax` по `updated_at` прямо из сырья (прием #165).
|
||||||
|
|
||||||
|
**8. Часовой пояс как способ смотреть на время.**
|
||||||
|
Урок: у ночных событий `toDate(UTCEventTime)` может не совпадать с `EventDate`. Это нормальное свойство данных. День нужно считать по готовой колонке и явно называть часовой пояс, в котором его рассматривают.
|
||||||
|
Материал: `docs/architecture/storage.md`, «Часовые пояса»; ADR 0015.
|
||||||
|
Форма: расследование данных.
|
||||||
|
Проверка: менти сам находит ночные события и объясняет расхождение.
|
||||||
|
|
||||||
|
**9. Сырое против разобранного: `ecommerce`.**
|
||||||
|
Урок: типизированный разбор JSON может молча подменить ошибку значением. Например, `qty` со значением «oops» превратится в 0. Поэтому правильность разбора нужно подтверждать инвариантом источника.
|
||||||
|
Материал: колонка `ecommerce` рядом с массивами `purchase_*` / `product_*` в `ods.event`; разбор `items` в `sql/dds/order_rebuild.sql`; спека, раздел 1.2 («кандидат будущей лабы»).
|
||||||
|
Форма: построить свое по ТЗ — разобрать `ecommerce` запросом и сверить с готовыми массивами того же события.
|
||||||
|
Проверка: построчная сверка с колонками-массивами.
|
||||||
|
|
||||||
|
## DDS — модель
|
||||||
|
|
||||||
|
**10. Лаба сессий: своя нарезка против эталона.**
|
||||||
|
Урок: сессия — расчетная сущность, границы которой определяет логика в хранилище. Самостоятельная нарезка может разойтись с эталоном. Если расхождение невозможно, задание сводится к `GROUP BY` по готовому полю.
|
||||||
|
Материал: `docs/architecture/dds/session.md`, `sql/dds/session_rebuild.sql` (два окна, полный порядок `(event_time, event_id)`, детерминированный чекан), эталон `VisitID` в `ods.event_v`.
|
||||||
|
Форма: построить свое — менти собирает нарезку сам до знакомства со стендовым кодом; либо вычитание `session_rebuild.sql` (раздел о гипотезе ниже).
|
||||||
|
Проверка: `dq_check` / `sessions_vs_reference` (зерно кука × день против `uniqExact(VisitID)`); затем сравнение со стендовой нарезкой по `session_id`.
|
||||||
|
|
||||||
|
**11. Поштучный разбор расхождения нарезки.**
|
||||||
|
Урок: счетная сверка показывает, сколько сессий разошлось. Чтобы понять, где именно отличаются границы, нужно вручную соединить свою сессию с эталонной по времени.
|
||||||
|
Материал: `sql/dq/sessions_vs_reference.sql`, `docs/architecture/dds/session.md`, «Сверка с эталоном» («поштучное сопоставление границ — материал лабы»).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: найденная кука, день и пауза, на которой решения разошлись.
|
||||||
|
|
||||||
|
**12. Склейка идентичностей во времени (#145).**
|
||||||
|
Урок: текущее знание о пользователе и знание на выбранную дату дают разные отчеты. Если использовать сведения из будущего, исторический график известной аудитории изменится.
|
||||||
|
Материал: `dds.identity_map_v` (пары без временной колонки — решение `docs/architecture/dds/identity.md`), момент связи выводится как минимум времени `purchase`-события, соединенного с заказом.
|
||||||
|
Форма: построить свое (запрос, без изменения стенда).
|
||||||
|
Проверка: два способа на одном наборе данных; расхождение объясняется письменно.
|
||||||
|
|
||||||
|
**13. Справочник регионов (#143).**
|
||||||
|
Урок: справочник содержит данные, которыми источник владеет вне хранилища. Если собрать словарь руками, можно разобраться в механике, которую готовый каталог стенда позволяет только использовать.
|
||||||
|
Материал: образец `sql/ddl/05-dic-catalog.sql` и ADR 0012; зацепка в контракте — `region_city_id` / `region_country_id` отдаются как есть (`dds.event_v`).
|
||||||
|
Форма: построить свое по образцу (свой CSV, подложка, словарь, `dictGet` в отчете).
|
||||||
|
Проверка: обогащенный отчет; неатомарность обновления словаря на двух нодах — дополнительное наблюдение (спека, раздел 12).
|
||||||
|
|
||||||
|
**14. Инвариант позиций: недостающая проверка.**
|
||||||
|
Урок: равенство «цена × количество = items_total» проверяет, не исказил ли молчаливый разбор данные. Сейчас это равенство проверяют вручную, хотя ему нужно постоянное место в контуре DQ.
|
||||||
|
Материал: комментарий в `sql/dds/order_rebuild.sql` («постоянное место такой проверки — контур DQ этапа 4; пока она делается руками»), образцы `sql/dq/*_vs_dds.sql`, `dags/dq_check.py`.
|
||||||
|
Форма: построить свое — новая проверка по образцу существующих пяти.
|
||||||
|
Проверка: проверка обязана уметь краснеть — испортить позицию руками и увидеть красное (`docs/architecture/testing.md`).
|
||||||
|
|
||||||
|
**15. Сверка слоя с источником по дню.**
|
||||||
|
Урок: трансформация выполняет заданное правило, но сама не замечает, что часть данных не приехала. Это обнаруживает отдельная сверка слоя с источником.
|
||||||
|
Материал: `docs/architecture/etl.md`, «Чего конвейер о себе не знает» (три слепых пятна и лекарство: счет заказов и поздний `updated_at` с обеих сторон).
|
||||||
|
Форма: построить свое — вторая самодельная DQ-проверка.
|
||||||
|
Проверка: снести часть дня в ODS-окне — проверка краснеет, конвейер сам не замечал.
|
||||||
|
|
||||||
|
## DM — витрины
|
||||||
|
|
||||||
|
**16. Пять домашек DM (готовое ядро).**
|
||||||
|
Урок: готовые ТЗ уже содержат пять отдельных уроков (`docs/labs/README.md`): `events_enriched_v` показывает, что слой задает контракт, а не копирует данные; `top_pages_daily` меняет зерно, материализует витрину и включает ее в конвейер; `session_overview` учит хранить слагаемые вместо средних и не складывать уникальные значения, даже если на текущих данных результат совпадает; `dq_errors_daily` показывает, что у брака есть собственный день и срок жизни, а витрина может пережить источник; `utm_effectiveness` объясняет, зачем в имени нужно предупреждение `declared_`.
|
||||||
|
Материал: пять готовых ТЗ в `docs/labs/README.md`.
|
||||||
|
Форма: построить свое по ТЗ.
|
||||||
|
Проверка: `dags/homework_check.py` + `sql/dq/*_vs_dds.sql` (полное внешнее соединение с независимым ответом).
|
||||||
|
|
||||||
|
**17. Свой дашборд в Superset (готовая необязательная лаба).**
|
||||||
|
Урок: пользовательские объекты BI хранятся отдельно от стендовых и переживают перезапуск. Стендовые объекты при импорте перезаписываются.
|
||||||
|
Материал: `docs/labs/superset-dashboard.md`, `docs/architecture/superset.md`.
|
||||||
|
Форма: построить свое.
|
||||||
|
Проверка: глазами — счетного инварианта у раскладки панелей нет.
|
||||||
|
|
||||||
|
**18. Инкремент против пересборки (#149, повышенная сложность).**
|
||||||
|
Урок: чтобы обновлять меньше данных, приходится хранить историю изменений. Полная перезагрузка эту историю стирает.
|
||||||
|
Материал: `sql/dm/daily_traffic_rebuild.sql` (полный пересчет), `docs/architecture/dm.md`, «Ритм пересборки» и отклоненный вариант; момент `first_matched_at` — из сюжета 12.
|
||||||
|
Форма: построить свое (инкрементальная альтернатива рядом со стендовой).
|
||||||
|
Проверка: бесплатный контроль — результат обязан совпасть с эталонной витриной `dm.daily_traffic_v`.
|
||||||
|
|
||||||
|
**19. Вычитание стендовых витрин.**
|
||||||
|
Урок: при сверке двух источников нужен `GLOBAL FULL OUTER JOIN` с подзапросами с обеих сторон, `join_use_nulls` и явный приоритет классов. Значение с отсутствующей стороны должно остаться `NULL`, а не превратиться в ноль.
|
||||||
|
Материал: `sql/dm/purchase_vs_orders_rebuild.sql` (и соседние `revenue_daily_*`, `daily_traffic_*`), `docs/architecture/dm.md`.
|
||||||
|
Форма: вычитание — с оговоркой: запросы `sql/dq/*_vs_dds.sql` лежат открыто и почти дословно повторяют рецепт витрины. Предупреждение из `docs/labs/README.md` («открыв их до того, как задание сделано, ты обменяешь урок на готовый SQL») здесь особенно важно.
|
||||||
|
Проверка: `dq_check`, группа `marts_vs_dds` + `classes_vs_inventory`.
|
||||||
|
|
||||||
|
## BI — Superset (сюжеты сверх готовой лабы)
|
||||||
|
|
||||||
|
**20. Ловушка ноды 2.**
|
||||||
|
Урок: забытый `ON CLUSTER` и представление над локальной таблицей могут проявиться только в дашборде. Superset намеренно читает ноду 2, а DDL запускается с ноды 1.
|
||||||
|
Материал: `docs/architecture/superset.md`, «Устройство зоны»; спека, «Роли нод».
|
||||||
|
Форма: расследование-опыт (создать объект без `ON CLUSTER`, открыть дашборд).
|
||||||
|
Проверка: своя же находка и починка.
|
||||||
|
|
||||||
|
## Поперек слоев
|
||||||
|
|
||||||
|
### Airflow-переобработка по дневным партициям
|
||||||
|
|
||||||
|
**21. Переиграть день X.**
|
||||||
|
Урок: объем повторного прогона определяется состоянием хранилища. После падения конвейеру не нужно помнить параметры прошлого запуска: он ищет первую несобранную дату, а не последнюю собранную.
|
||||||
|
Материал: `sql/dds/order_scope.sql` и `session_scope.sql` (окно + хвост от первой дыры, `GLOBAL NOT IN`), `dags/dds_transform.py` (день — отдельная размноженная задача замены), `docs/architecture/etl.md`.
|
||||||
|
Форма: расследование-опыт — снести партицию дня в `dds.session_rep`, запустить `etl_pipeline` без параметров, увидеть, как объем сам нашел дыру.
|
||||||
|
Проверка: `dq_check` зеленый, день вернулся тем же (детерминированный чекан).
|
||||||
|
|
||||||
|
**22. Атомарность — пошардовая.**
|
||||||
|
Урок: `REPLACE PARTITION` заменяет данные в локальных таблицах по одному шарду. Если одна из двух замен завершится ошибкой, половина дня останется старой. Сам конвейер этого не заметит, поэтому нужна отдельная сверка.
|
||||||
|
Материал: `sql/dds/order_replace.sql` (комментарий о границе обещания), `docs/architecture/etl.md`, «Чего конвейер о себе не знает»; правило донора — `sql/ddl/45-dds-tables.sql`.
|
||||||
|
Форма: расследование (в паре с сюжетом 15).
|
||||||
|
Проверка: `purchase_vs_orders_vs_dds` или самодельная сверка по дню.
|
||||||
|
|
||||||
|
### Сверка purchase vs orders (классы A–D)
|
||||||
|
|
||||||
|
**23. Воронка подтверждений и недосчет трекера (#186, класс B).**
|
||||||
|
Урок: со стороны трекера потерянное событие можно обнаружить только по разрыву воронки между `/confirmation` и `purchase`. Отсюда правило: поведение считаем по трекеру, деньги — по бэкенду.
|
||||||
|
Материал: `lost_event` в `dm.purchase_vs_orders_v`, события дня в `dds.event_v`; механика — `docs/architecture/orders/fate.md` («потеря точечная»).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: оцененный недосчет сходится с долей `lost_event` закрытых дней.
|
||||||
|
|
||||||
|
**24. Бизнес-дубль и наивная выручка (#187, класс D).**
|
||||||
|
Урок: движок удаляет технические дубли, а бизнес-дубли приходится определять самим — по `purchaseID`, а не по совпадению строк. `ReplacingMergeTree` не удалит дубль с новым `WatchID`, и это правильное поведение.
|
||||||
|
Материал: `duplicate_event`, свертка по `purchase_id[1]` в `sql/dm/purchase_vs_orders_rebuild.sql`; `fate.md` («дубль сюжетный»).
|
||||||
|
Форма: расследование — наивный `SUM` против дедуплицированного.
|
||||||
|
Проверка: разница равна вкладу дублей; сверка с `dm.revenue_daily_v`.
|
||||||
|
|
||||||
|
**25. Дельта суммы объясняется сравнением позиций (класс C).**
|
||||||
|
Урок: `amount_delta` обозначает конкретное расхождение, а не случайный шум. Его можно до копейки объяснить вычеркнутой позицией, если разобрать вложенный JSON и применить `ARRAY JOIN`. Ради этого навыка позиции и разбираются.
|
||||||
|
Материал: `amount_delta` в сверке, массивы `product_*` события против `item_*` заказа; `fate.md` («дельта — вычеркнутая позиция»).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: найденная позиция и ее полная стоимость равны дельте.
|
||||||
|
|
||||||
|
**26. Две дороги отмены и история версий (класс A).**
|
||||||
|
Урок: текущее состояние заказа не сохраняет его историю. Различить случаи «оплачен и отменен» и «отменен неоплаченным» можно только по следам: физическим версиям ODS до слияния и сырью STG, пока оно хранится трое суток.
|
||||||
|
Материал: `fate.md`, «Что из двух дорог видно»; `ods.order_dist`, `stg.orders_raw_dist`; выручка дня, которая выросла и убыла.
|
||||||
|
Форма: расследование с ограничением по времени — часть случаев неразличима принципиально, и признать это тоже урок.
|
||||||
|
Проверка: списки заказов по дорогам, сверенные с историей версий.
|
||||||
|
|
||||||
|
### Опоздания и закрытие дня
|
||||||
|
|
||||||
|
**27. Опоздавшие заказы и закрытое окно (#188).**
|
||||||
|
Урок: отчет за день зависит от момента расчета. День можно считать закрытым, когда источник уже не способен его изменить. Поэтому опись хранит счетчики классов только для закрытых дней.
|
||||||
|
Материал: `awaiting_order` в `dm.purchase_vs_orders_v`, задержка слепков D+1/D+2 (`fate.md`, «Опоздание»), `data/world-inventory.json` (счетчики `orders` только у дней с закрытым окном), возврат `awaiting_order`-дней в объем — `sql/dm/purchase_vs_orders_scope.sql`.
|
||||||
|
Форма: расследование по ходу проживаемых дней.
|
||||||
|
Проверка: вчерашний отчет, не сошедшийся утром, сходится завтра; разность слепков называет опоздавших.
|
||||||
|
|
||||||
|
**28. Дыхание выручки.**
|
||||||
|
Урок: значение выручки за день меняется, пока день внутри окна изменяемости, а после выхода из окна фиксируется. Эти изменения видны как повторные пересборки партиции.
|
||||||
|
Материал: `dm.revenue_daily_v` по ходу дней, окно из ярлыка `snapshot_date` (`sql/dm/revenue_daily_scope.sql`), K = 7 — константа мира.
|
||||||
|
Форма: расследование-наблюдение (снять один день несколько раз).
|
||||||
|
Проверка: за окном значение больше не меняется.
|
||||||
|
|
||||||
|
### Мониторинг и runbook
|
||||||
|
|
||||||
|
В репозитории пока нет runbook для случаев «keeper упал / DDL повис». Спека оставляет его в хвосте этапа 9 вместе с `system.text_log` (ADR 0002), потому что такой документ должен опираться на реальный опыт. Поэтому менти может написать runbook сам: по шагам сломать сценарий, записать наблюдения и оставить стенду документ, которого сейчас не хватает.
|
||||||
|
|
||||||
|
**29. Keeper — общая память кластера.**
|
||||||
|
Урок: без keeper останавливаются DDL и репликация. На дашборде это бывает видно раньше, чем в ошибках запросов. Читаемый путь в keeper помогает понять, какие данные он хранит и за что отвечает.
|
||||||
|
Материал: панель keeper и очередь DDL в `docs/architecture/monitoring/cluster.md`, `system.zookeeper` (читаемый путь — `docs/architecture/storage.md`, «Путь реплицированных таблиц»), `make smoke` / `make check-clickhouse`.
|
||||||
|
Форма: расследование-опыт «посмотрели — сломали — увидели — вернули» (остановить keeper), итог — свой runbook.
|
||||||
|
Проверка: возврат к зеленым целям `make`; шаги записаны.
|
||||||
|
|
||||||
|
**30. Повисший DDL.**
|
||||||
|
Урок: каждый запрос `ON CLUSTER` проходит через keeper. Очередь распределенных DDL показывает, дошел ли запрос до обеих нод и что ответила каждая из них.
|
||||||
|
Материал: `system.distributed_ddl_queue`, панель «Распределенный DDL» (`cluster.md`: единственная панель без `clusterAllReplicas` — и почему).
|
||||||
|
Форма: расследование-опыт (DDL при недоступной второй ноде, таймаут `ON CLUSTER`).
|
||||||
|
Проверка: запись в очереди со статусом по нодам; починка доводит до `Finished`.
|
||||||
|
|
||||||
|
**31. Мониторинг сам стоит денег.**
|
||||||
|
Урок: запросы Grafana попадают в `system.query_log` вместе с запросами людей. Самый частый клиент хранилища — сам мониторинг. Если отфильтровать его запросы, стоимость наблюдаемости станет невидимой.
|
||||||
|
Материал: `docs/architecture/monitoring/queries.md` (панель «Кто спрашивает», строки начала и конца, куски шардов в панели ошибок).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: собственное объяснение верха списка тяжелых запросов.
|
||||||
|
|
||||||
|
**32. Свежесть второго источника — пила, а не линия.**
|
||||||
|
Урок: возраст заказов растет в течение дня и резко уменьшается после нового слепка. Так устроен источник; это не признак деградации. Для панелей свежести, потока и брака выбираются разные слои данных. Если выбрать неверный слой, панель даст правдоподобный, но ложный ответ.
|
||||||
|
Материал: `docs/architecture/monitoring/data.md` (почему свежесть — ODS, поток и брак — сырье, окно доли брака).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: предсказать форму графика до взгляда на него.
|
||||||
|
|
||||||
|
### За границей лаб — опорные точки лекций
|
||||||
|
|
||||||
|
Спека (раздел 12) оставляет несколько сюжетов для лекций и не включает их в лабораторные работы: `Sign` и CollapsingMergeTree (почему на стенде `sum(Sign)` равен `count()`, а в бою нет), «как выбирают топологии в бою», идентичность «как в бою» (`setUserID`, вероятностная склейка, identity graph), анти-паттерны ключа шардирования. Последний сюжет уже входит в домашние задания DM как требование защитить выбранный ключ (`docs/labs/README.md`).
|
||||||
|
|
||||||
|
## Гипотеза «вычитание + DQ» для нижних слоев
|
||||||
|
|
||||||
|
В #148 предложено удалить SQL трансформаций потока и написать его заново. Владелец уточнил, что даги менять не нужно: это несущая инфраструктура. Обратную связь дают инварианты, которые не зависят от реализации слоя. Ниже каждый поток проверяется отдельно: что можно отдать на переписывание, что должно остаться, чем проверять результат и где заканчивается эталон.
|
||||||
|
|
||||||
|
На всю таблицу влияют два общих факта:
|
||||||
|
|
||||||
|
- `homework_check` покрывает только пять витрин DM. `dq_check` содержит пять проверок, но внешний эталон есть лишь у двух: `sessions_vs_reference` использует чужой чекан `VisitID` из `ods.event_v`, а `classes_vs_inventory` — опись мира. Три проверки `*_vs_dds` пересчитывают витрину из DDS по тому же рецепту. Они находят ошибку в сборке витрины, но не ошибку в DDS, потому что обе стороны читают один слой.
|
||||||
|
- Все сверки читают таблицы `_dist`, поэтому их агрегаты не зависят от раскладки (спека, раздел 6). Если строка попала не на свой шард, счетные проверки останутся зелеными. Это ловит только пересчет ключа шардирования по `hostName()` (прием из `docs/architecture/storage.md`, замер #137). Такой пересчет нужно явно включать в ТЗ каждого вычитания.
|
||||||
|
|
||||||
|
### По потокам
|
||||||
|
|
||||||
|
**События STG (прием).** На переписывание этот участок не отдается. Здесь почти нет трансформации: `stg.hits_raw_mv` перекладывает колонки (`sql/ddl/40-stg-views.sql`). При этом пересоздание матвью на работающем стенде незаметно пропускает ближайшие сообщения (замер в `storage.md`, «Что проверено»). Чтец, таблицы сырья и матвью остаются несущей стеной. Урок этого участка раскрывают расследования из сюжетов 1–5.
|
||||||
|
|
||||||
|
**События STG → ODS (разбор).** Вся логика — в DDL, в паре матвью `sql/ddl/30-ods-views.sql`; отдельных файлов событий в `sql/ods/` нет. Удаление и пересоздание этих матвью остановит прием и снова создаст зазор с пропущенными сообщениями. Поэтому обычное вычитание здесь не подходит. Рабочая форма — **«собери рядом»**: выполнить пакетный `INSERT SELECT` из сырья в собственную таблицу по срезу `_load_ts`, а затем построчно сверить результат с нетронутым `ods.event_v`. Эталоном служат стендовый ODS и подневный счет против описи (`make check-clickhouse`). Однако опись проверяет только количество и только для покрытых ею дней. Содержимое неопорных колонок остается без независимой проверки: неверно разобранные UTM или браузер нигде не покраснеют, потому что витрины сверяются с DDS, а DDS читает ODS. Срок работы тоже ограничен: сырье хранится трое суток.
|
||||||
|
|
||||||
|
**Заказы STG (забор, `sql/stg/orders_raw_load.sql`).** Формально трансформация вынесена в отдельный файл, но ее учебный смысл сводится к перекладке колонок, а цена ошибки велика. Офсеты коммитятся при чтении, поэтому неверный повтор унесет слепок до следующего переигрыша дня через пульт. Этот участок остается стеной. Эталон описи — хеш байтов слепка — проверяет топик, но не сам забор.
|
||||||
|
|
||||||
|
**Заказы STG → ODS (`sql/ods/order_parse.sql`).** Вычитание возможно с оговоркой. Это пакетный шаг, идемпотентный по `_load_id`: повтор не меняет `ods.order_v` («Что проверено» в `orders/ingestion.md`). Брак обрабатывается отдельной целью, а слепок можно вернуть переигрышем дня. Обратную связь менти собирает сам: проверяет равенство «сырье среза = годные + брак» по `_load_id`, а текущее состояние сверяет с `argMax` по `updated_at` из сырья. Ниже по течению результат проверяет `classes_vs_inventory` через DDS и витрину. Слабое место — ветвь брака. Чистый мир не производит брак, поэтому отрицание предиката останется непроверенным, пока менти сам не подложит по строке каждого класса. Готовый рецепт есть в `orders/ingestion.md`, «Риски и проверка»; после опыта данные нужно обязательно убрать.
|
||||||
|
|
||||||
|
**DDS сессия (`session_scope` / `rebuild` / `replace`).** Это лучший поток для вычитания: здесь гипотеза подтверждается без дополнительных условий. Эталон `VisitID` внешний, а `sessions_vs_reference.sql` считает чужой чекан и не повторяет алгоритм нарезки. Ошибиться можно в важных местах: в полном порядке окон, границе суток в `PARTITION BY`, детерминированном чекане и расчете объема из позиции мира. Эталон заканчивается на атрибутах строки. Сверка работает на зерне кука × день и проверяет только число сессий. Поэтому неверные границы, длительность, вход, выход или UTM могут пройти зеленым. `session_overview` из `homework_check` тоже согласится, потому что сверяет витрину с тем же слоем. Вторая ступень обратной связи — поштучная сверка границ по `VisitID` из сюжета 11, которую менти пишет сам.
|
||||||
|
|
||||||
|
**DDS заказ (`order_scope` / `rebuild` / `replace`).** Вычитание возможно при условии: сначала нужно построить недостающую проверку. Косвенный эталон уже есть. `classes_vs_inventory` ловит потерянные заказы и неверные статусы закрытых дней, а `purchase_vs_orders` проверяет `items_total`. Но независимо не проверяются разбор массивов `item_*` и время в поясе `Europe/Samara`. Выручка сойдется сама с собой, а счетчиков выручки в описи намеренно нет (спека, раздел 8). Стенд уже подсказывает решение: до вычитания нужно реализовать инвариант «цена × количество = items_total» из сюжета 14. Последовательность «сначала сторож, потом ломать» сама становится частью урока.
|
||||||
|
|
||||||
|
**DDS карта идентичностей (`identity_map_rebuild.sql`).** Для чистого вычитания этот поток не подходит: независимого эталона нет. `daily_traffic_vs_dds` читает карту с обеих сторон, поэтому пустая или задвоенная карта пройдет проверку. Счетчики идентичности в опись не входят (спека, раздел 8). Менти может сам написать слабые инварианты: `known_users <= visitors` и «каждая пара подтверждена покупкой и заказом». Они проверят допустимость пар, но не их полноту. Подходящие формы — «собери рядом и сверь со стендовой картой» либо вычитание вместе с самодельной проверкой, которая честно названа неполной.
|
||||||
|
|
||||||
|
**DM стендовые витрины.** Формально это самый защищенный участок: `dq_check` независимо пересчитывает каждую витрину. На деле проверка содержит готовый ответ: `revenue_daily_vs_dds.sql` дословно повторяет рецепт витрины. Вычитание возможно как сюжет 19, но только с предупреждением из `docs/labs/README.md` о цене подглядывания.
|
||||||
|
|
||||||
|
### Сводная таблица
|
||||||
|
|
||||||
|
| Поток | Отдать на переписывание | Несущая стена | Обратная связь | Где кончается эталон |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| События STG | ничего | чтец, сырье, `hits_raw_mv` | опись (счет) | все, кроме счета |
|
||||||
|
| События STG→ODS | «собери рядом», не вычитание | обе матвью, `*_errors` | стендовый ODS рядом, опись | содержимое неопорных колонок; окно сырья 3 суток |
|
||||||
|
| Заказы STG | ничего | чтец, забор | хеш слепка в описи | сам забор |
|
||||||
|
| Заказы STG→ODS | `order_parse.sql` | `*_errors`, чтец | баланс среза по `_load_id`, `argMax(updated_at)`, опись классов | ветвь брака — пока не подложен брак руками |
|
||||||
|
| DDS сессия | `session_*` (все три) | донор, DDL, даг | `sessions_vs_reference` (внешний эталон) | атрибуты строки — нужна поштучная сверка |
|
||||||
|
| DDS заказ | `order_*` (все три) | донор, DDL, даг | опись классов, `items_total` в сверке | разбор позиций и пояс — сначала сюжет 14 |
|
||||||
|
| DDS карта | только с оговорками | DDL, даг | нет независимой | почти сразу; форма — «собери рядом» |
|
||||||
|
| DM витрины | `revenue/pvo/traffic` | доноры, DDL, даг | `dq_check` целиком | ответ лежит в самой проверке |
|
||||||
|
|
||||||
|
### Вывод
|
||||||
|
|
||||||
|
Гипотеза «вычитание + DQ» подходит не для всех нижних слоев. Без оговорок она работает только для сессий DDS: там есть внешний эталон, настоящие места для ошибок и проверка, которая не содержит готового решения.
|
||||||
|
|
||||||
|
Для заказов DDS и разбора заказов ODS нужна последовательность «сначала построй сторожа, потом ломай». Недостающие инварианты — сюжеты 14 и 15, а также баланс среза — сами входят в учебный материал. Их нужно построить до вычитания.
|
||||||
|
|
||||||
|
STG и прием событий нельзя отдавать в форме «удали и перепиши»: код там служит несущей частью стенда. Для этих участков подходят расследования и форма «собери рядом».
|
||||||
|
|
||||||
|
У всех счетных проверок остается общая дыра — раскладка по шардам. Поэтому пересчет ключа по `hostName()` должен входить в ТЗ каждого вычитания отдельной строкой.
|
||||||
@@ -265,7 +265,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
| | Расхождение | Механика в генераторе | Ориентир доли |
|
| | Расхождение | Механика в генераторе | Ориентир доли |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе |
|
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе |
|
||||||
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
|
| B | Потерянное событие | заказ есть, `purchase` не доехал | бросок 3%, наблюдаемых ~1,8%: часть маячков защищена парами склейки, часть уходит в отмену приоритетом |
|
||||||
| C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~1–2% |
|
| C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~1–2% |
|
||||||
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
|
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
|
||||||
|
|
||||||
@@ -274,8 +274,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
|
|
||||||
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
||||||
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
||||||
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
|
ориентир ~10%. Точные доли зафиксированы замером на каноническом мире
|
||||||
опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
|
(28 дней, 2026-08-30, #194) — запись в «Что проверено»
|
||||||
|
[карты проверок](../architecture/testing.md); опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
|
||||||
только по дням с закрытым окном (раздел 8).
|
только по дням с закрытым окном (раздел 8).
|
||||||
|
|
||||||
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
||||||
@@ -532,10 +533,11 @@ Kafka день переигрывается генератором заново:
|
|||||||
|
|
||||||
## 8. Эталонный мир и опись
|
## 8. Эталонный мир и опись
|
||||||
|
|
||||||
В git хранится только опись эталонного мира; сам снимок (14 модельных
|
В git хранится только опись эталонного мира; сам снимок генерируется на
|
||||||
дней) генерируется на месте — при `make up` и при проверках (решение
|
месте — при `make up` и при проверках (решение развилки «Производительность»
|
||||||
развилки «Производительность» карты #26, подробности —
|
карты #26, подробности — [спека генератора](2026-08-01-generator.md),
|
||||||
[спека генератора](2026-08-01-generator.md), раздел 5). Опись несёт
|
раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней
|
||||||
|
снято на этапе 7. Опись несёт
|
||||||
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
|
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
|
||||||
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
||||||
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман
|
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман
|
||||||
@@ -555,8 +557,8 @@ Kafka день переигрывается генератором заново:
|
|||||||
названного читателя в опись не кладётся
|
названного читателя в опись не кладётся
|
||||||
([что хранит опись](../architecture/orders/inventory.md)).
|
([что хранит опись](../architecture/orders/inventory.md)).
|
||||||
|
|
||||||
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
|
Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
|
||||||
при пересборке.
|
[карты проверок](../architecture/testing.md).
|
||||||
|
|
||||||
## 9. Оценка объёма исполнения
|
## 9. Оценка объёма исполнения
|
||||||
|
|
||||||
@@ -618,8 +620,8 @@ v2 стартует пустым, поэтому объём ниже — это
|
|||||||
выручка, сверка A+C.
|
выручка, сверка A+C.
|
||||||
5. Airflow: `etl_pipeline` (партиционная переобработка).
|
5. Airflow: `etl_pipeline` (партиционная переобработка).
|
||||||
6. Расхождения B+D и опоздания; счётчики описи.
|
6. Расхождения B+D и опоздания; счётчики описи.
|
||||||
7. Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация
|
7. Эталонный мир: опись и пересборка снимка, чек-скрипты. CI-генерация
|
||||||
на amd64 и arm64.
|
снята (спека генератора, раздел 5).
|
||||||
8. Superset-дашборд v2.
|
8. Superset-дашборд v2.
|
||||||
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
||||||
и границы — ADR 0002. Этап идёт не подряд, см. ниже.
|
и границы — ADR 0002. Этап идёт не подряд, см. ниже.
|
||||||
@@ -658,7 +660,10 @@ ADR 0002 с приёмочными совпадают почти целиком.
|
|||||||
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
|
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
|
||||||
переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг
|
переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг
|
||||||
самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и
|
самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и
|
||||||
конвейер, в который она встает, проектируются вместе. Единый стиль блока
|
конвейер, в который она встает, проектируются вместе. Сюда же —
|
||||||
|
необязательная лаба «свой дашборд в Superset» (#197): она стоит на
|
||||||
|
витринах домашек DM и продумана до готового ТЗ в этапе 8, тогда как
|
||||||
|
остальные лабы пока на уровне идей. Единый стиль блока
|
||||||
домашек — за работой учебного пути. Спека даёт будущим лабам только
|
домашек — за работой учебного пути. Спека даёт будущим лабам только
|
||||||
опорные точки — контрольные числа описи (сверка, идентичность). Явное
|
опорные точки — контрольные числа описи (сверка, идентичность). Явное
|
||||||
следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет.
|
следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет.
|
||||||
|
|||||||
@@ -65,7 +65,8 @@
|
|||||||
приём на стороне хранилища.
|
приём на стороне хранилища.
|
||||||
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
|
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
|
||||||
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
|
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
|
||||||
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок — 14 дней;
|
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок совпадает со
|
||||||
|
стартовым миром — 8 дней;
|
||||||
в git — только опись мира; автоматических порогов по времени нет.
|
в git — только опись мира; автоматических порогов по времени нет.
|
||||||
|
|
||||||
## 1. Модель мира
|
## 1. Модель мира
|
||||||
@@ -358,10 +359,17 @@
|
|||||||
- **Средний модельный день — ~50 тыс. событий**; суточные волны с пиками до
|
- **Средний модельный день — ~50 тыс. событий**; суточные волны с пиками до
|
||||||
~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей —
|
~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей —
|
||||||
правдоподобный средний магазин.
|
правдоподобный средний магазин.
|
||||||
- **Эталонный снимок — 14 дней**: две полные календарные недели, D0 —
|
- **Эталонный снимок совпадает со стартовым миром — 8 дней**: понедельник
|
||||||
понедельник. Самая короткая длина, при которой есть замороженная зона за
|
по понедельник, полная неделя и один закрытый день за окном K = 7.
|
||||||
окном K = 7, дышащая зона и две волны недельной сезонности. Удлинение до
|
Обещание в 14 дней снято на этапе 7 (решение владельца 2026-08-30):
|
||||||
месяца — дешёвый ход (пересборка описи), если понадобится.
|
у дней 8–13 не нашлось читателя — лабы проигрывают мир дальше без описи,
|
||||||
|
транспорт байты с описью не сверяет, а проверке «пересгенерируй день N —
|
||||||
|
сравни хеш» день 3 годится так же, как день 13. Замеры и тесты, меряющие
|
||||||
|
мир на окне в две недели, не меняются: мир — функция без края, окну
|
||||||
|
замера опись не нужна. Удлинение описи остается дешевым ходом
|
||||||
|
(пересборка) и делается, когда читатель появится — например, если
|
||||||
|
растяжке `classes_vs_inventory` понадобится сверять больше одного
|
||||||
|
закрытого дня.
|
||||||
- **В git — только опись, снимок не хранится.** Снимок генерируется при
|
- **В git — только опись, снимок не хранится.** Снимок генерируется при
|
||||||
создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в
|
создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в
|
||||||
git не хранят. Опись несёт паспорт мира, счётчики и хеши по дням; проверки
|
git не хранят. Опись несёт паспорт мира, счётчики и хеши по дням; проверки
|
||||||
@@ -370,7 +378,9 @@
|
|||||||
повторный подъём не пересчитывает. Честная потеря —
|
повторный подъём не пересчитывает. Честная потеря —
|
||||||
страховка на случай платформенного бага: раньше менти с расходящимися
|
страховка на случай платформенного бага: раньше менти с расходящимися
|
||||||
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек
|
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек
|
||||||
описи; смягчение — CI гоняет генерацию на amd64 и arm64.
|
описи; смягчение — дисциплина целочисленной случайности (раздел 2):
|
||||||
|
расхождения amd64/arm64 сняты устройством, а не сторожем. CI ради одного
|
||||||
|
прогона не заводится (решение владельца 2026-08-30).
|
||||||
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
|
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
|
||||||
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
|
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
|
||||||
в пиковые часы сильных дней — до ~100. Число —
|
в пиковые часы сильных дней — до ~100. Число —
|
||||||
@@ -389,7 +399,7 @@
|
|||||||
| Операция | Ориентир |
|
| Операция | Ориентир |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) |
|
| Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) |
|
||||||
| Пересборка эталонного мира (14 дней + опись, без транспорта) | до минуты |
|
| Пересборка эталонного мира (8 дней + опись, без транспорта) | 20,5 с (замер 30 августа 2026 года) |
|
||||||
| Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) |
|
| Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) |
|
||||||
| Лаг живого дня | секунды |
|
| Лаг живого дня | секунды |
|
||||||
|
|
||||||
@@ -497,8 +507,8 @@
|
|||||||
Расхождение переменной с данными (менти почистил партицию руками) лечится
|
Расхождение переменной с данными (менти почистил партицию руками) лечится
|
||||||
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
|
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
|
||||||
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)).
|
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)).
|
||||||
- **Этап 7 (эталонный мир)**: пересборка — это опись, не артефакт;
|
- **Этап 7 (эталонный мир)**: пересборка — это опись, не артефакт.
|
||||||
CI-генерация на amd64 и arm64.
|
CI-генерация на amd64 и arm64 снята — резолюция в разделе 5.
|
||||||
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
|
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
|
||||||
готовая демонстрация идемпотентности конвейера.
|
готовая демонстрация идемпотентности конвейера.
|
||||||
|
|
||||||
@@ -925,11 +935,13 @@
|
|||||||
|
|
||||||
### Опись мира и словарь
|
### Опись мира и словарь
|
||||||
|
|
||||||
- **Опись мира одна и растет до эталонной** (#42, 2026-08-07; решение владельца
|
- **Опись мира одна** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7,
|
||||||
при нарезке, 2026-08-01). Стартовый мир этапа 2 — первые восемь дней,
|
2026-08-30). Стартовый мир этапа 2 — первые восемь дней,
|
||||||
понедельник по понедельник: полная неделя и первый замкнутый цикл K = 7.
|
понедельник по понедельник: полная неделя и первый замкнутый цикл K = 7.
|
||||||
`data/world-inventory.json` — та самая опись, которую обещает раздел 5, пока
|
`data/world-inventory.json` — та самая опись, которую обещает раздел 5.
|
||||||
короткая; этап 7 продлит ее до 14 дней, а не заведет вторую. В JSON лежат
|
Этап 7 оставил ее восьмидневной: у хвоста в шесть дней не нашлось читателя
|
||||||
|
(резолюция — в разделе 5); вырастет она продолжением этого же файла, а не
|
||||||
|
вторым. В JSON лежат
|
||||||
паспорт мира — зерно, версия генератора и хеш каталога — и строки дней с
|
паспорт мира — зерно, версия генератора и хеш каталога — и строки дней с
|
||||||
датой, числом событий и хешем. Опись собирает `make inventory`, свежесть
|
датой, числом событий и хешем. Опись собирает `make inventory`, свежесть
|
||||||
сторожит `test_inventory.py` тем же способом, что свежесть описания выгрузки.
|
сторожит `test_inventory.py` тем же способом, что свежесть описания выгрузки.
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# Программа учебных лаб
|
||||||
|
|
||||||
|
Эта спека собирает программу лаб из
|
||||||
|
[инвентаризации сюжетов](../research/2026-08-31-labs-inventory.md), выполненной
|
||||||
|
в тикете #201. Тикет спеки — #202, карта — #182.
|
||||||
|
|
||||||
|
Статус: спека принята владельцем; судьба идей-кандидатов решена здесь поименно.
|
||||||
|
|
||||||
|
## 1. Кому и зачем
|
||||||
|
|
||||||
|
Менти уже прошел большой теоретический курс ClickHouse объемом около 60 часов.
|
||||||
|
Он знает устройство системы в теории, но почти ничего не делал своими руками.
|
||||||
|
|
||||||
|
Поэтому в программе нет вводного блока с теорией. Здесь теория становится
|
||||||
|
ремеслом: менти руками осваивает слои хранилища, DQ, оркестрацию и
|
||||||
|
идемпотентность.
|
||||||
|
|
||||||
|
Обещание программы: менти проходит конвейер стенда автором, а не читателем.
|
||||||
|
|
||||||
|
## 2. Устройство программы: основная программа и факультативы
|
||||||
|
|
||||||
|
У частей программы два статуса, третьего нет.
|
||||||
|
|
||||||
|
**Основная программа** — обязательная часть. Менти проходит ее целиком и по
|
||||||
|
порядку, потому что каждая следующая ступень опирается на предыдущую. От
|
||||||
|
ступени к ступени растет самостоятельность: сначала менти получает готовые ТЗ с
|
||||||
|
готовыми сторожами (DQ-проверками), а затем строит сторожей сам.
|
||||||
|
|
||||||
|
**Факультативы** — необязательные лабы. Они собраны в тематические разделы,
|
||||||
|
чтобы менти мог одним взглядом выбрать интересное. Для каждого раздела указано,
|
||||||
|
после какой ступени основной программы он открывается.
|
||||||
|
|
||||||
|
В программе используются четыре формы заданий:
|
||||||
|
|
||||||
|
- *построить свое по ТЗ* — рядом есть образец, но готового ответа нет;
|
||||||
|
- *вычитание* — SQL готовой трансформации удаляется и пишется заново, а даги
|
||||||
|
остаются как несущая инфраструктура;
|
||||||
|
- *собери рядом* — менти строит свою копию результата рядом с нетронутым
|
||||||
|
стендовым и сверяет их построчно;
|
||||||
|
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а
|
||||||
|
менти ищет причину и объясняет ее.
|
||||||
|
|
||||||
|
## 3. Основная программа — семь ступеней
|
||||||
|
|
||||||
|
### Ступень 1. Построй пять витрин слоя DM
|
||||||
|
|
||||||
|
Первая часть программы опирается на шесть существующих заданий из
|
||||||
|
[`docs/labs/`](../labs/README.md). Пять ТЗ по витринам входят в первую ступень
|
||||||
|
основной программы и выполняются как есть. Их точечный пересмотр — работа
|
||||||
|
реализации. Шестое задание, про свой дашборд в Superset, остается факультативом
|
||||||
|
в разделе «Superset и BI». Набор уроков пяти витрин уже записан в их общем
|
||||||
|
описании: от слоя как договора до честных имен показателей.
|
||||||
|
|
||||||
|
Форма — построить свое по ТЗ. Обратную связь дает даг `homework_check`.
|
||||||
|
|
||||||
|
### Ступень 2. Свой отчет в Superset
|
||||||
|
|
||||||
|
Менти строит один график в Superset поверх витрины, которую сделал на первой
|
||||||
|
ступени. Урок — увидеть, как собственный результат из хранилища становится
|
||||||
|
отчетом в BI, не превращая знакомство с инструментом в отдельный большой
|
||||||
|
проект.
|
||||||
|
|
||||||
|
Форма — построить свое по ТЗ. Результат проверяется глазами. ТЗ для этой новой
|
||||||
|
ступени предстоит написать при реализации программы. Полный собственный дашборд
|
||||||
|
остается факультативом в разделе «Superset и BI».
|
||||||
|
|
||||||
|
### Ступень 3. Нарежь сессии сам
|
||||||
|
|
||||||
|
До того как открыть стендовый SQL нарезки сессий в DDS, менти самостоятельно
|
||||||
|
делит события на визиты. Сначала он сравнивает число своих сессий с эталоном
|
||||||
|
`VisitID`. Затем вручную соединяет свою сессию с эталонной и поштучно разбирает
|
||||||
|
хотя бы одно расхождение границ. Урок — счет называет проблему, но причину
|
||||||
|
приходится искать в отдельных строках.
|
||||||
|
|
||||||
|
Форма — построить свое по ТЗ и провести расследование. Обратная связь состоит
|
||||||
|
из счетной сверки с `VisitID` и ручного сопоставления границ.
|
||||||
|
|
||||||
|
### Ступень 4. Перепиши нарезку стенда
|
||||||
|
|
||||||
|
Менти удаляет и заново пишет SQL нарезки сессий DDS: расчет объема, пересборку
|
||||||
|
и замену. Урок — изменить расчетную сущность внутри готового конвейера и
|
||||||
|
проверить ее внешним эталоном. Это единственный поток, для которого
|
||||||
|
инвентаризация подтвердила вычитание без оговорок: здесь есть настоящие места
|
||||||
|
для ошибок, а проверка не содержит готового ответа.
|
||||||
|
|
||||||
|
Форма — вычитание. Обратную связь дают `dq_check` и поштучная сверка,
|
||||||
|
построенная на третьей ступени.
|
||||||
|
|
||||||
|
### Ступень 5. Сначала сторож, потом ломай: заказы
|
||||||
|
|
||||||
|
Сначала менти строит две недостающие DQ-проверки. Первая утверждает равенство
|
||||||
|
«цена × количество = items_total». Вторая сверяет слой с источником по дням. Их
|
||||||
|
менти строит по образцу пяти стендовых проверок в `sql/dq/`. Обе проверки
|
||||||
|
должны покраснеть на управляемой ошибке. Затем менти вручную подкладывает по
|
||||||
|
строке каждого класса брака, проверяет разбор и убирает эти строки за собой.
|
||||||
|
|
||||||
|
Только после этого он вычитает разбор заказов ODS `order_parse` и трансформацию
|
||||||
|
заказа DDS. Урок — сторожа строят до рискованного изменения, а не после первой
|
||||||
|
поломки.
|
||||||
|
|
||||||
|
Форма — построить свои проверки по ТЗ, провести управляемое расследование и
|
||||||
|
выполнить вычитание. Обратную связь дают две новые проверки, баланс годных и
|
||||||
|
ошибочных строк по срезу и существующие сверки заказов.
|
||||||
|
|
||||||
|
### Ступень 6. Разбери сырые события и сверь результат со стендом
|
||||||
|
|
||||||
|
Менти пакетно разбирает срез сырья событий в собственную таблицу и построчно
|
||||||
|
сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока
|
||||||
|
отдельно от живого приема и использовать стендовый результат как образец для
|
||||||
|
сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет
|
||||||
|
сообщения.
|
||||||
|
|
||||||
|
Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее
|
||||||
|
границы называются прямо: срок работы ограничен окном хранения сырья, а
|
||||||
|
содержимое неопорных колонок можно сравнить только со стендовым разбором, не с
|
||||||
|
независимым эталоном.
|
||||||
|
|
||||||
|
### Ступень 7. Мониторинг: куда смотреть
|
||||||
|
|
||||||
|
Сначала менти знакомится с панелями стенда. Затем проводит два безопасных
|
||||||
|
расследования. В первом он находит в `query_log`, что верх списка тяжелых
|
||||||
|
запросов занимает сам мониторинг. Во втором объясняет, почему свежесть второго
|
||||||
|
источника выглядит как пила, а не как линия, и выбирает правильный слой данных
|
||||||
|
для панели.
|
||||||
|
|
||||||
|
Форма — расследование. Обратная связь — найденные запросы и верное объяснение
|
||||||
|
формы графика и выбора слоя. Ломательные опыты остаются в факультативе «Что
|
||||||
|
делать, когда стенд сломался». Урок ступени — куда и на что смотреть в живой
|
||||||
|
системе.
|
||||||
|
|
||||||
|
**Общее правило вычитания:** ТЗ каждого вычитания на ступенях 4 и 5 включает
|
||||||
|
пересчет ключа шардирования по `hostName()`. Счетные сверки читают `_dist`,
|
||||||
|
поэтому учитывают строку даже на чужом шарде и остаются зелеными: они не видят
|
||||||
|
саму ошибку раскладки.
|
||||||
|
|
||||||
|
## 4. Факультативы — пять разделов
|
||||||
|
|
||||||
|
Ниже перечислены только названия сюжетов и их уроки. Материал, формы и способы
|
||||||
|
проверки остаются в [инвентаризации](../research/2026-08-31-labs-inventory.md).
|
||||||
|
|
||||||
|
### Почему выручка не сходится
|
||||||
|
|
||||||
|
Этот раздел рекомендован первым из факультативов. Он открывается после ступени
|
||||||
|
1 основной программы: материал живет на стенде с этапа 6.
|
||||||
|
|
||||||
|
- **Почему трекер недосчитывает покупки (#186).** Разрыв воронки подтверждений
|
||||||
|
показывает потерянные события и границу доверия к двум источникам.
|
||||||
|
- **Почему повтор покупки завышает выручку (#187).** Движок убирает технический
|
||||||
|
дубль строки, а повтор одной покупки приходится находить по деловому ключу.
|
||||||
|
- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно
|
||||||
|
разложить до конкретной вычеркнутой позиции и объяснить до копейки.
|
||||||
|
- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а
|
||||||
|
некоторые различия после истечения срока сырья уже не восстановить.
|
||||||
|
- **Когда отчет за день можно считать окончательным (#188).** Отчет за день
|
||||||
|
становится окончательным лишь после того, как источник больше не может
|
||||||
|
изменить этот день.
|
||||||
|
- **Почему выручка за день еще меняется.** Внутри окна изменяемости выручка
|
||||||
|
движется, а после закрытия дня фиксируется.
|
||||||
|
|
||||||
|
### Тонкий анализ данных
|
||||||
|
|
||||||
|
Открывается после ступени 1 основной программы.
|
||||||
|
|
||||||
|
- **Что мы знали о пользователе в тот день (#145).** Текущее знание и знание на
|
||||||
|
дату дают разные отчеты; сведения из будущего меняют прошлое.
|
||||||
|
- **Добавь названия регионов в отчет (#143).** Менти собирает свой словарь по
|
||||||
|
образцу каталога и обогащает отчет через `dictGet`.
|
||||||
|
|
||||||
|
### Superset и BI
|
||||||
|
|
||||||
|
Открывается после ступени 2 основной программы.
|
||||||
|
|
||||||
|
- **Свой дашборд.** Готовая лаба
|
||||||
|
[`docs/labs/superset-dashboard.md`](../labs/superset-dashboard.md) учит
|
||||||
|
хранить пользовательские объекты отдельно от стендовых.
|
||||||
|
- **Почему дашборд не видит объект на второй ноде.** Забытый `ON CLUSTER` может
|
||||||
|
остаться незаметным до тех пор, пока дашборд не обратится ко второй ноде.
|
||||||
|
|
||||||
|
### Инженерия стенда
|
||||||
|
|
||||||
|
Открывается после ступени 1 основной программы. Сюжеты про переигрывание дня и
|
||||||
|
пошардовую атомарность раскроются полнее после ступени 4, когда менти уже
|
||||||
|
спускался в DDS.
|
||||||
|
|
||||||
|
- **Как принимать данные из Kafka: потоком или слепком.** Поток и слепок
|
||||||
|
требуют разных способов приема и дают разную наблюдаемость.
|
||||||
|
- **Почему данные попадают на свои шарды независимо от ноды чтения.**
|
||||||
|
Нода-потребитель может меняться, а раскладку строк определяет ключ
|
||||||
|
шардирования.
|
||||||
|
- **Почему вторая группа потребителей дублирует поток.** Еще одна группа
|
||||||
|
потребителей создает еще одну полную копию потока.
|
||||||
|
- **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах
|
||||||
|
показывает потерю, которую трудно заметить по самим данным.
|
||||||
|
- **Почему сырье удаляется по времени загрузки.** Очистка сырья живет по
|
||||||
|
времени загрузки, а не по модельному дню.
|
||||||
|
- **Куда попадают битые строки.** Годные строки и брак делят поток без
|
||||||
|
пропусков и пересечений; менти подкладывает по строке каждого класса брака
|
||||||
|
событий и находит их в `ods.event_errors_dist`.
|
||||||
|
- **Какая версия заказа считается текущей.** Версию заказа задает слепок
|
||||||
|
источника, а `FINAL` скрывается за представлением.
|
||||||
|
- **Почему один момент попадает в разные дни.** Один момент дает разные дни в
|
||||||
|
разных поясах, поэтому день и пояс нужно называть явно.
|
||||||
|
- **Проверь, как стенд разобрал JSON в `ecommerce`.** Типизированный разбор
|
||||||
|
может молча подменить ошибку значением, поэтому менти сам разбирает сырой
|
||||||
|
JSON и сравнивает результат с массивами стенда.
|
||||||
|
- **Как конвейер восстанавливает пропущенный день.** Конвейер сам находит дыру
|
||||||
|
и определяет объем повторной обработки по состоянию хранилища.
|
||||||
|
- **Что будет, если день заменился не на всех шардах.** Замена дня завершается
|
||||||
|
отдельно на каждом шарде, поэтому целостность результата подтверждает
|
||||||
|
отдельная сверка.
|
||||||
|
- **Как обновлять витрину без полной пересборки (#149) — повышенная
|
||||||
|
сложность.** Экономия на пересчете требует хранить историю изменений, которую
|
||||||
|
полная пересборка стирает.
|
||||||
|
|
||||||
|
### Что делать, когда стенд сломался
|
||||||
|
|
||||||
|
Открывается после ступени 7 основной программы; весь раздел — повышенная
|
||||||
|
сложность.
|
||||||
|
|
||||||
|
- **Что ломается вместе с keeper.** Остановка keeper связывает состояние DDL и
|
||||||
|
репликации с тем, что видно на панелях и в системных таблицах.
|
||||||
|
- **Где искать запрос DDL, который не завершился.** Очередь распределенного DDL
|
||||||
|
показывает путь запроса и ответ каждой ноды.
|
||||||
|
|
||||||
|
Итог раздела — runbook, которого сейчас не хватает стенду. Менти пишет его по
|
||||||
|
результатам опытов, и документ остается в проекте.
|
||||||
|
|
||||||
|
## 5. Границы вычитания по потокам
|
||||||
|
|
||||||
|
Подробное сравнение потоков и таблица доказательств остаются в
|
||||||
|
[инвентаризации](../research/2026-08-31-labs-inventory.md). Спека берет это
|
||||||
|
сравнение за основу и проводит свои границы. Без оговорок вычитанию отдаются
|
||||||
|
только сессии DDS. Заказы можно вычитать лишь после самодельных сторожей. Для
|
||||||
|
событий ODS выбрана форма «собери рядом»; вычитание здесь отклонено. Прием STG
|
||||||
|
и забор заказов остаются несущей стеной и доступны только для расследований.
|
||||||
|
Карту идентичностей спека решает не отдавать вычитанию; причины этого выбора
|
||||||
|
названы в следующем разделе.
|
||||||
|
|
||||||
|
## 6. Что не вошло
|
||||||
|
|
||||||
|
Вычитание стендовых витрин DM не входит в программу. Оно повторяет урок
|
||||||
|
готового ядра DM, а открытые сверочные запросы в `sql/dq/` почти дословно
|
||||||
|
содержат ответ.
|
||||||
|
|
||||||
|
Форма «собери рядом» для карты идентичностей тоже не входит. Независимого
|
||||||
|
эталона нет, а слабые инварианты проверяют допустимость связей, но не полноту.
|
||||||
|
Аналитический урок идентичности уже несет склейка во времени (#145) в
|
||||||
|
факультативе «Тонкий анализ данных».
|
||||||
|
|
||||||
|
Сюжеты, которые инвентаризация относит к лекциям, остаются лекциями: `Sign` и
|
||||||
|
CollapsingMergeTree, топологии в бою и идентичность «как в бою». Эта спека
|
||||||
|
оставляет их в
|
||||||
|
[списке опорных точек раздела 12 спеки v2](2026-07-30-stand-v2-realism.md#опорные-точки-для-будущих-лекций-и-лаб).
|
||||||
|
|
||||||
|
## 7. Судьба идей-кандидатов
|
||||||
|
|
||||||
|
Эти решения закрывают тикеты после приемки спеки:
|
||||||
|
|
||||||
|
- #143, справочник регионов — вошла в факультатив «Тонкий анализ данных».
|
||||||
|
- #145, склейка во времени — вошла в факультатив «Тонкий анализ данных».
|
||||||
|
- #148, домашки ниже DM и вычитание — вошла в основную программу как ступени
|
||||||
|
4–6. Форму для каждого потока уточнила инвентаризация #201: вычитание,
|
||||||
|
«сначала сторож» или «собери рядом».
|
||||||
|
- #149, инкрементальный `daily_traffic` — вошла в факультатив «Инженерия
|
||||||
|
стенда» с пометкой «повышенная сложность».
|
||||||
|
- #186, воронка подтверждений — вошла в факультатив «Почему выручка не
|
||||||
|
сходится».
|
||||||
|
- #187, бизнес-дубль — вошла в факультатив «Почему выручка не сходится».
|
||||||
|
- #188, опоздавшие заказы — вошла в факультатив «Почему выручка не сходится».
|
||||||
|
|
||||||
|
## 8. Отклоненные варианты оси
|
||||||
|
|
||||||
|
- **Чистое «снизу вверх»: STG → ODS → DDS → DM.** Оно начинает программу с
|
||||||
|
самого тяжелого по инфраструктуре участка. Внизу быстро кончается эталон
|
||||||
|
обратной связи, а вычитание вслепую заставляет переписывать слой, которым
|
||||||
|
менти еще не пользовался.
|
||||||
|
- **Два прохода: сначала расследования снизу вверх, потом постройка сверху
|
||||||
|
вниз.** Пролог из восьми расследований откладывает первый собственный
|
||||||
|
результат. Вместо этого расследования разнесены по факультативам и
|
||||||
|
открываются по мере прохождения основной программы.
|
||||||
|
- **Сюжетами поперек слоев.** Такой порядок ломает принятое решение, по
|
||||||
|
которому готовые задания открывают программу, и превращает путь в сетку
|
||||||
|
зависимостей.
|
||||||
|
|
||||||
|
## 9. Границы спеки и что дальше
|
||||||
|
|
||||||
|
Спека не переписывает существующие ТЗ в `docs/labs/` и общий README; точечный
|
||||||
|
пересмотр относится к реализации.
|
||||||
|
|
||||||
|
Реализация программы тоже находится за границей спеки. В нее входят новые ТЗ
|
||||||
|
для ступеней 2–7 основной программы и факультативов, а также отражение порядка
|
||||||
|
программы в `docs/labs/README.md`. Карту #182 приемка этой спеки закрыла;
|
||||||
|
исполнение ведет отдельная карта — тикет #206 (решение тикета #203).
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from enum import IntEnum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -160,6 +161,14 @@ class Purchases:
|
|||||||
return self.person_id.size
|
return self.person_id.size
|
||||||
|
|
||||||
|
|
||||||
|
class EventOutcome(IntEnum):
|
||||||
|
"""Что случилось с маячком purchase после создания заказа."""
|
||||||
|
|
||||||
|
DELIVERED = 0
|
||||||
|
LOST = 1
|
||||||
|
DUPLICATED = 2
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _Events:
|
class _Events:
|
||||||
"""Строки одного вида торговых событий, готовые встать в поток.
|
"""Строки одного вида торговых событий, готовые встать в поток.
|
||||||
@@ -177,9 +186,18 @@ class _Events:
|
|||||||
basket: NDArray[np.int64]
|
basket: NDArray[np.int64]
|
||||||
|
|
||||||
|
|
||||||
# Что торговая половина отдаёт дню: поток целиком — колонки, страницы и
|
@dataclass(frozen=True, slots=True)
|
||||||
# товары — и покупки дня вторым выходом.
|
class Woven:
|
||||||
Woven = tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], Purchases]
|
"""Что торговая половина отдает дню после вплетения событий."""
|
||||||
|
|
||||||
|
columns: dict[str, NDArray[Any]]
|
||||||
|
page: NDArray[np.uint8]
|
||||||
|
product: NDArray[np.int64]
|
||||||
|
purchases: Purchases
|
||||||
|
event_outcome: NDArray[np.int8]
|
||||||
|
# Для каждой покупки: назначен ли её заказ планом.
|
||||||
|
assigned_order: NDArray[np.bool_]
|
||||||
|
|
||||||
|
|
||||||
# День без единой корзины: покупок в нём нет, а форма у рядов есть.
|
# День без единой корзины: покупок в нём нет, а форма у рядов есть.
|
||||||
_NO_PURCHASES = Purchases(
|
_NO_PURCHASES = Purchases(
|
||||||
@@ -191,6 +209,8 @@ _NO_PURCHASES = Purchases(
|
|||||||
revenue=np.empty(0, dtype=np.int64),
|
revenue=np.empty(0, dtype=np.int64),
|
||||||
moment=np.empty(0, dtype="datetime64[s]"),
|
moment=np.empty(0, dtype="datetime64[s]"),
|
||||||
)
|
)
|
||||||
|
_NO_EVENT_OUTCOMES = np.empty(0, dtype=np.int8)
|
||||||
|
_NO_ASSIGNED_ORDERS = np.empty(0, dtype=np.bool_)
|
||||||
|
|
||||||
|
|
||||||
def weave(
|
def weave(
|
||||||
@@ -200,6 +220,7 @@ def weave(
|
|||||||
page: NDArray[np.uint8],
|
page: NDArray[np.uint8],
|
||||||
product: NDArray[np.int64],
|
product: NDArray[np.int64],
|
||||||
person: NDArray[np.uint64],
|
person: NDArray[np.uint64],
|
||||||
|
assigned_order: NDArray[np.bool_],
|
||||||
) -> Woven:
|
) -> Woven:
|
||||||
"""Вплетает торговые события в поток и отдаёт поток и покупки дня.
|
"""Вплетает торговые события в поток и отдаёт поток и покупки дня.
|
||||||
|
|
||||||
@@ -207,14 +228,21 @@ def weave(
|
|||||||
события встают между ними, и поток пересобирается одним порядком. Второй
|
события встают между ними, и поток пересобирается одним порядком. Второй
|
||||||
выход — покупки дня: то же самое, чем они уехали в события.
|
выход — покупки дня: то же самое, чем они уехали в события.
|
||||||
|
|
||||||
`person` — человек за кукой каждой строки, выровненный по входящему
|
`person` и `assigned_order` выровнены по входящему потоку. Оба
|
||||||
потоку: личность приходит от плана состава, а не выводится из трекера
|
признака приходят из плана состава: личность дает заказу `user_id`,
|
||||||
(docs/architecture/orders/identity.md).
|
а назначение защищает мост склейки от потери purchase.
|
||||||
"""
|
"""
|
||||||
rng = day_stream(seed, day, Component.COMMERCE)
|
rng = day_stream(seed, day, Component.COMMERCE)
|
||||||
baskets = _baskets(rng, page, product, columns["VisitID"])
|
baskets = _baskets(rng, page, product, columns["VisitID"])
|
||||||
if not len(baskets):
|
if not len(baskets):
|
||||||
return columns, page, product, _NO_PURCHASES
|
return Woven(
|
||||||
|
columns=columns,
|
||||||
|
page=page,
|
||||||
|
product=product,
|
||||||
|
purchases=_NO_PURCHASES,
|
||||||
|
event_outcome=_NO_EVENT_OUTCOMES,
|
||||||
|
assigned_order=_NO_ASSIGNED_ORDERS,
|
||||||
|
)
|
||||||
|
|
||||||
goods = catalog.catalog()
|
goods = catalog.catalog()
|
||||||
draws = _draws(rng, baskets)
|
draws = _draws(rng, baskets)
|
||||||
@@ -225,8 +253,20 @@ def weave(
|
|||||||
_order_events(columns, baskets, goods, draws, revenue, coupons),
|
_order_events(columns, baskets, goods, draws, revenue, coupons),
|
||||||
)
|
)
|
||||||
columns, page, product, basket = _stream(rng, columns, page, product, events)
|
columns, page, product, basket = _stream(rng, columns, page, product, events)
|
||||||
purchases = _purchases(columns, basket, baskets, draws, revenue, coupons, person)
|
purchases, assigned = _purchases(
|
||||||
return columns, page, product, purchases
|
columns, basket, baskets, draws, revenue, coupons, person, assigned_order
|
||||||
|
)
|
||||||
|
columns, page, product, event_outcome = _corrupt_events(
|
||||||
|
seed, day, columns, page, product, purchases, assigned
|
||||||
|
)
|
||||||
|
return Woven(
|
||||||
|
columns=columns,
|
||||||
|
page=page,
|
||||||
|
product=product,
|
||||||
|
purchases=purchases,
|
||||||
|
event_outcome=event_outcome,
|
||||||
|
assigned_order=assigned,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
|
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
|
||||||
@@ -653,7 +693,8 @@ def _purchases(
|
|||||||
revenue: NDArray[np.int64],
|
revenue: NDArray[np.int64],
|
||||||
coupons: NDArray[np.object_],
|
coupons: NDArray[np.object_],
|
||||||
person: NDArray[np.uint64],
|
person: NDArray[np.uint64],
|
||||||
) -> Purchases:
|
assigned_order: NDArray[np.bool_],
|
||||||
|
) -> tuple[Purchases, NDArray[np.bool_]]:
|
||||||
"""Покупки дня — то же, чем они уехали в события, только структурой.
|
"""Покупки дня — то же, чем они уехали в события, только структурой.
|
||||||
|
|
||||||
Порядок здесь — порядок строк потока, он же порядок номеров заказа:
|
Порядок здесь — порядок строк потока, он же порядок номеров заказа:
|
||||||
@@ -664,7 +705,7 @@ def _purchases(
|
|||||||
here = np.flatnonzero(rows["EventType"] == PURCHASE)
|
here = np.flatnonzero(rows["EventType"] == PURCHASE)
|
||||||
mine = basket[here]
|
mine = basket[here]
|
||||||
bought = [baskets.positions_of(number, draws.kept) for number in mine.tolist()]
|
bought = [baskets.positions_of(number, draws.kept) for number in mine.tolist()]
|
||||||
return Purchases(
|
purchases = Purchases(
|
||||||
order_id=tuple(rows["purchaseID"][row][0] for row in here.tolist()),
|
order_id=tuple(rows["purchaseID"][row][0] for row in here.tolist()),
|
||||||
person_id=person[baskets.confirmation[mine]],
|
person_id=person[baskets.confirmation[mine]],
|
||||||
product=tuple(baskets.product[group] for group in bought),
|
product=tuple(baskets.product[group] for group in bought),
|
||||||
@@ -673,6 +714,118 @@ def _purchases(
|
|||||||
revenue=revenue[mine],
|
revenue=revenue[mine],
|
||||||
moment=rows["UTCEventTime"][here],
|
moment=rows["UTCEventTime"][here],
|
||||||
)
|
)
|
||||||
|
return purchases, assigned_order[baskets.confirmation[mine]]
|
||||||
|
|
||||||
|
|
||||||
|
def _corrupt_events(
|
||||||
|
seed: int,
|
||||||
|
day: int,
|
||||||
|
rows: dict[str, NDArray[Any]],
|
||||||
|
page: NDArray[np.uint8],
|
||||||
|
product: NDArray[np.int64],
|
||||||
|
purchases: Purchases,
|
||||||
|
assigned: NDArray[np.bool_],
|
||||||
|
) -> tuple[
|
||||||
|
dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], NDArray[np.int8]
|
||||||
|
]:
|
||||||
|
"""Теряет purchase или повторяет пару подтверждения.
|
||||||
|
|
||||||
|
Заказы уже собраны, а номера покупок уже вписаны в строки. Поэтому
|
||||||
|
порча меняет только событийную сторону. Все броски делаются на полную
|
||||||
|
длину покупок дня, чтобы отбор одного класса не сдвигал другой.
|
||||||
|
"""
|
||||||
|
count = len(purchases)
|
||||||
|
if not count:
|
||||||
|
return rows, page, product, _NO_EVENT_OUTCOMES
|
||||||
|
|
||||||
|
rng = day_stream(seed, day, Component.BEACONS)
|
||||||
|
lost = (rng.integers(0, 100, count) < world.LOST_PURCHASE_PERCENT) & ~assigned
|
||||||
|
duplicate_draw = rng.integers(0, 100, count) < world.DUPLICATE_PURCHASE_PERCENT
|
||||||
|
delay = rng.integers(*world.DUPLICATE_DELAY_SECONDS, count)
|
||||||
|
watch = ids.unique_apart_from(rng, 2 * count, rows["WatchID"]).reshape(count, 2)
|
||||||
|
|
||||||
|
purchase_rows = np.flatnonzero(rows["EventType"] == PURCHASE)
|
||||||
|
confirmation_rows = _confirmation_rows(rows, page, purchase_rows)
|
||||||
|
eligible = _duplicate_eligibility(rows, purchase_rows, delay)
|
||||||
|
duplicated = ~lost & duplicate_draw & eligible
|
||||||
|
|
||||||
|
outcome = np.full(count, EventOutcome.DELIVERED, dtype=np.int8)
|
||||||
|
outcome[lost] = EventOutcome.LOST
|
||||||
|
outcome[duplicated] = EventOutcome.DUPLICATED
|
||||||
|
|
||||||
|
keep = np.ones(page.size, dtype=bool)
|
||||||
|
keep[purchase_rows[lost]] = False
|
||||||
|
sources = np.column_stack(
|
||||||
|
(confirmation_rows[duplicated], purchase_rows[duplicated])
|
||||||
|
)
|
||||||
|
selected = np.flatnonzero(duplicated)
|
||||||
|
if selected.size:
|
||||||
|
copies = sources.ravel()
|
||||||
|
shifted = np.repeat(delay[selected], 2).astype("timedelta64[s]")
|
||||||
|
additions = {name: values[copies] for name, values in rows.items()}
|
||||||
|
additions["UTCEventTime"] = additions["UTCEventTime"] + shifted
|
||||||
|
additions["WatchID"] = watch[selected].ravel()
|
||||||
|
# Первая строка пары — обновление confirmation: ее предыдущая
|
||||||
|
# страница — она же, а не checkout из первого просмотра.
|
||||||
|
additions["Referer"] = additions["URL"].copy()
|
||||||
|
rows = {
|
||||||
|
name: np.concatenate((values[keep], additions[name]))
|
||||||
|
for name, values in rows.items()
|
||||||
|
}
|
||||||
|
page = np.concatenate((page[keep], page[copies]))
|
||||||
|
product = np.concatenate((product[keep], product[copies]))
|
||||||
|
else:
|
||||||
|
rows = {name: values[keep] for name, values in rows.items()}
|
||||||
|
page = page[keep]
|
||||||
|
product = product[keep]
|
||||||
|
|
||||||
|
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
|
||||||
|
return (
|
||||||
|
{name: values[order] for name, values in rows.items()},
|
||||||
|
page[order],
|
||||||
|
product[order],
|
||||||
|
outcome,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _confirmation_rows(
|
||||||
|
rows: dict[str, NDArray[Any]],
|
||||||
|
page: NDArray[np.uint8],
|
||||||
|
purchase_rows: NDArray[np.int64],
|
||||||
|
) -> NDArray[np.int64]:
|
||||||
|
"""Находит просмотр, с которым purchase образует пару."""
|
||||||
|
confirmation = (rows["EventType"] == "pageview") & (page == Page.CONFIRMATION)
|
||||||
|
by_visit = {
|
||||||
|
int(rows["VisitID"][row]): row for row in np.flatnonzero(confirmation).tolist()
|
||||||
|
}
|
||||||
|
return np.array(
|
||||||
|
[by_visit[int(rows["VisitID"][row])] for row in purchase_rows.tolist()],
|
||||||
|
dtype=np.int64,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_eligibility(
|
||||||
|
rows: dict[str, NDArray[Any]],
|
||||||
|
purchase_rows: NDArray[np.int64],
|
||||||
|
delay: NDArray[np.int64],
|
||||||
|
) -> NDArray[np.bool_]:
|
||||||
|
"""Проверяет полночь и запас до следующего визита куки."""
|
||||||
|
moment = rows["UTCEventTime"][purchase_rows]
|
||||||
|
shifted = moment + delay.astype("timedelta64[s]")
|
||||||
|
before_midnight = shifted < _midnight(rows) + np.timedelta64(1, "D")
|
||||||
|
eligible = before_midnight.copy()
|
||||||
|
for number, row in enumerate(purchase_rows.tolist()):
|
||||||
|
later_visit = (
|
||||||
|
(rows["ClientID"] == rows["ClientID"][row])
|
||||||
|
& (rows["VisitID"] != rows["VisitID"][row])
|
||||||
|
& (rows["UTCEventTime"] > moment[number])
|
||||||
|
)
|
||||||
|
if np.any(later_visit):
|
||||||
|
next_start = rows["UTCEventTime"][later_visit].min()
|
||||||
|
eligible[number] &= next_start - moment[number] > np.timedelta64(
|
||||||
|
world.VISIT_TIMEOUT_SECONDS + int(delay[number]), "s"
|
||||||
|
)
|
||||||
|
return eligible
|
||||||
|
|
||||||
|
|
||||||
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
|
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ class Day:
|
|||||||
page: NDArray[np.uint8]
|
page: NDArray[np.uint8]
|
||||||
product: NDArray[np.int64]
|
product: NDArray[np.int64]
|
||||||
orders: orders.Orders
|
orders: orders.Orders
|
||||||
|
# Событийный исход каждого заказа, в том же порядке.
|
||||||
|
purchase_outcome: NDArray[np.int8]
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self.page.size
|
return self.page.size
|
||||||
@@ -162,20 +164,23 @@ def stream(seed: int, day: int) -> Day:
|
|||||||
# целиком: в нём же они и упорядочиваются. Вторым выходом приходят
|
# целиком: в нём же они и упорядочиваются. Вторым выходом приходят
|
||||||
# покупки дня — из них заказная сторона собирает заказы.
|
# покупки дня — из них заказная сторона собирает заказы.
|
||||||
person = np.repeat(audience.person_id[visits.cookie], visits.pages)
|
person = np.repeat(audience.person_id[visits.cookie], visits.pages)
|
||||||
columns, page, product, purchases = commerce.weave(
|
assigned_order = np.repeat(visits.ordering, visits.pages)
|
||||||
|
woven = commerce.weave(
|
||||||
seed,
|
seed,
|
||||||
day,
|
day,
|
||||||
{name: value[order] for name, value in columns.items()},
|
{name: value[order] for name, value in columns.items()},
|
||||||
page[alive][order],
|
page[alive][order],
|
||||||
product[alive][order],
|
product[alive][order],
|
||||||
person[alive][order],
|
person[alive][order],
|
||||||
|
assigned_order[alive][order],
|
||||||
)
|
)
|
||||||
return Day(
|
return Day(
|
||||||
day=day,
|
day=day,
|
||||||
columns=columns,
|
columns=woven.columns,
|
||||||
page=page,
|
page=woven.page,
|
||||||
product=product,
|
product=woven.product,
|
||||||
orders=orders.of_day(seed, day, purchases),
|
orders=orders.of_day(seed, day, woven.purchases, woven.assigned_order),
|
||||||
|
purchase_outcome=woven.event_outcome,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
|
|
||||||
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по
|
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по
|
||||||
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
|
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
|
||||||
Эталонный снимок в четырнадцать дней придёт на этапе 7 и станет продолжением
|
Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 —
|
||||||
этой же описи, а не вторым файлом.
|
у хвоста не нашлось читателя. Понадобится длиннее — опись продолжит этот
|
||||||
|
же файл, а не заведет второй.
|
||||||
|
|
||||||
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
|
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
|
||||||
это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир
|
это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир
|
||||||
@@ -119,17 +120,38 @@ def _snapshot(number: int, orders: list[orders_module.Orders]) -> dict[str, Any]
|
|||||||
|
|
||||||
|
|
||||||
def _order_class_counts(today: day_module.Day) -> dict[str, int]:
|
def _order_class_counts(today: day_module.Day) -> dict[str, int]:
|
||||||
"""Заказы дня по итоговому классу: отмена перевешивает дельту суммы."""
|
"""Заказы дня по итоговому классу по приоритету спеки."""
|
||||||
purchase = today.columns["EventType"] == commerce.PURCHASE
|
purchase = today.columns["EventType"] == commerce.PURCHASE
|
||||||
declared = today.columns["purchaseRevenue"][purchase]
|
declared = {
|
||||||
counts = {"match": 0, "cancelled": 0, "amount_delta": 0}
|
purchase_id[0]: round(revenue[0] * commerce.KOPECKS)
|
||||||
|
for purchase_id, revenue in zip(
|
||||||
|
today.columns["purchaseID"][purchase],
|
||||||
|
today.columns["purchaseRevenue"][purchase],
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
counts = {
|
||||||
|
"match": 0,
|
||||||
|
"cancelled": 0,
|
||||||
|
"lost_event": 0,
|
||||||
|
"duplicate_event": 0,
|
||||||
|
"amount_delta": 0,
|
||||||
|
}
|
||||||
|
|
||||||
for outcome, items_total, revenue in zip(
|
for order_id, order_outcome, event_outcome, items_total in zip(
|
||||||
today.orders.outcome, today.orders.items_total, declared, strict=True
|
today.orders.order_id,
|
||||||
|
today.orders.outcome,
|
||||||
|
today.purchase_outcome,
|
||||||
|
today.orders.items_total,
|
||||||
|
strict=True,
|
||||||
):
|
):
|
||||||
if outcome != orders_module.OrderOutcome.PAID:
|
if order_outcome != orders_module.OrderOutcome.PAID:
|
||||||
counts["cancelled"] += 1
|
counts["cancelled"] += 1
|
||||||
elif items_total != round(revenue[0] * commerce.KOPECKS):
|
elif event_outcome == commerce.EventOutcome.LOST:
|
||||||
|
counts["lost_event"] += 1
|
||||||
|
elif event_outcome == commerce.EventOutcome.DUPLICATED:
|
||||||
|
counts["duplicate_event"] += 1
|
||||||
|
elif items_total != declared[order_id]:
|
||||||
counts["amount_delta"] += 1
|
counts["amount_delta"] += 1
|
||||||
else:
|
else:
|
||||||
counts["match"] += 1
|
counts["match"] += 1
|
||||||
|
|||||||
@@ -24,11 +24,13 @@
|
|||||||
одной доли перебрасывала бы весь подпоток после себя
|
одной доли перебрасывала бы весь подпоток после себя
|
||||||
(docs/architecture/orders/fate.md).
|
(docs/architecture/orders/fate.md).
|
||||||
|
|
||||||
**Судьба заказа — исход и его моменты**: из окна изменяемости заказ выходит
|
**Судьба заказа — исход, его моменты и задержка выгрузки**. Из окна
|
||||||
оплаченным или отменённым, а когда именно это случилось, сказано смещением в
|
изменяемости заказ выходит оплаченным или отменённым, а когда именно это
|
||||||
секундах от рождения заказа. Момента, которого у исхода нет, нет и в данных:
|
случилось, сказано смещением в секундах от рождения заказа. Момента, которого
|
||||||
его место занимает −1, а не ноль, — иначе «оплатили в секунду рождения» было
|
у исхода нет, нет и в данных: его место занимает −1, а не ноль, — иначе
|
||||||
бы не отличить от «не оплатили вовсе».
|
«оплатили в секунду рождения» было бы не отличить от «не оплатили вовсе».
|
||||||
|
Задержка говорит, сколько ранних слепков заказ пропустит; назначенный планом
|
||||||
|
приезжает сразу.
|
||||||
|
|
||||||
**Состояние на границе суток** — чтение готовой судьбы, а не накопление:
|
**Состояние на границе суток** — чтение готовой судьбы, а не накопление:
|
||||||
слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет
|
слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет
|
||||||
@@ -62,6 +64,7 @@ _DELIVERY_CUMULATIVE = np.cumsum(
|
|||||||
)
|
)
|
||||||
_OUTCOME_CUMULATIVE = np.cumsum(world.ORDER_OUTCOME_WEIGHTS)
|
_OUTCOME_CUMULATIVE = np.cumsum(world.ORDER_OUTCOME_WEIGHTS)
|
||||||
_MOMENT_HOUR_CUMULATIVE = np.cumsum(world.ORDER_MOMENT_HOUR_WEIGHTS)
|
_MOMENT_HOUR_CUMULATIVE = np.cumsum(world.ORDER_MOMENT_HOUR_WEIGHTS)
|
||||||
|
_SNAPSHOT_DELAY_CUMULATIVE = np.cumsum(world.ORDER_SNAPSHOT_DELAY_WEIGHTS)
|
||||||
|
|
||||||
|
|
||||||
class OrderOutcome(IntEnum):
|
class OrderOutcome(IntEnum):
|
||||||
@@ -103,12 +106,18 @@ class Orders:
|
|||||||
outcome: NDArray[np.int64]
|
outcome: NDArray[np.int64]
|
||||||
paid_after: NDArray[np.int64]
|
paid_after: NDArray[np.int64]
|
||||||
cancelled_after: NDArray[np.int64]
|
cancelled_after: NDArray[np.int64]
|
||||||
|
snapshot_delay: NDArray[np.int64]
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self.user_id.size
|
return self.user_id.size
|
||||||
|
|
||||||
|
|
||||||
def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
|
def of_day(
|
||||||
|
seed: int,
|
||||||
|
day: int,
|
||||||
|
purchases: Purchases,
|
||||||
|
assigned_order: NDArray[np.bool_],
|
||||||
|
) -> Orders:
|
||||||
"""Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги."""
|
"""Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги."""
|
||||||
rng = day_stream(seed, day, Component.ORDERS)
|
rng = day_stream(seed, day, Component.ORDERS)
|
||||||
delivery = _delivery(rng, len(purchases))
|
delivery = _delivery(rng, len(purchases))
|
||||||
@@ -116,6 +125,7 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
|
|||||||
product, quantity, items_total = _delta(rng, purchases)
|
product, quantity, items_total = _delta(rng, purchases)
|
||||||
created_at = _created_at(rng, purchases)
|
created_at = _created_at(rng, purchases)
|
||||||
discount = _discount(purchases, items_total)
|
discount = _discount(purchases, items_total)
|
||||||
|
snapshot_delay = _snapshot_delay(rng, assigned_order)
|
||||||
return Orders(
|
return Orders(
|
||||||
day=day,
|
day=day,
|
||||||
order_id=purchases.order_id,
|
order_id=purchases.order_id,
|
||||||
@@ -130,6 +140,7 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
|
|||||||
outcome=outcome,
|
outcome=outcome,
|
||||||
paid_after=paid_after,
|
paid_after=paid_after,
|
||||||
cancelled_after=cancelled_after,
|
cancelled_after=cancelled_after,
|
||||||
|
snapshot_delay=snapshot_delay,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -166,6 +177,11 @@ def at_boundary(rows: Orders, day: int) -> tuple[list[str], NDArray[np.datetime6
|
|||||||
return status.tolist(), updated
|
return status.tolist(), updated
|
||||||
|
|
||||||
|
|
||||||
|
def arrived(rows: Orders, day: int) -> NDArray[np.bool_]:
|
||||||
|
"""Какие заказы уже попали в выгрузку к слепку дня `day`."""
|
||||||
|
return rows.day + rows.snapshot_delay <= day
|
||||||
|
|
||||||
|
|
||||||
def _boundary(day: int) -> np.datetime64:
|
def _boundary(day: int) -> np.datetime64:
|
||||||
"""Граница суток `day`|`day+1` абсолютной меткой: полночь пояса счётчика.
|
"""Граница суток `day`|`day+1` абсолютной меткой: полночь пояса счётчика.
|
||||||
|
|
||||||
@@ -218,6 +234,15 @@ def _created_at(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_delay(
|
||||||
|
rng: np.random.Generator,
|
||||||
|
assigned_order: NDArray[np.bool_],
|
||||||
|
) -> NDArray[np.int64]:
|
||||||
|
"""Сколько ранних слепков пропустит заказ; назначенный приезжает сразу."""
|
||||||
|
delay = pick(rng, _SNAPSHOT_DELAY_CUMULATIVE, assigned_order.size)
|
||||||
|
return np.where(assigned_order, 0, delay)
|
||||||
|
|
||||||
|
|
||||||
def _moment(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
|
def _moment(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
|
||||||
"""Момент внутри окна: час по таблице весов и равномерная секунда в нём.
|
"""Момент внутри окна: час по таблице весов и равномерная секунда в нём.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
│ ├── ось → номер дня: когорта этого дня
|
│ ├── ось → номер дня: когорта этого дня
|
||||||
│ └── предыстория → глубина: когорта дня до D0
|
│ └── предыстория → глубина: когорта дня до D0
|
||||||
└── дни
|
└── дни
|
||||||
└── номер дня → трафик, торговля, заказная сторона, опоздания
|
└── номер дня → трафик, торговля, заказы, маячки счетчика
|
||||||
|
|
||||||
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
|
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
|
||||||
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
|
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
|
||||||
@@ -40,10 +40,8 @@ class Component(IntEnum):
|
|||||||
# каждого заказа читает из его собственного дня
|
# каждого заказа читает из его собственного дня
|
||||||
# (docs/architecture/orders/fate.md).
|
# (docs/architecture/orders/fate.md).
|
||||||
ORDERS = 2
|
ORDERS = 2
|
||||||
# Событийную сторону — потерю и дубль события — заведёт этап 6; до тех
|
# Маячки счетчика: потеря и повторная отправка событий.
|
||||||
# пор позиция занята мёртвым именем прежней нарезки, и из него никто не
|
BEACONS = 3
|
||||||
# бросает.
|
|
||||||
LATECOMERS = 3
|
|
||||||
|
|
||||||
|
|
||||||
class _Branch(IntEnum):
|
class _Branch(IntEnum):
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ from numpy.typing import NDArray
|
|||||||
|
|
||||||
from clickstream_generator import catalog, schema, world
|
from clickstream_generator import catalog, schema, world
|
||||||
from clickstream_generator.day import Day
|
from clickstream_generator.day import Day
|
||||||
from clickstream_generator.orders import Orders, at_boundary
|
from clickstream_generator.orders import Orders, arrived, at_boundary
|
||||||
|
|
||||||
_ARRAY_PREFIX = "Array("
|
_ARRAY_PREFIX = "Array("
|
||||||
|
|
||||||
@@ -72,9 +72,10 @@ def events(day: Day, limit: int | None = None) -> list[bytes]:
|
|||||||
def orders(window: Sequence[Orders], day: int) -> list[bytes]:
|
def orders(window: Sequence[Orders], day: int) -> list[bytes]:
|
||||||
"""Канонические байты слепка дня `day`: по документу JSON на заказ.
|
"""Канонические байты слепка дня `day`: по документу JSON на заказ.
|
||||||
|
|
||||||
`window` — заказы дней окна, от раннего дня к позднему: слепок несёт их
|
`window` — заказы дней окна, от раннего дня к позднему. Уже приехавшие
|
||||||
подряд, и порядок строк выходит порядком рождения заказов, он же
|
заказы слепок несёт подряд, поэтому порядок строк остаётся порядком
|
||||||
возрастание `order_id`. Какие это дни, решает `orders.window`.
|
рождения, он же возрастание `order_id`. Какие это дни, решает
|
||||||
|
`orders.window`.
|
||||||
|
|
||||||
Деньги уезжают строками с ровно двумя знаками, а не числами: у заказа они
|
Деньги уезжают строками с ровно двумя знаками, а не числами: у заказа они
|
||||||
станут `Decimal`, и дробь двоичного числа была бы потерей точности до
|
станут `Decimal`, и дробь двоичного числа была бы потерей точности до
|
||||||
@@ -97,7 +98,8 @@ def orders(window: Sequence[Orders], day: int) -> list[bytes]:
|
|||||||
delivery = rows.delivery.tolist()
|
delivery = rows.delivery.tolist()
|
||||||
total = rows.total.tolist()
|
total = rows.total.tolist()
|
||||||
|
|
||||||
for number, order_id in enumerate(rows.order_id):
|
for number in np.flatnonzero(arrived(rows, day)).tolist():
|
||||||
|
order_id = rows.order_id[number]
|
||||||
payloads.append(
|
payloads.append(
|
||||||
orjson.dumps(
|
orjson.dumps(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -169,6 +169,16 @@ BUYER_CHECKOUT_OF_CART_PERCENT = 60
|
|||||||
# страницу, на которой посетитель его нажал.
|
# страницу, на которой посетитель его нажал.
|
||||||
TRADE_DELAY_SECONDS = (2, 8)
|
TRADE_DELAY_SECONDS = (2, 8)
|
||||||
|
|
||||||
|
# Событийная сторона сверки: потерянные маячки purchase и повторная
|
||||||
|
# отправка пары «просмотр подтверждения + purchase». Замер (28 дней,
|
||||||
|
# канонический мир, 2026-08-30): дубль 1,89% — в пределах ориентира ~2%;
|
||||||
|
# потеря 1,78% при броске 3% — так и задумано: назначенные планом покупки
|
||||||
|
# из броска исключены (мост склейки), часть съедает приоритет отмены;
|
||||||
|
# наблюдаемая доля принята решением владельца (#194).
|
||||||
|
LOST_PURCHASE_PERCENT = 3
|
||||||
|
DUPLICATE_PURCHASE_PERCENT = 2
|
||||||
|
DUPLICATE_DELAY_SECONDS = (30, 301)
|
||||||
|
|
||||||
# Кто и что кладёт в корзину: доля открытых карточек, которая до неё дошла.
|
# Кто и что кладёт в корзину: доля открытых карточек, которая до неё дошла.
|
||||||
# Оба ряда читаются по уровням спроса товара (`catalog.DEMAND_LEVELS`):
|
# Оба ряда читаются по уровням спроса товара (`catalog.DEMAND_LEVELS`):
|
||||||
# магнит, обычный, залёживается.
|
# магнит, обычный, залёживается.
|
||||||
@@ -238,8 +248,8 @@ GOAL_PURCHASE_ID = 42150002
|
|||||||
# обычная курьерская, срочная. Это единственные деньги заказа, которых нет
|
# обычная курьерская, срочная. Это единственные деньги заказа, которых нет
|
||||||
# ни в одном событии: выручка клиента — сумма позиций, скидку по промокоду
|
# ни в одном событии: выручка клиента — сумма позиций, скидку по промокоду
|
||||||
# считает бэкенд по таблице `COUPONS`, а доставку решает свой бросок
|
# считает бэкенд по таблице `COUPONS`, а доставку решает свой бросок
|
||||||
# заказной стороны. Числа черновые: калибровка — пересборка эталонного
|
# заказной стороны. Замер (28 дней, канонический мир, 2026-08-30): 35,5% /
|
||||||
# мира, этап 7.
|
# 49,2% / 15,4% — совпадает с весами.
|
||||||
DELIVERY_KOPECKS_WEIGHTS = (
|
DELIVERY_KOPECKS_WEIGHTS = (
|
||||||
(0, 35),
|
(0, 35),
|
||||||
(29_900, 50),
|
(29_900, 50),
|
||||||
@@ -248,12 +258,13 @@ DELIVERY_KOPECKS_WEIGHTS = (
|
|||||||
|
|
||||||
# Чем кончается окно изменяемости заказа: веса трёх исходов, в порядке
|
# Чем кончается окно изменяемости заказа: веса трёх исходов, в порядке
|
||||||
# `orders.OrderOutcome`. «Создан» навсегда мир не допускает — из окна заказ
|
# `orders.OrderOutcome`. «Создан» навсегда мир не допускает — из окна заказ
|
||||||
# выходит либо оплаченным, либо отменённым. Доли черновые: калибровка —
|
# выходит либо оплаченным, либо отменённым. Замер (28 дней, 2026-08-30):
|
||||||
# пересборка эталонного мира, этап 7.
|
# отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12% отменен
|
||||||
|
# неоплаченным; ориентир мастер-спеки ~5%.
|
||||||
ORDER_OUTCOME_WEIGHTS = (
|
ORDER_OUTCOME_WEIGHTS = (
|
||||||
95, # оплачен
|
95, # оплачен
|
||||||
3, # оплачен, потом отменён
|
3, # оплачен, потом отменён
|
||||||
2, # отменён неоплаченным
|
2, # отменен неоплаченным
|
||||||
)
|
)
|
||||||
|
|
||||||
# Когда внутри окна случается момент заказа — оплата или отмена: веса часов
|
# Когда внутри окна случается момент заказа — оплата или отмена: веса часов
|
||||||
@@ -261,8 +272,8 @@ ORDER_OUTCOME_WEIGHTS = (
|
|||||||
# с каким весом»; часов в сумме ровно 144 — самый узкий край окна, у заказа,
|
# с каким весом»; часов в сумме ровно 144 — самый узкий край окна, у заказа,
|
||||||
# рождённого в конце суток (docs/architecture/orders/fate.md). Почти всё в
|
# рождённого в конце суток (docs/architecture/orders/fate.md). Почти всё в
|
||||||
# первые сутки, дальше затухание до края:
|
# первые сутки, дальше затухание до края:
|
||||||
# профиль гаснет, поэтому обрыв окна в данных не виден. Веса черновые, как и
|
# профиль гаснет, поэтому обрыв окна в данных не виден. Веса зафиксированы
|
||||||
# доли исходов.
|
# вместе с долями исходов — тем же замером выше.
|
||||||
ORDER_MOMENT_HOUR_WEIGHTS = tuple(
|
ORDER_MOMENT_HOUR_WEIGHTS = tuple(
|
||||||
weight
|
weight
|
||||||
for hours, weight in ((6, 100), (18, 60), (24, 25), (48, 8), (48, 2))
|
for hours, weight in ((6, 100), (18, 60), (24, 25), (48, 8), (48, 2))
|
||||||
@@ -273,6 +284,12 @@ ORDER_MOMENT_HOUR_WEIGHTS = tuple(
|
|||||||
# наличии, и заказ приезжает на строку короче клиентской корзины. Бросается
|
# наличии, и заказ приезжает на строку короче клиентской корзины. Бросается
|
||||||
# доля на все заказы дня, а достаётся дельта не всем — однопозиционному заказу
|
# доля на все заказы дня, а достаётся дельта не всем — однопозиционному заказу
|
||||||
# терять нечего, и таких больше половины, — поэтому в сверке наблюдаемая доля
|
# терять нечего, и таких больше половины, — поэтому в сверке наблюдаемая доля
|
||||||
# выходит вдвое-втрое ниже броска, около ориентира мастер-спеки в 1–2%. Число
|
# выходит вдвое-втрое ниже броска, около ориентира мастер-спеки в 1–2%. Замер
|
||||||
# черновое: калибровка — пересборка эталонного мира, этап 7.
|
# (28 дней, 2026-08-30): наблюдаемая доля 1,32% — в пределах ориентира.
|
||||||
ORDER_DELTA_PERCENT = 4
|
ORDER_DELTA_PERCENT = 4
|
||||||
|
|
||||||
|
# На сколько первых дневных слепков заказ опоздает: веса δ = 0, 1, 2.
|
||||||
|
# Назначенные планом заказы после броска получают 0, чтобы мост склейки
|
||||||
|
# появился в стартовом мире. Замер (28 дней, 2026-08-30): доля опоздавших
|
||||||
|
# (δ>0) 8,39% — в пределах ориентира мастер-спеки ~10%.
|
||||||
|
ORDER_SNAPSHOT_DELAY_WEIGHTS = (90, 5, 5)
|
||||||
@@ -21,7 +21,7 @@ from clickstream_generator.seeds import CANONICAL_SEED, Component
|
|||||||
|
|
||||||
WEEKDAY = 2
|
WEEKDAY = 2
|
||||||
WEEK = 7
|
WEEK = 7
|
||||||
# Окно эталонного снимка: доли по уровням спроса меряются на нём целиком —
|
# Окно замера — две недели: доли по уровням спроса меряются на нем целиком —
|
||||||
# на одном дне у самого редкого уровня набирается слишком мало карточек.
|
# на одном дне у самого редкого уровня набирается слишком мало карточек.
|
||||||
FORTNIGHT = 14
|
FORTNIGHT = 14
|
||||||
|
|
||||||
@@ -133,15 +133,15 @@ def opened_cards(events: day.Day) -> set[tuple[int, int]]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_a_confirmation_in_the_stream_always_has_its_purchase(weekday: day.Day):
|
def test_only_a_lost_purchase_leaves_a_confirmation_without_purchase(
|
||||||
"""Клиентская сторона честная: подтверждение без покупки — это потеря.
|
weekday: day.Day,
|
||||||
|
):
|
||||||
Потери и дубли стенд заводит намеренно и позже (этапы 4 и 6); здесь их
|
"""Потеря выбрасывает purchase, но оставляет просмотр confirmation."""
|
||||||
быть не должно ни одной. Обратное тоже верно: покупка без подтверждения
|
|
||||||
в потоке — заказ ниоткуда.
|
|
||||||
"""
|
|
||||||
bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist())
|
bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist())
|
||||||
assert confirmed_visits(weekday) == bought
|
missing = confirmed_visits(weekday) - bought
|
||||||
|
lost = int((weekday.purchase_outcome == commerce.EventOutcome.LOST).sum())
|
||||||
|
assert len(missing) == lost > 0
|
||||||
|
assert bought <= confirmed_visits(weekday)
|
||||||
|
|
||||||
|
|
||||||
def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
|
def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
|
||||||
@@ -159,7 +159,10 @@ def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
|
|||||||
events = day.stream(CANONICAL_SEED, WEEKDAY)
|
events = day.stream(CANONICAL_SEED, WEEKDAY)
|
||||||
bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist())
|
bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist())
|
||||||
assert bought
|
assert bought
|
||||||
assert confirmed_visits(events) == bought
|
missing = confirmed_visits(events) - bought
|
||||||
|
lost = int((events.purchase_outcome == commerce.EventOutcome.LOST).sum())
|
||||||
|
assert len(missing) == lost
|
||||||
|
assert bought <= confirmed_visits(events)
|
||||||
|
|
||||||
|
|
||||||
def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day):
|
def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day):
|
||||||
@@ -525,13 +528,15 @@ def test_the_goals_repeat_the_trade_events(weekday: day.Day):
|
|||||||
|
|
||||||
def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day):
|
def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day):
|
||||||
"""Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда."""
|
"""Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда."""
|
||||||
orders = rows_of(weekday, commerce.PURCHASE)
|
events = rows_of(weekday, commerce.PURCHASE)
|
||||||
numbers = [cell[0] for cell in orders["purchaseID"]]
|
numbers = [cell[0] for cell in events["purchaseID"]]
|
||||||
date = str(weekday.columns["EventDate"][0]).replace("-", "")
|
date = str(weekday.columns["EventDate"][0]).replace("-", "")
|
||||||
assert numbers == [f"{date}-{place:04d}" for place in range(1, len(numbers) + 1)]
|
expected = [f"{date}-{place:04d}" for place in range(1, len(weekday.orders) + 1)]
|
||||||
assert len(set(numbers)) == len(numbers)
|
# Порча идет после нумерации: потеря оставляет дыру, а дубль
|
||||||
# Нумерация идёт в порядке событий: строки дня уже упорядочены по времени.
|
# повторяет готовый номер и не двигает соседей.
|
||||||
assert list(orders["UTCEventTime"]) == sorted(orders["UTCEventTime"])
|
assert list(weekday.orders.order_id) == expected
|
||||||
|
assert set(numbers) <= set(expected)
|
||||||
|
assert list(events["UTCEventTime"]) == sorted(events["UTCEventTime"])
|
||||||
|
|
||||||
|
|
||||||
def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue(
|
def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue(
|
||||||
@@ -599,7 +604,23 @@ def test_the_trade_flow_draws_from_its_own_named_branch(
|
|||||||
|
|
||||||
monkeypatch.setattr(commerce, "day_stream", spy)
|
monkeypatch.setattr(commerce, "day_stream", spy)
|
||||||
day.stream(CANONICAL_SEED, WEEKDAY)
|
day.stream(CANONICAL_SEED, WEEKDAY)
|
||||||
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.COMMERCE)]
|
assert asked == [
|
||||||
|
(CANONICAL_SEED, WEEKDAY, Component.COMMERCE),
|
||||||
|
(CANONICAL_SEED, WEEKDAY, Component.BEACONS),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_assigned_purchase_is_never_lost(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""Гарантия плана сильнее порчи: мост склейки не теряется."""
|
||||||
|
monkeypatch.setattr(world, "LOST_PURCHASE_PERCENT", 100)
|
||||||
|
monkeypatch.setattr(world, "DUPLICATE_PURCHASE_PERCENT", 0)
|
||||||
|
events = day.stream(CANONICAL_SEED, WEEKDAY)
|
||||||
|
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
|
||||||
|
|
||||||
|
promised = set(audience.client_id[audience.assigned_order].tolist())
|
||||||
|
bought = set(rows_of(events, commerce.PURCHASE)["ClientID"].tolist())
|
||||||
|
assert promised
|
||||||
|
assert bought == promised
|
||||||
|
|
||||||
|
|
||||||
def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
|
def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
|
||||||
@@ -627,6 +648,78 @@ def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
|
|||||||
assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs
|
assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_duplicate_repeats_the_confirmation_story(weekday: day.Day):
|
||||||
|
"""Обновление повторяет просмотр и purchase в том же визите."""
|
||||||
|
purchases = rows_of(weekday, commerce.PURCHASE)
|
||||||
|
ids_by_order: dict[str, list[int]] = {}
|
||||||
|
for row, cell in enumerate(purchases["purchaseID"]):
|
||||||
|
ids_by_order.setdefault(str(cell[0]), []).append(row)
|
||||||
|
duplicated = [rows for rows in ids_by_order.values() if len(rows) == 2]
|
||||||
|
|
||||||
|
expected = int((weekday.purchase_outcome == commerce.EventOutcome.DUPLICATED).sum())
|
||||||
|
assert len(duplicated) == expected > 0
|
||||||
|
for pair in duplicated:
|
||||||
|
first, second = sorted(pair, key=lambda row: purchases["UTCEventTime"][row])
|
||||||
|
delay = (
|
||||||
|
(purchases["UTCEventTime"][second] - purchases["UTCEventTime"][first])
|
||||||
|
.astype("timedelta64[s]")
|
||||||
|
.astype(int)
|
||||||
|
)
|
||||||
|
assert world.DUPLICATE_DELAY_SECONDS[0] <= delay
|
||||||
|
assert delay < world.DUPLICATE_DELAY_SECONDS[1]
|
||||||
|
assert delay < world.VISIT_TIMEOUT_SECONDS
|
||||||
|
assert purchases["VisitID"][first] == purchases["VisitID"][second]
|
||||||
|
assert purchases["WatchID"][first] != purchases["WatchID"][second]
|
||||||
|
for name in (
|
||||||
|
"purchaseID",
|
||||||
|
"purchaseRevenue",
|
||||||
|
"productID",
|
||||||
|
"productQuantity",
|
||||||
|
"ecommerce",
|
||||||
|
):
|
||||||
|
left, right = purchases[name][first], purchases[name][second]
|
||||||
|
if isinstance(left, np.ndarray):
|
||||||
|
assert np.array_equal(left, right), name
|
||||||
|
else:
|
||||||
|
assert left == right, name
|
||||||
|
|
||||||
|
visit = purchases["VisitID"][first]
|
||||||
|
confirmation = (
|
||||||
|
(weekday.columns["EventType"] == "pageview")
|
||||||
|
& (weekday.page == Page.CONFIRMATION)
|
||||||
|
& (weekday.columns["VisitID"] == visit)
|
||||||
|
)
|
||||||
|
views = np.flatnonzero(confirmation)
|
||||||
|
assert views.size == 2
|
||||||
|
assert len(set(weekday.columns["WatchID"][views].tolist())) == 2
|
||||||
|
view_delay = (
|
||||||
|
(
|
||||||
|
weekday.columns["UTCEventTime"][views].max()
|
||||||
|
- weekday.columns["UTCEventTime"][views].min()
|
||||||
|
)
|
||||||
|
.astype("timedelta64[s]")
|
||||||
|
.astype(int)
|
||||||
|
)
|
||||||
|
assert view_delay == delay
|
||||||
|
|
||||||
|
cookie = purchases["ClientID"][first]
|
||||||
|
later_visit = (
|
||||||
|
(weekday.columns["ClientID"] == cookie)
|
||||||
|
& (weekday.columns["VisitID"] != visit)
|
||||||
|
& (weekday.columns["UTCEventTime"] > purchases["UTCEventTime"][second])
|
||||||
|
)
|
||||||
|
if np.any(later_visit):
|
||||||
|
gap = (
|
||||||
|
(
|
||||||
|
weekday.columns["UTCEventTime"][later_visit].min()
|
||||||
|
- purchases["UTCEventTime"][second]
|
||||||
|
)
|
||||||
|
.astype("timedelta64[s]")
|
||||||
|
.astype(int)
|
||||||
|
)
|
||||||
|
assert gap > world.VISIT_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
|
||||||
def paired_cookies() -> set[int]:
|
def paired_cookies() -> set[int]:
|
||||||
"""Куки двухкуковых пар: их заказы обещаны планом, а не решены днём."""
|
"""Куки двухкуковых пар: их заказы обещаны планом, а не решены днём."""
|
||||||
cookies: set[int] = set()
|
cookies: set[int] = set()
|
||||||
@@ -642,21 +735,26 @@ def test_flagged_buyers_buy_again_much_more_often(week: list[day.Day]):
|
|||||||
«Постоянный покупатель» читается только так — как кука, которая ходит
|
«Постоянный покупатель» читается только так — как кука, которая ходит
|
||||||
неделями и покупает не раз: метки в событии нет и не будет.
|
неделями и покупает не раз: метки в событии нет и не будет.
|
||||||
|
|
||||||
Куки двухкуковых пар из счёта исключены: их заказы назначил план, и
|
Куки двухкуковых пар из счета исключены: их заказы назначил план, и
|
||||||
вместе с ними лифт мерил бы гарантию, а не два новых рычага. Остаются
|
вместе с ними лифт мерил бы гарантию, а не два новых рычага. Даже после
|
||||||
те помеченные, чьи покупки решил день: повторно покупают 4,6% из них
|
событийной порчи оставшиеся помеченные покупают повторно более чем вдвое
|
||||||
против 1,5% прочих — втрое чаще при пороге «вдвое».
|
чаще прочих.
|
||||||
"""
|
"""
|
||||||
purchases: dict[int, int] = {}
|
purchases: dict[int, set[str]] = {}
|
||||||
flagged: set[int] = set()
|
flagged: set[int] = set()
|
||||||
for number, events in enumerate(week):
|
for number, events in enumerate(week):
|
||||||
audience = plan.audience(CANONICAL_SEED, number)
|
audience = plan.audience(CANONICAL_SEED, number)
|
||||||
flagged |= set(audience.client_id[audience.buyer].tolist())
|
flagged |= set(audience.client_id[audience.buyer].tolist())
|
||||||
for cookie in rows_of(events, commerce.PURCHASE)["ClientID"].tolist():
|
rows = rows_of(events, commerce.PURCHASE)
|
||||||
purchases[cookie] = purchases.get(cookie, 0) + 1
|
for cookie, order_id in zip(
|
||||||
|
rows["ClientID"].tolist(),
|
||||||
|
(str(cell[0]) for cell in rows["purchaseID"]),
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
purchases.setdefault(cookie, set()).add(order_id)
|
||||||
|
|
||||||
def repeat_share(cookies: set[int]) -> float:
|
def repeat_share(cookies: set[int]) -> float:
|
||||||
again = sum(1 for cookie in cookies if purchases[cookie] > 1)
|
again = sum(1 for cookie in cookies if len(purchases[cookie]) > 1)
|
||||||
return again / len(cookies)
|
return again / len(cookies)
|
||||||
|
|
||||||
buyers = set(purchases)
|
buyers = set(purchases)
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ def orders(events: day.Day) -> list[list[Any]]:
|
|||||||
int(theirs.outcome[number]),
|
int(theirs.outcome[number]),
|
||||||
int(theirs.paid_after[number]),
|
int(theirs.paid_after[number]),
|
||||||
int(theirs.cancelled_after[number]),
|
int(theirs.cancelled_after[number]),
|
||||||
|
int(theirs.snapshot_delay[number]),
|
||||||
]
|
]
|
||||||
for number in range(len(theirs))
|
for number in range(len(theirs))
|
||||||
]
|
]
|
||||||
@@ -88,6 +89,7 @@ def snapshot(events: day.Day) -> dict[str, Any]:
|
|||||||
"page": events.page.tolist(),
|
"page": events.page.tolist(),
|
||||||
"product": events.product.tolist(),
|
"product": events.product.tolist(),
|
||||||
"orders": orders(events),
|
"orders": orders(events),
|
||||||
|
"purchase_outcome": events.purchase_outcome.tolist(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -115,6 +117,7 @@ def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
|
|||||||
page=weekday.page,
|
page=weekday.page,
|
||||||
product=weekday.product,
|
product=weekday.product,
|
||||||
orders=weekday.orders,
|
orders=weekday.orders,
|
||||||
|
purchase_outcome=weekday.purchase_outcome,
|
||||||
)
|
)
|
||||||
assert snapshot(reordered) != original
|
assert snapshot(reordered) != original
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory
|
|||||||
# отправляемых; этот дешевле прочих — его окно короче.
|
# отправляемых; этот дешевле прочих — его окно короче.
|
||||||
RUN_DAY = 2
|
RUN_DAY = 2
|
||||||
|
|
||||||
|
# День любой: хеш дня не зависит ни от соседей, ни от края описи.
|
||||||
|
REPLAY_DAY = 4
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def inventory() -> dict:
|
def inventory() -> dict:
|
||||||
@@ -48,7 +51,15 @@ def test_order_class_counters_cover_only_closed_days(inventory: dict):
|
|||||||
rows = [row for row in inventory["days"] if "orders" in row]
|
rows = [row for row in inventory["days"] if "orders" in row]
|
||||||
|
|
||||||
assert [row["day"] for row in rows] == [0]
|
assert [row["day"] for row in rows] == [0]
|
||||||
assert set(rows[0]["orders"]) == {"match", "cancelled", "amount_delta"}
|
assert set(rows[0]["orders"]) == {
|
||||||
|
"match",
|
||||||
|
"cancelled",
|
||||||
|
"lost_event",
|
||||||
|
"duplicate_event",
|
||||||
|
"amount_delta",
|
||||||
|
}
|
||||||
|
assert rows[0]["orders"]["lost_event"] > 0
|
||||||
|
assert rows[0]["orders"]["duplicate_event"] > 0
|
||||||
today = day_module.stream(CANONICAL_SEED, rows[0]["day"])
|
today = day_module.stream(CANONICAL_SEED, rows[0]["day"])
|
||||||
assert sum(rows[0]["orders"].values()) == len(today.orders)
|
assert sum(rows[0]["orders"].values()) == len(today.orders)
|
||||||
|
|
||||||
@@ -72,3 +83,17 @@ def test_the_inventory_holds_the_hash_of_every_sent_snapshot(inventory: dict, tm
|
|||||||
|
|
||||||
row = next(row for row in inventory["snapshots"] if row["day"] == RUN_DAY - 1)
|
row = next(row for row in inventory["snapshots"] if row["day"] == RUN_DAY - 1)
|
||||||
assert row["sha256"] == sent
|
assert row["sha256"] == sent
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_replayed_day_matches_its_inventory_hash(tmp_path):
|
||||||
|
"""Опись сверяется с байтами, вышедшими из CLI, а не с собранной тут же:
|
||||||
|
пересчет сравнивался бы сам с собой.
|
||||||
|
"""
|
||||||
|
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
|
||||||
|
path = tmp_path / "day.jsonl"
|
||||||
|
|
||||||
|
assert cli.main(["batch", "--day", str(REPLAY_DAY), "--file", str(path)]) == 0
|
||||||
|
|
||||||
|
sent = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
row = next(row for row in stored["days"] if row["day"] == REPLAY_DAY)
|
||||||
|
assert row["sha256"] == sent
|
||||||
@@ -58,17 +58,52 @@ def user_id_by_order(events: day.Day) -> dict[str, int]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day):
|
def purchase_row_by_order(
|
||||||
"""Заказ — вторая проекция покупки, а не второе порождение.
|
events: day.Day,
|
||||||
|
) -> tuple[dict[str, NDArray[Any]], dict[str, int]]:
|
||||||
|
"""Первая строка purchase каждого доехавшего заказа."""
|
||||||
|
rows = purchases_of(events)
|
||||||
|
index: dict[str, int] = {}
|
||||||
|
for row, cell in enumerate(rows["purchaseID"]):
|
||||||
|
index.setdefault(str(cell[0]), row)
|
||||||
|
return rows, index
|
||||||
|
|
||||||
У всякого заказа ровно одно событие `purchase`, и номер у них один:
|
|
||||||
сойтись двум источникам больше негде — сверка стенда соединяется
|
def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day):
|
||||||
именно по нему.
|
"""Заказ рождается до потери или дубля его событийной проекции."""
|
||||||
"""
|
|
||||||
events = purchases_of(weekday)
|
events = purchases_of(weekday)
|
||||||
numbers = [cell[0] for cell in events["purchaseID"]]
|
numbers = [cell[0] for cell in events["purchaseID"]]
|
||||||
assert len(numbers) > 100
|
assert len(numbers) > 100
|
||||||
assert list(weekday.orders.order_id) == numbers
|
lost = {
|
||||||
|
order_id
|
||||||
|
for order_id, outcome in zip(
|
||||||
|
weekday.orders.order_id, weekday.purchase_outcome, strict=True
|
||||||
|
)
|
||||||
|
if outcome == commerce.EventOutcome.LOST
|
||||||
|
}
|
||||||
|
assert set(numbers) == set(weekday.orders.order_id) - lost
|
||||||
|
assert len(numbers) - len(set(numbers)) == int(
|
||||||
|
(weekday.purchase_outcome == commerce.EventOutcome.DUPLICATED).sum()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_assigned_order_never_waits_for_a_later_snapshot(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
):
|
||||||
|
"""Гарантия плана сильнее опоздания: мост склейки приезжает сразу."""
|
||||||
|
# Все неназначенные заказы опаздывают на два дня. Нулевую задержку после
|
||||||
|
# такого броска могут получить только заказы, защищённые планом.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
orders, "_SNAPSHOT_DELAY_CUMULATIVE", np.array([0, 0, 1], dtype=np.int64)
|
||||||
|
)
|
||||||
|
events = day.stream(CANONICAL_SEED, WEEKDAY)
|
||||||
|
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
|
||||||
|
|
||||||
|
promised = set(audience.person_id[audience.assigned_order].tolist())
|
||||||
|
immediate = set(events.orders.user_id[events.orders.snapshot_delay == 0].tolist())
|
||||||
|
assert promised
|
||||||
|
assert immediate == promised
|
||||||
|
assert (events.orders.snapshot_delay == 2).any()
|
||||||
|
|
||||||
|
|
||||||
def dropped_line(client: list[tuple[Any, int]], order: list[tuple[Any, int]]):
|
def dropped_line(client: list[tuple[Any, int]], order: list[tuple[Any, int]]):
|
||||||
@@ -88,16 +123,18 @@ def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day)
|
|||||||
"""
|
"""
|
||||||
goods = catalog.catalog()
|
goods = catalog.catalog()
|
||||||
price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True))
|
price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True))
|
||||||
events = purchases_of(weekday)
|
events, row_by_order = purchase_row_by_order(weekday)
|
||||||
money = weekday.orders
|
money = weekday.orders
|
||||||
deltas = 0
|
deltas = 0
|
||||||
|
|
||||||
for number, order in enumerate(weekday.orders.order_id):
|
for number, order in enumerate(weekday.orders.order_id):
|
||||||
assert order == events["purchaseID"][number][0]
|
if weekday.purchase_outcome[number] == commerce.EventOutcome.LOST:
|
||||||
|
continue
|
||||||
|
row = row_by_order[order]
|
||||||
client = list(
|
client = list(
|
||||||
zip(
|
zip(
|
||||||
events["productID"][number].tolist(),
|
events["productID"][row].tolist(),
|
||||||
events["productQuantity"][number].tolist(),
|
events["productQuantity"][row].tolist(),
|
||||||
strict=True,
|
strict=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -110,7 +147,7 @@ def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day)
|
|||||||
)
|
)
|
||||||
# Выручка клиента и `items_total` бэкенда — одно число: в событии
|
# Выручка клиента и `items_total` бэкенда — одно число: в событии
|
||||||
# оно дробное, у заказа целое в копейках.
|
# оно дробное, у заказа целое в копейках.
|
||||||
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS)
|
revenue = round(events["purchaseRevenue"][row][0] * commerce.KOPECKS)
|
||||||
if mine == client:
|
if mine == client:
|
||||||
assert money.items_total[number] == revenue
|
assert money.items_total[number] == revenue
|
||||||
continue
|
continue
|
||||||
@@ -136,15 +173,18 @@ def test_the_discount_comes_from_the_coupon_of_the_event(
|
|||||||
|
|
||||||
for today in start_world:
|
for today in start_world:
|
||||||
money = today.orders
|
money = today.orders
|
||||||
events = purchases_of(today)
|
events, row_by_order = purchase_row_by_order(today)
|
||||||
codes = [cell[0] for cell in events["purchaseCoupon"]]
|
for number, order_id in enumerate(money.order_id):
|
||||||
for number, code in enumerate(codes):
|
if today.purchase_outcome[number] == commerce.EventOutcome.LOST:
|
||||||
|
continue
|
||||||
|
row = row_by_order[order_id]
|
||||||
|
code = events["purchaseCoupon"][row][0]
|
||||||
items_total = money.items_total[number]
|
items_total = money.items_total[number]
|
||||||
expected = items_total * percent[code] // 100 if code else 0
|
expected = items_total * percent[code] // 100 if code else 0
|
||||||
assert money.discount[number] == expected, money.order_id[number]
|
assert money.discount[number] == expected, money.order_id[number]
|
||||||
assert money.discount[number] < items_total
|
assert money.discount[number] < items_total
|
||||||
|
|
||||||
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS)
|
revenue = round(events["purchaseRevenue"][row][0] * commerce.KOPECKS)
|
||||||
discounted_delta += int(bool(code) and items_total != revenue)
|
discounted_delta += int(bool(code) and items_total != revenue)
|
||||||
|
|
||||||
# Иначе проверка не отличила бы новую базу скидки от прежней.
|
# Иначе проверка не отличила бы новую базу скидки от прежней.
|
||||||
@@ -255,8 +295,13 @@ def test_the_user_id_is_the_person_behind_the_cookie(weekday: day.Day):
|
|||||||
zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True)
|
zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True)
|
||||||
)
|
)
|
||||||
events = purchases_of(weekday)
|
events = purchases_of(weekday)
|
||||||
for number, cookie in enumerate(events["ClientID"].tolist()):
|
by_order = user_id_by_order(weekday)
|
||||||
assert weekday.orders.user_id[number] == person[cookie]
|
for order_id, cookie in zip(
|
||||||
|
(cell[0] for cell in events["purchaseID"]),
|
||||||
|
events["ClientID"].tolist(),
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
assert by_order[order_id] == person[cookie]
|
||||||
assert np.all(weekday.orders.user_id > 0)
|
assert np.all(weekday.orders.user_id > 0)
|
||||||
assert weekday.orders.user_id.max() < 2**53
|
assert weekday.orders.user_id.max() < 2**53
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import pytest
|
|||||||
from clickstream_generator import plan, reference, world
|
from clickstream_generator import plan, reference, world
|
||||||
from clickstream_generator.seeds import CANONICAL_SEED
|
from clickstream_generator.seeds import CANONICAL_SEED
|
||||||
|
|
||||||
# Горизонт эталонного снимка — две недели (спека, раздел 5).
|
# Окно замера плана — две недели: два цикла недельной сезонности.
|
||||||
SNAPSHOT_DAYS = 14
|
WINDOW_DAYS = 14
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -54,9 +54,9 @@ def test_the_plan_is_a_pure_function_of_the_seed():
|
|||||||
|
|
||||||
|
|
||||||
def test_counters_are_a_pure_function_of_the_seed():
|
def test_counters_are_a_pure_function_of_the_seed():
|
||||||
first = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
first = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||||
plan.cohort.cache_clear()
|
plan.cohort.cache_clear()
|
||||||
assert first == plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
assert first == plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||||
|
|
||||||
|
|
||||||
def test_a_remembered_cohort_cannot_be_spoiled_from_outside():
|
def test_a_remembered_cohort_cannot_be_spoiled_from_outside():
|
||||||
@@ -216,12 +216,12 @@ def test_the_passport_points_into_the_directories():
|
|||||||
|
|
||||||
def test_the_daily_audience_matches_the_spec_band():
|
def test_the_daily_audience_matches_the_spec_band():
|
||||||
"""6–8 тыс. посетителей в день — правдоподобный средний магазин."""
|
"""6–8 тыс. посетителей в день — правдоподобный средний магазин."""
|
||||||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||||
assert all(6_000 <= size <= 8_000 for size in counters.audience)
|
assert all(6_000 <= size <= 8_000 for size in counters.audience)
|
||||||
|
|
||||||
|
|
||||||
def test_the_influx_holds_its_daily_number():
|
def test_the_influx_holds_its_daily_number():
|
||||||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||||
average = sum(counters.new_cookies) / len(counters.new_cookies)
|
average = sum(counters.new_cookies) / len(counters.new_cookies)
|
||||||
assert abs(average - world.DAILY_INFLUX) < world.DAILY_INFLUX // 10
|
assert abs(average - world.DAILY_INFLUX) < world.DAILY_INFLUX // 10
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ def test_the_influx_breathes_with_the_week():
|
|||||||
def mean(numbers: tuple[int, ...] | list[int]) -> float:
|
def mean(numbers: tuple[int, ...] | list[int]) -> float:
|
||||||
return sum(numbers) / len(numbers)
|
return sum(numbers) / len(numbers)
|
||||||
|
|
||||||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||||
weekdays = [size for day, size in enumerate(counters.new_cookies) if day % 7 < 5]
|
weekdays = [size for day, size in enumerate(counters.new_cookies) if day % 7 < 5]
|
||||||
weekend = [size for day, size in enumerate(counters.new_cookies) if day % 7 >= 5]
|
weekend = [size for day, size in enumerate(counters.new_cookies) if day % 7 >= 5]
|
||||||
expected = mean(world.WEEKLY_PROFILE_PERCENT[5:]) / mean(
|
expected = mean(world.WEEKLY_PROFILE_PERCENT[5:]) / mean(
|
||||||
@@ -250,7 +250,7 @@ def test_the_influx_differs_even_on_the_same_weekday():
|
|||||||
def test_the_influx_is_visible_on_the_plan():
|
def test_the_influx_is_visible_on_the_plan():
|
||||||
"""Накопленная аудитория растёт с горизонтом и с дневной не сходится."""
|
"""Накопленная аудитория растёт с горизонтом и с дневной не сходится."""
|
||||||
week = plan.counters(CANONICAL_SEED, 7)
|
week = plan.counters(CANONICAL_SEED, 7)
|
||||||
fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
fortnight = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||||
assert week.visitors < fortnight.visitors
|
assert week.visitors < fortnight.visitors
|
||||||
assert fortnight.visitors > 2 * max(fortnight.audience)
|
assert fortnight.visitors > 2 * max(fortnight.audience)
|
||||||
|
|
||||||
@@ -258,21 +258,21 @@ def test_the_influx_is_visible_on_the_plan():
|
|||||||
def test_the_horizon_is_a_prefix_not_another_world():
|
def test_the_horizon_is_a_prefix_not_another_world():
|
||||||
"""Продление истории днём N+1 не трогает дни 1…N."""
|
"""Продление истории днём N+1 не трогает дни 1…N."""
|
||||||
week = plan.counters(CANONICAL_SEED, 7)
|
week = plan.counters(CANONICAL_SEED, 7)
|
||||||
fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
fortnight = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||||
assert fortnight.audience[:7] == week.audience
|
assert fortnight.audience[:7] == week.audience
|
||||||
assert fortnight.new_cookies[:7] == week.new_cookies
|
assert fortnight.new_cookies[:7] == week.new_cookies
|
||||||
|
|
||||||
|
|
||||||
def test_counters_count_the_same_audience_that_the_day_gets():
|
def test_counters_count_the_same_audience_that_the_day_gets():
|
||||||
"""Счётчики и дня-функция спрашивают один план — расходиться им негде."""
|
"""Счётчики и дня-функция спрашивают один план — расходиться им негде."""
|
||||||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||||
for day in (0, 7, SNAPSHOT_DAYS - 1):
|
for day in (0, 7, WINDOW_DAYS - 1):
|
||||||
actual = plan.audience(CANONICAL_SEED, day).client_id.size
|
actual = plan.audience(CANONICAL_SEED, day).client_id.size
|
||||||
assert actual == counters.audience[day]
|
assert actual == counters.audience[day]
|
||||||
|
|
||||||
|
|
||||||
def test_counters_know_the_pairs_before_a_single_event():
|
def test_counters_know_the_pairs_before_a_single_event():
|
||||||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||||
assert counters.pairs > 0
|
assert counters.pairs > 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def test_day_components_do_not_share_randomness():
|
|||||||
Component.TRAFFIC,
|
Component.TRAFFIC,
|
||||||
Component.COMMERCE,
|
Component.COMMERCE,
|
||||||
Component.ORDERS,
|
Component.ORDERS,
|
||||||
Component.LATECOMERS,
|
Component.BEACONS,
|
||||||
)
|
)
|
||||||
assert len({int(component) for component in components}) == 4
|
assert len({int(component) for component in components}) == 4
|
||||||
draws = {
|
draws = {
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ def test_the_record_matches_the_wire_contract(days: list[day_module.Day]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if _born(record) == _date(SHORT_WINDOW_DAY):
|
if _born(record) == _date(SHORT_WINDOW_DAY):
|
||||||
assert record["created_at"][:19] + "Z" == bought[record["order_id"]]
|
# Потерянного purchase в трекере нет, но заказ остается.
|
||||||
|
if record["order_id"] in bought:
|
||||||
|
assert record["created_at"][:19] + "Z" == bought[record["order_id"]]
|
||||||
|
|
||||||
|
|
||||||
def test_negative_money_does_not_leave_the_source(days: list[day_module.Day]):
|
def test_negative_money_does_not_leave_the_source(days: list[day_module.Day]):
|
||||||
@@ -174,6 +176,63 @@ def test_the_rows_go_in_the_order_of_birth(days: list[day_module.Day]):
|
|||||||
assert len(set(numbers)) == len(numbers)
|
assert len(set(numbers)) == len(numbers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lateness_changes_only_the_rows_present_in_a_snapshot(
|
||||||
|
days: list[day_module.Day],
|
||||||
|
):
|
||||||
|
"""Неопоздавшие строки остаются прежними байтами и в прежнем порядке."""
|
||||||
|
number = SHORT_WINDOW_DAY
|
||||||
|
window = [today.orders for today in days[: number + 1]]
|
||||||
|
eager = serialize.orders(
|
||||||
|
[
|
||||||
|
replace(rows, snapshot_delay=np.zeros_like(rows.snapshot_delay))
|
||||||
|
for rows in window
|
||||||
|
],
|
||||||
|
number,
|
||||||
|
)
|
||||||
|
actual = serialize.orders(window, number)
|
||||||
|
actual_ids = {json.loads(payload)["order_id"] for payload in actual}
|
||||||
|
expected = [
|
||||||
|
payload for payload in eager if json.loads(payload)["order_id"] in actual_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len(actual) < len(eager)
|
||||||
|
assert actual == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_two_day_late_order_first_arrives_in_its_lived_state(
|
||||||
|
days: list[day_module.Day],
|
||||||
|
):
|
||||||
|
"""Заказ δ = 2 пропускает D и D+1 и догоняет жизнь, не меняя `created_at`."""
|
||||||
|
honest = days[0].orders
|
||||||
|
target = 0
|
||||||
|
delay = np.zeros_like(honest.snapshot_delay)
|
||||||
|
delay[target] = 2
|
||||||
|
cancelled_after = honest.cancelled_after.copy()
|
||||||
|
cancelled_after[target] = 30 * 3600
|
||||||
|
late = replace(
|
||||||
|
honest,
|
||||||
|
snapshot_delay=delay,
|
||||||
|
cancelled_after=cancelled_after,
|
||||||
|
)
|
||||||
|
order_id = late.order_id[target]
|
||||||
|
|
||||||
|
for number in (0, 1):
|
||||||
|
assert order_id not in {
|
||||||
|
json.loads(payload)["order_id"]
|
||||||
|
for payload in serialize.orders([late], number)
|
||||||
|
}
|
||||||
|
|
||||||
|
arrived = {
|
||||||
|
record["order_id"]: record
|
||||||
|
for record in map(json.loads, serialize.orders([late], 2))
|
||||||
|
}[order_id]
|
||||||
|
created_at = np.datetime_as_string(
|
||||||
|
late.created_at[target], unit="ms", timezone="UTC"
|
||||||
|
)
|
||||||
|
assert arrived["status"] == "cancelled"
|
||||||
|
assert arrived["created_at"] == created_at
|
||||||
|
|
||||||
|
|
||||||
def test_the_state_is_read_at_the_boundary_of_the_day(days: list[day_module.Day]):
|
def test_the_state_is_read_at_the_boundary_of_the_day(days: list[day_module.Day]):
|
||||||
"""Дыхание окна: оплаченный назавтра заказ в сегодняшнем слепке `created`.
|
"""Дыхание окна: оплаченный назавтра заказ в сегодняшнем слепке `created`.
|
||||||
|
|
||||||
@@ -325,7 +384,10 @@ def _purchase_times(today: day_module.Day) -> dict[str, str]:
|
|||||||
times = np.datetime_as_string(
|
times = np.datetime_as_string(
|
||||||
today.columns["UTCEventTime"][here], unit="s", timezone="UTC"
|
today.columns["UTCEventTime"][here], unit="s", timezone="UTC"
|
||||||
)
|
)
|
||||||
return dict(zip(numbers, times.tolist(), strict=True))
|
result: dict[str, str] = {}
|
||||||
|
for number, moment in zip(numbers, times.tolist(), strict=True):
|
||||||
|
result.setdefault(number, moment)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _born(record: dict) -> date:
|
def _born(record: dict) -> date:
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ def test_the_trade_event_never_overtakes_the_next_page():
|
|||||||
assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS)
|
assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_duplicate_delay_stays_inside_a_visit_timeout():
|
||||||
|
assert 0 < min(world.DUPLICATE_DELAY_SECONDS)
|
||||||
|
assert max(world.DUPLICATE_DELAY_SECONDS) <= world.VISIT_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
|
||||||
def conversion(cart: int, checkout_of_cart: int) -> float:
|
def conversion(cart: int, checkout_of_cart: int) -> float:
|
||||||
"""Конверсия визита в заказ, проценты: произведение трёх шагов воронки."""
|
"""Конверсия визита в заказ, проценты: произведение трёх шагов воронки."""
|
||||||
return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2
|
return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
slice_name: Сверка purchase и заказов
|
||||||
|
description: null
|
||||||
|
certified_by: null
|
||||||
|
certification_details: null
|
||||||
|
viz_type: pivot_table_v2
|
||||||
|
params:
|
||||||
|
viz_type: pivot_table_v2
|
||||||
|
groupbyRows:
|
||||||
|
- order_day
|
||||||
|
groupbyColumns:
|
||||||
|
- mismatch_class
|
||||||
|
metrics:
|
||||||
|
- order_count
|
||||||
|
adhoc_filters:
|
||||||
|
- expressionType: SIMPLE
|
||||||
|
subject: order_day
|
||||||
|
operator: TEMPORAL_RANGE
|
||||||
|
comparator: No filter
|
||||||
|
clause: WHERE
|
||||||
|
aggregateFunction: Sum
|
||||||
|
metricsLayout: COLUMNS
|
||||||
|
rowOrder: key_a_to_z
|
||||||
|
colOrder: key_a_to_z
|
||||||
|
order_desc: true
|
||||||
|
row_limit: 10000
|
||||||
|
valueFormat: SMART_NUMBER
|
||||||
|
date_format: smart_date
|
||||||
|
cache_timeout: null
|
||||||
|
uuid: 19600000-0000-4000-8000-000000000102
|
||||||
|
version: 1.0.0
|
||||||
|
dataset_uuid: 19600000-0000-4000-8000-000000000002
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
slice_name: Выручка по дням и категориям
|
||||||
|
description: null
|
||||||
|
certified_by: null
|
||||||
|
certification_details: null
|
||||||
|
viz_type: echarts_timeseries_bar
|
||||||
|
params:
|
||||||
|
viz_type: echarts_timeseries_bar
|
||||||
|
x_axis: report_date
|
||||||
|
metrics:
|
||||||
|
- revenue
|
||||||
|
groupby:
|
||||||
|
- product_category
|
||||||
|
adhoc_filters:
|
||||||
|
- expressionType: SIMPLE
|
||||||
|
subject: report_date
|
||||||
|
operator: TEMPORAL_RANGE
|
||||||
|
comparator: No filter
|
||||||
|
clause: WHERE
|
||||||
|
row_limit: 10000
|
||||||
|
stack: Stack
|
||||||
|
orientation: vertical
|
||||||
|
show_legend: true
|
||||||
|
rich_tooltip: true
|
||||||
|
show_value: false
|
||||||
|
y_axis_format: ',.2f'
|
||||||
|
x_axis_time_format: smart_date
|
||||||
|
cache_timeout: null
|
||||||
|
uuid: 19600000-0000-4000-8000-000000000101
|
||||||
|
version: 1.0.0
|
||||||
|
dataset_uuid: 19600000-0000-4000-8000-000000000001
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
slice_name: Посетители и известные пользователи
|
||||||
|
description: null
|
||||||
|
certified_by: null
|
||||||
|
certification_details: null
|
||||||
|
viz_type: echarts_timeseries_line
|
||||||
|
params:
|
||||||
|
viz_type: echarts_timeseries_line
|
||||||
|
x_axis: report_date
|
||||||
|
metrics:
|
||||||
|
- visitors
|
||||||
|
- known_users
|
||||||
|
groupby: []
|
||||||
|
adhoc_filters:
|
||||||
|
- expressionType: SIMPLE
|
||||||
|
subject: report_date
|
||||||
|
operator: TEMPORAL_RANGE
|
||||||
|
comparator: No filter
|
||||||
|
clause: WHERE
|
||||||
|
row_limit: 10000
|
||||||
|
seriesType: echarts_timeseries_line
|
||||||
|
show_legend: true
|
||||||
|
rich_tooltip: true
|
||||||
|
show_value: false
|
||||||
|
y_axis_format: ',d'
|
||||||
|
x_axis_time_format: smart_date
|
||||||
|
cache_timeout: null
|
||||||
|
uuid: 19600000-0000-4000-8000-000000000103
|
||||||
|
version: 1.0.0
|
||||||
|
dataset_uuid: 19600000-0000-4000-8000-000000000003
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
dashboard_title: Бизнес-сюжеты
|
||||||
|
description: null
|
||||||
|
css: null
|
||||||
|
slug: business-stories
|
||||||
|
certified_by: null
|
||||||
|
certification_details: null
|
||||||
|
published: true
|
||||||
|
uuid: 19600000-0000-4000-8000-000000000201
|
||||||
|
position:
|
||||||
|
DASHBOARD_VERSION_KEY: v2
|
||||||
|
ROOT_ID:
|
||||||
|
id: ROOT_ID
|
||||||
|
type: ROOT
|
||||||
|
children:
|
||||||
|
- GRID_ID
|
||||||
|
GRID_ID:
|
||||||
|
id: GRID_ID
|
||||||
|
type: GRID
|
||||||
|
parents:
|
||||||
|
- ROOT_ID
|
||||||
|
children:
|
||||||
|
- ROW-revenue
|
||||||
|
- ROW-pvo
|
||||||
|
- ROW-traffic
|
||||||
|
ROW-revenue:
|
||||||
|
id: ROW-revenue
|
||||||
|
type: ROW
|
||||||
|
parents:
|
||||||
|
- ROOT_ID
|
||||||
|
- GRID_ID
|
||||||
|
children:
|
||||||
|
- CHART-revenue
|
||||||
|
meta:
|
||||||
|
background: BACKGROUND_TRANSPARENT
|
||||||
|
CHART-revenue:
|
||||||
|
id: CHART-revenue
|
||||||
|
type: CHART
|
||||||
|
parents:
|
||||||
|
- ROOT_ID
|
||||||
|
- GRID_ID
|
||||||
|
- ROW-revenue
|
||||||
|
children: []
|
||||||
|
meta:
|
||||||
|
chartId: 1
|
||||||
|
height: 50
|
||||||
|
width: 12
|
||||||
|
sliceName: Выручка по дням и категориям
|
||||||
|
uuid: 19600000-0000-4000-8000-000000000101
|
||||||
|
ROW-pvo:
|
||||||
|
id: ROW-pvo
|
||||||
|
type: ROW
|
||||||
|
parents:
|
||||||
|
- ROOT_ID
|
||||||
|
- GRID_ID
|
||||||
|
children:
|
||||||
|
- CHART-pvo
|
||||||
|
meta:
|
||||||
|
background: BACKGROUND_TRANSPARENT
|
||||||
|
CHART-pvo:
|
||||||
|
id: CHART-pvo
|
||||||
|
type: CHART
|
||||||
|
parents:
|
||||||
|
- ROOT_ID
|
||||||
|
- GRID_ID
|
||||||
|
- ROW-pvo
|
||||||
|
children: []
|
||||||
|
meta:
|
||||||
|
chartId: 2
|
||||||
|
height: 50
|
||||||
|
width: 12
|
||||||
|
sliceName: Сверка purchase и заказов
|
||||||
|
uuid: 19600000-0000-4000-8000-000000000102
|
||||||
|
ROW-traffic:
|
||||||
|
id: ROW-traffic
|
||||||
|
type: ROW
|
||||||
|
parents:
|
||||||
|
- ROOT_ID
|
||||||
|
- GRID_ID
|
||||||
|
children:
|
||||||
|
- CHART-traffic
|
||||||
|
meta:
|
||||||
|
background: BACKGROUND_TRANSPARENT
|
||||||
|
CHART-traffic:
|
||||||
|
id: CHART-traffic
|
||||||
|
type: CHART
|
||||||
|
parents:
|
||||||
|
- ROOT_ID
|
||||||
|
- GRID_ID
|
||||||
|
- ROW-traffic
|
||||||
|
children: []
|
||||||
|
meta:
|
||||||
|
chartId: 3
|
||||||
|
height: 50
|
||||||
|
width: 12
|
||||||
|
sliceName: Посетители и известные пользователи
|
||||||
|
uuid: 19600000-0000-4000-8000-000000000103
|
||||||
|
metadata:
|
||||||
|
color_scheme: supersetColors
|
||||||
|
cross_filters_enabled: false
|
||||||
|
chart_configuration: {}
|
||||||
|
expanded_slices: {}
|
||||||
|
default_filters: '{}'
|
||||||
|
native_filter_configuration: []
|
||||||
|
theme_uuid: null
|
||||||
|
version: 1.0.0
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
table_name: daily_traffic_v
|
||||||
|
main_dttm_col: report_date
|
||||||
|
currency_code_column: null
|
||||||
|
description: null
|
||||||
|
default_endpoint: null
|
||||||
|
offset: 0
|
||||||
|
cache_timeout: null
|
||||||
|
catalog: null
|
||||||
|
schema: dm
|
||||||
|
sql: null
|
||||||
|
params: null
|
||||||
|
template_params: null
|
||||||
|
filter_select_enabled: true
|
||||||
|
fetch_values_predicate: null
|
||||||
|
extra: null
|
||||||
|
normalize_columns: false
|
||||||
|
always_filter_main_dttm: false
|
||||||
|
folders: null
|
||||||
|
uuid: 19600000-0000-4000-8000-000000000003
|
||||||
|
metrics:
|
||||||
|
- metric_name: known_users
|
||||||
|
verbose_name: Известные пользователи
|
||||||
|
metric_type: null
|
||||||
|
expression: SUM(known_users)
|
||||||
|
description: null
|
||||||
|
d3format: ',d'
|
||||||
|
currency: null
|
||||||
|
extra: null
|
||||||
|
warning_text: null
|
||||||
|
- metric_name: visitors
|
||||||
|
verbose_name: Посетители
|
||||||
|
metric_type: null
|
||||||
|
expression: SUM(visitors)
|
||||||
|
description: null
|
||||||
|
d3format: ',d'
|
||||||
|
currency: null
|
||||||
|
extra: null
|
||||||
|
warning_text: null
|
||||||
|
columns:
|
||||||
|
- column_name: _load_ts
|
||||||
|
verbose_name: Время загрузки
|
||||||
|
is_dttm: true
|
||||||
|
is_active: true
|
||||||
|
type: DATETIME64(3, 'UTC')
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: known_users
|
||||||
|
verbose_name: Известные пользователи
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: UINT64
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: _load_id
|
||||||
|
verbose_name: Запуск загрузки
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: STRING
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: visitors
|
||||||
|
verbose_name: Посетители
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: UINT64
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: report_date
|
||||||
|
verbose_name: Дата
|
||||||
|
is_dttm: true
|
||||||
|
is_active: true
|
||||||
|
type: DATE
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
version: 1.0.0
|
||||||
|
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
table_name: purchase_vs_orders_v
|
||||||
|
main_dttm_col: order_day
|
||||||
|
currency_code_column: null
|
||||||
|
description: null
|
||||||
|
default_endpoint: null
|
||||||
|
offset: 0
|
||||||
|
cache_timeout: null
|
||||||
|
catalog: null
|
||||||
|
schema: dm
|
||||||
|
sql: null
|
||||||
|
params: null
|
||||||
|
template_params: null
|
||||||
|
filter_select_enabled: true
|
||||||
|
fetch_values_predicate: null
|
||||||
|
extra: null
|
||||||
|
normalize_columns: false
|
||||||
|
always_filter_main_dttm: false
|
||||||
|
folders: null
|
||||||
|
uuid: 19600000-0000-4000-8000-000000000002
|
||||||
|
metrics:
|
||||||
|
- metric_name: order_count
|
||||||
|
verbose_name: Количество заказов
|
||||||
|
metric_type: null
|
||||||
|
expression: COUNT(order_id)
|
||||||
|
description: null
|
||||||
|
d3format: ',d'
|
||||||
|
currency: null
|
||||||
|
extra: null
|
||||||
|
warning_text: null
|
||||||
|
columns:
|
||||||
|
- column_name: items_total
|
||||||
|
verbose_name: Сумма позиций заказа
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: NULLABLE(DECIMAL(18, 2))
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: _load_ts
|
||||||
|
verbose_name: Время загрузки
|
||||||
|
is_dttm: true
|
||||||
|
is_active: true
|
||||||
|
type: DATETIME64(3, 'UTC')
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: declared_revenue
|
||||||
|
verbose_name: Выручка purchase
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: NULLABLE(DECIMAL(18, 2))
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: status
|
||||||
|
verbose_name: Статус заказа
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: LOWCARDINALITY(NULLABLE(STRING))
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: mismatch_class
|
||||||
|
verbose_name: Класс расхождения
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: LOWCARDINALITY(STRING)
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: order_day
|
||||||
|
verbose_name: День заказа
|
||||||
|
is_dttm: true
|
||||||
|
is_active: true
|
||||||
|
type: DATE
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: _load_id
|
||||||
|
verbose_name: Запуск загрузки
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: STRING
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: order_id
|
||||||
|
verbose_name: Заказ
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: STRING
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
version: 1.0.0
|
||||||
|
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
table_name: revenue_daily_v
|
||||||
|
main_dttm_col: report_date
|
||||||
|
currency_code_column: null
|
||||||
|
description: null
|
||||||
|
default_endpoint: null
|
||||||
|
offset: 0
|
||||||
|
cache_timeout: null
|
||||||
|
catalog: null
|
||||||
|
schema: dm
|
||||||
|
sql: null
|
||||||
|
params: null
|
||||||
|
template_params: null
|
||||||
|
filter_select_enabled: true
|
||||||
|
fetch_values_predicate: null
|
||||||
|
extra: null
|
||||||
|
normalize_columns: false
|
||||||
|
always_filter_main_dttm: false
|
||||||
|
folders: null
|
||||||
|
uuid: 19600000-0000-4000-8000-000000000001
|
||||||
|
metrics:
|
||||||
|
- metric_name: revenue
|
||||||
|
verbose_name: Выручка
|
||||||
|
metric_type: null
|
||||||
|
expression: SUM(revenue)
|
||||||
|
description: null
|
||||||
|
d3format: ',.2f'
|
||||||
|
currency:
|
||||||
|
symbol: ₽
|
||||||
|
symbolPosition: suffix
|
||||||
|
extra: null
|
||||||
|
warning_text: null
|
||||||
|
columns:
|
||||||
|
- column_name: _load_ts
|
||||||
|
verbose_name: Время загрузки
|
||||||
|
is_dttm: true
|
||||||
|
is_active: true
|
||||||
|
type: DATETIME64(3, 'UTC')
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: aov
|
||||||
|
verbose_name: Средний чек
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: DECIMAL(18, 2)
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: revenue
|
||||||
|
verbose_name: Выручка
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: DECIMAL(18, 2)
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: product_category
|
||||||
|
verbose_name: Категория товара
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: LOWCARDINALITY(STRING)
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: _load_id
|
||||||
|
verbose_name: Запуск загрузки
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: STRING
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: orders
|
||||||
|
verbose_name: Заказы
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: UINT64
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: units
|
||||||
|
verbose_name: Товары
|
||||||
|
is_dttm: false
|
||||||
|
is_active: true
|
||||||
|
type: UINT64
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
- column_name: report_date
|
||||||
|
verbose_name: Дата
|
||||||
|
is_dttm: true
|
||||||
|
is_active: true
|
||||||
|
type: DATE
|
||||||
|
advanced_data_type: null
|
||||||
|
groupby: true
|
||||||
|
filterable: true
|
||||||
|
expression: null
|
||||||
|
description: null
|
||||||
|
python_date_format: null
|
||||||
|
datetime_format: null
|
||||||
|
extra: null
|
||||||
|
version: 1.0.0
|
||||||
|
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
|
||||||
@@ -1,3 +1 @@
|
|||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
type: Database
|
|
||||||
timestamp: '2026-07-30T00:00:00+00:00'
|
|
||||||
@@ -2,13 +2,19 @@
|
|||||||
--
|
--
|
||||||
-- Эталонные счётчики читает Airflow из world-inventory.json: у настоящего
|
-- Эталонные счётчики читает Airflow из world-inventory.json: у настоящего
|
||||||
-- магазина такого оракула нет. Запрос возвращает только наблюдаемую сторону;
|
-- магазина такого оракула нет. Запрос возвращает только наблюдаемую сторону;
|
||||||
-- Python-задача сопоставляет ее с тремя счетчиками закрытого дня из описи.
|
-- Python-задача сопоставляет ее с пятью счетчиками закрытого дня из описи.
|
||||||
SELECT
|
SELECT
|
||||||
order_day AS data_date,
|
order_day AS data_date,
|
||||||
mismatch_class,
|
mismatch_class,
|
||||||
count() AS orders
|
count() AS orders
|
||||||
FROM dm.purchase_vs_orders_v
|
FROM dm.purchase_vs_orders_v
|
||||||
WHERE mismatch_class IN ('match', 'cancelled', 'amount_delta')
|
WHERE mismatch_class IN (
|
||||||
|
'match',
|
||||||
|
'cancelled',
|
||||||
|
'lost_event',
|
||||||
|
'duplicate_event',
|
||||||
|
'amount_delta'
|
||||||
|
)
|
||||||
GROUP BY
|
GROUP BY
|
||||||
data_date,
|
data_date,
|
||||||
mismatch_class
|
mismatch_class
|
||||||
|
|||||||
Reference in new issue
Block a user