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

Merged
ddmitry merged 4 commits from feat/43-ods-event into main 2026-08-07 16:50:46 +03:00
Owner

Closes #43

Достроен последний этаж цепочки Kafka → STG → ODS: типизированное событие,
строгий приём и таблица ошибок разбора. Постоянных проверок PR не добавляет ни
одной — все утверждения закрыты разовыми опытами при исполнении, и вот их след.

Разбор метки времени: развилка закрыта

JSONExtract не берёт ISO-8601 с суффиксом Z. На проводе UTCEventTime
уезжает как 2026-06-01T12:34:56Z (спека генератора, раздел 4), а
JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)') отдаёт на такой строке
NULL. Ни DateTime64, ни DateTime('UTC') суффикс тоже не разбирают; настройка
cast_string_to_date_time_mode = 'best_effort' не спасает — с ней суффикс берёт
CAST, а JSONExtract по-прежнему отдаёт NULL. Спека генератора обещала
обратное («принимает ISO без плясок»); оставь JSONExtract, и в брак уехали бы
все события до единого. Обещание помечено ошибочным.

Замена — разбор по буквально названному формату,
parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'), '%Y-%m-%dT%H:%i:%SZ'),
а не parseDateTimeBestEffort. Сначала стоял best-effort; владелец заметил, что
форма на проводе одна и каноническая, — и в эту сторону довод оказался сильнее,
чем я его понял. Раз запись ровно одна, разбирать её надо строго: широта
best-effort здесь работает против строгого приёма, потому что на непонятной
строке он не краснеет, а достраивает недостающее.

Что пришло parseDateTimeBestEffort по названному формату
20:00:21 (обрезано) 2026-01-01 20:00:21 — выдуман год и первое января NULL
2026-05-31 (одна дата) 2026-05-31 00:00:00 NULL
1780255221 (число эпохи) разобрано NULL

Первая строка и есть цена: сообщение прошло бы строгий приём с тихо неверным
временем — ровно с той порчей, ради которой заведён key_field_unparsed.

Регрессии на настоящих данных нет: по всем 101 252 строкам сырья модельного дня
точный формат разобрал метку у каждой и ни на одной не разошёлся с best-effort.
Различаются они только на порче.

Наблюдение стенда: пересозданная матвью пропускает ближайшее сообщение

Снял и заново создал матвью разбора при живом чтеце — сообщение, отправленное
сразу после, легло в сырьё и не попало в ODS никуда, ни в событие, ни в ошибки;
то же сообщение через минуту разобралось штатно. Воспроизведено дважды. Это тот
же зазор, о котором предупреждает нумерация файлов DDL, только приходит он не
при первом создании, а при замене матвью на работающем стенде. Записано в доку
хранилища как наблюдение: чем держится задержка, я не измерял. На нём я сам
споткнулся, проверяя правку разбора, и минуту думал, что сломал предикат.

Четыре SELECT «первым делом»

Стенд, 7 августа 2026 года, ClickHouse 26.3.17.56. Все четыре ответили так, как
ждала постановка; результаты записаны в ADR 0005, «Что проверено».

SELECT Ответ
isValidJSON('123') 1 — скаляр законный JSON
JSONExtractKeys('123') [] — и то же от вовсе не-JSON
JSONAsString на некорректном падает: код 117 INCORRECT_DATA, «JSON object must begin with '{'»; падает и на скаляре 123
именованный кортеж с Nullable-членами работает: неразобравшиеся члены — NULL, обращение по имени доступно, на не-объекте кортеж целиком из NULL и без исключения

Заодно записана гарантия на JSONType, на которой стоит первый класс брака:
Object у объекта, Int64 у скаляра, Null у не-JSON и у пустой строки —
исключений не бросает.

Разовый опыт: строгий приём, три класса

Управляемая пачка играет день 365 — за границей оси мира, EventDate
2027-06-01, своя партиция. Десять настоящих событий проигрывателя в файл, три
строки из него порчены мутацией: опечатка LastTrafficSource
LastTraficSource, скаляр 123 вместо объекта, UTCEventTime = «вчера
вечером». Всё тринадцать ушло в топик одной отправкой консольным продюсером.

error_class          строк   начало текста
key_field_unparsed       1   {"WatchID":1287399384231946,...
keyset_mismatch          1   {"WatchID":1287399384231946,...
not_an_object            1   123

Скаляр получил not_an_object, а не keyset_mismatch, — приоритет классов
работает.

Опыт прогнан заново после смены разбора метки, с третьим сообщением-ловушкой:
целое событие, UTCEventTime = «вчера вечером» и UTCEventTime = «20:00:21».
Сырьё 3, события 1, ошибки 2 — обе key_field_unparsed. Обрезанная метка
раньше проходила годной.

Разовый опыт: инвариант слоёв

Та же отправка, обрамление по диапазону _load_ts прогона.

сырьё:   13
события: 10
ошибки:  3
сумма:   13

Как обрамлено под FINAL. Счёт по ODS идёт
SELECT count() FROM ods.event_dist FINAL WHERE _load_ts >= … SETTINGS apply_prewhere_after_final = 1. Настройка выбрана вместо подзапроса:
она называет ровно то, чего мы хотим, — PREWHERE после свёртки версий, — тогда
как подзапрос полагался бы на то, что оптимизатор не протолкнёт условие внутрь.
_load_ts в ключ сортировки не входит и не может: колонка версии там запрещена
(спека, раздел 1.3).

Уборка. ALTER TABLE ods.event_rep ON CLUSTER … DROP PARTITION '2027-06-01'
— партиция опыта снесена, ods.event_dist вернулся к нулю. Порченые сообщения
оставлены в *_errors: там нарезка по дню загрузки, и снос задел бы настоящие
ошибки того же дня.

Разовый опыт: сверка разобранного события

Выражения сличения собраны разовым cd generator && uv run python -c … по
schema.COLUMNS; выражения матвью написаны руками по описанию выгрузки.
Десять событий пачки сошлись с сырым текстом по всем 47 колонкам: столбец
«разошлись» пуст у всех десяти.

Разовый опыт: _load_ts переносится из сырья

Сверено по WatchID на двух тысячах событий модельного дня, залитого дважды. У
каждого события метка совпала с меткой одной из двух его доставок, а после
FINAL — с меткой поздней. Случаев «метки нет среди доставок» — ноль, то есть
now64() в матвью разбора нет.

Разовый опыт: упавшая матвью останавливает потребление

Снесена цель матвью — распределённая ods.event_dist, чтец живой. За двадцать
секунд (сброс блока идёт за 7,5) в сырьё не приехало ничего, офсет группы
застыл с отставанием в одно сообщение:

TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
hits  1         5              6              1

Цель вернули — сообщение доехало само, без повторной отправки, отставание ушло
в ноль, событие разобралось. На этом опыте стоит правило «грязные записи не
валят пайплайн».

Тем же фактом закрыто второе утверждение группы «сказано по памяти»: обе матвью
разбора стоят над stg.hits_raw_dist, и события доезжают — значит матвью с
источником-Distributed видит вставку именно в распределённую таблицу. Группа
опустела.

Каждый опыт показан красным

Что сломали Что сказал опыт
снята матвью ошибок сырьё 2, события 1, ошибки 0, сумма 1 — зазор виден
в multiIf переставлены местами not_an_object и keyset_mismatch скаляр 123 получил класс keyset_mismatch
в матвью JSONExtract(raw, 'Titel', …) AS Title сверка дала [('Title',0)] — тихая пустая строка поймана

Настоящий день целиком

Стенд поднят с нуля (make clean && make up), проигрыватель залил день 0 через
Kafka.

сырьё:   50 626
события: 50 626   (FINAL)
ошибки:  0
дней:    1        (2026-06-01)

Повторная заливка того же дня:

сырьё:           101 252
ODS без FINAL:   101 252
ODS через FINAL:   50 626
ошибки:                0

Счёт после дедупа не изменился, а пара чисел по ODS — готовая иллюстрация к
запрету голого count() по ReplacingMergeTree: пока мержи не прошли, он
показывает удвоение.

Проверки

make check-clickhouse — 8 проверок, 7,5 с, зелено: столько же и так же
быстро, как до PR. Замер в карте целей не правился. make lint,
make typecheck, make test (406) — зелено; make docs пересобрал описание
выгрузки без диффа.

Ревью

Прогнано холодное ревью по двум линиям. Линия дефектов нашла расхождение доки
хранилища с построенным предикатом и неверное число в комментарии; линия
уместности — пересказ ADR 0005 в комментарии матвью, непрогнанный опыт про
_load_ts и молча закрытый вопрос раздела 11 спеки. Всё разобрано отдельным
коммитом.

Closes #43 Достроен последний этаж цепочки `Kafka → STG → ODS`: типизированное событие, строгий приём и таблица ошибок разбора. Постоянных проверок PR не добавляет ни одной — все утверждения закрыты разовыми опытами при исполнении, и вот их след. ## Разбор метки времени: развилка закрыта **`JSONExtract` не берёт ISO-8601 с суффиксом `Z`.** На проводе `UTCEventTime` уезжает как `2026-06-01T12:34:56Z` (спека генератора, раздел 4), а `JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)')` отдаёт на такой строке NULL. Ни `DateTime64`, ни `DateTime('UTC')` суффикс тоже не разбирают; настройка `cast_string_to_date_time_mode = 'best_effort'` не спасает — с ней суффикс берёт `CAST`, а `JSONExtract` по-прежнему отдаёт NULL. Спека генератора обещала обратное («принимает ISO без плясок»); оставь `JSONExtract`, и в брак уехали бы все события до единого. Обещание помечено ошибочным. **Замена — разбор по буквально названному формату**, `parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'), '%Y-%m-%dT%H:%i:%SZ')`, а не `parseDateTimeBestEffort`. Сначала стоял best-effort; владелец заметил, что форма на проводе одна и каноническая, — и в эту сторону довод оказался сильнее, чем я его понял. Раз запись ровно одна, разбирать её надо строго: широта best-effort здесь работает **против** строгого приёма, потому что на непонятной строке он не краснеет, а достраивает недостающее. | Что пришло | `parseDateTimeBestEffort` | по названному формату | |---|---|---| | `20:00:21` (обрезано) | `2026-01-01 20:00:21` — выдуман год и первое января | NULL | | `2026-05-31` (одна дата) | `2026-05-31 00:00:00` | NULL | | `1780255221` (число эпохи) | разобрано | NULL | Первая строка и есть цена: сообщение прошло бы строгий приём с тихо неверным временем — ровно с той порчей, ради которой заведён `key_field_unparsed`. Регрессии на настоящих данных нет: по всем 101 252 строкам сырья модельного дня точный формат разобрал метку у каждой и ни на одной не разошёлся с best-effort. Различаются они только на порче. ## Наблюдение стенда: пересозданная матвью пропускает ближайшее сообщение Снял и заново создал матвью разбора при живом чтеце — сообщение, отправленное сразу после, легло в сырьё и не попало в ODS никуда, ни в событие, ни в ошибки; то же сообщение через минуту разобралось штатно. Воспроизведено дважды. Это тот же зазор, о котором предупреждает нумерация файлов DDL, только приходит он не при первом создании, а при замене матвью на работающем стенде. Записано в доку хранилища как наблюдение: чем держится задержка, я не измерял. На нём я сам споткнулся, проверяя правку разбора, и минуту думал, что сломал предикат. ## Четыре SELECT «первым делом» Стенд, 7 августа 2026 года, ClickHouse 26.3.17.56. Все четыре ответили так, как ждала постановка; результаты записаны в ADR 0005, «Что проверено». | SELECT | Ответ | |---|---| | `isValidJSON('123')` | `1` — скаляр законный JSON | | `JSONExtractKeys('123')` | `[]` — и то же от вовсе не-JSON | | `JSONAsString` на некорректном | падает: код 117 `INCORRECT_DATA`, «JSON object must begin with '{'»; падает и на скаляре `123` | | именованный кортеж с `Nullable`-членами | работает: неразобравшиеся члены — NULL, обращение по имени доступно, на не-объекте кортеж целиком из NULL и без исключения | Заодно записана гарантия на `JSONType`, на которой стоит первый класс брака: `Object` у объекта, `Int64` у скаляра, `Null` у не-JSON и у пустой строки — исключений не бросает. ## Разовый опыт: строгий приём, три класса Управляемая пачка играет день 365 — за границей оси мира, `EventDate` `2027-06-01`, своя партиция. Десять настоящих событий проигрывателя в файл, три строки из него порчены мутацией: опечатка `LastTrafficSource` → `LastTraficSource`, скаляр `123` вместо объекта, `UTCEventTime` = «вчера вечером». Всё тринадцать ушло в топик одной отправкой консольным продюсером. ``` error_class строк начало текста key_field_unparsed 1 {"WatchID":1287399384231946,... keyset_mismatch 1 {"WatchID":1287399384231946,... not_an_object 1 123 ``` Скаляр получил `not_an_object`, а не `keyset_mismatch`, — приоритет классов работает. Опыт прогнан заново после смены разбора метки, с третьим сообщением-ловушкой: целое событие, `UTCEventTime` = «вчера вечером» и `UTCEventTime` = «20:00:21». Сырьё 3, события 1, ошибки 2 — обе `key_field_unparsed`. Обрезанная метка раньше проходила годной. ## Разовый опыт: инвариант слоёв Та же отправка, обрамление по диапазону `_load_ts` прогона. ``` сырьё: 13 события: 10 ошибки: 3 сумма: 13 ``` **Как обрамлено под `FINAL`.** Счёт по ODS идёт `SELECT count() FROM ods.event_dist FINAL WHERE _load_ts >= … SETTINGS apply_prewhere_after_final = 1`. Настройка выбрана вместо подзапроса: она называет ровно то, чего мы хотим, — PREWHERE после свёртки версий, — тогда как подзапрос полагался бы на то, что оптимизатор не протолкнёт условие внутрь. `_load_ts` в ключ сортировки не входит и не может: колонка версии там запрещена (спека, раздел 1.3). **Уборка.** `ALTER TABLE ods.event_rep ON CLUSTER … DROP PARTITION '2027-06-01'` — партиция опыта снесена, `ods.event_dist` вернулся к нулю. Порченые сообщения оставлены в `*_errors`: там нарезка по дню загрузки, и снос задел бы настоящие ошибки того же дня. ## Разовый опыт: сверка разобранного события Выражения сличения собраны разовым `cd generator && uv run python -c … ` по `schema.COLUMNS`; выражения матвью написаны руками по описанию выгрузки. Десять событий пачки сошлись с сырым текстом по всем 47 колонкам: столбец «разошлись» пуст у всех десяти. ## Разовый опыт: `_load_ts` переносится из сырья Сверено по `WatchID` на двух тысячах событий модельного дня, залитого дважды. У каждого события метка совпала с меткой одной из двух его доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди доставок» — ноль, то есть `now64()` в матвью разбора нет. ## Разовый опыт: упавшая матвью останавливает потребление Снесена цель матвью — распределённая `ods.event_dist`, чтец живой. За двадцать секунд (сброс блока идёт за 7,5) в сырьё не приехало ничего, офсет группы застыл с отставанием в одно сообщение: ``` TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG hits 1 5 6 1 ``` Цель вернули — сообщение доехало само, без повторной отправки, отставание ушло в ноль, событие разобралось. На этом опыте стоит правило «грязные записи не валят пайплайн». Тем же фактом закрыто второе утверждение группы «сказано по памяти»: обе матвью разбора стоят над `stg.hits_raw_dist`, и события доезжают — значит матвью с источником-`Distributed` видит вставку именно в распределённую таблицу. Группа опустела. ## Каждый опыт показан красным | Что сломали | Что сказал опыт | |---|---| | снята матвью ошибок | сырьё 2, события 1, ошибки 0, сумма 1 — зазор виден | | в `multiIf` переставлены местами `not_an_object` и `keyset_mismatch` | скаляр `123` получил класс `keyset_mismatch` | | в матвью `JSONExtract(raw, 'Titel', …) AS Title` | сверка дала `[('Title',0)]` — тихая пустая строка поймана | ## Настоящий день целиком Стенд поднят с нуля (`make clean && make up`), проигрыватель залил день 0 через Kafka. ``` сырьё: 50 626 события: 50 626 (FINAL) ошибки: 0 дней: 1 (2026-06-01) ``` Повторная заливка того же дня: ``` сырьё: 101 252 ODS без FINAL: 101 252 ODS через FINAL: 50 626 ошибки: 0 ``` Счёт после дедупа не изменился, а пара чисел по ODS — готовая иллюстрация к запрету голого `count()` по `ReplacingMergeTree`: пока мержи не прошли, он показывает удвоение. ## Проверки `make check-clickhouse` — 8 проверок, 7,5 с, зелено: столько же и так же быстро, как до PR. Замер в карте целей не правился. `make lint`, `make typecheck`, `make test` (406) — зелено; `make docs` пересобрал описание выгрузки без диффа. ## Ревью Прогнано холодное ревью по двум линиям. Линия дефектов нашла расхождение доки хранилища с построенным предикатом и неверное число в комментарии; линия уместности — пересказ ADR 0005 в комментарии матвью, непрогнанный опыт про `_load_ts` и молча закрытый вопрос раздела 11 спеки. Всё разобрано отдельным коммитом.
ddmitry added 3 commits 2026-08-07 16:17:49 +03:00
Зачем: имя файла осталось от прежней цели smoke-cluster, которой больше нет,
и читатель ищет проверку ClickHouse не там, где она лежит.

Что: git mv scripts/clickhouse-smoke.sh scripts/check-clickhouse.sh, тем же
коммитом — вызов в Makefile и строка в карте целей. Абзац-объяснение в
docs/architecture/testing.md снят: он обещал переименование, которое здесь и
случилось.

Проверка: make check-clickhouse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Зачем: цепочка 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>
Зачем: холодное ревью по двум линиям нашло дыру в следе опытов и три места,
где текст утверждает не то, что построено.

Что:
- Опыт «_load_ts переносится из сырья» прогнан и записан: у двух тысяч
  событий метка совпала с меткой одной из доставок, случаев «метки нет среди
  доставок» ноль. Туда же — ответ про форму ключа ODS: вопрос раздела 11
  спеки закрывался молча.
- Дока хранилища говорила, что предикат собран из функций, не возвращающих
  NULL; построено иначе — обнуляемый разбор есть, но кончается IS NOT NULL.
- Записана гарантия на JSONType: на не-JSON и пустой строке она отдаёт Null и
  не бросает, то есть годится в предикат. Раньше первый класс брака стоял на
  замере соседней функции.
- ttl_only_drop_parts у таблицы ошибок назван в доке хранилища.
- Комментарий матвью ужат: три вопроса строгого приёма пересказывали ADR 0005
  целиком. Осталось то, чего по коду не видно, — запрет трогать arraySort и
  замер про ISO-8601. Убрано неверное «в полусотне строк» и упоминание имени
  таблицы хранилища в докстринге контракта генератора.

Проверка: DDL применяется на живом кластере; make lint, typecheck, docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ddmitry added 1 commit 2026-08-07 16:48:52 +03:00
Зачем: parseDateTimeBestEffort на непонятной строке не краснеет, а достраивает
недостающее — обрезанное «20:00:21» становится первым января текущего года.
Такое сообщение проходило строгий приём с тихо неверным временем, то есть с
той самой порчей, ради которой класс key_field_unparsed и заведён.

Что:
- В обеих матвью разбор метки идёт parseDateTimeOrNull по формату
  '%Y-%m-%dT%H:%i:%SZ'. Форма на проводе одна и каноническая, поэтому широта
  best-effort не нужна вовсе, а платится за неё отключённой проверкой.
- Замеры в ADR 0005: три записи, которые best-effort достраивает; проверка,
  что настройка cast_string_to_date_time_mode не спасает JSONExtract; сверка
  на настоящих данных — по всем 101 252 строкам сырья модельного дня точный
  формат разобрал метку у каждой и ни на одной не разошёлся с best-effort.
- Записано наблюдение стенда: пересозданная на живом чтеце матвью пропускает
  ближайшее сообщение мимо ODS, через минуту то же сообщение разбирается.
  Воспроизведено дважды; на нём я сам споткнулся при проверке этой правки.

Проверка: опыт строгого приёма прогнан заново — три сообщения дали событие и
два key_field_unparsed, включая обрезанную метку, которая раньше проходила
годной. DDL применяется на живом кластере.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ddmitry merged commit f3ebc115ba into main 2026-08-07 16:50:46 +03:00
ddmitry deleted branch feat/43-ods-event 2026-08-07 16:50:47 +03:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: ddmitry/clickstream-data-platform#62