From 8c76a7a0d8df6b824d428bfafcd8617dbdf72388 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 22 Aug 2026 04:02:52 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(ods):=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D1=8B=20=D1=80=D0=B0=D0=B7=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=81=D0=B2=D0=BE=D0=B5=D0=BC=D1=83=20=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D1=83=20=D1=88=D0=B0=D1=80=D0=B4=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - заводское parallel_distributed_insert_select = 2 исполняло вставку из Distributed в Distributed локально на каждом шарде, минуя ключ цели: 1189 из 1662 заказов задвоились на обоих шардах, а ReplacingMergeTree через границу шарда дубли не схлопывает. - Что: - в обе вставки sql/ods/order_parse.sql добавлено явное parallel_distributed_insert_select = 0 с доводом комментарием. - в docs/architecture/storage.md описан капкан «INSERT из Distributed в Distributed при несовпадающих ключах» и записана проверка (#137). - Проверка: - межшардовые дубли по ods.order_rep дают 0 (было 1189), каждый шард держит только свои ключи; uniqExact(order_id) не изменился. - после прожитого дага world_next_day новый слепок принят, дублей по-прежнему 0. - make smoke, make check-clickhouse. Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/storage.md | 23 +++++++++++++++++++++++ sql/ods/order_parse.sql | 18 ++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index d88b19c..afca243 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -84,6 +84,19 @@ CSV-файла. Живёт он вне цепочки слоёв, в зоне `d Пошардовые счётчики STG и ODS поэтому не сходятся и сходиться не должны — сверять слои можно только через `_dist`. +Разные ключи у слоёв ставят капкан на вставке `INSERT INTO цель_dist SELECT … +FROM источник_dist`. Заводское значение `parallel_distributed_insert_select` — +2: если обе таблицы на одном кластере, вставка исполняется локально на каждом +шарде, и шард пишет прямо в локальную таблицу цели, минуя её ключ шардирования. +Строки наследуют раскладку источника, и ошибки при этом нет: расхождение видно +только пересчётом по `hostName()` — сколько ключей встретилось больше чем на +одном шарде. На разборе заказов так и вышло: 1189 из 1662 заказов легли +одинаковыми строками на оба шарда, а `ReplacingMergeTree` через границу шарда их +не схлопывает. Поэтому у такой вставки в `SETTINGS` стоит явное +`parallel_distributed_insert_select = 0`: раскладку делает инициатор, по ключу +цели (`sql/ods/order_parse.sql`). Правило нужно и дальше — сборки DDS тоже будут +читать `_dist` и писать в `_dist` с другим ключом. + Ключи ко-локации названы заранее, потому что на них стоит политика соединений из раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё прочее — через `GLOBAL`. Значит `dds.session` и `dds.identity_map` шардируются по @@ -573,6 +586,16 @@ DDL-словарь с файловым источником внутри `user_f разобрано, почему отказ в праве на движок называет не то право, которого не хватает. +**Проверка ко-локации заказов 22 августа 2026 года (#137).** Context7 и +`system.settings` закреплённого ClickHouse 26.3 назвали одну и ту же тройку +значений `parallel_distributed_insert_select`: `0` — выключено, `1` — `SELECT` +на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`. +На живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов +лежали на обоих шардах, после явного нуля и повторного разбора того же +`_load_id` — ни одного, каждый шард держит только свои ключи. Вершина версий +уцелела: деловые поля `ods.order_v` сошлись с эталоном, посчитанным прямо из +сырья. + **Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил, что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов в предикате приёма заказов. Остальное снято на закреплённом ClickHouse 26.3. diff --git a/sql/ods/order_parse.sql b/sql/ods/order_parse.sql index 0600773..e54c9e6 100644 --- a/sql/ods/order_parse.sql +++ b/sql/ods/order_parse.sql @@ -92,7 +92,16 @@ SELECT _load_ts FROM stg.orders_raw_dist WHERE _load_id = {load_id:String} AND row_is_valid -SETTINGS distributed_foreground_insert = 1; +-- Заводское значение parallel_distributed_insert_select — 2: вставку вида +-- INSERT ... SELECT из Distributed в Distributed на одном кластере ClickHouse +-- исполняет локально на каждом шарде, и строки минуют ключ шардирования цели. +-- Ключи здесь разные — сырьё разложено по хешу строки, заказы по +-- cityHash64(order_id), — так версии одного заказа и расползлись по обоим +-- шардам, где ReplacingMergeTree их уже не схлопывает. Ноль возвращает вставку +-- инициатору: он читает срез со всех шардов и пишет через ods.order_dist, +-- то есть по её ключу. +SETTINGS distributed_foreground_insert = 1, + parallel_distributed_insert_select = 0; -- Брак: тот же срез и буквальное отрицание того же предиката. -- @@ -118,4 +127,9 @@ SELECT _load_ts FROM stg.orders_raw_dist WHERE _load_id = {load_id:String} AND NOT row_is_valid -SETTINGS distributed_foreground_insert = 1; +-- Тот же ноль, хотя здесь заводское значение раскладку не ломало: ключ у +-- ods.order_errors_dist — cityHash64(raw), тот же, что у сырья, и локальная +-- запись попадала бы в свой шард сама. Совпадение ключей не гарантия: +-- поменяется ключ любой из двух таблиц — и строки разъедутся молча. +SETTINGS distributed_foreground_insert = 1, + parallel_distributed_insert_select = 0; -- 2.54.0 From 94a1ffeee26596d1316ca45456fb4c57258d7948 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 22 Aug 2026 04:28:51 -0400 Subject: [PATCH 2/3] =?UTF-8?q?docs(ods):=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D0=B5=D0=BC=D0=B0=D0=BD=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20snapshot=5Fdate=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D1=81=D1=85=D0=BB=D0=BE=D0=BF=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - проверка #137 показала: у заказа, не менявшегося несколько дней, версию выбирает только updated_at, и какая из одинаковых строк переживёт схлопывание — не обещано; snapshot_date выжившей строки означал не то, что обещала документация (у 86 заказов стартового мира выжил серединный день наблюдения). - Что: - в docs/architecture/orders/ingestion.md записано: snapshot_date, _load_id и _load_ts выжившей строки — координаты одного из наблюдений версии, не обязательно последнего; допущение «уточнение поднимает updated_at» названо допущением с границей применимости. - та же оговорка — в комментарии DDL ods.order_rep. - Проверка: - замеры на живом стенде: во всех 3034 парах строк с равным updated_at деловые поля совпали побайтово; монотонность updated_at по слепкам — без нарушений; ods.order_v сошёлся с эталоном argMax из сырья построчно. Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/orders/ingestion.md | 19 +++++++++++++++++++ sql/ddl/20-ods-tables.sql | 5 ++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/architecture/orders/ingestion.md b/docs/architecture/orders/ingestion.md index 321d8a5..1a612e3 100644 --- a/docs/architecture/orders/ingestion.md +++ b/docs/architecture/orders/ingestion.md @@ -106,6 +106,25 @@ JSON-объектом с точным набором ключей: `order_id`, ` - `_load_id` — какой запуск Airflow принял строку; - `_load_ts` — когда строка приехала в хранилище. +У выжившей после схлопывания строки три последние координаты означают меньше, +чем кажется. Версию выбирает только `updated_at`; заказ, не менявшийся +несколько дней, приезжает в нескольких слепках одинаковыми деловыми полями, и +какая из этих строк переживёт схлопывание — не обещано. Поэтому её +`snapshot_date` — *один из* дней, когда источник показывал эту версию, не +обязательно последний; то же с `_load_id` и `_load_ts`. Замер 22 августа +2026 года (#137): у 86 заказов стартового мира выжил даже не крайний день, а +серединный. Вопрос «когда заказ наблюдался в последний раз» задаётся сырью STG, +пока его не съел срок жизни, — а не одной строке ODS. + +Вся схема версий стоит на одном свойстве источника: уточнение заказа поднимает +`updated_at`. Наш генератор моделирует источник с триггерной меткой и это +свойство гарантирует; замеры 22 августа 2026 года его подтвердили — во всех +парах строк с равным `updated_at` деловые поля совпали побайтово, а по слепкам +метка не убывала ни разу. Это допущение, а не закон природы: источник, умеющий +уточнить строку тихо — правка мимо приложения, миграция, ручной `UPDATE`, — +эту схему ломает, и тогда версии по метке времени уже не хватает, нужна сверка +самих полей (в терминах dbt snapshots — стратегия `check` вместо `timestamp`). + Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории. `ods.order_v` сохраняет те же источник-ориентированные поля без обогащения diff --git a/sql/ddl/20-ods-tables.sql b/sql/ddl/20-ods-tables.sql index 3e56a52..35070c4 100644 --- a/sql/ddl/20-ods-tables.sql +++ b/sql/ddl/20-ods-tables.sql @@ -163,7 +163,10 @@ ENGINE = Distributed('clickstream_cluster', 'ods', 'event_errors_rep', cityHash6 -- updated_at — какая бизнес-версия новее; snapshot_date — в слепке какого -- модельного дня источник показал строку; _load_id — какой запуск Airflow её -- принял; _load_ts — когда она приехала в хранилище. Ключом сущности не --- становится ни одна из трёх последних: они про наблюдение и загрузку. +-- становится ни одна из трёх последних: они про наблюдение и загрузку. Версию +-- выбирает только updated_at, поэтому у выжившей после схлопывания строки +-- snapshot_date — один из дней, когда источник показывал эту версию, не +-- обязательно последний (docs/architecture/orders/ingestion.md). -- -- PARTITION BY toDate(created_at) — по дню создания строки в источнике. Он -- неизменен у всех версий заказа, поэтому версии лежат в одной партиции и -- 2.54.0 From 45360b6bcf422b65e6a63f374f197b317c965f64 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 22 Aug 2026 04:35:41 -0400 Subject: [PATCH 3/3] =?UTF-8?q?docs(ods):=20=D0=B2=D1=8B=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B8=20=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=87=D0=B8=D0=BD=D0=BA=D0=B8=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - правки #137 дважды рассказывали одно и то же: механика капкана жила и в storage.md, и у вставок, а цифры замеров — и в прозе, и в леджере; у читателя это спрашивает больше внимания, чем даёт. - Что: - комментарии у вставок order_parse.sql сжаты до довода с указателем на storage.md; из капкана в storage.md цифры ушли в «Что проверено»; в ingestion.md замеры перенесены из прозы в свой раздел «Что проверено». - Проверка: - рендер order_parse.sql по-прежнему делится ровно на два запроса (из комментария убрана точка с запятой, по которой оператор делит файл). Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/orders/ingestion.md | 36 ++++++++++++++------------- docs/architecture/storage.md | 22 ++++++++-------- sql/ods/order_parse.sql | 19 ++++++-------- 3 files changed, 36 insertions(+), 41 deletions(-) diff --git a/docs/architecture/orders/ingestion.md b/docs/architecture/orders/ingestion.md index 1a612e3..39b9e5a 100644 --- a/docs/architecture/orders/ingestion.md +++ b/docs/architecture/orders/ingestion.md @@ -106,24 +106,19 @@ JSON-объектом с точным набором ключей: `order_id`, ` - `_load_id` — какой запуск Airflow принял строку; - `_load_ts` — когда строка приехала в хранилище. -У выжившей после схлопывания строки три последние координаты означают меньше, -чем кажется. Версию выбирает только `updated_at`; заказ, не менявшийся -несколько дней, приезжает в нескольких слепках одинаковыми деловыми полями, и -какая из этих строк переживёт схлопывание — не обещано. Поэтому её -`snapshot_date` — *один из* дней, когда источник показывал эту версию, не -обязательно последний; то же с `_load_id` и `_load_ts`. Замер 22 августа -2026 года (#137): у 86 заказов стартового мира выжил даже не крайний день, а -серединный. Вопрос «когда заказ наблюдался в последний раз» задаётся сырью STG, -пока его не съел срок жизни, — а не одной строке ODS. +Версию выбирает только `updated_at`. Заказ, не менявшийся несколько дней, +приезжает в нескольких слепках одинаковыми деловыми полями, и какая из этих +строк переживёт схлопывание — не обещано. Поэтому `snapshot_date` выжившей +строки — один из дней, когда источник показывал эту версию, не обязательно +последний; то же с `_load_id` и `_load_ts`. Вопрос «когда заказ наблюдался в +последний раз» задаётся сырью STG, пока то живо, — а не одной строке ODS. -Вся схема версий стоит на одном свойстве источника: уточнение заказа поднимает -`updated_at`. Наш генератор моделирует источник с триггерной меткой и это -свойство гарантирует; замеры 22 августа 2026 года его подтвердили — во всех -парах строк с равным `updated_at` деловые поля совпали побайтово, а по слепкам -метка не убывала ни разу. Это допущение, а не закон природы: источник, умеющий -уточнить строку тихо — правка мимо приложения, миграция, ручной `UPDATE`, — -эту схему ломает, и тогда версии по метке времени уже не хватает, нужна сверка -самих полей (в терминах dbt snapshots — стратегия `check` вместо `timestamp`). +Сама схема версий стоит на допущении об источнике: уточнение заказа поднимает +`updated_at`. Генератор моделирует источник с триггерной меткой и допущение +выполняет (замер — «Что проверено»); источник, умеющий уточнить строку тихо — +ручным `UPDATE` мимо приложения, миграцией, — ломает его, и тогда версии по +метке времени мало: нужна сверка самих полей, в терминах dbt snapshots — +стратегия `check` вместо `timestamp`. Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории. @@ -205,6 +200,13 @@ JSON-объектом с точным набором ключей: `order_id`, ` `system.query_log` на первой ноде осталось ровно по одному завершённому запросу пользователя `etl` для забора в STG, записи годных строк и записи брака. +Допущение и произвол ярлыков замерены 22 августа 2026 года при исполнении +#137: во всех 3034 парах строк с равным `updated_at` деловые поля совпали +побайтово, по слепкам метка ни разу не убывала, а `ods.order_v` сошёлся +построчно с эталоном `argMax` по `updated_at`, посчитанным прямо из сырья. +Произвол ярлыков виден въявь: у 86 заказов стартового мира выжила строка +серединного слепка — не первого и не последнего. + Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении #93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны, офсеты коммитятся, а сбой забора не двигает позицию мира. Числа — [ADR diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index afca243..1ab42e9 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -87,15 +87,14 @@ CSV-файла. Живёт он вне цепочки слоёв, в зоне `d Разные ключи у слоёв ставят капкан на вставке `INSERT INTO цель_dist SELECT … FROM источник_dist`. Заводское значение `parallel_distributed_insert_select` — 2: если обе таблицы на одном кластере, вставка исполняется локально на каждом -шарде, и шард пишет прямо в локальную таблицу цели, минуя её ключ шардирования. -Строки наследуют раскладку источника, и ошибки при этом нет: расхождение видно -только пересчётом по `hostName()` — сколько ключей встретилось больше чем на -одном шарде. На разборе заказов так и вышло: 1189 из 1662 заказов легли -одинаковыми строками на оба шарда, а `ReplacingMergeTree` через границу шарда их -не схлопывает. Поэтому у такой вставки в `SETTINGS` стоит явное -`parallel_distributed_insert_select = 0`: раскладку делает инициатор, по ключу -цели (`sql/ods/order_parse.sql`). Правило нужно и дальше — сборки DDS тоже будут -читать `_dist` и писать в `_dist` с другим ключом. +шарде, минуя ключ шардирования цели. Строки наследуют раскладку источника, и +ошибки при этом нет — расхождение видно только пересчётом ключей по +`hostName()`. Так половина заказов и легла на оба шарда, где +`ReplacingMergeTree` через границу шарда дубли не схлопывает (замер — «Что +проверено», #137). Лекарство — явное `parallel_distributed_insert_select = 0` +в `SETTINGS` такой вставки: раскладку делает инициатор, по ключу цели +(`sql/ods/order_parse.sql`). Капкан ждёт и сборки DDS: они тоже будут читать +`_dist` и писать в `_dist` с другим ключом. Ключи ко-локации названы заранее, потому что на них стоит политика соединений из раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё @@ -592,9 +591,8 @@ DDL-словарь с файловым источником внутри `user_f на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`. На живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов лежали на обоих шардах, после явного нуля и повторного разбора того же -`_load_id` — ни одного, каждый шард держит только свои ключи. Вершина версий -уцелела: деловые поля `ods.order_v` сошлись с эталоном, посчитанным прямо из -сырья. +`_load_id` — ни одного. Вершина версий уцелела: деловые поля `ods.order_v` +сошлись с эталоном, посчитанным прямо из сырья. **Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил, что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов diff --git a/sql/ods/order_parse.sql b/sql/ods/order_parse.sql index e54c9e6..2b757f1 100644 --- a/sql/ods/order_parse.sql +++ b/sql/ods/order_parse.sql @@ -92,14 +92,11 @@ SELECT _load_ts FROM stg.orders_raw_dist WHERE _load_id = {load_id:String} AND row_is_valid --- Заводское значение parallel_distributed_insert_select — 2: вставку вида --- INSERT ... SELECT из Distributed в Distributed на одном кластере ClickHouse --- исполняет локально на каждом шарде, и строки минуют ключ шардирования цели. --- Ключи здесь разные — сырьё разложено по хешу строки, заказы по --- cityHash64(order_id), — так версии одного заказа и расползлись по обоим --- шардам, где ReplacingMergeTree их уже не схлопывает. Ноль возвращает вставку --- инициатору: он читает срез со всех шардов и пишет через ods.order_dist, --- то есть по её ключу. +-- Заводская двойка parallel_distributed_insert_select исполняет вставку из +-- Distributed в Distributed локально на каждом шарде, минуя ключ шардирования +-- цели, — так заказы и расползлись по обоим шардам. Ноль отдаёт раскладку +-- инициатору, по ключу цели. Капкан целиком — docs/architecture/storage.md, +-- «Раскладка по шардам». SETTINGS distributed_foreground_insert = 1, parallel_distributed_insert_select = 0; @@ -127,9 +124,7 @@ SELECT _load_ts FROM stg.orders_raw_dist WHERE _load_id = {load_id:String} AND NOT row_is_valid --- Тот же ноль, хотя здесь заводское значение раскладку не ломало: ключ у --- ods.order_errors_dist — cityHash64(raw), тот же, что у сырья, и локальная --- запись попадала бы в свой шард сама. Совпадение ключей не гарантия: --- поменяется ключ любой из двух таблиц — и строки разъедутся молча. +-- Тот же ноль: ключ брака сегодня совпадает с ключом сырья, и локальная +-- запись легла бы верно — но по совпадению, а не по контракту. SETTINGS distributed_foreground_insert = 1, parallel_distributed_insert_select = 0; -- 2.54.0