Достроен последний этаж цепочки 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 видит вставку именно в распределённую таблицу. Группа
опустела.
сырьё: 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 спеки. Всё разобрано отдельным
коммитом.
Зачем: имя файла осталось от прежней цели 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>
Зачем: 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 main2026-08-07 16:50:46 +03:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 здесь работает против строгого приёма, потому что на непонятной
строке он не краснеет, а достраивает недостающее.
parseDateTimeBestEffort20:00:21(обрезано)2026-01-01 20:00:21— выдуман год и первое января2026-05-31(одна дата)2026-05-31 00:00:001780255221(число эпохи)Первая строка и есть цена: сообщение прошло бы строгий приём с тихо неверным
временем — ровно с той порчей, ради которой заведён
key_field_unparsed.Регрессии на настоящих данных нет: по всем 101 252 строкам сырья модельного дня
точный формат разобрал метку у каждой и ни на одной не разошёлся с best-effort.
Различаются они только на порче.
Наблюдение стенда: пересозданная матвью пропускает ближайшее сообщение
Снял и заново создал матвью разбора при живом чтеце — сообщение, отправленное
сразу после, легло в сырьё и не попало в ODS никуда, ни в событие, ни в ошибки;
то же сообщение через минуту разобралось штатно. Воспроизведено дважды. Это тот
же зазор, о котором предупреждает нумерация файлов DDL, только приходит он не
при первом создании, а при замене матвью на работающем стенде. Записано в доку
хранилища как наблюдение: чем держится задержка, я не измерял. На нём я сам
споткнулся, проверяя правку разбора, и минуту думал, что сломал предикат.
Четыре SELECT «первым делом»
Стенд, 7 августа 2026 года, ClickHouse 26.3.17.56. Все четыре ответили так, как
ждала постановка; результаты записаны в ADR 0005, «Что проверено».
isValidJSON('123')1— скаляр законный JSONJSONExtractKeys('123')[]— и то же от вовсе не-JSONJSONAsStringна некорректномINCORRECT_DATA, «JSON object must begin with '{'»; падает и на скаляре123Nullable-членамиЗаодно записана гарантия на
JSONType, на которой стоит первый класс брака:Objectу объекта,Int64у скаляра,Nullу не-JSON и у пустой строки —исключений не бросает.
Разовый опыт: строгий приём, три класса
Управляемая пачка играет день 365 — за границей оси мира,
EventDate2027-06-01, своя партиция. Десять настоящих событий проигрывателя в файл, тристроки из него порчены мутацией: опечатка
LastTrafficSource→LastTraficSource, скаляр123вместо объекта,UTCEventTime= «вчеравечером». Всё тринадцать ушло в топик одной отправкой консольным продюсером.
Скаляр получил
not_an_object, а неkeyset_mismatch, — приоритет классовработает.
Опыт прогнан заново после смены разбора метки, с третьим сообщением-ловушкой:
целое событие,
UTCEventTime= «вчера вечером» иUTCEventTime= «20:00:21».Сырьё 3, события 1, ошибки 2 — обе
key_field_unparsed. Обрезанная меткараньше проходила годной.
Разовый опыт: инвариант слоёв
Та же отправка, обрамление по диапазону
_load_tsпрогона.Как обрамлено под
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) в сырьё не приехало ничего, офсет группы
застыл с отставанием в одно сообщение:
Цель вернули — сообщение доехало само, без повторной отправки, отставание ушло
в ноль, событие разобралось. На этом опыте стоит правило «грязные записи не
валят пайплайн».
Тем же фактом закрыто второе утверждение группы «сказано по памяти»: обе матвью
разбора стоят над
stg.hits_raw_dist, и события доезжают — значит матвью систочником-
Distributedвидит вставку именно в распределённую таблицу. Группаопустела.
Каждый опыт показан красным
multiIfпереставлены местамиnot_an_objectиkeyset_mismatch123получил классkeyset_mismatchJSONExtract(raw, 'Titel', …) AS Title[('Title',0)]— тихая пустая строка пойманаНастоящий день целиком
Стенд поднят с нуля (
make clean && make up), проигрыватель залил день 0 черезKafka.
Повторная заливка того же дня:
Счёт после дедупа не изменился, а пара чисел по ODS — готовая иллюстрация к
запрету голого
count()поReplacingMergeTree: пока мержи не прошли, онпоказывает удвоение.
Проверки
make check-clickhouse— 8 проверок, 7,5 с, зелено: столько же и так жебыстро, как до PR. Замер в карте целей не правился.
make lint,make typecheck,make test(406) — зелено;make docsпересобрал описаниевыгрузки без диффа.
Ревью
Прогнано холодное ревью по двум линиям. Линия дефектов нашла расхождение доки
хранилища с построенным предикатом и неверное число в комментарии; линия
уместности — пересказ ADR 0005 в комментарии матвью, непрогнанный опыт про
_load_tsи молча закрытый вопрос раздела 11 спеки. Всё разобрано отдельнымкоммитом.