# Хранилище: слои и конвенции Документ описывает сторону ClickHouse: как называются объекты, какие служебные колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами, и раздел «Что проверено» — чему в этом тексте верить и на каком основании. **Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов, keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/` лежит вся цепочка `Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное событие с таблицей ошибок и три матвью. Дальше по тексту устройство описано так, как оно проектируется; построенное от заложенного отличает карта таблиц в конце. Зона ответственности у документа одна — хранилище. Генератор описан отдельно: его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат события — в [описании выгрузки](../formats/clickstream-event.md). Хранилище строится по описанию выгрузки, как в бою строится по документации источника. Целевая картина всего стенда — [спека «Боевой реализм стенда (v2)»](../specs/2026-07-30-stand-v2-realism.md). ## Имена объектов Имя объекта заканчивается тем, что это за объект: | Суффикс | Что это | |---|---| | `_rep` | локальная таблица шарда, движок семейства `Replicated*` | | `_dist` | `Distributed` поверх одноимённой локальной | | `_kafka` | таблица на движке `Kafka` | | `_mv` | материализованное представление | | `_v` | обычное представление | Суффикс носит каждый физический объект. Голого имени у таблицы не существует: запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым именем в документах и разговоре понимается сущность, у которой этих объектов несколько. Единственное исключение — словари: у них воплощение одно, шардировать нечего, и суффикс ничего не различал бы. Распространённая конвенция, где голое имя означает локальную таблицу, а распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо возвращает данные одного шарда. Правило спеки «проверки и контрольные суммы — только по `Distributed`» такую тишину переживает плохо. Второе свойство — в списке по алфавиту объекты группируются по сущности, а не по технологии: все четыре объекта топика `hits` стоят рядом, потому что различаются хвостом, а не началом имени. Речь про дерево в клиенте и про `ORDER BY name`: порядок выдачи `SHOW TABLES` документация не оговаривает. Начало имени — сущность, и берётся она в разных слоях из разных мест. В STG имя приходит от транспорта: слой хранит то, что доехало по топику, и зовётся именем топика — `hits_raw` от `hits`, `orders_raw` от `orders`. В типизированных слоях имя приходит от предметной области и стоит в единственном числе: `event`, `order_snapshot`, `session`. Граница между «как привезли» и «что это такое» проходит по STG, и имена её показывают. ## Раскладка по шардам Пишем только в `_dist`. Локальные таблицы остаются для чтения и обслуживания — операций с партициями, ручной переобработки. Правило не про удобство: при записи через распределённую таблицу раскладку определяет ключ шардирования, то есть свойство данных, а при записи в локальную — то, какая нода случайно выполняла код. Отсюда урок стенда: какая нода читала топик, меняется между прогонами (видно в колонке `consumer_host`), а куда легли данные — нет. Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш выбран против перекоса: структурированный числовой идентификатор распределяется по остатку от деления неравномерно. У сырья выбора нет — строку иначе не разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один шард. Ключи у слоёв разные, и это имеет наблюдаемое следствие: сырая строка и разобранное из неё событие почти всегда оказываются на разных шардах. Пошардовые счётчики STG и ODS поэтому не сходятся и сходиться не должны — сверять слои можно только через `_dist`. Ключи ко-локации названы заранее, потому что на них стоит политика соединений из раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё прочее — через `GLOBAL`. Значит `dds.session` и `dds.identity_map` шардируются по `cityHash64(ClientID)`, а `dds.order` и производные от заказа — по `cityHash64(order_id)`. Ключи витрин появятся вместе с самими витринами. Открытый вопрос на будущее — не сама замена партиций: операции с ними по локальным таблицам правило разрешает прямо. Вопрос в шаге до неё. Партиция-донор должна быть уже разложена по шардам по тому же ключу, а разложить её можно только вставкой через распределённую таблицу — значит у каждой пакетной сущности появится вторая пара объектов, и имени для неё конвенция пока не даёт. Решать это вместе со сборкой DDS, а не задним числом. ## Служебные колонки Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок: `_topic`, `_partition`, `_offset`, `_timestamp` у Kafka, `_shard_num` у `Distributed` и прочие. Если положить на диск колонку с таким же именем, в матвью перестанет читаться, что дано движком, а что положено нами, — а это ровно то различие, ради которого метаданные доставки и хранятся. Поэтому они ложатся под именами `kafka_topic`, `kafka_partition`, `kafka_offset`, `kafka_timestamp`, рядом — `consumer_host`, имя читавшей ноды: виртуальные колонки его не несут, а после записи в `Distributed` он уже невосстановим. Типы у них такие: `kafka_topic` и `consumer_host` — `LowCardinality(String)`, значений мало и они повторяются; `kafka_partition` и `kafka_offset` — `UInt64`. С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и `_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена `Nullable(DateTime64(3))` и заполняется из `_timestamp_ms`: у брокера метка миллисекундная, соседняя `_load_ts` тоже `DateTime64(3)`, а слой сырья хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы либо падение приёма на первом сообщении, либо тихие нули за 1970 год. Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется на шарде-получателе, то есть назвал бы не ту ноду, которая читала топик, — колонка молча отвечала бы на другой вопрос. Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего не проверяет, поэтому там оказываются и целые события, и мусор. Разбирается всё это ниже, в матвью ODS — см. [ADR 0005](../adr/0005-event-ingestion.md). Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition, kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради которого он заведён: повтор доставки в сырье обязан быть виден. Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3)`. Ставится она один раз, в матвью приёма, и дальше переносится из STG в ODS как есть: колонка отвечает на вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же самое, отличается только метка, поэтому какая из двух строк переживёт мерж, безразлично. Пакетной переобработки у ODS нет: слой наполняет матвью, а не задание Airflow, и работа с партициями начинается выше. Переделать разобранное руками можно — вставкой из сырья с фильтром по `_load_ts`, в пределах трёхсуточного окна; ничья по версии разрешается в пользу вставленного позже. Имя согласовано с каноном служебных полей соседнего учебного стенда на Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у технических колонок — распространённая запись, её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит: запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание. Идентификатора пачки загрузки (`_load_id`) пока нет. В STG и ODS данные приезжают потоком через матвью, у которого нет ни батча, ни `run_id`, и колонка была бы пустой формальностью. В слоях, которые наполняет Airflow, `run_id` появится по-настоящему — тогда и заведём, тем же стилем имени. ## Путь реплицированных таблиц в keeper Шаблон — `/clickhouse/tables/{shard}/{database}/{table}`. База в пути обязательна: без неё одноимённые таблицы разных слоёв получат один и тот же узел в keeper и подерутся. Макрос `{uuid}` не используем, хотя он тоже развёл бы пути: он завязан на движок базы `Atomic` и делает путь нечитаемым, а на учебном стенде возможность открыть `system.zookeeper` и увидеть осмысленный путь — сама по себе половина урока про то, чем занят keeper. ## Приём: поток и его свойства Цепочка одна: чтец топика → матвью → сырьё STG → матвью разбора → событие и таблица ошибок ODS. Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы `clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт `ON CLUSTER` и макросов в имени не содержит. Разные группы дали бы каждой ноде полную копию топика, и это отдельная сцена для лабы, а не рабочий режим. Имя кластера в `ON CLUSTER` и в движке `Distributed` — `clickstream_cluster`, оно задано в `infra/clickhouse/config.d/cluster.xml`. **Источник матвью разбора — `stg.hits_raw_dist`, а не локальная таблица.** У матвью две привязки: источник, на вставку в который она срабатывает, и цель, куда пишет. Распределённая таблица — лицо слоя, локальная — его хранилище; потребитель слоя цепляется к лицу. Практически это значит, что разбор идёт на той же ноде, что читала Kafka, в момент вставки первой матвью — до раскладки по шардам. **Вставка фоновая, и окно потери мы принимаем.** Вставка в распределённую таблицу кладёт блок в локальный спул и сразу возвращает управление, а Kafka коммитит офсеты по факту работы матвью — то есть по факту записи в спул. Топик уже считает сообщение прочитанным, хотя на шарде его ещё нет: умри нода в этом промежутке — сообщения не перечитаются. Закрывает окно настройка `distributed_foreground_insert = 1`, и на ETL-вставках Airflow она стоит — там это обычный `SETTINGS` у запроса. На пути приёма её нет, и по трём причинам. Вставку выполняет фоновый поток Kafka-движка, своего запроса у него не бывает, так что настройка уровня запроса доехала бы только профилем пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда как при фоновом первая нода продолжает принимать и копит спул для соседа. Платится при этом не одно ожидание на блок, а три распределённые вставки — сырьё, событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а компромисс полезнее показать, чем спрятать за галочкой. Ещё одно место, где сырьё хранит не всё приехавшее: запись с пустым значением и запись-надгробие проходят молча, не оставляя строки. Свойство измерено и принято осознанно — подробности в разделе «Что проверено». **Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля противоположное: нода умерла после записи на шард, но до коммита офсетов, и при перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны. Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет `ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения. Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к сырому слою не относится. Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`). Удвоение сырья проходит мимо неё только потому, что при повторном чтении `_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на отсутствии механизма. **Матвью разбора две, и их условия обязаны делить поток без зазора и без нахлёста.** Одна забирает годные строки в `ods.event_dist`, вторая — брак в `ods.event_errors_dist`. Строка, подошедшая обеим, задвоится; не подошедшая ни одной — исчезнет молча. Держится это формой: второе условие пишется буквальным отрицанием первого, а сам предикат собирается только из функций, не возвращающих NULL, — иначе трёхзначная логика даст строку, которую не возьмёт ни `условие`, ни `NOT условие`. Те же функции не должны и бросать исключений: упавшая матвью роняет вставку и останавливает потребление до починки ([ADR 0005](../adr/0005-event-ingestion.md)). Постоянной сверки счётчиков при этом нет и не должно быть. У сырья срок жизни трое суток, а ODS хранит всё, поэтому равенство «сырьё = события + ошибки» разъедется само: сырьё истечёт раньше, а перезаливка модельного дня задвоит его, тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не смотреть на оповещения ([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым опытом при исполнении #43, на управляемой пачке: отправили N сообщений — получили N строк сырья и N в сумме событий и ошибок. Постоянной целью такой опыт не становится, и почему — в [карте проверок](testing.md), раздел «Интеграционная проверка постоянной целью не становится». Счёт по ODS идёт через `FINAL`: голый `count()` по `ReplacingMergeTree` зависит от того, сколько мержей успело пройти, и спека это прямо запрещает (раздел 6). ## Срок жизни сырья Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). В DDL значение проставлено явно, чтобы поведение не зависело от умолчания версии. Нарезать сырьё по модельному дню события было бы соблазнительно — он единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок снимается целиком, только когда в нём истекли все строки, а в партиции модельного дня лежит приехавшее в разное реальное время: мерж склеит куски разного возраста, самая свежая строка удержит весь кусок, и данные переживут срок неограниченно. В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного возраста — но не более чем на сутки, потому что партицию закрывает календарный день. Отсюда и оценка: сырьё живёт трое суток плюс хвост до суток, а не ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, так что «уходит само» здесь обещано, а «уходит вовремя» — нет. Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырьё режется своими координатами: и разбор полётов, и переобработка фильтруют по `_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается. Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в D0 и к реальному календарю не привязана; пакетный режим проигрывает две недели модельного мира за минуты реальных. Поэтому у переобработки и у гигиены диска разные часы, и обслуживают их разные средства. Декларативный TTL по модельной дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы эталонные дни прямо на входе. В бою слой сырья иногда собирают на движке `Null` — тогда он не хранится вовсе. Такой вариант отвергнут: на стенде сырьё нужно для отладки, поэтому окно, а не ноль. ## Таблица ошибок `ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым текстом, метаданными доставки и классом брака. Ключи её собственные, потому что у брака нет разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у строки, которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и сырьё; живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним, разбираться к моменту разбирательства будет уже нечем. Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине качества не на что опереться. Сами классы, их порядок и довод, почему порядок обязателен, — в [ADR 0005](../adr/0005-event-ingestion.md). Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по чему, у него нет ключа сущности. `ORDER BY` — `(error_class, kafka_partition, kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам доставки. ## Раскладка DDL Файлы лежат в `sql/ddl/` и применяются по порядку имён. Сначала все статичные объекты, потом матвью — тогда к моменту создания матвью её цель уже существует. | Файл | Что в нём | |---|---| | `00-databases.sql` | базы слоёв | | `10-stg-tables.sql` | Kafka-таблица, локальная и распределённая таблицы сырья | | `20-ods-tables.sql` | типизированное событие и таблица ошибок | | `30-ods-views.sql` | матвью разбора: сырьё в событие и в ошибки | | `40-stg-views.sql` | матвью приёма: чтец в сырьё | Порядок задают два правила. Первое: матвью принадлежит слою своей цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что наполняет ODS. Второе: матвью приёма создаётся последней из всех, и потому нарушает нумерацию слоёв. Kafka-движок начинает читать топик ровно тогда, когда к нему привязывают первую матвью; создай её раньше разбора — и всё, что доедет в зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на обычной отладке. Применение — двумя одноразовыми сервисами при `make up`, по образцу уже работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топик `hits` с двумя партициями, затем `clickhouse-init` дожидается его завершения и применяет файлы с ноды 1, `ON CLUSTER`. Этот порядок страхует от автосоздания топика с одной партицией. Переключателей тут два, и путать их не надо: брокер автосоздание разрешает, а потребитель librdkafka внутри ClickHouse его не просит — оба конца измерены, см. «Что проверено». То есть стенд держится на умолчании клиента, а урок «обе ноды читают топик» умирает тихо, поэтому топик и создаётся явно, до применения DDL. Образцы копируются не целиком, и в двух местах. `clickhouse-init` обязан ждать готовности **обеих** нод: `ON CLUSTER` ждёт исполнения на всех хостах и по таймауту бросает, а `airflow-init` ждёт только первую ноду, `superset-init` — только вторую. И второе: оба образца переживают `make up --wait` лишь потому, что от них зависят долгоживущие сервисы. Что делает `--wait` с одноразовым сервисом без зависимых, проверено при исполнении #37 на Docker Compose 2.40.3: считает его упавшим и возвращает единицу, хотя контейнер вышел с нулём. Поэтому зависимые есть и у новой пары: `clickhouse-init` ждёт `kafka-init`, а `airflow-init` — `clickhouse-init`, и цепочка упирается в долгоживущий Airflow. Побочная выгода важнее обхода `--wait`: к моменту старта Airflow DDL заведомо применён. Повторный `make up` поверх живого тома проходит зелёным: весь DDL идёт через `CREATE ... IF NOT EXISTS`. Оборотная сторона — изменённый объект тем же запуском не применяется, причём молча. Отдельного механизма для этого нет и не нужно: правка существующего DDL случается, только пока стенд пишут, а лекарство уже есть — `make clean && make up`. Мир регенерируется, сырьё живёт трое суток, терять нечего. ## Карта таблиц Ниже — то, что закладывает этап 2; всё перечисленное лежит в `sql/ddl/`. | Слой | Объект | Что это | |---|---|---| | STG | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` | | STG | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки | | STG | `stg.hits_raw_mv` | наполняет сырьё из чтеца | | ODS | `ods.event_rep` / `_dist` | типизированное широкое событие | | ODS | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём | | ODS | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки | Слои DDS и DM появляются на следующих этапах; их состав задан разделом 7 мастер-спеки и переносится сюда по мере постройки. ## Что проверено Документ на каждом шагу опирается на поведение ClickHouse, а местами и Kafka. Поэтому утверждения о них разведены на три группы: насколько фразе можно верить, должно быть видно из текста, а не зависеть от того, хорошо ли автор помнит документацию. Сверка с документацией — через MCP Context7, 5 августа 2026 года; то же разведение для механики приёма — в [ADR 0005](../adr/0005-event-ingestion.md). **Сверено с документацией.** Собственная колонка с именем виртуальной делает виртуальную недоступной. При вставке в `Distributed` шард выбирается по ключу шардирования; фоновый режим — умолчание, а `distributed_foreground_insert = 1` завершает вставку только после записи на все шарды. У семейства `Replicated*` есть дедупликация одинаковых блоков. Голый `count()` по `ReplacingMergeTree` зависит от того, сколько мержей прошло. `ttl_only_drop_parts` снимает кусок целиком и только когда истекли все строки в нём, а сам TTL исполняется на фоновых мержах. Kafka-движок начинает читать топик, когда к нему привязывают матвью, и одна группа потребителей на кластер спасает от дублей. Таблицы с одинаковым путём в keeper становятся репликами друг друга, а макрос `{uuid}` завязан на движок базы `Atomic`. `ON CLUSTER` ждёт все хосты и бросает по таймауту; `CREATE ... IF NOT EXISTS` на существующем объекте не бросает. **Проверено на стенде.** Опыты прогнаны на живом кластере: пять при исполнении #37 (четыре 5 августа 2026 года, пятый 6 августа) и два при исполнении #43 (7 августа). Все подтвердили то, что здесь написано. - Матвью с источником-`Distributed` срабатывает на вставку именно в эту распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят над `stg.hits_raw_dist`, а пишет в неё матвью приёма — и события доезжают до `ods.event`; значит блок она видит. В документации ClickHouse случая нет вовсе, до 7 августа утверждение держалось на опыте владельца. - Упавшая матвью роняет вставку и останавливает потребление до починки. Проверено сносом цели — распределённой `ods.event_dist` — при живом чтеце: за двадцать секунд (сброс блока идёт за 7,5) в сырьё не приехало ничего, а офсет группы застыл с отставанием в одно сообщение. Цель вернули — сообщение доехало само, без повторной отправки, отставание ушло в ноль, событие разобралось. Сносить надо именно распределённую таблицу: вставка в `Distributed` кладёт блок в спул и сразу возвращает управление, так что на сносе локальной ошибка всплыла бы фоном и утверждение показалось бы опровергнутым. - `RawBLOB` даёт ровно одну строку на каждое непустое сообщение. Три сообщения с ключами, поставленные в очередь до одного сброса продюсера, стали тремя строками с тремя разными офсетами: пачка продюсера границы сообщений не стирает. Запасной формат `LineAsString` не понадобился. Про границу «непустое» — сразу ниже. - Меток времени у Kafka-движка две, и разрядность у них разная: `_timestamp` — `Nullable(DateTime)`, `_timestamp_ms` — `Nullable(DateTime64(3))`. Отсюда форма `kafka_timestamp` в разделе о служебных колонках. - `DEFAULT hostName()`, объявленный только на локальной таблице, вычисляется на шарде-получателе. Строки, вставленные с ноды 1 и уехавшие на ноду 2, несут в этой колонке ноду 2, а в соседней, заполненной явным `hostName()` во вставляющем `SELECT`, — ноду 1. Довод за то, чтобы служебные колонки заполняла матвью выражением, держится. Отсюда же и невосстановимость: после записи в `Distributed` имя читавшей ноды взять больше неоткуда — своей колонкой оно не сохранено, а умолчание назовёт получателя. - `DROP PARTITION` и `REPLACE PARTITION` по `Distributed` не работают: обе операции отвечают кодом 48, «Table engine Distributed doesn't support partitioning», и оба шарда остаются нетронутыми. - Автосоздания топиков ClickHouse не просит, и переключателей здесь два. На брокере автосоздание разрешено: `auto.create.topics.enable=true`, и это умолчание образа, а не наша настройка. На клиенте — выключено: чтец с матвью, наведённые на несуществующий топик, ждали его с ошибкой «Broker: Unknown topic or partition», и топик не появился. Значит, от тихого топика с одной партицией стенд бережёт клиентское умолчание, а не брокер. Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись Kafka с пустым значением (ноль байт) и запись-надгробие (значение `null`) читаются, двигают офсет потребителя и не дают строки вовсе — ни в сырьё, ни в таблицу ошибок; приём при этом не останавливается. Проверено 5 августа 2026 года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, — в сырьё приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при чём: `LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на том же наборе даёт ровно те же две строки и тот же офсет. Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за случая, которого стенд не производит, — размен не в ту сторону. Знать о нём стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в самом сырье, `kafka_offset` лежит там колонкой, и дырка в офсетах — это он. Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS <локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно и на распределённой таблице, могло бы вычислиться до раскладки по шардам. То есть вывод опыта надёжен именно для умолчания, живущего только на локальной таблице. Это вычитано, а не измерено. На устройство приёма оговорка не влияет: служебные колонки мы заполняем выражением при любом ответе. **Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие матвью разбора, закрыты опытами при исполнении #43 и переехали выше.