docs(ingestion): упрощены объяснения источников и приема данных

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

Что: обновлены вводная генератора, справочники приема и модели заказа,
разделы хранилища и комментарии SQL и дага. Сохранены технические условия;
исполняемый код, схемы и поведение стенда не изменены.

Проверка: make lint, git diff --check, 142 локальные ссылки и справка CLI.
SQL без комментариев и Python AST без строк документации совпали
с исходными. Генерация данных и сброс состояния не выполнялись.

Refs #250
This commit is contained in:
ddadmin committed 2026-09-13 16:32:45 -04:00
1 parent 83434102d5
commit 75f28fe99f
13 files changed
+663 -974

No files matched your search

+205 -228
View File
@@ -122,112 +122,110 @@ DDS: представление читает события ODS.
## Служебные колонки
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
`_topic`, `_partition`, `_offset`, `_timestamp` у Kafka, `_shard_num` у
`Distributed` и прочие. Если положить на диск колонку с таким же именем, в
матвью перестанет читаться, что дано движком, а что положено нами, — а это
ровно то различие, ради которого метаданные доставки и хранятся. Поэтому они
ложатся под именами `kafka_topic`, `kafka_partition`, `kafka_offset`,
`kafka_timestamp`, рядом — `consumer_host`, имя читавшей ноды: виртуальные
колонки его не несут, а после записи в `Distributed` он уже невосстановим.
STG сохраняет исходное сообщение в строке `raw` и добавляет сведения
о доставке. JSON на этом шаге не разбирается: некорректное содержимое
тоже попадает в таблицу.
Типы у них такие: `kafka_topic` и `consumer_host` — `LowCardinality(String)`,
значений мало и они повторяются; `kafka_partition` и `kafka_offset` — `UInt64`.
С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок
даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и
`_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а сырой слой
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы либо
падение приёма на первом сообщении, либо тихие нули за 1970 год.
| Колонка STG | Что хранит |
|---|---|
| `kafka_topic` | Имя топика из виртуальной колонки Kafka `_topic` |
| `kafka_partition` | Номер партиции из `_partition` |
| `kafka_offset` | Позицию сообщения внутри партиции из `_offset` |
| `kafka_timestamp` | Метку времени Kafka из `_timestamp_ms`, если она есть |
| `consumer_host` | Имя ноды, прочитавшей Kafka |
| `_load_ts` | Реальное время записи сообщения в STG |
| `_load_id` | Только у заказов: `run_id` запуска Airflow, который принял порцию |
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
на шарде-получателе, то есть назвал бы не ту ноду, которая читала топик, —
колонка молча отвечала бы на другой вопрос.
Топик, партиция и смещение вместе позволяют найти сообщение Kafka.
`kafka_topic` и `consumer_host` имеют тип `LowCardinality(String)`,
`kafka_partition` и `kafka_offset` — `UInt64`.
`kafka_timestamp` имеет тип `Nullable(DateTime64(3, 'UTC'))`: сохраняет
миллисекунды и допускает отсутствие метки.
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего
не проверяет, поэтому там оказываются и целые сообщения, и мусор. События ниже
разбирают матвью ODS ([ADR 0005](../adr/0005-event-ingestion.md)), заказы —
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
0010](../adr/0010-order-versions-in-ods.md)).
Виртуальные колонки предоставляет движок, а колонки STG хранят значения
на диске. Их имена различаются, чтобы не скрыть виртуальную колонку
собственной. Значения вычисляются в запросе приема. В частности,
`hostName()` должен выполняться на ноде, читающей Kafka:
`DEFAULT hostName()` только в локальной таблице назвал бы шард-получатель.
Движок таблицы STG — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у
таблицы STG и нет. Замену версий сюда ставить нельзя — она отменила бы свойство
слоя, ради которого он заведён: повтор доставки в STG обязан быть виден.
Таблицы STG используют `ReplicatedMergeTree` с
`ORDER BY (kafka_partition, kafka_offset)`. Повторная доставка остается
отдельной строкой; STG нужен в том числе для разбора таких повторов.
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
повтор доставки. У `ods.order` версию задаёт `snapshot_date` — дата слепка
источника; `_load_ts` только показывает, когда конкретная строка приехала.
`_load_ts` имеет тип `DateTime64(3, 'UTC')`. При переходе в ODS метка
копируется из STG без пересчета. У событий она также выбирает последнюю
доставку; у заказов версию задает `snapshot_date`. `_load_id` заказа
переносится в годную строку ODS и в таблицу ошибок.
У непрерывного потока событий нет запуска Airflow, поэтому `_load_id`
в его STG и ODS отсутствует.
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
`DateTime64(3, 'UTC')`; ClickHouse их не создаёт и добавляет рядом собственную
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
Время события `UTCEventTime` и дата `EventDate` приходят из счетчика сайта.
Поля заказа `created_at` и `updated_at` приходят из базы магазина.
Они относятся к модельному времени; `_load_ts` — к реальным часам стенда.
Например, событие июня можно загрузить в сентябре: его `EventDate`
останется июньской, а `_load_ts` будет сентябрьской.
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
Airflow. Переделать разобранное руками можно вставкой из STG с фильтром по
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
`_load_id`; дневные партиции ODS он не заменяет.
Имя согласовано с каноном служебных полей соседнего учебного стенда на
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
технических колонок — распространённая запись,
её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит:
запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
Общего идентификатора пачки загрузки (`_load_id`) нет. У потока событий нет ни
пачки, ни `run_id`, и колонка была бы пустой формальностью. У заказов читатель
назван: `_load_id` равен `run_id` Airflow и переносится из STG в годную строку
ODS и в таблицу ошибок. Нужен ли он выше ODS, решается вместе с моделью DDS.
События разбираются материализованными представлениями. Автоматической
пакетной переобработки у них нет; сохраненный срез STG можно разобрать
отдельным запросом до истечения срока хранения. При повторной вставке
с тем же ключом и `_load_ts` `ReplacingMergeTree` оставляет более поздно
вставленную строку. Заказы разбирает задача Airflow по `_load_id`.
В DDS метки загрузки уже относятся к собственной сборке слоя —
[пример заказа](dds/order.md#служебные-колонки).
## Актуальные события
`ods.event_rep` хранит физически принятые версии события в
`ReplacingMergeTree(_load_ts)`, а `ods.event_dist` служит для записи и
диагностики доставки. Обычное чтение этой пары может показать несколько строк
одного `WatchID`, пока фоновые слияния их не схлопнули.
Повторная отправка события может дать несколько строк в `ods.event_rep`.
Движок `ReplicatedReplacingMergeTree(_load_ts)` считает версиями строки
с одинаковым полным ключом сортировки:
`(CounterID, EventDate, intHash32(ClientID), WatchID)`.
При слиянии остается строка с максимальным `_load_ts`, то есть последняя
доставка. Один `WatchID` обозначает событие, но физический ключ замены шире.
`ods.event_v` — обычное представление над `ods.event_dist FINAL`. Оно сохраняет
имена, типы и зерно события, но всегда возвращает актуальную версию. Актуальное
состояние читают через него, а не повторяют `FINAL` в каждом запросе.
`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том
числе snake_case-имена и расшифровку кодов.
`ods.event_v` читает `ods.event_dist FINAL`: выбирает актуальную строку
при выполнении запроса, не дожидаясь фоновых слияний. Поэтому события
считают через `_v`; число строк в `_dist` зависит от уже прошедших слияний.
`dds.event_v` использует этот результат, дает колонкам имена в `snake_case`
и расшифровывает коды.
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
исключение: источник объявляет `Array(Float64)`, а материализованное
представление ODS разбирает числа сырого JSON сразу в `Array(Decimal(18, 2))`.
DDS наследует этот тип без нового преобразования. Прямой разбор подтвержден
документацией ClickHouse через Context7 и запросом к ClickHouse 26.3; сравнение
с сырыми данными подтвердило: копейки всех покупок целы.
Выбор по полному `ORDER BY` и максимальной версии, включая чтение с `FINAL`,
сверен через Context7 с [документацией ClickHouse](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/replacingmergetree).
В стенде закреплена версия 26.3.17.56; `_v` использует `FINAL`, чтобы
результат не зависел от фоновых слияний.
Типы ODS повторяют [описание выгрузки](../formats/clickstream-event.md)
с одним денежным исключением: `purchaseRevenue` объявлен источником
как `Array(Float64)`, а из JSON разбирается сразу в
`Array(Decimal(18, 2))`. Это сумма в рублях с копейками; DDS наследует тип.
`productPrice` содержит округленные целые рубли и не подходит для
восстановления точной суммы покупки. Прямой разбор `purchaseRevenue`
проверен на ClickHouse 26.3; результаты сохранены в разделе «Что проверено».
## Версии заказов
Слепки повторяют состояние заказов из семидневного окна источника.
`ods.order_rep` хранит принятые версии в
`ReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`, партиция —
`toDate(created_at)`. `ods.order_dist` шардирует по
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
выбрал одного победителя.
`ReplicatedReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`,
партиция — `toDate(created_at)` в UTC. Запись через `ods.order_dist`
распределяет их по `cityHash64(order_id)`. У всех версий заказа одинаковы
день создания и шард, поэтому они могут заменять друг друга.
Физическая пара нужна для загрузки и диагностики. Обычное чтение показывает
версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
`ods.order_v` служит поверхностью точного текущего состояния для следующих
слоёв. Представление сохраняет язык источника и не решает, какой станет модель
DDS. `snapshot_date` в нём одновременно дата наблюдения и колонка версии. Она
не доказывает полноту принятой порции, поэтому ODS не публикует партиции по
дате слепка. Модель версионного ODS записана в
[ADR 0010](../adr/0010-order-versions-in-ods.md), выбор колонки версии описан в
[ADR 0014](../adr/0014-order-version-snapshot-date.md), пакетный приём описан в
[спецификации приёма заказов](orders/ingestion.md). Контракт следующего слоя
задан в [модели заказа в DDS](dds/order.md).
`ods.order_v` читает `_dist FINAL` и оставляет строку с максимальной датой
слепка. Дата `snapshot_date` показывает последнее принятое наблюдение,
а `updated_at` — время изменения заказа в магазине. У неизменившегося
заказа в следующем слепке дата наблюдения обновится, время изменения — нет.
Источник выпускает один слепок на дату, а поздний слепок показывает
не менее новое состояние. Отсутствие заказа в следующем слепке
не означает удаления. Физическая таблица не служит архивом:
старые версии исчезают при фоновых слияниях.
Прием добавляет версии отдельными строками. `snapshot_date` не доказывает,
что Kafka отдала весь слепок, поэтому партиция по этой дате не заменяется.
Подробности — [прием заказов](orders/ingestion.md),
[ADR 0010](../adr/0010-order-versions-in-ods.md) и
[ADR 0014](../adr/0014-order-version-snapshot-date.md).
Следующий слой описан в [модели заказа DDS](dds/order.md).
## Часовые пояса
@@ -289,169 +287,148 @@ UTC+4), и пересчёт идёт один раз при наполнении
## Приём: поток и его свойства
Раздел — про события. У заказов приём устроен иначе: слепок забирает по команде
даг `orders_ingest`, а не подписанная навсегда матвью — [ADR
0008](../adr/0008-order-ingestion.md) и [спецификация приёма
заказов](orders/ingestion.md).
События принимаются непрерывно. Заказы читает отдельным запросом
даг `orders_ingest` — [описание пакетного приема](orders/ingestion.md).
У событий путь такой:
Цепочка одна: чтец топика → матвью → STG → матвью разбора → событие и таблица
ошибок ODS.
```text
stg.hits_raw_kafka → stg.hits_raw_mv → stg.hits_raw_dist
├→ ods.event_mv → ods.event_dist
└→ ods.event_errors_mv → ods.event_errors_dist
```
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
`ON CLUSTER` и макросов в имени не содержит. Разные группы дали бы каждой ноде
полную копию топика, и это отдельная сцена для лабы, а не рабочий режим. Имя
кластера в `ON CLUSTER` и в движке `Distributed` — `clickstream_cluster`, оно
задано в `infra/clickhouse/config.d/cluster.xml`.
Таблицы Kafka на обеих нодах используют одну группу `clickstream_hits`.
Ноды делят чтение партиций топика. Разные группы прочитали бы каждая
свою полную копию потока. Имя группы одинаково в DDL на всем
`clickstream_cluster`; конфигурация кластера —
`infra/clickhouse/config.d/cluster.xml`.
**Источник матвью разбора — `stg.hits_raw_dist`, а не локальная таблица.**
У матвью две привязки: источник, на вставку в который она срабатывает, и цель,
куда пишет. Распределённая таблица — лицо слоя, локальная — его хранилище;
потребитель слоя цепляется к лицу. Практически это значит, что разбор идёт на
той же ноде, что читала Kafka, в момент вставки первой матвью — до раскладки по
шардам.
Материализованные представления разбора срабатывают на вставку
в `stg.hits_raw_dist`, до распределения строк по шардам.
Оба видят вставляемую порцию на той ноде, которая читает Kafka.
`stg.hits_raw_mv` создается после них: его подключение запускает
чтение топика. Если начать чтение раньше, сообщения успеют попасть
в STG без разбора в ODS.
**Вставка фоновая, и окно потери мы принимаем.** Вставка в распределённую
таблицу кладёт блок в локальный спул и сразу возвращает управление, а Kafka
коммитит офсеты по факту работы матвью — то есть по факту записи в спул. Топик
уже считает сообщение прочитанным, хотя на шарде его ещё нет: умри нода в этом
промежутке — сообщения не перечитаются.
Условие приема проверяет три вещи:
Закрывает окно настройка `distributed_foreground_insert = 1`, и на ETL-вставках
Airflow она стоит — там это обычный `SETTINGS` у запроса. На пути приёма её нет,
и по трём причинам. Вставку выполняет фоновый поток Kafka-движка, своего запроса
у него не бывает, так что настройка уровня запроса доехала бы только профилем
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
как при фоновом первая нода продолжает принимать и копит спул для соседа.
Платится при этом не одно ожидание на блок, а три распределённые вставки — STG,
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
компромисс полезнее показать, чем спрятать за галочкой.
- корень `raw` — объект JSON;
- набор верхнеуровневых ключей совпадает с 47 полями формата;
- пять опорных полей, `WatchID`, `VisitID`, `ClientID`, `EventDate`
и `UTCEventTime`, разбираются в ожидаемые типы.
Ещё одно место, где сырой слой хранит не всё приехавшее: запись с пустым
значением и запись-надгробие проходят молча, не оставляя строки. Свойство
измерено и принято осознанно — подробности в разделе «Что проверено».
Остальные поля извлекаются по описанию выгрузки без отдельных проверок.
Это граница приема: годная строка не означает, что проверен смысл
всех 47 значений. Подробности — в [ADR 0005](../adr/0005-event-ingestion.md).
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при
перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
`ods.event_mv` принимает строки, для которых условие истинно,
`ods.event_errors_mv` — для которых оно ложно. Условие не должно
возвращать `NULL`: такую строку не выбрали бы ни оно, ни его отрицание.
Разбор использует функции без исключений на некорректном сообщении,
поскольку ошибка материализованного представления прерывает вставку
и останавливает чтение Kafka до устранения причины.
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
`ReplacingMergeTree`, а таблицы STG вовсе не устраняют дубли: перезаливка
модельного дня честно удваивает `count()` в STG, и живёт эта пара до истечения
срока хранения. Это свойство слоя, а не поломка, — но обещание идемпотентности
конвейера к сырому слою не относится.
### Доставка и повторы
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
Удвоение строк STG проходит мимо неё только потому, что при повторном чтении
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
отсутствии механизма.
Вставка в Distributed на этом пути фоновая: блок сначала записывается
в локальную очередь на диске, затем отправляется на шарды.
Kafka подтверждает обработку после выполнения материализованных
представлений, когда данные еще могут находиться в очереди.
Если после подтверждения потерять ноду вместе с очередью, сообщения
не перечитаются. Возможен и повтор: данные уже записались на шард,
но подтверждение Kafka не успело сохраниться.
Поэтому прием не гарантирует доставку каждого сообщения хотя бы один раз.
**Матвью разбора две, и их условия обязаны делить поток без зазора и без
нахлёста.** Одна забирает годные строки в `ods.event_dist`, вторая — брак в
`ods.event_errors_dist`. Строка, подошедшая обеим, задвоится; не подошедшая ни
одной — исчезнет молча. Держится это формой: второе условие пишется буквальным
отрицанием первого, а сам предикат NULL не возвращает ни в одной своей части, —
иначе трёхзначная логика даст строку, которую не возьмёт ни `условие`, ни
`NOT условие`. Обнуляемый разбор в предикате поэтому есть, но заканчивается
`IS NOT NULL`, а сравнения дают 0 или 1. Функции предиката не должны и бросать
исключений: упавшая матвью
роняет вставку и останавливает потребление до починки
([ADR 0005](../adr/0005-event-ingestion.md)).
В ETL-запросах Airflow `distributed_foreground_insert = 1` дожидается
записи на шарды. У фонового приема событий этой настройки нет:
недоступность одного шарда тогда остановила бы весь прием. В принятом
режиме доступная нода продолжает принимать и накапливает очередь
для недоступного шарда. Ограничение сохранено для учебного стенда;
подробности и наблюдения — в разделе «Что проверено».
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырого
слоя срок жизни трое суток, а ODS хранит всё, поэтому равенство «строки STG =
события + ошибки» разъедется само: строки STG истекут раньше, а перезаливка
модельного дня задвоит их, тогда как в ODS тот же повтор схлопнется. Правило,
красное в норме, учит не смотреть на оповещения ([ADR
0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым опытом при
исполнении #43, на управляемой пачке: отправили N сообщений — получили N строк
STG и N в сумме событий и ошибок. Постоянной целью такой опыт не становится, и
почему — в [карте проверок](testing.md), раздел «Интеграционная проверка
постоянной целью не становится». Счёт актуальных событий идёт через
`ods.event_v`, которое скрывает `FINAL`: голый `count()` по `ReplacingMergeTree`
зависит от того, сколько мержей успело пройти, и спека это прямо запрещает
(раздел 6).
В STG повтор сохраняется, в ODS его устраняет выбор актуальной версии.
Хотя `Replicated*` умеет отбрасывать повторно вставленные одинаковые блоки,
при новом чтении Kafka меняется `_load_ts`, а вместе с ним и хеш блока.
Поэтому повторная отправка модельного дня увеличивает число строк STG.
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
против [паспорта мира](../../data/world-inventory.json), лежащего в git (`make
check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в этом вся
разница: паспорт описывает восемь дней стартового мира, счёт обрамлён их датами,
повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не помеха — ODS
хранит всё. Обе стороны равенства зафиксированы: одна кодом генератора, другая
файлом в git. У межслойной сверки такой опоры нет ни с одной стороны.
Пустое значение сообщения и значение `null` не дают строки даже в STG:
чтец продвигает позицию Kafka, но не передает строку на разбор.
Генератор таких сообщений не создает. Поведение измерено на стенде
и описано в разделе «Что проверено».
### Что можно сверять
Для короткого неизменного среза доставки можно проверить баланс:
число строк STG равно сумме годных строк и ошибок до удаления повторов.
Постоянно сравнивать полные таблицы так нельзя: STG удаляет старые данные,
ODS оставляет события и объединяет повторные доставки.
Счетчики по слоям тогда расходятся при штатной работе.
`make check-clickhouse` сравнивает актуальные события в `ods.event_v`
с [паспортом мира](../../data/world-inventory.json) только за восемь
начальных дней. Эта проверка учитывает повторную отправку через `FINAL`
и не зависит от срока жизни STG. Границы остальных проверок —
в [карте проверок](testing.md).
## Срок жизни сырых данных
Сырые данные в STG живут трое суток реального времени и уходят сами. Трое — это
окно отладки: столько сырые данные хранятся на ноутбуке, чтобы менти успел
разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню
загрузки, срок — по той же колонке `_load_ts`, снятие — целыми кусками
(`ttl_only_drop_parts`). В DDL значение проставлено явно, чтобы поведение не
зависело от умолчания версии.
TTL — правило автоматического удаления старых данных. В обеих таблицах
STG срок задан как три суток от `_load_ts`, по реальному времени загрузки.
Партиции тоже нарезаны по дню `_load_ts`. Это оставляет короткий период
для поиска ошибок и повторного разбора, после которого данные освобождают
место на диске.
Нарезать сырые данные по модельному дню события было бы соблазнительно — он
единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
снимается целиком, только когда в нём истекли все строки, а в партиции
модельного дня лежит приехавшее в разное реальное время: мерж склеит куски
разного возраста, самая свежая строка удержит весь кусок, и данные переживут
срок неограниченно.
Настройка `ttl_only_drop_parts = 1` удаляет только целые части данных —
файлы, которые ClickHouse создает при вставках и объединяет при слияниях.
Часть удаляется, когда срок истек у всех ее строк. Внутри партиции
дня загрузки возраст строк отличается не более чем на сутки.
Поэтому к трем суткам может добавиться до суток ожидания самой свежей
строки части, а затем ожидание фоновой обработки TTL. Точного времени
удаления правило не обещает.
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
возраста — но не более чем на сутки, потому что партицию закрывает календарный
день. Отсюда и оценка: сырые данные живут трое суток плюс хвост до суток, а не
ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику,
так что «уходит само» здесь обещано, а «уходит вовремя» — нет.
Модельная дата для TTL не подходит: события июня могут загружаться
осенью, и срок от `EventDate` истек бы сразу. Партиция по модельному дню
тоже смешала бы доставки разного возраста: очередная отправка дня могла бы
продлевать хранение старых строк после слияния частей.
Модельного дня среди колонок STG нет вовсе. Он свойство содержимого, а
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырые
данные режутся по своим координатам: и разбор полётов, и переобработка фильтруют
по `_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
D0 и к реальному календарю не привязана; пакетный режим проигрывает две недели
модельного мира за минуты реальных. Поэтому у переобработки и у гигиены диска
разные часы, и обслуживают их разные средства. Декларативный TTL по модельной
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
эталонные дни прямо на входе.
В бою сырой слой иногда собирают на движке `Null` — тогда он не хранится вовсе.
Такой вариант отвергнут: на стенде сырые данные нужны для отладки, поэтому окно,
а не ноль.
Для разбора сохраненных сообщений выбирайте небольшой срез по `_load_ts`.
Так запрос ограничивает партиции и сохраняет некорректные сообщения,
из которых модельную дату извлечь нельзя. `EventDate` появляется
отдельной колонкой только в ODS. В [задании ступени 6](../labs/6-1-event-parsing.md)
такой срез разбирается рядом с действующим приемом.
## Таблица ошибок
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что
у брака нет разобранных полей: шардируется `cityHash64` сырой строки —
`ClientID` у строки, которая не разобралась, взять неоткуда; нарезается по дню
загрузки, как и сырые данные; живёт месяц. Дольше сырых данных — намеренно: если
брак истекает вместе с ними, разбираться к моменту разбирательства будет уже
нечем. Снятие — целыми кусками (`ttl_only_drop_parts`), как у STG и по той же
причине: строки в партиции дня загрузки разного возраста не более чем на сутки,
и куску незачем переживать срок из-за самой свежей строки.
`ods.event_errors_dist` содержит сообщения, отвергнутые разбором событий:
исходный `raw`, сведения о доставке и класс ошибки `error_class`.
Класс имеет тип `LowCardinality(String)` и выбирается по первому
нарушенному условию:
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
качества не на что опереться. Сами классы, их порядок и довод, почему порядок
обязателен, — в [ADR 0005](../adr/0005-event-ingestion.md).
1. `not_an_object` — корень JSON не объект;
2. `keyset_mismatch` — набор ключей не совпал с форматом;
3. `key_field_unparsed` — одно из пяти опорных полей не разобралось.
Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по
чему, у него нет ключа сущности. `ORDER BY` — `(error_class, kafka_partition,
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам
доставки.
Порядок важен: строка вместо объекта нарушает сразу несколько условий,
но получает один класс. Полные условия — в
[ADR 0005](../adr/0005-event-ingestion.md).
`ods.order_errors` держит тот же диагностический минимум и `_load_id`
запуска. У заказов три класса по приоритету: `not_an_object`,
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не
разрастается до имени отдельного поля. Точная граница приёма — в
[спецификации заказов](orders/ingestion.md).
`ods.order_errors_dist` хранит такой же набор сведений и `_load_id`
запуска приема. Первые два класса совпадают с событиями,
третий называется `field_invalid`: у заказа проверяются все поля
верхнего уровня. Точные правила — в [описании приема заказов](orders/ingestion.md#граница-строгого-приёма).
Таблицы ошибок используют `ReplicatedMergeTree` без замены версий:
у некорректного сообщения может не быть разобранного ключа сущности.
Шард выбирается по `cityHash64(raw)`, ключ сортировки —
`(error_class, kafka_partition, kafka_offset)`, партиция — день `_load_ts`.
Начать поиск можно с класса ошибки, затем перейти к сообщениям
по координатам Kafka.
Срок хранения — один месяц от `_load_ts`, удаление целыми частями
с `ttl_only_drop_parts = 1`, как в STG. Ошибка вместе с исходным текстом
остается доступна дольше остальных сырых сообщений. Повторная обработка
может добавить тот же брак еще раз; у заказов повтор виден по совпадению
`_load_id` и координат Kafka.
## Справочники