diff --git a/README.md b/README.md
index 38cac01..4043b06 100644
--- a/README.md
+++ b/README.md
@@ -125,7 +125,7 @@ Kafka, затем заново создаст схему хранилища и
из `CLICKHOUSE_STUDENT_PASSWORD` в `.env`. Он читает те же источники, а создавать,
заполнять и удалять таблицы может только в базе `default`.
Для создания учебных витрин используйте подключение, описанное
-в [инструкции к заданиям](docs/labs/README.md#как-применить-свой-ddl).
+в [инструкции к заданиям](docs/labs/README.md#выполнение-sql-в-dbeaver).
Нативные порты `29000` и `29001` нужны `clickhouse-client`, но не DBeaver.
Остальные пользователи и роли описаны в
[справочнике стенда](docs/architecture/stand.md#пользователи-и-роли-clickhouse).
diff --git a/docs/labs/1-1-events-enriched.md b/docs/labs/1-1-events-enriched.md
index 18625a5..5469a32 100644
--- a/docs/labs/1-1-events-enriched.md
+++ b/docs/labs/1-1-events-enriched.md
@@ -1,37 +1,38 @@
-# Задание 1: `dm.events_enriched_v`
+# Задание 1: события для анализа, `dm.events_enriched_v`
-**Чему учит:** подготовить удобный для анализа набор полей события
-и обойтись представлением, сохранив одну строку на событие.
+Вы подготовите представление с полями для анализа событий. Оно будет
+добавлять вычисляемые поля при чтении, сохраняя одну строку на событие.
-## Сначала прочитать
+## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md).
-- [Как применить свой DDL](README.md#как-применить-свой-ddl): файл для решения
+- [Выполнение SQL в DBeaver](README.md#выполнение-sql-в-dbeaver): файл для решения
и подключение с правами на создание витрины.
-## Для чего нужна витрина
+## Данные для анализа событий
В `dds.event_v` 44 колонки, включая технические поля источника и массивы
-товаров. Для анализа событий в Superset нужны деловые поля и три готовых
-значения: час события, путь страницы и домен перехода. Тогда вам не придется
+товаров. Для анализа событий в Superset нужны сведения о посетителе и его
+действиях, а также три вычисляемых поля: час события, путь страницы и домен,
+с которого пришел посетитель. Тогда вам не придется
повторять эти выражения в каждом запросе.
Витрины следующих заданий читают DDS напрямую. Использовать
`dm.events_enriched_v` как источник для них не нужно: задания независимы, их
можно выполнять в любом порядке.
-## Что сделать
+## Создание представления
Создайте обычное представление `CREATE VIEW dm.events_enriched_v`.
-Единственный источник — `dds.event_v`. Зерно — **событие**: каждой строке
-источника соответствует одна строка витрины.
+Единственный источник: `dds.event_v`. Каждой строке источника должна
+соответствовать ровно одна строка витрины: одно событие.
-Физическая таблица, таблица-донор и задача в `dm_transform` не нужны.
-Представление не хранит строки, поэтому ключ шардирования и пересборка ему
-не требуются.
+Представление выполняет запрос при чтении и не хранит отдельную копию данных.
+Для него не нужно создавать таблицы или добавлять задачу в `dm_transform`.
Укажите колонки явно. Иначе новая колонка источника попадет в витрину
-автоматически, и договор с потребителем изменится без вашего участия.
+автоматически. Отчет получит ее, даже если вы не собирались менять его набор
+полей.
| Группа | Колонки |
|---|---|
@@ -57,9 +58,7 @@ DDS читают из ODS без отдельной загрузки. Эти с
## Типичная ошибка
Не разворачивайте массивы товаров через `ARRAY JOIN`. У события с тремя
-позициями получится три строки. Зерно изменится. Витрину с новым зерном на
-стенде хранят готовым агрегатом — причины описаны в разделе [«Как принято на
-ступени 1»](README.md#как-принято-на-ступени-1).
+позициями получится три строки вместо одной.
Главная опасность — потеря событий с пустым массивом товаров: `ARRAY JOIN`
исключает их из результата. Товары есть только в событиях корзины и покупки.
@@ -68,9 +67,9 @@ DDS читают из ODS без отдельной загрузки. Эти с
по-прежнему будет выглядеть таблицей событий.
Сохраните массивы как в источнике. Для анализа отдельных товарных позиций
-нужна другая витрина с другим зерном и именем.
+нужна отдельная витрина, где одна строка соответствует товарной позиции.
-## Как проверить себя
+## Проверка результата
После применения DDL откройте Airflow по [адресу из README](../../README.md#адреса-и-пароли),
найдите даг `homework_check` и запустите его вручную. В новом запуске откройте
@@ -79,16 +78,15 @@ DDS читают из ODS без отдельной загрузки. Эти с
расхождениях с источником — дни и примеры несовпадений. Задачи еще не
созданных витрин будут красными; это ожидаемо и не мешает проверке первой.
-Деловой ключ — `event_id` внутри дня. Получите результат прямо из
-`dds.event_v` за тот же день. Соедините его с витриной полным внешним
-соединением по `event_id`. Посчитайте строки, где ключ отсутствует с одной
-из сторон или различаются значения деловых колонок. Расхождений должно быть
-ноль.
+Выберите день и прочитайте его события из `dds.event_v` и своей витрины.
+Сопоставьте строки по `event_id` через `FULL OUTER JOIN`.
+Найдите события, которые есть только с одной стороны, и события, у которых
+различаются перенесенные из источника поля. Расхождений должно быть ноль.
Отдельно сверьте `event_hour`, `page_path` и `referer_host` с теми же
выражениями, рассчитанными прямо по `dds.event_v`.
-## Что должно получиться
+## Результат работы
- [ ] у вас есть `dm.events_enriched_v` с явно перечисленными колонками;
- [ ] вы сверили число строк и набор `event_id` с `dds.event_v` за каждый
diff --git a/docs/labs/1-2-top-pages-daily.md b/docs/labs/1-2-top-pages-daily.md
index bd1741c..4635c17 100644
--- a/docs/labs/1-2-top-pages-daily.md
+++ b/docs/labs/1-2-top-pages-daily.md
@@ -1,14 +1,15 @@
-# Задание 2: `dm.top_pages_daily`
+# Задание 2: показатели страниц по дням, `dm.top_pages_daily`
-**Чему учит:** собрать дневные показатели страниц и настроить их
-обновление в `dm_transform` через замену партиций.
+В этой работе вы рассчитаете показатели страниц за каждый день и сохраните
+их в таблице. Затем подключите обновление витрины к `dm_transform`:
+новый расчет будет заменять дневные партиции таблицы.
-## Сначала прочитать
+## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
-## Для чего нужна витрина
+## Дневные показатели страниц
По дневным показателям страниц аналитик сравнивает просмотры, посетителей и
действия с товарами. Добавление в корзину происходит на карточке товара,
@@ -19,19 +20,23 @@
действия происходят на разных страницах, а связи между ними в агрегате нет.
Путь человека исследуют по событиям или сессиям.
-## Что сделать
+## Создание витрины
-Источник — `dds.event_v`. Зерно витрины — **день события × страница**.
-Читайте DDS напрямую: так выполнение задания не зависит от витрины
-задания 1.
+Читайте события из `dds.event_v`. В результате нужна одна строка на страницу
+за день. Например, все просмотры одной карточки товара за один день попадут
+в одну строку. Витрина задания 1 для этого расчета не нужна.
-На стенде витрину с новым зерном хранят как готовый агрегат, чтобы не
-рассчитывать ее заново для каждого запроса. Причины описаны в разделе
-[«Как принято на ступени 1»](README.md#как-принято-на-ступени-1). Создайте
-пять объектов:
-`dm.top_pages_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
-публичное представление `dm.top_pages_daily_v`. Ключ партиции — день, он же
-единица пересборки.
+Сохраните рассчитанные показатели в таблице, чтобы отчеты читали готовые
+итоги. Создайте пять объектов:
+
+- основные таблицы `dm.top_pages_daily_rep` и `dm.top_pages_daily_dist`;
+- промежуточные таблицы `dm.top_pages_daily_stage_rep` и `dm.top_pages_daily_stage_dist`;
+- представление для чтения `dm.top_pages_daily_v`.
+
+Их назначение и порядок обновления разобраны во вводной:
+[«Создание и обновление витрин»](README.md#создание-и-обновление-витрин).
+Разбейте данные на партиции по дням, чтобы можно было заменить результат
+одного дня целиком.
### Состав колонок
@@ -50,25 +55,26 @@
адрес разделил бы показатели одной страницы на сотни строк. Учитывайте все
три типа событий, а их счетчики вычисляйте через `countIf`.
-### Ритм пересборки
+### Дни для пересчета
-События прожитого дня больше не меняются. Определяйте объем прогона по
-состоянию витрины: от первого несобранного прожитого дня до последнего
-прожитого. Отсчет от последнего собранного дня оставит пропуски после
-частичного сбоя.
+События завершившегося модельного дня больше не меняются. Найдите первый
+такой день, которого еще нет в витрине, и рассчитайте все дни от него до
+последнего завершившегося. Если начать после последнего дня, который уже
+есть в витрине, можно пропустить более ранний день, не загруженный из-за сбоя.
-Как у `revenue_daily`, заполните донор за весь диапазон одной вставкой,
-затем замените дневные партиции по одной.
+Как у `revenue_daily`, запишите расчет за все выбранные дни в промежуточную
+таблицу одной вставкой. Затем замените дневные партиции основной таблицы по
+одной. Добавьте запуск этих запросов в `dm_transform`.
### Ключ шардирования
-Выберите ключ самостоятельно. У `revenue_daily` кроме дня одно измерение с
-шестью близкими по объему значениями. Здесь страниц много, и число событий
-на них сильно различается.
+Выберите ключ самостоятельно. У `revenue_daily` строки сгруппированы по дню
+и категории товара; категорий всего шесть. Здесь страниц много, и число
+событий на них сильно различается.
-У цели и донора используйте одинаковый ключ: иначе при замене партиции
-строка потеряется на одном шарде и удвоится на другом. О том, [как устроен
-донор](../architecture/storage.md), читайте в разделе «Раскладка по шардам».
+У основной и промежуточной таблиц используйте одинаковый ключ шардирования.
+Замена партиции не перераспределяет строки между шардами. Подробнее об этом
+в разделе [«Раскладка по шардам»](../architecture/storage.md#раскладка-по-шардам).
## Типичная ошибка
@@ -82,13 +88,13 @@
Выбирайте проверку под возможную ошибку. Этот подход описан в
[справочнике проверок](../architecture/testing.md).
-## Как проверить себя
+## Проверка результата
-Деловой ключ — `page_path` внутри дня. Получите прямой агрегат из
-`dds.event_v` за день и соедините его с `dm.top_pages_daily_v` полным
-внешним соединением по ключу. Посчитайте строки, где ключ отсутствует с
-одной из сторон или различается любой из четырех счетчиков. Расхождений
-должно быть ноль.
+Рассчитайте те же показатели по `dds.event_v` за выбранный день.
+Сопоставьте результат со строками `dm.top_pages_daily_v` за этот день
+через `FULL OUTER JOIN` по `page_path`. Найдите страницы, которые есть
+только с одной стороны, и строки, где различается хотя бы один из четырех
+счетчиков. Расхождений должно быть ноль.
Перед полным сравнением проверьте дневные суммы:
@@ -98,10 +104,10 @@
Сумма `visitors` по страницам повторно учитывает посетителя, открывшего
несколько страниц. Число посетителей за день берите из `dm.daily_traffic_v`.
-## Что должно получиться
+## Результат работы
-- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
- использует тот же ключ шардирования, что и целевая таблица;
+- [ ] у вас есть пять объектов витрины; промежуточная таблица объявлена через
+ `AS` и использует тот же ключ шардирования, что и основная;
- [ ] в `dm.top_pages_daily_v` явно перечислены колонки, включая
`_load_id` и `_load_ts`;
- [ ] ваш запрос сам определяет дни пересборки по состоянию витрины;
diff --git a/docs/labs/1-3-session-overview.md b/docs/labs/1-3-session-overview.md
index 3e71140..7c0dc24 100644
--- a/docs/labs/1-3-session-overview.md
+++ b/docs/labs/1-3-session-overview.md
@@ -1,14 +1,15 @@
-# Задание 3: `dm.session_overview`
+# Задание 3: визиты по устройствам, `dm.session_overview`
-**Чему учит:** хранить слагаемые для расчета средних и долей и понимать,
+Вы соберете дневные показатели визитов по устройствам. В таблице будут
+храниться суммы и счетчики для расчета средних и долей. Заодно проверите,
когда сложение числа посетителей по устройствам завышает аудиторию.
-## Сначала прочитать
+## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
-## Для чего нужна витрина
+## Показатели визитов
Витрина нужна, чтобы по дням сравнивать визиты с телефона и компьютера: их
число, длительность, просмотры страниц и частоту покупок.
@@ -17,13 +18,19 @@
устройствам. Почему такие итоги хранят в отдельной таблице, объясняет
[справочник DM](../architecture/dm.md).
-## Что сделать
+## Создание витрины
-Источник — `dds.session_v`. Зерно — **день сессии × категория устройства**.
-Зерно новое, поэтому витрина хранится готовым агрегатом — см. [«Как принято
-на ступени 1»](README.md#как-принято-на-ступени-1). Создайте пять объектов:
-`dm.session_overview_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
-публичное представление `dm.session_overview_v`. Ключ партиции — день.
+Читайте готовые сессии из `dds.session_v`. В результате нужна одна строка
+на категорию устройства за день: например, итоги всех визитов с телефонов
+за один день.
+
+Создайте пять объектов по [общим правилам ступени 1](README.md#создание-и-обновление-витрин):
+
+- основные таблицы `dm.session_overview_rep` и `dm.session_overview_dist`;
+- промежуточные таблицы `dm.session_overview_stage_rep` и `dm.session_overview_stage_dist`;
+- представление для чтения `dm.session_overview_v`.
+
+Разбейте данные на партиции по дням.
### Состав колонок
@@ -47,19 +54,20 @@
категорий встречаются `desktop`, `mobile` и `tablet`. Строку для `tv`
создавайте только при появлении таких визитов.
-Вместо `avg_duration_seconds` храните слагаемые. Среднее из средних по трем
-устройствам даст неверный ответ за день: число сессий в категориях
-различается, поэтому средние нужно взвешивать. Потребитель делит сумму
-`duration_seconds_total` на сумму `sessions`. Долю отказов считают так же:
-сумму `bounce_sessions` делят на сумму `sessions`, а не усредняют готовые
-доли.
+Для расчета средней длительности храните общую длительность и число сессий.
+Готовую среднюю `avg_duration_seconds` сохранять не нужно. Число сессий
+в категориях различается, поэтому среднее из трех средних даст неверный
+ответ за день. Чтобы получить его правильно, разделите сумму
+`duration_seconds_total` на сумму `sessions`. Для доли отказов разделите
+сумму `bounce_sessions` на сумму `sessions`. Усреднение готовых долей тоже
+даст неверный результат.
-### Ритм пересборки
+### Дни для пересчета
-Сессии прожитого дня больше не меняются. Как в задании 2, определяйте
-диапазон от первого несобранного прожитого дня до последнего прожитого.
-Соберите его в донор и замените дневные партиции. Добавьте задачу в
-`dm_transform`.
+Сессии завершившегося модельного дня больше не меняются. Как в задании 2,
+найдите первый завершившийся день, которого еще нет в витрине. Рассчитайте
+все дни от него до последнего завершившегося, запишите их в промежуточную
+таблицу и замените дневные партиции основной. Добавьте задачу в `dm_transform`.
### Ключ шардирования
@@ -68,18 +76,18 @@
`device_category`, сначала посчитайте строки на каждом шарде. Затем
объясните, почему такое распределение вас устраивает.
-Ключ донора — тот же, что у цели (см. [«Как принято на ступени
-1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
-[справочника хранилища](../architecture/storage.md)).
+У основной и промежуточной таблиц должен быть одинаковый ключ шардирования:
+см. [общие правила ступени 1](README.md#создание-и-обновление-витрин).
-## Почему сумма `visitors` сходится с `dm.daily_traffic_v`
+## Число посетителей за день
Сравните сумму `visitors` по устройствам за день с `dm.daily_traffic_v`. На
-стенде значения совпадут: устройство приписано куке на всю жизнь, поэтому за
-день она учитывается только в одной строке. Это свойство мира стенда; см.
+стенде значения совпадут: одному `client_id` всегда соответствует одно
+устройство, поэтому за день он учитывается только в одной категории.
+Это свойство мира стенда; см.
раздел «Паспорт куки» в [словаре понятий](../../CONTEXT.md).
-Если появятся визиты одной куки с двух устройств, сумма начнет завышать
+Если появятся визиты одного `client_id` с двух устройств, сумма начнет завышать
аудиторию. Проверки счетчиков по категориям этого не обнаружат. Число
посетителей дня берите из `dm.daily_traffic_v`, где оно рассчитано для дня
целиком.
@@ -91,12 +99,13 @@
событий совпало с источником, а число сессий завышено, вероятная причина —
сборка витрины из событий вместо сессий.
-## Как проверить себя
+## Проверка результата
-Деловой ключ — `device_category` внутри дня. Получите прямой агрегат из
-`dds.session_v` за день. Соедините его с `dm.session_overview_v` полным
-внешним соединением по ключу. Посчитайте строки, где ключ отсутствует с
-одной из сторон или различается любой счетчик. Расхождений должно быть ноль.
+Рассчитайте те же показатели по `dds.session_v` за выбранный день.
+Сопоставьте результат со строками `dm.session_overview_v` за этот день
+через `FULL OUTER JOIN` по `device_category`. Найдите категории, которые
+есть только с одной стороны, и строки, где различается хотя бы один
+счетчик. Расхождений должно быть ноль.
Еще две проверки дневных итогов:
@@ -104,12 +113,12 @@
повторного учета визитов;
- сумма `events_total` равна сумме `events_total` по сессиям того же дня.
-## Что должно получиться
+## Результат работы
-- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
- использует ключ шардирования целевой таблицы;
+- [ ] у вас есть пять объектов витрины; промежуточная таблица объявлена через
+ `AS` и использует ключ шардирования основной;
- [ ] вы явно перечислили колонки в `dm.session_overview_v` и собрали
показатели из готовых сессий `dds.session_v`;
-- [ ] в таблице хранятся слагаемые для средних и долей;
+- [ ] в таблице хранятся суммы и счетчики для расчета средних и долей;
- [ ] проверка `session_overview` в `homework_check` прошла успешно;
- [ ] витрина обновляется при запуске `dm_transform`.
diff --git a/docs/labs/1-4-dq-errors-daily.md b/docs/labs/1-4-dq-errors-daily.md
index a9506f5..423c658 100644
--- a/docs/labs/1-4-dq-errors-daily.md
+++ b/docs/labs/1-4-dq-errors-daily.md
@@ -1,28 +1,42 @@
-# Задание 4: `dm.dq_errors_daily`
+# Задание 4: ошибки загрузки по дням, `dm.dq_errors_daily`
-**Чему учит:** считать брак по дню загрузки и сохранять сводку дольше
-исходных строк.
+Вы соберете дневную сводку ошибок загрузки. Ее итоги должны сохраниться
+и после того, как исходные сообщения будут удалены из хранилища.
-## Сначала прочитать
+## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
-## Для чего нужна витрина
+## Ошибки загрузки и их источники
-Сводку открывают при росте брака в потоке: смотрят, каких ошибок стало
-больше и сколько строк забраковано. Причину конкретной ошибки исследуют
-в `ods.event_errors_dist` и `ods.order_errors_dist`, где есть сырой текст и
-координаты доставки.
+Если сообщение не удалось разобрать, стенд записывает его в таблицу ошибок.
+Такие строки дальше называются браком. Вы соберете сводку, по которой можно
+увидеть, каких ошибок стало больше и сколько строк забраковано.
-## Что сделать
+Источники сводки:
-Зерно — **день загрузки × источник × класс брака**. В источнике зерно —
-строка брака. Зерно новое, поэтому витрина хранится готовым агрегатом — см.
-[«Как принято на ступени 1»](README.md#как-принято-на-ступени-1). Создайте
-пять объектов: `dm.dq_errors_daily_rep` / `_dist`, донор `_stage_rep` /
-`_stage_dist` и публичное представление `dm.dq_errors_daily_v`. Ключ
-партиции — день загрузки.
+- `ods.event_errors_dist`: ошибки разбора событий;
+- `ods.order_errors_dist`: ошибки разбора выгрузки заказов.
+
+В этих таблицах есть исходный текст сообщения и сведения о его доставке:
+они пригодятся при поиске причины ошибки. Представлений `_v` у таблиц ошибок
+нет, создавать их для задания не нужно. Чтение локальной таблицы `_rep`
+даст строки только одного шарда из двух, поэтому используйте `_dist`.
+
+## Создание сводки
+
+В результате нужна одна строка на день загрузки, источник и класс ошибки.
+Например, ошибки `field_invalid` в заказах за один день загрузки попадут
+в одну строку с их количеством.
+
+Создайте пять объектов по [общим правилам ступени 1](README.md#создание-и-обновление-витрин):
+
+- основные таблицы `dm.dq_errors_daily_rep` и `dm.dq_errors_daily_dist`;
+- промежуточные таблицы `dm.dq_errors_daily_stage_rep` и `dm.dq_errors_daily_stage_dist`;
+- представление для чтения `dm.dq_errors_daily_v`.
+
+Разбейте данные на партиции по дням загрузки.
### Состав колонок
@@ -41,16 +55,17 @@
Служебная `_load_ts` витрины описывает сборку сводки; ее дата может
отличаться от `load_date`.
-У событий и заказов свои классы брака. `source` входит в зерно, чтобы
-одинаковые имена классов с разным смыслом не объединялись в одну строку.
+У событий и заказов свои классы брака. Группируйте строки также по `source`,
+чтобы одноименные классы разных источников не объединялись.
-### Ритм пересборки
+### Пересчет и хранение итогов
-Брак хранится месяц по `TTL`, сырые данные — трое суток. Сводка хранится
-бессрочно: итоги нужны и после удаления исходных строк.
+Правило `TTL` удаляет строки брака через месяц, сырые данные через трое суток.
+Сводку храните бессрочно: итоги нужны и после удаления исходных строк.
-Получите список дней загрузки, еще имеющихся в источниках. Соберите их в
-донор и замените только эти партиции, сохранив остальные.
+Получите список дней загрузки, еще имеющихся в источниках. Рассчитайте их
+итоги в промежуточной таблице и замените только эти партиции основной таблицы.
+Более старые итоги сохраните.
В течение текущих суток ошибки еще поступают: пересчитайте этот день на
следующем прогоне.
@@ -60,36 +75,23 @@
Выберите ключ самостоятельно. Классов брака у событий и заказов вместе
меньше десятка. Объясните, насколько существенен перекос при таком объеме.
-Ключ донора — тот же, что у цели (см. [«Как принято на ступени
-1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
-[справочника хранилища](../architecture/storage.md)).
-
-## Источники: таблицы `_dist`
-
-Только это задание читает данные не через `_v`: брак нужен для диагностики
-приема и не входит в бизнес-модель. Публичных представлений у таблиц ошибок
-нет, создавать их для задания не нужно. Источники:
-
-- `ods.event_errors_dist` — ошибки разбора событий;
-- `ods.order_errors_dist` — ошибки разбора слепка заказов.
-
-Чтение локальной `_rep` даст строки одного шарда из двух: часть брака не
-попадет в сводку, хотя запрос выполнится без ошибки.
+У основной и промежуточной таблиц должен быть одинаковый ключ шардирования:
+см. [общие правила ступени 1](README.md#создание-и-обновление-витрин).
## Типичная ошибка
-Если заменить всю таблицу данными из нового донора, исчезнут итоги дней,
-истекших по `TTL` источника. Восстановить их неоткуда.
+Если заменить всю сводку новым расчетом, исчезнут итоги дней, за которые
+исходные строки уже удалены по `TTL`. Восстановить эти итоги неоткуда.
-## Как проверить себя
+## Проверка результата
-Деловой ключ — `source` и `error_class` внутри `load_date`. Проверяйте дни,
-оставшиеся хотя бы в одной таблице ошибок. Если дня за пределами `TTL` уже
-нет в источнике, это не ошибка: сверять его уже не с чем.
+Сопоставляйте строки по `load_date`, `source` и `error_class`. Проверяйте дни,
+оставшиеся хотя бы в одной таблице ошибок. Старые итоги, для которых исходные
+строки уже удалены по `TTL`, сверять не с чем.
Посчитайте строки прямо в обеих таблицах ошибок и соедините результат с
-`dm.dq_errors_daily_v` полным внешним соединением по ключу. Затем посчитайте
-строки, где ключ отсутствует с одной из сторон или различается `error_rows`.
+`dm.dq_errors_daily_v` через `FULL OUTER JOIN` по этим трем полям. Посчитайте
+строки, которые есть только с одной стороны или у которых различается `error_rows`.
Расхождений должно быть ноль.
Сумма `error_rows` по проверяемым дням равна общему числу строк в
@@ -99,10 +101,10 @@
Если в источниках нет брака, витрина должна быть пустой. В этом случае
расхождений тоже ноль и проверка успешна.
-## Что должно получиться
+## Результат работы
-- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
- использует ключ шардирования целевой таблицы;
+- [ ] у вас есть пять объектов витрины; промежуточная таблица объявлена через
+ `AS` и использует ключ шардирования основной;
- [ ] сводка включает ошибки событий и заказов из таблиц `_dist`;
- [ ] ваш запрос пересобирает только дни, еще доступные в источниках,
сохраняя более старые итоги;
diff --git a/docs/labs/1-5-utm-effectiveness.md b/docs/labs/1-5-utm-effectiveness.md
index e2735ab..8bbe5cc 100644
--- a/docs/labs/1-5-utm-effectiveness.md
+++ b/docs/labs/1-5-utm-effectiveness.md
@@ -1,30 +1,38 @@
-# Задание 5: `dm.utm_effectiveness`
+# Задание 5: показатели рекламы, `dm.utm_effectiveness`
-**Чему учит:** различать сумму покупки со слов клиента (`declared_`) и
-серверную выручку, находить причины их расхождения.
+Вы подготовите показатели рекламных каналов по событиям сайта. Суммы покупок
+в этой витрине будут отличаться от выручки по заказам магазина. В конце
+работы вы сравните их и объясните причины расхождения.
-## Сначала прочитать
+## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
-## Для чего нужна витрина
+## Покупки по рекламным каналам
Аналитик сравнивает рекламные каналы по дням: трафик, добавления в корзину,
-покупки и заявленные клиентом суммы.
+покупки и их суммы из событий сайта.
-На стенде поведение и атрибуцию считают по трекеру, деньги — по бэкенду.
-Здесь сделано исключение: UTM-метки есть только в кликстриме, поэтому для
-оценки покупок по меткам используют клиентскую сумму.
+События сайта показывают действия посетителя и рекламные метки его переходов.
+Заказы магазина служат источником выручки. Но в заказах нет UTM-меток, поэтому
+в этой витрине вы возьмете суммы из событий покупки. Их передает браузер
+посетителя; дальше они называются клиентскими суммами. Приставка `declared_`
+в имени показателя напоминает об этом источнике.
-## Что сделать
+## Создание витрины
-Читайте `dds.event_v` напрямую, без соседних витрин DM. Зерно — **день
-события × `utm_source` × `utm_medium` × `utm_campaign`**. Зерно новое,
-поэтому витрина хранится готовым агрегатом — см. [«Как принято на ступени
-1»](README.md#как-принято-на-ступени-1). Создайте пять объектов:
-`dm.utm_effectiveness_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
-публичное представление `dm.utm_effectiveness_v`. Ключ партиции — день.
+Читайте события из `dds.event_v`, без других витрин DM. В результате нужна
+одна строка на день и сочетание трех меток: `utm_source`, `utm_medium`,
+`utm_campaign`. События одного дня с одинаковыми метками попадают в одну группу.
+
+Создайте пять объектов по [общим правилам ступени 1](README.md#создание-и-обновление-витрин):
+
+- основные таблицы `dm.utm_effectiveness_rep` и `dm.utm_effectiveness_dist`;
+- промежуточные таблицы `dm.utm_effectiveness_stage_rep` и `dm.utm_effectiveness_stage_dist`;
+- представление для чтения `dm.utm_effectiveness_v`.
+
+Разбейте данные на партиции по дням.
### Состав колонок
@@ -54,31 +62,32 @@
Для `utm_campaign` выбран тип `String`: имен кампаний со временем становится
много, и затраты на словарь `LowCardinality` перестают окупаться.
-### Ритм пересборки
+### Дни для пересчета
-События прожитого дня больше не меняются. Как в задании 2, соберите в донор
-дни от первого несобранного до последнего прожитого и замените дневные
-партиции. Добавьте задачу в `dm_transform`.
+События завершившегося модельного дня больше не меняются. Как в задании 2,
+найдите первый завершившийся день, которого еще нет в витрине. Рассчитайте
+все дни от него до последнего завершившегося, запишите их в промежуточную
+таблицу и замените дневные партиции основной. Добавьте задачу в `dm_transform`.
### Ключ шардирования
Выберите ключ самостоятельно. Если берете хеш одной из трех UTM-колонок,
посчитайте долю строк с ее самым частым значением и обоснуйте выбор.
-Ключ донора — тот же, что у цели (см. [«Как принято на ступени
-1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
-[справочника хранилища](../architecture/storage.md)).
+У основной и промежуточной таблиц должен быть одинаковый ключ шардирования:
+см. [общие правила ступени 1](README.md#создание-и-обновление-витрин).
-## Атрибуция по событию, а не по визиту
+## Рекламные метки событий
Группируйте события по их собственным меткам. Модели «первое касание» и
«последнее касание» здесь не рассчитываются.
В `dds.session_v` записаны UTM первого события визита. Распределение по
каналам может отличаться от этой витрины, и это не означает ошибку в
-расчете. В отчете различайте атрибуцию визитов и событий.
+расчете. Укажите в отчете, что распределяете по рекламным каналам отдельные
+события. Такое распределение называют атрибуцией.
-## Прямой трафик остается в витрине
+## События без рекламных меток
Сохраните события без меток: прямой трафик обычно крупнейший канал, и без
него доля рекламы будет завышена. Если отфильтровать такие строки, дневные
@@ -108,13 +117,13 @@
причины можно посмотреть в `dm.purchase_vs_orders_v`, где выделены классы
расхождений.
-## Как проверить себя
+## Проверка результата
-Деловой ключ — `utm_source`, `utm_medium`, `utm_campaign` внутри дня.
-Получите прямой агрегат из `dds.event_v` за день и соедините его с
-`dm.utm_effectiveness_v` полным внешним соединением по ключу. Посчитайте
-строки, где ключ отсутствует с одной из сторон или различается любая из пяти
-величин. Расхождений должно быть ноль.
+Рассчитайте те же показатели по `dds.event_v` за выбранный день.
+Сопоставьте результат со строками `dm.utm_effectiveness_v` за этот день
+через `FULL OUTER JOIN` по `utm_source`, `utm_medium` и `utm_campaign`.
+Найдите сочетания меток, которые есть только с одной стороны, и строки,
+где различается хотя бы один из пяти показателей. Расхождений должно быть ноль.
Дополнительно сверьте дневные итоги:
@@ -126,10 +135,10 @@
Сумма `visitors` по строкам повторно учитывает одного посетителя,
приходящего по разным меткам. Для числа посетителей дня она не подходит.
-## Что должно получиться
+## Результат работы
-- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
- использует ключ шардирования целевой таблицы;
+- [ ] у вас есть пять объектов витрины; промежуточная таблица объявлена через
+ `AS` и использует ключ шардирования основной;
- [ ] строки без UTM-меток сохранены, а `declared_revenue` хранится
в `Decimal(18, 2)`;
- [ ] вы записали, почему сумма отличается от `dm.revenue_daily_v`;
diff --git a/docs/labs/2-1-superset-chart.md b/docs/labs/2-1-superset-chart.md
index fcb68eb..3d59b54 100644
--- a/docs/labs/2-1-superset-chart.md
+++ b/docs/labs/2-1-superset-chart.md
@@ -1,24 +1,27 @@
# Свой график в Superset
-**Чему учит:** увидеть, как собственный результат из хранилища становится
-отчетом в BI.
+В этой работе вы построите график по своей витрине и проверите числа
+на нем SQL-запросом.
-## Сначала прочитать
+## Подготовка
- [«Немного Superset»](../handbook/superset.md);
- [задание 3: `dm.session_overview`](1-3-session-overview.md).
-## На какой вопрос ответит график
+## График отказов по устройствам
-В задании 3 вы собрали витрину `dm.session_overview_v`. Теперь покажите, с
-каких устройств чаще уходят после первой страницы. Постройте столбиковый
-график доли отказов по `device_category` за весь период витрины. Измерение
-одно — `device_category`, метрика тоже одна.
+В задании 3 первой ступени вы собрали витрину `dm.session_overview_v`.
+Теперь покажите, с каких устройств чаще уходят после единственного события.
+Такие визиты в стенде считаются отказами. Постройте столбиковый график доли
+отказов по `device_category` за весь период витрины. Каждый столбик должен
+показывать долю для одной категории устройств.
-## Что сделать
+## Настройка графика
-Заведите физический датасет в подключении к `ClickHouse`: схема `dm`, объект
-`session_overview_v`. Назовите по-русски хотя бы две колонки:
+Добавьте представление как источник данных для графиков. В Superset такой
+источник называется физическим датасетом. Выберите подключение `ClickHouse`,
+схему `dm` и объект `session_overview_v`. Задайте русские подписи хотя бы двум
+колонкам:
- `device_category` — «Категория устройства»;
- `report_date` — «Дата отчета».
@@ -32,10 +35,10 @@
| `SQL expression` | `SUM(bounce_sessions) / SUM(sessions)` |
| `D3 format` | `.1%` |
-Доля считается из сумм слагаемых: среднее готовых долей соврет. Поэтому
-метрике нужно это выражение. Сохраните ее в датасете, а не в графике:
-подписи и формат задаются в датасете, а график их использует. Так во всех
-будущих графиках у метрики будут одинаковые подписи. Это правило описано в
+Выражение делит общее число отказов на общее число сессий. Усреднение дневных
+долей может дать неверный результат: число сессий в разные дни различается.
+Сохраните метрику в датасете, чтобы все будущие графики использовали одно
+выражение, подпись и формат. Это правило описано в
[справочнике Superset](../architecture/superset.md#дерево-импорта).
Не используйте вкладку «Custom SQL» конструктора графика. Созданная там
@@ -58,7 +61,7 @@ FROM dm.session_overview_v;
Стендовые объекты не меняйте. Что переживает `make up`, а что стирает
`make clean`, описано в главе [«Немного Superset»](../handbook/superset.md#стендовые-объекты-и-ваши).
-## Как проверить себя
+## Проверка результата
Автоматической проверки этого задания нет. Сверьте числа на графике
с результатом запроса в SQL Lab:
@@ -76,7 +79,7 @@ ORDER BY device_category;
проценты с одним знаком, как формат `.1%`, поэтому числа должны совпасть
один в один.
-## Что должно получиться
+## Результат работы
- [ ] у вас есть физический датасет `dm.session_overview_v` в подключении
`ClickHouse`;
diff --git a/docs/labs/3-1-sessions-by-hand.md b/docs/labs/3-1-sessions-by-hand.md
index 937b00c..4b1da26 100644
--- a/docs/labs/3-1-sessions-by-hand.md
+++ b/docs/labs/3-1-sessions-by-hand.md
@@ -1,35 +1,35 @@
-# Нарежьте сессии сами
+# Сессии из событий
-**Чему учит:** собрать сессии из событий, сравнить их число с `VisitID`
-источника и найти события, из-за которых расчеты расходятся.
+Вы самостоятельно объедините события сайта в сессии и сравните их число
+с готовыми визитами источника, обозначенными `VisitID`. Затем найдете
+события, из-за которых расчеты расходятся.
-## Сначала прочитать
+## Подготовка
- [Хранилище и его слои, раздел DDS](../handbook/storage-layers.md#dds-язык-бизнеса);
- [формат события: Идентификаторы и время](../formats/clickstream-event.md#идентификаторы-и-время).
-## Для чего нужны сессии
+## События и визиты
Визит, или сессия, объединяет действия посетителя за один заход на сайт.
По визитам аналитик считает длительность и глубину просмотра.
В хранилище сессии собираются из отдельных событий.
-Сверьте самостоятельный расчет с `VisitID`, чтобы увидеть, как из событий
-получается сессия.
-## Что сделать
+## Расчет сессий
-### Нарезка
+### Границы визитов
В SQL Lab или DBeaver под `analyst` напишите запрос к `ods.event_v`.
-Для своей нарезки и эталона возьмите одни и те же строки за все доступные
+Для своего расчета и эталона возьмите одни и те же строки за все доступные
дни: для расследования понадобятся события соседних дней.
Выберите `ClientID`, `UTCEventTime`, `EventDate`, `EventType` и `VisitID`.
В `dds.event_v` эталонного `VisitID` нет.
-Для начала считайте визитом последовательные события одной куки (`ClientID`);
-пауза дольше 30 минут открывает новый визит.
+`ClientID` хранится в cookie браузера и обозначает посетителя в событиях
+сайта. Для начала считайте визитом последовательные события одного
+`ClientID`: пауза дольше 30 минут открывает новый визит.
Учитывайте события всех типов. Упорядочите их по `UTCEventTime`.
-Присвойте каждому событию номер своей сессии внутри куки.
+Присвойте каждому событию номер своей сессии внутри `ClientID`.
Подсказка: оконные функции.
Днем сессии считайте `EventDate` ее первого события: дата из `UTCEventTime`
@@ -38,7 +38,8 @@
### Сверка с эталоном
-В эталоне посчитайте число визитов каждой куки за модельный день:
+Посчитайте по эталонному `VisitID` число визитов каждого `ClientID`
+за модельный день:
```sql
SELECT ClientID, EventDate, uniqExact(VisitID) AS reference_sessions
@@ -47,46 +48,48 @@ GROUP BY ClientID, EventDate
ORDER BY EventDate, ClientID;
```
-Посчитайте свои сессии по куке и дню сессии.
-Соедините два готовых агрегата через `FULL OUTER JOIN` по куке и дню.
-В результате полного соединения останутся ключи обеих сторон: при расхождении
-на вашей стороне ключа может не быть совсем. Учтите такие строки при сравнении.
-Выведите сводку по дням с числом разошедшихся кук и список этих кук
-с днем и обоими значениями счета. Общая сумма за день могла бы скрыть
-взаимную компенсацию ошибок разных кук. По списку видно, у кого искать причину.
+Посчитайте свои сессии по `ClientID` и дню сессии. Соедините результаты двух
+расчетов через `FULL OUTER JOIN` по этим полям. Учтите строки, которые есть
+только с одной стороны: полное соединение сохранит их в результате.
+
+Выведите сводку по дням с числом `ClientID`, для которых расчеты разошлись,
+и список этих идентификаторов с днем и обоими значениями. Общая сумма за день
+могла бы скрыть ошибки: для одного посетителя вы посчитали лишнюю сессию,
+для другого пропустили. По списку видно, чьи события нужно исследовать.
Расхождений будет всего несколько. Если их нет, мир прожил мало дней.
Прогоните несколько дней через [даги модельного времени](../handbook/airflow-dags.md#даги-мира)
и повторите сверку.
-### Расследование
+### Поиск причины расхождения
-Выберите одну разошедшуюся куку. Выведите ее события за два соседних дня
+Выберите один `ClientID` с расхождением. Выведите его события за два соседних дня
с `UTCEventTime`, `EventDate`, `VisitID` и номером своей сессии.
-Сопоставьте сессии через события: общего ключа у двух нарезок нет,
+Сопоставьте сессии через события: общего ключа у двух расчетов нет,
зато у каждого события есть и `VisitID`, и присвоенный вами номер.
Найдите два соседних события, между которыми границы визитов разошлись.
-Запишите куку, день, время обоих событий и причину одним предложением.
+Запишите `ClientID`, день, время обоих событий и причину одним предложением.
Уточните определение визита и добейтесь нуля расхождений во всех днях.
-## Как проверить себя
+## Проверка результата
Сохраните исходный и уточненный расчеты, результаты сверки и найденные строки.
Таблицы и представления создавать не нужно. Задачи в `homework_check`
для этого задания нет: вы проверяете расчет своим запросом.
В `sql/dds/session_rebuild.sql` и
-`sql/dq/sessions_vs_reference.sql` лежат готовые запросы нарезки и сверки.
+`sql/dq/sessions_vs_reference.sql` лежат готовые запросы расчета сессий и сверки.
Откройте их после собственного решения и расследования. Сравните запросы
со своими.
-Сопоставление со стендовой нарезкой по `session_id` остается на ступень 4.
+На ступени 4 вы сравните свой результат с готовыми сессиями стенда
+по `session_id`.
-## Что должно получиться
+## Результат работы
- [ ] ваш запрос собирает сессии из всех событий `ods.event_v` за все
доступные дни;
-- [ ] у вас есть сводка расхождений по дням и список кук, полученные через
+- [ ] у вас есть сводка расхождений по дням и список `ClientID`, полученные через
`FULL OUTER JOIN`;
-- [ ] вы объяснили причину одного расхождения и записали куку, день и два
+- [ ] вы объяснили причину одного расхождения и записали `ClientID`, день и два
события, на которых оно видно;
- [ ] уточненный расчет дает ноль расхождений во всех днях.
diff --git a/docs/labs/4-1-session-pipeline.md b/docs/labs/4-1-session-pipeline.md
index cb16984..05a3f9c 100644
--- a/docs/labs/4-1-session-pipeline.md
+++ b/docs/labs/4-1-session-pipeline.md
@@ -1,16 +1,17 @@
-# Перепишите нарезку стенда
+# Расчет сессий в стенде
-**Чему учит:** переписать расчет сессий в готовом конвейере и проверить
+На прошлой ступени вы рассчитали сессии отдельным запросом. Теперь
+перепишете расчет внутри стенда, запустите его через Airflow и проверите
результат по исходным событиям.
-## Сначала прочитать
+## Подготовка
-- [задание «Нарежьте сессии сами»](3-1-sessions-by-hand.md);
+- [задание «Сессии из событий»](3-1-sessions-by-hand.md);
- [модель сессии DDS](../architecture/dds/session.md);
- [дневной конвейер трансформаций](../architecture/etl.md#объём-прогона);
- [раскладка по шардам](../architecture/storage.md#раскладка-по-шардам).
-## Что предстоит сделать
+## Файлы для изменения
Даг, таблицы сессий и проверка их числа уже созданы.
Перепишите только три файла:
@@ -19,56 +20,57 @@
- `sql/dds/session_rebuild.sql`;
- `sql/dds/session_replace.sql`.
-Удалите из них готовый SQL и соберите свою реализацию. Параметры и порядок
-запросов заданы в `dds_transform`. DDL задает целевые таблицы и донор. Донор —
-служебная таблица, где день собирается перед заменой. В
-`sessions_vs_reference.sql` число сессий сравнивается с `VisitID` из ODS.
+Удалите из них готовый SQL и напишите свой. Даг `dds_transform` передаст
+параметры и выполнит запросы по порядку. Таблицы уже созданы: основные хранят
+готовые сессии, промежуточные нужны для нового расчета перед заменой данных.
+Запрос `sessions_vs_reference.sql` сравнивает число сессий с `VisitID` из ODS.
-## Как даг запускает ваши запросы
+## Выполнение запросов в Airflow
Объем прогона — дни, которые соберет один запуск. Его вычисляет первый файл.
| Файл | Вход | Результат |
|---|---|---|
-| `session_scope.sql` | `{position:UInt16}` — номер первого несыгранного дня | `first_day` и `last_day` |
+| `session_scope.sql` | `{position:UInt16}` — номер первого еще не прожитого модельного дня | `first_day` и `last_day` |
| `session_rebuild.sql` | `{first_day:Date}`, `{last_day:Date}`, `{load_id:String}` | Сессии в `dds.session_stage_dist` |
| `session_replace.sql` | День в `{{ params.day }}` | Партиция дня в `dds.session_rep` |
-Первый прожитый день, которого еще нет в DDS, становится `first_day`.
-`last_day` — последний прожитый день. Начало шкалы нужно взять из событий ODS:
-`position` считает дни от него. Живой модельный день еще не закончен, поэтому
-сессии за него не собираются.
+`first_day` должен быть первым завершившимся модельным днем, которого еще
+нет в DDS, а `last_day` последним завершившимся днем. Начальную дату возьмите
+из событий ODS: `position` считает дни от нее. Текущий модельный день еще
+не закончен, поэтому сессии за него не рассчитываются.
-Если в ODS нет ни одного события, запрос не возвращает строк. Если DDS уже
-догнал мир, запрос возвращает строку с `first_day = NULL`. В этом случае группа
-сессий в Airflow получает состояние `skipped`.
+Если в ODS нет ни одного события, запрос не возвращает строк. Если все
+завершившиеся дни уже есть в DDS, запрос возвращает строку с `first_day = NULL`.
+Тогда Airflow пропускает группу задач расчета сессий: ее состояние будет `skipped`.
`session_rebuild.sql` сначала очищает `dds.session_stage_rep` на всем кластере.
-Затем он собирает в `dds.session_stage_dist` все дни отрезка. Добавьте к
+Затем он записывает в `dds.session_stage_dist` расчет за все выбранные дни. Добавьте к
вставке `SETTINGS distributed_foreground_insert = 1`. Так строки успеют попасть
на шарды до замены партиций.
-Цель `session_replace.sql` — локальная таблица `dds.session_rep`, источник —
-локальный донор `dds.session_stage_rep`. Операция выполняется на всем кластере.
+Запрос `session_replace.sql` заменяет партицию в основной `dds.session_rep`
+готовой партицией из промежуточной `dds.session_stage_rep`.
+Операция выполняется на всем кластере, на локальных таблицах каждого шарда.
Не ставьте точку с запятой в конце `session_scope.sql`. Драйвер добавляет формат
-ответа, и точка с запятой превратит строку в мультизапрос, который ClickHouse
+ответа. С точкой с запятой получится несколько команд, которые ClickHouse
отклонит. День для замены подставляет шаблон Jinja в Airflow. Параметр
ClickHouse в `ALTER ... PARTITION` здесь не работает.
-## Нарежьте сессии
+## Правила расчета сессий
Источник сборки — `dds.event_v`. Одна строка результата представляет один
-визит. На третьей ступени вы уже нарезали сессии:
+визит. На третьей ступени вы уже получили правила разделения событий на сессии:
- сессия не пересекает модельные сутки;
- пауза больше 30 минут открывает новую сессию;
-- в нарезке участвуют события всех типов.
+- в расчете участвуют события всех типов.
-Пересборка добавляет требования к повторяемости. При одинаковом времени
-порядок событий задает `event_id`, поэтому полный порядок окон —
+Повторный расчет должен давать тот же результат. При одинаковом времени
+упорядочивайте события по `event_id`: порядок в оконных функциях должен быть
`(event_time, event_id)`. Один визит должен получать одинаковый `session_id`
-при каждом запуске. Тогда его можно сравнить с сохраненным результатом.
+при каждом запуске.
Вычислите идентификатор как `cityHash64(client_id, min(event_time))`.
Состав и типы строки заданы в `dds.session_rep`. Заполните начало, конец и
@@ -76,11 +78,11 @@ ClickHouse в `ALTER ... PARTITION` здесь не работает.
покупки. Возьмите входную и выходную страницы, UTM первого события, устройство
и город. Заполните также служебные колонки из схемы таблицы.
-Пишите через `dds.session_stage_dist`: место строки определит ключ
-`client_id`. При записи прямо в локальную таблицу оно зависело бы от ноды,
+Пишите через `dds.session_stage_dist`: шард для строки определяется по
+`client_id`. При записи прямо в локальную таблицу он зависел бы от узла,
которая выполнила запрос.
-## Запустите свою реализацию
+## Запуск нового расчета
Готовые дни повторно не собираются. Чтобы новый SQL точно выполнился, выберите
последний собранный день:
@@ -90,8 +92,8 @@ SELECT max(session_date)
FROM dds.session_v;
```
-Берите последний день: более ранняя дыра расширит объем до всех последующих
-прожитых дней.
+Берите последний день: если удалить более ранний, расчет придется повторить
+и за все последующие завершившиеся дни.
Сохраните десять строк с идентификатором, границами, длительностью и счетчиками:
@@ -113,7 +115,7 @@ ORDER BY client_id, started_at, session_id
LIMIT 10;
```
-Создайте в DBeaver подключение к первой ноде по [инструкции из
+Создайте в DBeaver подключение к первому узлу по [инструкции из
README](../../README.md#подключиться-из-dbeaver). Укажите пользователя `etl` и
пароль `CLICKHOUSE_ETL_PASSWORD` из `.env`. У этой учетной записи есть право
менять таблицы DDS на кластере. Подставьте найденную дату и удалите только ее
@@ -128,19 +130,18 @@ DROP PARTITION 'YYYY-MM-DD';
группу `session`: в ней должны выполниться задачи `scope` и `rebuild`, а также
`replace` для выбранного дня. Если после третьей ступени мир ушел вперед,
в группе появятся задачи `replace` и для последующих прожитых дней. Следом
-выполнятся витрины и `dq_check`.
+обновятся витрины и выполнится `dq_check`.
Если прогон дошел до замены, но упал на проверке, ошибочная партиция уже
находится в DDS. После правки снова удалите тот же день перед следующим
запуском. Иначе день останется заполненным, а новая сборка для него не
выполнится.
-## Как проверить себя
+## Проверка результата
Сначала повторите запрос с десятью строками. `session_id`, границы,
-длительность и счетчики должны совпасть с сохраненным результатом. Так вы
-завершите отложенное с третьей ступени сопоставление со стендовой нарезкой по
-`session_id`.
+длительность и счетчики должны совпасть с сохраненным результатом.
+Это проверит, что ваш расчет восстановил те же сессии с теми же идентификаторами.
Задача `dq_check.sessions_vs_reference` должна стать зеленой. Та же проверка
видна в хранилище:
@@ -152,33 +153,34 @@ WHERE check_name = 'sessions_vs_reference'
ORDER BY data_date;
```
-Эта сверка сравнивает число сессий каждой куки за день с `VisitID`. Нулевой
-`failed_rows` не подтверждает границы, страницы и UTM: две ошибки могут
-оставить тот же счет. Возьмите поштучную сверку из третьей ступени. Для одной
-куки выбранного дня сопоставьте границы `VisitID` по событиям с границами
-строк `dds.session_v`. По исходным событиям проверьте страницы, UTM, устройство
+Эта сверка сравнивает число сессий каждого `client_id` за день с `VisitID`.
+При `failed_rows = 0` число сессий совпало, но их границы, страницы и UTM
+еще могут различаться. Как на третьей ступени, проверьте отдельные сессии.
+Для одного `client_id` выбранного дня найдите начало и конец каждого
+`VisitID` по событиям и сравните с границами строк `dds.session_v`.
+По исходным событиям проверьте страницы, UTM, устройство
и город. Страницы и UTM не входят в сохраненные десять строк: готовый SQL
стенда выбирал их через `argMin` и `argMax` только по `event_time`. При равном
времени ожидаемое значение определяйте по исходным событиям и полному порядку
`(event_time, event_id)`.
-Осталась физическая раскладка. Напишите отдельный запрос к
-`dds.session_dist`. Для каждой строки он должен:
+Проверьте также, на каком шарде лежит каждая строка. Напишите отдельный
+запрос к `dds.session_dist`. Он должен:
-1. получить фактическую ноду через `hostName()`;
-2. пересчитать ожидаемую ноду по `cityHash64(client_id)`;
-3. посчитать строки, у которых ноды не совпали.
+1. получить фактический узел строки через `hostName()`;
+2. определить ожидаемый узел по `cityHash64(client_id)`;
+3. посчитать строки, у которых фактический и ожидаемый узлы не совпали.
У кластера два равных шарда: остаток 0 соответствует `clickhouse-01`, остаток
-1 — `clickhouse-02`. Сгруппируйте ответ по фактической ноде. На каждой ноде
+1 — `clickhouse-02`. Сгруппируйте ответ по фактическому узлу. На каждом узле
число строк на чужом шарде должно быть нулевым. Проверка числа сессий читает
Distributed-таблицу и такую ошибку не заметит.
После зеленого результата запустите `etl_pipeline` еще раз, не удаляя
-партицию. Расчет объема должен пропустить группу сессий: новых прожитых дней и
-дыр в слое нет.
+партицию. Группа сессий должна быть пропущена: все завершившиеся дни уже
+рассчитаны, новых дней и пропусков нет.
-## Что должно получиться
+## Результат работы
- [ ] вы переписали три файла `sql/dds/session_*.sql`, сохранив даг,
таблицы и проверку стенда;
@@ -186,6 +188,6 @@ Distributed-таблицу и такую ошибку не заметит.
- [ ] вы сверили десять сохраненных строк: `session_id`, границы,
длительность и счетчики совпали после пересборки;
- [ ] вы получили ноль расхождений в `sessions_vs_reference` за все
- прожитые дни и вручную сверили границы и атрибуты сессии одной куки;
+ прожитые дни и вручную сверили границы и поля сессии одного `client_id`;
- [ ] ваш запрос с `hostName()` не нашел строк на чужом шарде;
- [ ] повторный прогон без удаления данных пропустил группу сессий.
diff --git a/docs/labs/5-1-order-checks.md b/docs/labs/5-1-order-checks.md
index 3906d56..0de0c17 100644
--- a/docs/labs/5-1-order-checks.md
+++ b/docs/labs/5-1-order-checks.md
@@ -1,9 +1,10 @@
-# Научите проверки замечать ошибки в заказах
+# Проверки качества заказов
-**Чему учит:** написать проверки заказов до изменения их обработки
-и убедиться на намеренной ошибке, что проверки ее замечают.
+Перед изменением обработки заказов вы добавите две проверки качества данных.
+Затем намеренно испортите один заказ и убедитесь, что проверки обнаружили
+ошибки.
-## Сначала прочитать
+## Подготовка
- [модель заказа DDS](../architecture/dds/order.md);
- [дневной конвейер](../architecture/etl.md#объём-прогона);
@@ -13,30 +14,29 @@
и на ее включение в [`dq_check`](../../dags/dq_check.py). Готового ответа для
двух проверок из этого задания в репозитории нет.
-## Что предстоит сделать
+## Новые проверки
Добавьте две проверки и включите их в даг:
- `sql/dq/order_items_total.sql` проверяет сумму позиций заказа;
- `sql/dq/orders_vs_ods.sql` сверяет DDS с его источником;
-- `dags/dq_check.py` запускает обе проверки, публикует их строки в
+- `dags/dq_check.py` запускает обе проверки, записывает их результаты в
`dm.dq_summary` и создает для них отдельные задачи.
Разбор ODS и сборку DDS вы перепишете в следующих заданиях. Здесь сохраните
-их, даги загрузки, DDL, таблицы-доноры, Kafka и службы как есть.
+их, даги загрузки, DDL, промежуточные таблицы, Kafka и службы как есть.
Перед опытами остановите автоматическое движение мира: поставьте `world_live`
на паузу и дождитесь завершения активных прогонов. До завершения опытов не
запускайте руками `world_next_day`, `world_live_day`, `world_initialize` и
-`world_recreate`. Это условие стабильного модельного времени: одной паузы
-`world_live` для него мало.
+`world_recreate`, чтобы новые данные не меняли результат между проверками.
-## Что будут проверять ваши запросы
+## Требования к запросам
Чтобы `dq_check` мог запустить проверку, ее запрос возвращает по одной строке
на день с колонками `data_date`, `reference_rows`, `actual_rows`,
`failed_rows`, `diagnostics`. Даг сохраняет в `dm.dq_summary` только счетчики,
-статус и координаты запуска. Примеры из `diagnostics` ищите в
+статус и сведения о запуске. Примеры из `diagnostics` ищите в
журнале упавшей задачи Airflow. Ограничьте их тем же малым числом, что и
существующие запросы в `sql/dq/`.
@@ -44,46 +44,48 @@
Имя проверки - `order_items_total`. Для каждого заказа из `dds.order_v`
посчитайте сумму `item_price * item_quantity` и сравните ее с `items_total`.
-`reference_rows` считает ключи со стороны `items_total`, `actual_rows` - ключи
-со стороны вычисленной суммы, а `failed_rows` - несовпавшие значения. В
-исправном наборе первые два счетчика равны; несовпадения сумм считает
-`failed_rows`. Для диагностики достаточно `order_id`, ожидаемой суммы и
-суммы позиций.
+В `reference_rows` запишите число заказов со стороны `items_total`,
+в `actual_rows` число заказов со стороны вычисленной суммы, в `failed_rows`
+число заказов, у которых суммы не совпали. На исправных данных первые два
+счетчика равны, а `failed_rows` равен нулю. Для примеров расхождений
+достаточно `order_id`, значения `items_total` и вычисленной суммы позиций.
-Проверка относится к типизированному разбору массива `items` на границе
-ODS -> DDS. Скидку, доставку и равенство `total` здесь проверять не нужно.
+Проверка поможет заметить ошибки разбора `items` при переносе заказа
+из ODS в DDS. Скидку, доставку и равенство `total` здесь проверять не нужно.
-### DDS против ODS
+### Полнота заказов в DDS
Имя проверки - `orders_vs_ods`. Источник модели DDS здесь - текущее состояние
`ods.order_v`. С ним вы и сравниваете DDS; обращаться к внутреннему состоянию
генератора для этой проверки не нужно.
-Одним запросом соберите обе стороны на зерне дня заказа и сравните:
+Одним запросом рассчитайте итоги каждого дня заказа в обоих слоях и сравните:
- число заказов;
- максимальный `updated_at`.
-День ODS вычисляется в `Europe/Samara`, как `order_date` в DDS. Область
-проверки - объединение дней обеих сторон, включая день, который полностью
-пропал из одного слоя. Перед сравнением приведите времена к одному часовому
-поясу. `reference_rows` - число заказов ODS, `actual_rows` - число заказов DDS,
-а `failed_rows` равен единице для дня, где расходится хотя бы одно значение.
+День заказа в ODS вычисляйте в `Europe/Samara`, как `order_date` в DDS.
+Проверьте все дни, которые есть хотя бы в одном слое: так вы заметите день,
+целиком пропавший из другого. Перед сравнением приведите времена к одному
+часовому поясу. `reference_rows` содержит число заказов ODS, `actual_rows`
+число заказов DDS. В `failed_rows` запишите 1, если за день расходится
+хотя бы одно значение, и 0, если оба совпали.
Обе стороны распределены по `order_id`. Дневные агрегаты соедините через
`GLOBAL FULL OUTER JOIN` с `join_use_nulls = 1`, как в существующих сверках.
-Если разбор ODS и сборка DDS одинаково потеряют строку, слои останутся равны
-по этой проверке. Общая потеря при разборе проявится в отдельном балансе по
-`_load_id` из [следующего задания](5-2-order-parsing.md).
+Если строка потерялась при разборе в ODS, она не попадет и в DDS. Эта проверка
+не заметит потерю: данные слоев совпадут. В [следующем задании](5-2-order-parsing.md)
+вы отдельно сравните число исходных строк с числом разобранных заказов
+и ошибок за один запуск, обозначенный `_load_id`.
-## Получите красный и зеленый результаты
+## Проверка на намеренных ошибках
Сначала запустите `dq_check` на нетронутых данных. Обе новые задачи должны
завершиться успешно, а в `dm.dq_summary_v` у обоих имен не должно быть
-нарушенных дней.
+дней с расхождениями.
Выберите оплаченный заказ из последнего слепка с непустым массивом позиций и
-сохраните его `order_id` и `order_date`. Подключитесь к первой ноде из DBeaver
+сохраните его `order_id` и `order_date`. Подключитесь к первому узлу из DBeaver
по [инструкции из README](../../README.md#подключиться-из-dbeaver) под
пользователем `etl`. Прибавьте одну копейку к `items_total` этого заказа в
локальной таблице `dds.order_rep` на всем кластере:
@@ -102,7 +104,7 @@ SETTINGS mutations_sync = 2;
Запустите `etl_pipeline`. Текущее окно заказов будет заново собрано из ODS, и
исходная строка вернется в DDS. Повторный `dq_check` должен дать зеленый
-результат для нового сторожа.
+результат для новой проверки.
Теперь удалите тот же заказ из `dds.order_rep` на всем кластере:
@@ -118,9 +120,9 @@ SETTINGS mutations_sync = 2;
Если `orders_vs_ods` не обнаружил удаление, исправьте запрос до следующего
задания.
-## Как проверить себя
+## Проверка результата
-В `dm.dq_summary_v` должны быть зелены обе новые и пять прежних проверок:
+В `dm.dq_summary_v` обе новые и пять прежних проверок должны быть успешны:
```sql
SELECT check_name, sum(failed_rows) AS failed_rows, groupUniqArray(status)
@@ -132,12 +134,12 @@ ORDER BY check_name;
Проверка суммы должна охватывать каждый день `dds.order_v`. Сверка слоев
должна охватывать объединение дней ODS и DDS, включая дни только одной стороны.
-## Завершите опыт
+## Завершение работы
После восстановления заказа и успешного `dq_check` можно закончить работу или
перейти к разбору заказов, оставив модельное время на паузе.
-## Что должно получиться
+## Результат работы
- [ ] две новые проверки запускаются в `dq_check`, а их дневные результаты
появляются в `dm.dq_summary`;
diff --git a/docs/labs/5-2-order-parsing.md b/docs/labs/5-2-order-parsing.md
index dc61cf8..a37d12a 100644
--- a/docs/labs/5-2-order-parsing.md
+++ b/docs/labs/5-2-order-parsing.md
@@ -1,9 +1,10 @@
-# Перепишите разбор заказов в ODS
+# Разбор заказов в ODS
-**Чему учит:** переписать разбор заказов и на пробных сообщениях проверить,
-что годные строки и брак попадают в свои таблицы без потерь.
+Вы перепишете разбор заказов в ODS. На четырех пробных сообщениях проверите,
+что правильно заполненный заказ и сообщения с ошибками попадают
+в соответствующие таблицы без потерь.
-## Сначала прочитать
+## Подготовка
- [проверки заказов](5-1-order-checks.md);
- [прием заказов в ODS](../architecture/orders/ingestion.md);
@@ -11,24 +12,22 @@
- [`orders_ingest`](../../dags/orders_ingest.py);
- [`order_parse.sql`](../../sql/ods/order_parse.sql).
-Запрос для проверки пробных строк вы напишете сами.
-
-## Что предстоит сделать
+## Порядок работы
Сначала проведите опыт на готовом `order_parse.sql`. Затем удалите его SQL,
соберите разбор заново и повторите опыт с другим маркером.
-Не меняйте устройство `orders_ingest`, DDL, Kafka, таблиц и преобразований
-DDS. Оба опыта должны закончиться адресной очисткой только своих строк.
+Сохраните устройство `orders_ingest`, DDL, Kafka, таблиц и преобразований
+DDS. После каждого опыта удалите только добавленные вами строки.
Перед началом убедитесь, что после [предыдущего задания](5-1-order-checks.md)
-все семь DQ остались зелеными. Поставьте `world_live` на паузу, дождитесь
+все семь проверок качества прошли успешно. Поставьте `world_live` на паузу, дождитесь
активных прогонов и до завершения опыта не запускайте `world_next_day`,
`world_live_day`, `world_initialize` и `world_recreate`. Не запускайте
`etl_pipeline` между
отправкой пробной пачки и ее очисткой.
-## Четыре строки для двух ветвей
+## Пробные сообщения
Сделайте четыре сообщения с общим уникальным маркером:
@@ -39,9 +38,9 @@ DDS. Оба опыта должны закончиться адресной оч
`field_invalid`.
`not_an_object` означает, что корень JSON не объект. `keyset_mismatch` означает,
-что у объекта отличается точный набор верхнеуровневых ключей. `field_invalid`
-означает, что ключи на месте, но значение не прошло проверку типа, формата или
-вместимости.
+что у объекта есть лишние верхнеуровневые ключи или не хватает обязательных.
+`field_invalid` означает, что ключи на месте, но значение имеет неверный тип,
+формат или не помещается в заданный тип данных.
Соберите строки в `/tmp/<маркер>.jsonl`. Заготовка дает одну годную строку и
по одному примеру каждого класса брака:
@@ -79,31 +78,33 @@ docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server kafka:9092 --topic orders < "$LAB_BATCH"
```
-Запустите `orders_ingest` и найдите `_load_id` по маркеру в
-`stg.orders_raw_dist`. У четырех строк должен быть один идентификатор прогона.
+Запустите `orders_ingest`. Найдите свои строки по маркеру в
+`stg.orders_raw_dist` и сохраните их `_load_id`: это идентификатор запуска,
+который принял сообщения. Он должен быть одинаковым у всех четырех строк.
-## Зафиксируйте исходный результат
+## Проверка готового разбора
-До любого запуска `etl_pipeline` проверьте срез по найденному `_load_id`:
+До запуска `etl_pipeline` проверьте строки с найденным `_load_id`:
- в STG четыре строки;
-- в физической `ods.order_dist` одна годная строка;
+- в таблице `ods.order_dist` одна годная строка;
- в `ods.order_errors_dist` три строки, по одной каждого класса;
- `STG = годные + брак`;
-- текущая строка `ods.order_v` совпадает по деловым полям с результатом
- `argMax` по `snapshot_date` из годного сырья среза.
+- поля заказа в текущей строке `ods.order_v` совпадают с полями из годного
+ исходного сообщения; последнюю версию для сравнения выберите через
+ `argMax` по `snapshot_date`.
-Счет физической таблицы нужен именно по `_load_id`: представление
-`ods.order_v` оставляет текущую версию сущности и не показывает, сколько строк
-разобрал запуск. Запрос сверки с `argMax` напишите сами. Эталонные деловые поля
-стройте только из `raw`; отдельно сравните различающиеся `created_at` и
-`updated_at`.
+Считайте строки `ods.order_dist` именно по `_load_id`: представление
+`ods.order_v` оставляет только текущую версию каждого заказа и не показывает,
+сколько строк разобрал запуск. Запрос сверки с `argMax` напишите сами.
+Значения для сравнения извлеките из `raw` пробных сообщений. Отдельно
+сравните `created_at` и `updated_at`: в заготовке они намеренно различаются.
Проверьте раскладку годной строки через `hostName()` на `ods.order_dist`.
-Фактическая нода должна совпасть с остатком `cityHash64(order_id) % 2`:
+Узел, на котором лежит строка, должен соответствовать остатку `cityHash64(order_id) % 2`:
0 - `clickhouse-01`, 1 - `clickhouse-02`.
-## Уберите пробную пачку
+## Удаление пробных сообщений
Удалите только строки маркера из локальных таблиц:
@@ -135,40 +136,41 @@ SETTINGS mutations_sync = 2;
запустите `etl_pipeline`: пересборка текущего окна удалит заказ из DDS и
обновит витрины.
-## Перепишите разбор
+## Собственный разбор заказов
Удалите готовый SQL из `sql/ods/order_parse.sql` и напишите свой. Чтобы
`orders_ingest` мог его использовать, сохраните следующие условия:
-- вход - строки `stg.orders_raw_dist` одного `{load_id:String}`;
-- каждая строка среза попадает ровно в один результат: годный заказ или брак;
-- произвольный `raw` не останавливает весь разбор исключением;
+- запрос читает строки `stg.orders_raw_dist` с переданным `{load_id:String}`;
+- каждая прочитанная строка попадает ровно в один результат: годный заказ или брак;
+- ошибка в `raw` одной строки не прерывает разбор остальных;
- годные строки записываются в `ods.order_dist`, остальные - в
`ods.order_errors_dist`;
- классы брака и их порядок - `not_an_object`, `keyset_mismatch`,
`field_invalid`;
- метки доставки и `_load_id` переходят из STG без пересчета;
-- задача завершается после доставки обоих результатов через Distributed, а
- строки распределяются по ключу целевой таблицы.
+- задача завершается после записи и заказов, и ошибок на шарды через
+ Distributed; строки распределяются по ключу соответствующей целевой таблицы.
-Точный набор полей, типы и граница строгого приема описаны в
+Точный набор полей, типы и условия проверки значений описаны в
[документе приема](../architecture/orders/ingestion.md#граница-строгого-приёма),
а целевые колонки - в [`20-ods-tables.sql`](../../sql/ddl/20-ods-tables.sql).
-Внутренность `items` остается сырым JSON: ее разбирает DDS.
+Содержимое `items` сохраните как JSON: его разбирает DDS.
Повторите опыт с новым маркером. Получите тот же баланс 4 = 1 + 3, те же три
-класса, верную текущую строку по `snapshot_date`, разные времена и правильный
-шард. Затем очистите второй срез теми же адресными командами и запустите
+класса, верную текущую версию заказа по `snapshot_date`, разные значения
+`created_at` и `updated_at` и правильный шард. Затем удалите строки второго
+опыта теми же командами и запустите
`dq_check`.
-## Завершите опыт
+## Завершение работы
Убедитесь, что оба маркера отсутствуют в STG, ODS, таблице брака и DDS,
временные файлы удалены, а семь проверок успешны. Теперь можно перейти к
сборке DDS, не снимая паузу с модельного времени. Если вы прерываетесь и до
опыта `world_live` работал, снимите его с паузы.
-## Что должно получиться
+## Результат работы
- [ ] у вас сохранены результаты до переписывания: четыре строки дали
один заказ и три класса брака; вы сверили поля текущей версии и шард;
diff --git a/docs/labs/5-3-order-pipeline.md b/docs/labs/5-3-order-pipeline.md
index a5d531d..9d3515a 100644
--- a/docs/labs/5-3-order-pipeline.md
+++ b/docs/labs/5-3-order-pipeline.md
@@ -1,9 +1,10 @@
-# Перепишите сборку заказов в DDS
+# Сборка заказов в DDS
-**Чему учит:** настроить пересборку заказов по состоянию ODS и DDS
-и проверить, что она восстанавливает пропущенный день за пределами текущего окна.
+Вы перепишете загрузку заказов из ODS в DDS и выбор дней для пересчета.
+Затем удалите данные за один из прошлых дней и проверите, что ваша
+загрузка восстановила их.
-## Сначала прочитать
+## Подготовка
- [проверки заказов](5-1-order-checks.md);
- [разбор заказов в ODS](5-2-order-parsing.md);
@@ -11,7 +12,7 @@
- [дневной конвейер](../architecture/etl.md#объём-прогона);
- [раскладку по шардам](../architecture/storage.md#раскладка-по-шардам).
-## Что предстоит сделать
+## Файлы для изменения
Удалите готовый SQL и перепишите только три файла:
@@ -19,18 +20,18 @@
- `sql/dds/order_rebuild.sql`;
- `sql/dds/order_replace.sql`.
-Даг `dds_transform`, DDL, донор, ODS и обе проверки из первого задания
+Даг `dds_transform`, DDL, промежуточную таблицу, ODS и обе проверки из первого задания
оставьте как есть. Перед началом убедитесь, что пробные строки предыдущего
-задания удалены, а семь DQ дают зеленый результат.
+задания удалены, а семь проверок качества дают зеленый результат.
Поставьте `world_live` на паузу и дождитесь завершения активных прогонов. До
завершения опыта не запускайте `world_live_day`, `world_initialize` и
`world_recreate`. `world_next_day` понадобится только в явно указанном ниже
случае.
-## Как даг запускает ваши запросы
+## Выполнение запросов в Airflow
-Сохраните интерфейс `dds_transform`:
+Даг `dds_transform` ожидает от файлов следующие параметры и результаты:
| Файл | Вход | Результат |
|---|---|---|
@@ -38,39 +39,46 @@
| `order_rebuild.sql` | `{first_day:Date}`, `{last_day:Date}`, `{load_id:String}` | выбранные дни в `dds.order_stage_dist` |
| `order_replace.sql` | день в `{{ params.day }}` | партиция дня в `dds.order_rep` |
-Объем состоит из текущего окна источника и хвоста от первого дня, который есть
-в ODS, но отсутствует в DDS. Текущее окно дают строки последнего
-`snapshot_date`: их минимальный и максимальный день заказа. Если источник
-пуст, `order_scope.sql` не возвращает строку. Если дыр нет, `first_day` равен
-началу окна. Если дыра старше, непрерывный отрезок начинается с нее.
+Сначала определите дни, за которые заказы еще могут измениться. Возьмите
+строки с последним `snapshot_date` и найдите минимальную и максимальную
+дату заказа. Этот диапазон далее называется текущим окном источника.
+
+Затем найдите первый день, который есть в ODS, но отсутствует в DDS.
+Если он раньше начала текущего окна, начните расчет с него. Иначе начните
+с начала окна. Полученная дата станет `first_day`, а конец окна `last_day`.
+Один запуск пересчитывает все дни между ними. Если источник пуст,
+`order_scope.sql` не возвращает строку.
`order_rebuild.sql` сначала очищает все партиции `dds.order_stage_rep` на
кластере, затем записывает в `dds.order_stage_dist` весь выбранный отрезок. Он
читает текущее состояние `ods.order_v`, вычисляет `order_date`, переводит
времена в `Europe/Samara`, разбирает `items` в три параллельных массива и
-ставит координаты запуска.
+заполняет служебные поля запуска.
-Запись через Distributed должна завершиться до замены и использовать ключ
-целевой таблицы для раскладки строк. Способ собрать весь отрезок внутри файла
-выберите сами.
+Дождитесь записи всех строк на шарды через Distributed перед заменой партиций.
+Строки должны распределяться по ключу целевой таблицы. Способ рассчитать
+выбранные дни внутри файла выберите сами.
`order_replace.sql` работает с локальными `dds.order_rep` и
-`dds.order_stage_rep` на всем кластере. `REPLACE PARTITION` переносит готовую
-дневную партицию из донора и оставляет донор на месте. Структура, ключи и
-политика хранения совпадают: DDL объявляет донор через `AS dds.order_rep`.
+`dds.order_stage_rep` на всем кластере. `REPLACE PARTITION` заменяет дневную
+партицию основной таблицы готовой партицией из промежуточной. Промежуточная
+партиция при этом сохраняется. Структура, ключи и политика хранения таблиц
+совпадают: DDL создает промежуточную через `AS dds.order_rep`.
-## Восстановите ближайший закрытый день
+## Восстановление пропущенного дня
-Найдите последний по дате день DDS, который лежит левее начала текущего окна
-ODS. Это ближайший к окну закрытый день: его удаление показывает расширение
-объема на дыру без пересборки всей истории.
+Найдите самый поздний день DDS перед началом текущего окна ODS. Заказы за этот
+день уже не меняются, поэтому он считается закрытым. Удалив его, вы проверите,
+что ваш запрос заметил пропуск и включил этот день в расчет.
Если такого дня еще нет, один раз продвиньте мир через `world_next_day`,
дождитесь `orders_ingest`, затем запустите `etl_pipeline`. После этого снова
-зафиксируйте модельное время до конца опыта и выберите ближайший закрытый день.
+оставьте модельное время неизменным до конца опыта и выберите этот день.
-Сохраните для него число заказов и контрольную сумму деловых полей без
-`_load_id` и `_load_ts`. Удалите только его партицию:
+Сохраните для него число заказов и контрольную сумму их полей, исключив
+служебные `_load_id` и `_load_ts`. Контрольная сумма позволит проверить,
+что после восстановления значения остались прежними. Удалите только
+партицию выбранного дня:
```sql
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
@@ -90,29 +98,30 @@ DROP PARTITION 'YYYY-MM-DD';
прогона число заказов и контрольная сумма выбранного дня должны совпасть с
сохраненными.
-## Проверьте раскладку и повтор
+## Распределение по шардам и повторный запуск
-Напишите отдельный запрос к `dds.order_dist`, который получает фактическую
-ноду через `hostName()`, вычисляет ожидаемую по `cityHash64(order_id) % 2` и
-считает несовпадения по каждой ноде. Остаток 0 соответствует
-`clickhouse-01`, остаток 1 - `clickhouse-02`. На обеих нодах число строк на
-чужом шарде должно быть нулевым. Счетные DQ читают Distributed-таблицы и такую
-ошибку раскладки не обнаруживают.
+Напишите отдельный запрос к `dds.order_dist`, который получает фактический
+узел каждой строки через `hostName()`, вычисляет ожидаемый по
+`cityHash64(order_id) % 2` и считает несовпадения по каждому узлу.
+Остаток 0 соответствует `clickhouse-01`, остаток 1 - `clickhouse-02`.
+На обоих узлах число строк на чужом шарде должно быть нулевым.
+Проверки количества строк читают данные со всех шардов через Distributed
+и такую ошибку не обнаруживают.
Запустите `etl_pipeline` еще раз при неизменном модельном времени. Группа
заказов снова должна пересобрать текущее изменяемое окно. Контрольные суммы
-деловых полей остаются прежними, а все семь проверок дают зеленый результат.
+полей заказов остаются прежними, а все семь проверок дают зеленый результат.
-## Завершите опыт
+## Завершение работы
После восстановления партиции и успешных проверок снимите `world_live`
с паузы, если он работал до упражнения.
-## Что должно получиться
+## Результат работы
- [ ] вы переписали три файла `sql/dds/order_*.sql`, сохранив остальные
части стенда;
-- [ ] вы удалили ближайший к окну закрытый день и увидели ошибку DQ до
+- [ ] вы удалили ближайший к окну закрытый день и увидели ошибку проверки до
восстановления;
- [ ] ваш расчет выбрал `first_day` с удаленного дня и заменил партиции
всего отрезка до конца окна;
@@ -120,4 +129,4 @@ DROP PARTITION 'YYYY-MM-DD';
с сохраненными значениями; они совпали;
- [ ] `order_items_total`, `orders_vs_ods` и пять прежних проверок успешны;
- [ ] ваш запрос с `hostName()` не нашел заказов на чужом шарде;
-- [ ] повторный `etl_pipeline` сохранил деловые данные, проверки прошли.
+- [ ] после повторного `etl_pipeline` поля заказов остались прежними, проверки прошли.
diff --git a/docs/labs/6-1-event-parsing.md b/docs/labs/6-1-event-parsing.md
index 8c09c92..d400cf6 100644
--- a/docs/labs/6-1-event-parsing.md
+++ b/docs/labs/6-1-event-parsing.md
@@ -1,9 +1,9 @@
-# Разберите сырые события рядом со стендом
+# Разбор сырых событий
-**Чему учит:** пакетно разобрать сообщения отдельно от живого приема и
-построчно проверить свой результат по нетронутому ODS.
+В этой работе вы разберете сохраненные сообщения о событиях в свою таблицу.
+Каждую полученную строку сравните с результатом обработки в стенде.
-## Сначала прочитать
+## Подготовка
- [формат события](../formats/clickstream-event.md);
- [справочник хранилища](../architecture/storage.md), разделы про имена
@@ -15,26 +15,26 @@
Готовый стендовый разбор находится в `sql/ddl/30-ods-views.sql`. Откройте его
после собственной сверки: в нем лежит ответ на основную часть задания.
-## Что предстоит сделать
+## Порядок работы
-Создайте в базе `default` одну свою таблицу годных событий и наполните ее одним
-пакетным `INSERT SELECT` из `stg.hits_raw_dist`. Работайте с первой нодой под
-пользователем `etl`.
+Создайте в базе `default` свою таблицу для правильно заполненных событий.
+Выберите небольшой набор сообщений из `stg.hits_raw_dist`, разберите их
+и загрузите в таблицу одним `INSERT SELECT`. Работайте с первым узлом под
+пользователем `etl`. Затем сравните результат с событиями в ODS.
-Матвью `ods.event_mv` и `ods.event_errors_mv` продолжают принимать живой
-поток. Оставьте их, STG, ODS, Kafka и даги без изменений. Пересоздание матвью
-на работающем стенде оставило бы зазор в приеме, а для пакетного опыта оно не
-нужно.
+Материализованные представления `ods.event_mv` и `ods.event_errors_mv`
+продолжают обрабатывать поступающие сообщения. Оставьте их, STG, ODS, Kafka
+и даги без изменений. Если пересоздать представления на работающем стенде,
+часть сообщений может остаться без обработки.
Дайте своей таблице имя, которое не пересечется с чужой работой, и запишите
-его вместе с запросами. В конце вы удалите только эту таблицу.
+его вместе с запросами.
-## Выберите сырой срез
+## Выбор исходных сообщений
STG хранит строки примерно три дня по `_load_ts`. Эта колонка отмечает время
-поступления в хранилище. Модельный день события лежит в `EventDate`. Сначала
-найдите среди недавних данных завершившийся интервал со строками. Посмотрите
-счет по минутам:
+поступления в хранилище. Модельный день события лежит в `EventDate`.
+Посмотрите, сколько строк поступило за каждую из последних минут:
```sql
SELECT
@@ -48,7 +48,8 @@ ORDER BY minute DESC;
```
Выберите короткий интервал `[slice_from, slice_to)`, который уже закончился и
-дает удобное для построчной проверки число строк. Если одна минута велика,
+дает удобное для построчной проверки число строк. Этот набор строк дальше
+называется срезом. Если за одну минуту пришло слишком много строк,
сузьте ее точными границами `_load_ts`. Если недавних строк нет, откройте
Airflow. При включенном `world_live` посмотрите его текущий запуск и дочерний
`world_live_day`: дождитесь свежих строк от текущего или следующего планового
@@ -64,9 +65,9 @@ Airflow. При включенном `world_live` посмотрите его т
Во всех дальнейших запросах читайте `stg.hits_raw_dist` только с этими
границами. Новые строки с более поздним `_load_ts` в срез не попадут. Повторите
-опыт до очистки по TTL; после нее прежний срез уже не восстановить из ODS.
+опыт до удаления исходных строк по TTL; после него прежний срез уже не восстановить из ODS.
-## Соберите собственную таблицу
+## Таблица для результата
Таблица содержит все 47 полей источника с именами и типами
`ods.event_rep`, а также `_load_ts`. Тип `purchaseRevenue` возьмите из ODS:
@@ -75,14 +76,15 @@ Airflow. При включенном `world_live` посмотрите его т
Обработайте повторные доставки так же, как ODS. Возьмите
`ReplacingMergeTree(_load_ts)`, партицию `EventDate` и полный ключ замены:
`(CounterID, EventDate, intHash32(ClientID), WatchID)`. Достаточно одной
-локальной таблицы: загрузка и сверка в этом опыте выполняются на одной ноде.
+локальной таблицы: загрузка и сверка в этом опыте выполняются на одном узле.
-`WatchID` — логический идентификатор события, уникальный по
-[договору генератора](../specs/2026-08-01-generator.md). Физический ключ ODS
-шире: в `ReplacingMergeTree` версиями считаются строки с одинаковым полным
-ключом сортировки. Используйте его при загрузке и сверке.
+`WatchID` обозначает событие и уникален по
+[правилам генератора](../specs/2026-08-01-generator.md). Но `ReplacingMergeTree`
+считает версиями строки, у которых совпадает весь ключ сортировки.
+Поэтому для загрузки и сверки используйте все четыре части ключа ODS,
+перечисленные выше.
-## Разберите годные строки
+## Разбор сообщений
Для каждой строки среза проверьте те же условия приема, что и на стенде:
@@ -92,8 +94,10 @@ Airflow. При включенном `world_live` посмотрите его т
в ожидаемые типы.
Напишите разбор, который не прерывает пакет из-за одной грязной строки. Сами
-выберите способ безопасного извлечения пяти опорных полей, форму общих выражений
-и место фильтра. Остальные поля извлеките по описанию формата.
+выберите, как извлечь пять перечисленных полей без ошибки запроса, какие
+выражения вынести в общую часть и где отфильтровать неподходящие строки.
+Эти пять полей дальше называются опорными. Остальные поля извлеките
+по описанию формата.
Перенесите `_load_ts` из STG без пересчета: это время исходной доставки и
колонка версии. Время запуска вашего запроса сюда не входит. Вставьте в свою
@@ -114,25 +118,25 @@ Airflow. При включенном `world_live` посмотрите его т
условия отбора на этом срезе, но не классификатор ошибок. Подкладывать грязные
сообщения ради задания не требуется.
-## Приведите стороны к одному зерну
+## Подготовка к сравнению
-### Учтите повторные доставки
+### Повторные доставки
В STG одна строка представляет доставку. Повтор сообщения остается второй
строкой. В `ods.event_v` одна строка представляет актуальное событие, потому
что представление читает физическую таблицу с `FINAL`.
-Свою сторону тоже читайте с `FINAL`. Ожидаемый счет равен числу различных
-полных ключей среди годных доставок. Разница между числом доставок и этим
-счетом показывает повторы в исходном срезе.
+Свою таблицу тоже читайте с `FINAL`. Число строк должно совпасть с числом
+различных полных ключей среди годных исходных сообщений. Разница между числом
+годных доставок и этим числом показывает повторы в исходном срезе.
-### Выберите события ODS для сравнения
+### События ODS для сравнения
Теперь нужно найти в ODS события из выбранного среза. Список стройте по
исходным доставкам: если взять его из своей таблицы, ошибочно отвергнутое
событие исчезнет с обеих сторон сверки и потеря останется незамеченной.
-Снова возьмите весь исходный STG-срез. По координатам Kafka исключите
+Снова возьмите все выбранные строки STG. По координатам Kafka исключите
доставки, которые есть в `ods.event_errors_dist`. У оставшихся извлеките
`WatchID` без риска прервать запрос и выберите эти события из свежего
`ods.event_v`. Свое условие отбора здесь не применяйте: решение о годности
@@ -141,36 +145,38 @@ Airflow. При включенном `world_live` посмотрите его т
`WatchID` связывает доставку с событием в ODS. Для удаления повторов и
сравнения полей дальше используйте полный физический ключ из схемы таблицы.
-## Сверьте 47 полей
+## Сверка полей
Соедините свою таблицу и выбранные строки `ods.event_v` через `FULL OUTER
JOIN` по полному ключу замены. Полное соединение сохраняет строки,
-которых нет на одной из сторон. Разделите результат на три исхода:
+которых нет на одной из сторон. Найдите три вида расхождений:
- строка есть только в ODS — вы ее потеряли;
- строка есть только у вас — вы приняли лишнее или иначе разобрали часть
ключа;
- ключ есть с обеих сторон, но различается хотя бы одно из 47 полей.
-ClickHouse по умолчанию заполняет отсутствующую сторону значениями типов.
+ClickHouse по умолчанию заполняет отсутствующую сторону значениями по умолчанию
+для соответствующих типов, например нулем или пустой строкой.
Включите для сверки `join_use_nulls = 1` или добавьте явный признак присутствия
в каждую сторону. Не определяйте отсутствие по значениям полей события.
Сравните все поля явно, включая массивы, `ecommerce` и точное десятичное
-`purchaseRevenue`. Удобный вид диагностики выберите сами: важно, чтобы по
-результату можно было назвать отличающиеся колонки одной строки. Выведите число
-строк каждого из трех исходов. В верном разборе все три числа равны нулю.
+`purchaseRevenue`. Выберите, как показать расхождения, чтобы было видно,
+какие колонки различаются в конкретной строке. Выведите число строк каждого
+из трех видов расхождений. В верном разборе все три числа равны нулю.
`_load_ts` не входит в 47 полей источника и не проверяет JSON-разбор.
-Проверьте его отдельно. Свяжите каждую строку своей таблицы с меткой одной из
-доставок того же полного ключа в исходном срезе. После `FINAL` остается самая
-поздняя метка этого ключа внутри среза. С ODS ее не сравнивайте: там может
+Проверьте его отдельно. Для каждой строки своей таблицы найдите самую позднюю
+метку доставки того же полного ключа в исходном срезе. Она должна совпасть
+с `_load_ts`, который вы читаете после `FINAL`. С ODS эту метку не сравнивайте: там может
храниться более поздняя доставка того же события из-за границ среза.
-## Расследуйте расхождение
+## Поиск ошибок разбора
-Если сверка не пуста, возьмите одну строку. Вернитесь к независимому списку
-ключей и исходному `raw`, выведите рядом координаты Kafka и `_load_ts`.
+Если нашлись расхождения, выберите одну строку. Найдите ее ключ в списке,
+который вы получили из STG, и исходное сообщение `raw`. Выведите рядом
+координаты Kafka и `_load_ts`.
Для измененной строки назовите поля, которые расходятся, и сравните каждое с
сырой строкой. Для пропавшей или лишней строки проверьте пять опорных полей и
@@ -178,28 +184,28 @@ ClickHouse по умолчанию заполняет отсутствующую
`ods.event_errors_dist`, посмотрите `error_class`.
Исправьте разбор, удалите и заново создайте только собственную таблицу, затем
-повторите вставку того же среза. Остановитесь, когда счет исходных доставок по
+повторите вставку того же среза. Остановитесь, когда число исходных доставок по
прежним границам не изменился, число годных и отвергнутых строк в сумме дает
-число исходных доставок, а построчная сверка пуста.
+число исходных доставок, а построчная сверка не находит расхождений.
-## Чего не подтверждает сравнение с ODS
+## Ограничения проверки
`ods.event_v` — образец для сравнения. Оба разбора могли повторить одну
ошибку. Для пяти опорных полей у вас есть проверка преобразования в нужный тип
и исходный JSON. Остальные 42 поля можно вручную сопоставить с `raw`, однако
второго независимого источника для всех значений на стенде нет.
-## Уберите свой объект
+## Сохранение результата и удаление таблицы
Сохраните DDL таблицы, запрос разбора, границы среза, число исходных, годных
и отвергнутых строк и итоговую сверку без расхождений. Запишите также, чего
не подтверждает сравнение с ODS и как TTL ограничивает время для повторения
опыта. Затем удалите свою таблицу командой `DROP TABLE` с ее точным именем.
Убедитесь через `system.tables`, что объекта больше нет.
-Прикладные строки стенда вы не меняли, поэтому удалять данные из STG, ODS или
+Данные стенда вы не меняли, поэтому удалять строки из STG, ODS или
Kafka после опыта не нужно.
-## Что должно получиться
+## Результат работы
- [ ] у вас сохранены границы выбранного среза, число строк и координаты
доставки по партициям Kafka;
diff --git a/docs/labs/7-1-monitoring.md b/docs/labs/7-1-monitoring.md
index 4929a47..a983c80 100644
--- a/docs/labs/7-1-monitoring.md
+++ b/docs/labs/7-1-monitoring.md
@@ -1,9 +1,10 @@
# Мониторинг: куда смотреть
-**Чему учит:** начать расследование с подходящего экрана, найти источник
-нагрузки и объяснить свежесть данных через путь доставки.
+Вы познакомитесь с дашбордами мониторинга и проведете два расследования:
+найдете запросы, которые создают нагрузку, и выясните, почему события сайта
+и заказы обновляются с разной частотой.
-## Перед началом
+## Знакомство с дашбордами
Откройте Grafana и Airflow по адресам из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли). Для первого знакомства в Grafana
@@ -11,7 +12,7 @@
- «Данные» — путь сообщений от источника до хранилища;
- «Кластер» — процессор, память, диски и внутренняя работа ClickHouse;
-- «Запросы» — выполненные запросы, их стоимость и ошибки.
+- «Запросы» — выполненные запросы, затраченные на них ресурсы и ошибки.
Откройте все три дашборда и запишите по одному вопросу, на который каждый из
них помогает ответить. «Данные» оставьте открытым в соседней вкладке.
@@ -19,7 +20,7 @@
Проведите два расследования на работающем стенде. Службы останавливать и данные
портить не понадобится.
-## Расследование 1. Кто читает больше всех
+## Расследование 1. Какие запросы читают больше всего данных
Начните в спокойный момент: не запускайте одновременно свои тяжелые запросы.
Откройте дашборд «Запросы». На панели «Тяжёлые запросы: что прочитало больше
@@ -27,8 +28,8 @@
Сохраните:
-- границы окна и время запроса;
-- место запроса в списке, пользователя и ноду;
+- начало и конец выбранного периода, время запроса;
+- место запроса в списке, пользователя и узел;
- текст запроса;
- длительность, память, число прочитанных и отданных строк.
@@ -44,22 +45,25 @@
В меню панели «Кто спрашивает: сколько запросов и какой у них аппетит»
откройте Inspect, затем Query. Ответьте на два вопроса:
-1. Почему журнал читают сразу с обеих нод?
-2. Почему из счета исключены строки начала запроса и работа шардов?
+1. Почему журнал читают сразу с обоих узлов?
+2. Почему при подсчете запросов исключают записи о начале выполнения и
+ записи о выполнении частей запроса на шардах?
Проверьте второе объяснение по панели ошибок: ее фильтр не исключает записи
-шардов. Объясните, почему различаются вопросы панелей, а не только их SQL.
+шардов. Объясните, на какие разные вопросы отвечают эти две панели
+и зачем им разные условия отбора.
## Расследование 2. Как меняется свежесть заказов
Откройте дашборд «Данные». Панель «Свежесть: сколько прошло с последней
-доехавшей строки» показывает текущее число, а не историю. До наблюдения
-нарисуйте ожидаемую форму свежести событий и заказов в течение одного живого
-дня. Отдельно отметьте момент доставки слепка заказов.
+доехавшей строки» показывает, сколько времени прошло с последней загрузки.
+Истории значений на ней нет. До наблюдения нарисуйте, как, по вашему ожиданию,
+будут меняться эти числа для событий и заказов в течение одного запуска
+`world_live_day`. Отдельно отметьте ожидаемый момент доставки слепка заказов.
Если `world_live` снят с паузы, наблюдайте его текущий или следующий
-`world_live_day`: новый ручной запуск встанет в очередь. Если выключатель на
-паузе и активного живого дня нет, запустите один `world_live_day` вручную.
+`world_live_day`: новый ручной запуск встанет в очередь. Если `world_live` на
+паузе и активного `world_live_day` нет, запустите один `world_live_day` вручную.
Модельные сутки занимают около 24 реальных минут.
Запишите несколько наблюдений до конца дня и сразу после него:
@@ -69,29 +73,29 @@
| | | | |
Дашборд обновляется раз в минуту, поэтому точного совпадения момента
-доставки со строкой таблицы ждать не нужно. Рост и сброс свежести заказов
-перенесите на свой набросок. Объясните форму линии: какую метку времени
+доставки с записью наблюдения ждать не нужно. Перенесите рост и сброс
+показателя заказов на свой рисунок. Объясните форму линии: какую метку времени
вычитает панель из текущего времени, почему число растет и какое событие его
-сбрасывает. Модельную дату сыгранного дня подпишите отдельно от реального
+сбрасывает. Модельную дату наблюдаемого дня подпишите отдельно от реального
времени наблюдения.
-Подтвердите сброс двумя следами:
+Подтвердите, что сброс совпал с загрузкой заказов:
- в Airflow завершился прием слепка `orders_ingest`;
- на панели «Дневной слепок заказов: какой доехал, когда и после какой паузы»
появилась новая верхняя строка.
-Если вы начали после доставки, дождитесь следующего дня либо честно запишите,
-какую часть формы увидели. Одно текущее значение не доказывает ни рост, ни
+Если вы начали после доставки, дождитесь следующего дня либо запишите,
+какую часть изменений успели увидеть. Одно текущее значение не доказывает ни рост, ни
сброс.
-## Объясните путь данных
+## Источники показателей
Не открывая справочник, объясните своими словами:
-- почему свежесть считают в ODS, а поток — в STG;
+- почему время с последней загрузки считают по ODS, а число поступивших сообщений по STG;
- почему знаменатель доли брака берут из STG;
-- почему у числителя и знаменателя доли брака одно окно.
+- почему числитель и знаменатель доли брака берут за один период.
Свяжите ответы с тем, что вы видели на панелях. Затем сверьте объяснение с
[главой о мониторинге](../handbook/monitoring.md) и справочниками дашбордов
@@ -99,20 +103,20 @@
[«Данные»](../architecture/monitoring/data.md). Устройство живого дня и
доставки слепка описано в [главе об Airflow](../handbook/airflow-dags.md).
-## Что должно получиться
+## Результат работы
- [ ] для каждого из трех дашбордов записан вопрос, на который он отвечает;
-- [ ] у вас сохранены окно, текст и показатели верхнего запроса;
+- [ ] у вас сохранены выбранный период, текст и показатели верхнего запроса;
вы выяснили, кто его отправил и зачем;
- [ ] вы нашли запрос открытых дашбордов, который прочитал больше всего
строк, и панель, которая его отправила;
-- [ ] вы можете объяснить, зачем журнал собирают с двух нод и какие записи
+- [ ] вы можете объяснить, зачем журнал собирают с двух узлов и какие записи
учитывают при подсчете запросов;
- [ ] у вас есть прогноз свежести, сделанный до наблюдения, и несколько
замеров; рост и сброс на рисунке объяснены через время последней доставки;
- [ ] вы подтвердили доставку заказов в Airflow и на панели слепков,
подписали модельную дату отдельно от реального времени наблюдений;
- [ ] вы можете объяснить выбор слоев для свежести, потока и доли брака
- и почему числитель и знаменатель берут за одно окно;
+ и почему числитель и знаменатель берут за один период;
- [ ] после наблюдений службы продолжают работать, данные и стендовые
дашборды остались без изменений.
diff --git a/docs/labs/README.md b/docs/labs/README.md
index 7b82156..4a5b743 100644
--- a/docs/labs/README.md
+++ b/docs/labs/README.md
@@ -1,84 +1,121 @@
# Учебные задания
-Программа состоит из двух частей: основная включает семь ступеней, которые вы
-проходите по порядку, а факультативы выбираете по желанию. Причины такого
-порядка и формы заданий описаны в [программе учебных лаб](../specs/2026-09-01-labs-program.md#2-устройство-программы-основная-программа-и-факультативы).
+Вы будете достраивать хранилище небольшого интернет-магазина. Стенд уже
+загружает события сайта и заказы, обрабатывает их и строит несколько отчетов.
+Сначала вы подготовите данные для новых отчетов, затем разберетесь, как
+работают расчеты и загрузка, и перепишете часть из них.
-## Основная программа
+Основная программа состоит из семи ступеней. Проходите их по порядку,
+факультативы выбирайте по желанию. Перед первым заданием прочитайте главу
+[«Хранилище и его слои»](../handbook/storage-layers.md) и выполните запросы
+из нее: они покажут данные, с которыми предстоит работать.
-| Ступень | Задание | Чему учит |
+## Порядок заданий
+
+| Ступень | Задания | Содержание |
|---|---|---|
-| 1 | Пять витрин слоя DM | построить результат для потребителя хранилища; уроки каждой витрины — в таблице ниже |
-| 2 | [Свой график в Superset](2-1-superset-chart.md) | превратить свой результат из хранилища в отчет в BI |
-| 3 | [Нарежьте сессии сами](3-1-sessions-by-hand.md) | сверить число сессий в своей нарезке с эталоном и найти события, из-за которых оно расходится |
-| 4 | [Перепишите нарезку стенда](4-1-session-pipeline.md) | переписать расчет сессий в готовом конвейере и проверить по исходным событиям |
-| 5 | [Научите проверки замечать ошибки в заказах](5-1-order-checks.md)
[Перепишите разбор заказов в ODS](5-2-order-parsing.md)
[Перепишите сборку заказов в DDS](5-3-order-pipeline.md) | написать проверки качества до рискованного изменения, затем переписать разбор и сборку заказов |
-| 6 | [Разберите сырые события рядом со стендом](6-1-event-parsing.md) | изучить разбор потока отдельно от живого приема и сверить его со стендовым результатом |
-| 7 | [Мониторинг: куда смотреть](7-1-monitoring.md) | выбрать, куда и на что смотреть в живой системе |
+| 1 | Пять витрин для отчетов | подготовить данные о событиях, страницах, визитах, ошибках загрузки и рекламе; задания перечислены ниже |
+| 2 | [Свой график в Superset](2-1-superset-chart.md) | построить график по своей витрине и проверить числа на нем |
+| 3 | [Сессии из событий](3-1-sessions-by-hand.md) | объединить действия посетителя в визиты и сравнить их с визитами источника |
+| 4 | [Расчет сессий в стенде](4-1-session-pipeline.md) | включить свой расчет в готовую обработку данных и проверить результат |
+| 5 | [Проверки качества заказов](5-1-order-checks.md)
[Разбор заказов в ODS](5-2-order-parsing.md)
[Сборка заказов в DDS](5-3-order-pipeline.md) | сначала написать проверки качества, затем с их помощью проверить свою обработку заказов |
+| 6 | [Разбор сырых событий](6-1-event-parsing.md) | извлечь поля из сообщений и сравнить результат с готовым разбором стенда |
+| 7 | [Мониторинг: куда смотреть](7-1-monitoring.md) | найти тяжелые запросы и разобраться, почему данные обновляются с разной частотой |
-## Ступень 1: пять витрин слоя DM
+## Ступень 1: витрины для отчетов
-Слой DM в стенде построен наполовину: витрины трех сюжетов дашборда и контроль
-качества уже построены, остальные пять витрин строите вы. Каждая из них —
-самостоятельное задание: в нем есть состав витрины, зерно, источники и способ
-проверки.
+Витрина готовит данные для конкретного отчета. В стенде они находятся в базе
+`dm`: это слой DM. Витрины выручки, трафика, сверки покупок с заказами и
+сводка качества уже готовы. Вы добавите еще пять.
-| № | Результат | Чему учит |
+| № | Витрина | Содержание задания |
|---|---|---|
-| 1 | [`events_enriched_v`](1-1-events-enriched.md) | подготовить удобный для анализа набор полей, сохранив одну строку на событие |
-| 2 | [`top_pages_daily`](1-2-top-pages-daily.md) | создавать материализованную витрину с дневной пересборкой в конвейере |
-| 3 | [`session_overview`](1-3-session-overview.md) | хранить слагаемые для расчета средних и долей |
-| 4 | [`dq_errors_daily`](1-4-dq-errors-daily.md) | считать брак по дню загрузки и хранить итоги дольше источника |
-| 5 | [`utm_effectiveness`](1-5-utm-effectiveness.md) | читать `declared_` как предупреждение о сумме со слов клиента |
+| 1 | [События для анализа: `events_enriched_v`](1-1-events-enriched.md) | выбрать нужные поля и добавить вычисляемые, сохранив одну строку на событие |
+| 2 | [Показатели страниц по дням: `top_pages_daily`](1-2-top-pages-daily.md) | сохранить итоги в таблице и настроить их обновление через Airflow |
+| 3 | [Визиты по устройствам: `session_overview`](1-3-session-overview.md) | хранить суммы и счетчики для расчета средних и долей |
+| 4 | [Ошибки загрузки по дням: `dq_errors_daily`](1-4-dq-errors-daily.md) | сохранить число ошибок даже после удаления исходных строк |
+| 5 | [Показатели рекламы: `utm_effectiveness`](1-5-utm-effectiveness.md) | различать суммы покупок из событий сайта и выручку по заказам магазина |
-Общее устройство слоя — [справочник DM](../architecture/dm.md). Он отвечает на
-вопросы «почему так», задание отвечает на вопрос «что сделать».
+Начните с [первого задания](1-1-events-enriched.md): для него достаточно
+одного представления. В заданиях 2–5 понадобятся таблицы и их обновление
+по дням. К общим правилам ниже возвращайтесь по мере работы.
-## Как принято на ступени 1
+## Создание и обновление витрин
-**Читать — через `_v`.** Физические `_rep` и `_dist` служат загрузке и
-обслуживанию, а публичный договор DDS — `dds.event_v` или `dds.session_v` —
-остается прежним даже при изменении физических объектов. Чтение DDS
-напрямую делает задания независимыми: соседняя витрина может быть еще не
-построена.
-Исключение — задание 4: у таблиц брака публичного договора нет.
+### Источники данных
-**Писать — в `_dist`.** Так строки распределяются по ключу шардирования. При
-записи в локальную таблицу распределение зависит от узла, выполнившего
-запрос, и при следующем запуске может измениться. Подробности — раздел
-«Раскладка по шардам» [справочника хранилища](../architecture/storage.md).
+События читайте из `dds.event_v`, сессии из `dds.session_v`. Суффикс `_v`
+обозначает обычное представление: оно выполняет сохраненный запрос при
+чтении. Такие представления сохраняют привычные имена и состав колонок,
+когда внутреннее устройство таблиц меняется.
-**Форма — по зерну.** На стенде агрегат с новым зерном принято хранить
-готовым: потребитель читает его много раз без повторного расчета по
-источнику. Создайте `dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` /
-`_stage_dist` и публичное `dm.<имя>_v` с явным списком колонок. При прежнем
-зерне достаточно представления: таблица дублировала бы данные. Доводы —
-раздел «Таблица или представление» [справочника
-DM](../architecture/dm.md).
+Каждая учебная витрина читает готовый источник и не зависит от других
+учебных витрин. В задании 4 источниками служат таблицы ошибок ODS:
+представлений для них нет.
-**Ключ шардирования — ваше решение.** Объясните, почему выбранный ключ не
-дает существенного перекоса и почему более сложный ничего не добавит.
-Объявите донор через `AS <цель>_rep` с ключом цели: иначе замена партиции
-потеряет строку на одном шарде и удвоит на другом. Детали — раздел
-«Раскладка по шардам» [справочника хранилища](../architecture/storage.md).
-Пример обоснования — `dm.revenue_daily_dist` в
+### Таблицы и представления
+
+В заданиях 2–5 вы группируете исходные строки. Например, в `top_pages_daily`
+одна строка содержит итоги для одной страницы за день. То, чему соответствует
+одна строка, называют **зерном**. На стенде итоги с новым зерном сохраняют
+в таблицах. Отчеты читают готовые числа.
+
+Новый расчет сначала записывают в промежуточную таблицу. Пока он готовится,
+отчеты читают прежние данные из основной. Затем готовыми разделами
+промежуточной таблицы заменяют разделы основной. Раздел называется
+**партицией**; в этих заданиях он содержит данные одного дня.
+Промежуточную таблицу также называют **донором**.
+
+На примере `top_pages_daily` пять объектов выглядят так:
+
+| Объект | Для чего нужен |
+|---|---|
+| `dm.top_pages_daily_rep` | Хранит рассчитанные строки на каждом шарде |
+| `dm.top_pages_daily_dist` | Читает строки со всех шардов и распределяет запись между ними |
+| `dm.top_pages_daily_stage_rep` | Хранит новый расчет до замены данных в основной таблице |
+| `dm.top_pages_daily_stage_dist` | Распределяет запись нового расчета между шардами |
+| `dm.top_pages_daily_v` | Дает отчетам доступ к готовой витрине; явно перечисляет ее колонки |
+
+### Распределение по шардам
+
+Каждый из двух шардов стенда хранит свою часть данных. Записывайте строки
+через таблицы `_dist`: они выбирают шард по ключу шардирования. Прямая запись
+в `_rep` оставит строки на том узле, где выполнен запрос.
+
+Ключ для своей витрины выберите сами. Проверьте распределение строк между
+шардами и объясните, почему выбранного ключа достаточно.
+Пример обоснования для `dm.revenue_daily_dist` есть в
[`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
-**Где лежит код.** DDL заданий — в `sql/ddl/70-labs.sql`.
-При создании мира он применяется после стендовых файлов.
-В уже работающем стенде сохранение файла и повторный `make up` новый DDL
-не применят — выполните его сами, как описано ниже. В
-`sql/dm/<витрина>_scope.sql` определяются дни прогона, в `_rebuild.sql` они
-собираются в донор, в `_replace.sql` заменяется партиция. Задачу сборки
-добавьте в [`dags/dm_transform.py`](../../dags/dm_transform.py): без нее
-конвейер не обновит витрину. Представлению достаточно `CREATE VIEW`.
+Создайте промежуточную таблицу через `AS <цель>_rep`, сохранив структуру и
+ключи основной. У обеих таблиц `_dist` должен быть одинаковый ключ
+шардирования: замена партиции выполняется внутри каждого шарда и не
+перераспределяет строки. Подробности есть в разделе
+[«Раскладка по шардам»](../architecture/storage.md#раскладка-по-шардам).
-**Образец — витрина `revenue_daily`.** У нее есть пять объектов, донор,
-сборка, замена дневных партиций и задача в конвейере. Используйте ее как
-образец всего порядка пересборки. Состав колонок берите из своего ТЗ, иначе
-получите показатели другой витрины.
+### SQL-файлы и запуск расчета
-## Как применить свой DDL
+Команды создания таблиц и представлений (DDL) сохраняйте в
+`sql/ddl/70-labs.sql`. При создании мира этот файл выполняется после
+стендовых файлов. В работающем стенде примените команды сами по инструкции
+ниже: сохранение файла и повторный `make up` их не выполнят.
+
+Для витрины с таблицами нужны три файла в `sql/dm/`:
+
+- `<витрина>_scope.sql` определяет, какие дни пересчитать;
+- `<витрина>_rebuild.sql` рассчитывает их и записывает в промежуточную таблицу;
+- `<витрина>_replace.sql` заменяет партицию одного дня в основной таблице.
+
+Добавьте запуск расчета в [`dags/dm_transform.py`](../../dags/dm_transform.py),
+чтобы витрина обновлялась вместе с остальными. Для представления из задания 1
+достаточно выполнить `CREATE VIEW`.
+
+Образец этого порядка работы есть у готовой витрины `revenue_daily`.
+Ее таблицы, SQL-файлы и задача Airflow разобраны в
+[справочнике DM](../architecture/dm.md#dmrevenue_daily_v).
+Состав колонок и выбор дней для пересчета берите из своего задания.
+
+## Выполнение SQL в DBeaver
В DBeaver создайте подключение по [инструкции в README](../../README.md#подключиться-из-dbeaver),
но укажите пользователя `etl` и пароль из `CLICKHOUSE_ETL_PASSWORD` в `.env`.
@@ -88,37 +125,33 @@ DM](../architecture/dm.md).
Создайте файл `sql/ddl/70-labs.sql` и сохраняйте в нем DDL своих витрин.
Откройте его в SQL-редакторе этого подключения и выполните DDL текущего
задания. Создавайте объекты с
-`ON CLUSTER clickstream_cluster`, чтобы они появились на обеих нодах;
+`ON CLUSTER clickstream_cluster`, чтобы они появились на обоих узлах;
образец для представления — [`sql/ddl/60-dm-views.sql`](../../sql/ddl/60-dm-views.sql).
Сохраненный файл позволит воссоздать ваши объекты при пересборке мира.
-## Самопроверка ступени 1
+## Проверка витрин
-Даг `homework_check` запускается руками, расписания у него нет. Задача
-витрины красная, пока витрины нет или счет не сходится, и зеленая, когда
-собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет: это
-инструмент лабы, а не история качества стенда.
+В Airflow есть даг `homework_check` с отдельной задачей для каждой учебной
+витрины. Запускайте его вручную: расписания у него нет. Задача завершится
+ошибкой, если витрина еще не создана или ее данные расходятся с ожидаемыми.
+Причину и примеры расхождений ищите в журнале задачи. Проверки еще не
+выполненных заданий тоже будут красными.
-Для каждой витрины проверка независимо рассчитывает ожидаемый результат
-прямо по ее источнику. У четырех заданий источник
-эталона тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания
-4 это таблицы брака ODS, потому что в DDS нет сущности брака. Обе стороны
-соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число
-строк, где стороны разошлись или одной стороны нет. Ноль — зеленый. Полное
-соединение сохраняет ключи обеих сторон: при внутреннем пропавший день или
-ключ не попал бы в результат, и потеря осталась бы незамеченной.
+Проверка заново рассчитывает показатели по источнику и сравнивает их с вашей
+витриной. Строки сопоставляются по полям, которые их определяют: например,
+по дню и странице. `FULL OUTER JOIN` позволяет найти и разные значения, и
+строки, которых нет с одной стороны. `INNER JOIN` пропустил бы отсутствующие
+строки. Когда расхождений нет, задача становится зеленой.
-Раздел «Как проверить себя» каждого задания по витрине называет деловой ключ и
-сравниваемые величины. Тот же прием в стендовом исполнении — [даг
-`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq).
+`homework_check` не сохраняет результаты в сводку качества `dm.dq_summary`.
-Запросы проверок лежат в `sql/dq/`. Откройте их после собственного решения:
+Запросы проверок лежат в [`sql/dq/`](../../sql/dq/). Откройте их после собственного решения:
они заново рассчитывают показатели витрины, поэтому содержат ответ на
существенную часть задания. Сравните свой способ расчета с готовым.
-## Факультативы
+## Задания по желанию
-Эти задания вы берете по желанию. Полный список сюжетов — в разделе
+Полный список сюжетов — в разделе
[«Факультативы — пять разделов»](../specs/2026-09-01-labs-program.md#4-факультативы--пять-разделов)
программы.
@@ -129,16 +162,17 @@ DM](../architecture/dm.md).
- [Куда пропали покупки](optional/missing-purchases.md): найти заказы без
события, выбрать источник витрины и условие проверки полноты.
- [Почему повтор покупки завышает выручку](optional/duplicate-purchases.md):
- выбрать зерно и ключ расчета, проверить его на повторных событиях.
+ рассчитать выручку с одной строкой на заказ и проверить результат на
+ повторных событиях.
- [Какая позиция объясняет разницу в сумме](optional/order-amount-difference.md):
- разобрать позиции без потери точности и локализовать расхождение источников.
+ разобрать товары заказа без потери точности и найти причину разницы в суммах.
- [Почему вчерашняя выручка меняется сегодня](optional/revenue-over-time.md):
- обосновать окно пересчета витрины и требования к истории заказов.
+ определить, за какие дни пересчитывать выручку и какую историю заказов сохранять.
Первые три работы используют готовые данные. Четвертая продвигает мир через
Airflow; для нее вы сохраняете состояния заказов после каждого модельного дня.
-### Тонкий анализ данных
+### Данные о посетителях
Открывается после ступени 1.
@@ -146,9 +180,7 @@ Airflow; для нее вы сохраняете состояния заказо
сохранить историю связей, сравнить расчеты на прежних сессиях и определить,
какие сведения нужны для воспроизведения результата.
-Обогащение отчета своим словарем регионов пока описано только в программе.
-
-### Superset и BI
+### Отчеты в Superset
Открывается после ступени 2. Два самостоятельных задания:
@@ -157,21 +189,15 @@ Airflow; для нее вы сохраняете состояния заказо
- [В DBeaver работает, в Superset нет](optional/superset-missing-object.md):
выяснить, почему клиенты по-разному видят один объект хранилища.
-### Инженерия стенда
+### Загрузка и обновление данных
-Открывается после ступени 1. Прием и хранение данных, раскладка по шардам,
-повторная обработка дня и обновление витрин.
+Открывается после ступени 1.
- [Как обновлять витрину без полной пересборки](optional/incremental-daily-traffic.md):
продолжить работу о связях пользователей, пересчитать затронутые дни
и проверить повтор незавершенной загрузки. Повышенная сложность.
-### Что делать, когда стенд сломался
-
-Открывается после ступени 7. Поломки стенда и инструкции по восстановлению
-по итогам расследований; весь раздел — повышенная сложность.
-
-## Уборка
+## Удаление временных объектов
После опытов удаляйте свои временные объекты. Если удалили учебную витрину,
ее задача в `homework_check` должна снова завершиться ошибкой: отсутствие
diff --git a/docs/labs/optional/duplicate-purchases.md b/docs/labs/optional/duplicate-purchases.md
index 09686ce..c26e3d3 100644
--- a/docs/labs/optional/duplicate-purchases.md
+++ b/docs/labs/optional/duplicate-purchases.md
@@ -1,19 +1,20 @@
# Почему повтор покупки завышает выручку
-**Чему учит:** выбрать зерно и ключ расчета, чтобы повторные события
-не умножали денежную сумму.
+Несколько событий покупки могут относиться к одному заказу. Вы проверите,
+как они завышают сумму, и составите расчет, который учитывает каждый заказ
+один раз.
Факультатив доступен после ступени 1. Работайте в DBeaver пользователем
`analyst` по [инструкции](../../../README.md#подключиться-из-dbeaver).
Дождитесь завершения `etl_pipeline`; оставьте `world_live` на паузе и
дождитесь уже начатых прогонов, чтобы данные не менялись между запросами.
-## Проверьте расчет по событиям
+## Расчет по событиям покупки
В ODS уже устранены повторные доставки событий. Достаточно ли этого,
чтобы сложить суммы всех `purchase` для витрины заказов? Проверьте на данных.
-Возьмите один закрытый день: его дата должна быть не позднее
+Возьмите день, за который уже получены все слепки заказов: его дата должна быть не позднее
`max(snapshot_date) - 6` из `ods.order_v`. Во всех запросах к `dds.event_v`
отбирайте этот `event_date` и `event_type = 'purchase'`.
@@ -22,12 +23,12 @@
Сначала посчитайте число событий и сумму всех заявленных сумм. Затем найдите
номера заказов, которые встречаются несколько раз. Если таких нет, выберите
-другой закрытый день.
+другой день с полученными слепками.
Для двух повторившихся покупок выпишите `event_id`, `client_id`, `event_time`,
номер заказа и сумму. Сопоставьте число событий и заказов в `dds.order_v`.
-## Соберите расчет по заказам
+## Расчет по заказам
Напишите запрос с одной строкой на номер заказа. Сохраните в результате
номер, число исходных событий и одну заявленную сумму. До выбора суммы
@@ -38,7 +39,7 @@
разных номеров в исходных событиях. Отдельно проверьте свое решение на случае
двух разных заказов с одинаковой суммой: сколько денег оно учтет?
-## Найдите заказы, из-за которых сумма завышена
+## Заказы с повторными событиями
Возьмите один повторившийся номер заказа. Сколько денег он добавил в первый
расчет и сколько — во второй? Подтвердите разность его событиями.
@@ -58,22 +59,23 @@
его статус и [порядок присвоения классов](../../architecture/dm.md#dmpurchase_vs_orders_v).
Класс витрины не заменяет поиск всех повторов в событиях.
-## Определите границы расчета
+## Сравнение с выручкой магазина
Сложите `revenue` из `dm.revenue_daily_v` за тот же `report_date` по всем
категориям. Эта витрина считает стоимость позиций оплаченных заказов,
без скидки и доставки.
-Сравните результат с исправленной клиентской суммой. Совпадение не требуется:
+Сравните результат с суммой по событиям после устранения повторов. Совпадение не требуется:
удаление повторов устраняет одну причину расхождения. Найдите хотя бы один
заказ, который объясняет, почему деньги трекера и магазина все еще могут
различаться. Для этого сопоставьте события, заказ и готовую сверку.
-Рядом со своим запросом укажите его зерно и ключ. Обоснуйте, где нужна одна
-строка на событие, а где — одна на заказ: следует ли ради этого расчета
+Рядом с запросом укажите, чему соответствует одна строка результата
+и какие поля ее определяют. Обоснуйте, где нужна одна строка на событие,
+а где одна на заказ: следует ли ради этого расчета
удалять повторные покупки из `dds.event_v`? Используйте найденные `event_id`.
-## Что должно получиться
+## Результат работы
- [ ] сохранен запрос с одной строкой на заказ, числом событий и суммой;
- [ ] уникальность ключа и полнота набора номеров проверены по источнику;
diff --git a/docs/labs/optional/identity-over-time.md b/docs/labs/optional/identity-over-time.md
index 7140157..c4cbd43 100644
--- a/docs/labs/optional/identity-over-time.md
+++ b/docs/labs/optional/identity-over-time.md
@@ -1,7 +1,8 @@
# Как новые заказы меняют отчет о посетителях
-**Чему учит:** сохранять историю связей посетителей с покупателями
-и использовать ее для воспроизведения прежнего расчета.
+Вы сохраните историю связей посетителей сайта с покупателями магазина.
+На ее основе проверите, как новые связи меняют старые отчеты и что нужно,
+чтобы повторить прежний расчет.
Факультатив доступен после ступени 1. Понадобятся Airflow и DBeaver с
подключением `student` по [инструкции](../../../README.md#подключиться-из-dbeaver).
@@ -9,13 +10,14 @@
ваши таблицы будут доступны только на узле этого подключения.
Если предложенное имя таблицы занято, выберите свое.
-В событиях сайта посетитель обозначен кукой `client_id`, а в заказах
-покупатель — `user_id`. Сопоставив событие покупки с заказом, можно связать
-эти идентификаторы. Тогда и к прежним визитам этой куки можно добавить
+В событиях сайта посетитель обозначен `client_id` из cookie браузера,
+а в заказах покупатель обозначен `user_id`. Сопоставив событие покупки
+с заказом, можно связать эти идентификаторы. Тогда и к прежним визитам
+этого `client_id` можно добавить
пользователя магазина. Вы проверите, как появление такой связи меняет число
известных пользователей в отчете за прошедший день.
-## Сопоставьте посетителей с покупателями
+## Связи посетителей с покупателями
Поставьте `world_live` на паузу, дождитесь завершения начатых прогонов
генерации и обработки. Запустите `etl_pipeline` и дождитесь успеха.
@@ -32,7 +34,8 @@
найдите самое раннее время события покупки, которое удалось сопоставить
с заказом. Назовите эту колонку `first_purchase_at`.
В событии `purchase` массив `purchase_id` содержит один номер заказа.
-Статус заказа не ограничивайте: отмена покупки не меняет владельца куки.
+Статус заказа не ограничивайте: отмена покупки не меняет найденную связь
+посетителя с покупателем.
Источники распределены по разным ключам. Для соединения используйте
`GLOBAL INNER JOIN`, заключив выборки событий и заказов в подзапросы.
@@ -43,7 +46,7 @@
Для одной пары покажите номера заказов и события, по которым получили
`first_purchase_at`.
-## Сохраните список связей после каждого модельного дня
+## История связей
Создайте таблицу:
@@ -84,7 +87,7 @@ SELECT max(event_date) AS observation_day FROM dds.event_v;
не устраняет повторы вставки. Сохраните и проверьте список перед тем,
как запускать следующий модельный день.
-## Сравните дату покупки с датой появления связи
+## Дата покупки и дата появления связи
Сравните списки за соседние даты: H0 с H1, затем H1 с H2. Найдите пары,
которых не было в прежнем списке. Для нескольких таких пар покажите
@@ -101,7 +104,7 @@ SELECT max(event_date) AS observation_day FROM dds.event_v;
у вас нет более ранних копий списка. Для новой пары можно установить
интервал между двумя сохранениями, но не точное время внутри него.
-## Пересчитайте отчет за прежние дни
+## Пересчет прежних отчетов
Возьмите один и тот же набор сессий из `dds.session_v` за даты до H0
включительно. Постройте два дневных расчета:
@@ -128,7 +131,7 @@ SELECT max(event_date) AS observation_day FROM dds.event_v;
На примере найденных различий объясните, какие данные нужно сохранять,
чтобы повторить прежний расчет.
-## Что должно получиться
+## Результат работы
У вас есть запрос сопоставления посетителей с покупателями, три сохраненных
списка связей и сравнение двух расчетов на одном наборе сессий. На конкретных
diff --git a/docs/labs/optional/incremental-daily-traffic.md b/docs/labs/optional/incremental-daily-traffic.md
index 195ee06..feaf0e7 100644
--- a/docs/labs/optional/incremental-daily-traffic.md
+++ b/docs/labs/optional/incremental-daily-traffic.md
@@ -1,7 +1,8 @@
# Как обновлять витрину без полной пересборки
-**Чему учит:** выбирать дни для пересчета по новым данным, проверять результат
-и повторять незавершенную загрузку.
+Вы настроите обновление витрины только за дни, затронутые новыми данными.
+Затем проверите, можно ли продолжить загрузку, которая успела записать
+лишь часть результатов.
Факультатив повышенной сложности продолжает работу
[«Как новые заказы меняют отчет о посетителях»](identity-over-time.md).
@@ -18,7 +19,7 @@
Удаление данных, исправления старых сессий и пересоздание мира потребовали бы
другого способа обнаружения изменений.
-## Создайте витрину и журнал успешных загрузок
+## Витрина и журнал загрузок
Создайте две локальные таблицы. Если имена заняты, выберите свои.
@@ -52,7 +53,7 @@ ORDER BY observation_day;
и успешной сверки. Максимальная дата в журнале укажет, какой список
использовался в последней завершенной загрузке.
-## Выполните первую загрузку целиком
+## Первая загрузка
Убедитесь, что `world_live` стоит на паузе и нет активных прогонов генерации
и обработки. Запустите `etl_pipeline` и дождитесь успеха. Получите текущую
@@ -79,7 +80,7 @@ ORDER BY observation_day;
и различия обоих показателей. Учебную витрину читайте с `FINAL`.
После совпадения запишите дату наблюдения в журнал один раз.
-## Найдите дни, которые нужно пересчитать
+## Выбор дней для пересчета
Продвиньте мир на один день: `world_next_day` с `days = 1`, затем
`etl_pipeline`; после каждого запуска дождитесь успеха. Сохраните новый
@@ -90,7 +91,7 @@ ORDER BY observation_day;
даты для пересчета без повторов. Он должен учесть два изменения:
- в сессиях появились дни, которых еще нет в вашей витрине;
-- в списке на H появились пары, которых не было в списке на P; у этих кук
+- в списке на H появились пары, которых не было в списке на P; у этих `client_id`
есть сессии, в том числе за прежние дни.
Сначала выведите даты и выберите один прежний день: какая новая пара
@@ -103,7 +104,7 @@ ORDER BY observation_day;
найденными сравнением списков? Покажите пару, которую ограничение по дате
покупки пропускает.
-## Обновите выбранные дни
+## Обновление витрины
Напишите второй `INSERT INTO ... SELECT`. Для выбранных дат он должен
рассчитать все сессии с полным списком связей на H. Прибавление числа новых
@@ -117,7 +118,7 @@ ORDER BY observation_day;
Запрос должен вернуть пустой результат, а витрина — остаться прежней.
Повторно записывать H в журнал не требуется.
-## Проверьте повторный запуск после частичной загрузки
+## Повтор незавершенной загрузки
Снова запустите `world_next_day` с `days = 1`, затем `etl_pipeline`,
дожидаясь успеха каждого. Сохраните новый список связей с его датой H.
@@ -133,7 +134,7 @@ ORDER BY observation_day;
не означает, что витрина обновлена. Что изменится, если записать H в журнал
до обновления витрины?
-## Что должно получиться
+## Результат работы
Сохраните SQL первой загрузки, выбора дат, обновления и сверки.
После двух новых модельных дней ваша витрина совпадает со стендовой,
@@ -143,8 +144,9 @@ ORDER BY observation_day;
Сравните число пересчитанных дней с полной загрузкой и перечислите,
какие дополнительные данные пришлось хранить. Так можно оценить объем работы.
На небольшом стенде полный пересчет может быть дешевле.
-В вашей реализации поиск связей пока читает всю историю покупок —
-инкрементальным стал именно пересчет дневной витрины.
+В вашей реализации поиск связей пока читает всю историю покупок.
+По новым данным обновляется только дневная витрина; такое обновление
+называют инкрементальным.
После работы удалите свои `lab_daily_traffic`, `lab_traffic_runs` и
`lab_identity_history` через `DROP TABLE`, если они больше не нужны.
diff --git a/docs/labs/optional/missing-purchases.md b/docs/labs/optional/missing-purchases.md
index ff6333a..9d33f6a 100644
--- a/docs/labs/optional/missing-purchases.md
+++ b/docs/labs/optional/missing-purchases.md
@@ -1,21 +1,24 @@
# Куда пропали покупки
-**Чему учит:** проверить полноту данных по независимому источнику и выбрать
-источник для витрины заказов.
+Вы сравните покупки из событий сайта с заказами магазина. Найденные
+расхождения помогут выбрать источник для полной витрины заказов.
Факультатив доступен после ступени 1. В DBeaver подключитесь пользователем
`analyst` по [инструкции](../../../README.md#подключиться-из-dbeaver).
Дождитесь завершения `etl_pipeline`; на время расследования оставьте
`world_live` на паузе и дождитесь уже начатых прогонов.
-## Выберите день
+## Выбор дня
Можно ли построить полную витрину заказов по событиям `purchase`?
Проверьте это на одном дне: сначала сопоставьте события сайта, затем сверьте
номера заказов с выгрузкой магазина.
-Возьмите день, для которого уже получен последний слепок семидневного окна.
-Запрос показывает самую позднюю подходящую дату:
+Слепок заказов содержит их состояние на конец модельного дня. Состояние
+заказа может меняться семь дней, поэтому для окончательного сравнения нужны
+все слепки за этот период. Возьмите день, для которого уже получен последний
+из них. Такой день называется закрытым. Запрос показывает самую позднюю
+подходящую дату:
```sql
SELECT max(snapshot_date) - 6 AS last_closed_day
@@ -27,7 +30,7 @@ FROM ods.order_v;
Сравнивайте все заказы дня, включая отмененные: сейчас вопрос в том,
зарегистрировал ли магазин заказ, а не в том, был ли он оплачен.
-## Найдите разрыв в событиях
+## События оформления заказа
При оформлении заказа посетитель видит страницу подтверждения, а трекер
отправляет событие покупки. Просмотр страницы и покупка — отдельные события.
@@ -42,7 +45,7 @@ FROM ods.order_v;
заказа пришло ровно по одному событию каждого вида? Что можно предположить
по вашему результату и чего эти два числа еще не доказывают?
-## Сверьте с магазином
+## Сверка с заказами магазина
В событии номер заказа находится в `purchase_id[1]`, в заказах — в `order_id`.
Соберите список заказов выбранного дня, для которых нет события покупки.
@@ -72,7 +75,7 @@ FROM ods.order_v;
Если в выбранном дне повторов нет, возьмите другой закрытый день.
-## Сопоставьте вывод с готовой сверкой
+## Сравнение с готовой витриной
Найдите свои заказы без события в `dm.purchase_vs_orders_v` по `order_id`.
Сравните их `status` и `mismatch_class`. Витрина присваивает заказу один класс,
@@ -83,7 +86,7 @@ FROM ods.order_v;
Возьмите один заказ из своего списка и сохраните два запроса: первый находит
его в магазине, второй показывает отсутствие покупки в трекере.
-## Выберите источник и условие проверки
+## Выбор источника для витрины
По результатам сверки укажите источник для витрины, в которой должен быть
каждый зарегистрированный заказ. Обоснуйте выбор найденными номерами.
@@ -93,7 +96,7 @@ FROM ods.order_v;
Позволяет ли отсутствие события в `dds.event_v` установить место потери —
до приема в хранилище или при обработке? Какие данные нужны, чтобы это выяснить?
-## Что должно получиться
+## Результат работы
- [ ] сохранен запрос сверки по дате, возвращающий заказы без события;
- [ ] оба равенства сходятся, влияние повторного просмотра объяснено;
diff --git a/docs/labs/optional/order-amount-difference.md b/docs/labs/optional/order-amount-difference.md
index 0f18a45..46a2397 100644
--- a/docs/labs/optional/order-amount-difference.md
+++ b/docs/labs/optional/order-amount-difference.md
@@ -1,14 +1,15 @@
# Какая позиция объясняет разницу в сумме
-**Чему учит:** разобрать вложенные данные без потери точности и локализовать
-расхождение двух источников до товарной позиции.
+Вы сравните товары в событии покупки и заказе магазина, чтобы найти причину
+разницы в суммах. При разборе вложенных данных понадобится сохранить точность
+цен до копейки.
Факультатив доступен после ступени 1. В DBeaver используйте пользователя
`analyst` из [инструкции подключения](../../../README.md#подключиться-из-dbeaver).
Дождитесь завершения `etl_pipeline`; оставьте `world_live` на паузе и
дождитесь уже начатых прогонов.
-## Найдите заказ
+## Выбор заказа
Суммы покупки в трекере и заказа в магазине различаются. Разберите их позиции
и проверьте отдельно точность своего запроса и различия между событием и заказом.
@@ -24,12 +25,12 @@
`declared_revenue`, если мы ищем расхождение именно в составе корзины?
Проверьте свой выбор после разбора позиций.
-## Разберите позиции с двух сторон
+## Товарные позиции события и заказа
Найдите событие покупки в `ods.event_v`: `purchaseID[1]` совпадает с номером
заказа, `EventDate` — с выбранным днем, `EventType = 'purchase'`.
Откройте поле `ecommerce`. Внутри JSON массив `purchase.products` содержит
-позиции клиента с полями `id`, `price` и `quantity`.
+товары из события покупки с полями `id`, `price` и `quantity`.
Разверните этот массив в строки и получите артикул, точную цену, количество
и стоимость каждой позиции. Для разбора пригодятся `JSONExtractArrayRaw`
@@ -47,7 +48,7 @@
Сохраните запрос сверки для выбранных даты и номера заказа; в результате
оставьте артикул, значения с обеих сторон и разность стоимостей позиций.
-## Проверьте объяснение
+## Проверка сумм
Подтвердите три равенства:
@@ -66,7 +67,7 @@
Какие строки подтверждают найденное расхождение? Есть ли в источниках
сведения о том, почему оно возникло? Отделите наблюдения от предположений.
-## Выберите поля для точного расчета
+## Поля для точного расчета
Укажите рядом с запросом, откуда брать цену, каким типом ее читать и по какому
ключу сопоставлять позиции. Подтвердите выбор результатами обеих сверок:
@@ -74,7 +75,7 @@
между собой. Какой результат укажет на ошибку вашего разбора, а какой
потребует искать причину выше — в данных, которые получил запрос?
-## Что должно получиться
+## Результат работы
- [ ] сохранен запрос сверки позиций, не теряющий товары без пары;
- [ ] все три равенства сходятся до копейки;
diff --git a/docs/labs/optional/revenue-over-time.md b/docs/labs/optional/revenue-over-time.md
index 469236e..b569b47 100644
--- a/docs/labs/optional/revenue-over-time.md
+++ b/docs/labs/optional/revenue-over-time.md
@@ -1,16 +1,18 @@
# Почему вчерашняя выручка меняется сегодня
-**Чему учит:** обосновать окно пересчета витрины и требования к истории
-заказов по наблюдаемым опозданиям и изменениям статусов.
+Вы проследите, как опоздавшие заказы и смена статусов меняют выручку
+за прошедший день. По этим наблюдениям определите, какие дни пересчитывать
+и какую историю заказов сохранять.
Факультатив доступен после ступени 1. Понадобятся Airflow и DBeaver с
подключением `student` по [инструкции](../../../README.md#подключиться-из-dbeaver).
-Он читает данные стенда и записывает ваши наблюдения в базу `default`.
+Пользователь `student` может читать данные стенда и записывать ваши наблюдения
+в базу `default`.
Вы будете наблюдать один день оформления заказов, продвигая мир по одному
модельному дню. Проверьте, можно ли при загрузке выручки каждый раз считать
только новый день. Новые данные останутся в стенде.
-## Подготовьте первое наблюдение
+## Подготовка
Поставьте `world_live` на паузу. Дождитесь завершения начатых прогонов
генерации и обработки, затем запустите `etl_pipeline` и дождитесь успеха.
@@ -28,14 +30,15 @@ SELECT
Дата слепка, по которому сделано очередное наблюдение, будет **S**.
Это модельные даты магазина. Календарная дата запуска Airflow день D не задает.
-Слепок снимается к концу суток и доставляется следующим прогоном. Поэтому
-покупки последнего сыгранного дня уже видны в событиях, а их заказов в
+Слепок содержит состояние заказов на конец модельных суток и доставляется
+при следующем запуске генератора. Поэтому покупки последнего завершившегося
+дня уже видны в событиях, а их заказов в
хранилище еще нет. Проверьте это для D в `dds.order_v` и
`dm.purchase_vs_orders_v`.
-## Подготовьте место для наблюдений
+## Таблицы для истории заказов
-Публичное представление заказа показывает текущее состояние, а сырые слепки
+Представление `dds.order_v` показывает текущее состояние заказов, а сырые слепки
хранятся ограниченное время. Сохраните историю в двух своих таблицах: потом
вы сможете сравнивать наблюдения обычными SQL-запросами в том же DBeaver.
@@ -66,7 +69,7 @@ ENGINE = MergeTree
ORDER BY (order_date, snapshot_date);
```
-## Сохраните первое наблюдение
+## Первое наблюдение
Напишите два `INSERT INTO ... SELECT` с явным списком колонок назначения:
@@ -93,7 +96,7 @@ ORDER BY (order_date, snapshot_date);
статусам и сумму `items_total` только у `paid`. Эта сумма должна сходиться
с сохраненной выручкой, сложенной по всем категориям.
-## Продвигайте мир по одному дню
+## Наблюдения за следующие дни
Для каждого следующего наблюдения:
@@ -102,16 +105,16 @@ ORDER BY (order_date, snapshot_date);
не обновляет.
3. Повторите два запроса сохранения с новой датой S и проверьте записанные строки.
-Во всех запросах остается прежний день D. За один запуск проигрывайте один
+Во всех запросах остается прежний день D. За один запуск добавляйте один
день, чтобы сохранить промежуточные состояния. Пакетный `world_next_day`
отправляет события без пауз; ждать 24 минуты живого дня здесь не нужно.
-Продолжайте до слепка **D + 7** включительно. У заказов дня D окно состоит
-из семи дней, от D до D + 6. Последний шаг нужен для сравнения состояния
-в конце окна и за его пределами.
+Продолжайте до слепка **D + 7** включительно. Заказы дня D могут меняться
+семь дней, от D до D + 6. Этот период называется окном изменяемости.
+Последний шаг нужен для сравнения состояния в конце окна и после него.
От исходного наблюдения потребуется восемь однодневных запусков.
-## Найдите изменения в уже загруженном дне
+## Изменения заказов и выручки
Для сравнения прочитайте `default.lab_order_history` дважды: в одном
подзапросе отберите прежнюю S, в другом — новую; день D остается общим.
@@ -138,7 +141,7 @@ ORDER BY (order_date, snapshot_date);
Достаточно ли добавлять в старый день только опоздавшие заказы? Проверьте
свой ответ на найденных изменениях статусов.
-## Определите, какую историю нужно хранить
+## Требования к истории заказов
Возьмите отмененный заказ, у которого в вашей истории был статус `paid`.
Укажите два наблюдения, доказывающих, что он был оплачен, а затем отменен.
@@ -153,7 +156,7 @@ ORDER BY (order_date, snapshot_date);
Какие сведения для каждой задачи должен передавать источник и сохранять
хранилище? Проверьте, каких нужных сведений нет в ваших таблицах наблюдений.
-## Задайте правило пересчета
+## Правило пересчета витрины
Сравните сохраненные состояния при S = D + 6 и S = D + 7: состав заказов,
их статусы, суммы и выручку. Какой слепок последним мог изменить заказы дня D
@@ -171,13 +174,13 @@ ORDER BY (order_date, snapshot_date);
[слепка и его доставки](../../architecture/orders/snapshot.md#слепок-и-его-доставка)
и [границ истории отмен](../../architecture/orders/fate.md#судьба-заказа).
-## Что должно получиться
+## Результат работы
- [ ] сохранена последовательность наблюдений одного дня D с датами слепков S;
- [ ] на опоздавших заказах и изменениях статусов показаны ошибки загрузки
только новых дней или только новых заказов;
- [ ] изменение выручки между наблюдениями сходится с изменением вкладов заказов;
-- [ ] оплата одной из последующих отмен подтверждена двумя наблюдениями;
+- [ ] для одного отмененного заказа найдены два наблюдения: с оплатой и с отменой;
- [ ] сформулированы требования к истории расчетов и истории оплаты;
- [ ] правило пересчета и момент закрытия дня обоснованы наблюдениями
и правилом доставки слепков.
diff --git a/docs/labs/optional/superset-dashboard.md b/docs/labs/optional/superset-dashboard.md
index 48c042e..173806c 100644
--- a/docs/labs/optional/superset-dashboard.md
+++ b/docs/labs/optional/superset-dashboard.md
@@ -1,12 +1,13 @@
# Свой дашборд в Superset
-**Чему учит:** собрать из своих витрин отчет о поведении посетителей,
-подобрать графики под вопросы и разместить их на одном понятном экране.
+Из своих витрин вы соберете отчет о поведении посетителей: подберете графики
+и разместите их на одном экране так, чтобы отчет можно было понять
+без ваших пояснений.
-Это факультатив раздела «Superset и BI». Его можно пройти после ступени 2,
+Это задание раздела «Отчеты в Superset». Его можно пройти после ступени 2,
когда готовы витрины ступени 1.
-## На какие вопросы ответит дашборд
+## Отчет о поведении посетителей
Соберите обзор трафика и поведения посетителей. Он должен отвечать на три
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
@@ -17,25 +18,23 @@
стендового дашборда не нужно: выберите форму, в которой ответы читаются без
пояснений рядом с экраном.
-## Свои объекты — отдельно
+## Отдельный дашборд для задания
Не добавляйте графики в стендовый дашборд и не правьте его датасеты. При
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
-таких объектов описан в [доке Superset](../../architecture/superset.md), «Импорт
+таких объектов описан в [справочнике Superset](../../architecture/superset.md), «Импорт
при каждой инициализации».
Используйте свои датасеты и графики, соберите из них свой дашборд.
-Назовите его, например, «Мои витрины DM». Это то же правило, по которому DDL
-домашек лежит в `sql/ddl/70-labs.sql`: стендовые объекты уже есть, свои
-создаете вы.
+Назовите его, например, «Мои витрины DM».
## Датасеты
Датасет `session_overview_v`, его метрика доли отказов и график уже готовы
после [ступени 2](../2-1-superset-chart.md). Используйте их здесь. Добавьте
недостающие датасеты, чтобы в подключении `ClickHouse` были представлены
-пять публичных представлений схемы `dm`:
+пять представлений ваших витрин из схемы `dm`:
- `events_enriched_v`;
- `top_pages_daily_v`;
@@ -56,7 +55,7 @@
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
-Для долей используйте слагаемые из витрин. Например, доля отказов считается
+Для долей используйте суммы счетчиков из витрин. Например, доля отказов считается
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
долям для такого графика не подходит.
@@ -73,7 +72,7 @@
по модельному времени. Для панели ошибок выберите период загрузки отдельно
и укажите его в подписи. Общий фильтр времени для всего дашборда не требуется.
-## Как проверить себя
+## Проверка результата
Автоматической проверки этого задания нет. Числа вы сверите запросами,
а понятность графиков проверите по подписям.
@@ -103,9 +102,9 @@
Проверьте, можно ли найти эти ответы по одним подписям графиков. Если есть
возможность, попросите другого человека прочитать экран без ваших пояснений.
-## Что должно получиться
+## Результат работы
-- [ ] у вас есть пять датасетов поверх публичных представлений витрин заданий 1–5;
+- [ ] у вас есть пять датасетов по представлениям витрин заданий 1–5;
- [ ] вы собрали свой дашборд, сохранив стендовые объекты без изменений;
- [ ] на дашборде есть хотя бы один график от каждой витрины;
- [ ] вы проверили, что графики и подписи понятны по вопросам из раздела выше;
diff --git a/docs/labs/optional/superset-missing-object.md b/docs/labs/optional/superset-missing-object.md
index aff842e..2c26e58 100644
--- a/docs/labs/optional/superset-missing-object.md
+++ b/docs/labs/optional/superset-missing-object.md
@@ -1,16 +1,16 @@
# В DBeaver работает, в Superset нет
-**Чему учит:** найти причину, по которой один клиент читает объект
-хранилища, а другой его не видит.
+Вы создадите представление, которое читается из DBeaver, но недоступно
+в Superset. Затем найдете причину и исправите ее.
Факультатив доступен после [ступени 2](../2-1-superset-chart.md).
Свой дашборд для опыта не нужен. Используйте готовую витрину стенда
`dm.daily_traffic_v`.
-## Подготовьте опыт
+## Подготовка
-В DBeaver подключитесь к первой ноде пользователем `etl`, как описано
-в разделе [«Как применить свой DDL»](../README.md#как-применить-свой-ddl).
+В DBeaver подключитесь к первому узлу пользователем `etl`, как описано
+в разделе [«Выполнение SQL в DBeaver»](../README.md#выполнение-sql-в-dbeaver).
Для опыта создайте отдельное представление:
```sql
@@ -28,19 +28,19 @@ SELECT count() FROM dm.bi_node_probe_v;
Повторите запрос в SQL Lab Superset, выбрав подключение `ClickHouse`.
Сохраните текст ошибки.
-## Найдите причину
+## Поиск причины
Выясните, к каким серверам обращаются DBeaver и Superset. Адреса подключения
показывают настройки клиентов, а имя ответившего сервера можно спросить
запросом `SELECT hostName()`.
-Проверьте наличие `dm.bi_node_probe_v` на обеих нодах. Для второй ноды
+Проверьте наличие `dm.bi_node_probe_v` на обоих узлах. Для второго узла
заведите подключение DBeaver с портом из
[README](../../../README.md#подключиться-из-dbeaver). Запишите, где объект
есть, и объясните, почему успешного создания в первом подключении оказалось
недостаточно.
-Исправьте DDL так, чтобы представление появилось на обеих нодах. Подключение
+Исправьте DDL так, чтобы представление появилось на обоих узлах. Подключение
Superset должно остаться прежним. Сохраните исправленный DDL и повторите
исходный запрос в обоих клиентах.
@@ -48,23 +48,23 @@ Superset должно остаться прежним. Сохраните исп
[справочнике Superset](../../architecture/superset.md#устройство-зоны) и
[описание распределенного DDL ClickHouse](https://clickhouse.com/docs/reference/statements/distributed-ddl).
-## Как проверить себя
+## Проверка результата
В обоих клиентах запрос к вашему представлению должен вернуть столько же
строк, сколько `SELECT count() FROM dm.daily_traffic_v`. Сравнивайте результаты
при завершенном прогоне конвейера, пока мир не продвигается.
Объясните причину ошибки через три факта: куда пришел DDL, куда пришел
-запрос Superset и на каких нодах существовало представление.
+запрос Superset и на каких узлах существовало представление.
-После опыта удалите только `dm.bi_node_probe_v` на обеих нодах. Не включайте
+После опыта удалите только `dm.bi_node_probe_v` на обоих узлах. Не включайте
его в постоянный DDL домашних витрин: это временный объект расследования.
-## Что должно получиться
+## Результат работы
- [ ] у вас сохранены успешный запрос из DBeaver и ошибка из SQL Lab;
- [ ] вы можете объяснить, к каким серверам подключены клиенты, где было
создано представление и почему Superset его не видел;
- [ ] у вас есть исправленный DDL; оба клиента возвращают столько же строк,
сколько исходная витрина;
-- [ ] после опыта вы удалили учебное представление с обеих нод.
+- [ ] после опыта вы удалили учебное представление с обоих узлов.
diff --git a/docs/specs/2026-09-01-labs-program.md b/docs/specs/2026-09-01-labs-program.md
index ed6e519..aa3c2f1 100644
--- a/docs/specs/2026-09-01-labs-program.md
+++ b/docs/specs/2026-09-01-labs-program.md
@@ -45,12 +45,16 @@
Пишите для учащегося: объясняйте, чему он научится, зачем нужны действия
и как понять результат. Историю разработки оставляйте за пределами задания.
-«Как проверить себя» описывает действия для проверки и поиска ошибок.
-«Что должно получиться» — результат работы и то, что учащийся теперь может
+Начинайте задание коротким описанием предстоящей работы. Заголовки должны
+называть содержание разделов: «Подготовка», «Создание витрины», «Проверка
+результата». Шаблонные вопросы «Чему учит» и «Что сделать» не нужны.
+
+Раздел проверки описывает действия для проверки и поиска ошибок.
+Раздел результата перечисляет итог работы и то, что учащийся теперь может
объяснить. Если проверка уже встроена в шаги упражнения, повторять ее
отдельным разделом не нужно.
-Остальные заголовки и структуру выбирайте по содержанию. Образцы — задания
+Структуру выбирайте по содержанию. Образцы — задания
про [дашборд Superset](../labs/optional/superset-dashboard.md) и
[расследование подключения](../labs/optional/superset-missing-object.md).