feat(ods): версией заказа стала дата слепка, окно DDS — из данных #167

Merged
ddmitry merged 2 commits from 165-ods-order-version-snapshot-date into main 2026-08-26 18:50:39 +03:00
13 changed files with 140 additions and 141 deletions
Showing only changes of commit a82c75705d - Show all commits

No files matched your search

-11
View File
@@ -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 при разборе загружает
+1 -7
View File
@@ -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:
+10 -11
View File
@@ -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
View File
@@ -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)).
- **Замена партиции прошла на одном шарде из двух.** День в слое есть — значит, - **Замена партиции прошла на одном шарде из двух.** День в слое есть — значит,
объём следующего прогона его не тронет, а слой останется собранным наполовину объём следующего прогона его не тронет, а слой останется собранным наполовину
+1 -1
View File
@@ -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).
+40 -23
View File
@@ -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: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
+9 -8
View File
@@ -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)). Там же разобрано,
> почему обещанию документации про ничью — «побеждает вставленная позже» —
> верить нельзя: замер на стенде его опроверг. Абзац выше оставлен как был,
> документ историчен.
## Дата слепка и константы мира ## Дата слепка и константы мира
Периодический слепок имеет зерно заранее заданного периода — например, дня, — Периодический слепок имеет зерно заранее заданного периода — например, дня, —
+11 -10
View File
@@ -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
View File
@@ -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;
+4 -3
View File
@@ -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).
+2 -2
View File
@@ -1,8 +1,8 @@
-- DDS: физические модели слоя и доноры их партиций. -- DDS: физические модели слоя и доноры их партиций.
-- --
-- Идемпотентность здесь устроена иначе, чем в приёме, и это главный урок обеих -- Идемпотентность здесь устроена иначе, чем в приёме, и это главный урок обеих
-- сущностей файла. В ODS повтор гасит колонка версии: -- сущностей файла. В ODS повтор гасит колонка версии: ReplacingMergeTree по
-- ReplacingMergeTree(updated_at) оставляет позднюю строку. Здесь колонки -- snapshot_date оставляет строку позднейшего слепка. Здесь колонки
-- версии нет вовсе — повтор гасит замена партиции целиком: день собирается -- версии нет вовсе — повтор гасит замена партиции целиком: день собирается
-- заново и подменяется одним атомарным движением. Поэтому строка таблицы -- заново и подменяется одним атомарным движением. Поэтому строка таблицы
-- ровно одна на своё зерно, а чтению не нужен FINAL. -- ровно одна на своё зерно, а чтению не нужен FINAL.
+26 -28
View File
@@ -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