docs(storage): связность восстановлена, форма дат на проводе задана

- Зачем:
  - холодное ревью связности нашло девять мест, где вставленный текст спорит с
    соседним; отдельно вскрылось, что представление дат в JSON не зафиксировано
    нигде, а #43 обязан его знать раньше, чем #41 напишет сериализатор.
- Что:
  - гарантия приёма переписана: после снятия синхронной вставки «хотя бы один
    раз» стало неправдой — есть и окно потери, и окно дубля.
  - критерий выбора пяти опорных колонок приведён к списку, который он
    порождает; `CounterID` оговорён отдельно.
  - «переобработки у ODS нет вовсе» смягчено до пакетной: ручная вставка из
    сырья в пределах окна возможна.
  - в спеку генератора добавлена форма дат на проводе — ISO-8601, с доводом от
    читаемости слоя сырья.
  - убраны осиротевшая фраза про порядок сервисов, дубль порядка классов брака,
    устаревшая датировка сверки и ещё три следа вставок.
- Проверка:
  - make config-test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 21:53:03 +03:00
co-authored by Claude Opus 5
parent f1254ce79d
commit 923ebad80e
4 changed files with 65 additions and 31 deletions
+29 -22
View File
@@ -2,7 +2,8 @@
Документ описывает сторону ClickHouse: как называются объекты, какие служебные
колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из
каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами.
каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами, и
раздел «Что проверено» — чему в этом тексте верить и на каком основании.
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
keeper, Kafka, каркас сервисов. Объекты хранилища и механизм применения DDL
@@ -79,11 +80,12 @@ keeper, Kafka, каркас сервисов. Объекты хранилища
`cityHash64(ClientID)`, а `dds.order` и производные от заказа — по
`cityHash64(order_id)`. Ключи витрин появятся вместе с самими витринами.
Известное ограничение правила «пишем только в `_dist`»: пакетные слои собираются
заменой дневных партиций, а `DROP/REPLACE PARTITION` работает только по локальным
таблицам. Чем и как раскладывать партицию-донор по шардам до замены, здесь не
решено — вопрос встаёт вместе со сборкой DDS, и решать его нужно тогда, а не
задним числом.
Открытый вопрос на будущее — не сама замена партиций: операции с ними по
локальным таблицам правило разрешает прямо. Вопрос в шаге до неё. Партиция-донор
должна быть уже разложена по шардам по тому же ключу, а разложить её можно
только вставкой через распределённую таблицу — значит у каждой пакетной сущности
появится вторая пара объектов, и имени для неё конвенция пока не даёт. Решать
это вместе со сборкой DDS, а не задним числом.
## Служебные колонки
@@ -104,8 +106,8 @@ keeper, Kafka, каркас сервисов. Объекты хранилища
(раздел 11 спеки) — записать её в необнуляемый тип значит либо уронить приём на
первом сообщении, либо получить тихие нули за 1970 год.
Заполняются обе группы колонок выражением в `SELECT` матвью приёма, а не
`DEFAULT` в таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()`
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()`
сработал бы на шарде-получателе и назвал бы не ту ноду, которая читала топик, —
то есть колонка молча отвечала бы на другой вопрос.
@@ -124,8 +126,10 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у
этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же
самое, отличается только метка, поэтому какая из двух строк переживёт мерж,
безразлично. Переобработки как стадии у ODS нет вовсе: слой наполняет матвью, а
не пакетное задание, и пакетная работа с партициями начинается выше.
безразлично. Пакетной переобработки у ODS нет: слой наполняет матвью, а не
задание Airflow, и работа с партициями начинается выше. Переделать разобранное
руками можно — вставкой из сырья с фильтром по `_load_ts`, в пределах
трёхсуточного окна; ничья по версии разрешается в пользу вставленного позже.
Имя согласовано с каноном служебных полей соседнего учебного стенда на
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
@@ -185,8 +189,13 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
компромисс полезнее показать, чем спрятать за галочкой.
**Гарантия — «хотя бы один раз», не транзакция.** Падение после записи на шард,
но до коммита офсетов даёт повтор при перечитывании. В ODS повтор схлопнет
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при
перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня
честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения.
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к
@@ -267,11 +276,8 @@ D0 и к реальному календарю не привязана; паке
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
качества не на что опереться. Сами классы перечислены в
[ADR 0005](../adr/0005-event-ingestion.md) и проверяются по порядку, потому что
пересекаются: сообщение, не являющееся объектом JSON, проваливает заодно и сверку
набора ключей — `JSONExtractKeys` от скаляра даёт пустой массив. Побеждает первый
совпавший класс, тем же приёмом, что `mismatch_class` в витрине сверки.
качества не на что опереться. Сами классы, их порядок и довод, почему порядок
обязателен, — в [ADR 0005](../adr/0005-event-ingestion.md).
Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по
чему, у него нет ключа сущности. `ORDER BY``(error_class, kafka_partition,
@@ -304,7 +310,11 @@ ODS. Второе: матвью приёма создаётся последне
Применение — двумя одноразовыми сервисами при `make up`, по образцу уже
работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топик
`hits` с двумя партициями, затем `clickhouse-init` дожидается его завершения и
применяет файлы с ноды 1, `ON CLUSTER`.
применяет файлы с ноды 1, `ON CLUSTER`. Этот порядок страхует от автосоздания
топика брокером с одной партицией: у потребителя librdkafka разрешение на
автосоздание по умолчанию выключено, так что случиться это не обязано, но урок
«обе ноды читают топик» умирает тихо, и полагаться на умолчание клиента здесь
не стоит.
Образцы копируются не целиком, и в двух местах. `clickhouse-init` обязан ждать
готовности **обеих** нод: `ON CLUSTER` ждёт исполнения на всех хостах и по
@@ -312,10 +322,7 @@ ODS. Второе: матвью приёма создаётся последне
только вторую. И второе: оба образца переживают `make up --wait` лишь потому, что
от них зависят долгоживущие сервисы; у пары `kafka-init` / `clickhouse-init`
таких зависимых нет, и как поведёт себя `--wait` с одноразовым сервисом без них —
проверяется при исполнении #37. Порядок страхует от автосоздания топика
брокером с одной партицией: у потребителя librdkafka разрешение на автосоздание
по умолчанию выключено, так что случиться это не обязано, но урок «обе ноды
читают топик» умирает тихо, и полагаться на умолчание клиента здесь не стоит.
проверяется при исполнении #37.
Повторный `make up` поверх живого тома проходит зелёным: весь DDL идёт через
`CREATE ... IF NOT EXISTS`. Оборотная сторона — изменённый объект тем же