diff --git a/docs/adr/0005-event-ingestion.md b/docs/adr/0005-event-ingestion.md index 2959f83..a45ee71 100644 --- a/docs/adr/0005-event-ingestion.md +++ b/docs/adr/0005-event-ingestion.md @@ -41,8 +41,11 @@ JSON. цены и пользы: единственный производитель топика — собственный генератор, сериализующий из контракта по объявленным типам, поэтому неверный тип может прийти только из руки, а сорок семь проверок на NULL превратили бы матвью в -простыню. Пять выбраны по последствию: на них стоят ключ сортировки, партиция и -дедупликация, и их порча отравляет всё ниже по течению. +простыню. Пять выбраны по последствию: это идентификаторы события, визита и +посетителя, дата партиции и метка времени, по которой события упорядочиваются +внутри сессии, — порча любой отравляет всё ниже по течению. `CounterID` +формально тоже входит в ключ сортировки, но на стенде он константа, и NULL там +взяться неоткуда. Присутствие иначе и не проверить. `Nullable` в ClickHouse не оборачивает составные типы: `Nullable(Array)` запрещён, а @@ -133,7 +136,9 @@ contract-тест из #43 сюда не дотягивается — он ср ## Что проверено -По документации ClickHouse через MCP Context7, 3 августа 2026 года. +По документации ClickHouse через MCP Context7: основная сверка — 3 августа +2026 года, перепроверка после правок — 5 августа. Датировка важна: 5 августа +утверждение про `Nullable(Tuple)` развернулось на противоположное. При режиме `stream` движок отдаёт `_raw_message` и `_error` только для сообщений, которые не разобрались, и оставляет их пустыми для разобранных. @@ -159,9 +164,8 @@ contract-тест из #43 сюда не дотягивается — он ср распределённую таблицу — блок она видит до разрезания по шардам. Проверено владельцем на рабочих проектах; на стенде подтверждается заодно с приёмкой #37. -На живом стенде проверяется при исполнении #37. Первые два пункта внесены в -раздел 11 спеки как несущие; остальные — однострочные `SELECT`, их довольно -прогнать заодно: +На живом стенде проверяется при исполнении #37. Первые два внесены в раздел 11 +спеки; остальные — однострочные `SELECT`, их довольно прогнать заодно: - `RawBLOB` в Kafka-движке даёт ровно одну строку на сообщение. Проверять это нужно первым и до написания DDL: формулировка «читает вход в одно значение» diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index 47847eb..436943d 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -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`. Оборотная сторона — изменённый объект тем же diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index d725223..e5c3e5c 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -315,8 +315,11 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate - **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`, `EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей сообщения сверяется с контрактным; строка с NULL среди опорных колонок - или с разошедшимся набором ключей уходит в `*_errors`. Опорными выбраны те, на - которых стоят ключ сортировки, партиция и дедупликация; остальные сорок две + или с разошедшимся набором ключей уходит в `*_errors`. Опорными выбраны те, + чья порча отравляет всё ниже по течению: идентификаторы события, визита и + посетителя, дата партиции и метка времени, по которой события упорядочиваются + внутри сессии. `CounterID` формально тоже в ключе сортировки, но на стенде он + константа, и NULL там взяться неоткуда. Остальные сорок две достаются обычными типами — сорок семь проверок на NULL превратили бы матвью в простыню, а присутствие и так целиком закрыто сверкой ключей. Сверка ключей — не добавка: у массивов NULL не бывает, и пропавшее поле-массив иначе @@ -379,7 +382,7 @@ README. | Слой | Объект | Что это | |---|---|---| | Kafka | `hits`, `orders` | два топика, по 2 партиции | -| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV; для orders — развилка этапа 3, см. раздел 12 | сырые строки, Kafka Engine на обеих нодах | +| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV; для orders — развилка этапа 3, не решена (ниже) | сырые строки, Kafka Engine на обеих нодах | | ODS | `ods.event` (+`_errors`) | типизированное широкое событие, ReplacingMergeTree | | ODS | `ods.order_snapshot` (+`_errors`) | слепки заказов как приехали, партиция по `snapshot_date`, без дедупа | | DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) | @@ -393,6 +396,15 @@ README. суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья — см. [доку хранилища](../architecture/storage.md). +Как принимаются заказы — развилка этапа 3, и она не решена. Событиям выбран +приём сырья байтами с разбором функциями ([ADR 0005](../adr/0005-event-ingestion.md)); +заказам этот же способ идёт только вместе с ответом на вопрос, нужен ли им слой +сырья вообще — у них слепок, а не поток. Нужен — и типизированный чтец даст двух +чтецов на один топик, а такую схему ADR 0005 отверг; не нужен — и слои +перестают быть единообразными. Разбирать грилингом, когда дойдём до заказов; +как учебное сравнение двух способов приёма это записано и в опорных точках +раздела 12. + Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия: `ods.event` и `ods.order_snapshot` получают метку загрузки `_load_ts`, и у `ods.event` она же служит колонкой версии ReplacingMergeTree; а таблицы diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 0528318..1268582 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -261,6 +261,17 @@ ([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких событий в одно сообщение сломала бы разбор целиком. Сторожится тестом приёмника. +- **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`, + `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырья: весь + смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте и + разбирает событие глазами, а число эпохи этот урок убивает. Разбору это + ничего не стоит: `JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)')` + принимает ISO без плясок. Колонка `ecommerce` — строка, внутри которой лежит + экранированный JSON, как отдаёт Метрика. + + Форму пинит хранилище (#43) как первый потребитель, сериализатор (#41) + её соблюдает. Порядок тикетов обратный порядку зависимости, поэтому здесь она + и записана — иначе каждый выберет своё, и разойдётся это уже после приёмки. - **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз, numpy-массивы и datetime сериализует нативно (заметка исследования #31). Смена библиотеки меняет канонические байты, поэтому проходит как