feat(ods): версией заказа стала дата слепка, окно DDS — из данных #167
No files matched your search
@@ -48,16 +48,6 @@ x-kafka-target: &kafka-target
|
|||||||
|
|
||||||
x-world-starting-days: &world-starting-days "8"
|
x-world-starting-days: &world-starting-days "8"
|
||||||
|
|
||||||
# Окно изменяемости заказа: слепок бэкенда несёт заказы последних K дней, за
|
|
||||||
# окном заказ замерзает. Число принадлежит модельному миру, и владелец у него
|
|
||||||
# один — generator/world.py (ORDER_WINDOW_DAYS): сделать окно настройкой
|
|
||||||
# генератора значило бы разрешить менять физику мира снаружи, а опись мира
|
|
||||||
# держится на её неизменности. Стенду копия нужна затем, что по этому же числу
|
|
||||||
# трансформация DDS решает, какие дни пересобирать. Разойдутся — пересборка
|
|
||||||
# промахнётся по краю окна. Сверять копии некому, и это осознанно: слепые пятна
|
|
||||||
# конвейера собраны в docs/architecture/etl.md и адресованы контуру DQ.
|
|
||||||
x-order-window-days: &order-window-days "7"
|
|
||||||
|
|
||||||
x-airflow-common: &airflow-common
|
x-airflow-common: &airflow-common
|
||||||
image: clickstream-airflow:local
|
image: clickstream-airflow:local
|
||||||
build:
|
build:
|
||||||
@@ -87,7 +77,6 @@ x-airflow-common: &airflow-common
|
|||||||
STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default
|
STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default
|
||||||
WORLD_STARTING_DAYS: *world-starting-days
|
WORLD_STARTING_DAYS: *world-starting-days
|
||||||
KAFKA_ORDERS_TOPIC: *orders-topic
|
KAFKA_ORDERS_TOPIC: *orders-topic
|
||||||
ORDER_WINDOW_DAYS: *order-window-days
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./dags:/opt/airflow/dags:ro
|
- ./dags:/opt/airflow/dags:ro
|
||||||
# Запросы дагов: даг называет файл, а обработчик DAG при разборе загружает
|
# Запросы дагов: даг называет файл, а обработчик DAG при разборе загружает
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from airflow.exceptions import AirflowException
|
from airflow.exceptions import AirflowException
|
||||||
@@ -36,11 +35,6 @@ START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
|||||||
SQL_ROOT = Path("/opt/airflow/sql")
|
SQL_ROOT = Path("/opt/airflow/sql")
|
||||||
CLICKHOUSE_CONNECTION = "clickhouse_default"
|
CLICKHOUSE_CONNECTION = "clickhouse_default"
|
||||||
|
|
||||||
# Окно изменяемости заказа, модельные дни: по нему трансформация решает, какие
|
|
||||||
# дни ещё могут измениться. Число принадлежит миру, владелец — генератор;
|
|
||||||
# почему у стенда лежит копия и чем она рискует — compose.yaml.
|
|
||||||
ORDER_WINDOW_DAYS = int(os.environ["ORDER_WINDOW_DAYS"])
|
|
||||||
|
|
||||||
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставит
|
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставит
|
||||||
# пульт мира, и только она отличает прожитый день от живого — зачем это
|
# пульт мира, и только она отличает прожитый день от живого — зачем это
|
||||||
# сессиям, разобрано в sql/dds/session_scope.sql.
|
# сессиям, разобрано в sql/dds/session_scope.sql.
|
||||||
@@ -125,7 +119,7 @@ def dds_transform():
|
|||||||
"""Спросить у хранилища, какие дни собирает этот запуск."""
|
"""Спросить у хранилища, какие дни собирает этот запуск."""
|
||||||
query = (SQL_ROOT / "dds" / "order_scope.sql").read_text(encoding="utf-8")
|
query = (SQL_ROOT / "dds" / "order_scope.sql").read_text(encoding="utf-8")
|
||||||
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
|
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
|
||||||
row = hook.get_first(query, parameters={"window_days": ORDER_WINDOW_DAYS})
|
row = hook.get_first(query)
|
||||||
# Пустой ответ означает пустой источник: собирать нечего, и
|
# Пустой ответ означает пустой источник: собирать нечего, и
|
||||||
# зеленеть на этом нельзя — прогон встаёт с внятной причиной.
|
# зеленеть на этом нельзя — прогон встаёт с внятной причиной.
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|||||||
@@ -66,15 +66,14 @@ ODS: это единственный устойчивый ключ сущнос
|
|||||||
собран этот день».
|
собран этот день».
|
||||||
|
|
||||||
Ярлыки ODS — `snapshot_date`, `_load_id` приёма, `_load_ts` прибытия — в
|
Ярлыки ODS — `snapshot_date`, `_load_id` приёма, `_load_ts` прибытия — в
|
||||||
модель не переезжают. Версию заказа они не задают, а физически выжившие их
|
модель не переезжают. Лгать они перестали: колонкой версии в ODS стала дата
|
||||||
значения — жребий: у строк с равным `updated_at` деловые поля совпадают
|
слепка, и у выжившей строки она равна последнему слепку, в котором заказ был
|
||||||
побайтово, и какая переживёт схлопывание — произвол (замер —
|
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Довод стал проще:
|
||||||
[приём заказов](../orders/ingestion.md), «Что проверено»). Перенесённый
|
читателя у этих колонок здесь нет — модель отвечает на вопрос «что сейчас
|
||||||
ярлык выглядел бы фактом, оставаясь жребием; лживая координата хуже
|
известно о заказе», а не «когда его показывал источник». Отсюда правило слоя:
|
||||||
отсутствующей. Отсюда правило слоя: **служебные колонки — координаты
|
**служебные колонки — координаты собственной загрузки слоя; чужие координаты
|
||||||
собственной загрузки слоя; чужие координаты спрашиваются у их слоя по ключу
|
спрашиваются у их слоя по ключу сущности**. Этим закрыто наследство #85:
|
||||||
сущности**. Этим закрыто наследство #85: читатель у чужих ярлыков выше ODS
|
читатель у чужих ярлыков выше ODS не нашёлся и при названных читателях модели.
|
||||||
не нашёлся и при названных читателях модели.
|
|
||||||
|
|
||||||
Происшествие качества данных расследуется цепочкой, где каждый шаг
|
Происшествие качества данных расследуется цепочкой, где каждый шаг
|
||||||
спрашивает свой слой на его языке: по `_load_id` строки DDS видно, какой
|
спрашивает свой слой на его языке: по `_load_id` строки DDS видно, какой
|
||||||
@@ -109,8 +108,8 @@ ODS: это единственный устойчивый ключ сущнос
|
|||||||
позиций и пересчёт времён при каждом чтении собирали бы модель заново на
|
позиций и пересчёт времён при каждом чтении собирали бы модель заново на
|
||||||
лету. Публичное `dds.order_v` поверх готовой физической модели этому доводу
|
лету. Публичное `dds.order_v` поверх готовой физической модели этому доводу
|
||||||
не противоречит: оно ничего заново не вычисляет.
|
не противоречит: оно ничего заново не вычисляет.
|
||||||
- **`ReplacingMergeTree(updated_at)` ещё раз.** Повторил бы механику ODS
|
- **`ReplacingMergeTree` ещё раз.** Повторил бы механику ODS вторым разом:
|
||||||
вторым разом: физически хранил бы устаревшие состояния, вернул бы `FINAL`
|
физически хранил бы устаревшие состояния, вернул бы `FINAL`
|
||||||
и обёртку-представление — а урок версий менти уже отыграл.
|
и обёртку-представление — а урок версий менти уже отыграл.
|
||||||
- **Отдельная сущность `dds.order_item`.** Вторая таблица, второй вопрос
|
- **Отдельная сущность `dds.order_item`.** Вторая таблица, второй вопрос
|
||||||
шардирования и `JOIN` там, где хватает `ARRAY JOIN`; нормализация из
|
шардирования и `JOIN` там, где хватает `ARRAY JOIN`; нормализация из
|
||||||
|
|||||||
+18
-27
@@ -41,40 +41,34 @@
|
|||||||
|
|
||||||
Параметров у конвейера нет: объём он выводит сам из `world_position` и
|
Параметров у конвейера нет: объём он выводит сам из `world_position` и
|
||||||
состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по
|
состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по
|
||||||
последний прожитый; заказы пересобирают дышащее окно K и тот же хвост; карта
|
последний прожитый; заказы пересобирают дышащее окно и тот же хвост; карта
|
||||||
идентичностей пересчитывается целиком; правила витрин — у
|
идентичностей пересчитывается целиком; правила витрин — у
|
||||||
[слоя DM](dm.md).
|
[слоя DM](dm.md).
|
||||||
Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и
|
Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и
|
||||||
после падения, а разгону на семь дней хватает одного прогона.
|
после падения, а разгону на семь дней хватает одного прогона.
|
||||||
|
|
||||||
Окно заказов отсчитывается от **последнего дня, о котором рассказал сам
|
Окно заказов — **дни, которые нёс последний слепок**. Называет их сам
|
||||||
источник**, — от позднейшего дня заказа в ODS. Рядом напрашиваются ещё два
|
источник: `snapshot_date` выжившей строки равен последнему слепку, в котором
|
||||||
способа, и оба отвергнуты по одной причине: они отвечают не на тот вопрос.
|
заказ был, поэтому оба края окна трансформация читает прямо из ODS
|
||||||
|
([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Своего числа окна у
|
||||||
|
стенда нет, и расходиться с миром нечему.
|
||||||
|
|
||||||
Позиция мира говорит, какой день прожит, а не какой доехал: прогон дня D везёт
|
Позиция мира заказам не отвечает: она говорит, какой день прожит, а не какой
|
||||||
слепок дня D − 1, да и позиция ставится раньше трансформации, так что после
|
доехал. Прогон дня D везёт слепок дня D − 1, да и ставится позиция раньше
|
||||||
неудачного приёма она обгоняет хранилище. Слой обязан считать объём по тому,
|
трансформации, так что после неудачного приёма обгоняет хранилище. Слой обязан
|
||||||
что у него есть, а не по тому, что мир о себе заявляет.
|
считать объём по тому, что у него есть, а не по тому, что мир о себе заявляет.
|
||||||
|
|
||||||
У сессий всё наоборот: им отвечает только позиция, потому что вопрос у них
|
У сессий всё наоборот: им отвечает только позиция, потому что вопрос у них
|
||||||
другой. Им нужен не день, о котором рассказал источник, а день, который мир
|
другой. Им нужен не день, о котором рассказал источник, а день, который мир
|
||||||
**прожил**: события живой день от прожитого не отличают — и посреди дня D, и
|
**прожил**: события живой день от прожитого не отличают — и посреди дня D,
|
||||||
сразу после его конца позднейший день событий равен D. Соберись сессии посреди дня, визит, начатый в
|
и сразу после его конца позднейший день событий равен D. Соберись сессии посреди
|
||||||
23:50, остался бы обрезанным навсегда: замёрзший день слой не пересобирает.
|
дня, визит, начатый в 23:50, остался бы обрезанным навсегда: замёрзший день слой
|
||||||
|
не пересобирает.
|
||||||
Отсюда правило — последний прожитый день равен первому дню событий плюс позиция
|
Отсюда правило — последний прожитый день равен первому дню событий плюс позиция
|
||||||
минус один ([`sql/dds/session_scope.sql`](../../sql/dds/session_scope.sql)).
|
минус один ([`sql/dds/session_scope.sql`](../../sql/dds/session_scope.sql)).
|
||||||
Оговорка про «доехал» остаётся в силе и здесь: за хвост потока отвечает не
|
Оговорка про «доехал» остаётся в силе и здесь: за хвост потока отвечает не
|
||||||
позиция, а «Готовность источника».
|
позиция, а «Готовность источника».
|
||||||
|
|
||||||
Ярлыки доставки — `snapshot_date`, `_load_id`, `_load_ts` — не отвечают тоже, и
|
|
||||||
это свойство ODS, а не недосмотр: слой схлопывает доставки, сохраняя деловые
|
|
||||||
версии, поэтому у заказа, который не менялся, ярлык выжившей строки достаётся
|
|
||||||
жребием ([приём заказов](orders/ingestion.md), «Роль ODS»). Замер на стартовом
|
|
||||||
мире: последний слепок нёс все 1662 заказа, а его ярлык сохранили 1188 —
|
|
||||||
отбор «дни, у которых `snapshot_date` равен последнему» потерял бы четверть
|
|
||||||
заказов и молча пропустил бы дальний край окна, где заказы давно замёрзли.
|
|
||||||
К времени создания это не относится: у всех версий заказа оно одно.
|
|
||||||
|
|
||||||
## Готовность источника
|
## Готовность источника
|
||||||
|
|
||||||
Первым шагом `dds_transform` обязан дождаться, пока хвост топика `hits`
|
Первым шагом `dds_transform` обязан дождаться, пока хвост топика `hits`
|
||||||
@@ -102,14 +96,11 @@
|
|||||||
## Чего конвейер о себе не знает
|
## Чего конвейер о себе не знает
|
||||||
|
|
||||||
Трансформация собирает слой по правилу, а не сверяет его с источником. Отсюда
|
Трансформация собирает слой по правилу, а не сверяет его с источником. Отсюда
|
||||||
четыре вещи, которых она не заметит, и у всех четырёх один адрес — контур DQ.
|
три вещи, которых она не заметит, и у всех трёх один адрес — контур DQ.
|
||||||
|
|
||||||
- **Окно K разъехалось с источником.** Число живёт двумя копиями: у генератора
|
- **Часть последнего слепка не доехала.** Отказ вставки после коммита офсетов
|
||||||
в `world.py` и у стенда в `compose.yaml`. Разойдутся — слой начнёт
|
уносит прочитанное с собой. Пропади так строки самого старого дня окна —
|
||||||
недобирать край окна или переписывать замёрзший день, и оба прогона будут
|
окно, выведенное из ярлыков, молча сузится, и день не пересоберётся
|
||||||
зелёными.
|
|
||||||
- **Пустой свежий день сдвигает край окна.** Если в позднейший день источника
|
|
||||||
не родилось ни одного заказа, окно съезжает на день назад
|
|
||||||
([`sql/dds/order_scope.sql`](../../sql/dds/order_scope.sql)).
|
([`sql/dds/order_scope.sql`](../../sql/dds/order_scope.sql)).
|
||||||
- **Замена партиции прошла на одном шарде из двух.** День в слое есть — значит,
|
- **Замена партиции прошла на одном шарде из двух.** День в слое есть — значит,
|
||||||
объём следующего прогона его не тронет, а слой останется собранным наполовину
|
объём следующего прогона его не тронет, а слой останется собранным наполовину
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
[мост к склейке](identity.md).
|
[мост к склейке](identity.md).
|
||||||
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
|
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
|
||||||
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order`
|
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order`
|
||||||
принимает версии заказа на `ReplacingMergeTree(updated_at)` —
|
принимает версии заказа на `ReplacingMergeTree(snapshot_date)` —
|
||||||
[приём из Kafka в ODS](ingestion.md).
|
[приём из Kafka в ODS](ingestion.md).
|
||||||
- **Дальше заказ живёт моделью DDS** — уже по хранилищную сторону границы,
|
- **Дальше заказ живёт моделью DDS** — уже по хранилищную сторону границы,
|
||||||
в наборе слоя: [модель заказа](../dds/order.md).
|
в наборе слоя: [модель заказа](../dds/order.md).
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
|
|
||||||
- сохранить пакетный забор как контраст потоковому приёму событий;
|
- сохранить пакетный забор как контраст потоковому приёму событий;
|
||||||
- один раз принять байты в STG и независимо разложить строки на годные и брак;
|
- один раз принять байты в STG и независимо разложить строки на годные и брак;
|
||||||
- хранить в ODS типизированные версии заказов, не привязывая идемпотентность к
|
- хранить в ODS типизированные версии заказов, не публикуя слепок заменой
|
||||||
`snapshot_date`;
|
партиции;
|
||||||
- дать следующим слоям один корректный способ прочитать текущее состояние;
|
- дать следующим слоям один корректный способ прочитать текущее состояние;
|
||||||
- оставить код проверки коротким и ограничить его контрактом провода.
|
- оставить код проверки коротким и ограничить его контрактом провода.
|
||||||
|
|
||||||
@@ -81,6 +81,13 @@ JSON-объектом с точным набором ключей: `order_id`, `
|
|||||||
малый контракт заказа нет причины. Граница строгости заканчивается на форме
|
малый контракт заказа нет причины. Граница строгости заканчивается на форме
|
||||||
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
|
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
|
||||||
|
|
||||||
|
Одно условие контракта предикатом не проверить, и названо оно всё равно
|
||||||
|
здесь: **одна дата — один слепок**. Вторую выгрузку того же дня приём не
|
||||||
|
отличит от первой, а колонка версии — дата слепка — на ней даёт ничью.
|
||||||
|
Модельный источник выгружает день один раз; настоящему такое условие ставят
|
||||||
|
договором, а не предикатом
|
||||||
|
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)).
|
||||||
|
|
||||||
Класс брака выбирается первым совпадением:
|
Класс брака выбирается первым совпадением:
|
||||||
|
|
||||||
1. `not_an_object`;
|
1. `not_an_object`;
|
||||||
@@ -94,31 +101,34 @@ JSON-объектом с точным набором ключей: `order_id`, `
|
|||||||
## Роль ODS
|
## Роль ODS
|
||||||
|
|
||||||
`ods.order_rep` хранит физически принятые версии в
|
`ods.order_rep` хранит физически принятые версии в
|
||||||
`ReplacingMergeTree(updated_at)`. Ключ сортировки — `order_id`, партиция — день
|
`ReplacingMergeTree(snapshot_date)`. Ключ сортировки — `order_id`, партиция —
|
||||||
неизменного `created_at`. `ods.order_dist` шардирует по
|
день неизменного `created_at`. `ods.order_dist` шардирует по
|
||||||
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
|
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
|
||||||
`FINAL` даёт корректный результат через распределённую таблицу.
|
`FINAL` даёт корректный результат через распределённую таблицу.
|
||||||
|
|
||||||
Четыре координаты отвечают на разные вопросы:
|
Четыре координаты отвечают на разные вопросы:
|
||||||
|
|
||||||
- `updated_at` — какая бизнес-версия заказа новее;
|
|
||||||
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
|
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
|
||||||
|
- `updated_at` — когда строку последний раз меняли в источнике;
|
||||||
- `_load_id` — какой запуск Airflow принял строку;
|
- `_load_id` — какой запуск Airflow принял строку;
|
||||||
- `_load_ts` — когда строка приехала в хранилище.
|
- `_load_ts` — когда строка приехала в хранилище.
|
||||||
|
|
||||||
Версию выбирает только `updated_at`. Заказ, не менявшийся несколько дней,
|
Версию выбирает `snapshot_date`: выживает строка позднейшего слепка
|
||||||
приезжает в нескольких слепках одинаковыми деловыми полями, и какая из этих
|
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Поэтому ярлык
|
||||||
строк переживёт схлопывание — не обещано. Поэтому `snapshot_date` выжившей
|
выжившей строки — правдивый ответ на вопрос «когда заказ видели в последний
|
||||||
строки — один из дней, когда источник показывал эту версию, не обязательно
|
раз», и по нему трансформация DDS читает объём пересборки. `_load_id` и
|
||||||
последний; то же с `_load_id` и `_load_ts`. Вопрос «когда заказ наблюдался в
|
`_load_ts` выжившей строки говорят про ту же доставку.
|
||||||
последний раз» задаётся сырью STG, пока то живо, — а не одной строке ODS.
|
|
||||||
|
|
||||||
Сама схема версий стоит на допущении об источнике: уточнение заказа поднимает
|
Схема версий стоит на дисциплине источника: слепок с поздней датой не
|
||||||
`updated_at`. Генератор моделирует источник с триггерной меткой и допущение
|
показывает состояния старше. Модельный мир её держит — состояние читается на
|
||||||
выполняет (замер — «Что проверено»); источник, умеющий уточнить строку тихо —
|
границе суток, и слепок дня выходит чистой функцией зерна и дня; источник,
|
||||||
ручным `UPDATE` мимо приложения, миграцией, — ломает его, и тогда версии по
|
собирающий слепок из отставшей реплики, её ломает. Деловая метка `updated_at`
|
||||||
метке времени мало: нужна сверка самих полей, в терминах dbt snapshots —
|
при этом монотонна по слепкам, поэтому выбор по дате слепка даёт тот же ответ,
|
||||||
стратегия `check` вместо `timestamp`.
|
что выбор по метке (замер — «Что проверено»). Тихая правка строки — ручным
|
||||||
|
`UPDATE` мимо приложения, миграцией — метку не двигает, но следующий слепок
|
||||||
|
перекроет её сам, пока заказ в окне изменяемости. За окном источник заказ
|
||||||
|
больше не показывает, и там помогает только сверка самих полей, в терминах
|
||||||
|
dbt snapshots — стратегия `check` вместо `timestamp`.
|
||||||
|
|
||||||
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
|
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
|
||||||
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
|
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
|
||||||
@@ -201,12 +211,19 @@ JSON-объектом с точным набором ключей: `order_id`, `
|
|||||||
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу
|
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу
|
||||||
пользователя `etl` для забора в STG, записи годных строк и записи брака.
|
пользователя `etl` для забора в STG, записи годных строк и записи брака.
|
||||||
|
|
||||||
Допущение и произвол ярлыков замерены 22 августа 2026 года при исполнении
|
Дисциплина источника замерена 22 августа 2026 года при исполнении #137: во
|
||||||
#137: во всех 3034 парах строк с равным `updated_at` деловые поля совпали
|
всех 3034 парах строк с равным `updated_at` деловые поля совпали побайтово, по
|
||||||
побайтово, по слепкам метка ни разу не убывала, а `ods.order_v` сошёлся
|
слепкам метка ни разу не убывала, а `ods.order_v` сошёлся построчно с эталоном
|
||||||
построчно с эталоном `argMax` по `updated_at`, посчитанным прямо из сырья.
|
`argMax` по `updated_at`, посчитанным прямо из сырья. Тем же замером виден
|
||||||
Произвол ярлыков виден въявь: у 86 заказов стартового мира выжила строка
|
произвол ярлыка при версии по деловой метке — довод, на котором стоит
|
||||||
серединного слепка — не первого и не последнего.
|
[ADR 0014](../../adr/0014-order-version-snapshot-date.md).
|
||||||
|
|
||||||
|
Правдивость ярлыка снята на пересобранном мире 26 августа 2026 года при
|
||||||
|
исполнении #165. После двух слепков `snapshot_date` каждой из 1917 строк
|
||||||
|
`ods.order_v` равен последнему слепку, который нёс её заказ: расхождений ноль
|
||||||
|
при 3356 физически лежащих строках — выбор был не единственным. Деловое
|
||||||
|
содержимое сменой колонки версии не тронулось: те же строки построчно совпали
|
||||||
|
с эталоном `argMax` по `updated_at` из сырья по всем полям.
|
||||||
|
|
||||||
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
|
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
|
||||||
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
|
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
|
|||||||
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
|
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
|
||||||
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
|
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
|
||||||
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
|
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
|
||||||
повтор доставки. У `ods.order` версию задаёт `updated_at` источника;
|
повтор доставки. У `ods.order` версию задаёт `snapshot_date` — дата слепка
|
||||||
`_load_ts` только показывает, когда конкретная строка приехала.
|
источника; `_load_ts` только показывает, когда конкретная строка приехала.
|
||||||
|
|
||||||
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
|
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
|
||||||
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
|
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
|
||||||
@@ -224,7 +224,7 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
|
|||||||
## Версии заказов
|
## Версии заказов
|
||||||
|
|
||||||
`ods.order_rep` хранит принятые версии в
|
`ods.order_rep` хранит принятые версии в
|
||||||
`ReplacingMergeTree(updated_at)`: ключ сортировки — `order_id`, партиция —
|
`ReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`, партиция —
|
||||||
`toDate(created_at)`. `ods.order_dist` шардирует по
|
`toDate(created_at)`. `ods.order_dist` шардирует по
|
||||||
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
|
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
|
||||||
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
|
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
|
||||||
@@ -234,10 +234,11 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
|
|||||||
версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
|
версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
|
||||||
`ods.order_v` служит поверхностью точного текущего состояния для следующих
|
`ods.order_v` служит поверхностью точного текущего состояния для следующих
|
||||||
слоёв. Представление сохраняет язык источника и не решает, какой станет модель
|
слоёв. Представление сохраняет язык источника и не решает, какой станет модель
|
||||||
DDS. `snapshot_date` в нём остаётся датой наблюдения строки, а не ключом
|
DDS. `snapshot_date` в нём — дата наблюдения и колонка версии, но не ключ
|
||||||
публикации. Полное решение — в
|
публикации. Полное решение — в
|
||||||
[ADR 0010](../adr/0010-order-versions-in-ods.md) и
|
[ADR 0010](../adr/0010-order-versions-in-ods.md), колонка версии —
|
||||||
[спецификации приёма заказов](orders/ingestion.md). Какой моделью заказ
|
в [ADR 0014](../adr/0014-order-version-snapshot-date.md); механика приёма —
|
||||||
|
в [спецификации приёма заказов](orders/ingestion.md). Какой моделью заказ
|
||||||
живёт дальше — [модель заказа в DDS](dds/order.md).
|
живёт дальше — [модель заказа в DDS](dds/order.md).
|
||||||
|
|
||||||
## Часовые пояса
|
## Часовые пояса
|
||||||
@@ -669,8 +670,8 @@ DDL-словарь с файловым источником внутри `user_f
|
|||||||
порции из пяти строк (две версии одного заказа плюс по одной строке каждого
|
порции из пяти строк (две версии одного заказа плюс по одной строке каждого
|
||||||
класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с
|
класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с
|
||||||
ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL`
|
ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL`
|
||||||
через распределённую таблицу вернул одну строку — ту, у которой `updated_at`
|
через распределённую таблицу вернул одну строку — ту, что несла позднейшую
|
||||||
позже, — тогда как физический счёт показывал две. Повтор задачи с тем же
|
версию, — тогда как физический счёт показывал две. Повтор задачи с тем же
|
||||||
`_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок
|
`_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок
|
||||||
записала тот же брак второй раз, как и обещано спецификацией приёма.
|
записала тот же брак второй раз, как и обещано спецификацией приёма.
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,12 @@ parseDateTime64InJodaSyntaxOrNull(
|
|||||||
монотонные миллисекундные `updated_at` на один заказ; отдельный счётчик версий
|
монотонные миллисекундные `updated_at` на один заказ; отдельный счётчик версий
|
||||||
без такого сценария был бы лишним.
|
без такого сценария был бы лишним.
|
||||||
|
|
||||||
|
> Вывод о колонке версии пересмотрен: версию `ods.order` задаёт `snapshot_date`
|
||||||
|
> ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Там же разобрано,
|
||||||
|
> почему обещанию документации про ничью — «побеждает вставленная позже» —
|
||||||
|
> верить нельзя: замер на стенде его опроверг. Абзац выше оставлен как был,
|
||||||
|
> документ историчен.
|
||||||
|
|
||||||
## Дата слепка и константы мира
|
## Дата слепка и константы мира
|
||||||
|
|
||||||
Периодический слепок имеет зерно заранее заданного периода — например, дня, —
|
Периодический слепок имеет зерно заранее заданного периода — например, дня, —
|
||||||
|
|||||||
@@ -220,11 +220,12 @@ Ecommerce (заполнены только у торговых событий):
|
|||||||
[исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md).
|
[исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md).
|
||||||
|
|
||||||
- `ods.order` принимает версии заказа в
|
- `ods.order` принимает версии заказа в
|
||||||
`ReplacingMergeTree(updated_at)`: `ORDER BY order_id`, партиция по дню
|
`ReplacingMergeTree(snapshot_date)`: `ORDER BY order_id`, партиция по дню
|
||||||
неизменного `created_at`, шардирование по `cityHash64(order_id)`.
|
неизменного `created_at`, шардирование по `cityHash64(order_id)`. Дата
|
||||||
`snapshot_date` остаётся датой наблюдения источника, но не задаёт публикацию
|
слепка служит колонкой версии — выживает строка позднейшего слепка
|
||||||
или идемпотентность. Физическое чтение может видеть несколько версий;
|
([ADR 0014](../adr/0014-order-version-snapshot-date.md)), — но публикацию
|
||||||
`ods.order_v` возвращает точное текущее состояние. В DDS одна строка хранит
|
и идемпотентность по-прежнему не задаёт. Физическое чтение может видеть
|
||||||
|
несколько версий; `ods.order_v` возвращает точное текущее состояние. В DDS одна строка хранит
|
||||||
один заказ с позициями в типизированных массивах; физическая модель и
|
один заказ с позициями в типизированных массивах; физическая модель и
|
||||||
пакетное наполнение описаны в [доке заказа](../architecture/dds/order.md).
|
пакетное наполнение описаны в [доке заказа](../architecture/dds/order.md).
|
||||||
|
|
||||||
@@ -452,17 +453,17 @@ README.
|
|||||||
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
|
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
|
||||||
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска.
|
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска.
|
||||||
Один следующий `task_id` двумя последовательными запросами пишет годные строки
|
Один следующий `task_id` двумя последовательными запросами пишет годные строки
|
||||||
и ошибки из того же среза. `ods.order` хранит версии по `updated_at`,
|
и ошибки из того же среза. `ods.order` хранит версии по дате слепка
|
||||||
а не публикует партицию `snapshot_date`. Слой сырья остаётся точкой повтора и
|
([ADR 0014](../adr/0014-order-version-snapshot-date.md)), а не публикует
|
||||||
|
партицию `snapshot_date`. Слой сырья остаётся точкой повтора и
|
||||||
разбора одной принятой порции.
|
разбора одной принятой порции.
|
||||||
|
|
||||||
Стенд получает от этого два режима приёма рядом, поток и слепок, и сравнение
|
Стенд получает от этого два режима приёма рядом, поток и слепок, и сравнение
|
||||||
из опорных точек раздела 12 переформулировано под них.
|
из опорных точек раздела 12 переформулировано под них.
|
||||||
|
|
||||||
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
|
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
|
||||||
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но у
|
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию
|
||||||
заказа бизнес-версию задаёт `updated_at`; `_load_id` проходит через STG и обе
|
заказа задаёт дата слепка; `_load_id` проходит через STG и обе цели ODS. Таблицы
|
||||||
цели ODS. Таблицы
|
|
||||||
`stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды —
|
`stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды —
|
||||||
без них урок «какая нода читала топик» ненаблюдаем.
|
без них урок «какая нода читала топик» ненаблюдаем.
|
||||||
Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое
|
Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое
|
||||||
|
|||||||
+12
-10
@@ -156,17 +156,19 @@ ENGINE = Distributed('clickstream_cluster', 'ods', 'event_errors_rep', cityHash6
|
|||||||
-- Слепок привозит состояние заказов окна изменяемости, а не поток изменений,
|
-- Слепок привозит состояние заказов окна изменяемости, а не поток изменений,
|
||||||
-- и одна и та же сущность приезжает в нём много дней подряд. Поэтому строка
|
-- и одна и та же сущность приезжает в нём много дней подряд. Поэтому строка
|
||||||
-- здесь — версия заказа, а не запись слепка: ключ сущности order_id, колонка
|
-- здесь — версия заказа, а не запись слепка: ключ сущности order_id, колонка
|
||||||
-- версии updated_at (время последнего изменения строки в источнике). Порядок
|
-- версии snapshot_date — день, состояние которого снял источник. У полного
|
||||||
-- версий решает источник, а не хранилище.
|
-- слепка версия и есть слепок: истинно то, что источник сказал последним
|
||||||
|
-- (ADR 0014).
|
||||||
--
|
--
|
||||||
-- Четыре координаты отвечают на разные вопросы, и путать их нельзя:
|
-- Четыре координаты отвечают на разные вопросы, и путать их нельзя:
|
||||||
-- updated_at — какая бизнес-версия новее; snapshot_date — в слепке какого
|
-- snapshot_date — в слепке какого модельного дня источник показал строку;
|
||||||
-- модельного дня источник показал строку; _load_id — какой запуск Airflow её
|
-- updated_at — когда строку последний раз меняли в источнике; _load_id —
|
||||||
-- принял; _load_ts — когда она приехала в хранилище. Ключом сущности не
|
-- какой запуск Airflow её принял; _load_ts — когда она приехала в хранилище.
|
||||||
-- становится ни одна из трёх последних: они про наблюдение и загрузку. Версию
|
-- Ключом сущности не становится ни одна: заказ один, а дней наблюдения и
|
||||||
-- выбирает только updated_at, поэтому у выжившей после схлопывания строки
|
-- версий у него много. Схлопывание оставляет строку позднейшего слепка,
|
||||||
-- snapshot_date — один из дней, когда источник показывал эту версию, не
|
-- поэтому snapshot_date выжившей — последний день, когда источник показывал
|
||||||
-- обязательно последний (docs/architecture/orders/ingestion.md).
|
-- заказ, и по нему DDS читает объём пересборки
|
||||||
|
-- (docs/architecture/orders/ingestion.md).
|
||||||
--
|
--
|
||||||
-- PARTITION BY toDate(created_at) — по дню создания строки в источнике. Он
|
-- PARTITION BY toDate(created_at) — по дню создания строки в источнике. Он
|
||||||
-- неизменен у всех версий заказа, поэтому версии лежат в одной партиции и
|
-- неизменен у всех версий заказа, поэтому версии лежат в одной партиции и
|
||||||
@@ -194,7 +196,7 @@ CREATE TABLE IF NOT EXISTS ods.order_rep ON CLUSTER clickstream_cluster
|
|||||||
_load_id String,
|
_load_id String,
|
||||||
_load_ts DateTime64(3, 'UTC')
|
_load_ts DateTime64(3, 'UTC')
|
||||||
)
|
)
|
||||||
ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}', updated_at)
|
ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}', snapshot_date)
|
||||||
PARTITION BY toDate(created_at)
|
PARTITION BY toDate(created_at)
|
||||||
ORDER BY order_id;
|
ORDER BY order_id;
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ FROM ods.event_dist FINAL;
|
|||||||
-- ODS: каноническое чтение текущего состояния заказов.
|
-- ODS: каноническое чтение текущего состояния заказов.
|
||||||
--
|
--
|
||||||
-- То же, что у событий, но версию здесь задаёт источник: физическая таблица
|
-- То же, что у событий, но версию здесь задаёт источник: физическая таблица
|
||||||
-- хранит все приехавшие версии заказа, а FINAL оставляет ту, у которой
|
-- хранит все приехавшие версии заказа, а FINAL оставляет приехавшую
|
||||||
-- updated_at позже. Читать заказы голым SELECT по _dist значит зависеть от
|
-- позднейшим слепком — колонка версии здесь snapshot_date. Читать заказы
|
||||||
-- того, сколько фоновых слияний успело пройти.
|
-- голым SELECT по _dist значит зависеть от того, сколько фоновых слияний
|
||||||
|
-- успело пройти.
|
||||||
--
|
--
|
||||||
-- Поля остаются языком источника, обогащения нет: DDS читает это
|
-- Поля остаются языком источника, обогащения нет: DDS читает это
|
||||||
-- представление и отдельно решает, какой будет его модель (ADR 0010).
|
-- представление и отдельно решает, какой будет его модель (ADR 0010).
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
-- DDS: физические модели слоя и доноры их партиций.
|
-- DDS: физические модели слоя и доноры их партиций.
|
||||||
--
|
--
|
||||||
-- Идемпотентность здесь устроена иначе, чем в приёме, и это главный урок обеих
|
-- Идемпотентность здесь устроена иначе, чем в приёме, и это главный урок обеих
|
||||||
-- сущностей файла. В ODS повтор гасит колонка версии:
|
-- сущностей файла. В ODS повтор гасит колонка версии: ReplacingMergeTree по
|
||||||
-- ReplacingMergeTree(updated_at) оставляет позднюю строку. Здесь колонки
|
-- snapshot_date оставляет строку позднейшего слепка. Здесь колонки
|
||||||
-- версии нет вовсе — повтор гасит замена партиции целиком: день собирается
|
-- версии нет вовсе — повтор гасит замена партиции целиком: день собирается
|
||||||
-- заново и подменяется одним атомарным движением. Поэтому строка таблицы
|
-- заново и подменяется одним атомарным движением. Поэтому строка таблицы
|
||||||
-- ровно одна на своё зерно, а чтению не нужен FINAL.
|
-- ровно одна на своё зерно, а чтению не нужен FINAL.
|
||||||
|
|||||||
+26
-28
@@ -10,11 +10,11 @@
|
|||||||
-- запросом, дописывая формат ответа, — точка с запятой сделала бы из этого
|
-- запросом, дописывая формат ответа, — точка с запятой сделала бы из этого
|
||||||
-- мультизапрос, который ClickHouse не принимает.
|
-- мультизапрос, который ClickHouse не принимает.
|
||||||
--
|
--
|
||||||
-- Объём складывается из двух частей. Первая — дышащее окно: K последних дней
|
-- Объём складывается из двух частей. Первая — дышащее окно: дни, которые нёс
|
||||||
-- источника, состояние которых слепок ещё может изменить. Вторая — хвост от
|
-- последний слепок, то есть те, чьё состояние источник ещё может изменить.
|
||||||
-- первого дня, которого в слое нет: первая сборка застаёт слой пустым и
|
-- Вторая — хвост от первого дня, которого в слое нет: первая сборка застаёт
|
||||||
-- собирает весь стартовый мир, а после разгона мира пачкой слой догоняет
|
-- слой пустым и собирает весь стартовый мир, а после разгона мира пачкой слой
|
||||||
-- уехавший вперёд источник.
|
-- догоняет уехавший вперёд источник.
|
||||||
--
|
--
|
||||||
-- **Хвост считается от первого недостающего дня, а не от последнего
|
-- **Хвост считается от первого недостающего дня, а не от последнего
|
||||||
-- собранного.** Разница не косметическая: замены партиций идут отдельными
|
-- собранного.** Разница не косметическая: замены партиций идут отдельными
|
||||||
@@ -24,22 +24,26 @@
|
|||||||
-- зеленеть над щербатым слоем. Цена честного ответа — проход по дню заказа
|
-- зеленеть над щербатым слоем. Цена честного ответа — проход по дню заказа
|
||||||
-- в ODS.
|
-- в ODS.
|
||||||
--
|
--
|
||||||
-- Край окна берётся из делового поля, а не из ярлыков доставки: время
|
-- Окно читается из ярлыка слепка, а не вычисляется вычитанием окна
|
||||||
-- создания заказа одинаково у всех его версий, а `snapshot_date` и `_load_id`
|
-- изменяемости: у выжившей строки snapshot_date — последний слепок, который
|
||||||
-- выжившей строки достаются жребием (docs/architecture/etl.md, «Объём
|
-- нёс этот заказ (ADR 0014). Поэтому оба края окна знает сам источник, а
|
||||||
-- прогона», и docs/architecture/orders/ingestion.md, «Роль ODS»).
|
-- копии его константы на стороне стенда больше нет.
|
||||||
--
|
--
|
||||||
-- Известная неточность края: если в самый свежий день источника не родилось
|
-- FINAL не нужен ни здесь, ни ниже: время рождения заказа у всех его версий
|
||||||
-- ни одного заказа, окно съезжает на день назад — лишний замёрзший день
|
-- одно, а строки последнего слепка несут максимум колонки версии — мерж
|
||||||
-- пересоберётся впустую, а пустой день не тронется. Данные от этого не
|
-- оставляет как раз их.
|
||||||
-- страдают, меняется только ярлык загрузки лишнего дня. Точный ответ здесь
|
|
||||||
-- дала бы дата слепка, но она и есть тот самый жребий.
|
|
||||||
WITH
|
WITH
|
||||||
-- FINAL не нужен: время рождения заказа версии не различают.
|
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
|
||||||
(SELECT max(toDate(toTimeZone(created_at, 'Europe/Samara')))
|
(
|
||||||
FROM ods.order_dist) AS last_source_day,
|
SELECT min(toDate(toTimeZone(created_at, 'Europe/Samara')))
|
||||||
(SELECT min(toDate(toTimeZone(created_at, 'Europe/Samara')))
|
FROM ods.order_dist
|
||||||
FROM ods.order_dist) AS first_source_day,
|
WHERE snapshot_date = last_snapshot
|
||||||
|
) AS window_start,
|
||||||
|
(
|
||||||
|
SELECT max(toDate(toTimeZone(created_at, 'Europe/Samara')))
|
||||||
|
FROM ods.order_dist
|
||||||
|
WHERE snapshot_date = last_snapshot
|
||||||
|
) AS window_end,
|
||||||
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
|
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
|
||||||
-- бы дни источника с локальной частью слоя на каждом шарде — день, чьи
|
-- бы дни источника с локальной частью слоя на каждом шарде — день, чьи
|
||||||
-- заказы легли на соседний шард, показался бы недостающим.
|
-- заказы легли на соседний шард, показался бы недостающим.
|
||||||
@@ -51,15 +55,9 @@ WITH
|
|||||||
FROM ods.order_dist
|
FROM ods.order_dist
|
||||||
)
|
)
|
||||||
WHERE day GLOBAL NOT IN (SELECT order_date FROM dds.order_dist)
|
WHERE day GLOBAL NOT IN (SELECT order_date FROM dds.order_dist)
|
||||||
) AS first_missing_day,
|
) AS first_missing_day
|
||||||
last_source_day - ({window_days:UInt16} - 1) AS window_start
|
|
||||||
SELECT
|
SELECT
|
||||||
-- Дыр нет — начинаем с окна; есть — с первой из них, если она старше окна.
|
-- Дыр нет — начинаем с окна; есть — с первой из них, если она старше окна.
|
||||||
-- greatest держит левый край не раньше первого дня, известного источнику:
|
least(window_start, coalesce(first_missing_day, window_start)) AS first_day,
|
||||||
-- у молодого мира окно шире самого мира.
|
window_end AS last_day
|
||||||
greatest(
|
|
||||||
least(window_start, coalesce(first_missing_day, window_start)),
|
|
||||||
first_source_day
|
|
||||||
) AS first_day,
|
|
||||||
last_source_day AS last_day
|
|
||||||
WHERE (SELECT count() FROM ods.order_dist) > 0
|
WHERE (SELECT count() FROM ods.order_dist) > 0
|
||||||
Reference in new issue
Block a user