feat(ods): типизированное событие, строгий приём и таблица ошибок

Зачем: цепочка Kafka → STG → ODS достраивается последним этажом. Сырьё уже
доезжает (#37), настоящие события в топике есть (#41), а типизированного слоя
не было — событие негде было прочитать колонками, а брак негде увидеть.

Что:
- sql/ddl/20-ods-tables.sql — ods.event_rep/_dist на ReplacingMergeTree с
  версией _load_ts, партиция по EventDate, ключ по разделу 1.3 спеки,
  шардирование cityHash64(ClientID); ods.event_errors_rep/_dist с классом
  брака, своими ключами и сроком жизни в месяц.
- sql/ddl/30-ods-views.sql — две матвью над stg.hits_raw_dist. Годность
  считает предикат из трёх частей, вторая матвью берёт его дословное
  отрицание, класс брака пишется первым совпавшим из трёх.
- Метку времени разбирает parseDateTimeBestEffortOrNull, а не JSONExtract:
  ISO-8601 с суффиксом Z JSONExtract не берёт вовсе. Спека генератора
  обещала обратное — обещание поправлено, форма на проводе не менялась.
- Сверка объявлений (contract-тест) снята из документов и из докстрингов
  schema.py: сверх строгого приёма она ловила только смену типа.
- Документация приведена в соответствие: ADR 0005, дока хранилища и обе
  спеки; группа «сказано по памяти» в доке хранилища опустела.

Проверка: make up && make check-clickhouse (8 проверок, 7,5 с); make lint,
make typecheck, make test (406), make docs без диффа. Разовые опыты при
исполнении — в теле PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 16:06:46 +03:00
co-authored by Claude Opus 5
parent d7485217d8
commit 6910440400
8 changed files with 475 additions and 73 deletions
+52 -20
View File
@@ -125,15 +125,20 @@ STG сырьё исключительно от брака: слой сырых
присутствие, и потому сужен до пяти колонок.
Цена решения. Контракт получает ещё два места: типы сорока семи колонок в
выражениях матвью и список тех же имён для сверки ключей. Раздел 1.4 спеки
предупреждает, что, повторяясь примерно в семи местах, они расходятся молча, а
contract-тест из #43 сюда не дотягивается — он сравнивает `system.columns`
целевой таблицы со схемой генератора и о выражениях матвью ничего не знает.
Смягчение работает не везде: опечатка в имени скалярного поля уводит строки в
таблицу ошибок пачкой и видна сразу, а опечатка в имени массива даёт пустой
массив тихо — сверка ключей проверяет ключи сообщения, а не выражения матвью.
Эти двенадцать колонок сторожит smoke: известное событие с товарами обязано
доезжать с непустыми массивами.
выражениях матвью и список тех же имён для сверки ключейа список этот
повторён дважды, по разу на матвью. Раздел 1.4 спеки предупреждает, что,
повторяясь примерно в семи местах, они расходятся молча.
И сверка ключей эту цену покрывает не всю: она смотрит на ключи сообщения, а
не на выражения матвью. Опечатка в имени внутри `JSONExtract` даёт умолчание
типа — ноль, пустую строку, пустой массив, — и молчит она у сорока двух
обычных колонок ровно так же, как у двенадцати массивов. Громко ломаются
только пять опорных: у них разбор `Nullable` стоит в предикате, и опечатка
уводит в таблицу ошибок все строки до единой. Остальные сорок две сторожит
сверка разобранного события против сырого текста — разовый опыт при
исполнении #43, а не постоянная проверка; сила его в том, что выражения
сверки собираются из контракта, а выражения матвью написаны руками по
описанию выгрузки, и одна опечатка в двух местах не повторяется.
Второе — разбор функциями дороже разбора форматом. На объёмах стенда это
несущественно; если станет заметно, сорок семь вызовов сворачиваются в один
@@ -177,15 +182,42 @@ contract-тест из #43 сюда не дотягивается — он ср
Тогда же нашлась и граница обещания — запись с пустым значением и
запись-надгробие не дают строки вовсе; из-за неё в «Решении» и в «Почему»
приписано слово «непустое». Замер целиком — в [доке
хранилища](../architecture/storage.md), раздел «Что проверено». Остальные три
по-прежнему ждут живого стенда; это однострочные `SELECT`, их довольно прогнать
заодно:
хранилища](../architecture/storage.md), раздел «Что проверено».
- форма именованного кортежа в `JSONExtract` с `Nullable`-членами — нужна для
свёртки сорока семи вызовов в один, если разбор окажется дорогим;
- `isValidJSON('123')` возвращает единицу, а `JSONExtractKeys` от скаляра —
пустой массив. На обоих стоят классы брака и их приоритет, а документация
поведение на не-объекте не описывает: два `SELECT` закрывают вопрос;
- `JSONAsString` действительно падает на некорректном JSON, а не пропускает
строку. На этом стоит отказ от него в пользу `RawBLOB`; документация про
ошибочный ввод молчит.
Остальные четыре закрыты при исполнении #43, на стенде 7 августа 2026 года,
ClickHouse 26.3.17.56. Все четыре ответили так, как ждала постановка:
- `isValidJSON('123')` возвращает единицу — скаляр законный JSON, и первый
класс брака поэтому проверяет именно объект, а не валидность;
- `JSONExtractKeys('123')` возвращает пустой массив, и он же приходит от
вовсе не-JSON. Значит скаляр проваливает и сверку ключей — отсюда
обязательный порядок классов;
- `JSONAsString` на некорректном вводе падает, а не пропускает строку: код 117
`INCORRECT_DATA`, «JSON object must begin with '{'». Падает и на скаляре
`123`. На этом стоит отказ от него в пользу `RawBLOB`;
- форма именованного кортежа с `Nullable`-членами работает и годится в
запасной вариант: `JSONExtract(raw, 'Tuple(WatchID Nullable(UInt64), …)')`
даёт NULL в тех членах, что не разобрались, обращение по имени члена
доступно, а на не-объекте кортеж выходит целиком из NULL и исключения нет.
Тем же заходом нашлось то, о чём никто не спрашивал, и оно оказалось
блокирующим. **`JSONExtract` с типом `DateTime` не разбирает ISO-8601 с
суффиксом зоны.** На проводе `UTCEventTime` уезжает как
`2026-06-01T12:34:56Z` (спека генератора, раздел 4), а
`JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)')` отдаёт на такой
строке NULL — то есть все события до единого уходили бы в брак с классом
`key_field_unparsed`. Ни `DateTime64`, ни `DateTime('UTC')` суффикс тоже не
берут; без `Z` та же строка разбирается. Спека генератора обещала обратное
(«принимает ISO без плясок») — обещание было ошибочным и исправлено тем же
PR. Разбор метки времени поэтому идёт
`parseDateTimeBestEffortOrNull(JSONExtractString(raw, 'UTCEventTime'))`:
документация ClickHouse прямо относит ISO-8601 к форматам
`parseDateTimeBestEffort` (сверено через Context7 7 августа 2026 года), а
вариант `*OrNull` возвращает NULL вместо исключения и потому годится в
предикат. `EventDate` уезжает как `2026-06-01` и разбирается `JSONExtract`
без оговорок.
Оговорка к обнуляемому разбору даты, измеренная там же: `Nullable(Date)`
даёт NULL на строке, которая датой не является вовсе («мусор»), и на числе,
но невозможную дату `2026-13-99` молча приводит к `1970-01-01`. То есть
класс `key_field_unparsed` ловит порчу типа, а не порчу значения внутри типа.
+32 -21
View File
@@ -6,10 +6,11 @@
раздел «Что проверено» — чему в этом тексте верить и на каком основании.
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/` уже лежат базы слоёв
и объекты STG — чтец топика `hits`, таблицы сырья и матвью приёма. Объектов ODS
в репозитории пока нет. Дальше по тексту устройство описано так, как оно
проектируется; построенное от заложенного отличает карта таблиц в конце.
keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/` лежит вся цепочка
`Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное
событие с таблицей ошибок и три матвью. Дальше по тексту устройство описано
так, как оно проектируется; построенное от заложенного отличает карта таблиц в
конце.
Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат
@@ -229,9 +230,12 @@ NULL, — иначе трёхзначная логика даст строку,
разъедется само: сырьё истечёт раньше, а перезаливка модельного дня задвоит его,
тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не
смотреть на оповещения
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверяется разово в
smoke на управляемой пачке: отправили N сообщений — получили N строк сырья и N в
сумме событий и ошибок. Счёт по ODS идёт через `FINAL`: голый `count()` по
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым
опытом при исполнении #43, на управляемой пачке: отправили N сообщений —
получили N строк сырья и N в сумме событий и ошибок. Постоянной целью такой
опыт не становится, и почему — в [карте
проверок](testing.md), раздел «Интеграционная проверка постоянной целью не
становится». Счёт по ODS идёт через `FINAL`: голый `count()` по
`ReplacingMergeTree` зависит от того, сколько мержей успело пройти, и спека это
прямо запрещает (раздел 6).
@@ -345,8 +349,7 @@ ODS. Второе: матвью приёма создаётся последне
## Карта таблиц
Ниже — то, что закладывает этап 2. DDL слоя STG уже лежит в `sql/ddl/`;
объектов ODS в репозитории пока нет.
Ниже — то, что закладывает этап 2; всё перечисленное лежит в `sql/ddl/`.
| Слой | Объект | Что это |
|---|---|---|
@@ -382,9 +385,24 @@ ODS. Второе: матвью приёма создаётся последне
завязан на движок базы `Atomic`. `ON CLUSTER` ждёт все хосты и бросает по
таймауту; `CREATE ... IF NOT EXISTS` на существующем объекте не бросает.
**Проверено на стенде.** Опыты прогнаны на живом кластере при исполнении #37:
четыре 5 августа 2026 года, пятый 6 августа. Все подтвердили то, что здесь
написано.
**Проверено на стенде.** Опыты прогнаны на живом кластере: пять при исполнении
#37 (четыре 5 августа 2026 года, пятый 6 августа) и два при исполнении #43
(7 августа). Все подтвердили то, что здесь написано.
- Матвью с источником-`Distributed` срабатывает на вставку именно в эту
распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят над
`stg.hits_raw_dist`, а пишет в неё матвью приёма — и события доезжают до
`ods.event`; значит блок она видит. В документации ClickHouse случая нет
вовсе, до 7 августа утверждение держалось на опыте владельца.
- Упавшая матвью роняет вставку и останавливает потребление до починки.
Проверено сносом цели — распределённой `ods.event_dist` — при живом чтеце: за
двадцать секунд (сброс блока идёт за 7,5) в сырьё не приехало ничего, а
офсет группы застыл с отставанием в одно сообщение. Цель вернули — сообщение
доехало само, без повторной отправки, отставание ушло в ноль, событие
разобралось. Сносить надо именно распределённую таблицу: вставка в
`Distributed` кладёт блок в спул и сразу возвращает управление, так что на
сносе локальной ошибка всплыла бы фоном и утверждение показалось бы
опровергнутым.
- `RawBLOB` даёт ровно одну строку на каждое непустое сообщение. Три сообщения
с ключами, поставленные в очередь до одного сброса продюсера, стали тремя
@@ -433,12 +451,5 @@ Kafka с пустым значением (ноль байт) и запись-н
таблице. Это вычитано, а не измерено. На устройство приёма оговорка не влияет:
служебные колонки мы заполняем выражением при любом ответе.
**Сказано по памяти, проверки пока нет.** Осталось два утверждения, и оба ждут
одного и того же — матвью разбора, а она приходит с #43.
- Матвью с источником-`Distributed` срабатывает на вставку именно в эту
распределённую таблицу, до раскладки по шардам. В документации случая нет
вовсе, утверждение держится на опыте владельца.
- Упавшая матвью роняет вставку и останавливает потребление до починки. На этой
фразе стоит правило «грязные записи не валят пайплайн», а сама она стоит пока
на одном рассуждении.
**Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие
матвью разбора, закрыты опытами при исполнении #43 и переехали выше.
+4 -4
View File
@@ -22,10 +22,10 @@
макросов `shard`: обе ноды здоровы и порты отвечают. `make check-clickhouse` не
заметит потерянного подключения Superset: он про ClickHouse и только.
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Договор со
схемой событий — вопрос к ClickHouse, значит дом ему в `check-clickhouse`, даже
если по цене он подошёл бы смоуку. Счётчики против манифеста — тоже вопрос к
ClickHouse: строки в `ods.event` считает сам сервер и отвечает сразу.
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
против манифеста — вопрос к ClickHouse: строки в `ods.event` считает сам
сервер и отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене
они подошли бы смоуку.
## Карта целей
+7 -12
View File
@@ -172,8 +172,9 @@ Ecommerce (заполнены только у торговых событий):
рендеренное «описание выгрузки» в доках — аналог документации Метрики.
Сторона хранилища (DDL, SELECT матвью, трансформации, витрины) пишется по
этой документации на своих этапах, как в бою хранилище адаптируется к
источнику; границу сторожат строгий приём (раздел 6) и contract-тест в
smoke — сравнение `system.columns` поднятого стенда со схемой генератора.
источнику; границу сторожит строгий приём (раздел 6). Вторым сторожем здесь
стояла сверка объявлений — `system.columns` поднятого стенда против схемы
генератора; она снята при исполнении #43 как ничего не добавляющая к соседу.
Без контракта 47 колонок, повторяясь примерно в семи местах, расходятся
молча. Заодно это учебный артефакт: менти видит на живом примере, что
такое data contract.
@@ -584,8 +585,10 @@ v2 стартует пустым, поэтому объём ниже — это
Список убывает по мере постройки: проверенное уходит отсюда, а ответ с датой
остаётся там, где на него опираются. Формат чтеца и форма виртуальной метки
времени закрыты при исполнении #37 — см. [доку
хранилища](../architecture/storage.md), раздел «Что проверено».
времени закрыты при исполнении #37, форма ключа ODS, поведение матвью над
`Distributed` и запасной именованный кортеж — при исполнении #43; ответы — в
[доке хранилища](../architecture/storage.md) и
[ADR 0005](../adr/0005-event-ingestion.md), разделы «Что проверено».
- Поведение соединения двух Distributed-таблиц и `distributed_product_mode`
эмпирически на стенде (хвост #14).
@@ -593,14 +596,6 @@ v2 стартует пустым, поэтому объём ниже — это
прогонами, отсутствие дублей при штатной работе. Закрыто пока наполовину: что
обе ноды читают топик и обе партиции доезжают, показал #37; что дублей нет и
как раскладка меняется между прогонами — нет.
- Точная форма `ORDER BY` ODS-таблиц (выражение `intHash32` в ключе
ReplacingMergeTree).
- Матвью с источником-`Distributed` срабатывает на вставку именно в эту
распределённую таблицу, до раскладки по шардам: на этом стоит цепочка
STG → ODS (ADR 0005). Проверено владельцем на рабочих проектах, в документации
ClickHouse этот случай не описан.
- Форма именованного кортежа в `JSONExtract` с `Nullable`-членами — ею
сворачиваются 47 вызовов в один, если разбор окажется дорогим (ADR 0005).
- Размер артефакта эталонного мира после пересборки.
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом
сверять через MCP Context7 (правило AGENTS.md).
+24 -12
View File
@@ -33,8 +33,8 @@
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
хешами манифеста. Транспорт (офсеты Kafka, темп) — вне обещания.
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
хранилище строится по рендеренной документации, границу сторожит
contract-тест.
хранилище строится по рендеренной документации, границу сторожит строгий
приём на стороне хранилища.
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок — 14 дней;
@@ -229,10 +229,13 @@
- **Сторона хранилища пишется по документации, не генерируется.** DDL
`ods.event`, SELECT матвью, `dds.event_v`, трансформации — работа
следующих этапов по «описанию выгрузки», как в бою хранилище адаптируется
к источнику. Границу сторожат два боевых механизма: строгий приём
к источнику. Границу сторожит боевой механизм строгий приём
(`Nullable`-разбор со сверкой набора ключей, таблицы `*_errors` — раздел 6
мастер-спеки) и contract-тест в smoke — сравнение `system.columns`
поднятого стенда со схемой генератора.
мастер-спеки). Сверка объявлений (`system.columns` поднятого стенда против
контракта) здесь стояла вторым механизмом и снята при исполнении #43:
сверх строгого приёма она ловила ровно одно — смену типа колонки, — а её
ловит и сверка разобранного события, причём на живых данных, а не на
объявлениях.
Отклонено с доводами:
@@ -263,10 +266,18 @@
- **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`,
`UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырья: весь
смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте и
разбирает событие глазами, а число эпохи этот урок убивает. Разбору это
ничего не стоит: `JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)')`
принимает ISO без плясок. Колонка `ecommerce` — строка, внутри которой лежит
экранированный JSON, как отдаёт Метрика.
разбирает событие глазами, а число эпохи этот урок убивает. Колонка
`ecommerce` — строка, внутри которой лежит экранированный JSON, как отдаёт
Метрика.
Оговорка про цену разбора, вписанная сюда 6 августа и оказавшаяся неверной:
здесь стояло, что `JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)')`
«принимает ISO без плясок». Не принимает — на строке с суффиксом `Z` он
отдаёт NULL, и при исполнении #43 это увело бы в брак все события до
единого. Измерено на стенде 7 августа 2026 года; форма на проводе от этого
не меняется, меняется выражение разбора на стороне хранилища —
`parseDateTimeBestEffortOrNull` вместо `JSONExtract`
([ADR 0005](../adr/0005-event-ingestion.md)).
Форму реализует сериализатор (#41), хранилище (#43) читает то, что он
положил: порядок тикетов развёрнут 6 августа 2026 года, и отправитель идёт
@@ -283,7 +294,8 @@
ReplacingMergeTree.
- **Эталонный снимок при старте стенда — через Kafka, пакетным режимом
проигрывателя.** Отдельный механизм заливки не строится: каждый `make up`
бесплатно прогоняет весь конвейер и contract-тест на настоящих данных.
бесплатно прогоняет весь конвейер на настоящих данных, и строгий приём
хранилища проверяет контракт тем же прогоном.
Оговорка «если заливка уйдёт в десятки минут — вернуться к прямой
загрузке» проверена при фиксации чисел: 14 × 50 тыс. ≈ 700 тыс. событий —
расчётно минута-две, запас есть.
@@ -395,8 +407,8 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
Внесены в мастер-спеку тем же коммитом, что и эта спека:
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
contract — хранилище пишется по документации, границу сторожит
contract-тест (раздел 3 здесь).
contract — хранилище пишется по документации, границу сторожит строгий
приём (раздел 3 здесь).
- **Раздел 8**: артефакт `data/startup_history/` в git заменён манифестом;
снимок генерируется на месте (раздел 5 здесь). Туман «политика
версионирования артефакта» закрыт этим же ходом: версионируется манифест.