From 9d855c9b581b57c247abb20cf6a8e1b0a810af29 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 6 Mar 2026 21:06:46 +0300 Subject: [PATCH 01/24] =?UTF-8?q?feat(docs):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20PRD=20=D0=B8=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D0=B0=20=D0=BE=D0=B1=D1=83?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D0=BA?= =?UTF-8?q?=D1=83=D1=80=D1=81=D0=B0=20=D0=BF=D0=BE=20Lakehouse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - Формализовать цели, аудиторию и структуру учебного курса для плавного погружения джунов в Lakehouse-архитектуру. - Что: - Создан docs/PRD_Course.md с описанием JTBD, "Aha-moments" и метрик успеха. - Создан docs/COURSE_PLAN.md с детальной программой из 10 интерактивных уроков. - Проверка: - Файлы присутствуют в директории docs и корректно ссылаются друг на друга. --- docs/COURSE_PLAN.md | 44 ++++++++++++++++++++++++++++++++++ docs/PRD_Course.md | 58 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 docs/COURSE_PLAN.md create mode 100644 docs/PRD_Course.md diff --git a/docs/COURSE_PLAN.md b/docs/COURSE_PLAN.md new file mode 100644 index 0000000..b37a06f --- /dev/null +++ b/docs/COURSE_PLAN.md @@ -0,0 +1,44 @@ +# Программа учебного курса: Введение в Lakehouse + +Ориентировочная разбивка на 10 интерактивных уроков (Jupyter Notebooks). +Каждый урок рассчитан на 1-1.5 часа вдумчивой работы (теория + самостоятельная практика). + +## Блок I. Основы Lakehouse (Storage + Format) + +* **01_intro_and_storage.ipynb** + * Что такое Lakehouse. Разделение хранения и вычислений. + * Загрузка сырых файлов (CSV/JSON) в MinIO (S3). +* **02_first_iceberg_table.ipynb** + * Создание первой Iceberg-таблицы через Spark. + * Изучение структуры файлов (data, metadata, manifests) в MinIO. + +## Блок II. Единая точка правды (Catalog + Compute) + +* **03_the_catalog.ipynb** + * Роль JDBC-каталога (PostgreSQL). + * Как разные движки находят таблицы. +* **04_spark_meets_trino.ipynb** + * Разделение ролей (Spark пишет, Trino читает). + * Выполнение SQL-запросов к Iceberg-таблицам через Trino (интеграция вызовов Trino прямо в Jupyter). + +## Блок III. Инженерия данных (Data Pipelines) + +* **05_bronze_layer.ipynb** + * Чтение сырых данных Spark'ом и инжест "как есть" (Raw to Bronze). +* **06_silver_layer_and_schema_evolution.ipynb** + * Очистка данных. + * *Практика:* самостоятельное применение Schema Evolution (добавление/изменение колонок). +* **07_gold_layer_and_partitioning.ipynb** + * Агрегации бизнес-метрик. + * *Практика:* самостоятельное партиционирование таблиц для ускорения запросов (Partition Pruning). + +## Блок IV. Продвинутые фичи Lakehouse + +* **08_time_travel_and_snapshots.ipynb** + * Работа со снапшотами (Snapshots). + * *Практика:* восстановление таблицы после ошибочных `DELETE`/`UPDATE`, чтение "исторических" данных. +* **09_lakehouse_maintenance.ipynb** + * Проблема мелких файлов. + * Компактизация (Compaction) и очистка старых версий (Vacuum). +* **10_final_pipeline.ipynb** + * *Практика:* самостоятельный финальный end-to-end мини-пайплайн (Bronze -> Silver -> Gold), объединяющий все пройденные концепции. \ No newline at end of file diff --git a/docs/PRD_Course.md b/docs/PRD_Course.md new file mode 100644 index 0000000..ca76cfd --- /dev/null +++ b/docs/PRD_Course.md @@ -0,0 +1,58 @@ +# Product Requirements Document (PRD): Учебный курс «Введение в Lakehouse» + +## 1. Product Vision & Value Proposition + +**Видение:** Создать интерактивный практический курс, который "сломает" привычную картину мира монолитных баз данных (PostgreSQL/Greenplum) и плавно перестроит мышление студентов на парадигму распределенных систем (Lakehouse). +**Ценность:** Дать безопасную, наглядную "песочницу" для старта работы с современным Lakehouse, чтобы переход от классических БД к связке S3 + Iceberg + Spark/Trino прошел легко, предсказуемо и с минимальным трением. + +## 2. Target Audience (Целевая аудитория) + +* **Кто:** Junior Data Engineers, менти, студенты. +* **Их текущая картина мира:** Привыкли к классическим СУБД. Для них база данных — это единый "черный ящик", где движок вычислений (Compute) и жесткие диски (Storage) неразделимы, строго реляционны и транзакционны. +* **Бэкграунд:** Уверенно пишут SQL, знают Python, понимают классические слои DWH (Raw, ODS, DDS), умеют работать с Docker. + +## 3. Problem Statement & Jobs-To-Be-Done (JTBD) + +**Ключевая проблема:** +Когнитивный диссонанс при столкновении с Big Data и Lakehouse. Студенты не понимают, как набор Parquet-файлов в папках (S3) может быть "базой данных", зачем нужен отдельный каталог метаданных (Iceberg/Hive) и почему для записи мы используем один инструмент (Spark), а для чтения — другой (Trino). У них нет четкой ментальной модели распределенных систем. + +**Jobs-To-Be-Done (JTBD):** +* *Основной JTBD:* «Когда я перехожу на новый проект или получаю задачу, связанную с Lakehouse, я хочу **быстро понять общую архитектуру и влиться в работу с минимальным трением**, чтобы не чувствовать себя слепым котенком и не сломать прод из-за непонимания распределенной природы данных». +* *Вторичный JTBD:* «Я хочу получить практический обзор современных технологий (Spark, Iceberg, Trino), чтобы уверенно отвечать на вопросы на собеседованиях и расширить свой кругозор за пределы PostgreSQL». + +## 4. Ключевые "Aha-Moments" (Моменты озарения) + +Курс должен быть спроектирован так, чтобы студент через практику испытал следующие инсайты: +1. **Storage is Just Files:** «Ого, таблица в Lakehouse — это просто набор Parquet-файлов в бакете MinIO!» +2. **Decoupled Compute:** «Вау, я могу писать данные Spark'ом, а читать Trino, и они оба смотрят на одни и те же файлы без их копирования!» +3. **The Magic of Metadata:** «Так вот зачем нужен Iceberg! Это просто умный JSON-манифест, который говорит движкам, какие именно файлы читать для Time Travel или партиционирования». + +## 5. Метрики Успеха и Формат (Goals & Success Metrics) + +**Цели продукта:** +1. Студент может пройти курс от начала до конца за **~12 часов** вдумчивой работы в браузере (Jupyter + MinIO UI + Trino UI). +2. Студент может своими словами (на собеседовании или коллегам) объяснить архитектуру Lakehouse (Storage, Compute, Catalog) и зачем нужен табличный формат (Iceberg). +3. Студент умеет самостоятельно реализовать базовый пайплайн (чтение, трансформация, запись) в парадигме Lakehouse. + +**User Experience / Flow (Пользовательский опыт):** +Подход **«50/50 (Теория + Самостоятельная практика)»**. Ноутбуки предоставляют теорию, демистифицирующую Lakehouse (нацеленную на вызов "Aha-Moments"), примеры кода (50%) и заготовки ячеек. Студент изучает пример, а затем самостоятельно дописывает код для решения задачи, закрепляя новые концепции на практике. Финал курса — самостоятельный сквозной мини-пайплайн. + +--- + +## 6. Содержание (Scope) + +Ориентировочная программа на 10 интерактивных уроков (Jupyter Notebooks) вынесена в отдельный документ: **[COURSE_PLAN.md](./COURSE_PLAN.md)**. + +--- + +## 7. Технические Требования и Ограничения + +**Technical Requirements (В скоупе)** +* Использование существующего `docker-compose.yml` (Spark, Trino, MinIO, PostgreSQL, Jupyter). +* Установка дополнительных библиотек в образ Jupyter (например, `trino-python-client`), чтобы студенты могли выполнять запросы к Trino прямо из ноутбука. +* Подготовка небольших, но реалистичных датасетов (CSV/JSON) в папке репозитория. + +**Out of Scope (Вне скоупа / На будущее)** +* Оркестрация пайплайнов (Airflow, Dagster). +* Развертывание в Kubernetes / Облаках. +* Тюнинг производительности Spark на больших объемах данных. From f4a5bf0e2a67c2a2b85751869c9a0a51446cbb69 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 6 Mar 2026 22:39:33 +0300 Subject: [PATCH 02/24] =?UTF-8?q?docs(course):=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D1=91=D0=BD=20PRD=20=D0=B8=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - зафиксировать согласованную рамку курса по Lakehouse и сделать имя документа проще и единообразнее. - Что: - переработан PRD курса: уточнены ЦА, learning outcomes, scope, dataset strategy и out of scope. - добавлены checkpoints, acceptance criteria и методические принципы курса. - файл docs/PRD_Course.md переименован в docs/course_prd.md. - Проверка: - проверен итоговый diff и содержимое Markdown-документа; код и тесты не затрагивались. --- docs/PRD_Course.md | 58 --------------- docs/course_prd.md | 179 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 58 deletions(-) delete mode 100644 docs/PRD_Course.md create mode 100644 docs/course_prd.md diff --git a/docs/PRD_Course.md b/docs/PRD_Course.md deleted file mode 100644 index ca76cfd..0000000 --- a/docs/PRD_Course.md +++ /dev/null @@ -1,58 +0,0 @@ -# Product Requirements Document (PRD): Учебный курс «Введение в Lakehouse» - -## 1. Product Vision & Value Proposition - -**Видение:** Создать интерактивный практический курс, который "сломает" привычную картину мира монолитных баз данных (PostgreSQL/Greenplum) и плавно перестроит мышление студентов на парадигму распределенных систем (Lakehouse). -**Ценность:** Дать безопасную, наглядную "песочницу" для старта работы с современным Lakehouse, чтобы переход от классических БД к связке S3 + Iceberg + Spark/Trino прошел легко, предсказуемо и с минимальным трением. - -## 2. Target Audience (Целевая аудитория) - -* **Кто:** Junior Data Engineers, менти, студенты. -* **Их текущая картина мира:** Привыкли к классическим СУБД. Для них база данных — это единый "черный ящик", где движок вычислений (Compute) и жесткие диски (Storage) неразделимы, строго реляционны и транзакционны. -* **Бэкграунд:** Уверенно пишут SQL, знают Python, понимают классические слои DWH (Raw, ODS, DDS), умеют работать с Docker. - -## 3. Problem Statement & Jobs-To-Be-Done (JTBD) - -**Ключевая проблема:** -Когнитивный диссонанс при столкновении с Big Data и Lakehouse. Студенты не понимают, как набор Parquet-файлов в папках (S3) может быть "базой данных", зачем нужен отдельный каталог метаданных (Iceberg/Hive) и почему для записи мы используем один инструмент (Spark), а для чтения — другой (Trino). У них нет четкой ментальной модели распределенных систем. - -**Jobs-To-Be-Done (JTBD):** -* *Основной JTBD:* «Когда я перехожу на новый проект или получаю задачу, связанную с Lakehouse, я хочу **быстро понять общую архитектуру и влиться в работу с минимальным трением**, чтобы не чувствовать себя слепым котенком и не сломать прод из-за непонимания распределенной природы данных». -* *Вторичный JTBD:* «Я хочу получить практический обзор современных технологий (Spark, Iceberg, Trino), чтобы уверенно отвечать на вопросы на собеседованиях и расширить свой кругозор за пределы PostgreSQL». - -## 4. Ключевые "Aha-Moments" (Моменты озарения) - -Курс должен быть спроектирован так, чтобы студент через практику испытал следующие инсайты: -1. **Storage is Just Files:** «Ого, таблица в Lakehouse — это просто набор Parquet-файлов в бакете MinIO!» -2. **Decoupled Compute:** «Вау, я могу писать данные Spark'ом, а читать Trino, и они оба смотрят на одни и те же файлы без их копирования!» -3. **The Magic of Metadata:** «Так вот зачем нужен Iceberg! Это просто умный JSON-манифест, который говорит движкам, какие именно файлы читать для Time Travel или партиционирования». - -## 5. Метрики Успеха и Формат (Goals & Success Metrics) - -**Цели продукта:** -1. Студент может пройти курс от начала до конца за **~12 часов** вдумчивой работы в браузере (Jupyter + MinIO UI + Trino UI). -2. Студент может своими словами (на собеседовании или коллегам) объяснить архитектуру Lakehouse (Storage, Compute, Catalog) и зачем нужен табличный формат (Iceberg). -3. Студент умеет самостоятельно реализовать базовый пайплайн (чтение, трансформация, запись) в парадигме Lakehouse. - -**User Experience / Flow (Пользовательский опыт):** -Подход **«50/50 (Теория + Самостоятельная практика)»**. Ноутбуки предоставляют теорию, демистифицирующую Lakehouse (нацеленную на вызов "Aha-Moments"), примеры кода (50%) и заготовки ячеек. Студент изучает пример, а затем самостоятельно дописывает код для решения задачи, закрепляя новые концепции на практике. Финал курса — самостоятельный сквозной мини-пайплайн. - ---- - -## 6. Содержание (Scope) - -Ориентировочная программа на 10 интерактивных уроков (Jupyter Notebooks) вынесена в отдельный документ: **[COURSE_PLAN.md](./COURSE_PLAN.md)**. - ---- - -## 7. Технические Требования и Ограничения - -**Technical Requirements (В скоупе)** -* Использование существующего `docker-compose.yml` (Spark, Trino, MinIO, PostgreSQL, Jupyter). -* Установка дополнительных библиотек в образ Jupyter (например, `trino-python-client`), чтобы студенты могли выполнять запросы к Trino прямо из ноутбука. -* Подготовка небольших, но реалистичных датасетов (CSV/JSON) в папке репозитория. - -**Out of Scope (Вне скоупа / На будущее)** -* Оркестрация пайплайнов (Airflow, Dagster). -* Развертывание в Kubernetes / Облаках. -* Тюнинг производительности Spark на больших объемах данных. diff --git a/docs/course_prd.md b/docs/course_prd.md new file mode 100644 index 0000000..a196303 --- /dev/null +++ b/docs/course_prd.md @@ -0,0 +1,179 @@ +# Product Requirements Document (PRD): Учебный курс «Введение в Lakehouse» + +## 1. Product Vision & Value Proposition + +**Видение:** создать практический курс, который помогает студенту перейти от привычной картины мира классических СУБД к базовой рабочей модели Lakehouse без лишнего стресса и без опасных иллюзий про "магическую" распределенную платформу. + +**Ценность:** дать безопасную и наглядную песочницу на базе локального стенда (`Spark + Trino + Iceberg + MinIO + PostgreSQL`), в которой студент может руками повторить типовые повседневные действия Data Engineer и понять, как устроена современная Lakehouse-архитектура. + +## 2. Target Audience & Prerequisites + +**Основная аудитория:** студент курса [`de-roadmap`](https://github.com/dementev-dev/de-roadmap), который уже прошел или уверенно знает: + +* SQL +* Python +* Git +* Airflow +* Greenplum + +**Дополнительно:** возможно, уже поверхностно знаком с `NiFi` или `Kafka`, но это не является обязательным требованием. + +**Важно:** курс рассчитан не на абсолютного новичка, а на человека, который уже понимает базовые DE-процессы и хочет перенести это понимание в мир Lakehouse. + +## 3. Problem Statement & JTBD + +**Ключевая проблема:** при первом столкновении с Lakehouse студент часто не понимает: + +* как набор файлов в объектном хранилище может быть "таблицей"; +* зачем отдельно нужны `storage`, `catalog` и `compute`; +* почему один движок может писать данные, а другой читать те же самые таблицы; +* как безопасно работать с такими таблицами, не ломая данные и не теряя воспроизводимость пайплайна. + +В результате Lakehouse воспринимается либо как непонятная магия, либо как хаотичный набор технологий без общей модели. + +**Основной JTBD:** «Когда я попадаю в команду, где есть Lakehouse-стек, я хочу быстро понять, как он устроен и как в нем выполнять простые рабочие задачи, чтобы не быть опасным для окружения и не тратить недели на распаковку базовых концепций». + +**Вторичный JTBD:** «Я хочу руками пощупать популярный стек `Spark + Trino + Iceberg + S3-compatible storage`, чтобы увереннее обсуждать такие системы на собеседованиях и в работе». + +## 4. Product Goals + +Курс должен помочь студенту: + +1. Получить практический, а не только теоретический, ввод в Lakehouse. +2. Освоить безопасный набор базовых ежедневных действий, полезных на старте работы. +3. Понять архитектуру ровно настолько, чтобы осознанно пользоваться инструментами и не допускать грубых ошибок. +4. Сопоставить новые концепции с уже знакомой картиной мира `PostgreSQL/Greenplum` и классических DWH-слоев. + +## 5. Learning Outcomes + +После прохождения курса студент: + +1. Понимает различие между `storage`, `catalog` и `compute`, и может объяснить роль `MinIO`, `PostgreSQL`, `Spark` и `Trino` в этом стенде. +2. Умеет поднять локальный стенд, проверить его базовую работоспособность и использовать логи/веб-интерфейсы для первичной диагностики проблем. +3. Умеет загрузить raw-данные, прочитать их в `Spark`, проверить схему и подготовить данные к дальнейшей обработке. +4. Умеет создать и заполнить `Iceberg`-таблицу как основной табличный формат курса. +5. Умеет реализовать простой поток `raw -> bronze -> silver` с базовыми трансформациями и сохранением воспроизводимости шагов. +6. Умеет читать одну и ту же таблицу из `Spark` и `Trino` и понимает, почему это возможно без копирования данных. +7. Умеет выполнить базовые безопасные операции с таблицей: изменение схемы, просмотр snapshot-ов, чтение предыдущего состояния, базовый `compaction` и `vacuum`. + +## 6. Teaching Principles + +**Принцип 1. Практика первична.** Курс не должен превращаться в обзорную лекцию. Теория нужна для объяснения того, что студент делает руками. + +**Принцип 2. Ноутбук как учебник и тренажер.** В ноутбуках должны быть: + +* готовые демонстрационные ячейки с объяснением подхода; +* обязательные самостоятельные задания на похожее действие; +* `checkpoints` с ожидаемым результатом. + +**Принцип 3. Постоянный мостик к знакомому миру.** Новые концепции объясняются через короткие сравнения с `PostgreSQL/Greenplum` и классической нотацией слоев `stg/ods/dds`, где это уместно. + +**Принцип 4. Безопасность важнее глубины.** Курс должен в первую очередь снижать риск типовых ошибок новичка, а не учить всем возможностям стека. + +## 7. Risk Reduction / Common Mistakes + +Курс должен явно учить не допускать следующие ошибки: + +1. Путать `storage`, `catalog` и `compute`, не понимая, где реально лежат данные и кто отвечает за метаданные. +2. Воспринимать Lakehouse как "одну базу данных", скрывающую физическую структуру хранения. +3. Делать разрушительные операции вслепую: неаккуратный `overwrite`, `drop`, неконтролируемую перезапись слоя. +4. Смешивать роли слоев `raw`, `bronze`, `silver`, из-за чего теряется происхождение и воспроизводимость данных. +5. Менять схему и типы данных без понимания влияния на downstream-чтение и на другие движки. + +## 8. Scope + +**В первой версии курса в скоупе:** + +* архитектурная модель `storage + catalog + compute`; +* один основной табличный формат: `Iceberg`; +* работа с raw-данными; +* построение слоев `bronze` и `silver`; +* чтение и запись через `Spark`; +* чтение через `Trino`; +* базовое schema evolution; +* snapshot/time travel на прикладном уровне; +* базовый `compaction/vacuum` с параллелями к `Greenplum AppendOnly`; +* набор практик в Jupyter Notebooks с обязательными заданиями и checkpoints. + +Ориентировочная программа курса вынесена в отдельный документ: **[COURSE_PLAN.md](./COURSE_PLAN.md)**. + +## 9. Dataset Strategy + +**Основной датасет курса:** `NYC TLC Yellow Taxi Trip Records` + `Taxi Zone Lookup`. + +**Почему он выбран:** + +* это узнаваемый и реалистичный DE-кейс; +* он хорошо подходит для слоев `raw -> bronze -> silver`; +* его удобно использовать для демонстрации работы `Spark`, `Trino` и `Iceberg`; +* он поставляется в `PARQUET`, что делает raw-слой ближе к реальной жизни. + +**Режимы использования данных:** + +* `Default`: 3-6 месяцев данных для стандартного прохождения курса; +* `Extended`: до полного года данных для более тяжелого режима или будущего расширения курса. + +**Методическое решение:** курс строится вокруг одного домена данных, но не требует жесткого сквозного проекта. Основной формат курса - набор связанных практик на одном и том же наборе данных. + +## 10. User Experience / Learning Flow + +Курс строится по модели **"объяснение -> демонстрация -> самостоятельное повторение -> checkpoint"**. + +Каждый урок должен: + +1. Коротко объяснить новую концепцию. +2. Показать готовый рабочий пример. +3. Дать студенту похожее задание для самостоятельного выполнения. +4. Заканчиваться проверяемым результатом. + +**Формат прохождения:** желательно с ментором, но курс должен быть самодостаточным и для самостоятельного изучения. + +## 11. Success Metrics & Acceptance Criteria + +**Цели продукта:** + +1. Студент проходит курс за `~12-15 часов` вдумчивой работы. +2. Студент после курса способен безопасно выполнить базовые операции в Lakehouse-стенде без постоянной внешней помощи. +3. Студент может внятно объяснить ключевые архитектурные принципы Lakehouse на уровне junior/middle interview readiness. + +**Acceptance Criteria для учебного результата:** + +1. Студент проходит обязательные `checkpoints` по урокам. +2. Студент самостоятельно выполняет набор практик на одном датасете, включая: + * загрузку raw-данных; + * построение `bronze`; + * построение `silver`; + * создание/чтение `Iceberg`-таблицы; + * чтение одной и той же таблицы через `Spark` и `Trino`; + * демонстрацию базового schema evolution; + * демонстрацию snapshot/time travel; + * демонстрацию базового `compaction/vacuum`. +3. Студент может своими словами объяснить: + * что такое `Lakehouse` и чем он отличается от классической БД; + * как разделены `storage`, `catalog`, `compute`; + * что такое `Iceberg` в практическом смысле; + * почему `Spark` может писать, а `Trino` читать одни и те же таблицы; + * зачем нужны слои `raw -> bronze -> silver`. + +## 12. Technical Requirements + +**В скоупе:** + +* использование существующего `docker-compose.yml` (`Spark`, `Trino`, `MinIO`, `PostgreSQL`, `Jupyter`); +* подготовка учебных датасетов и/или инструкций по их загрузке в репозиторий; +* поддержка выполнения запросов к `Trino` из ноутбуков; +* подготовка Jupyter Notebooks как основного формата учебных материалов; +* наличие воспроизводимых шагов для старта, сброса и повторного прохождения практик. + +## 13. Out of Scope (v1) + +**Вне первой версии курса:** + +* `gold`-слой и полноценные бизнес-витрины; +* `Airflow`, `Kafka`, `NiFi`, `streaming`, `CDC`; +* сложные `MERGE`-сценарии, row-level deletes и update-heavy кейсы; +* глубокий performance tuning; +* production security, governance, multi-user setup; +* Kubernetes / облачное развертывание; +* детальное сравнение нескольких типов catalog; +* глубокий разбор internals `Iceberg` на уровне спецификации. From 7514e5a6251b60e90a7e07d544a025c09349bbaa Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 6 Mar 2026 22:50:35 +0300 Subject: [PATCH 03/24] =?UTF-8?q?docs(course):=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20PRD=20=D0=B8=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D0=B0=20=D0=BA=D1=83=D1=80?= =?UTF-8?q?=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужно выровнять учебную программу с PRD и явно отделить стартовый onboarding от практики в ноутбуках. - Что: - обновлены название курса и файл программы, а структура материалов пересобрана вокруг стартовой документации, 8 практических модулей и вспомогательных артефактов. - добавлены трассировка Learning Outcomes, hands-on упражнение для модуля 2 и уточнения по доставке учебного data bundle. - зафиксированы acceptance criteria, минимальный scope glossary и статус partitioning как legacy-темы вне обязательного трека v1. - Проверка: - просмотрен git diff HEAD~1 -- docs/course_prd.md docs/course_program.md. - проверен git diff --cached --stat перед amend. --- docs/COURSE_PLAN.md | 44 ------- docs/course_prd.md | 38 ++++-- docs/course_program.md | 287 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 315 insertions(+), 54 deletions(-) delete mode 100644 docs/COURSE_PLAN.md create mode 100644 docs/course_program.md diff --git a/docs/COURSE_PLAN.md b/docs/COURSE_PLAN.md deleted file mode 100644 index b37a06f..0000000 --- a/docs/COURSE_PLAN.md +++ /dev/null @@ -1,44 +0,0 @@ -# Программа учебного курса: Введение в Lakehouse - -Ориентировочная разбивка на 10 интерактивных уроков (Jupyter Notebooks). -Каждый урок рассчитан на 1-1.5 часа вдумчивой работы (теория + самостоятельная практика). - -## Блок I. Основы Lakehouse (Storage + Format) - -* **01_intro_and_storage.ipynb** - * Что такое Lakehouse. Разделение хранения и вычислений. - * Загрузка сырых файлов (CSV/JSON) в MinIO (S3). -* **02_first_iceberg_table.ipynb** - * Создание первой Iceberg-таблицы через Spark. - * Изучение структуры файлов (data, metadata, manifests) в MinIO. - -## Блок II. Единая точка правды (Catalog + Compute) - -* **03_the_catalog.ipynb** - * Роль JDBC-каталога (PostgreSQL). - * Как разные движки находят таблицы. -* **04_spark_meets_trino.ipynb** - * Разделение ролей (Spark пишет, Trino читает). - * Выполнение SQL-запросов к Iceberg-таблицам через Trino (интеграция вызовов Trino прямо в Jupyter). - -## Блок III. Инженерия данных (Data Pipelines) - -* **05_bronze_layer.ipynb** - * Чтение сырых данных Spark'ом и инжест "как есть" (Raw to Bronze). -* **06_silver_layer_and_schema_evolution.ipynb** - * Очистка данных. - * *Практика:* самостоятельное применение Schema Evolution (добавление/изменение колонок). -* **07_gold_layer_and_partitioning.ipynb** - * Агрегации бизнес-метрик. - * *Практика:* самостоятельное партиционирование таблиц для ускорения запросов (Partition Pruning). - -## Блок IV. Продвинутые фичи Lakehouse - -* **08_time_travel_and_snapshots.ipynb** - * Работа со снапшотами (Snapshots). - * *Практика:* восстановление таблицы после ошибочных `DELETE`/`UPDATE`, чтение "исторических" данных. -* **09_lakehouse_maintenance.ipynb** - * Проблема мелких файлов. - * Компактизация (Compaction) и очистка старых версий (Vacuum). -* **10_final_pipeline.ipynb** - * *Практика:* самостоятельный финальный end-to-end мини-пайплайн (Bronze -> Silver -> Gold), объединяющий все пройденные концепции. \ No newline at end of file diff --git a/docs/course_prd.md b/docs/course_prd.md index a196303..34d0159 100644 --- a/docs/course_prd.md +++ b/docs/course_prd.md @@ -1,4 +1,4 @@ -# Product Requirements Document (PRD): Учебный курс «Введение в Lakehouse» +# Product Requirements Document (PRD): Учебный курс «Lakehouse без магии» ## 1. Product Vision & Value Proposition @@ -56,6 +56,8 @@ 6. Умеет читать одну и ту же таблицу из `Spark` и `Trino` и понимает, почему это возможно без копирования данных. 7. Умеет выполнить базовые безопасные операции с таблицей: изменение схемы, просмотр snapshot-ов, чтение предыдущего состояния, базовый `compaction` и `vacuum`. +Трассировка этих outcomes на модули и checkpoints фиксируется в [course_program.md](./course_program.md). + ## 6. Teaching Principles **Принцип 1. Практика первична.** Курс не должен превращаться в обзорную лекцию. Теория нужна для объяснения того, что студент делает руками. @@ -93,9 +95,10 @@ * базовое schema evolution; * snapshot/time travel на прикладном уровне; * базовый `compaction/vacuum` с параллелями к `Greenplum AppendOnly`; -* набор практик в Jupyter Notebooks с обязательными заданиями и checkpoints. +* набор практик в Jupyter Notebooks с обязательными заданиями и checkpoints; +* стартовая документация до первого ноутбука: prerequisites, запуск стенда, вход в Jupyter и базовый troubleshooting. -Ориентировочная программа курса вынесена в отдельный документ: **[COURSE_PLAN.md](./COURSE_PLAN.md)**. +Ориентировочная программа курса вынесена в отдельный документ: **[course_program.md](./course_program.md)**. ## 9. Dataset Strategy @@ -115,6 +118,15 @@ **Методическое решение:** курс строится вокруг одного домена данных, но не требует жесткого сквозного проекта. Основной формат курса - набор связанных практик на одном и том же наборе данных. +**Механика доставки данных в v1:** + +* raw-датасет не хранится в репозитории целиком; +* стартовая документация должна давать воспроизводимый способ получить фиксированный учебный набор данных (`Default` или `Extended`) из публичного источника; +* `Taxi Zone Lookup` доставляется тем же способом, что и основной датасет; +* перед началом модуля про raw-данные студент должен иметь локальный data bundle на хосте; +* в модуле про raw-ingest студент загружает уже полученные файлы в raw-зону `MinIO`, а не ищет и не скачивает данные вручную прямо из ноутбука; +* ручная загрузка файлов через UI допускается только как fallback-сценарий, а не как основной путь прохождения. + ## 10. User Experience / Learning Flow Курс строится по модели **"объяснение -> демонстрация -> самостоятельное повторение -> checkpoint"**. @@ -128,18 +140,21 @@ **Формат прохождения:** желательно с ментором, но курс должен быть самодостаточным и для самостоятельного изучения. -## 11. Success Metrics & Acceptance Criteria +## 11. Product Targets & Acceptance Criteria -**Цели продукта:** +**Ориентиры продукта:** + +Это целевые ориентиры курса, но не blocking acceptance criteria для материалов. 1. Студент проходит курс за `~12-15 часов` вдумчивой работы. 2. Студент после курса способен безопасно выполнить базовые операции в Lakehouse-стенде без постоянной внешней помощи. 3. Студент может внятно объяснить ключевые архитектурные принципы Lakehouse на уровне junior/middle interview readiness. -**Acceptance Criteria для учебного результата:** +**Acceptance Criteria для учебных материалов и результата:** -1. Студент проходит обязательные `checkpoints` по урокам. -2. Студент самостоятельно выполняет набор практик на одном датасете, включая: +1. В составе курса есть стартовая документация до первого ноутбука, включая prerequisites, запуск стенда, вход в `Jupyter` и получение учебного data bundle. +2. Студент проходит обязательные `checkpoints` по урокам. +3. Студент самостоятельно выполняет набор практик на одном датасете, включая: * загрузку raw-данных; * построение `bronze`; * построение `silver`; @@ -148,7 +163,7 @@ * демонстрацию базового schema evolution; * демонстрацию snapshot/time travel; * демонстрацию базового `compaction/vacuum`. -3. Студент может своими словами объяснить: +4. Студент может своими словами объяснить: * что такое `Lakehouse` и чем он отличается от классической БД; * как разделены `storage`, `catalog`, `compute`; * что такое `Iceberg` в практическом смысле; @@ -161,8 +176,10 @@ * использование существующего `docker-compose.yml` (`Spark`, `Trino`, `MinIO`, `PostgreSQL`, `Jupyter`); * подготовка учебных датасетов и/или инструкций по их загрузке в репозиторий; +* наличие воспроизводимого способа получить учебный data bundle без ручного поиска по внешним сайтам; * поддержка выполнения запросов к `Trino` из ноутбуков; -* подготовка Jupyter Notebooks как основного формата учебных материалов; +* подготовка Jupyter Notebooks как основного формата практической части курса; +* подготовка стартовой документации для входа в курс до первого запуска ноутбуков; * наличие воспроизводимых шагов для старта, сброса и повторного прохождения практик. ## 13. Out of Scope (v1) @@ -172,6 +189,7 @@ * `gold`-слой и полноценные бизнес-витрины; * `Airflow`, `Kafka`, `NiFi`, `streaming`, `CDC`; * сложные `MERGE`-сценарии, row-level deletes и update-heavy кейсы; +* партиционирование и `partition pruning` как отдельная учебная тема первой версии; * глубокий performance tuning; * production security, governance, multi-user setup; * Kubernetes / облачное развертывание; diff --git a/docs/course_program.md b/docs/course_program.md new file mode 100644 index 0000000..5e5491d --- /dev/null +++ b/docs/course_program.md @@ -0,0 +1,287 @@ +# Программа учебного курса: Lakehouse без магии + +Этот документ заменяет предыдущий черновой план и выравнивает программу с PRD из [course_prd.md](./course_prd.md). + +## 1. Рамка курса + +**Формат:** стартовая документация + 8 практических уроков в `Jupyter Notebooks` + вспомогательные справочные материалы. + +**Оценка длительности:** `~12-15 часов` вдумчивой работы. + +**Ориентир на модуль:** в среднем `~1.5 часа`, но финальная практика может занять дольше. + +**Сквозной учебный кейс:** `NYC TLC Yellow Taxi Trip Records` + `Taxi Zone Lookup`. + +**Методика каждого урока:** `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`. + +**Главная цель курса:** дать студенту безопасную рабочую модель Lakehouse на локальном стенде `Spark + Trino + Iceberg + MinIO + PostgreSQL`, а не обзор всех возможных фич. + +## 2. Принципы декомпозиции программы + +1. Сначала студент должен научиться поднимать и диагностировать стенд, и только потом строить пайплайн. +2. Архитектурная модель `storage + catalog + compute` проходит через весь курс, а не выносится в одну "теоретическую" лекцию. +3. Весь курс строится вокруг одного датасета и одного потока `raw -> bronze -> silver`. +4. В первой версии курса основной табличный формат только один: `Iceberg`. +5. Практики про безопасность важнее "вау-фич": аккуратный `overwrite`, понимание снапшотов, воспроизводимость шагов, базовое обслуживание таблиц. +6. Темы вне PRD v1 не включаются в основной трек и остаются в backlog. + +## 3. Состав учебных материалов + +Курс не должен состоять только из ноутбуков. Ноутбуки являются ядром практики, но вход в курс, запуск стенда и правила работы должны быть вынесены в обычную документацию, доступную до старта `Jupyter`. + +### 3.1. Стартовая документация до первого ноутбука + +Этот слой нужен, потому что студент ещё не имеет доступа к ноутбукам, пока не поднят стенд. + +**Минимальный обязательный набор:** + +* `START_HERE.md` или аналогичный стартовый документ с маршрутом прохождения курса; +* инструкция по prerequisites: `Docker`, `Docker Compose`, свободные порты, базовые команды; +* пошаговый гайд по запуску стенда: `build`, `up`, проверка сервисов, открытие UI; +* краткое объяснение, как пользоваться `Jupyter`, где лежат ноутбуки и как читать структуру урока; +* troubleshooting по типовым проблемам старта; +* инструкция по reset/restart стенда для повторного прохождения практик. + +**Что должен закрывать этот слой:** + +* студент понимает, что нужно установить и проверить до начала курса; +* студент может поднять стенд без ментора; +* студент знает, как попасть в `Jupyter`, `MinIO`, `Trino UI`; +* студент понимает, как устроен формат уроков и что от него ожидается. + +### 3.2. Практическое ядро курса + +Это 8 ноутбуков, в которых живут демонстрации, самостоятельные задания и checkpoints. + +### 3.3. Вспомогательные материалы + +Кроме ноутбуков и стартового onboarding-слоя, курсу потребуются: + +* вспомогательные скрипты в `src/spark` и `src/trino`; +* инструкции по загрузке или подготовке учебных датасетов; +* краткий glossary по терминам `storage`, `catalog`, `compute`, `table format`, `namespace`, `metadata`, `manifest`, `snapshot`, `time travel`, `schema evolution`, `compaction`, `vacuum`; +* cheat sheet по типовым командам, адресам сервисов, ключевым путям и точкам входа; +* опционально, отдельные mentor notes для ведения курса с ментором. + +## 4. Трассировка Learning Outcomes на модули + +| Learning Outcome | Основные модули | Где проверяется | +| --- | --- | --- | +| `LO1`. Понимание различий между `storage`, `catalog`, `compute` | 1, 2, 6 | checkpoints модулей 2 и 6 | +| `LO2`. Умение поднять и диагностировать стенд | 1 | checkpoint модуля 1 | +| `LO3`. Умение загрузить raw-данные и проверить схему | 3 | checkpoint модуля 3 | +| `LO4`. Умение создать и заполнить `Iceberg`-таблицу | 4 | checkpoint модуля 4 | +| `LO5`. Умение собрать поток `raw -> bronze -> silver` | 4, 5, 8 | checkpoints модулей 5 и 8 | +| `LO6`. Умение читать одну таблицу из `Spark` и `Trino` | 6, 8 | checkpoint модуля 6 и финальная практика | +| `LO7`. Умение выполнять безопасные операции с таблицей | 7, 8 | checkpoints модулей 7 и 8 | + +## 5. Верхнеуровневая структура практической части + +### Модуль 1. Вход в стенд и базовая диагностика + +**Рабочее название ноутбука:** `01_environment_and_smoke_test.ipynb` + +**Зачем нужен:** студент должен уметь самостоятельно поднять локальный стенд и понять, куда смотреть, если что-то не работает. + +**Содержание:** + +* запуск `docker compose build` и `docker compose up -d`; +* проверка сервисов и веб-интерфейсов; +* базовый smoke test для Spark; +* первый обзор того, где в стенде `MinIO`, `PostgreSQL`, `Spark`, `Trino`, `Jupyter`. + +**Практика студента:** + +* запустить стенд; +* проверить статусы контейнеров и основные UI; +* выполнить простой smoke test и прочитать его результат. + +**Checkpoint:** студент подтверждает, что стенд поднят, понимает назначение сервисов и умеет сделать первичную диагностику через `docker compose ps`, логи и UI. + +### Модуль 2. Ментальная модель Lakehouse: storage, catalog, compute + +**Рабочее название ноутбука:** `02_lakehouse_mental_model.ipynb` + +**Зачем нужен:** убрать магическое восприятие Lakehouse и связать новую модель с привычным миром `PostgreSQL/Greenplum`. + +**Содержание:** + +* демонстрационный проход по цепочке `Spark -> catalog -> MinIO` на маленькой demo-таблице; +* что такое таблица в Lakehouse в практическом смысле; +* где лежат данные, где лежат метаданные, кто выполняет вычисления; +* разница между "одной базой данных" и набором согласованных компонентов; +* короткие параллели с классическим DWH. + +**Практика студента:** + +* выполнить готовую демонстрационную запись через `Spark` в небольшую demo-таблицу; +* найти соответствующие артефакты в `MinIO` и запись о таблице в каталоге через подготовленные диагностические шаги; +* сопоставить наблюдения с ролями `storage / catalog / compute`. + +**Checkpoint:** студент своими словами объясняет роли `MinIO`, `PostgreSQL`, `Spark` и `Trino`, не путает физическое хранение с логической таблицей и может показать, где в стенде видны данные, метаданные и вычислитель. + +### Модуль 3. Raw-данные и первое чтение в Spark + +**Рабочее название ноутбука:** `03_raw_ingest_and_first_read.ipynb` + +**Зачем нужен:** показать, как raw-данные попадают в стенд и как с ними безопасно начать работать. + +**Содержание:** + +* работа с заранее подготовленным локальным data bundle из onboarding-документации; +* явная загрузка исходных `PARQUET`-файлов в raw-зону; +* чтение raw-данных через Spark; +* проверка схемы, типов и базового качества данных; +* обсуждение того, почему raw лучше не "чинить на месте" и почему датасет не должен скачиваться "из интернета из ноутбука". + +**Практика студента:** + +* проверить состав локально полученного набора `NYC Taxi`; +* загрузить его в raw-зону `MinIO`; +* прочитать несколько месяцев `NYC Taxi`; +* проверить схему и посчитать базовые метрики; +* найти простые аномалии или проблемные значения. + +**Checkpoint:** студент умеет загрузить учебный набор в raw-зону, прочитать raw-данные, проверить схему и объяснить, почему raw-слой нужен как воспроизводимая точка входа. + +### Модуль 4. Первая рабочая Iceberg-таблица и слой bronze + +**Рабочее название ноутбука:** `04_bronze_with_iceberg.ipynb` + +**Зачем нужен:** перейти от набора файлов к управляемой таблице и построить первый слой обработки. + +**Содержание:** + +* создание namespace и Iceberg-таблицы через Spark; +* запись raw-данных в `bronze`; +* осмотр структуры Iceberg на прикладном уровне: data files, metadata, snapshots; +* связь `raw -> bronze` с привычным `stg/ods`-мышлением. + +**Практика студента:** + +* создать первую Iceberg-таблицу; +* загрузить в неё данные из raw; +* проверить результат через чтение таблицы и осмотр артефактов хранения. + +**Checkpoint:** студент умеет создать и заполнить Iceberg-таблицу и понимает, что таблица в Lakehouse не сводится к одному каталогу с файлами. + +### Модуль 5. Слой silver и воспроизводимые трансформации + +**Рабочее название ноутбука:** `05_silver_layer.ipynb` + +**Зачем нужен:** научить строить простой, понятный и воспроизводимый pipeline `raw -> bronze -> silver`. + +**Содержание:** + +* базовые трансформации и очистка данных; +* явная фиксация правил преобразования; +* разделение ответственности между слоями; +* проверки качества на уровне строк, схемы и агрегатов. + +**Практика студента:** + +* собрать `silver` из `bronze`; +* нормализовать часть полей; +* добавить простые проверки результата. + +**Checkpoint:** студент может воспроизводимо построить `silver` и объяснить, чем `bronze` отличается от `silver`. + +### Модуль 6. Одна таблица, два движка: Spark и Trino + +**Рабочее название ноутбука:** `06_spark_and_trino_on_same_table.ipynb` + +**Зачем нужен:** закрепить идею разделения вычислительных движков и показать, что данные не нужно копировать между системами. + +**Содержание:** + +* чтение одной и той же Iceberg-таблицы из Spark и Trino; +* роль общего каталога; +* простые SQL-проверки в Trino; +* ограничение темы: курс не уходит в глубокое сравнение движков. + +**Практика студента:** + +* записать таблицу через Spark; +* прочитать ту же таблицу через Trino; +* сверить результаты и ответить, почему это работает. + +**Checkpoint:** студент понимает, почему `Spark` может писать, а `Trino` читать ту же таблицу без копирования данных. + +### Модуль 7. Безопасная работа с таблицами: schema evolution и time travel + +**Рабочее название ноутбука:** `07_safe_table_changes.ipynb` + +**Зачем нужен:** научить не ломать таблицы вслепую и пользоваться базовыми защитными механизмами. + +**Содержание:** + +* добавление и изменение схемы на базовом уровне; +* влияние schema evolution на downstream-чтение; +* snapshots и time travel; +* разбор типовых ошибок новичка: неаккуратный `overwrite`, слепая перезапись, неявные изменения типов. + +**Практика студента:** + +* добавить новую колонку или безопасно изменить схему; +* посмотреть историю snapshot-ов; +* прочитать предыдущее состояние таблицы после намеренно "неудачного" изменения. + +**Checkpoint:** студент умеет делать базовые изменения схемы, смотреть историю таблицы и использовать time travel как страховку. + +### Модуль 8. Базовое обслуживание таблиц и финальная практика + +**Рабочее название ноутбука:** `08_maintenance_and_final_lab.ipynb` + +**Зачем нужен:** завершить курс рабочим циклом поддержки таблицы и собрать все изученное в одну практику. + +**Содержание:** + +* проблема мелких файлов; +* базовый `compaction`; +* базовый `vacuum` / cleanup старых версий; +* короткая параллель с обслуживанием `Greenplum AppendOnly`-таблиц; +* финальный мини-сценарий: `raw -> bronze -> silver -> проверка через Trino -> безопасное изменение -> обслуживание`. + +**Практика студента:** + +* выполнить compaction на учебной таблице; +* очистить старые версии в контролируемом сценарии; +* пройти финальный end-to-end checkpoint. + +**Checkpoint:** студент выполняет полный учебный сценарий и может объяснить, зачем нужны compaction и cleanup в прикладной работе. + +## 6. Что получает студент по итогам курса + +После прохождения программы студент: + +1. Поднимает локальный стенд и диагностирует типовые проблемы на старте. +2. Понимает различие между `storage`, `catalog` и `compute`. +3. Умеет загрузить raw-данные и прочитать их в Spark. +4. Умеет создать и заполнить Iceberg-таблицу. +5. Умеет построить простой поток `raw -> bronze -> silver`. +6. Умеет читать одну и ту же таблицу из `Spark` и `Trino`. +7. Умеет выполнять базовые безопасные операции: schema evolution, snapshots/time travel, compaction, vacuum. + +## 7. Что не входит в первую версию курса + +Следующие темы сознательно исключены из основного плана и могут стать отдельным расширением: + +* `gold`-слой и полноценные бизнес-витрины; +* партиционирование и `partition pruning` как отдельная обязательная тема v1; +* performance tuning и физический дизайн таблиц как самостоятельный модуль; +* `MERGE`, row-level deletes, update-heavy сценарии; +* streaming, `Kafka`, `CDC`, `Airflow`, `NiFi`; +* deep dive во внутренности `Iceberg` на уровне спецификации; +* production security, governance, multi-user setup, Kubernetes и облака. + +Существующие demo-артефакты по партиционированию в репозитории считаются legacy-материалами и не входят в обязательный трек первой версии курса. + +## 8. Следующий уровень детализации + +После утверждения этой структуры следующий документ должен описывать не "темы вообще", а каркас учебных артефактов: + +1. состав стартовой документации и порядок чтения до первого запуска, включая получение data bundle; +2. список ноутбуков и их точные learning objectives; +3. обязательные демонстрации, самостоятельные задания и checkpoints по каждому уроку; +4. набор вспомогательных `src/spark` и `src/trino`-скриптов, включая загрузку датасета и диагностические шаги для модуля 2; +5. правила сброса стенда и повторного прохождения практик. From 9d84e15e01573681792bc0391260e34b006c430e Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 6 Mar 2026 23:45:37 +0300 Subject: [PATCH 04/24] =?UTF-8?q?docs(course):=20=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D0=B2=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20legacy=20HOWTO=20?= =?UTF-8?q?=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=20REA?= =?UTF-8?q?DME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужно убрать устаревший учебный маршрут из student-facing документов, но сохранить старые лабораторные как reference-материал. - Что: - HOWTO.md перенесен в docs/archive/legacy_howto.md и помечен как архивный legacy-документ. - README.md больше не отправляет студента в устаревший HOWTO и ссылается на актуальную программу курса. - Проверка: - просмотрен git diff -- README.md HOWTO.md docs/archive/legacy_howto.md. - проверен git diff --cached --stat перед коммитом. --- README.md | 4 +- HOWTO.md => docs/archive/legacy_howto.md | 60 ++++++++++++++++-------- 2 files changed, 42 insertions(+), 22 deletions(-) rename HOWTO.md => docs/archive/legacy_howto.md (75%) mode change 100755 => 100644 diff --git a/README.md b/README.md index 5c51372..85963df 100755 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ graph LR - MinIO Console: `http://localhost:9001` - JupyterLab (если включён): `http://localhost:8888` -4. Для пошаговых лабораторных работ см. файл `HOWTO.md`. +4. Актуальная структура курса описана в `docs/course_program.md`. --- @@ -381,7 +381,7 @@ spark.sql("INSERT INTO lakehouse.default.spark_table VALUES (1, 'Alice'), (2, 'B * Добавить отдельные каталоги Trino (например, `hive`, `tpch`) для демонстрации федеративных запросов. * Добавить пример интеграции с BI-инструментом (DBeaver/Metabase/Superset) поверх Trino. -Для пошаговых учебных сценариев (лабораторных работ) см. файл `HOWTO.md`. +Актуальная структура курса описана в `docs/course_program.md`. Старый `HOWTO` сохранён как архивный материал в `docs/archive/legacy_howto.md`. --- diff --git a/HOWTO.md b/docs/archive/legacy_howto.md old mode 100755 new mode 100644 similarity index 75% rename from HOWTO.md rename to docs/archive/legacy_howto.md index 4be75ef..ed076b5 --- a/HOWTO.md +++ b/docs/archive/legacy_howto.md @@ -1,3 +1,23 @@ +# Legacy HOWTO: ранние лабораторные по Lakehouse-стенду + +Этот документ сохранён как архивный материал и не является актуальным учебным маршрутом для курса `Lakehouse без магии`. + +Зачем он сохранён: + +* в нём есть полезные ранние учебные сценарии и привязка к существующим demo-скриптам; +* он помогает понять, как эволюционировал стенд и какие практики уже когда-то обкатывались; +* его можно использовать как reference при создании новых ноутбуков и стартовой документации. + +Почему это legacy: + +* документ был написан до текущего PRD и новой программы курса; +* он смешивает актуальные темы с темами вне обязательного трека `v1`, например с отдельной лабой про партиционирование; +* он не соответствует новой структуре материалов `README -> START_HERE -> course_program -> notebooks`. + +Ниже сохранено исходное содержимое старого `HOWTO.md` почти без изменений. + +--- + # HOWTO: Учебные лабораторные по Lakehouse-стенду Этот файл описывает пошаговые учебные сценарии (лабораторные работы), которые можно выполнять поверх стенда из `docker-compose.yml`. @@ -6,29 +26,29 @@ Лабораторки опираются на примеры в `src/`: -- `src/spark/cluster_smoke.py` — проверка, что Spark‑кластер жив. -- `src/spark/iceberg_demo.sql` — первая Iceberg‑таблица в Spark. -- `src/spark/iceberg_smoke.py` — Spark создаёт Iceberg‑таблицу и читает её. +- `src/spark/cluster_smoke.py` — проверка, что Spark-кластер жив. +- `src/spark/iceberg_demo.sql` — первая Iceberg-таблица в Spark. +- `src/spark/iceberg_smoke.py` — Spark создаёт Iceberg-таблицу и читает её. - `src/trino/iceberg_smoke.sql` — Trino читает таблицу, созданную в Spark. Все команды ниже выполняются из корня репозитория. Краткая карта лабораторных: -- **Лаба 0** — стенд поднят, Spark‑кластер жив. -- **Лаба 1** — первая Iceberg‑таблица в Spark. +- **Лаба 0** — стенд поднят, Spark-кластер жив. +- **Лаба 1** — первая Iceberg-таблица в Spark. - **Лаба 2** — общий каталог Spark ↔ Trino. - **Лаба 3** — партиционирование и эволюция схемы. -- **Лаба 4** — мини‑ETL поверх Lakehouse (эскиз). +- **Лаба 4** — мини-ETL поверх Lakehouse (эскиз). --- -## Лаба 0. Стенд поднят, Spark‑кластер жив +## Лаба 0. Стенд поднят, Spark-кластер жив **Цель** - Убедиться, что все контейнеры поднялись. -- Проверить, что Spark‑кластер (master + workers) работает. +- Проверить, что Spark-кластер (master + workers) работает. **Предусловия** @@ -52,7 +72,7 @@ Ожидаем, что `spark-master`, `spark-worker-1`, `spark-worker-2` в статусе `Up`. -3. Запустить smoke‑скрипт кластера из контейнера `spark-master`: +3. Запустить smoke-скрипт кластера из контейнера `spark-master`: ```bash docker compose exec spark-master \ @@ -71,11 +91,11 @@ --- -## Лаба 1. Первая Iceberg‑таблица из Spark +## Лаба 1. Первая Iceberg-таблица из Spark **Цель** -- Создать Iceberg‑таблицу с помощью Spark SQL. +- Создать Iceberg-таблицу с помощью Spark SQL. - Посмотреть файлы таблицы в MinIO (`warehouse/default/demo_tbl/...`). **Предусловия** @@ -91,7 +111,7 @@ /opt/spark/bin/spark-sql ``` -2. Выполнить учебный SQL‑скрипт: +2. Выполнить учебный SQL-скрипт: - Внутри интерактивной сессии `spark-sql`: @@ -127,7 +147,7 @@ возвращает данные. -- В MinIO видна структура Iceberg‑таблицы: служебные файлы и файлы данных. +- В MinIO видна структура Iceberg-таблицы: служебные файлы и файлы данных. --- @@ -135,7 +155,7 @@ **Цель** -- Показать, что Spark и Trino используют общий Iceberg‑каталог (метаданные в Postgres, данные в MinIO). +- Показать, что Spark и Trino используют общий Iceberg-каталог (метаданные в Postgres, данные в MinIO). - Создать таблицу из Spark и прочитать её через Trino. **Предусловия** @@ -145,7 +165,7 @@ **Шаги** -1. Создать таблицу и записать данные из Spark (PySpark‑скрипт): +1. Создать таблицу и записать данные из Spark (PySpark-скрипт): ```bash docker compose exec spark-master \ @@ -169,7 +189,7 @@ 3. Прочитать ту же таблицу из Trino: -- Вариант через заранее скопированный SQL‑файл (как в README): +- Вариант через заранее скопированный SQL-файл (как в README): ```bash docker compose cp src/trino/iceberg_smoke.sql trino:/tmp/ @@ -211,7 +231,7 @@ **Подготовленные примеры** -- `src/spark/partitioned_table_demo.sql` — создание партиционированной Iceberg‑таблицы и вставка данных. +- `src/spark/partitioned_table_demo.sql` — создание партиционированной Iceberg-таблицы и вставка данных. - `src/spark/schema_evolution_demo.sql` — демонстрация `ALTER TABLE` и добавления колонок. - `src/trino/schema_evolution_demo.sql` — чтение той же таблицы с эволюцией схемы из Trino. - `notebooks/03_partitioning_and_schema_evolution.ipynb` — интерактивный разбор тех же примеров в Jupyter. @@ -271,7 +291,7 @@ docker compose exec trino trino --file /tmp/schema_evolution_demo.sql ``` -**Вариант B: через Jupyter‑ноутбук** +**Вариант B: через Jupyter-ноутбук** - Открыть `http://localhost:8888` и запустить ноутбук `03_partitioning_and_schema_evolution.ipynb`. - Ноутбук: @@ -281,12 +301,12 @@ --- -## Лаба 4. Мини‑ETL поверх Lakehouse (эскиз) +## Лаба 4. Мини-ETL поверх Lakehouse (эскиз) Идея лабы — собрать end-to-end сценарий: - есть сырые данные (CSV/JSON) в S3/MinIO; -- Spark читает raw‑данные, чистит и пишет в Iceberg‑таблицу; +- Spark читает raw-данные, чистит и пишет в Iceberg-таблицу; - Trino делает поверх неё аналитику. Планируемые компоненты: From 688c46c68bd85f5be2ffdf11545fc1e79b1d40ad Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 00:05:43 +0300 Subject: [PATCH 05/24] =?UTF-8?q?feat(module-1):=20=D0=B4=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=BD=D1=8B=20=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D0=B0=D0=BB=D1=8B=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D1=8F=201=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D1=91?= =?UTF-8?q?=D0=BD=20legacy-=D0=BD=D0=BE=D1=83=D1=82=D0=B1=D1=83=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужен воспроизводимый вход в курс и единое место хранения планов по модулям. - Что: - добавлены `plans/README.md`, living plan Модуля 1, `START_HERE.md` и канонический ноутбук `01_environment_and_smoke_test.ipynb`, а `cluster_smoke.py` расширен до reusable helper и CLI smoke test. - уточнены onboarding-материалы и окружение: добавлены Spark UI в `README.md`, ресурсы хоста и креды MinIO в `START_HERE.md`, использован `NB_GID` в `jupyter/Dockerfile`, в ноутбуке усилены самостоятельные задания и добавлены `cell id`, а пояснения в `cluster_smoke.py` переведены на русский для студентов. - обновлены `README.md`, `AGENTS.md` и archive howto, удалены устаревшие `spark-basic-test.ipynb` и `03_partitioning_and_schema_evolution.ipynb`. - Проверка: - `python3 -m py_compile src/spark/cluster_smoke.py src/spark/__init__.py`. - `docker compose build spark-master` и `docker compose build jupyter`. - `docker compose up -d`, `docker compose exec jupyter python3 -c "from spark.cluster_smoke import create_spark_session, run_cluster_smoke; spark=create_spark_session(app_name='module-01-validation'); print(run_cluster_smoke(spark)); spark.stop()"` и `docker compose exec jupyter jupyter nbconvert --to notebook --execute /opt/work/01_environment_and_smoke_test.ipynb --output-dir /tmp --output module1-validation-2.ipynb`. --- AGENTS.md | 3 +- README.md | 9 +- START_HERE.md | 153 +++++++++++++ docs/archive/legacy_howto.md | 14 +- jupyter/Dockerfile | 8 +- notebooks/01_environment_and_smoke_test.ipynb | 213 ++++++++++++++++++ ...03_partitioning_and_schema_evolution.ipynb | 163 -------------- notebooks/spark-basic-test.ipynb | 86 ------- plans/README.md | 16 ++ ...le-01-environment-and-basic-diagnostics.md | 67 ++++++ src/spark/__init__.py | 3 + src/spark/cluster_smoke.py | 88 +++++++- 12 files changed, 554 insertions(+), 269 deletions(-) create mode 100644 START_HERE.md create mode 100644 notebooks/01_environment_and_smoke_test.ipynb delete mode 100755 notebooks/03_partitioning_and_schema_evolution.ipynb delete mode 100755 notebooks/spark-basic-test.ipynb create mode 100644 plans/README.md create mode 100644 plans/module-01-environment-and-basic-diagnostics.md create mode 100644 src/spark/__init__.py diff --git a/AGENTS.md b/AGENTS.md index 7b47720..745e156 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ This repository contains a small teaching Lakehouse stack (Spark + Trino + Icebe - `trino/`: catalog config, e.g. `trino/catalog/lakehouse.properties`. - `src/`: PySpark and SQL examples split by engine (`src/spark/...`, `src/trino/...`). - `notebooks/`: demo notebooks (mounted into Jupyter at `/opt/work`). +- `plans/`: internal living docs for implementation plans. Keep new examples in `src/` or `notebooks/`, and avoid mixing configuration and code. @@ -37,7 +38,7 @@ There is no formal automated test suite yet. Validate changes by: - building and starting the stack, then - running `src/spark/cluster_smoke.py` or the SQL in `src/spark/iceberg_demo.sql`, -- opening `notebooks/spark-basic-test.ipynb` in Jupyter and checking it runs end-to-end. +- opening `notebooks/01_environment_and_smoke_test.ipynb` in Jupyter and checking it runs end-to-end. If you add tests, prefer `pytest` under `tests/` and mark slow, integration-heavy tests clearly. diff --git a/README.md b/README.md index 85963df..5190000 100755 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ graph LR ## Быстрый старт +Подробный маршрут старта вынесен в [START_HERE.md](./START_HERE.md). Если нужен только краткий запуск, достаточно: + 1. Установить Docker и Docker Compose (см. раздел «Требования» ниже). 2. В корне репозитория выполнить: @@ -56,11 +58,13 @@ graph LR 3. Открыть основные интерфейсы: + - Spark Master UI: `http://localhost:8080` - Trino Web UI: `http://localhost:8090` - MinIO Console: `http://localhost:9001` - JupyterLab (если включён): `http://localhost:8888` -4. Актуальная структура курса описана в `docs/course_program.md`. +4. Для входа в курс и Модуль 1 открыть `START_HERE.md`. +5. Актуальная структура курса описана в `docs/course_program.md`. --- @@ -79,6 +83,8 @@ graph LR * библиотека Iceberg нужной версии, * `pyspark`, `pyarrow` и базовый набор инструментов. * `jupyter/Dockerfile` — образ JupyterLab на базе Spark-образа. +* `START_HERE.md` — канонический маршрут входа в курс до первого ноутбука. +* `plans/` — внутренние living docs с планами работ по модулям курса. * `spark/spark-defaults.conf` — конфигурация Spark для работы с: * Iceberg-каталогом `lakehouse` (тип `jdbc`, метаданные в Postgres), @@ -217,6 +223,7 @@ docker compose run --rm iceberg-catalog-init * В контейнере монтируется `./notebooks` в `/opt/work`. * `./src` доступен read-only в `/opt/src`, переменная `PYTHONPATH=/opt/src` уже установлена — можно импортировать функции из скриптов прямо в ноутбуках. +* Первый канонический ноутбук курса: `notebooks/01_environment_and_smoke_test.ipynb`. --- diff --git a/START_HERE.md b/START_HERE.md new file mode 100644 index 0000000..efebd2d --- /dev/null +++ b/START_HERE.md @@ -0,0 +1,153 @@ +# START HERE + +Этот документ нужен для первого входа в курс и прохождения Модуля 1: поднять стенд, проверить сервисы, открыть интерфейсы и выполнить базовый smoke test. + +## Маршрут прохождения + +1. Проверить prerequisites. +2. Собрать и поднять стенд. +3. Убедиться, что контейнеры живы. +4. Открыть основные UI. +5. Зайти в Jupyter и выполнить `notebooks/01_environment_and_smoke_test.ipynb`. +6. При проблемах использовать логи и шаги диагностики из этого документа. + +## Prerequisites + +Нужно заранее установить: + +- Docker; +- Docker Compose; +- современный браузер для UI; +- свободные порты на хосте. + +Рекомендуемые ресурсы хоста: + +- не менее `4 vCPU`; +- не менее `10 GB RAM`, иначе `Spark`, `Trino` и `Jupyter` могут стартовать нестабильно; +- хотя бы `8-10 GB` свободного места под образы и контейнеры. + +## Порты стенда + +| Сервис | Адрес | Зачем нужен | +| --- | --- | --- | +| Spark Master UI | `http://localhost:8080` | Проверка мастера Spark и подключённых worker-ов | +| Spark Worker 1 UI | `http://localhost:8081` | Проверка первого worker-а | +| Spark Worker 2 UI | `http://localhost:8082` | Проверка второго worker-а | +| Trino UI | `http://localhost:8090` | Проверка координатора Trino | +| MinIO API | `http://localhost:9000` | S3-compatible endpoint | +| MinIO Console | `http://localhost:9001` | Просмотр бакетов и файлов | +| JupyterLab | `http://localhost:8888` | Основная точка входа в практику | +| PostgreSQL | `localhost:5432` | JDBC-каталог Iceberg, нужен для диагностики | + +## Быстрый запуск + +Все команды выполняются из корня репозитория. + +### 1. Собрать образы + +```bash +docker compose build +``` + +### 2. Поднять стенд + +```bash +docker compose up -d +``` + +### 3. Проверить статус контейнеров + +```bash +docker compose ps +``` + +Ожидаемое состояние: + +- сервисы `spark-master`, `spark-worker-1`, `spark-worker-2`, `minio`, `postgres-iceberg`, `trino`, `jupyter` находятся в состоянии `Up`; +- однократные init-контейнеры вроде `minio-init` и `iceberg-catalog-init` могут завершиться после успешной инициализации. + +## Куда заходить после старта + +Открой в браузере: + +- `http://localhost:8080` для `Spark Master UI`; +- `http://localhost:8090` для `Trino UI`; +- `http://localhost:9001` для `MinIO Console`; +- `http://localhost:8888` для `JupyterLab`. + +Для входа в `MinIO Console` используй: + +- логин: `minioadmin`; +- пароль: `minioadmin`. + +Если интерфейсы открываются, переходи в Jupyter и запускай: + +```text +notebooks/01_environment_and_smoke_test.ipynb +``` + +## Роли сервисов в стенде + +| Сервис | Роль в модуле | +| --- | --- | +| `MinIO` | `storage`: объектное хранилище для данных и файлов Iceberg | +| `PostgreSQL` | `catalog`: хранит метаданные JDBC-каталога Iceberg | +| `Spark` | `compute`: выполняет PySpark и Spark SQL задания | +| `Trino` | `compute`: читает те же таблицы через общий каталог | +| `Jupyter` | Точка входа в учебные ноутбуки | + +## Как работать в Jupyter + +- `./notebooks` смонтирован в контейнер как `/opt/work`; +- `./src` смонтирован read-only как `/opt/src`; +- `PYTHONPATH=/opt/src`, поэтому helper-скрипты из `src/` доступны для импорта в ноутбуках; +- первый ноутбук курса: `01_environment_and_smoke_test.ipynb`. + +## Базовая диагностика + +### Посмотреть список контейнеров + +```bash +docker compose ps +``` + +### Посмотреть логи конкретного сервиса + +```bash +docker compose logs -f spark-master +docker compose logs -f trino +docker compose logs -f minio +docker compose logs -f jupyter +``` + +### Типовые первые проверки + +- `Spark UI` не открывается: проверь `docker compose ps` и логи `spark-master`. +- `Trino UI` не открывается: проверь `docker compose ps` и логи `trino`. +- `JupyterLab` не открывается: проверь `docker compose ps` и логи `jupyter`. +- `MinIO Console` не открывается: проверь `docker compose ps` и логи `minio`. +- smoke test падает из ноутбука: сначала убедись, что `spark-master` и worker-ы видны в `Spark UI`. + +## Restart и reset + +### Мягкий перезапуск стенда + +```bash +docker compose down +docker compose up -d +``` + +### Полный reset с удалением данных + +```bash +docker compose down -v +docker compose up -d +``` + +Используй полный reset, если хочешь пройти практику заново с чистого состояния. + +## Что делать дальше + +- пройти `notebooks/01_environment_and_smoke_test.ipynb`; +- свериться с программой курса в `docs/course_program.md`; +- после прохождения Модуля 1 переходить к следующим учебным материалам. diff --git a/docs/archive/legacy_howto.md b/docs/archive/legacy_howto.md index ed076b5..be62ab9 100644 --- a/docs/archive/legacy_howto.md +++ b/docs/archive/legacy_howto.md @@ -234,7 +234,7 @@ - `src/spark/partitioned_table_demo.sql` — создание партиционированной Iceberg-таблицы и вставка данных. - `src/spark/schema_evolution_demo.sql` — демонстрация `ALTER TABLE` и добавления колонок. - `src/trino/schema_evolution_demo.sql` — чтение той же таблицы с эволюцией схемы из Trino. -- `notebooks/03_partitioning_and_schema_evolution.ipynb` — интерактивный разбор тех же примеров в Jupyter. +- интерактивный ноутбук для этой legacy-лабы удалён из актуального репозитория, чтобы не смешивать его с основным треком курса. **Вариант A: через Spark SQL и Trino CLI** @@ -291,13 +291,13 @@ docker compose exec trino trino --file /tmp/schema_evolution_demo.sql ``` -**Вариант B: через Jupyter-ноутбук** +**Вариант B: через Jupyter вручную** -- Открыть `http://localhost:8888` и запустить ноутбук `03_partitioning_and_schema_evolution.ipynb`. -- Ноутбук: - - создаёт SparkSession, подключённый к кластеру; - - выполняет скрипты `partitioned_table_demo.sql` и `schema_evolution_demo.sql`; - - показывает агрегаты по партициям и данные до/после эволюции схемы в интерактивном виде. +- Открыть `http://localhost:8888`, если нужен самостоятельный разбор SQL-примеров в Jupyter. +- Повторить те же шаги вручную в кодовых ячейках: + - создать SparkSession, подключённый к кластеру; + - выполнить скрипты `partitioned_table_demo.sql` и `schema_evolution_demo.sql`; + - посмотреть агрегаты по партициям и данные до/после эволюции схемы в интерактивном виде. --- diff --git a/jupyter/Dockerfile b/jupyter/Dockerfile index daf1928..cacf393 100755 --- a/jupyter/Dockerfile +++ b/jupyter/Dockerfile @@ -10,12 +10,14 @@ RUN pip3 install --no-cache-dir "jupyterlab==4.2.5" # Создаём непривилегированного пользователя ARG NB_USER=jovyan ARG NB_UID=1000 +ARG NB_GID=100 -RUN useradd --uid ${NB_UID} -m -s /bin/bash ${NB_USER} \ +RUN if ! getent group ${NB_GID} >/dev/null; then groupadd --gid ${NB_GID} ${NB_USER}; fi \ + && useradd --uid ${NB_UID} --gid ${NB_GID} -m -s /bin/bash ${NB_USER} \ && mkdir -p /opt/work \ - && chown -R ${NB_UID}:${NB_UID} /opt/work + && chown -R ${NB_UID}:${NB_GID} /opt/work -USER ${NB_UID} +USER ${NB_UID}:${NB_GID} WORKDIR /opt/work EXPOSE 8888 diff --git a/notebooks/01_environment_and_smoke_test.ipynb b/notebooks/01_environment_and_smoke_test.ipynb new file mode 100644 index 0000000..024d0fd --- /dev/null +++ b/notebooks/01_environment_and_smoke_test.ipynb @@ -0,0 +1,213 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "module1-intro", + "metadata": {}, + "source": [ + "# Модуль 1. Вход в стенд и базовая диагностика\n", + "\n", + "Этот ноутбук является канонической практикой Модуля 1.\n", + "\n", + "На выходе ты должен:\n", + "\n", + "- понимать, что стенд уже поднят на хосте;\n", + "- подключиться к Spark-кластеру из Jupyter;\n", + "- выполнить базовый smoke test;\n", + "- сопоставить сервисы стенда с их ролями;\n", + "- знать первый маршрут диагностики через `docker compose ps`, логи и UI.\n" + ] + }, + { + "cell_type": "markdown", + "id": "module1-before-start", + "metadata": {}, + "source": [ + "## Перед стартом\n", + "\n", + "Перед запуском этого ноутбука пройди шаги из `START_HERE.md`.\n", + "\n", + "Важно: `docker compose build`, `docker compose up -d`, `docker compose ps` и просмотр логов выполняются на хосте, а не внутри Jupyter.\n", + "\n", + "Ожидаемое состояние перед началом:\n", + "\n", + "- контейнеры `spark-master`, `spark-worker-1`, `spark-worker-2`, `minio`, `postgres-iceberg`, `trino`, `jupyter` уже подняты;\n", + "- открываются `Spark UI`, `Trino UI`, `MinIO Console` и `JupyterLab`;\n", + "- ты работаешь в Jupyter внутри контейнера `jupyter`.\n" + ] + }, + { + "cell_type": "markdown", + "id": "module1-service-map", + "metadata": {}, + "source": [ + "## Карта сервисов\n", + "\n", + "| Сервис | Роль |\n", + "| --- | --- |\n", + "| `MinIO` | `storage`: объектное хранилище для данных и файлов Iceberg |\n", + "| `PostgreSQL` | `catalog`: хранит метаданные JDBC-каталога Iceberg |\n", + "| `Spark` | `compute`: выполняет PySpark и Spark SQL |\n", + "| `Trino` | `compute`: читает те же таблицы через общий каталог |\n", + "| `Jupyter` | Точка входа в учебные ноутбуки |\n", + "\n", + "В этом модуле мы не строим пайплайн и не создаём Iceberg-таблицы. Задача только одна: убедиться, что среда рабочая и понятная.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "module1-create-session", + "metadata": {}, + "outputs": [], + "source": [ + "from spark.cluster_smoke import (\n", + " create_spark_session,\n", + " format_smoke_report,\n", + " run_cluster_smoke,\n", + ")\n", + "\n", + "spark = create_spark_session(app_name=\"module-01-environment-and-smoke-test\")\n", + "spark\n" + ] + }, + { + "cell_type": "markdown", + "id": "module1-cluster-demo", + "metadata": {}, + "source": [ + "## Демонстрация: подключение к кластеру\n", + "\n", + "Сначала посмотрим на базовые признаки того, что ноутбук говорит именно с кластером, а не с локальным `local[*]` режимом.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "module1-connection-details", + "metadata": {}, + "outputs": [], + "source": [ + "connection_details = {\n", + " \"app_name\": spark.sparkContext.appName,\n", + " \"master_url\": spark.sparkContext.master,\n", + " \"default_parallelism\": spark.sparkContext.defaultParallelism,\n", + " \"spark_version\": spark.version,\n", + "}\n", + "connection_details\n" + ] + }, + { + "cell_type": "markdown", + "id": "module1-visual-check", + "metadata": {}, + "source": [ + "Проверь глазами:\n", + "\n", + "- `master_url` должен быть `spark://spark-master:7077`, а не `local[*]`;\n", + "- `default_parallelism` должен быть больше `1`;\n", + "- в `Spark UI` должны быть видны master и worker-ы.\n" + ] + }, + { + "cell_type": "markdown", + "id": "module1-smoke-explanation", + "metadata": {}, + "source": [ + "## Демонстрация: Spark smoke test\n", + "\n", + "Smoke test запускает распределённое вычисление на `range(0, 1_000_000)` и сверяет детерминированный результат.\n", + "\n", + "Если тест проходит, это означает минимум следующее:\n", + "\n", + "- Jupyter может создать SparkSession;\n", + "- Spark подключён к кластерному master;\n", + "- задание действительно выполняется, а не падает на первом действии.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "module1-run-smoke", + "metadata": {}, + "outputs": [], + "source": [ + "smoke_report = run_cluster_smoke(spark)\n", + "print(format_smoke_report(smoke_report))\n", + "smoke_report\n" + ] + }, + { + "cell_type": "markdown", + "id": "module1-self-check", + "metadata": {}, + "source": [ + "## Самостоятельное повторение\n", + "\n", + "Сделай руками и проверь себя:\n", + "\n", + "1. Открой `Spark UI`, `Trino UI`, `MinIO Console` и `JupyterLab`.\n", + "2. Сопоставь каждый сервис с одной из ролей: `storage`, `catalog`, `compute`, `entrypoint`.\n", + "3. Объясни, почему этот smoke test не должен работать в `local[*]` режиме.\n", + "4. На хосте выполни `docker compose ps` и посмотри, какие контейнеры должны быть в состоянии `Up`.\n", + "5. На хосте выполни `docker compose logs -f trino` или `docker compose logs -f spark-master`, чтобы посмотреть, где начинается первичная диагностика.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "module1-service-roles", + "metadata": {}, + "outputs": [], + "source": [ + "service_roles = {\n", + " # Заполни значения самостоятельно: storage, catalog, compute или entrypoint.\n", + " \"MinIO\": \"TODO\",\n", + " \"PostgreSQL\": \"TODO\",\n", + " \"Spark\": \"TODO\",\n", + " \"Trino\": \"TODO\",\n", + " \"Jupyter\": \"TODO\",\n", + "}\n", + "service_roles\n" + ] + }, + { + "cell_type": "markdown", + "id": "module1-checkpoint", + "metadata": {}, + "source": [ + "## Checkpoint\n", + "\n", + "К концу модуля ты должен уметь подтвердить:\n", + "\n", + "- стенд поднят и основные контейнеры живы;\n", + "- `Spark UI`, `MinIO Console`, `Trino UI` и `JupyterLab` открываются;\n", + "- smoke test завершился успешно и вернул ожидаемый результат;\n", + "- ты понимаешь назначение `MinIO`, `PostgreSQL`, `Spark`, `Trino`, `Jupyter`;\n", + "- ты знаешь, что первый маршрут диагностики начинается с `docker compose ps`, `docker compose logs -f ` и проверки UI.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "module1-stop-session", + "metadata": {}, + "outputs": [], + "source": [ + "spark.stop()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/03_partitioning_and_schema_evolution.ipynb b/notebooks/03_partitioning_and_schema_evolution.ipynb deleted file mode 100755 index 52e5c05..0000000 --- a/notebooks/03_partitioning_and_schema_evolution.ipynb +++ /dev/null @@ -1,163 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Лаба 3: партиционирование и эволюция схемы", - "", - "В этом ноутбуке мы посмотрим, как Iceberg работает с партиционированными таблицами и эволюцией схемы поверх общего каталога `lakehouse`.", - "", - "Перед началом убедись, что стенд запущен (`docker compose up -d`) и Spark-кластер доступен." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pyspark.sql import SparkSession", - "", - "spark = (", - " SparkSession.builder", - " .appName(\"lab3-partitioning-schema-evolution\")", - " .master(\"spark://spark-master:7077\")", - " .getOrCreate()", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Часть 1. Партиционированная таблица", - "", - "Для начала создадим партиционированную Iceberg-таблицу `lakehouse.default.partition_demo` с помощью готового SQL-скрипта из `src/spark/partitioned_table_demo.sql`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path", - "", - "sql_path = Path(\"/opt/src/spark/partitioned_table_demo.sql\")", - "sql_text = sql_path.read_text(encoding=\"utf-8\")", - "", - "for statement in sql_text.split(\";\"):", - " stmt = statement.strip()", - " if stmt:", - " spark.sql(stmt)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Посмотрим, какие данные записаны по датам, и обсудим партиционирование по `event_date`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "spark.sql(\"\"\"", - "SELECT", - " event_date,", - " COUNT(*) AS cnt,", - " SUM(amount) AS total_amount", - "FROM lakehouse.default.partition_demo", - "GROUP BY event_date", - "ORDER BY event_date", - "\"\"\").show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "spark.sql(\"\"\"", - "SELECT *", - "FROM lakehouse.default.partition_demo", - "WHERE event_date = DATE '2024-01-01'", - "ORDER BY user_id", - "\"\"\").show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Часть 2. Эволюция схемы", - "", - "Теперь посмотрим на эволюцию схемы: создадим таблицу, добавим колонку и вставим новые строки с дополнительными данными.", - "", - "Для подготовки таблицы используем скрипт `src/spark/schema_evolution_demo.sql`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "sql_path = Path(\"/opt/src/spark/schema_evolution_demo.sql\")", - "sql_text = sql_path.read_text(encoding=\"utf-8\")", - "", - "for statement in sql_text.split(\";\"):", - " stmt = statement.strip()", - " if stmt:", - " spark.sql(stmt)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "spark.sql(\"DESCRIBE TABLE lakehouse.default.schema_evolution_demo\").show(truncate=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "spark.sql(\"\"\"", - "SELECT *", - "FROM lakehouse.default.schema_evolution_demo", - "ORDER BY id", - "\"\"\").show(truncate=False)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Обрати внимание, что старые строки имеют `NULL` в колонке `metadata`, а новые — заполненное значение.", - "Iceberg хранит историю снапшотов и позволяет эволюцию схемы без сложных миграций." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/spark-basic-test.ipynb b/notebooks/spark-basic-test.ipynb deleted file mode 100755 index 623fb20..0000000 --- a/notebooks/spark-basic-test.ipynb +++ /dev/null @@ -1,86 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "ca5e5822-18cb-405e-90f3-a0f2f8ad2260", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Setting default log level to \"WARN\".\n", - "To adjust logging level use sc.setLogLevel(newLevel). For SparkR, use setLogLevel(newLevel).\n", - "25/12/03 09:08:48 WARN NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable\n", - "25/12/03 09:08:52 WARN MetricsConfig: Cannot locate configuration: tried hadoop-metrics2-s3a-file-system.properties,hadoop-metrics2.properties\n", - " \r" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "+---+---+\n", - "| id|txt|\n", - "+---+---+\n", - "| 1| ok|\n", - "+---+---+\n", - "\n" - ] - } - ], - "source": [ - "from pyspark.sql import SparkSession\n", - "\n", - "spark = (\n", - " SparkSession.builder\n", - " .appName(\"check\")\n", - " .master(\"spark://spark-master:7077\")\n", - " .getOrCreate()\n", - ")\n", - "\n", - "spark.sql(\"\"\"\n", - " CREATE TABLE IF NOT EXISTS lakehouse.default.demo_fix (\n", - " id BIGINT,\n", - " txt STRING\n", - " )\n", - " USING iceberg\n", - "\"\"\")\n", - "\n", - "spark.sql(\"INSERT INTO lakehouse.default.demo_fix VALUES (1, 'ok')\")\n", - "\n", - "spark.sql(\"SELECT * FROM lakehouse.default.demo_fix\").show()\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1e125075-feab-4974-baa2-f787e41b46fd", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 0000000..bcbac09 --- /dev/null +++ b/plans/README.md @@ -0,0 +1,16 @@ +# Планы работ + +Этот каталог хранит внутренние рабочие планы по развитию учебного курса. + +## Правила хранения + +- Формат: `living docs`. +- История изменений хранится в git. +- Именование файлов: `module-NN-.md`. +- В каждом плане фиксируются `Статус` и `Последнее обновление`. + +## Оглавление + +| Модуль | План | Статус | Целевые артефакты | Обновлён | +| --- | --- | --- | --- | --- | +| 1. Вход в стенд и базовая диагностика | [module-01-environment-and-basic-diagnostics.md](./module-01-environment-and-basic-diagnostics.md) | Ready for validation | `START_HERE.md`, `notebooks/01_environment_and_smoke_test.ipynb`, `src/spark/cluster_smoke.py` | 2026-03-06 | diff --git a/plans/module-01-environment-and-basic-diagnostics.md b/plans/module-01-environment-and-basic-diagnostics.md new file mode 100644 index 0000000..690dc1c --- /dev/null +++ b/plans/module-01-environment-and-basic-diagnostics.md @@ -0,0 +1,67 @@ +# Модуль 1. Вход в стенд и базовая диагностика + +**Статус:** `Ready for validation` +**Последнее обновление:** `2026-03-06` + +## Цель + +Дать студенту безопасный и воспроизводимый вход в курс: он должен сам поднять локальный стенд, открыть основные веб-интерфейсы, выполнить Spark smoke test и понять первый маршрут диагностики проблем. + +## Результат для студента + +После прохождения модуля студент: + +- поднимает стенд командами `docker compose build` и `docker compose up -d`; +- проверяет состояние контейнеров через `docker compose ps`; +- открывает `Spark UI`, `MinIO Console`, `Trino UI` и `JupyterLab`; +- запускает smoke test Spark-кластера и понимает, что именно он проверяет; +- знает, куда смотреть при первичной диагностике через логи и UI. + +## Deliverables + +- `plans/README.md` как индекс всех рабочих планов; +- `START_HERE.md` как канонический вход в курс до первого ноутбука; +- `notebooks/01_environment_and_smoke_test.ipynb` как канонический ноутбук Модуля 1; +- `src/spark/cluster_smoke.py` как общий helper и CLI smoke test; +- обновлённые ссылки и инструкции в `README.md` и `AGENTS.md`. + +## План работ + +1. Создать каталог `plans/` и зафиксировать в нём правила хранения и индекс. +2. Подготовить `START_HERE.md` с prerequisites, запуском, проверкой сервисов, входом в Jupyter и reset/restart шагами. +3. Расширить `src/spark/cluster_smoke.py` до reusable helper с функцией `run_cluster_smoke(spark) -> dict` и CLI `main()`. +4. Собрать `notebooks/01_environment_and_smoke_test.ipynb` по схеме `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`. +5. Перенести полезное содержимое из старого `spark-basic-test.ipynb` в новый ноутбук и убрать старый артефакт из активного маршрута. +6. Обновить `README.md` и `AGENTS.md`, чтобы новый вход и новый ноутбук стали каноническими. +7. Прогнать валидацию: smoke script должен проходить синтаксическую проверку, а ноутбук должен быть валидным `ipynb`. + +## Checkpoint + +Студент должен уметь: + +- показать результат `docker compose ps`; +- открыть `Spark UI`, `MinIO Console`, `Trino UI` и `JupyterLab`; +- выполнить smoke test и объяснить, что он подтвердил; +- назвать роли `MinIO`, `PostgreSQL`, `Spark`, `Trino` и `Jupyter`; +- описать первый шаг диагностики, если не поднимается `trino` или `spark-master`. + +## Acceptance Criteria + +- `START_HERE.md` позволяет поднять стенд с нуля без обращения к `docs/archive/legacy_howto.md`; +- `notebooks/01_environment_and_smoke_test.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении; +- один и тот же smoke helper используется из CLI и из ноутбука; +- в репозитории остаётся один канонический вход в Модуль 1; +- `plans/README.md` позволяет быстро найти модульный план и понять его статус. + +## Риски + +- Старые ссылки на `spark-basic-test.ipynb` могут остаться в инструкциях и создавать путаницу. +- Если smoke test будет слишком сложным, он перестанет быть быстрым диагностическим шагом. +- Если в ноутбуке окажутся host-level команды, студент может ошибочно пытаться запускать `docker compose` внутри Jupyter. + +## Out of Scope + +- загрузка raw-данных; +- разбор internals `Iceberg`; +- запросы к `Trino` как отдельная практика; +- `schema evolution`, `time travel`, `compaction`, `vacuum`. diff --git a/src/spark/__init__.py b/src/spark/__init__.py new file mode 100644 index 0000000..2cb140b --- /dev/null +++ b/src/spark/__init__.py @@ -0,0 +1,3 @@ +from .cluster_smoke import create_spark_session, format_smoke_report, run_cluster_smoke + +__all__ = ["create_spark_session", "format_smoke_report", "run_cluster_smoke"] diff --git a/src/spark/cluster_smoke.py b/src/spark/cluster_smoke.py index a222785..2a81a73 100755 --- a/src/spark/cluster_smoke.py +++ b/src/spark/cluster_smoke.py @@ -1,12 +1,84 @@ +from __future__ import annotations + from pyspark.sql import SparkSession -spark = ( - SparkSession.builder - .appName("test-cluster") - .master("spark://spark-master:7077") # важно: не local[*] - .getOrCreate() -) +DEFAULT_MASTER_URL = "spark://spark-master:7077" +DEFAULT_APP_NAME = "cluster-smoke" +DEFAULT_ROW_COUNT = 1_000_000 -spark.range(0, 1000000).groupBy().sum().show() -spark.stop() +def create_spark_session( + app_name: str = DEFAULT_APP_NAME, + master_url: str = DEFAULT_MASTER_URL, +) -> SparkSession: + """Создать SparkSession для проверок окружения в Модуле 1. + + Функция специально остаётся минимальной и опирается на смонтированный + в контейнер `spark-defaults.conf` для остальной Spark/Iceberg-конфигурации. + """ + return SparkSession.builder.appName(app_name).master(master_url).getOrCreate() + + +def run_cluster_smoke(spark: SparkSession, row_count: int = DEFAULT_ROW_COUNT) -> dict: + spark_context = spark.sparkContext + master_url = spark_context.master + + if master_url.startswith("local"): + raise RuntimeError( + "Smoke test must run against the Spark cluster, not local mode." + ) + + # Берём больше партиций, чем даёт defaultParallelism, чтобы задача точно + # распределилась по worker-ам, а не схлопнулась в слишком маленький job. + partition_count = max(spark_context.defaultParallelism * 2, 8) + expected_sum = row_count * (row_count - 1) // 2 + actual_sum = ( + spark.range(0, row_count, 1, numPartitions=partition_count) + .selectExpr("SUM(id) AS total_sum") + .collect()[0]["total_sum"] + ) + + if actual_sum != expected_sum: + raise RuntimeError( + f"Unexpected smoke result: expected {expected_sum}, got {actual_sum}." + ) + + return { + "app_name": spark_context.appName, + "master_url": master_url, + "spark_version": spark.version, + "default_parallelism": spark_context.defaultParallelism, + "partition_count": partition_count, + "row_count": row_count, + "result_sum": actual_sum, + "expected_sum": expected_sum, + } + + +def format_smoke_report(report: dict) -> str: + return "\n".join( + [ + "Spark cluster smoke test passed.", + f"app_name={report['app_name']}", + f"master_url={report['master_url']}", + f"spark_version={report['spark_version']}", + f"default_parallelism={report['default_parallelism']}", + f"partition_count={report['partition_count']}", + f"row_count={report['row_count']}", + f"result_sum={report['result_sum']}", + f"expected_sum={report['expected_sum']}", + ] + ) + + +def main() -> None: + spark = create_spark_session() + try: + report = run_cluster_smoke(spark) + print(format_smoke_report(report)) + finally: + spark.stop() + + +if __name__ == "__main__": + main() From d1bcd63bf1ce8aae26176097278e98e48d53b7d5 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 00:31:09 +0300 Subject: [PATCH 06/24] =?UTF-8?q?docs(repo):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=BD=D1=8B=20=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=81=D0=BA=D0=B8=D0=B5=20=D0=B8=D0=BD=D1=81?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=BA=D1=86=D0=B8=D0=B8=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=B0=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужно отделить стабильные правила для агента от изменчивой структуры курса и стенда. - Что: - сокращен и переписан AGENTS.md под текущую модель репозитория как курса вокруг Lakehouse-стенда. - добавлен docs/maintainer_guide.md с картой репозитория, источниками истины и правилами синхронизации документации. - Проверка: - сверены AGENTS.md и docs/maintainer_guide.md с README.md, START_HERE.md, docs/course_prd.md и docs/course_program.md. --- AGENTS.md | 78 ++++++++++++++-------------- docs/maintainer_guide.md | 108 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 38 deletions(-) create mode 100644 docs/maintainer_guide.md diff --git a/AGENTS.md b/AGENTS.md index 745e156..8a37f41 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,57 +1,59 @@ # Repository Guidelines -This repository contains a small teaching Lakehouse stack (Spark + Trino + Iceberg + MinIO) intended for demos and mentoring. +Этот репозиторий теперь представляет собой не только локальный Lakehouse-стенд, но и каркас учебного курса `Lakehouse без магии`. -## Project Structure & Module Organization +В `AGENTS.md` держим только стабильные правила для автоматизированных правок. Изменчивый контекст проекта, карта репозитория и правила синхронизации вынесены в [docs/maintainer_guide.md](docs/maintainer_guide.md). -- `docker-compose.yml`: orchestrates Spark, Trino, MinIO, PostgreSQL, and Jupyter. -- `spark/`: Spark image (`Dockerfile`) and `spark-defaults.conf`. -- `jupyter/`: Jupyter image (`Dockerfile`) based on the Spark image. -- `trino/`: catalog config, e.g. `trino/catalog/lakehouse.properties`. -- `src/`: PySpark and SQL examples split by engine (`src/spark/...`, `src/trino/...`). -- `notebooks/`: demo notebooks (mounted into Jupyter at `/opt/work`). -- `plans/`: internal living docs for implementation plans. +## Канонические документы -Keep new examples in `src/` or `notebooks/`, and avoid mixing configuration and code. +- `README.md` — верхнеуровневое описание стенда, архитектуры и сервисов. +- `START_HERE.md` — первый маршрут для студента до запуска ноутбуков. +- `docs/course_prd.md` — рамки курса, learning outcomes, scope и out of scope. +- `docs/course_program.md` — модульная структура курса и состав учебных материалов. +- `docs/maintainer_guide.md` — карта репозитория и правила синхронизации изменений. +- `plans/` — внутренние living docs; не источник истины для студентского маршрута. +- `docs/archive/` — архивные материалы; не использовать как актуальную документацию без явного запроса. -## Build, Test, and Development Commands +## Структура репозитория -Run from the repo root: +- `docker-compose.yml`, `spark/`, `trino/`, `jupyter/` — исполняемый стенд. +- `notebooks/`, `src/` — учебные материалы и вспомогательные примеры. +- `docs/`, `START_HERE.md`, `README.md` — входная и методическая документация. +- `plans/` — внутреннее планирование. -- `docker compose build`: build custom Spark and Jupyter images. -- `docker compose up -d`: start the full stack in the background. -- `docker compose ps`: check container status. -- `docker compose down -v`: stop the stack and remove volumes (for a clean slate). +Новые учебные примеры держи в `src/` или `notebooks/`, а конфигурацию стенда не смешивай с учебным контентом. -Use `docker compose logs -f ` when debugging (`spark-master`, `trino`, `minio`, etc.). +## Правила изменений -## Coding Style & Naming Conventions +- Сначала меняй канонический документ для соответствующего слоя, потом синхронизируй связанные ссылки и упоминания. +- Если меняются сервисы, порты, имена контейнеров, образы или команды запуска, обновляй как минимум `README.md`, `START_HERE.md` и затронутые примеры. +- Если меняются scope курса, learning outcomes или модульная структура, обновляй `docs/course_prd.md` и `docs/course_program.md`, а не только `plans/`. +- Поддерживай согласованный маршрут `README -> START_HERE -> docs/course_program.md -> notebooks/src`. +- Не дублируй крупные фрагменты между документами, если можно сослаться на канонический файл. +- Legacy-материалы явно помечай как архивные и не возвращай их в основной маршрут без явного запроса. -- Python: PEP 8, 4-space indentation, snake_case for functions, lower_snake_case for files (e.g. `spark_join_demo.py`). -- SQL: uppercase keywords, `schema.table` naming, short English identifiers; comments may be in Russian. -- Compose/Docker: service names kebab-case (`spark-master`), env vars UPPER_SNAKE_CASE. -- Prefer small, focused examples; reuse helpers from `src/` in notebooks where possible. +## Стиль и содержание -## Testing Guidelines +- Сохраняй русский язык в student-facing документации, если задача не про перевод. +- Предпочитай небольшие, сфокусированные примеры и не усложняй стенд без явного запроса. +- Держи ноутбуки понятными для самостоятельного прохождения; переиспользуемую логику выноси в `src/`. +- Не добавляй обязательные шаги со скачиванием данных прямо из ноутбуков, если это можно оформить через onboarding-документацию. -There is no formal automated test suite yet. Validate changes by: +## Проверка изменений -- building and starting the stack, then -- running `src/spark/cluster_smoke.py` or the SQL in `src/spark/iceberg_demo.sql`, -- opening `notebooks/01_environment_and_smoke_test.ipynb` in Jupyter and checking it runs end-to-end. +- Для изменений только в документации проверь согласованность ссылок, терминов и маршрута прохождения. +- Для изменений стенда сначала используй лёгкую валидацию вроде `docker compose config`. +- Если меняется поведение стенда или учебных примеров, по возможности проверь сценарии из `src/spark/cluster_smoke.py`, `src/spark/iceberg_smoke.py`, `src/trino/iceberg_smoke.sql` или `notebooks/01_environment_and_smoke_test.ipynb`. +- Если полная проверка не выполнялась, укажи это явно. -If you add tests, prefer `pytest` under `tests/` and mark slow, integration-heavy tests clearly. +## Commit & PR Notes -## Commit & Pull Request Guidelines - -- Commits: short, descriptive subject in Russian or English, present tense (e.g. `Спарк запускается, создаётся тестовая таблица`, `Add Iceberg join demo`). -- Keep changes small and focused; update `README.md` when behavior, ports, or images change. -- PRs should describe the problem, the solution, and any impact on local setup; include screenshots (Trino UI, MinIO, Jupyter) when UI changes are relevant. +- Коммиты: короткий subject в настоящем времени, на русском или английском. +- Изменения поведения, портов, маршрута входа или состава материалов обычно требуют обновления документации рядом с кодом. +- Избегай больших рефакторингов структуры без явного запроса пользователя. ## Agent-Specific Instructions -When editing as an automated agent: - -- Respect this file and avoid large refactors without an explicit request. -- Preserve Russian user-facing text unless the change is explicitly about translation. -- Prefer minimal changes that keep the demo simple and robust for newcomers. +- Уважай существующие изменения пользователя и не откатывай их без запроса. +- Предпочитай минимальные изменения, которые делают курс проще и устойчивее для новичка. +- Если непонятно, какой маршрут сейчас актуален, доверяй `START_HERE.md` и `docs/course_program.md`, а не старым HOWTO или планам. diff --git a/docs/maintainer_guide.md b/docs/maintainer_guide.md new file mode 100644 index 0000000..52b6f79 --- /dev/null +++ b/docs/maintainer_guide.md @@ -0,0 +1,108 @@ +# Карта репозитория и источники истины + +Этот документ нужен, чтобы не раздувать `AGENTS.md` и не дублировать изменчивый контекст проекта в нескольких местах. + +## Что это за репозиторий сейчас + +Репозиторий состоит из двух тесно связанных слоёв: + +1. Локальный Lakehouse-стенд на `Spark + Trino + Iceberg + MinIO + PostgreSQL`. +2. Учебный курс `Lakehouse без магии`, который использует этот стенд как практическую среду. + +Из-за этого изменения часто затрагивают не только конфиги и контейнеры, но и маршрут студента по документации и ноутбукам. + +## Основные зоны репозитория + +| Зона | Где лежит | Назначение | +| --- | --- | --- | +| Runtime / stand | `docker-compose.yml`, `spark/`, `trino/`, `jupyter/` | Топология сервисов, образы, конфиги, порты | +| Onboarding | `README.md`, `START_HERE.md` | Вход в стенд и первый пользовательский маршрут | +| Course definition | `docs/course_prd.md`, `docs/course_program.md` | Границы курса, learning outcomes, структура модулей | +| Practice materials | `notebooks/`, `src/` | Практика студента, smoke-скрипты, демонстрации, helper-логика | +| Internal planning | `plans/` | Внутренние living docs по разработке материалов | +| Archive | `docs/archive/` | Исторические материалы, не входящие в актуальный маршрут | + +## Источники истины + +| Файл или каталог | За что отвечает | Что не стоит туда складывать | +| --- | --- | --- | +| `docker-compose.yml` | Реальный состав сервисов, контейнеров, сетей, портов и зависимостей | Учебные пояснения, которые не нужны для запуска | +| `spark/`, `trino/`, `jupyter/` | Конкретные runtime-конфиги и образы | Описание программы курса | +| `README.md` | Верхнеуровневое объяснение архитектуры стенда и состава репозитория | Пошаговый student onboarding во всех деталях | +| `START_HERE.md` | Первый маршрут студента: prerequisites, запуск, первые UI, первый ноутбук, базовая диагностика | Полный PRD курса или подробный бэклог модулей | +| `docs/course_prd.md` | Product scope, аудитория, learning outcomes, dataset strategy, out of scope | Технические мелочи запуска контейнеров | +| `docs/course_program.md` | Модульная структура курса, состав материалов, checkpoints | Подробные docker-команды и legacy-лабы | +| `notebooks/` | Каноническая практическая часть курса | Длинные инфраструктурные HOWTO | +| `src/` | Smoke-скрипты, SQL-демо и переиспользуемая helper-логика | Случайные одноразовые заметки и черновики | +| `plans/` | Внутренний статус работ, живые планы, декомпозиция задач | Источник истины для student-facing маршрута | +| `docs/archive/` | Справочный legacy-контент | Актуальные инструкции без явной пометки `legacy` | + +## Порядок приоритетов, если документы расходятся + +- Для реального поведения стенда: `docker-compose.yml` и runtime-конфиги важнее `README.md`. +- Для первого student onboarding: `START_HERE.md` важнее старых HOWTO и внутренних планов. +- Для scope курса: `docs/course_prd.md` важнее `plans/`. +- Для структуры модулей и состава практик: `docs/course_program.md` важнее случайных упоминаний в README или archive-документах. + +Если после изменения документы начинают спорить друг с другом, исправляй не только тот файл, который менял, но и ближайший канонический слой. + +## Что обновлять при разных типах изменений + +### Если меняется стенд + +Примеры: новые сервисы, другие порты, переименование контейнеров, изменения образов, новые обязательные env vars. + +Минимум проверь и при необходимости обнови: + +- `docker-compose.yml`; +- `README.md`; +- `START_HERE.md`; +- затронутые команды в `src/`, ноутбуках и планах. + +### Если меняется маршрут студента + +Примеры: новый первый ноутбук, другой onboarding-flow, перенос шагов из README в отдельный документ. + +Минимум проверь и при необходимости обнови: + +- `START_HERE.md`; +- `docs/course_program.md`; +- соответствующие `notebooks/`; +- `README.md`, если меняется верхнеуровневый вход в репозиторий. + +### Если меняется scope курса + +Примеры: новый обязательный модуль, изменение learning outcomes, перенос темы в backlog, смена основного датасета. + +Минимум проверь и при необходимости обнови: + +- `docs/course_prd.md`; +- `docs/course_program.md`; +- релевантные `plans/`; +- student-facing материалы, если scope уже отражён в них явно. + +### Если добавляется новый учебный материал + +Примеры: новый ноутбук, новый smoke-скрипт, новый SQL demo. + +Обычно нужно: + +- положить практику в `notebooks/` или `src/`; +- отразить её в `docs/course_program.md`, если материал входит в основной трек; +- при необходимости обновить `START_HERE.md`, если это новая точка входа; +- не раздувать `README.md` подробностями модуля, если это уже описано в программе курса. + +## Что не стоит держать в AGENTS.md + +- Полную программу курса по модулям. +- Подробные таблицы портов и runtime-параметров. +- Длинные HOWTO и лабораторные сценарии. +- Исторические объяснения того, как стенд выглядел раньше. + +`AGENTS.md` должен оставаться коротким набором правил. Всё, что часто меняется вместе с курсом и стендом, лучше хранить в обычной документации. + +## Работа с legacy-материалами + +- `docs/archive/` хранит полезный контекст, но не определяет текущий маршрут курса. +- Если старый материал нужен как reference, оставляй его в archive и явно помечай, почему он не канонический. +- Если legacy-документ снова становится частью основного курса, переноси его обратно в актуальную структуру, а не оставляй полуживой копией. From 3f76d66ed13e5fbf8674e39b534e4ed889c9316c Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 00:38:26 +0300 Subject: [PATCH 07/24] =?UTF-8?q?docs(readme):=20=D1=80=D0=B0=D0=B7=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BD=D1=8B=20student-facing=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B5=D0=BD=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужно убрать смешение маршрута студента с внутренними документами сопровождения репозитория. - Что: - переработан README.md как верхнеуровневый вход в репозиторий без ссылок на внутренние maintainer-документы. - добавлен docs/stack_reference.md для технического reference стенда и уточнены его роли относительно START_HERE.md. - синхронизированы AGENTS.md и docs/maintainer_guide.md под новую границу между onboarding и внутренней документацией. - Проверка: - сверены README.md, START_HERE.md, docs/stack_reference.md, AGENTS.md и docs/maintainer_guide.md на согласованность маршрута и аудиторий. --- AGENTS.md | 5 +- README.md | 397 +++++---------------------------------- docs/maintainer_guide.md | 9 +- docs/stack_reference.md | 172 +++++++++++++++++ 4 files changed, 233 insertions(+), 350 deletions(-) create mode 100644 docs/stack_reference.md diff --git a/AGENTS.md b/AGENTS.md index 8a37f41..74065f2 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,9 @@ ## Канонические документы -- `README.md` — верхнеуровневое описание стенда, архитектуры и сервисов. +- `README.md` — верхнеуровневое описание репозитория, архитектуры и точек входа. - `START_HERE.md` — первый маршрут для студента до запуска ноутбуков. +- `docs/stack_reference.md` — технический reference по сервисам, портам, доступу и smoke-тестам. - `docs/course_prd.md` — рамки курса, learning outcomes, scope и out of scope. - `docs/course_program.md` — модульная структура курса и состав учебных материалов. - `docs/maintainer_guide.md` — карта репозитория и правила синхронизации изменений. @@ -26,7 +27,7 @@ ## Правила изменений - Сначала меняй канонический документ для соответствующего слоя, потом синхронизируй связанные ссылки и упоминания. -- Если меняются сервисы, порты, имена контейнеров, образы или команды запуска, обновляй как минимум `README.md`, `START_HERE.md` и затронутые примеры. +- Если меняются сервисы, порты, имена контейнеров, образы или команды запуска, обновляй как минимум `README.md`, `START_HERE.md`, `docs/stack_reference.md` и затронутые примеры. - Если меняются scope курса, learning outcomes или модульная структура, обновляй `docs/course_prd.md` и `docs/course_program.md`, а не только `plans/`. - Поддерживай согласованный маршрут `README -> START_HERE -> docs/course_program.md -> notebooks/src`. - Не дублируй крупные фрагменты между документами, если можно сослаться на канонический файл. diff --git a/README.md b/README.md index 5190000..a80f79e 100755 --- a/README.md +++ b/README.md @@ -1,17 +1,12 @@ -# Учебный Lakehouse-стенд (Trino + Spark + Iceberg + MinIO) +# Lakehouse без магии: локальный стенд и учебные материалы -Учебный стенд для демонстрации **Lakehouse-архитектуры** на одном ноутбуке: +Этот репозиторий объединяет: -- объектное хранилище S3-класса (MinIO), -- движок запросов Trino, -- Spark для batch/ETL и интерактивных экспериментов, -- формат таблиц Iceberg, -- JDBC-каталог (PostgreSQL) для метаданных Iceberg в Trino, -- конфиг Spark, заточенный под работу с Iceberg + S3. +- локальный Lakehouse-стенд на `Spark + Trino + Iceberg + MinIO + PostgreSQL`; +- учебный курс `Lakehouse без магии`, который использует этот стенд как практическую среду; +- стартовые ноутбуки и demo-скрипты для первых экспериментов. -Стенд ориентирован на обучение менти и быструю демонстрацию концепции Lakehouse: разделение **storage / compute / catalog** без лишней обвязки (Hive Metastore, полноценный Hadoop-кластер и т.п.). - ---- +Репозиторий рассчитан не на «универсальную платформу для всего», а на понятную локальную песочницу, где можно руками пройти путь от запуска стенда до чтения одной и той же Iceberg-таблицы из `Spark` и `Trino`. ## Архитектура @@ -36,363 +31,73 @@ graph LR T <--> P ``` -**Основные идеи:** +Коротко по ролям: -* **Данные** (Parquet-файлы + служебные каталоги Iceberg) лежат в бакете MinIO (`s3a://lakehouse/warehouse/...`). -* **Метаданные** Iceberg хранятся в PostgreSQL (JDBC-каталог `lakehouse`). -* **Spark и Trino** используют один и тот же JDBC-каталог: таблицы, созданные в Spark, доступны в Trino, и наоборот. +- `MinIO` хранит данные и служебные файлы Iceberg. +- `PostgreSQL` хранит метаданные JDBC-каталога `lakehouse`. +- `Spark` и `Trino` работают как два compute-движка поверх одного storage и одного catalog. +- `Jupyter` служит точкой входа в практическую часть курса. ---- +## С чего начать -## Быстрый старт +Если ты заходишь в репозиторий впервые, используй такой маршрут: -Подробный маршрут старта вынесен в [START_HERE.md](./START_HERE.md). Если нужен только краткий запуск, достаточно: +1. Открой [START_HERE.md](./START_HERE.md) для первого запуска стенда и базовой диагностики. +2. После старта стенда выполни `notebooks/01_environment_and_smoke_test.ipynb`. +3. Для структуры курса смотри [docs/course_program.md](./docs/course_program.md). -1. Установить Docker и Docker Compose (см. раздел «Требования» ниже). -2. В корне репозитория выполнить: - - ```bash - docker compose build - docker compose up -d - ``` - -3. Открыть основные интерфейсы: - - - Spark Master UI: `http://localhost:8080` - - Trino Web UI: `http://localhost:8090` - - MinIO Console: `http://localhost:9001` - - JupyterLab (если включён): `http://localhost:8888` - -4. Для входа в курс и Модуль 1 открыть `START_HERE.md`. -5. Актуальная структура курса описана в `docs/course_program.md`. - ---- - -## Состав репозитория - -* `docker-compose.yml` — описание всех сервисов стенда: - - * Spark master / worker(ы) на базе кастомного образа `spark-iceberg`, - * Trino, - * MinIO (S3-совместимое хранилище), - * PostgreSQL под Iceberg JDBC-каталог, - * (опционально) Jupyter / notebook для PySpark. -* `spark/Dockerfile` — сборка кастомного образа **Spark** с зависимостями: - - * `hadoop-aws`, `aws-java-sdk`, - * библиотека Iceberg нужной версии, - * `pyspark`, `pyarrow` и базовый набор инструментов. -* `jupyter/Dockerfile` — образ JupyterLab на базе Spark-образа. -* `START_HERE.md` — канонический маршрут входа в курс до первого ноутбука. -* `plans/` — внутренние living docs с планами работ по модулям курса. -* `spark/spark-defaults.conf` — конфигурация Spark для работы с: - - * Iceberg-каталогом `lakehouse` (тип `jdbc`, метаданные в Postgres), - * MinIO через `s3a://`, - * расширениями `IcebergSparkSessionExtensions`. -* `src/` — учебные примеры для Spark и Trino: - - * `src/spark/cluster_smoke.py` — проверка, что кластер жив (Spark master/worker). - * `src/spark/iceberg_smoke.py` — Spark создаёт Iceberg-таблицу и читает её. - * `src/spark/iceberg_demo.sql` — пример создания Iceberg-таблицы через Spark SQL. - * `src/trino/iceberg_smoke.sql` — Trino читает таблицу, созданную в Spark. -* `trino/catalog/lakehouse.properties` — конфиг каталога Trino `lakehouse`: - - * коннектор `iceberg`, - * `jdbc`-каталог (PostgreSQL), - * доступ к MinIO как к S3-хранилищу. - ---- - -## Требования - -- Docker и Docker Compose. -- Порты по умолчанию должны быть свободны (см. таблицу «Сервисы и порты» ниже). - -### Сервисы и порты - -| Сервис | Контейнер | Порт (host → container) | Назначение / UI | -|------------------------|-------------------|-------------------------|-------------------------------------| -| Trino | `trino` | `8090 → 8080` | Web UI Trino | -| MinIO API | `minio` | `9000 → 9000` | S3 endpoint | -| MinIO Console | `minio` | `9001 → 9001` | Веб-консоль MinIO | -| PostgreSQL (каталог) | `postgres-iceberg`| `5432 → 5432` | Доступ для psql/DBeaver и т.п. | -| Spark master UI | `spark-master` | `8080 → 8080` | Web UI мастера Spark | -| Spark worker-1 UI | `spark-worker-1` | `8081 → 8081` | Web UI первого воркера | -| Spark worker-2 UI | `spark-worker-2` | `8082 → 8081` | Web UI второго воркера (host 8082) | -| JupyterLab | `jupyter` | `8888 → 8888` | JupyterLab с PySpark | - ---- - -## Сборка и запуск - -Все команды в этом разделе выполняются из корня репозитория. - -### 1. Собрать образы +Если нужен только краткий запуск, из корня репозитория достаточно: ```bash docker compose build -``` - -Будут собраны кастомные образы Spark/Jupyter с зависимостями Iceberg, S3A, JDBC-драйвером Postgres и Python-библиотеками. - -### 2. Поднять стенд - -```bash docker compose up -d -``` - -Что происходит при старте: - -* MinIO поднимается с root-пользователем/паролем (по умолчанию смотри в `docker-compose.yml`, обычно `minioadmin/minioadmin`), init-контейнер создаёт бакет `lakehouse`. -* PostgreSQL под каталог Iceberg создаёт БД и пользователя (значения — в `docker-compose.yml` / `.env`). -* Trino стартует с каталогом `lakehouse`, описанным в `lakehouse.properties`. -* Spark master/worker получают конфиг из `spark-defaults.conf` (общий JDBC-каталог Iceberg + MinIO). - -Проверить статус: - -```bash docker compose ps ``` -### 3. Остановить стенд и очистить данные +Основные UI после старта: -Чтобы остановить все сервисы и удалить данные в MinIO/PostgreSQL (Docker volumes), можно выполнить: +- Spark Master UI: `http://localhost:8080` +- Trino UI: `http://localhost:8090` +- MinIO Console: `http://localhost:9001` +- JupyterLab: `http://localhost:8888` -```bash -docker compose down -v -``` +Полный onboarding, диагностика и reset-сценарии находятся в `START_HERE.md`. ---- +## Как устроен репозиторий -## Доступ к сервисам +| Где | Что лежит | +| --- | --- | +| `docker-compose.yml` | состав сервисов стенда, порты, сети, init-контейнеры | +| `spark/` | Dockerfile и конфиг Spark для Iceberg + MinIO | +| `trino/` | каталог `lakehouse` и настройки Trino | +| `jupyter/` | образ JupyterLab на базе Spark-образа | +| `notebooks/` | практические ноутбуки курса | +| `src/` | smoke-скрипты, SQL-демо и helper-логика | +| `docs/` | PRD, программа курса, reference-документы | +| `plans/` | внутренние living docs по развитию материалов | -### Trino +## Основные документы для прохождения -Web UI (координатор): +- [START_HERE.md](./START_HERE.md) — первый маршрут для студента. +- [docs/course_program.md](./docs/course_program.md) — модульная структура и состав учебных материалов. +- [docs/stack_reference.md](./docs/stack_reference.md) — технический reference по сервисам, портам, конфигам и smoke-тестам. -```text -http://localhost:8090 -``` +## Что уже можно делать в стенде -(точный порт см. в `docker-compose.yml`). +- поднять локальный кластер `Spark` с двумя worker-ами; +- создать Iceberg-таблицу из `Spark`; +- прочитать ту же таблицу из `Trino`; +- пройти базовый smoke test через ноутбук или demo-скрипты; +- использовать стенд как основу для следующих модулей курса. -Подключение **из контейнера trino**: +## Куда смотреть за техническими деталями -```bash -docker exec -it trino trino \ - --server http://localhost:8080 \ - --catalog lakehouse -``` - -Проверка: - -```sql -SHOW CATALOGS; -SHOW SCHEMAS FROM lakehouse; -``` - -### MinIO - -* Консоль: `http://localhost:9001` -* S3 endpoint: `http://localhost:9000` - -Учётные данные — `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` из `docker-compose.yml` или `.env`. - -Создай в MinIO бакет `lakehouse` (если его нет) — данные Iceberg будут храниться именно там. - -### PostgreSQL (каталог Iceberg для Trino) - -Подключение (пример): - -```bash -psql -h localhost -p 5432 -U iceberg -d iceberg -``` - -(имя пользователя, БД и порт уточняются в `docker-compose.yml`). - -Служебные таблицы Iceberg JDBC-каталога (`iceberg_tables`, `iceberg_namespace_properties`) создаёт однократный контейнер `iceberg-catalog-init`. Если база уже запускалась без них, можно переинициализировать вручную: - -```bash -docker compose run --rm iceberg-catalog-init -``` - -### Jupyter / notebooks - -Веб-интерфейс: `http://localhost:8888` (по умолчанию без токена). - -* В контейнере монтируется `./notebooks` в `/opt/work`. -* `./src` доступен read-only в `/opt/src`, переменная `PYTHONPATH=/opt/src` уже установлена — можно импортировать функции из скриптов прямо в ноутбуках. -* Первый канонический ноутбук курса: `notebooks/01_environment_and_smoke_test.ipynb`. - ---- - -## Конфигурация Trino (каталог `lakehouse`) - -Файл `lakehouse.properties` монтируется в `/etc/trino/catalog/lakehouse.properties`. - -Ключевые параметры: - -```properties -connector.name=iceberg - -# Каталог Iceberg типа JDBC (метаданные в PostgreSQL) -iceberg.catalog.type=jdbc -iceberg.jdbc-catalog.catalog-name=lakehouse -iceberg.jdbc-catalog.driver-class=org.postgresql.Driver -iceberg.jdbc-catalog.connection-url=jdbc:postgresql://postgres-iceberg:5432/iceberg -iceberg.jdbc-catalog.connection-user=iceberg -iceberg.jdbc-catalog.connection-password=iceberg -iceberg.jdbc-catalog.schema-version=V1 - -# Хранилище файлов Iceberg – S3 (MinIO) через hadoop-клиент -iceberg.file-system.type=hadoop -fs.native-s3.enabled=true - -s3.endpoint=http://minio:9000 -s3.region=us-east-1 -s3.path-style-access=true -s3.aws-access-key=minioadmin -s3.aws-secret-key=minioadmin -``` - -**Что это даёт:** - -* Trino хранит *метаданные* Iceberg в PostgreSQL (таблицы каталога, снапшоты, манифесты и т.п.). -* *Файлы данных* лежат в MinIO, в бакете `lakehouse`, к которому Trino ходит по `s3://`/`s3a://` через S3-клиент. - -Пример создания схемы и таблицы из Trino: - -```sql --- Схема в каталоге lakehouse (метаданные в PostgreSQL) -CREATE SCHEMA lakehouse.default; - --- Таблица Iceberg с данными в s3://lakehouse/default/test_table/ -CREATE TABLE lakehouse.default.test_table ( - id bigint, - name varchar -); -``` - ---- - -## Конфигурация Spark (spark-defaults.conf) - -`spark-defaults.conf` монтируется в `/opt/spark/conf/spark-defaults.conf` в контейнеры Spark. - -Ключевые моменты: - -```properties -# Iceberg Spark extensions -spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions - -# Каталог Iceberg для Spark (JDBC, метаданные в Postgres) -spark.sql.catalog.lakehouse=org.apache.iceberg.spark.SparkCatalog -spark.sql.catalog.lakehouse.catalog-impl=org.apache.iceberg.jdbc.JdbcCatalog -spark.sql.catalog.lakehouse.uri=jdbc:postgresql://postgres-iceberg:5432/iceberg -spark.sql.catalog.lakehouse.jdbc.user=iceberg -spark.sql.catalog.lakehouse.jdbc.password=iceberg -spark.sql.catalog.lakehouse.jdbc.driver=org.postgresql.Driver -spark.sql.catalog.lakehouse.warehouse=s3a://lakehouse/warehouse -spark.sql.catalog.lakehouse.default-namespace=default - -# S3/MinIO через s3a -spark.hadoop.fs.s3a.endpoint=http://minio:9000 -spark.hadoop.fs.s3a.access.key=minioadmin -spark.hadoop.fs.s3a.secret.key=minioadmin -spark.hadoop.fs.s3a.path.style.access=true -spark.hadoop.fs.s3a.impl=org.apache.hadoop.fs.s3a.S3AFileSystem -spark.hadoop.fs.s3a.connection.ssl.enabled=false -``` - -**Важно:** Spark и Trino используют единый JDBC-каталог `lakehouse`: метаданные лежат в Postgres, данные — в MinIO. Таблица, созданная в Spark, видна в Trino без дополнительной настройки. - -Пример создания таблицы из Spark: - -```python -from pyspark.sql import SparkSession - -spark = (SparkSession.builder - .appName("lakehouse-demo") - .getOrCreate()) - -# Каталог lakehouse указан явно -spark.sql(""" - CREATE TABLE lakehouse.default.spark_table ( - id BIGINT, - name STRING - ) - USING iceberg -""") - -spark.sql("INSERT INTO lakehouse.default.spark_table VALUES (1, 'Alice'), (2, 'Bob')") -``` - -Файлы окажутся в `s3a://lakehouse/warehouse/default/spark_table/`. - ---- - -## Типовой учебный сценарий - -1. **Поднять стенд** (`docker compose build`, затем `docker compose up -d`). -2. **Создать бакет `lakehouse`** в MinIO Console (если ещё нет). -3. **Создать таблицу из Spark**, записать туда данные, показать дерево файлов Iceberg в MinIO (data/manifest/metadata). -4. **Прочитать ту же таблицу из Trino** (проверка общего каталога). -5. **Создать таблицу из Trino** и прочитать её из Spark. -6. Обсудить архитектуру: Postgres хранит метаданные Iceberg, MinIO — данные, Spark/Trino — compute. - ---- - -## Smoke-тесты Spark → Trino - -Быстрая проверка, что таблица, созданная в Spark, читается в Trino через общий JDBC-каталог. - -1. Убедиться, что стенд запущен: `docker compose up -d`. -2. Скопировать скрипты внутрь контейнеров: - - ```bash - docker compose cp src/spark/iceberg_smoke.py spark-master:/tmp/ - docker compose cp src/trino/iceberg_smoke.sql trino:/tmp/ - ``` - -3. Выполнить smoke из Spark: - - ```bash - docker compose exec spark-master /opt/spark/bin/spark-submit /tmp/iceberg_smoke.py - ``` - - Скрипт создаст `lakehouse.default.spark_trino_smoke`, вставит строку `1, from_spark` и прочитает её. - -4. Прочитать ту же таблицу из Trino: - - ```bash - docker compose exec trino trino --file /tmp/iceberg_smoke.sql - ``` - - В выводе должны быть строки `spark_trino_smoke` в списке таблиц и `1, from_spark` в результате выборки. - -5. Очистка (опционально): - - ```bash - docker compose exec spark-master /opt/spark/bin/spark-sql -e "DROP TABLE IF EXISTS lakehouse.default.spark_trino_smoke" - ``` - ---- - -## Дальнейшее развитие - -Планируемые/возможные расширения: - -* Подключить **Airflow** и запускать Spark-job’ы поверх этого же Lakehouse. -* Вынести настройки (`MINIO_ROOT_USER`, `ICEBERG_*`, `POSTGRES_*`) в `.env` с шаблоном для студентов. -* Добавить отдельные каталоги Trino (например, `hive`, `tpch`) для демонстрации федеративных запросов. -* Добавить пример интеграции с BI-инструментом (DBeaver/Metabase/Superset) поверх Trino. - -Актуальная структура курса описана в `docs/course_program.md`. Старый `HOWTO` сохранён как архивный материал в `docs/archive/legacy_howto.md`. - ---- +- [docs/stack_reference.md](./docs/stack_reference.md) — сервисы, порты, доступ, reset, smoke tests; +- [spark/spark-defaults.conf](./spark/spark-defaults.conf) — конфигурация Spark-каталога `lakehouse`; +- [trino/catalog/lakehouse.properties](./trino/catalog/lakehouse.properties) — конфигурация каталога Trino; +- [docker-compose.yml](./docker-compose.yml) — фактический состав стенда. ## Лицензия -Материалы этого репозитория лицензированы на условиях Creative Commons Attribution 4.0 International (CC BY 4.0). -См. файл `LICENSE` или . +Материалы этого репозитория лицензированы на условиях Creative Commons Attribution 4.0 International (`CC BY 4.0`). +См. [LICENSE](./LICENSE). diff --git a/docs/maintainer_guide.md b/docs/maintainer_guide.md index 52b6f79..d85920a 100644 --- a/docs/maintainer_guide.md +++ b/docs/maintainer_guide.md @@ -2,6 +2,8 @@ Этот документ нужен, чтобы не раздувать `AGENTS.md` и не дублировать изменчивый контекст проекта в нескольких местах. +Это внутренний документ сопровождения репозитория. Для прохождения курса и первого запуска стенда он обычно не нужен. + ## Что это за репозиторий сейчас Репозиторий состоит из двух тесно связанных слоёв: @@ -16,7 +18,8 @@ | Зона | Где лежит | Назначение | | --- | --- | --- | | Runtime / stand | `docker-compose.yml`, `spark/`, `trino/`, `jupyter/` | Топология сервисов, образы, конфиги, порты | -| Onboarding | `README.md`, `START_HERE.md` | Вход в стенд и первый пользовательский маршрут | +| Onboarding | `README.md`, `START_HERE.md` | Вход в репозиторий и первый пользовательский маршрут | +| Runtime reference | `docs/stack_reference.md` | Технические детали стенда: порты, доступ, команды, smoke-тесты | | Course definition | `docs/course_prd.md`, `docs/course_program.md` | Границы курса, learning outcomes, структура модулей | | Practice materials | `notebooks/`, `src/` | Практика студента, smoke-скрипты, демонстрации, helper-логика | | Internal planning | `plans/` | Внутренние living docs по разработке материалов | @@ -28,8 +31,9 @@ | --- | --- | --- | | `docker-compose.yml` | Реальный состав сервисов, контейнеров, сетей, портов и зависимостей | Учебные пояснения, которые не нужны для запуска | | `spark/`, `trino/`, `jupyter/` | Конкретные runtime-конфиги и образы | Описание программы курса | -| `README.md` | Верхнеуровневое объяснение архитектуры стенда и состава репозитория | Пошаговый student onboarding во всех деталях | +| `README.md` | Верхнеуровневое объяснение репозитория, архитектуры и точек входа | Пошаговый student onboarding во всех деталях и low-level runtime reference | | `START_HERE.md` | Первый маршрут студента: prerequisites, запуск, первые UI, первый ноутбук, базовая диагностика | Полный PRD курса или подробный бэклог модулей | +| `docs/stack_reference.md` | Технический reference стенда: сервисы, порты, доступ, reset, smoke-тесты | Роль основного onboarding-документа или описание всей программы курса | | `docs/course_prd.md` | Product scope, аудитория, learning outcomes, dataset strategy, out of scope | Технические мелочи запуска контейнеров | | `docs/course_program.md` | Модульная структура курса, состав материалов, checkpoints | Подробные docker-команды и legacy-лабы | | `notebooks/` | Каноническая практическая часть курса | Длинные инфраструктурные HOWTO | @@ -57,6 +61,7 @@ - `docker-compose.yml`; - `README.md`; - `START_HERE.md`; +- `docs/stack_reference.md`; - затронутые команды в `src/`, ноутбуках и планах. ### Если меняется маршрут студента diff --git a/docs/stack_reference.md b/docs/stack_reference.md new file mode 100644 index 0000000..1c078b6 --- /dev/null +++ b/docs/stack_reference.md @@ -0,0 +1,172 @@ +# Технический reference стенда + +Этот документ описывает сам стенд как runtime-среду: сервисы, порты, доступ, основные команды и smoke-тесты. + +Если нужен первый student onboarding, используй [START_HERE.md](../START_HERE.md). Если нужна программа курса, используй [course_program.md](./course_program.md). + +## Состав сервисов + +| Сервис | Контейнер | Порт на хосте | Назначение | +| --- | --- | --- | --- | +| Spark master | `spark-master` | `7077`, `8080` | мастер Spark и его UI | +| Spark worker 1 | `spark-worker-1` | `8081` | первый worker Spark | +| Spark worker 2 | `spark-worker-2` | `8082` | второй worker Spark | +| Trino | `trino` | `8090` | SQL engine и Web UI | +| MinIO API | `minio` | `9000` | S3-compatible endpoint | +| MinIO Console | `minio` | `9001` | веб-консоль бакетов и объектов | +| PostgreSQL | `postgres-iceberg` | `5432` | JDBC-каталог Iceberg | +| JupyterLab | `jupyter` | `8888` | практические ноутбуки | + +## Основные команды + +Все команды выполняются из корня репозитория. + +### Сборка и запуск + +```bash +docker compose build +docker compose up -d +docker compose ps +``` + +### Логи + +```bash +docker compose logs -f spark-master +docker compose logs -f trino +docker compose logs -f minio +docker compose logs -f jupyter +``` + +### Остановка и reset + +```bash +docker compose down +docker compose down -v +``` + +`down -v` удаляет volumes и возвращает стенд в чистое состояние. + +## Доступ к сервисам + +### Spark + +- Master UI: `http://localhost:8080` +- Worker UI: `http://localhost:8081` и `http://localhost:8082` +- Spark master endpoint внутри сети compose: `spark://spark-master:7077` + +Пример запуска smoke-скрипта: + +```bash +docker compose exec spark-master \ + /opt/spark/bin/spark-submit /opt/src/spark/cluster_smoke.py +``` + +### Trino + +- Web UI: `http://localhost:8090` + +CLI внутри контейнера: + +```bash +docker compose exec -it trino trino --catalog lakehouse +``` + +Примеры первых команд: + +```sql +SHOW CATALOGS; +SHOW SCHEMAS FROM lakehouse; +SHOW TABLES FROM lakehouse.default; +``` + +### MinIO + +- S3 endpoint: `http://localhost:9000` +- Console: `http://localhost:9001` +- логин: `minioadmin` +- пароль: `minioadmin` + +Бакет `lakehouse` обычно создаётся автоматически контейнером `minio-init`. + +### PostgreSQL + +Подключение с хоста: + +```bash +psql -h localhost -p 5432 -U iceberg -d iceberg +``` + +Служебные таблицы JDBC-каталога создаёт `iceberg-catalog-init`. + +Если нужен повторный запуск инициализации: + +```bash +docker compose run --rm iceberg-catalog-init +``` + +### JupyterLab + +- адрес: `http://localhost:8888` +- `./notebooks` смонтирован как `/opt/work` +- `./src` смонтирован read-only как `/opt/src` +- `PYTHONPATH=/opt/src` + +Первый ноутбук курса: `notebooks/01_environment_and_smoke_test.ipynb`. + +## Как связаны Spark, Trino, PostgreSQL и MinIO + +- `Spark` использует каталог `lakehouse` через `JdbcCatalog`. +- `Trino` использует тот же каталог `lakehouse` через `iceberg.jdbc-catalog`. +- метаданные таблиц лежат в `PostgreSQL`; +- данные и metadata-файлы Iceberg лежат в `MinIO` в бакете `lakehouse`. + +Именно поэтому таблица, созданная в `Spark`, может читаться в `Trino` без копирования данных. + +## Где лежат ключевые конфиги + +| Файл | Что задаёт | +| --- | --- | +| `docker-compose.yml` | состав сервисов, порты, volumes, init-контейнеры | +| `spark/spark-defaults.conf` | Spark catalog `lakehouse`, `s3a`, Iceberg extensions | +| `trino/catalog/lakehouse.properties` | каталог Trino `lakehouse`, JDBC и S3-доступ | +| `spark/Dockerfile` | образ Spark с Iceberg, S3A и Python-зависимостями | +| `jupyter/Dockerfile` | образ JupyterLab на базе Spark | + +## Smoke-тесты + +### 1. Проверка, что Spark-кластер жив + +```bash +docker compose exec spark-master \ + /opt/spark/bin/spark-submit /opt/src/spark/cluster_smoke.py +``` + +### 2. Проверка Spark -> Trino через общий каталог + +```bash +docker compose cp src/spark/iceberg_smoke.py spark-master:/tmp/ +docker compose cp src/trino/iceberg_smoke.sql trino:/tmp/ +docker compose exec spark-master /opt/spark/bin/spark-submit /tmp/iceberg_smoke.py +docker compose exec trino trino --file /tmp/iceberg_smoke.sql +``` + +Ожидаемый результат: + +- в Spark создаётся `lakehouse.default.spark_trino_smoke`; +- в Trino видна та же таблица; +- выборка возвращает строку `1, from_spark`. + +Опциональная очистка: + +```bash +docker compose exec spark-master \ + /opt/spark/bin/spark-sql -e "DROP TABLE IF EXISTS lakehouse.default.spark_trino_smoke" +``` + +## Когда какой документ использовать + +- `README.md` — чтобы понять, что это за репозиторий и куда идти дальше. +- `START_HERE.md` — чтобы впервые поднять стенд и пройти Модуль 1. +- `stack_reference.md` — чтобы быстро вспомнить порты, команды, точки доступа и smoke-тесты. +- `course_program.md` — чтобы понять учебную траекторию дальше первого модуля. From b9916ab2f5329901400b4ede445acdad32dc2161 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 00:46:38 +0300 Subject: [PATCH 08/24] =?UTF-8?q?docs(readme):=20=D1=83=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=8B=20=D1=81=D1=85=D0=B5=D0=BC=D0=B0=20?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BD=D0=B4=D0=B0=20=D0=B8=20=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D1=80=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужно точнее показать пользовательский путь по стенду и добавить понятную точку контакта с автором курса. - Что: - обновлена архитектурная диаграмма в README.md: добавлены пользователь, точки входа и явное разделение access, compute и shared services. - уточнены подписи к ролям компонентов стенда. - добавлен блок с контактами автора и предложением менторского разбора тем по Data Engineering. - Проверка: - сверены диаграмма и описания в README.md с фактической топологией docker-compose и сценарием использования через JupyterLab и Trino. --- README.md | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index a80f79e..2dd1d0a 100755 --- a/README.md +++ b/README.md @@ -10,25 +10,35 @@ ## Архитектура -```mermaid -graph LR - subgraph Storage - M[(MinIO
S3-compatible)] - end +Ниже показана упрощённая рабочая схема стенда: пользователь входит через `JupyterLab` и `Trino UI / CLI`, а `Spark` и `Trino` независимо работают поверх общего `storage` и общего `catalog`. - subgraph Catalog - P[(PostgreSQL
Iceberg JDBC catalog)] +```mermaid +graph TB + U[Student / engineer] + + subgraph Access + J[JupyterLab
notebooks / PySpark] + Q[Trino UI / CLI
ad hoc SQL] end subgraph Compute - S[Spark 3.5.1
PySpark / SQL] + S[Spark cluster
master + workers] T[Trino 478
Iceberg connector] end - S <--> M - S <--> P - T <--> M - T <--> P + subgraph Shared Services + P[(PostgreSQL
Iceberg JDBC catalog)] + M[(MinIO
S3-compatible)] + end + + U --> J + U --> Q + J --> S + Q --> T + S --> P + S --> M + T --> P + T --> M ``` Коротко по ролям: @@ -36,7 +46,8 @@ graph LR - `MinIO` хранит данные и служебные файлы Iceberg. - `PostgreSQL` хранит метаданные JDBC-каталога `lakehouse`. - `Spark` и `Trino` работают как два compute-движка поверх одного storage и одного catalog. -- `Jupyter` служит точкой входа в практическую часть курса. +- `JupyterLab` служит основной точкой входа в практическую часть курса. +- `Trino UI / CLI` даёт отдельную точку входа для ad hoc SQL и проверки таблиц. ## С чего начать @@ -97,6 +108,12 @@ docker compose ps - [trino/catalog/lakehouse.properties](./trino/catalog/lakehouse.properties) — конфигурация каталога Trino; - [docker-compose.yml](./docker-compose.yml) — фактический состав стенда. +## Автор и менторство + +Этот репозиторий и материалы курса можно проходить самостоятельно, но при желании их можно разбирать вместе с автором как с ментором по `Data Engineering`. + +Если хочешь глубже пройти темы `Spark`, `Trino`, `Iceberg`, `Lakehouse` и связанные практики по `DE`, напиши: [@dementev_dev](https://t.me/dementev_dev). + ## Лицензия Материалы этого репозитория лицензированы на условиях Creative Commons Attribution 4.0 International (`CC BY 4.0`). From d69fec2bf50c9234c2a029e22eabfe2d071c2557 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 01:18:29 +0300 Subject: [PATCH 09/24] =?UTF-8?q?docs(plans):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=9C?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=202=20=D0=B8=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - необходимо зафиксировать структуру второго модуля «Ментальная модель Lakehouse» и подготовить инфраструктуру для инспекции компонентов. - Что: - создан файл plans/module-02-lakehouse-mental-model.md с детальным планом работ, целями и чекпоинтом. - в jupyter/Dockerfile добавлены библиотеки boto3 и psycopg2-binary для программного доступа к MinIO и PostgreSQL из ноутбуков. - модуль 2 добавлен в общий индекс планов в plans/README.md. - Проверка: - файл плана соответствует методике курса, в Dockerfile запинены версии библиотек. --- jupyter/Dockerfile | 7 ++- plans/README.md | 1 + plans/module-02-lakehouse-mental-model.md | 64 +++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 plans/module-02-lakehouse-mental-model.md diff --git a/jupyter/Dockerfile b/jupyter/Dockerfile index cacf393..9bddc20 100755 --- a/jupyter/Dockerfile +++ b/jupyter/Dockerfile @@ -4,8 +4,11 @@ FROM de-spark-iceberg:3.5.1 USER root ENV DEBIAN_FRONTEND=noninteractive -# Только JupyterLab, без дополнительных пакетов -RUN pip3 install --no-cache-dir "jupyterlab==4.2.5" +# Устанавливаем JupyterLab и пакеты для инспекции стенда (MinIO, PostgreSQL) +RUN pip3 install --no-cache-dir \ + "jupyterlab==4.2.5" \ + "boto3>=1.35,<2" \ + "psycopg2-binary>=2.9,<3" # Создаём непривилегированного пользователя ARG NB_USER=jovyan diff --git a/plans/README.md b/plans/README.md index bcbac09..8d8aa87 100644 --- a/plans/README.md +++ b/plans/README.md @@ -14,3 +14,4 @@ | Модуль | План | Статус | Целевые артефакты | Обновлён | | --- | --- | --- | --- | --- | | 1. Вход в стенд и базовая диагностика | [module-01-environment-and-basic-diagnostics.md](./module-01-environment-and-basic-diagnostics.md) | Ready for validation | `START_HERE.md`, `notebooks/01_environment_and_smoke_test.ipynb`, `src/spark/cluster_smoke.py` | 2026-03-06 | +| 2. Ментальная модель Lakehouse: storage, catalog, compute | [module-02-lakehouse-mental-model.md](./module-02-lakehouse-mental-model.md) | Draft | `notebooks/02_lakehouse_mental_model.ipynb` | 2026-03-07 | diff --git a/plans/module-02-lakehouse-mental-model.md b/plans/module-02-lakehouse-mental-model.md new file mode 100644 index 0000000..ad62844 --- /dev/null +++ b/plans/module-02-lakehouse-mental-model.md @@ -0,0 +1,64 @@ +# Модуль 2. Ментальная модель Lakehouse: storage, catalog, compute + +**Статус:** `Draft` +**Последнее обновление:** `2026-03-07` + +## Цель + +Убрать "магическое" восприятие Lakehouse и связать новую модель с привычным миром классических монолитных СУБД (PostgreSQL/Greenplum). Студент должен на практике увидеть физическое разделение ролей: где лежат данные, где хранятся метаданные, и кто выполняет вычисления. + +## Результат для студента + +После прохождения модуля студент: + +- понимает, что таблица в Lakehouse в практическом смысле — это согласованный набор файлов данных и метаданных; +- может проследить цепочку записи `Spark -> catalog -> MinIO` на примере маленькой demo-таблицы; +- осознает разницу между "одной базой данных" и набором независимых компонентов Lakehouse; +- умеет находить физические артефакты таблицы в объектном хранилище (MinIO); +- умеет находить записи о таблице в каталоге метаданных (PostgreSQL); +- не путает физическое хранение с логической таблицей. + +## Deliverables + +- `notebooks/02_lakehouse_mental_model.ipynb` как основной ноутбук Модуля 2; +- Подготовленные диагностические шаги (например, вспомогательные функции в `src/spark` или запросы/скрипты прямо в ноутбуке) для инспекции MinIO и PostgreSQL; +- Обновление индекса планов в `plans/README.md`. + +## План работ + +1. Подготовить структуру `notebooks/02_lakehouse_mental_model.ipynb` по методике курса: `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`. +2. Написать короткий теоретический блок с параллелями: классический DWH (Greenplum/Postgres) vs. Lakehouse (Storage + Catalog + Compute). +3. Сделать готовую демонстрацию: создание отдельного namespace (например, `lakehouse.module_02`) и небольшой demo-таблицы через Spark, запись пары строк. Для обеспечения идемпотентности и чистоты при повторном прохождении использовать `CREATE OR REPLACE TABLE` (или очистку таблицы перед записью) и конструкции `IF NOT EXISTS` для namespace. +4. Разработать шаги для инспекции: + - запрос к PostgreSQL (каталогу метаданных), чтобы увидеть регистрацию таблицы; + - запрос к MinIO (например, s3a list или boto3), чтобы увидеть появление файлов данных (parquet) и метаданных (json/avro). +5. Сформулировать самостоятельное задание: студент создает свою таблицу, пишет в нее данные и находит её следы в хранилище и каталоге. +6. Включить в конец ноутбука обязательный шаг очистки (cleanup) — удаление таблиц и namespace `module_02`, чтобы не оставлять мусор для следующих модулей. +7. Описать вопросы для Checkpoint. + +## Checkpoint + +Студент должен уметь: + +- показать конкретные файлы данных и метаданных созданной таблицы в MinIO (через UI или код); +- показать запись о таблице в PostgreSQL и объяснить, на что указывает поле `metadata_location`; +- объяснить, что произойдёт, если удалить файлы из MinIO, но не трогать каталог (или наоборот); +- сопоставить эти наблюдения с ролями `storage` и `catalog`. + +## Acceptance Criteria + +- Ноутбук `02_lakehouse_mental_model.ipynb` полностью раскрывает тему декомпозиции Lakehouse без глубокого погружения в спецификацию Iceberg. +- Демонстрации работают в локальном окружении (Jupyter) и наглядно показывают изменения в MinIO и PostgreSQL. +- Самостоятельное задание выполнимо на базе показанных примеров. +- Модуль 2 добавлен в индекс `plans/README.md`. + +## Риски + +- Излишнее углубление во внутренности Iceberg-манифестов. Нужно держать фокус только на факте их наличия и разделения `data/metadata`. +- Доступность PostgreSQL/MinIO из Jupyter: необходимо убедиться, что в `jupyter/Dockerfile` установлены `boto3` и `psycopg2-binary`, а также учитывать DNS-резолв `postgres-iceberg` из контейнера `jupyter` (сеть `spark-net` это позволяет, но могут быть нюансы). + +## Out of Scope + +- Загрузка и чтение сырых данных (NYC Taxi) — отложено до Модуля 3. +- Сложные трансформации. +- Чтение одной таблицы двумя движками (Spark и Trino) — отложено до Модуля 6 (в этом модуле чтение демо-таблицы будет только через Spark SQL для базовой верификации записи). From 3763538aca6236e1e68d51297c94762d65c3519e Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 01:26:19 +0300 Subject: [PATCH 10/24] =?UTF-8?q?feat(notebooks):=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BD=D0=BE=D1=83?= =?UTF-8?q?=D1=82=D0=B1=D1=83=D0=BA=20=D0=9C=D0=BE=D0=B4=D1=83=D0=BB=D1=8F?= =?UTF-8?q?=202=20=C2=AB=D0=9C=D0=B5=D0=BD=D1=82=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=20Lakehouse?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - необходимо дать студенту практический инструмент для понимания разделения storage, catalog и compute. - Что: - создан notebooks/02_lakehouse_mental_model.ipynb с демонстрацией записи в Spark и инспекцией в PostgreSQL и MinIO. - в ноутбук добавлена таблица сравнения Lakehouse vs классические СУБД. - реализованы механизмы идемпотентности (CREATE OR REPLACE TABLE) и автоматической очистки ресурсов. - обновлен статус Модуля 2 в планах на "Ready for validation". - Проверка: - проверена структура JSON ноутбука, наличие всех демонстрационных и самостоятельных ячеек, а также корректность путей к MinIO (prefix warehouse/). --- notebooks/02_lakehouse_mental_model.ipynb | 300 ++++++++++++++++++++++ plans/README.md | 2 +- plans/module-02-lakehouse-mental-model.md | 2 +- 3 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 notebooks/02_lakehouse_mental_model.ipynb diff --git a/notebooks/02_lakehouse_mental_model.ipynb b/notebooks/02_lakehouse_mental_model.ipynb new file mode 100644 index 0000000..cd71428 --- /dev/null +++ b/notebooks/02_lakehouse_mental_model.ipynb @@ -0,0 +1,300 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Модуль 2. Ментальная модель Lakehouse: storage, catalog, compute\n", + "\n", + "В этом модуле мы на практике разберем, чем Lakehouse отличается от классических реляционных СУБД (PostgreSQL, Greenplum).\n", + "В классической СУБД данные, метаданные и вычислитель жестко связаны внутри одной системы. В Lakehouse эти слои физически разделены:\n", + "- **Storage (Хранилище)**: где лежат физические файлы (у нас это MinIO / S3).\n", + "- **Catalog (Каталог)**: где хранится информация о таблицах и их расположении (у нас это PostgreSQL).\n", + "- **Compute (Вычислитель)**: кто обрабатывает данные (у нас это Spark, а позже добавится Trino).\n", + "\n", + "| | PostgreSQL / Greenplum | Lakehouse |\n", + "|---|---|---|\n", + "| **Данные** | Внутри СУБД (pg_data) | Файлы в объектном хранилище (MinIO) |\n", + "| **Метаданные** | Системные каталоги (pg_catalog) | Внешний каталог (PostgreSQL JDBC) |\n", + "| **Вычислитель** | Тот же процесс СУБД | Независимые движки (Spark, Trino) |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Мы не указываем .master() явно, так как он и другие настройки Iceberg\n", + "# подтягиваются из конфигурации spark-defaults.conf при старте сессии.\n", + "from pyspark.sql import SparkSession\n", + "\n", + "spark = SparkSession.builder \\\n", + " .appName(\"Module-02-Mental-Model\") \\\n", + " .getOrCreate()\n", + "\n", + "# Spark выводит много INFO/WARN логов — убираем их, чтобы не засорять вывод ноутбука\n", + "spark.sparkContext.setLogLevel(\"ERROR\")\n", + "print(\"Spark сессия готова!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Запись таблицы (Compute)\n", + "Мы используем Spark (вычислитель) для создания таблицы и записи данных." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Создаем namespace (логическую базу данных)\n", + "spark.sql(\"CREATE NAMESPACE IF NOT EXISTS lakehouse.module_02\")\n", + "\n", + "# Создаем или перезаписываем таблицу и вставляем пару строк\n", + "spark.sql(\"\"\"\n", + "CREATE OR REPLACE TABLE lakehouse.module_02.demo_table (\n", + " id int,\n", + " name string,\n", + " created_at timestamp\n", + ")\n", + "USING iceberg\n", + "\"\"\")\n", + "\n", + "spark.sql(\"\"\"\n", + "INSERT INTO lakehouse.module_02.demo_table \n", + "VALUES \n", + " (1, 'Alice', current_timestamp()),\n", + " (2, 'Bob', current_timestamp())\n", + "\"\"\")\n", + "\n", + "# Прочитаем данные через Spark, чтобы убедиться, что они записались\n", + "spark.sql(\"SELECT * FROM lakehouse.module_02.demo_table\").show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Где живут метаданные? (Catalog)\n", + "Таблица в Lakehouse — это не просто файлы. Чтобы Spark (или Trino) знал, где лежат данные и какая у них схема, используется Catalog. В нашем стенде это PostgreSQL.\n", + "Посмотрим, какая запись появилась в базе данных каталога." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import psycopg2\n", + "import pandas as pd\n", + "import warnings\n", + "\n", + "# Подключаемся к PostgreSQL, который хранит метаданные Iceberg (наш Catalog)\n", + "with psycopg2.connect(\n", + " host=\"postgres-iceberg\",\n", + " database=\"iceberg\",\n", + " user=\"iceberg\",\n", + " password=\"iceberg\"\n", + ") as conn:\n", + " # Запрашиваем информацию о нашей таблице\n", + " query = \"\"\"\n", + " SELECT table_namespace, table_name, metadata_location, previous_metadata_location \n", + " FROM iceberg_tables \n", + " WHERE table_namespace = 'module_02'\n", + " \"\"\"\n", + "\n", + " # Используем catch_warnings локально, чтобы скрыть UserWarning от pandas при чтении из DB-API\n", + " with warnings.catch_warnings():\n", + " warnings.simplefilter('ignore', UserWarning)\n", + " df_catalog = pd.read_sql(query, conn)\n", + "\n", + "# Посмотрим, куда указывает catalog (обратите внимание на metadata_location)\n", + "df_catalog" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "В поле `metadata_location` хранится путь к актуальному файлу метаданных таблицы в физическом хранилище.\n", + "\n", + "## 3. Где живут сами данные? (Storage)\n", + "В качестве физического хранилища (Storage) мы используем MinIO — S3-совместимое объектное хранилище. Данные и файлы метаданных лежат там.\n", + "Посмотрим на физические артефакты нашей таблицы." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import boto3\n", + "\n", + "# Подключаемся к MinIO\n", + "s3 = boto3.client(\n", + " 's3',\n", + " endpoint_url='http://minio:9000',\n", + " aws_access_key_id='minioadmin',\n", + " aws_secret_access_key='minioadmin'\n", + ")\n", + "\n", + "bucket_name = 'lakehouse'\n", + "# Путь (prefix) начинается с warehouse, так как это задано в настройках каталога\n", + "prefix = 'warehouse/module_02/demo_table/'\n", + "\n", + "# Получаем список всех файлов (объектов), относящихся к нашей таблице\n", + "response = s3.list_objects_v2(Bucket=bucket_name, Prefix=prefix)\n", + "\n", + "print(\"Файлы таблицы в MinIO:\\n\")\n", + "contents = response.get('Contents', [])\n", + "if not contents:\n", + " print(\"Файлы не найдены. Проверьте prefix или убедитесь, что таблица создана.\")\n", + "else:\n", + " for obj in contents:\n", + " key = obj['Key']\n", + " size_kb = obj['Size'] / 1024\n", + " if '/data/' in key:\n", + " print(f\"[DATA] {key} ({size_kb:.2f} KB)\")\n", + " elif '/metadata/' in key:\n", + " print(f\"[META] {key} ({size_kb:.2f} KB)\")\n", + " else:\n", + " print(f\"[OTHER] {key} ({size_kb:.2f} KB)\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Что мы видим?**\n", + "1. Папка `metadata/` содержит файлы `.json` (снимки состояния таблицы), `.avro` (манифесты, описывающие, какие файлы данных актуальны).\n", + "2. Папка `data/` содержит сами данные в формате `.parquet`.\n", + "\n", + "> **Подумайте:** Что произойдет, если вы удалите файлы из MinIO, но не удалите запись в каталоге? А что если наоборот?" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 4. Самостоятельное задание\n", + "\n", + "1. Создайте таблицу `lakehouse.module_02.my_first_table`.\n", + "2. Запишите в неё несколько произвольных строк.\n", + "3. Прочитайте таблицу через Spark.\n", + "4. Выполните запросы к PostgreSQL и MinIO (аналогично примерам выше), чтобы найти `metadata_location` и физические `parquet` файлы вашей новой таблицы." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: создание таблицы my_first_table и вставка данных\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: чтение данных через Spark\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: инспекция каталога (PostgreSQL)\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: инспекция хранилища (MinIO)\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Checkpoint\n", + "\n", + "Убедитесь, что вы можете ответить на следующие вопросы:\n", + "- Можете ли вы показать конкретные файлы данных и метаданных созданной таблицы в MinIO (через UI MinIO на порту 9001 или через код выше)?\n", + "- Можете ли вы показать запись о таблице в PostgreSQL и объяснить, на что указывает поле `metadata_location`?\n", + "- Что произойдёт, если удалить файлы из MinIO, но не трогать каталог (или наоборот)?\n", + "- Понимаете ли вы теперь разницу между `storage` (MinIO) и `catalog` (PostgreSQL)?" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 5. Очистка ресурсов (Cleanup)\n", + "Выполните ячейку ниже, чтобы удалить созданные таблицы и namespace. Это необходимо, чтобы не оставлять \"мусор\" перед переходом к следующим модулям." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Удаляем таблицы (это удалит их и из каталога, и из хранилища)\n", + "spark.sql(\"DROP TABLE IF EXISTS lakehouse.module_02.demo_table\")\n", + "spark.sql(\"DROP TABLE IF EXISTS lakehouse.module_02.my_first_table\")\n", + "\n", + "# Удаляем namespace\n", + "spark.sql(\"DROP NAMESPACE IF EXISTS lakehouse.module_02\")\n", + "\n", + "spark.stop()\n", + "print(\"Очистка завершена, Spark сессия остановлена.\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/plans/README.md b/plans/README.md index 8d8aa87..a843ae2 100644 --- a/plans/README.md +++ b/plans/README.md @@ -14,4 +14,4 @@ | Модуль | План | Статус | Целевые артефакты | Обновлён | | --- | --- | --- | --- | --- | | 1. Вход в стенд и базовая диагностика | [module-01-environment-and-basic-diagnostics.md](./module-01-environment-and-basic-diagnostics.md) | Ready for validation | `START_HERE.md`, `notebooks/01_environment_and_smoke_test.ipynb`, `src/spark/cluster_smoke.py` | 2026-03-06 | -| 2. Ментальная модель Lakehouse: storage, catalog, compute | [module-02-lakehouse-mental-model.md](./module-02-lakehouse-mental-model.md) | Draft | `notebooks/02_lakehouse_mental_model.ipynb` | 2026-03-07 | +| 2. Ментальная модель Lakehouse: storage, catalog, compute | [module-02-lakehouse-mental-model.md](./module-02-lakehouse-mental-model.md) | Ready for validation | `notebooks/02_lakehouse_mental_model.ipynb` | 2026-03-07 | diff --git a/plans/module-02-lakehouse-mental-model.md b/plans/module-02-lakehouse-mental-model.md index ad62844..6aa9ceb 100644 --- a/plans/module-02-lakehouse-mental-model.md +++ b/plans/module-02-lakehouse-mental-model.md @@ -1,6 +1,6 @@ # Модуль 2. Ментальная модель Lakehouse: storage, catalog, compute -**Статус:** `Draft` +**Статус:** `Ready for validation` **Последнее обновление:** `2026-03-07` ## Цель From 60079dd1c86ca6009120b8469278091233e9fe37 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 11:43:38 +0300 Subject: [PATCH 11/24] =?UTF-8?q?docs(plan):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=203=20=D0=BF=D0=BE=20raw-ingest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужен согласованный draft модуля про raw-данные, первое чтение в Spark и подготовку data bundle. - Что: - добавлен план `module-03-raw-ingest-and-first-read.md` с deliverables, структурой ноутбука и acceptance criteria. - зафиксированы требования к onboarding-документации, raw-ingest в MinIO и валидации data bundle. - Проверка: - план вручную сверен с `docs/course_program.md`, `docs/course_prd.md` и `docs/stack_reference.md`. - runtime-проверка стенда и ноутбука не выполнялась. --- plans/module-03-raw-ingest-and-first-read.md | 173 +++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 plans/module-03-raw-ingest-and-first-read.md diff --git a/plans/module-03-raw-ingest-and-first-read.md b/plans/module-03-raw-ingest-and-first-read.md new file mode 100644 index 0000000..ad317f2 --- /dev/null +++ b/plans/module-03-raw-ingest-and-first-read.md @@ -0,0 +1,173 @@ +# Модуль 3. Raw-данные и первое чтение в Spark + +**Статус:** `Draft` +**Последнее обновление:** `2026-03-07` + +## Цель + +Научить студента полному циклу работы с raw-данными в Lakehouse: доставка заранее скачанного PARQUET-датасета в raw-зону MinIO, чтение raw-данных через Spark, проверка схемы, типов и базового качества. Студент должен понять, зачем нужен raw-слой и почему raw-данные не следует «чинить на месте». + +## Результат для студента + +После прохождения модуля студент: + +- понимает, как локальный data bundle попадает в raw-зону MinIO; +- умеет загружать PARQUET-файлы в MinIO программно через `boto3`; +- умеет читать raw-данные из MinIO через Spark (`spark.read.parquet` по `s3a://` пути); +- умеет проверять схему DataFrame (`.printSchema()`, `.dtypes`), считать базовые метрики (`.describe()`, `.count()`, null-доли); +- может найти простые аномалии или проблемные значения в реальном датасете; +- понимает, почему raw-слой нужен как воспроизводимая неизменяемая точка входа; +- понимает, почему датасет не следует скачивать из интернета прямо в ноутбуке. + +## Deliverables + +- `notebooks/03_raw_ingest_and_first_read.ipynb` — основной ноутбук Модуля 3; +- изменение `docker-compose.yml` — volume mount `./data:/opt/data:ro` в сервис `jupyter`; +- изменение `.gitignore` — добавление `/data/`; +- изменение `START_HERE.md` — секция «Подготовка учебного датасета (перед Модулем 3)» + обновление маршрута прохождения и секции «Как работать в Jupyter» (добавить упоминание `/opt/data`); +- изменение `docs/stack_reference.md` — обновление секции JupyterLab (добавить `./data` mount); +- обновление индекса планов в `plans/README.md`. + +## Зависимости от инфраструктуры + +### Доставка данных в контейнер + +Добавить volume mount `./data:/opt/data:ro` в секцию `jupyter.volumes` файла `docker-compose.yml` (аналогично `./src:/opt/src:ro`). + +Студент заранее скачивает PARQUET на хост по инструкции из `START_HERE.md`. В ноутбуке студент загружает файлы из `/opt/data/` в MinIO через `boto3`. Это делает процесс наглядным и воспроизводимым. + +```yaml +# docker-compose.yml, секция jupyter.volumes +volumes: + - ./spark/spark-defaults.conf:/opt/spark/conf/spark-defaults.conf:ro + - ./notebooks:/opt/work + - ./src:/opt/src:ro + - ./data:/opt/data:ro # учебный data bundle (NYC Taxi) +``` + +### Инструкция скачивания датасета + +Добавить в `START_HERE.md` секцию после «Restart и reset», перед «Что делать дальше»: + +- `mkdir -p data/nyc_taxi` +- 3 команды `curl` для Yellow Tripdata 2024-01..03 с `https://d37ci6vzurychx.cloudfront.net/trip-data/` +- 1 команда `curl` для `taxi_zone_lookup.csv` с `https://d37ci6vzurychx.cloudfront.net/misc/` +- Проверочный `ls -lh data/nyc_taxi/` +- Инструкция фиксирует конкретные файлы (2024-01, 2024-02, 2024-03) как каноническое учебное подмножество; расширенный режим — до 12 месяцев 2024 года +- Пометка: после изменения `docker-compose.yml` (добавления volume) нужен `docker compose up -d` для пересоздания контейнера `jupyter`; после этого новые файлы в `./data` видны сразу без перезапуска + +### Структура raw-зоны в MinIO + +Путь: `s3a://lakehouse/raw/nyc_taxi/`. Отдельный prefix `raw/` не смешивается с `warehouse/` (Iceberg-таблицы). Имена файлов сохраняются оригинальными, чтобы подчеркнуть принцип «raw не изменяется». + +### .gitignore + +Добавить в конец: + +``` +# Dataset (downloaded by student, not stored in repo) +/data/ +``` + +## План работ + +1. Добавить `/data/` в `.gitignore`. +2. Добавить volume mount `./data:/opt/data:ro` в `docker-compose.yml`, секция `jupyter.volumes`. +3. Добавить в `START_HERE.md` секцию «Подготовка учебного датасета (перед Модулем 3)» с воспроизводимыми командами скачивания NYC Taxi Yellow Tripdata PARQUET (3 месяца + taxi zone lookup). Обновить маршрут прохождения (добавить шаг подготовки данных). Обновить секцию «Как работать в Jupyter» — добавить упоминание `./data` mount как `/opt/data`. Обновить секцию «Что делать дальше», чтобы маршрут включал Модуль 3. +4. Обновить `docs/stack_reference.md` — в секции JupyterLab добавить строку `./data` смонтирован read-only как `/opt/data`. +5. Создать `notebooks/03_raw_ingest_and_first_read.ipynb` по ячеечной структуре, описанной ниже. +6. Обновить `plans/README.md` — добавить строку Модуля 3 в таблицу оглавления. +7. Валидация: убедиться, что `docker compose config` проходит без ошибок; выполнить ноутбук сверху вниз на поднятом стенде с data bundle. + +## Структура ноутбука + +### Секция 0: Введение +- **[md]** Заголовок, цели модуля, prerequisite (data bundle из `START_HERE.md`). Таблица-сравнение: «Классический DWH: данные уже внутри СУБД» vs «Lakehouse: данные нужно явно доставить в storage». + +### Секция 1: Проверка data bundle +- **[md]** Зачем проверяем; почему не скачиваем из интернета в ноутбуке (воспроизводимость, сетевые ограничения, разделение ответственности onboarding vs практика). +- **[code]** `os.listdir("/opt/data/nyc_taxi")`, вывод файлов с размерами, assert на наличие PARQUET-файлов и `taxi_zone_lookup.csv` — оба нужны для полного прохождения модуля (включая самостоятельное задание в Секции 6). Понятное сообщение с отсылкой к `START_HERE.md` при отсутствии любого из компонентов. + +### Секция 2: Загрузка в raw-зону MinIO +- **[md]** Что такое raw-зона, параллель с stg-слоем в Greenplum, конвенция путей, почему raw не чинится «на месте». +- **[code]** Демонстрация: перед загрузкой проверяем через `s3.head_object()`, существует ли уже объект с таким ключом. Три ветки: (1) объект не существует — загружаем через `boto3.upload_file()`; (2) объект существует и размер совпадает с локальным файлом — пропускаем с пометкой «already exists, skipping»; (3) объект существует, но размер отличается — **fail fast** с понятной ошибкой (raise), без перезаписи. Это обеспечивает идемпотентность и подкрепляет принцип «raw не перезаписывается». В markdown-пояснении перед ячейкой явно объяснить: повторный запуск безопасен, уже загруженные файлы не перетираются, а конфликт размеров сигнализирует о проблеме с данными. +- **[code]** Верификация: `s3.list_objects_v2()` — листинг raw-зоны. +- **[md]** Данные теперь в storage. Spark может читать их напрямую по `s3a://` пути; для Trino потребуется зарегистрированная таблица (это будет в Модулях 4 и 6). + +### Секция 3: Первое чтение в Spark +- **[md]** Разница `spark.read.parquet("s3a://...")` (raw-файлы) vs `spark.table(...)` (управляемая Iceberg-таблица). Мы пока на этапе raw, Iceberg-таблица появится в Модуле 4. +- **[code]** Создание SparkSession (паттерн из Module 2 — без `.master()`, `setLogLevel("ERROR")`). +- **[code]** Чтение одного файла: `spark.read.parquet("s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_2024-01.parquet")`. +- **[code]** Чтение всех файлов wildcard: `yellow_tripdata_*.parquet` (без привязки к конкретному году в wildcard, чтобы код работал с любыми скачанными файлами). + +### Секция 4: Проверка схемы +- **[md]** Зачем проверять схему: в классическом DWH схема задана DDL, в raw — вычитывается из файлов. Что может пойти не так: разные типы в разных файлах, неожиданные nullable-поля. +- **[code]** `.printSchema()`. +- **[code]** Колонки и типы в удобном формате (`.dtypes`). +- **[code]** Null-анализ ключевых колонок (`VendorID`, `tpep_pickup_datetime`, `passenger_count`, `trip_distance`, `total_amount`) — количество и доля null в каждой. +- **[md]** Обсуждение: null в raw — норма, не исправляем на месте. Очистка — задача bronze/silver слоев (Модули 4-5). + +### Секция 5: Базовые метрики и аномалии +- **[md]** Цель: быстро понять диапазоны значений, выявить очевидные проблемы. +- **[code]** `.describe()` на числовых колонках. +- **[code]** Поиск аномалий: отрицательные `fare_amount`, нулевая дистанция с ненулевой суммой, `total_amount > $1000`, pickup за пределами ожидаемого диапазона дат (определяется динамически из имён файлов, а не хардкодится). +- **[md]** Аномалии в реальных данных — норма, а не баг загрузки. Задача raw — сохранить как есть. + +### Секция 6: Самостоятельное задание +- **[md]** Инструкция: (1) прочитать `taxi_zone_lookup.csv` из raw-зоны через Spark, (2) вывести схему + 10 строк, (3) посчитать количество зон по borough, (4) найти топ-5 pickup locations и join с зонами, (5) найти ещё одну аномалию или интересный паттерн в данных. +- **[code]** 4 пустых ячейки с `# Ваш код: ...`. + +### Секция 7: Почему raw-слой важен +- **[md]** Мини-лекция (3-5 абзацев): raw = неизменяемый архив; параллель с stg/ods в Greenplum; почему не скачиваем в ноутбуке; почему не чиним на месте (можно перестроить bronze/silver заново); в Lakehouse raw-файлы лежат в storage отдельно от compute — Spark читает их напрямую, а для Trino потребуется оформить данные в таблицу. + +### Секция 8: Checkpoint +- **[md]** Вопросы для самопроверки: (1) показать файлы в raw-зоне MinIO, (2) какая схема у Yellow Taxi — сколько колонок, (3) какие колонки содержат null — это баг или норма, (4) зачем raw-слой, если данные потом трансформируются, (5) почему не скачиваем из ноутбука, (6) что произойдёт, если NYC TLC изменит формат файла. + +### Секция 9: Завершение +- **[md]** «Мы намеренно не удаляем raw-данные из MinIO. В Модуле 4 мы будем использовать их для создания bronze-слоя.» +- **[code]** `spark.stop()`. + +### Дизайн-решения по ноутбуку + +- **Helper-код:** весь код inline в ноутбуке. Отдельный модуль в `src/` не создаётся — студент должен видеть каждый шаг явно (`boto3.upload_file()`, `spark.read.parquet()`, `.printSchema()`). +- **Cleanup:** ноутбук НЕ удаляет raw-данные из MinIO — они нужны Модулю 4 (bronze). Spark-сессия останавливается. Явное пояснение в конце ноутбука. + +## Checkpoint + +Студент должен уметь: + +- показать файлы в raw-зоне MinIO (через MinIO Console или через код); +- прочитать raw PARQUET через Spark и вывести несколько строк; +- назвать схему Yellow Taxi (количество колонок, основные типы); +- показать, какие колонки содержат null-значения; +- объяснить, почему raw-слой нужен как воспроизводимая неизменяемая точка входа; +- объяснить, почему данные не скачиваются прямо из ноутбука. + +## Acceptance Criteria + +- `docker-compose.yml` содержит volume mount `./data:/opt/data:ro` для контейнера `jupyter`, и `docker compose config` проходит без ошибок. +- `START_HERE.md` содержит воспроизводимую инструкцию по скачиванию NYC Taxi PARQUET (минимум 3 файла + taxi zone lookup) до запуска Модуля 3. +- `.gitignore` содержит `/data/` для исключения датасета из git. +- `notebooks/03_raw_ingest_and_first_read.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении при наличии скачанного data bundle. +- Ноутбук следует методике курса: `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`. +- Ноутбук использует паттерны из Модулей 1-2: `boto3` для MinIO, SparkSession без `.master()`, `setLogLevel("ERROR")`. +- Raw-данные остаются в MinIO после выполнения ноутбука (cleanup отсутствует намеренно). +- При отсутствии data bundle — понятный assert с отсылкой к `START_HERE.md`. +- `docs/stack_reference.md` обновлён: секция JupyterLab содержит `./data` mount. +- `plans/README.md` содержит строку Модуля 3 в оглавлении. + +## Риски + +- **Размер данных.** 3 месяца Yellow Taxi — примерно 120-150 MB (~40-50 MB на файл). Комфортно для стенда с 10 GB RAM. Extended (12 месяцев, ~600 MB) может замедлить Spark, но не должен вызывать падения. +- **Студент забыл скачать данные.** Assert в Секции 1 с понятным сообщением и ссылкой на `START_HERE.md`. +- **NYC TLC изменит формат/URL.** Ноутбук использует wildcard `yellow_tripdata_*.parquet` без привязки к году. Инструкция в `START_HERE.md` фиксирует конкретные файлы 2024-01..03, но если URL станут недоступны — потребуется обновление инструкции. +- **Директория `data/` не существует.** Docker volume mount создаст пустую директорию, assert в ноутбуке поймает отсутствие файлов. +- **Пересоздание контейнера.** Добавление volume в `docker-compose.yml` требует пересоздания контейнера `jupyter` (`docker compose up -d` достаточно — Docker пересоздаст только изменённый сервис). После этого bind mount уже работает: новые файлы в `./data` видны сразу без дополнительных перезапусков. Указать явно в инструкции. + +## Out of Scope + +- Создание Iceberg-таблиц — отложено до Модуля 4. +- Трансформации и очистка данных — отложено до Модулей 4-5. +- Чтение raw-данных через Trino (Trino не умеет читать произвольные файлы без таблицы) — отложено до Модуля 6. +- Партиционирование, MERGE, streaming, gold-слой. +- Автоматизированный скрипт скачивания (может быть добавлен позже, не является блокером). From 7ae771df68b24d92908d0087190789da611aa73a Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 12:02:02 +0300 Subject: [PATCH 12/24] =?UTF-8?q?docs(plan):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=204=20=D0=BF=D0=BE=20bronze-?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужен согласованный draft модуля про первую Iceberg-таблицу и переход от raw к bronze. - Что: - добавлен план `module-04-bronze-with-iceberg.md` с deliverables, структурой ноутбука и checkpoints. - зафиксированы решения по CTAS, инспекции metadata Iceberg и зависимости модуля 5 от bronze-таблицы. - Проверка: - план вручную сверен с `docs/course_program.md` и `docs/course_prd.md`. - runtime-проверка ноутбука и SQL-операций на стенде не выполнялась. --- plans/module-04-bronze-with-iceberg.md | 223 +++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 plans/module-04-bronze-with-iceberg.md diff --git a/plans/module-04-bronze-with-iceberg.md b/plans/module-04-bronze-with-iceberg.md new file mode 100644 index 0000000..8d16f8d --- /dev/null +++ b/plans/module-04-bronze-with-iceberg.md @@ -0,0 +1,223 @@ +# Модуль 4. Первая рабочая Iceberg-таблица и слой bronze + +**Статус:** `Draft` +**Последнее обновление:** `2026-03-07` + +## Цель + +Научить студента переходу от набора raw-файлов к управляемой Iceberg-таблице. Студент создаёт namespace `bronze`, записывает raw-данные NYC Taxi в Iceberg-таблицу и осматривает внутреннюю структуру таблицы (snapshots, data files, manifests). Студент понимает разницу между «каталогом с Parquet-файлами» и «управляемой таблицей» и связывает bronze-слой с привычным stg/ods-мышлением. + +## Результат для студента + +После прохождения модуля студент: + +- умеет создать Iceberg namespace и таблицу через Spark SQL; +- умеет загрузить raw-данные из MinIO в bronze-таблицу через CTAS (`CREATE OR REPLACE TABLE ... AS SELECT`); +- понимает разницу между `spark.read.parquet("s3a://...")` (набор файлов) и `spark.table("lakehouse.bronze.nyc_taxi_yellow")` (управляемая таблица); +- умеет осмотреть структуру Iceberg-таблицы: snapshots, history, data files; +- умеет сопоставить физическую структуру в MinIO (metadata/, data/) с логической таблицей; +- понимает, зачем нужен bronze-слой и как он соотносится с stg/ods в классическом DWH; +- понимает, что таблица в Lakehouse — это не «один каталог с файлами», а согласованный набор data files + metadata + catalog entry. + +## Deliverables + +- `notebooks/04_bronze_with_iceberg.ipynb` — основной ноутбук Модуля 4; +- обновление индекса планов в `plans/README.md`. + +Инфраструктурные изменения не требуются: `docker-compose.yml`, `START_HERE.md`, `docs/stack_reference.md` уже содержат всё необходимое после Модуля 3. + +## Зависимости + +### Предусловия + +- Стенд поднят и работает (Модуль 1). +- Студент прошёл Модуль 3: raw-данные загружены в `s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet` и `taxi_zone_lookup.csv`. +- Каталог `lakehouse` настроен в `spark-defaults.conf` как JdbcCatalog с warehouse `s3a://lakehouse/warehouse`. + +### Что используют последующие модули + +- Модуль 5 читает `lakehouse.bronze.nyc_taxi_yellow` для построения silver. +- Модуль 6 читает bronze/silver через Trino. + +## Дизайн-решения + +### Namespace: `lakehouse.bronze` (не `module_04`) + +Семантическое имя, потому что: +- Модуль 5 будет читать `lakehouse.bronze.*` для построения silver — семантическое имя делает связь явной. +- Подкрепляет мышление слоями `raw → bronze → silver` (PRD Risk #4: не смешивать роли слоёв). +- Модуль 2 использовал `module_02` для throwaway-демо с cleanup. Bronze — постоянный слой без cleanup, поэтому другая конвенция. + +### Создание и наполнение таблицы: CTAS с явным списком колонок + +Используем `CREATE OR REPLACE TABLE ... USING iceberg AS SELECT col1, col2, ... FROM raw_view` (CTAS). Это: +- объединяет создание и наполнение в один шаг — понятнее и ближе к `CREATE TABLE AS SELECT` в PostgreSQL; +- явный список колонок в SELECT исключает ошибки из-за несовпадения порядка или состава колонок (PRD Risk #3, #5); +- `CREATE OR REPLACE` обеспечивает идемпотентность: повторный запуск пересоздаёт таблицу, snapshot history остаётся чистой; +- trade-off: `CREATE OR REPLACE` уничтожает историю — в production это опасно, но для первичной загрузки учебного bronze допустимо. В Модуле 7 студент увидит, почему в рабочих сценариях нужен другой подход. + +В Секции 2 DDL с явной схемой показывается как справочный пример (не выполняется) — чтобы студент увидел знакомую форму `CREATE TABLE (col type, ...)`. + +### Инспекция Iceberg: три уровня глубины + +Модуль 2 показал level 1 (catalog entry в PostgreSQL + файлы в MinIO). Модуль 4 идёт глубже: + +- **Spark metadata tables:** `.snapshots`, `.history`, `.files` — встроенные metadata-таблицы Iceberg; +- **Физическая структура в MinIO:** boto3 листинг с разделением `[DATA]` / `[META]`, ASCII-дерево каталогов; +- **Таблица-сравнение** PostgreSQL/Greenplum vs Iceberg (pg_catalog ↔ JDBC catalog, heap files ↔ Parquet data files, WAL/MVCC ↔ snapshots). + +### Cleanup: нет + +Bronze нужен Модулю 5. Spark-сессия останавливается. Явное пояснение в финале ноутбука: «Мы намеренно не удаляем bronze-таблицы. В Модуле 5 мы будем строить silver на основе bronze.» + +### Taxi zone lookup: самостоятельное задание + +Студент создаёт `lakehouse.bronze.taxi_zone_lookup` как упражнение. Это подготовка к join в Модуле 5 (silver). + +## План работ + +1. Создать `notebooks/04_bronze_with_iceberg.ipynb` по ячеечной структуре, описанной ниже. +2. Обновить `plans/README.md` — добавить строку Модуля 4 в таблицу оглавления. +3. Валидация: убедиться, что ноутбук выполняется сверху вниз на поднятом стенде после прохождения Модуля 3 (raw-данные в MinIO). + +## Структура ноутбука + +### Секция 0: Введение +- **[md]** Заголовок: «Модуль 4. Первая рабочая Iceberg-таблица и слой bronze». Цели модуля. Prerequisite: Модуль 3 пройден, raw-данные в MinIO. Таблица-сравнение: + +| | Классический DWH (Greenplum) | Lakehouse | +|---|---|---| +| Raw-данные | Загружены в stg-таблицу через COPY/gpfdist | Файлы в объектном хранилище (raw-зона) | +| Bronze/ODS | `CREATE TABLE AS SELECT` из stg | `CREATE TABLE ... USING iceberg AS SELECT` из raw (CTAS) | +| Метаданные | pg_catalog (внутри СУБД) | Внешний каталог (PostgreSQL JDBC) + файлы metadata в MinIO | +| История изменений | Нет (или WAL на уровне СУБД) | Snapshots и manifests (встроены в формат) | + +### Секция 1: Spark-сессия и проверка raw +- **[code]** SparkSession (паттерн Модулей 2-3: без `.master()`, `setLogLevel("ERROR")`). +- **[code]** Быстрая проверка: `spark.read.parquet("s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet").count()` + `.printSchema()`. Assert на ненулевой count с отсылкой к Модулю 3. +- **[md]** Сейчас читаем как набор файлов — нет истории, каталогизации, контроля схемы. Дальше создадим управляемую таблицу. + +### Секция 2: Создание namespace и знакомство с DDL +- **[md]** Что такое namespace в Iceberg (аналог schema/database в PostgreSQL). Почему `bronze`, а не `module_04`. +- **[code]** `CREATE NAMESPACE IF NOT EXISTS lakehouse.bronze`. +- **[md]** Как выглядит DDL Iceberg-таблицы: показываем полную схему Yellow Taxi как справочный пример (не выполняем). Это знакомая студенту форма — `CREATE TABLE ... (col type, ...)`, как в PostgreSQL. Объясняем ключевое отличие: `USING iceberg` — указание на формат таблицы. +- **[md]** Для создания и одновременного наполнения таблицы данными используем CTAS (CREATE TABLE AS SELECT) — это удобнее, чем отдельные DDL + INSERT. Перечисляем колонки: VendorID, tpep_pickup_datetime, tpep_dropoff_datetime, passenger_count, trip_distance, RatecodeID, store_and_fwd_flag, PULocationID, DOLocationID, payment_type, fare_amount, extra, mta_tax, tip_amount, tolls_amount, improvement_surcharge, total_amount, congestion_surcharge, Airport_fee. + +### Секция 3: Загрузка raw → bronze (CTAS) +- **[md]** Аналогия с `CREATE TABLE ods AS SELECT ... FROM stg` в Greenplum. Почему явный список колонок: `SELECT *` привязывается к порядку, а не к именам — это хрупко и может дать некорректное сопоставление полей. Используем `CREATE OR REPLACE TABLE ... AS SELECT col1, col2, ... FROM raw_yellow` — CTAS одновременно создаёт таблицу и наполняет данными. +- **[md]** Идемпотентность: `CREATE OR REPLACE` при повторном запуске пересоздаёт таблицу с чистого листа — snapshot history остаётся чистой (один snapshot = одна загрузка). Trade-off: в production `CREATE OR REPLACE` — разрушительная операция (удаляет историю), но для первичной загрузки учебного bronze это осознанный выбор ради простоты. В Модуле 7 увидим, почему в рабочих сценариях нужен другой подход. +- **[code]** Чтение raw → `createOrReplaceTempView("raw_yellow")` → `CREATE OR REPLACE TABLE lakehouse.bronze.nyc_taxi_yellow USING iceberg AS SELECT VendorID, tpep_pickup_datetime, ... FROM raw_yellow` (CTAS с явным списком колонок). +- **[code]** Верификация: count bronze vs count raw — должны совпасть. + +### Секция 4: Файлы vs управляемая таблица +- **[md]** Ключевой момент модуля: два способа доступа к одним и тем же данным. +- **[code]** Демо обоих способов: `spark.read.parquet("s3a://...")` vs `spark.table("lakehouse.bronze.nyc_taxi_yellow")`. Оба дают одинаковый count и columns, но таблица даёт каталог, историю, схему, доступ через Trino. +- **[md]** Таблица-сравнение свойств: + +| Свойство | Raw файлы | Iceberg-таблица | +|---|---|---| +| Каталогизация | Нет | Да (PostgreSQL JDBC) | +| История изменений | Нет | Snapshots | +| Контроль схемы | Выводится из файлов | Зафиксирована в metadata | +| Чтение из Trino | Невозможно | Да | +| Атомарность записи | Нет | Да | + +### Секция 5: Осмотр структуры Iceberg + +#### 5a: Snapshots +- **[md]** Snapshot = «снимок» состояния таблицы. Аналогия с PostgreSQL: как если бы каждая транзакция оставляла именованный checkpoint. +- **[code]** `SELECT * FROM lakehouse.bronze.nyc_taxi_yellow.snapshots`. +- **[md]** Пояснение полей: `committed_at`, `snapshot_id`, `operation`, `summary`. + +#### 5b: History +- **[code]** `SELECT * FROM lakehouse.bronze.nyc_taxi_yellow.history`. +- **[md]** Последовательность snapshots. Каждый — результат конкретной операции. + +#### 5c: Data files +- **[code]** `SELECT file_path, file_format, record_count, file_size_in_bytes / 1024 / 1024 AS size_mb FROM lakehouse.bronze.nyc_taxi_yellow.files`. +- **[md]** Каждый data file — отдельный Parquet в MinIO. Iceberg знает, какие файлы принадлежат текущему snapshot. + +#### 5d: Физическая структура в MinIO +- **[code]** Листинг через boto3 с разделением `[DATA]` / `[META]` (паттерн из Module 2). Итого по размерам data vs metadata. +- **[md]** ASCII-дерево (после CTAS — один snapshot, одна операция): + ``` + warehouse/bronze/nyc_taxi_yellow/ + ├── metadata/ + │ ├── v1.metadata.json ← CTAS создал таблицу и записал данные (текущий) + │ ├── snap-*.avro ← manifest list (один snapshot) + │ └── *.avro ← manifests (списки data files) + └── data/ + ├── *.parquet ← данные + └── ... + ``` + CTAS создаёт таблицу и наполняет данными за одну операцию, поэтому в metadata один metadata JSON и один snapshot. + В Модуле 7 увидим, как snapshots помогают вернуться к предыдущему состоянию. + +### Секция 6: raw → bronze и привычное мышление +- **[md]** Мини-лекция (3-4 абзаца): + - В классическом DWH: stg → ods → dds. В Lakehouse: raw → bronze → silver (→ gold, за рамками курса). + - Bronze — первый управляемый слой. Содержит данные «как есть» из raw, но уже как Iceberg-таблицу: с каталогом, историей, возможностью чтения через Trino. + - Bronze ≠ raw. Raw — неизменяемый архив файлов. Bronze — управляемая таблица, которую можно пересоздать из raw. + - В Модуле 5 — silver: очистка, трансформации, бизнес-логика. + +### Секция 7: Самостоятельное задание +- **[md]** Инструкция: (1) прочитать `taxi_zone_lookup.csv` из raw-зоны, (2) создать `lakehouse.bronze.taxi_zone_lookup` с DDL (LocationID INT, Borough STRING, Zone STRING, service_zone STRING), (3) загрузить данные, (4) проверить через `spark.table(...)`, (5) осмотреть snapshots. +- **[code]** 5 пустых ячеек `# Ваш код: ...`. + +### Секция 8: Checkpoint +- **[md]** Вопросы для самопроверки: + 1. Чем отличается `spark.read.parquet("s3a://...")` от `spark.table("lakehouse.bronze.nyc_taxi_yellow")`? + 2. Что хранит snapshot и зачем он нужен? + 3. Где физически лежат данные bronze-таблицы и где метаданные? + 4. Почему bronze — это не то же самое, что raw? + 5. Что произойдёт, если удалить один data file из MinIO, но не изменить metadata? + 6. Можете ли вы показать таблицу в MinIO Console (через браузер)? + +### Секция 9: Завершение +- **[md]** «Мы намеренно не удаляем bronze-таблицы. В Модуле 5 мы будем строить silver-слой на основе `lakehouse.bronze.nyc_taxi_yellow`.» +- **[code]** `spark.stop()`. + +### Дизайн-решения по ноутбуку + +- **Helper-код:** весь код inline в ноутбуке. Студент должен видеть каждый шаг: CTAS, metadata-запросы, boto3. +- **Cleanup:** нет. Bronze нужен Модулю 5. Spark-сессия останавливается. + +## Checkpoint + +Студент должен уметь: + +- создать Iceberg namespace и таблицу через Spark SQL; +- загрузить raw-данные в bronze-таблицу; +- объяснить разницу между чтением raw-файлов и чтением управляемой таблицы; +- показать snapshots, history и data files через metadata-таблицы Iceberg; +- показать физическую структуру таблицы в MinIO (через UI или код); +- объяснить, зачем нужен bronze-слой и чем он отличается от raw. + +## Acceptance Criteria + +- `notebooks/04_bronze_with_iceberg.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении после прохождения Модуля 3 (raw-данные в MinIO). +- Ноутбук следует методике курса: `объяснение → демонстрация → самостоятельное повторение → checkpoint`. +- Ноутбук использует паттерны из Модулей 1-3: SparkSession без `.master()`, `setLogLevel("ERROR")`, `boto3` для MinIO. +- Ноутбук идемпотентен: повторное выполнение (`CREATE OR REPLACE`) не дублирует данные и не роняет ячейки. +- CTAS использует явный список колонок, а не `SELECT *`. +- Bronze-таблица `lakehouse.bronze.nyc_taxi_yellow` остаётся после выполнения ноутбука для использования в Модуле 5. +- При отсутствии raw-данных — понятный assert с отсылкой к Модулю 3. +- Текст ноутбука на русском языке с параллелями к PostgreSQL/Greenplum. +- `plans/README.md` содержит строку Модуля 4 в оглавлении. + +## Риски + +- **Время CTAS.** ~7-10 млн строк за 1-3 мин на стенде (2 workers × 2 GB). Extended (12 мес) — до 5-10 мин. Добавить markdown-предупреждение перед ячейкой загрузки. +- **Схема raw PARQUET.** Фиксируем колонки NYC Taxi 2024 в DDL. Если студент скачал другой год — DDL может не совпасть. Пояснение: придерживаться инструкции из `START_HERE.md`. +- **Column case sensitivity.** NYC Taxi PARQUET использует mixed case (`VendorID`, `PULocationID`). Iceberg хранит имена case-sensitive. DDL должен совпадать с именами в PARQUET. Проверить на стенде при реализации. +- **Мелкие файлы.** Spark может создать много мелких data files (по числу partitions). Нормально для Module 4 — тема compaction отложена до Модуля 8. Упомянуть, не решать. +- **Идемпотентность.** Используем `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) для повторных запусков — пересоздаёт таблицу целиком, snapshot history остаётся чистой. Это проще и надёжнее, чем `DELETE FROM` + `INSERT INTO` (который усложняет snapshot history и требует проверки поддержки `DELETE` на текущем стеке). Компромисс: `CREATE OR REPLACE` уничтожает предыдущие snapshots, но для учебного bronze это допустимо. В markdown явно объяснить этот trade-off. + +## Out of Scope + +- Трансформации и очистка данных — отложено до Модуля 5. +- Чтение bronze через Trino — отложено до Модуля 6. +- Time travel и schema evolution — отложено до Модуля 7. +- Compaction и vacuum — отложено до Модуля 8. +- Партиционирование bronze-таблицы (PRD: вне v1). +- Gold-слой и бизнес-витрины. From 6d1b6e9f6129561ec8e90a97c8e6d5ceee0a5e51 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 18:51:45 +0300 Subject: [PATCH 13/24] =?UTF-8?q?feat(course):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C?= =?UTF-8?q?=203=20=D0=BF=D1=80=D0=BE=20raw=20ingest=20=D0=B8=20=D1=87?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=20Spark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужен следующий практический шаг после модулей 1-2: доставить raw-данные в MinIO и впервые прочитать их через Spark. - Что: - добавлен ноутбук модуля 3 с raw ingest, first read, проверкой схемы, null-анализом и поиском аномалий. - обновлены START_HERE, stack reference и docker-compose для data bundle в ./data и mount в /opt/data. - добавлены правила игнорирования data bundle, .gitkeep для пустой директории и обновлён статус плана модуля. - Проверка: - docker compose config. - выполнение notebooks/03_raw_ingest_and_first_read.ipynb на локальном data bundle. --- .gitignore | 6 + START_HERE.md | 45 +- data/nyc_taxi/.gitkeep | 0 docker-compose.yml | 1 + docs/stack_reference.md | 3 + notebooks/03_raw_ingest_and_first_read.ipynb | 685 +++++++++++++++++++ plans/README.md | 1 + plans/module-03-raw-ingest-and-first-read.md | 2 +- 8 files changed, 738 insertions(+), 5 deletions(-) create mode 100644 data/nyc_taxi/.gitkeep create mode 100644 notebooks/03_raw_ingest_and_first_read.ipynb diff --git a/.gitignore b/.gitignore index b3ea1eb..88e0a28 100755 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ venv/ # Logs *.log + +# Dataset (downloaded by student, not stored in repo) +/data/* +!/data/nyc_taxi/ +/data/nyc_taxi/* +!/data/nyc_taxi/.gitkeep diff --git a/START_HERE.md b/START_HERE.md index efebd2d..ddb61d6 100644 --- a/START_HERE.md +++ b/START_HERE.md @@ -1,6 +1,6 @@ # START HERE -Этот документ нужен для первого входа в курс и прохождения Модуля 1: поднять стенд, проверить сервисы, открыть интерфейсы и выполнить базовый smoke test. +Этот документ нужен для первого входа в курс: поднять стенд, проверить сервисы, открыть интерфейсы, выполнить базовый smoke test и подготовить локальный data bundle для следующих модулей. ## Маршрут прохождения @@ -9,7 +9,8 @@ 3. Убедиться, что контейнеры живы. 4. Открыть основные UI. 5. Зайти в Jupyter и выполнить `notebooks/01_environment_and_smoke_test.ipynb`. -6. При проблемах использовать логи и шаги диагностики из этого документа. +6. Перед Модулем 3 подготовить учебный датасет в `./data/nyc_taxi`. +7. При проблемах использовать логи и шаги диагностики из этого документа. ## Prerequisites @@ -100,8 +101,10 @@ notebooks/01_environment_and_smoke_test.ipynb - `./notebooks` смонтирован в контейнер как `/opt/work`; - `./src` смонтирован read-only как `/opt/src`; +- `./data` смонтирован read-only как `/opt/data`; - `PYTHONPATH=/opt/src`, поэтому helper-скрипты из `src/` доступны для импорта в ноутбуках; -- первый ноутбук курса: `01_environment_and_smoke_test.ipynb`. +- первый ноутбук курса: `01_environment_and_smoke_test.ipynb`; +- локальный учебный data bundle для Модуля 3 должен быть доступен внутри Jupyter по пути `/opt/data/nyc_taxi`. ## Базовая диагностика @@ -146,8 +149,42 @@ docker compose up -d Используй полный reset, если хочешь пройти практику заново с чистого состояния. +## Подготовка учебного датасета (перед Модулем 3) + +Перед `notebooks/03_raw_ingest_and_first_read.ipynb` нужно заранее скачать учебный data bundle на хост, а не изнутри ноутбука. + +Скачай каноническое учебное подмножество `NYC TLC Yellow Taxi Trip Records`: + +```bash +curl -fLo data/nyc_taxi/yellow_tripdata_2024-01.parquet \ + https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-01.parquet +curl -fLo data/nyc_taxi/yellow_tripdata_2024-02.parquet \ + https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-02.parquet +curl -fLo data/nyc_taxi/yellow_tripdata_2024-03.parquet \ + https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-03.parquet +curl -fLo data/nyc_taxi/taxi_zone_lookup.csv \ + https://d37ci6vzurychx.cloudfront.net/misc/taxi_zone_lookup.csv +``` + +Проверь, что файлы на месте: + +```bash +ls -lh data/nyc_taxi/ +``` + +Минимальный набор для курса: + +- `yellow_tripdata_2024-01.parquet` +- `yellow_tripdata_2024-02.parquet` +- `yellow_tripdata_2024-03.parquet` +- `taxi_zone_lookup.csv` + +Если хочешь расширенный режим, можешь скачать все 12 месяцев `2024`, но основной маршрут курса и примеры опираются на первые 3 месяца. + ## Что делать дальше - пройти `notebooks/01_environment_and_smoke_test.ipynb`; +- пройти `notebooks/02_lakehouse_mental_model.ipynb`; +- подготовить data bundle по инструкции выше и пройти `notebooks/03_raw_ingest_and_first_read.ipynb`; - свериться с программой курса в `docs/course_program.md`; -- после прохождения Модуля 1 переходить к следующим учебным материалам. +- после прохождения первых модулей переходить к следующим учебным материалам. diff --git a/data/nyc_taxi/.gitkeep b/data/nyc_taxi/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index 39451c7..48596ab 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -181,6 +181,7 @@ services: - ./spark/spark-defaults.conf:/opt/spark/conf/spark-defaults.conf:ro - ./notebooks:/opt/work - ./src:/opt/src:ro + - ./data:/opt/data:ro trino: image: trinodb/trino:478 diff --git a/docs/stack_reference.md b/docs/stack_reference.md index 1c078b6..b5c5154 100644 --- a/docs/stack_reference.md +++ b/docs/stack_reference.md @@ -110,10 +110,13 @@ docker compose run --rm iceberg-catalog-init - адрес: `http://localhost:8888` - `./notebooks` смонтирован как `/opt/work` - `./src` смонтирован read-only как `/opt/src` +- `./data` смонтирован read-only как `/opt/data` - `PYTHONPATH=/opt/src` Первый ноутбук курса: `notebooks/01_environment_and_smoke_test.ipynb`. +Для Модуля 3 локально скачанный data bundle должен лежать на хосте в `./data/nyc_taxi` и будет доступен внутри Jupyter по пути `/opt/data/nyc_taxi`. + ## Как связаны Spark, Trino, PostgreSQL и MinIO - `Spark` использует каталог `lakehouse` через `JdbcCatalog`. diff --git a/notebooks/03_raw_ingest_and_first_read.ipynb b/notebooks/03_raw_ingest_and_first_read.ipynb new file mode 100644 index 0000000..e867b9c --- /dev/null +++ b/notebooks/03_raw_ingest_and_first_read.ipynb @@ -0,0 +1,685 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "471a32f6", + "metadata": {}, + "source": [ + "# Модуль 3. Raw-данные и первое чтение в Spark\n", + "\n", + "В этом модуле мы разбираем полный первый цикл работы с учебным датасетом в Lakehouse:\n", + "\n", + "- проверяем заранее скачанный локальный data bundle;\n", + "- загружаем исходные файлы в raw-зону `MinIO`;\n", + "- читаем raw-данные через `Spark` по `s3a://` пути;\n", + "- смотрим схему, типы, базовые метрики и простые аномалии;\n", + "- фиксируем, почему raw-слой нужен как воспроизводимая неизменяемая точка входа.\n", + "\n", + "После этого ноутбука ты должен уметь объяснить, почему raw-данные не надо «чинить на месте» и почему их не стоит скачивать прямо из ноутбука.\n" + ] + }, + { + "cell_type": "markdown", + "id": "723dba8f", + "metadata": {}, + "source": [ + "## 0. Перед стартом\n", + "\n", + "Перед выполнением ноутбука:\n", + "\n", + "- подними стенд по `START_HERE.md`;\n", + "- пройди `notebooks/01_environment_and_smoke_test.ipynb`;\n", + "- желательно пройти `notebooks/02_lakehouse_mental_model.ipynb`;\n", + "- заранее скачай data bundle в `./data/nyc_taxi` по инструкции из `START_HERE.md`.\n", + "\n", + "В классическом `PostgreSQL / Greenplum` данные часто уже лежат внутри системы. В Lakehouse путь более явный: сначала исходные файлы должны попасть в object storage, и только потом мы начинаем поверх них читать, профилировать и строить таблицы.\n", + "\n", + "| | Классический DWH | Lakehouse |\n", + "| --- | --- | --- |\n", + "| Где стартует работа | Данные уже внутри СУБД | Исходные файлы сначала попадают в storage |\n", + "| Первая практическая задача | `SELECT` из готовой таблицы | Загрузить raw и прочитать raw |\n", + "| Что важно не сломать | Таблицу / схему БД | Неизменяемый raw-слой |\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a5c56637", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import re\n", + "from datetime import datetime\n", + "\n", + "import boto3\n", + "import pandas as pd\n", + "from botocore.exceptions import ClientError\n", + "from pyspark.sql import SparkSession, functions as F\n", + "\n", + "DATA_DIR = Path(\"/opt/data/nyc_taxi\")\n", + "BUCKET_NAME = \"lakehouse\"\n", + "RAW_PREFIX = \"raw/nyc_taxi/\"\n", + "RAW_URI = f\"s3a://{BUCKET_NAME}/{RAW_PREFIX}\"\n", + "ZONE_LOOKUP_RAW_URI = f\"{RAW_URI}taxi_zone_lookup.csv\"\n", + "\n", + "spark = SparkSession.builder .appName(\"module-03-raw-ingest-and-first-read\") .getOrCreate()\n", + "\n", + "spark.sparkContext.setLogLevel(\"ERROR\")\n", + "\n", + "s3 = boto3.client(\n", + " \"s3\",\n", + " endpoint_url=\"http://minio:9000\",\n", + " aws_access_key_id=\"minioadmin\",\n", + " aws_secret_access_key=\"minioadmin\",\n", + ")\n", + "\n", + "\n", + "def format_bytes(num_bytes: int) -> str:\n", + " units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"]\n", + " value = float(num_bytes)\n", + " for unit in units:\n", + " if value < 1024 or unit == units[-1]:\n", + " return f\"{value:.1f} {unit}\"\n", + " value /= 1024\n", + "\n", + "\n", + "def object_exists(bucket: str, key: str) -> bool:\n", + " try:\n", + " s3.head_object(Bucket=bucket, Key=key)\n", + " return True\n", + " except ClientError as exc:\n", + " error_code = exc.response.get(\"Error\", {}).get(\"Code\", \"\")\n", + " if error_code in {\"404\", \"NoSuchKey\", \"NotFound\"}:\n", + " return False\n", + " raise\n", + "\n", + "\n", + "def list_objects(bucket: str, prefix: str) -> list[dict]:\n", + " paginator = s3.get_paginator(\"list_objects_v2\")\n", + " objects = []\n", + " for page in paginator.paginate(Bucket=bucket, Prefix=prefix):\n", + " objects.extend(page.get(\"Contents\", []))\n", + " return objects\n", + "\n", + "\n", + "def infer_month_bounds(paths: list[Path]) -> tuple[datetime, datetime]:\n", + " months = []\n", + " for path in paths:\n", + " match = re.fullmatch(r\"yellow_tripdata_(\\d{4})-(\\d{2})\\.parquet\", path.name)\n", + " if match:\n", + " year = int(match.group(1))\n", + " month = int(match.group(2))\n", + " months.append((year, month))\n", + "\n", + " assert months, \"Не удалось определить месяцы из имён yellow_tripdata_*.parquet\"\n", + "\n", + " start_year, start_month = min(months)\n", + " end_year, end_month = max(months)\n", + "\n", + " expected_start = datetime(start_year, start_month, 1)\n", + " if end_month == 12:\n", + " expected_end = datetime(end_year + 1, 1, 1)\n", + " else:\n", + " expected_end = datetime(end_year, end_month + 1, 1)\n", + "\n", + " return expected_start, expected_end\n", + "\n", + "\n", + "print(\"Spark и MinIO-клиент готовы\")\n", + "print(f\"RAW URI: {RAW_URI}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "d4d8f302", + "metadata": {}, + "source": [ + "## 1. Проверка data bundle\n", + "\n", + "Сначала убеждаемся, что нужные файлы уже лежат внутри Jupyter-контейнера по пути `/opt/data/nyc_taxi`.\n", + "\n", + "Почему не скачиваем данные прямо здесь:\n", + "\n", + "- так модуль остаётся воспроизводимым и не зависит от сетевого доступа внутри ноутбука;\n", + "- onboarding и подготовка окружения остаются в `START_HERE.md`, а практика в ноутбуке занимается только Lakehouse-операциями;\n", + "- студент видит границу ответственности: сначала подготовить bundle, потом работать с raw-слоем.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "da2f7b51", + "metadata": {}, + "outputs": [], + "source": [ + "assert DATA_DIR.exists(), (\n", + " \"Каталог /opt/data/nyc_taxi не найден. \"\n", + " \"Сначала скачай data bundle по инструкции из START_HERE.md и убедись, что ./data смонтирован в Jupyter как /opt/data.\"\n", + ")\n", + "\n", + "bundle_files = sorted(path for path in DATA_DIR.iterdir() if path.is_file())\n", + "yellow_files = sorted(DATA_DIR.glob(\"yellow_tripdata_*.parquet\"))\n", + "zone_lookup_file = DATA_DIR / \"taxi_zone_lookup.csv\"\n", + "\n", + "assert yellow_files, (\n", + " \"Не найдены файлы yellow_tripdata_*.parquet в /opt/data/nyc_taxi. \"\n", + " \"Скачай каноническое подмножество из START_HERE.md.\"\n", + ")\n", + "assert zone_lookup_file.exists(), (\n", + " \"Не найден taxi_zone_lookup.csv. Он нужен и для этого модуля, и для самостоятельного задания. \"\n", + " \"Скачай его по инструкции из START_HERE.md.\"\n", + ")\n", + "\n", + "bundle_report = pd.DataFrame(\n", + " [\n", + " {\n", + " \"file\": path.name,\n", + " \"size_bytes\": path.stat().st_size,\n", + " \"size_human\": format_bytes(path.stat().st_size),\n", + " }\n", + " for path in bundle_files\n", + " ]\n", + ").sort_values(\"file\").reset_index(drop=True)\n", + "\n", + "bundle_report\n" + ] + }, + { + "cell_type": "markdown", + "id": "c8defd34", + "metadata": {}, + "source": [ + "Набор файлов должен быть понятным и фиксированным. В этом модуле мы работаем не с «каким-то случайным parquet из интернета», а с заранее определённым учебным набором.\n" + ] + }, + { + "cell_type": "markdown", + "id": "dabd90f2", + "metadata": {}, + "source": [ + "## 2. Загрузка в raw-зону MinIO\n", + "\n", + "Raw-слой в Lakehouse похож на воспроизводимый входной `stg`-контур: мы сохраняем исходные файлы как есть, под оригинальными именами, не переписывая их содержимое.\n", + "\n", + "Конвенция пути в этом курсе:\n", + "\n", + "- bucket: `lakehouse`\n", + "- raw prefix: `raw/nyc_taxi/`\n", + "- полный путь для чтения: `s3a://lakehouse/raw/nyc_taxi/`\n", + "\n", + "Важно: raw-зона не смешивается с `warehouse/`, где потом будут жить Iceberg-таблицы.\n", + "\n", + "Повторный запуск этой секции должен быть безопасным:\n", + "\n", + "- если объекта ещё нет в raw-зоне, мы его загружаем;\n", + "- если объект уже есть и размер совпадает, мы пропускаем загрузку;\n", + "- если объект уже есть, но размер отличается, это сигнал проблемы с данными или bundle, и мы падаем сразу, без перезаписи raw.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8644cc94", + "metadata": {}, + "outputs": [], + "source": [ + "upload_candidates = [*yellow_files, zone_lookup_file]\n", + "upload_report = []\n", + "\n", + "for path in upload_candidates:\n", + " object_key = f\"{RAW_PREFIX}{path.name}\"\n", + " local_size = path.stat().st_size\n", + " existed_before = object_exists(BUCKET_NAME, object_key)\n", + "\n", + " if existed_before:\n", + " metadata = s3.head_object(Bucket=BUCKET_NAME, Key=object_key)\n", + " remote_size = metadata[\"ContentLength\"]\n", + " if remote_size != local_size:\n", + " raise ValueError(\n", + " \"Raw object already exists with different size: \"\n", + " f\"{object_key} (local={local_size}, remote={remote_size}). \"\n", + " \"Остановись и проверь data bundle вместо перезаписи raw-слоя.\"\n", + " )\n", + " action = \"skipped_same_size\"\n", + " else:\n", + " s3.upload_file(str(path), BUCKET_NAME, object_key)\n", + " metadata = s3.head_object(Bucket=BUCKET_NAME, Key=object_key)\n", + " remote_size = metadata[\"ContentLength\"]\n", + " action = \"uploaded\"\n", + "\n", + " upload_report.append(\n", + " {\n", + " \"file\": path.name,\n", + " \"raw_key\": object_key,\n", + " \"action\": action,\n", + " \"local_size_human\": format_bytes(local_size),\n", + " \"remote_size_human\": format_bytes(remote_size),\n", + " }\n", + " )\n", + "\n", + "pd.DataFrame(upload_report)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11f4c459", + "metadata": {}, + "outputs": [], + "source": [ + "raw_objects = list_objects(BUCKET_NAME, RAW_PREFIX)\n", + "\n", + "pd.DataFrame(\n", + " [\n", + " {\n", + " \"key\": obj[\"Key\"],\n", + " \"size_human\": format_bytes(obj[\"Size\"]),\n", + " }\n", + " for obj in raw_objects\n", + " ]\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "48295c73", + "metadata": {}, + "source": [ + "Если объект уже существовал, мы не перезаписываем его без необходимости. Совпадающий размер означает, что повторный запуск секции идемпотентен. Несовпадающий размер считается конфликтом и останавливает сценарий до любых изменений в raw.\n", + "\n", + "На этом шаге данные уже лежат в storage. `Spark` может читать их напрямую по `s3a://` пути, а для `Trino` понадобится зарегистрированная таблица. До неё мы дойдём в Модулях 4 и 6.\n" + ] + }, + { + "cell_type": "markdown", + "id": "696d6adc", + "metadata": {}, + "source": [ + "## 3. Первое чтение raw PARQUET через Spark\n", + "\n", + "Теперь читаем raw-файлы напрямую из `MinIO` по `s3a://` пути. Это и есть первая важная практика Lakehouse: вычислитель (`Spark`) работает поверх файлов в object storage, а не только поверх локальной файловой системы или таблиц внутри одной СУБД.\n", + "\n", + "Здесь важно различать два режима работы:\n", + "\n", + "- `spark.read.parquet(\"s3a://...\")` читает raw-файлы напрямую по физическому пути;\n", + "- `spark.table(\"lakehouse....\")` читает уже зарегистрированную управляемую таблицу через каталог.\n", + "\n", + "Сейчас мы ещё не создавали `Iceberg`-таблицу, поэтому работаем именно через `spark.read.parquet(...)`. К `spark.table(...)` вернёмся в Модуле 4.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0e8823aa", + "metadata": {}, + "outputs": [], + "source": [ + "single_file_uri = f\"{RAW_URI}yellow_tripdata_2024-01.parquet\"\n", + "single_month_df = spark.read.parquet(single_file_uri)\n", + "\n", + "print(f\"Читаем один raw-файл: {single_file_uri}\")\n", + "print(f\"Строк в одном файле: {single_month_df.count():,}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fd5ad623", + "metadata": {}, + "outputs": [], + "source": [ + "yellow_df = spark.read.parquet(f\"{RAW_URI}yellow_tripdata_*.parquet\")\n", + "row_count = yellow_df.count()\n", + "\n", + "print(f\"Прочитано строк: {row_count:,}\")\n", + "print(f\"Количество parquet-файлов в наборе: {len(yellow_files)}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e147271", + "metadata": {}, + "outputs": [], + "source": [ + "yellow_df.show(10, truncate=False)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "efa037e6", + "metadata": {}, + "outputs": [], + "source": [ + "input_files = sorted(yellow_df.inputFiles())\n", + "pd.DataFrame({\"input_file\": input_files})\n" + ] + }, + { + "cell_type": "markdown", + "id": "e8866f38", + "metadata": {}, + "source": [ + "Обрати внимание: мы читаем сразу несколько файлов wildcard-путём. Это обычный рабочий сценарий для raw-слоя, где данные часто приходят партиями по датам, дням или месяцам.\n", + "\n", + "В этом модуле мы намеренно не используем `cache()` для всего raw DataFrame. Для учебного профилирования важнее устойчиво прочитать исходные файлы, чем пытаться держать весь raw-срез в памяти executor-ов.\n" + ] + }, + { + "cell_type": "markdown", + "id": "9701728a", + "metadata": {}, + "source": [ + "## 4. Схема, типы и null-значения\n", + "\n", + "Следующий шаг после первого чтения: понять, какие колонки пришли, какие у них типы и где уже на raw-слое встречаются `null`.\n", + "\n", + "Здесь важно не перепутать диагностику и очистку. В raw-слое мы наблюдаем и фиксируем реальность данных, а не исправляем её.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e2809a8b", + "metadata": {}, + "outputs": [], + "source": [ + "yellow_df.printSchema()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "126e5601", + "metadata": {}, + "outputs": [], + "source": [ + "schema_df = pd.DataFrame(yellow_df.dtypes, columns=[\"column\", \"spark_type\"])\n", + "print(f\"Количество колонок: {len(yellow_df.columns)}\")\n", + "schema_df\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c6093e2a", + "metadata": {}, + "outputs": [], + "source": [ + "null_counts_row = yellow_df.select(\n", + " *[F.sum(F.col(column).isNull().cast(\"int\")).alias(column) for column in yellow_df.columns]\n", + ").toPandas().iloc[0]\n", + "\n", + "null_report = pd.DataFrame(\n", + " {\n", + " \"column\": yellow_df.columns,\n", + " \"null_count\": [int(null_counts_row[column]) for column in yellow_df.columns],\n", + " }\n", + ")\n", + "null_report[\"null_share\"] = (null_report[\"null_count\"] / row_count).round(6)\n", + "null_report.sort_values([\"null_count\", \"column\"], ascending=[False, True]).reset_index(drop=True)\n" + ] + }, + { + "cell_type": "markdown", + "id": "567156e6", + "metadata": {}, + "source": [ + "`Null` в raw-данных сам по себе не является багом загрузки. Это нормальная часть профиля источника. Позже, на `bronze` и `silver`, мы будем принимать явные решения: какие поля очищать, что отбрасывать, а что нормализовать.\n" + ] + }, + { + "cell_type": "markdown", + "id": "69e6b77e", + "metadata": {}, + "source": [ + "## 5. Базовые метрики и аномалии\n", + "\n", + "Теперь быстро профилируем данные и ищем несколько очевидных проблемных паттернов:\n", + "\n", + "- отрицательный `fare_amount`;\n", + "- нулевая дистанция при ненулевой сумме;\n", + "- слишком большой `total_amount`;\n", + "- `pickup` вне ожидаемого диапазона дат.\n", + "\n", + "Диапазон дат будем выводить из имён файлов, а не хардкодить руками.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3a4ed596", + "metadata": {}, + "outputs": [], + "source": [ + "numeric_columns = [\n", + " column\n", + " for column, spark_type in yellow_df.dtypes\n", + " if spark_type.startswith((\"tinyint\", \"smallint\", \"int\", \"bigint\", \"float\", \"double\", \"decimal\"))\n", + "]\n", + "\n", + "yellow_df.select(*numeric_columns).describe().toPandas()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cc029c98", + "metadata": {}, + "outputs": [], + "source": [ + "required_columns = {\n", + " \"fare_amount\",\n", + " \"trip_distance\",\n", + " \"total_amount\",\n", + " \"tpep_pickup_datetime\",\n", + " \"PULocationID\",\n", + " \"DOLocationID\",\n", + "}\n", + "missing_columns = sorted(required_columns.difference(yellow_df.columns))\n", + "assert not missing_columns, (\n", + " \"В raw-схеме не хватает ожидаемых колонок: \"\n", + " f\"{missing_columns}. Возможно, источник поменял формат, и модуль требует обновления.\"\n", + ")\n", + "\n", + "expected_start, expected_end = infer_month_bounds(yellow_files)\n", + "\n", + "anomaly_report = pd.DataFrame(\n", + " [\n", + " {\n", + " \"check\": \"fare_amount < 0\",\n", + " \"count\": yellow_df.filter(F.col(\"fare_amount\") < 0).count(),\n", + " },\n", + " {\n", + " \"check\": \"trip_distance = 0 and total_amount != 0\",\n", + " \"count\": yellow_df.filter(\n", + " (F.col(\"trip_distance\") == 0) & (F.col(\"total_amount\") != 0)\n", + " ).count(),\n", + " },\n", + " {\n", + " \"check\": \"total_amount > 1000\",\n", + " \"count\": yellow_df.filter(F.col(\"total_amount\") > 1000).count(),\n", + " },\n", + " {\n", + " \"check\": f\"pickup outside [{expected_start.date()}, {expected_end.date()})\",\n", + " \"count\": yellow_df.filter(\n", + " (F.col(\"tpep_pickup_datetime\") < F.lit(expected_start))\n", + " | (F.col(\"tpep_pickup_datetime\") >= F.lit(expected_end))\n", + " ).count(),\n", + " },\n", + " ]\n", + ")\n", + "\n", + "anomaly_report\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0cc5aaf7", + "metadata": {}, + "outputs": [], + "source": [ + "suspicious_rows = yellow_df.filter(\n", + " (F.col(\"fare_amount\") < 0)\n", + " | ((F.col(\"trip_distance\") == 0) & (F.col(\"total_amount\") != 0))\n", + " | (F.col(\"total_amount\") > 1000)\n", + " | (F.col(\"tpep_pickup_datetime\") < F.lit(expected_start))\n", + " | (F.col(\"tpep_pickup_datetime\") >= F.lit(expected_end))\n", + ")\n", + "\n", + "suspicious_rows.select(\n", + " \"tpep_pickup_datetime\",\n", + " \"passenger_count\",\n", + " \"trip_distance\",\n", + " \"fare_amount\",\n", + " \"total_amount\",\n", + " \"PULocationID\",\n", + " \"DOLocationID\",\n", + ").show(20, truncate=False)\n" + ] + }, + { + "cell_type": "markdown", + "id": "9ac27204", + "metadata": {}, + "source": [ + "Реальные данные почти всегда содержат выбросы, странные записи и неоднозначные значения. Это не повод немедленно переписывать raw-файлы. Наоборот, raw нужен именно затем, чтобы исходный срез можно было перечитать и переработать заново с новыми правилами.\n" + ] + }, + { + "cell_type": "markdown", + "id": "095c9ac6", + "metadata": {}, + "source": [ + "## 6. Самостоятельное задание\n", + "\n", + "Используй raw-данные, которые уже лежат в `MinIO`:\n", + "\n", + "1. Прочитай `taxi_zone_lookup.csv` из raw-зоны через `Spark`.\n", + "2. Выведи схему и первые 10 строк.\n", + "3. Посчитай количество зон по `Borough`.\n", + "4. Найди `top-5` `pickup`-локаций в поездках и присоедини к ним названия зон.\n", + "5. Найди ещё одну аномалию или интересный паттерн и коротко опиши его.\n", + "\n", + "Подсказка: CSV-файл лежит по пути `s3a://lakehouse/raw/nyc_taxi/taxi_zone_lookup.csv`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fe530d7d", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: прочитай taxi_zone_lookup.csv из raw-зоны через Spark\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "99fb5256", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: выведи схему и первые 10 строк\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "219a40fc", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: посчитай количество зон по Borough и top-5 pickup locations с join на lookup\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5c944f60", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: найди ещё одну аномалию или интересный паттерн в данных\n" + ] + }, + { + "cell_type": "markdown", + "id": "e23dfe47", + "metadata": {}, + "source": [ + "## 7. Почему raw-слой важен\n", + "\n", + "Raw-слой полезен не потому, что в нём удобно жить постоянно, а потому что он даёт устойчивую исходную точку. Если завтра поменяются правила очистки, дедупликации или нормализации, мы не обязаны заново скачивать источник и гадать, что именно уже успели «подправить». Мы просто перечитываем raw и перестраиваем downstream-слои.\n", + "\n", + "Для инженера с опытом `PostgreSQL / Greenplum` полезна такая аналогия: raw похож на аккуратный внешний `staging`-контур, но в Lakehouse он обычно хранится в object storage как набор файлов, а не как таблица внутри одной СУБД.\n", + "\n", + "Отдельно важно, что загрузка источника вынесена из ноутбука в onboarding. Это делает практику менее хрупкой: ноутбук не зависит от внешней сети, а набор данных остаётся одинаковым у всех студентов.\n", + "\n", + "Наконец, raw не стоит чинить «на месте». Если в исходных данных есть `null`, отрицательные значения или странные даты, это сигнал для явных трансформаций в `bronze` и `silver`, а не повод тихо переписать входные файлы.\n" + ] + }, + { + "cell_type": "markdown", + "id": "8849c73f", + "metadata": {}, + "source": [ + "## 8. Checkpoint\n", + "\n", + "Проверь себя:\n", + "\n", + "1. Можешь ли ты показать файлы raw-зоны в `MinIO` через код или `MinIO Console`?\n", + "2. Сколько колонок в схеме `Yellow Taxi` и какие типы там встречаются?\n", + "3. Какие колонки содержат `null` и почему это не обязательно баг загрузки?\n", + "4. Зачем нужен raw-слой, если дальше всё равно будут трансформации?\n", + "5. Почему мы не скачиваем датасет прямо из ноутбука?\n", + "6. Что произойдёт, если источник завтра поменяет формат файлов?\n" + ] + }, + { + "cell_type": "markdown", + "id": "371c4c9d", + "metadata": {}, + "source": [ + "## 9. Завершение\n", + "\n", + "Мы намеренно не удаляем raw-данные из `MinIO`. В Модуле 4 они понадобятся для построения первого управляемого `bronze`-слоя на `Iceberg`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8574e96b", + "metadata": {}, + "outputs": [], + "source": [ + "spark.stop()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/plans/README.md b/plans/README.md index a843ae2..41e26b5 100644 --- a/plans/README.md +++ b/plans/README.md @@ -15,3 +15,4 @@ | --- | --- | --- | --- | --- | | 1. Вход в стенд и базовая диагностика | [module-01-environment-and-basic-diagnostics.md](./module-01-environment-and-basic-diagnostics.md) | Ready for validation | `START_HERE.md`, `notebooks/01_environment_and_smoke_test.ipynb`, `src/spark/cluster_smoke.py` | 2026-03-06 | | 2. Ментальная модель Lakehouse: storage, catalog, compute | [module-02-lakehouse-mental-model.md](./module-02-lakehouse-mental-model.md) | Ready for validation | `notebooks/02_lakehouse_mental_model.ipynb` | 2026-03-07 | +| 3. Raw-данные и первое чтение в Spark | [module-03-raw-ingest-and-first-read.md](./module-03-raw-ingest-and-first-read.md) | Ready for validation | `docker-compose.yml`, `.gitignore`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/03_raw_ingest_and_first_read.ipynb` | 2026-03-07 | diff --git a/plans/module-03-raw-ingest-and-first-read.md b/plans/module-03-raw-ingest-and-first-read.md index ad317f2..1442f8c 100644 --- a/plans/module-03-raw-ingest-and-first-read.md +++ b/plans/module-03-raw-ingest-and-first-read.md @@ -1,6 +1,6 @@ # Модуль 3. Raw-данные и первое чтение в Spark -**Статус:** `Draft` +**Статус:** `Ready for validation` **Последнее обновление:** `2026-03-07` ## Цель From 6567a44b74f6f99a60e725b70e92cf7d6df74432 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 19:22:04 +0300 Subject: [PATCH 14/24] =?UTF-8?q?feat(course):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BD=D0=BE=D1=83=D1=82=D0=B1=D1=83?= =?UTF-8?q?=D0=BA=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=20bronze=20=D1=81?= =?UTF-8?q?=20Iceberg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужен практический модуль 4, который переводит студента от raw-файлов к первой управляемой Iceberg-таблице. - Что: - добавлен ноутбук notebooks/04_bronze_with_iceberg.ipynb с CTAS в bronze, разбором metadata tables и структурой хранения в MinIO. - добавлены пояснения про namespace, сравнение raw vs Iceberg и разбор snapshot-полей по итогам ревью. - обновлен plans/README.md строкой для модуля 4. - Проверка: - синтаксис всех code-ячеек ноутбука проверен локальной компиляцией. - тесты пройдены пользователем. --- notebooks/04_bronze_with_iceberg.ipynb | 692 +++++++++++++++++++++++++ plans/README.md | 1 + 2 files changed, 693 insertions(+) create mode 100644 notebooks/04_bronze_with_iceberg.ipynb diff --git a/notebooks/04_bronze_with_iceberg.ipynb b/notebooks/04_bronze_with_iceberg.ipynb new file mode 100644 index 0000000..97291f2 --- /dev/null +++ b/notebooks/04_bronze_with_iceberg.ipynb @@ -0,0 +1,692 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2a1c9579", + "metadata": {}, + "source": [ + "# Модуль 4. Первая рабочая Iceberg-таблица и слой bronze\n", + "\n", + "В этом модуле мы переходим от raw-файлов в `MinIO` к первой управляемой таблице `Iceberg`.\n", + "\n", + "Что делаем на практике:\n", + "\n", + "- проверяем, что raw-данные из Модуля 3 доступны в `MinIO`;\n", + "- создаём namespace `lakehouse.bronze`;\n", + "- загружаем raw `Yellow Taxi` в `Iceberg`-таблицу через `CTAS`;\n", + "- сравниваем чтение по физическому пути и чтение через каталог;\n", + "- смотрим `snapshots`, `history`, `files` и физическую структуру таблицы в `MinIO`.\n", + "\n", + "После этого ноутбука ты должен понимать, почему `bronze`-слой это уже не «просто папка с parquet», а управляемая таблица с каталогом и metadata.\n" + ] + }, + { + "cell_type": "markdown", + "id": "e13e7b2b", + "metadata": {}, + "source": "## 0. Перед стартом\n\nПеред выполнением ноутбука:\n\n- подними стенд по `START_HERE.md`;\n- пройди `notebooks/03_raw_ingest_and_first_read.ipynb`;\n- убедись, что raw-файлы уже лежат в `s3a://lakehouse/raw/nyc_taxi/`.\n\nСвязка с привычным `PostgreSQL / Greenplum`:\n\n| | Классический DWH (`Greenplum` / `PostgreSQL`) | Lakehouse |\n| --- | --- | --- |\n| Raw-данные | Загружены в `stg`-таблицу или внешний staging | Файлы в object storage (`raw/`) |\n| Первый управляемый слой | Явный DDL + `INSERT INTO ... SELECT` из `stg` | `CREATE TABLE ... USING iceberg AS SELECT` из raw (CTAS) |\n| Метаданные | `pg_catalog` внутри СУБД | JDBC catalog + metadata files в `MinIO` |\n| История изменений | Нет встроенной истории таблицы | `snapshots`, `manifests`, metadata JSON |\n\nЗдесь важно различать два уровня:\n\n- raw: исходные файлы, которые можно перечитать заново;\n- bronze: первая управляемая таблица, которую удобно читать из `Spark` и позже из `Trino`." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "33cd36c0", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import PurePosixPath\n", + "\n", + "import boto3\n", + "import pandas as pd\n", + "from pyspark.sql import SparkSession, functions as F\n", + "\n", + "BUCKET_NAME = \"lakehouse\"\n", + "RAW_PREFIX = \"raw/nyc_taxi/\"\n", + "RAW_URI = f\"s3a://{BUCKET_NAME}/{RAW_PREFIX}\"\n", + "RAW_YELLOW_URI = f\"{RAW_URI}yellow_tripdata_*.parquet\"\n", + "ZONE_LOOKUP_RAW_URI = f\"{RAW_URI}taxi_zone_lookup.csv\"\n", + "CATALOG_NAME = \"lakehouse\"\n", + "BRONZE_NAMESPACE = f\"{CATALOG_NAME}.bronze\"\n", + "BRONZE_TABLE = f\"{BRONZE_NAMESPACE}.nyc_taxi_yellow\"\n", + "BRONZE_LOOKUP_TABLE = f\"{BRONZE_NAMESPACE}.taxi_zone_lookup\"\n", + "\n", + "spark = SparkSession.builder .appName(\"module-04-bronze-with-iceberg\") .getOrCreate()\n", + "\n", + "spark.sparkContext.setLogLevel(\"ERROR\")\n", + "\n", + "s3 = boto3.client(\n", + " \"s3\",\n", + " endpoint_url=\"http://minio:9000\",\n", + " aws_access_key_id=\"minioadmin\",\n", + " aws_secret_access_key=\"minioadmin\",\n", + ")\n", + "\n", + "\n", + "def format_bytes(num_bytes: int) -> str:\n", + " units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"]\n", + " value = float(num_bytes)\n", + " for unit in units:\n", + " if value < 1024 or unit == units[-1]:\n", + " return f\"{value:.1f} {unit}\"\n", + " value /= 1024\n", + "\n", + "\n", + "def list_objects(bucket: str, prefix: str) -> list[dict]:\n", + " paginator = s3.get_paginator(\"list_objects_v2\")\n", + " objects = []\n", + " for page in paginator.paginate(Bucket=bucket, Prefix=prefix):\n", + " objects.extend(page.get(\"Contents\", []))\n", + " return objects\n", + "\n", + "\n", + "def get_table_location(table_name: str) -> str:\n", + " details = spark.sql(f\"DESCRIBE TABLE EXTENDED {table_name}\").toPandas()\n", + " location_row = details.loc[details[\"col_name\"] == \"Location\", \"data_type\"]\n", + " assert not location_row.empty, f\"Не удалось найти Location для {table_name}\"\n", + " return location_row.iloc[0]\n", + "\n", + "\n", + "print(\"Spark и MinIO-клиент готовы\")\n", + "print(f\"RAW URI: {RAW_URI}\")\n", + "print(f\"Bronze table: {BRONZE_TABLE}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "e195a355", + "metadata": {}, + "source": "## 1. Проверка raw перед загрузкой в bronze\n\nСначала убеждаемся, что raw-данные из Модуля 3 действительно доступны в `MinIO`. Если их нет, строить `bronze` просто не из чего.\n\nКлючевое различие:\n\n- `raw` читается по физическому пути;\n- `bronze` будет читаться уже как логическая таблица через каталог." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9e69d44a", + "metadata": {}, + "outputs": [], + "source": [ + "raw_objects = list_objects(BUCKET_NAME, RAW_PREFIX)\n", + "assert raw_objects, (\n", + " \"Raw-зона пуста. Сначала пройди Модуль 3 и загрузи data bundle в MinIO.\"\n", + ")\n", + "\n", + "raw_report = pd.DataFrame(\n", + " [\n", + " {\n", + " \"key\": obj[\"Key\"],\n", + " \"size_bytes\": obj[\"Size\"],\n", + " \"size_human\": format_bytes(obj[\"Size\"]),\n", + " }\n", + " for obj in raw_objects\n", + " ]\n", + ").sort_values(\"key\").reset_index(drop=True)\n", + "\n", + "yellow_object_count = int(raw_report[\"key\"].str.contains(r\"yellow_tripdata_.*\\.parquet\", regex=True).sum())\n", + "assert yellow_object_count > 0, (\n", + " \"В raw-зоне не найдены yellow_tripdata_*.parquet. Сначала пройди Модуль 3.\"\n", + ")\n", + "assert (raw_report[\"key\"] == f\"{RAW_PREFIX}taxi_zone_lookup.csv\").any(), (\n", + " \"В raw-зоне не найден taxi_zone_lookup.csv. Он нужен для задания этого модуля и join в Модуле 5.\"\n", + ")\n", + "\n", + "print(f\"Всего объектов в raw-зоне: {len(raw_report)}\")\n", + "print(f\"Parquet-файлов Yellow Taxi: {yellow_object_count}\")\n", + "raw_report\n" + ] + }, + { + "cell_type": "markdown", + "id": "f97b8ec8", + "metadata": {}, + "source": "## 2. Чтение raw-файлов через Spark\n\nСейчас мы ещё работаем с raw напрямую: `spark.read.parquet(\"s3a://...\")`.\n\nЭто не таблица `Iceberg`, а просто набор parquet-файлов по физическому пути. Нам нужно посмотреть на схему и подготовить явный список колонок для `CTAS`, чтобы не полагаться на `SELECT *`." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61f18800", + "metadata": {}, + "outputs": [], + "source": [ + "raw_yellow_df = spark.read.parquet(RAW_YELLOW_URI)\n", + "raw_row_count = raw_yellow_df.count()\n", + "\n", + "print(f\"Прочитано raw-строк: {raw_row_count:,}\")\n", + "print(f\"Количество колонок: {len(raw_yellow_df.columns)}\")\n", + "raw_yellow_df.show(10, truncate=False)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a04c71e6", + "metadata": {}, + "outputs": [], + "source": [ + "raw_yellow_df.printSchema()\n", + "\n", + "pd.DataFrame(raw_yellow_df.dtypes, columns=[\"column\", \"spark_type\"])\n" + ] + }, + { + "cell_type": "markdown", + "id": "ce148bf6", + "metadata": {}, + "source": "Ниже список колонок, который мы явно зафиксируем в `CTAS`.\n\nПочему это лучше, чем `SELECT *`:\n\n- порядок и состав колонок становятся явными;\n- проще заметить неожиданные изменения источника;\n- загрузка в `bronze` остаётся сознательным решением, а не непрозрачным автоматизмом." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "305ec274", + "metadata": {}, + "outputs": [], + "source": [ + "expected_columns = [\n", + " \"VendorID\",\n", + " \"tpep_pickup_datetime\",\n", + " \"tpep_dropoff_datetime\",\n", + " \"passenger_count\",\n", + " \"trip_distance\",\n", + " \"RatecodeID\",\n", + " \"store_and_fwd_flag\",\n", + " \"PULocationID\",\n", + " \"DOLocationID\",\n", + " \"payment_type\",\n", + " \"fare_amount\",\n", + " \"extra\",\n", + " \"mta_tax\",\n", + " \"tip_amount\",\n", + " \"tolls_amount\",\n", + " \"improvement_surcharge\",\n", + " \"total_amount\",\n", + " \"congestion_surcharge\",\n", + " \"Airport_fee\",\n", + "]\n", + "\n", + "missing_columns = sorted(set(expected_columns).difference(raw_yellow_df.columns))\n", + "assert not missing_columns, (\n", + " \"В raw-схеме не хватает ожидаемых колонок: \"\n", + " f\"{missing_columns}. Проверь data bundle и инструкцию из START_HERE.md.\"\n", + ")\n", + "\n", + "pd.DataFrame({\n", + " \"column\": expected_columns,\n", + " \"present_in_raw\": [column in raw_yellow_df.columns for column in expected_columns],\n", + "})\n" + ] + }, + { + "cell_type": "markdown", + "id": "350d2087", + "metadata": {}, + "source": "## 3. Создание `bronze` namespace и `Iceberg`-таблицы\n\nСначала создаём namespace `lakehouse.bronze`, а затем выполняем `CTAS` (`CREATE OR REPLACE TABLE ... USING iceberg AS SELECT ...`).\n\nЧто такое namespace в `Iceberg`:\n\n- это логический контейнер для таблиц;\n- если ты работал с `PostgreSQL` — это ближайший аналог `schema` внутри базы;\n- запись `lakehouse.bronze.nyc_taxi_yellow` читается как `catalog.namespace.table`.\n\nПочему здесь используем именно `bronze`, а не `module_04`:\n\n- имя сразу отражает роль слоя в пайплайне `raw -> bronze -> silver`;\n- Модуль 5 будет читать именно `lakehouse.bronze.*`, и семантическое имя делает маршрут прозрачным;\n- в отличие от демонстрационного `module_02`, этот слой не временный и будет использоваться в следующих модулях.\n\nЭто удобный учебный компромисс:\n\n- один шаг создаёт таблицу и сразу наполняет её;\n- повторный запуск секции остаётся идемпотентным;\n- минус: `CREATE OR REPLACE` пересоздаёт таблицу и сбрасывает старую историю snapshot-ов.\n\nДля первого `bronze`-слоя это допустимо. В Модуле 7 мы отдельно разберём, почему в рабочих сценариях нужно аккуратнее относиться к истории таблицы." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a52a03b4", + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(f\"CREATE NAMESPACE IF NOT EXISTS {BRONZE_NAMESPACE}\")\n", + "raw_yellow_df.createOrReplaceTempView(\"raw_yellow\")\n", + "\n", + "ctas_sql = f\"\"\"\n", + "CREATE OR REPLACE TABLE {BRONZE_TABLE}\n", + "USING iceberg\n", + "AS\n", + "SELECT\n", + " VendorID,\n", + " tpep_pickup_datetime,\n", + " tpep_dropoff_datetime,\n", + " passenger_count,\n", + " trip_distance,\n", + " RatecodeID,\n", + " store_and_fwd_flag,\n", + " PULocationID,\n", + " DOLocationID,\n", + " payment_type,\n", + " fare_amount,\n", + " extra,\n", + " mta_tax,\n", + " tip_amount,\n", + " tolls_amount,\n", + " improvement_surcharge,\n", + " total_amount,\n", + " congestion_surcharge,\n", + " Airport_fee\n", + "FROM raw_yellow\n", + "\"\"\"\n", + "\n", + "print(\"Temp view ready: raw_yellow\")\n", + "print(ctas_sql)\n" + ] + }, + { + "cell_type": "markdown", + "id": "773150ad", + "metadata": {}, + "source": [ + "Справочная форма DDL, более похожая на привычный `PostgreSQL`-стиль, могла бы выглядеть так:\n", + "\n", + "```sql\n", + "CREATE TABLE lakehouse.bronze.nyc_taxi_yellow (\n", + " VendorID BIGINT,\n", + " tpep_pickup_datetime TIMESTAMP,\n", + " tpep_dropoff_datetime TIMESTAMP,\n", + " passenger_count DOUBLE,\n", + " trip_distance DOUBLE,\n", + " RatecodeID DOUBLE,\n", + " store_and_fwd_flag STRING,\n", + " PULocationID BIGINT,\n", + " DOLocationID BIGINT,\n", + " payment_type BIGINT,\n", + " fare_amount DOUBLE,\n", + " extra DOUBLE,\n", + " mta_tax DOUBLE,\n", + " tip_amount DOUBLE,\n", + " tolls_amount DOUBLE,\n", + " improvement_surcharge DOUBLE,\n", + " total_amount DOUBLE,\n", + " congestion_surcharge DOUBLE,\n", + " Airport_fee DOUBLE\n", + ") USING iceberg;\n", + "```\n", + "\n", + "Но в этом модуле мы используем именно `CTAS`, потому что хотим одной операцией и создать таблицу, и загрузить в неё raw-срез.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3fa6f522", + "metadata": {}, + "outputs": [], + "source": [ + "# Для полного набора из нескольких месяцев эта ячейка может выполняться 1-3 минуты.\n", + "spark.sql(ctas_sql)\n", + "\n", + "bronze_df = spark.table(BRONZE_TABLE)\n", + "bronze_row_count = bronze_df.count()\n", + "\n", + "print(f\"Строк в raw: {raw_row_count:,}\")\n", + "print(f\"Строк в bronze: {bronze_row_count:,}\")\n", + "assert bronze_row_count == raw_row_count, \"Количество строк в bronze не совпало с raw\"\n" + ] + }, + { + "cell_type": "markdown", + "id": "70dead5a", + "metadata": {}, + "source": "## 4. Читаем уже не raw, а управляемую таблицу\n\nТеперь ключевое отличие:\n\n- `spark.read.parquet(\"s3a://...\")` читает набор файлов по физическому пути;\n- `spark.table(\"lakehouse.bronze.nyc_taxi_yellow\")` читает логическую таблицу через каталог `Iceberg`.\n\nПо результату оба варианта могут вернуть похожие строки. Но второй вариант работает с каталогом и metadata: знает схему, историю изменений и согласованное состояние таблицы." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "252cba12", + "metadata": {}, + "outputs": [], + "source": [ + "bronze_df.show(10, truncate=False)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "abfd4ee4", + "metadata": {}, + "outputs": [], + "source": [ + "comparison_df = pd.DataFrame(\n", + " [\n", + " {\n", + " \"mode\": \"raw path\",\n", + " \"reader\": f\"spark.read.parquet('{RAW_YELLOW_URI}')\",\n", + " \"row_count\": raw_row_count,\n", + " \"object\": \"набор parquet-файлов\",\n", + " },\n", + " {\n", + " \"mode\": \"catalog table\",\n", + " \"reader\": f\"spark.table('{BRONZE_TABLE}')\",\n", + " \"row_count\": bronze_row_count,\n", + " \"object\": \"Iceberg-таблица\",\n", + " },\n", + " ]\n", + ")\n", + "\n", + "comparison_df\n" + ] + }, + { + "cell_type": "markdown", + "id": "5b4dffa6", + "metadata": {}, + "source": "Эту разницу полезно зафиксировать в одной таблице:\n\n| Свойство | Raw-файлы | Iceberg-таблица |\n| --- | --- | --- |\n| Каталогизация | Нет | Да (`PostgreSQL` JDBC catalog) |\n| История изменений | Нет | `Snapshots` |\n| Контроль схемы | Выводится из файлов | Зафиксирована в metadata |\n| Чтение из `Trino` | Невозможно без внешней таблицы | Да |\n| Атомарность записи | Нет | Да |" + }, + { + "cell_type": "markdown", + "id": "ccb4fb69", + "metadata": {}, + "source": [ + "## 5. Что знает `Iceberg` о таблице: snapshots, history, files\n", + "\n", + "У `Iceberg` есть встроенные metadata-таблицы. Это один из самых наглядных ответов на вопрос, почему таблица не сводится к «папке с parquet-файлами».\n", + "\n", + "Нас интересуют три представления:\n", + "\n", + "- `.snapshots` — какие snapshots есть у таблицы;\n", + "- `.history` — как менялось текущее состояние таблицы;\n", + "- `.files` — какие data files входят в текущий snapshot.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "03fa2fc4", + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(\n", + " f\"SELECT committed_at, snapshot_id, parent_id, operation, manifest_list, summary \"\n", + " f\"FROM {BRONZE_TABLE}.snapshots ORDER BY committed_at DESC\"\n", + ").toPandas()\n" + ] + }, + { + "cell_type": "markdown", + "id": "3f048c15", + "metadata": {}, + "source": "Как читать поля в `.snapshots`:\n\n- `committed_at` — когда snapshot стал текущим;\n- `snapshot_id` — уникальный идентификатор версии таблицы;\n- `operation` — какая операция создала snapshot, например `append` или `replace`;\n- `summary` — краткое описание результата операции: сколько файлов добавлено, сколько строк записано и т.д.\n\nНа практике это можно воспринимать как именованный checkpoint таблицы: каждая запись в истории фиксирует согласованное состояние, к которому движок может обратиться." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "db404328", + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(\n", + " f\"SELECT made_current_at, snapshot_id, parent_id, is_current_ancestor \"\n", + " f\"FROM {BRONZE_TABLE}.history ORDER BY made_current_at DESC\"\n", + ").toPandas()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dc0f3c91", + "metadata": {}, + "outputs": [], + "source": [ + "files_df = spark.sql(\n", + " f\"\"\"\n", + " SELECT\n", + " content,\n", + " file_path,\n", + " file_format,\n", + " record_count,\n", + " file_size_in_bytes\n", + " FROM {BRONZE_TABLE}.files\n", + " ORDER BY file_path\n", + " \"\"\"\n", + ")\n", + "\n", + "files_pd = files_df.toPandas()\n", + "files_pd[\"file_size_human\"] = files_pd[\"file_size_in_bytes\"].map(format_bytes)\n", + "files_pd\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a9cfcaa6", + "metadata": {}, + "outputs": [], + "source": [ + "file_summary = pd.DataFrame(\n", + " [\n", + " {\n", + " \"metric\": \"data_files_count\",\n", + " \"value\": len(files_pd),\n", + " },\n", + " {\n", + " \"metric\": \"total_records_in_files\",\n", + " \"value\": int(files_pd[\"record_count\"].sum()) if not files_pd.empty else 0,\n", + " },\n", + " {\n", + " \"metric\": \"total_file_size_bytes\",\n", + " \"value\": int(files_pd[\"file_size_in_bytes\"].sum()) if not files_pd.empty else 0,\n", + " },\n", + " {\n", + " \"metric\": \"total_file_size_human\",\n", + " \"value\": format_bytes(int(files_pd[\"file_size_in_bytes\"].sum())) if not files_pd.empty else \"0 B\",\n", + " },\n", + " ]\n", + ")\n", + "\n", + "file_summary\n" + ] + }, + { + "cell_type": "markdown", + "id": "bc85ca8f", + "metadata": {}, + "source": "`Iceberg` хранит не только путь к данным, но и состояние таблицы как набора metadata-артефактов.\n\nПрактический смысл:\n\n- snapshot фиксирует согласованную версию таблицы;\n- manifests описывают, какие data files входят в snapshot;\n- каталог указывает на актуальный metadata-файл таблицы.\n\nИменно поэтому удалить один parquet-файл «руками» из `MinIO` опаснее, чем кажется: metadata продолжит ссылаться на него, а таблица станет неконсистентной." + }, + { + "cell_type": "markdown", + "id": "d48ea169", + "metadata": {}, + "source": [ + "## 6. Физическая структура таблицы в `MinIO`\n", + "\n", + "Теперь посмотрим на ту же таблицу с физической стороны. Нас интересуют две группы объектов:\n", + "\n", + "- `data/` — parquet-файлы с данными;\n", + "- `metadata/` — metadata JSON, manifest list и manifests.\n", + "\n", + "Это хороший момент, чтобы связать логическую таблицу и физическую структуру в object storage.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9d2d088d", + "metadata": {}, + "outputs": [], + "source": [ + "bronze_location = get_table_location(BRONZE_TABLE)\n", + "bronze_prefix = bronze_location.replace(f\"s3a://{BUCKET_NAME}/\", \"\").rstrip(\"/\") + \"/\"\n", + "bronze_objects = list_objects(BUCKET_NAME, bronze_prefix)\n", + "assert bronze_objects, f\"По пути {bronze_location} не найдены объекты таблицы\"\n", + "\n", + "bronze_storage_report = pd.DataFrame(\n", + " [\n", + " {\n", + " \"key\": obj[\"Key\"],\n", + " \"kind\": \"DATA\" if \"/data/\" in obj[\"Key\"] else \"META\" if \"/metadata/\" in obj[\"Key\"] else \"OTHER\",\n", + " \"size_bytes\": obj[\"Size\"],\n", + " \"size_human\": format_bytes(obj[\"Size\"]),\n", + " }\n", + " for obj in bronze_objects\n", + " ]\n", + ").sort_values([\"kind\", \"key\"]).reset_index(drop=True)\n", + "\n", + "print(f\"Table location: {bronze_location}\")\n", + "bronze_storage_report\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c2fbc7e1", + "metadata": {}, + "outputs": [], + "source": [ + "storage_summary = bronze_storage_report.groupby(\"kind\", as_index=False)[\"size_bytes\"].sum()\n", + "storage_summary[\"size_human\"] = storage_summary[\"size_bytes\"].map(format_bytes)\n", + "storage_summary\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2fb4911d", + "metadata": {}, + "outputs": [], + "source": [ + "root = PurePosixPath(bronze_prefix.rstrip(\"/\"))\n", + "rendered_lines = [str(root)]\n", + "\n", + "for key in bronze_storage_report[\"key\"]:\n", + " rel = PurePosixPath(key).relative_to(root)\n", + " parts = rel.parts\n", + " for depth, part in enumerate(parts):\n", + " prefix = \" \" * depth + (\"- \" if depth else \"\")\n", + " rendered = prefix + part\n", + " if rendered not in rendered_lines:\n", + " rendered_lines.append(rendered)\n", + "\n", + "print(\"Структура каталога таблицы:\\n\")\n", + "print(\"\\n\".join(rendered_lines))\n" + ] + }, + { + "cell_type": "markdown", + "id": "ef5b24bf", + "metadata": {}, + "source": "Обычно после первого `CTAS` ты увидишь примерно такую картину:\n\n```text\nwarehouse/bronze/nyc_taxi_yellow/\n - data\n - *.parquet\n - metadata\n - v1.metadata.json\n - snap-*.avro\n - *.avro\n```\n\nСмысл структуры:\n\n- `data/` хранит содержимое таблицы;\n- `metadata/` хранит описание состояния таблицы;\n- JDBC catalog знает, где лежит актуальный metadata-файл.\n\nЭто и есть практическая разница между «таблица как логический объект» и «папка с parquet-файлами»." + }, + { + "cell_type": "markdown", + "id": "2b58a761", + "metadata": {}, + "source": "## 7. Почему `raw -> bronze` похоже на `stg -> ods`\n\nВ классическом DWH часто есть переход `stg -> ods`: сначала источник попадает в staging, потом появляется первая рабочая таблица, с которой удобно работать дальше.\n\nВ этом курсе аналог выглядит так:\n\n- `raw` — неизменяемый архив исходных файлов в object storage;\n- `bronze` — первая управляемая таблица на `Iceberg`, которую можно читать через каталог, осматривать через metadata-таблицы и позже запрашивать из `Trino`;\n- `silver` — следующий слой, где появятся нормализация, очистка и правила трансформаций.\n\nПоэтому `bronze` не равно `raw`:\n\n- raw нужен как воспроизводимая точка входа;\n- bronze нужен как управляемая таблица с каталогом и snapshot-ами;\n- bronze можно пересоздать из raw, если логика меняется." + }, + { + "cell_type": "markdown", + "id": "320c0199", + "metadata": {}, + "source": [ + "## 8. Самостоятельное задание\n", + "\n", + "Используй raw-файл `taxi_zone_lookup.csv`, который уже лежит в `MinIO`.\n", + "\n", + "Что нужно сделать:\n", + "\n", + "1. Прочитать `taxi_zone_lookup.csv` из raw-зоны через `Spark`.\n", + "2. Создать таблицу `lakehouse.bronze.taxi_zone_lookup` с колонками `LocationID INT`, `Borough STRING`, `Zone STRING`, `service_zone STRING`.\n", + "3. Загрузить туда данные.\n", + "4. Проверить результат через `spark.table(...)`.\n", + "5. Посмотреть `snapshots` для этой таблицы.\n", + "\n", + "Подсказка: файл лежит по пути `s3a://lakehouse/raw/nyc_taxi/taxi_zone_lookup.csv`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e94508bf", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: прочитай taxi_zone_lookup.csv из raw-зоны через Spark\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e058aeb", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: создай таблицу lakehouse.bronze.taxi_zone_lookup\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41c2c79a", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: загрузи данные в bronze lookup-таблицу\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "80b392e1", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: проверь результат через spark.table(...)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61e90959", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: посмотри snapshots для lakehouse.bronze.taxi_zone_lookup\n" + ] + }, + { + "cell_type": "markdown", + "id": "24c04d60", + "metadata": {}, + "source": [ + "## 9. Checkpoint\n", + "\n", + "Проверь себя:\n", + "\n", + "1. Чем отличается `spark.read.parquet(\"s3a://...\")` от `spark.table(\"lakehouse.bronze.nyc_taxi_yellow\")`?\n", + "2. Что хранит snapshot и зачем он нужен?\n", + "3. Где физически лежат данные таблицы и где лежат её метаданные?\n", + "4. Почему `bronze` это не то же самое, что `raw`?\n", + "5. Что произойдёт, если удалить один data file из `MinIO`, но metadata не поменять?\n", + "6. Можешь ли ты показать таблицу и через `Spark`, и через `MinIO Console`?\n" + ] + }, + { + "cell_type": "markdown", + "id": "76d9061f", + "metadata": {}, + "source": [ + "## 10. Завершение\n", + "\n", + "Мы намеренно не удаляем `lakehouse.bronze.nyc_taxi_yellow`. Эта таблица понадобится в Модуле 5, где мы будем строить `silver`-слой.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "078b9876", + "metadata": {}, + "outputs": [], + "source": [ + "spark.stop()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/plans/README.md b/plans/README.md index 41e26b5..4efd315 100644 --- a/plans/README.md +++ b/plans/README.md @@ -16,3 +16,4 @@ | 1. Вход в стенд и базовая диагностика | [module-01-environment-and-basic-diagnostics.md](./module-01-environment-and-basic-diagnostics.md) | Ready for validation | `START_HERE.md`, `notebooks/01_environment_and_smoke_test.ipynb`, `src/spark/cluster_smoke.py` | 2026-03-06 | | 2. Ментальная модель Lakehouse: storage, catalog, compute | [module-02-lakehouse-mental-model.md](./module-02-lakehouse-mental-model.md) | Ready for validation | `notebooks/02_lakehouse_mental_model.ipynb` | 2026-03-07 | | 3. Raw-данные и первое чтение в Spark | [module-03-raw-ingest-and-first-read.md](./module-03-raw-ingest-and-first-read.md) | Ready for validation | `docker-compose.yml`, `.gitignore`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/03_raw_ingest_and_first_read.ipynb` | 2026-03-07 | +| 4. Первая рабочая Iceberg-таблица и слой bronze | [module-04-bronze-with-iceberg.md](./module-04-bronze-with-iceberg.md) | Draft | `notebooks/04_bronze_with_iceberg.ipynb` | 2026-03-07 | From 82d067d6c6a2b6b5482180fd4b96d7973a13950a Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 20:03:18 +0300 Subject: [PATCH 15/24] =?UTF-8?q?docs(plan):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=205=20=D0=BF=D0=BE=20silver-?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - необходимо расширить курс до silver-слоя с воспроизводимыми трансформациями. - Что: - создан файл plans/module-05-silver-layer.md с планом модуля. - обновлен plans/README.md с записью о новом модуле в таблицу содержания. - Проверка: - проверка согласованности ссылок и структуры документации. --- plans/README.md | 1 + plans/module-05-silver-layer.md | 289 ++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 plans/module-05-silver-layer.md diff --git a/plans/README.md b/plans/README.md index 4efd315..a1396db 100644 --- a/plans/README.md +++ b/plans/README.md @@ -17,3 +17,4 @@ | 2. Ментальная модель Lakehouse: storage, catalog, compute | [module-02-lakehouse-mental-model.md](./module-02-lakehouse-mental-model.md) | Ready for validation | `notebooks/02_lakehouse_mental_model.ipynb` | 2026-03-07 | | 3. Raw-данные и первое чтение в Spark | [module-03-raw-ingest-and-first-read.md](./module-03-raw-ingest-and-first-read.md) | Ready for validation | `docker-compose.yml`, `.gitignore`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/03_raw_ingest_and_first_read.ipynb` | 2026-03-07 | | 4. Первая рабочая Iceberg-таблица и слой bronze | [module-04-bronze-with-iceberg.md](./module-04-bronze-with-iceberg.md) | Draft | `notebooks/04_bronze_with_iceberg.ipynb` | 2026-03-07 | +| 5. Слой silver и воспроизводимые трансформации | [module-05-silver-layer.md](./module-05-silver-layer.md) | Draft | `notebooks/05_silver_layer.ipynb` | 2026-03-07 | diff --git a/plans/module-05-silver-layer.md b/plans/module-05-silver-layer.md new file mode 100644 index 0000000..c6ae736 --- /dev/null +++ b/plans/module-05-silver-layer.md @@ -0,0 +1,289 @@ +# Модуль 5. Слой silver и воспроизводимые трансформации + +**Статус:** `Draft` +**Последнее обновление:** `2026-03-07` + +## Цель + +Научить студента переходу от bronze-таблицы «как есть» к осознанно очищенной и обогащённой silver-таблице. Студент формулирует правила трансформаций явно (не зарытыми в код), применяет их к bronze, обогащает таблицу вычисляемыми колонками и join-данными, а затем проверяет результат на трёх уровнях: строки, схема, агрегаты. + +## Результат для студента + +После прохождения модуля студент: + +- понимает, зачем нужен silver-слой и чем он отличается от bronze; +- умеет сформулировать правила трансформации явно и зафиксировать их как «реестр правил» перед кодом; +- умеет отфильтровать некачественные записи из bronze по осознанным критериям; +- умеет нормализовать типы колонок (DOUBLE -> INT для passenger_count, RatecodeID); +- умеет обработать NULL-значения явным образом (coalesce); +- умеет добавить вычисляемую колонку (trip_duration_minutes); +- умеет обогатить данные через JOIN с lookup-таблицей (названия зон pickup/dropoff); +- умеет выполнить проверки качества результата на трёх уровнях: строки, схема, агрегаты; +- может сопоставить bronze/silver с привычным ods/dds-мышлением. + +## Deliverables + +- `notebooks/05_silver_layer.ipynb` — основной ноутбук Модуля 5; +- обновление `plans/README.md` — строка Модуля 5 в оглавлении. + +Инфраструктурные изменения не требуются. + +## Зависимости + +### Предусловия + +- Стенд поднят и работает (Модуль 1). +- Студент прошёл Модуль 4: таблицы `lakehouse.bronze.nyc_taxi_yellow` и `lakehouse.bronze.taxi_zone_lookup` существуют. + +### Что используют последующие модули + +- Модуль 6 читает `lakehouse.silver.nyc_taxi_yellow` через Spark и Trino. +- Модуль 8 использует silver в финальной практике. + +## Дизайн-решения + +### Namespace: `lakehouse.silver` + +Семантическое имя (как `lakehouse.bronze` в Модуле 4). Подкрепляет мышление слоями, Модуль 6 будет читать `lakehouse.silver.*`. + +### Правила трансформаций: явный реестр в markdown перед кодом + +Ключевая методическая идея модуля — правила не должны быть зарыты в код. Перед блоком кода стоит markdown-таблица с реестром правил (аналог mapping specification в DWH-практике). Это делает трансформации прозрачными и подкрепляет PRD Risk #4. + +### Состав трансформаций: три группы + +**Группа 1 — Фильтрация:** +- F1: `fare_amount < 0` — отрицательные тарифы (аномалия из Модуля 3) +- F2: `total_amount > 1000` — экстремальные суммы (аномалия из Модуля 3) +- F3: `tpep_pickup_datetime` вне `[2024-01-01, 2025-01-01)` — записи за пределами учебного диапазона (аномалия из Модуля 3) + +**Группа 2 — Нормализация и обработка NULL:** +- N1: `passenger_count` — DOUBLE -> INT, NULL -> 0 +- N2: `RatecodeID` — DOUBLE -> INT, NULL -> 99 (unknown) +- N3: `congestion_surcharge` — NULL -> 0.0 +- N4: `Airport_fee` — NULL -> 0.0 + +**Группа 3 — Обогащение:** +- E1: `trip_duration_minutes` — вычисляемая колонка `(unix_timestamp(dropoff) - unix_timestamp(pickup)) / 60` +- E2: `pickup_borough`, `pickup_zone` — LEFT JOIN с taxi_zone_lookup по PULocationID +- E3: `dropoff_borough`, `dropoff_zone` — LEFT JOIN с taxi_zone_lookup по DOLocationID + +Ряд правил намеренно оставлен для самостоятельного задания: фильтрация `trip_distance = 0 AND total_amount != 0`, нормализация `payment_type` (BIGINT -> INT с именованными значениями), вычисляемая колонка `speed_mph`. + +### Диапазон дат для F3 + +Фиксированный `[2024-01-01, 2025-01-01)` — полный 2024 год, а не только каноническое подмножество `2024-01..2024-03` из `START_HERE.md`. Причина: фильтр ловит записи с явно ошибочным годом (2023, 2025 и т.д.), а не зависит от того, какие месяцы скачал студент. Канонический bundle — 3 месяца, расширенный — до 12, и в обоих случаях диапазон `[2024-01-01, 2025-01-01)` корректен. В Модуле 3 диапазон выводился из имён файлов через `infer_month_bounds`, но в silver мы работаем с bronze-таблицей (не с файлами), поэтому фиксированный диапазон проще и уместнее. + +### JOIN: два LEFT JOIN с алиасами + +LEFT JOIN (не INNER), чтобы не терять строки с LocationID без соответствия в lookup. Потеря строк из-за INNER JOIN была бы неявной трансформацией. + +### Создание таблицы: SQL CTAS с temp view + +Тот же паттерн, что в Модуле 4: трансформации через PySpark DataFrame API (`.filter()`, `.withColumn()`, `.join()`), затем `createOrReplaceTempView("silver_ready")`, затем `CREATE OR REPLACE TABLE lakehouse.silver.nyc_taxi_yellow USING iceberg AS SELECT col1, col2, ... FROM silver_ready` с явным списком колонок. + +### Проверки качества: три уровня + +- **Строковый (row-level):** assert нет fare_amount < 0, нет total_amount > 1000, нет NULL в passenger_count/RatecodeID +- **Схемный (schema-level):** наличие новых колонок, проверка типов (passenger_count = INT, RatecodeID = INT) +- **Агрегатный (aggregate-level):** таблица-сравнение bronze vs silver (row count, % отфильтрованных, средние fare/distance) + +### Taxi zone lookup: жёсткий prerequisite + +Lookup — самостоятельное задание Модуля 4. Модуль 5 работает только с bronze-таблицами и не должен лезть в raw-слой — это нарушило бы принцип разделения ответственности между слоями (PRD Risk #4). Стратегия: жёсткий assert при отсутствии `lakehouse.bronze.taxi_zone_lookup` с понятным сообщением и отсылкой к Модулю 4: «Таблица `lakehouse.bronze.taxi_zone_lookup` не найдена. Вернись в Модуль 4 и выполни самостоятельное задание (Секция 8).» + +### Cleanup: нет + +Silver нужен Модулям 6 и 8. Spark-сессия останавливается. + +## План работ + +1. Создать `notebooks/05_silver_layer.ipynb` по ячеечной структуре, описанной ниже. +2. Обновить `plans/README.md` — добавить строку Модуля 5 в таблицу оглавления. +3. Валидация: убедиться, что ноутбук выполняется сверху вниз на поднятом стенде после прохождения Модуля 4 (bronze-таблицы в каталоге). + +## Структура ноутбука + +### Секция 0: Введение +- **[md]** Заголовок, цели модуля, prerequisite (Модуль 4). Таблица-сравнение: + +| | Классический DWH (Greenplum) | Lakehouse | +|---|---|---| +| Источник | ods/stg-таблица | Bronze Iceberg-таблица | +| Результат | dds/fact-таблица с очищенными данными | Silver Iceberg-таблица | +| Правила трансформаций | Mapping specification / ETL-документация | Явный реестр правил в ноутбуке перед кодом | +| Проверки качества | dq-check скрипты, data quality framework | Asserts на трёх уровнях | +| Воспроизводимость | Повторный запуск ETL | `CREATE OR REPLACE TABLE ... AS SELECT` | + +- **[md]** Ключевая идея: silver = bronze + явные правила трансформации + проверки качества. + +### Секция 1: Spark-сессия и проверка bronze +- **[code]** SparkSession (паттерн Модулей 2-4: без `.master()`, `setLogLevel("ERROR")`). Константы, вспомогательные функции (`format_bytes`, `list_objects` — переиспользование паттерна из Модуля 4). +- **[code]** Assert: `spark.table(BRONZE_TABLE).count() > 0` с отсылкой к Модулю 4. Assert: `spark.table(BRONZE_LOOKUP_TABLE)` существует — при отсутствии жёсткая ошибка с отсылкой к самостоятельному заданию Модуля 4 (Секция 8). +- **[md]** Обе bronze-таблицы на месте. Теперь строим silver. + +### Секция 2: Профиль bronze — что нужно трансформировать +- **[md]** Прежде чем писать трансформации, понимаем текущее состояние данных. В Модуле 3 мы уже нашли аномалии. Теперь смотрим через bronze. +- **[code]** Краткий профиль: null-доли ключевых колонок, количество строк с fare_amount < 0, total_amount > 1000, pickup вне диапазона. +- **[md]** Таблица-профиль: какие проблемы нашли, сколько строк затронуто, что будем делать. + +### Секция 3: Реестр правил трансформации +- **[md]** Центральная идея модуля. Фиксируем все правила явно перед кодом. Аналог mapping specification в DWH. +- **[md]** Таблица правил (F1-F3, N1-N4, E1-E3) с колонками: #, Колонка/область, Правило, Тип, Обоснование. +- **[md]** Зачем это нужно: если через месяц спросят «почему в silver нет строк с отрицательным fare?», ответ в реестре, а не в git blame. + +### Секция 4: Применение правил — фильтрация +- **[md]** Начинаем с фильтрации. Порядок осознанный: сначала фильтруем, потом нормализуем и обогащаем (не тратим ресурсы на трансформацию строк, которые будут удалены). +- **[code]** Чтение bronze DataFrame. Применение F1, F2, F3. Подсчёт строк до/после, вывод количества и процента удалённых. +- **[md]** Обычно фильтры качества удаляют небольшую долю строк. Если фильтр убирает заметную часть данных — это сигнал перепроверить правило или источник. + +### Секция 5: Применение правил — нормализация +- **[md]** Приводим типы и обрабатываем NULL. Параллель с PostgreSQL: как `ALTER COLUMN TYPE integer`, но между слоями, а не in-place. +- **[code]** Применение N1-N4: `coalesce` + `cast` для passenger_count и RatecodeID, `coalesce` для surcharge и fee. Каждый `.withColumn()` с комментарием, какому правилу соответствует. + +### Секция 6: Применение правил — обогащение +- **[md]** Добавляем вычисляемые колонки и данные из lookup. LEFT JOIN — не теряем строки. Параллель с денормализацией в PostgreSQL. +- **[code]** E1: `trip_duration_minutes`. E2, E3: два LEFT JOIN с `taxi_zone_lookup` (алиасы `pu_zone`, `do_zone`). +- **[code]** Вывод нескольких строк с новыми колонками. + +### Секция 7: Создание silver-таблицы +- **[md]** Используем CTAS с `CREATE OR REPLACE`. Namespace `lakehouse.silver`. +- **[md]** Явное предупреждение о `CREATE OR REPLACE`: это полная перезапись таблицы с потерей предыдущего состояния и snapshot-истории. Для учебного silver это осознанный компромисс ради идемпотентности (повторный запуск ноутбука пересоздаёт таблицу с чистого листа). В рабочих сценариях это разрушительная операция — в Модуле 7 студент увидит, почему нужен другой подход. Параллель: `DROP TABLE + CREATE TABLE AS SELECT` в PostgreSQL. +- **[code]** `CREATE NAMESPACE IF NOT EXISTS lakehouse.silver`. Регистрация temp view. CTAS с явным списком демо-схемы: 24 колонки (19 оригинальных + 5 новых). Это каноническая схема, на которую опираются Модули 6 и 8. +- **[md]** Предупреждение о времени выполнения (2-4 мин на 3 мес данных). +- **[code]** Верификация: count, printSchema. + +### Секция 8: Проверки качества результата + +#### 8a: Строковый уровень +- **[code]** Assert: нет строк с fare_amount < 0, total_amount > 1000, NULL в passenger_count, NULL в RatecodeID. + +#### 8b: Схемный уровень +- **[code]** Проверка наличия новых колонок (trip_duration_minutes, pickup_borough, pickup_zone, dropoff_borough, dropoff_zone). Проверка типов: passenger_count = int, RatecodeID = int. + +#### 8c: Агрегатный уровень +- **[code]** Таблица-сравнение bronze vs silver: row count, % отфильтрованных, средние fare_amount, trip_distance. +- **[md]** Ориентир: если фильтры удалили больше 5% строк, это повод пересмотреть правила или проверить данные. + +### Секция 9: bronze vs silver — разделение ответственности +- **[md]** Мини-лекция (3-4 абзаца): raw -> bronze -> silver (-> gold, вне курса). Bronze = «как есть», silver = очищенные + обогащённые. Если правило неверно — bronze хранит оригинал, silver можно пересоздать. +- **[md]** Таблица-сравнение bronze vs silver по свойствам: данные, NULL, типы, доп. колонки, проверки, правила, «можно пересоздать из». + +### Секция 10: Самостоятельное задание +- **[md]** Инструкция: расширить silver-пайплайн тремя новыми правилами (по одному на каждый тип трансформации), пересоздать таблицу и проверить результат. + + **Фильтрация — F4:** удалить строки с `trip_distance = 0 AND total_amount != 0` (аномалия, найденная в Модуле 3, но не включённая в демонстрацию). + + **Нормализация — N5:** привести `payment_type` из BIGINT в INT и дать осмысленное значение для NULL (например, 0 = unknown). Подсказка: паттерн `coalesce` + `cast` тот же, что для `passenger_count`. + + **Обогащение — E4:** добавить вычисляемую колонку `speed_mph` — средняя скорость поездки в милях в час: `trip_distance / (trip_duration_minutes / 60)`. Подсказка: нужно обработать деление на ноль (если `trip_duration_minutes = 0`, результат должен быть NULL, а не ошибка). + + **Проверки:** после пересоздания silver написать: (1) row-level assert для F4, (2) schema-level проверку наличия `speed_mph` и типа `payment_type`, (3) сравнить количество строк до и после добавления F4. + +- **[code]** 6 пустых ячеек `# Ваш код: ...` (фильтрация, нормализация, обогащение, пересоздание таблицы, проверки, сравнение). + +### Секция 11: Checkpoint +- **[md]** Вопросы: + 1. Чем silver отличается от bronze? + 2. Зачем фиксировать правила перед кодом? + 3. Какие три уровня проверок качества и зачем? + 4. Почему LEFT JOIN, а не INNER JOIN с lookup? + 5. Что если фильтр удалит 50% строк? + 6. Можно ли пересоздать silver, если обнаружится новая аномалия? + +### Секция 12: Завершение +- **[md]** «Не удаляем silver-таблицу. В Модуле 6 будем читать через Spark и Trino, в Модуле 8 — финальная практика.» +- **[code]** `spark.stop()`. + +### Дизайн-решения по ноутбуку + +- **Helper-код:** весь код inline в ноутбуке. Студент должен видеть каждый шаг: фильтрацию, нормализацию, join, проверки. +- **Cleanup:** нет. Silver нужен Модулям 6 и 8. Spark-сессия останавливается. +- **Порядок трансформаций:** фильтрация -> нормализация -> обогащение. Логический порядок: сначала убираем мусор, потом чистим типы, потом добавляем контекст. +- **Spark API:** трансформации через PySpark DataFrame API (`.filter()`, `.withColumn()`, `.join()`), запись через SQL CTAS (консистентно с Модулем 4). + +## Схемы silver-таблицы + +### Демо-схема (24 колонки) — после секций 4-7 + +Это каноническая схема, на которую опираются downstream-модули (6, 8). Она создаётся демонстрационным кодом ноутбука и является минимальной гарантированной схемой `lakehouse.silver.nyc_taxi_yellow`. + +``` +VendorID BIGINT (без изменений) +tpep_pickup_datetime TIMESTAMP (без изменений) +tpep_dropoff_datetime TIMESTAMP (без изменений) +passenger_count INT (N1: DOUBLE->INT, NULL->0) +trip_distance DOUBLE (без изменений) +RatecodeID INT (N2: DOUBLE->INT, NULL->99) +store_and_fwd_flag STRING (без изменений) +PULocationID BIGINT (без изменений) +DOLocationID BIGINT (без изменений) +payment_type BIGINT (без изменений) +fare_amount DOUBLE (без изменений, F1 отфильтровал < 0) +extra DOUBLE (без изменений) +mta_tax DOUBLE (без изменений) +tip_amount DOUBLE (без изменений) +tolls_amount DOUBLE (без изменений) +improvement_surcharge DOUBLE (без изменений) +total_amount DOUBLE (без изменений, F2 отфильтровал > 1000) +congestion_surcharge DOUBLE (N3: NULL->0.0) +Airport_fee DOUBLE (N4: NULL->0.0) +trip_duration_minutes DOUBLE (E1: новая, вычисляемая) +pickup_borough STRING (E2: новая, из lookup JOIN) +pickup_zone STRING (E2: новая, из lookup JOIN) +dropoff_borough STRING (E3: новая, из lookup JOIN) +dropoff_zone STRING (E3: новая, из lookup JOIN) +``` + +### Расширенная схема (26 колонок) — после самостоятельного задания (секция 10) + +Студент пересоздаёт silver-таблицу с дополнительными правилами. Итоговая таблица содержит 24 демо-колонки + 2 новых, с изменением типа `payment_type`. Downstream-модули (6, 8) не зависят от расширенной схемы: `payment_type INT` обратно совместим с `BIGINT` для чтения, а дополнительные колонки (`speed_mph`) не используются downstream. + +``` +payment_type INT (N5: BIGINT->INT, NULL->0; было BIGINT) +speed_mph DOUBLE (E4: новая, trip_distance / (trip_duration_minutes / 60)) +``` + +Также добавляется фильтр F4 (`trip_distance = 0 AND total_amount != 0`), который уменьшает количество строк. + +## Checkpoint + +Студент должен уметь: + +- создать silver-таблицу из bronze с явными правилами трансформации; +- показать реестр правил и объяснить обоснование каждого; +- показать результаты проверок качества на трёх уровнях; +- объяснить, чем silver отличается от bronze; +- объяснить, почему silver можно пересоздать из bronze. + +## Acceptance Criteria + +- `notebooks/05_silver_layer.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении после прохождения Модуля 4 (bronze-таблицы в каталоге). +- Ноутбук следует методике курса: `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`. +- Ноутбук использует паттерны из Модулей 1-4: SparkSession без `.master()`, `setLogLevel("ERROR")`. +- Ноутбук идемпотентен: повторное выполнение (`CREATE OR REPLACE`) не дублирует данные и не роняет ячейки. +- CTAS использует явный список колонок, а не `SELECT *`. +- Правила трансформации зафиксированы в markdown-таблице перед кодом, а не только в коде. +- Проверки качества выполняются на трёх уровнях: строки, схема, агрегаты. +- Silver-таблица `lakehouse.silver.nyc_taxi_yellow` остаётся после выполнения ноутбука для использования в Модулях 6 и 8. +- При отсутствии bronze-таблиц — понятный assert с отсылкой к Модулю 4. +- Текст ноутбука на русском языке с параллелями к PostgreSQL/Greenplum. +- `plans/README.md` содержит строку Модуля 5 в оглавлении. + +## Риски + +- **Taxi zone lookup отсутствует.** Самостоятельное задание Модуля 4 может быть не выполнено. Жёсткий assert с отсылкой к Модулю 4. Модуль 5 не создаёт bronze-таблицы самостоятельно — это нарушило бы разделение ответственности между слоями. +- **Время CTAS.** JOIN + трансформации — на 30-50% медленнее bronze CTAS. На 3 мес (~7 млн строк): 2-4 мин. Extended: до 5-10 мин. Предупреждение в markdown. +- **Column case sensitivity.** Новые колонки (lowercase) рядом с оригинальными (mixed case: VendorID, PULocationID). Нормально для Iceberg, но добавить пояснение. +- **trip_duration_minutes отрицательные.** Если dropoff < pickup — длительность отрицательна. Упомянуть как наблюдение, не фильтровать в демо (потенциально — для самостоятельного задания или будущего правила). +- **Идемпотентность.** `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) для повторных запусков — тот же паттерн, что в Модуле 4. Пересоздаёт таблицу целиком. + +## Out of Scope + +- Gold-слой и бизнес-витрины. +- Чтение silver через Trino — отложено до Модуля 6. +- Сложные трансформации (MERGE, SCD, window functions). +- dq-фреймворки (Great Expectations, dbt tests) — принцип показан через assert. +- Time travel и schema evolution — отложено до Модуля 7. +- Compaction и vacuum — отложено до Модуля 8. +- Партиционирование silver-таблицы (PRD: вне v1). From 39b1a5618c6e80b48b67738b9e7ad57861b494a6 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 21:30:26 +0300 Subject: [PATCH 16/24] =?UTF-8?q?docs(plan):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=206=20=D0=BF=D0=BE=20Spark=20?= =?UTF-8?q?=D0=B8=20Trino=20=D0=BD=D0=B0=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужно продемонстрировать возможность одновременной работы Spark и Trino с одной Iceberg-таблицей. - Что: - создан файл plans/module-06-spark-and-trino-on-same-table.md с планом модуля. - обновлен plans/README.md с записью о новом модуле в таблицу содержания. - Проверка: - проверка согласованности ссылок и структуры документации. --- plans/README.md | 1 + ...module-06-spark-and-trino-on-same-table.md | 318 ++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 plans/module-06-spark-and-trino-on-same-table.md diff --git a/plans/README.md b/plans/README.md index a1396db..df89af2 100644 --- a/plans/README.md +++ b/plans/README.md @@ -18,3 +18,4 @@ | 3. Raw-данные и первое чтение в Spark | [module-03-raw-ingest-and-first-read.md](./module-03-raw-ingest-and-first-read.md) | Ready for validation | `docker-compose.yml`, `.gitignore`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/03_raw_ingest_and_first_read.ipynb` | 2026-03-07 | | 4. Первая рабочая Iceberg-таблица и слой bronze | [module-04-bronze-with-iceberg.md](./module-04-bronze-with-iceberg.md) | Draft | `notebooks/04_bronze_with_iceberg.ipynb` | 2026-03-07 | | 5. Слой silver и воспроизводимые трансформации | [module-05-silver-layer.md](./module-05-silver-layer.md) | Draft | `notebooks/05_silver_layer.ipynb` | 2026-03-07 | +| 6. Одна таблица, два движка: Spark и Trino | [module-06-spark-and-trino-on-same-table.md](./module-06-spark-and-trino-on-same-table.md) | Draft | `jupyter/Dockerfile`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/06_spark_and_trino_on_same_table.ipynb` | 2026-03-07 | diff --git a/plans/module-06-spark-and-trino-on-same-table.md b/plans/module-06-spark-and-trino-on-same-table.md new file mode 100644 index 0000000..7c30898 --- /dev/null +++ b/plans/module-06-spark-and-trino-on-same-table.md @@ -0,0 +1,318 @@ +# Модуль 6. Одна таблица, два движка: Spark и Trino + +**Статус:** `Draft` +**Последнее обновление:** `2026-03-07` + +## Цель + +Показать студенту главное практическое следствие архитектуры Lakehouse: таблица, записанная одним движком, может быть прочитана другим без копирования данных. Студент записывает таблицу через Spark, читает её же через Trino, сравнивает результаты и объясняет, почему это работает через общий каталог и общее хранилище. + +## Результат для студента + +После прохождения модуля студент: + +- умеет записать таблицу через Spark и тут же прочитать её через Trino — без копирования данных; +- умеет выполнять SQL-запросы к Iceberg-таблицам из Trino (программно из ноутбука и через DBeaver); +- умеет сопоставить результаты одного и того же запроса из Spark и из Trino; +- понимает роль общего каталога (PostgreSQL JDBC) и общего хранилища (MinIO) в обеспечении доступа из разных движков; +- может объяснить отличие Lakehouse-модели (decoupled compute) от классического DWH (engine = storage). + +## Deliverables + +- `notebooks/06_spark_and_trino_on_same_table.ipynb` — основной ноутбук Модуля 6; +- обновление `jupyter/Dockerfile` — добавление `trino>=0.328` в pip install; +- обновление `START_HERE.md` — новая секция «Подключение DBeaver к Trino (перед Модулем 6)»; +- обновление `docs/stack_reference.md` — блок о подключении DBeaver к Trino; +- обновление `plans/README.md` — строка Модуля 6 в оглавлении. + +## Зависимости + +### Предусловия + +- Стенд поднят и работает (Модуль 1). +- Студент прошёл Модуль 5: таблица `lakehouse.silver.nyc_taxi_yellow` существует (не менее 24 колонок; если студент выполнил расширенное задание Модуля 5 — колонок может быть больше, это нормально). +- Студент прошёл Модуль 4: таблица `lakehouse.bronze.nyc_taxi_yellow` существует. +- Рекомендуется: DBeaver установлен на хосте студента (или аналогичный SQL-клиент). Не обязателен — ноутбук самодостаточен через Python `trino` клиент. + +### Что используют последующие модули + +- Модуль 7 работает со schema evolution и time travel, используя и Spark, и Trino. +- Модуль 8 использует silver в финальной практике с проверкой через Trino. + +## Дизайн-решения + +### Два пути к Trino: ноутбук (основной) и DBeaver (параллельный) + +PRD требует, чтобы ноутбук был самодостаточным учебником и тренажёром: демонстрации, самостоятельные задания и checkpoints — в ноутбуке ([course_prd.md:65-68](../docs/course_prd.md#L65)). Поэтому основной исполняемый путь к Trino — Python `trino` клиент в code cells. Ноутбук выполняется сверху вниз без внешних зависимостей. + +DBeaver — рекомендуемый параллельный инструмент для тех, кто хочет работать в привычном SQL-интерфейсе. Инструкция по подключению добавляется в `START_HERE.md`. В ноутбуке каждый Trino-запрос сопровождается markdown-заметкой: «Этот же запрос можно выполнить в DBeaver». Это: + +- подкрепляет идею decoupled compute: два разных клиента (Python, DBeaver) работают с одним движком; +- даёт студенту выбор привычного инструмента; +- не делает ноутбук зависимым от внешнего приложения. + +### Python `trino` клиент: основной исполняемый путь + +Пакет `trino>=0.328` добавляется в `jupyter/Dockerfile`. В начале ноутбука создаётся helper-функция `trino_query(sql)`, которая выполняет SQL через Trino и выводит результат. Все Trino-запросы в ноутбуке — исполняемые code cells через этот helper. Это обеспечивает воспроизводимость и проверяемость. + +### Структура «aha-момента»: write → read + +Два этапа: + +1. **Сравнение существующих таблиц.** Spark читает silver, Trino читает silver — метрики совпадают. Устанавливает факт: общий каталог работает. +2. **Живой цикл write → read.** Spark создаёт новую таблицу (`lakehouse.default.borough_summary`) через CTAS. Trino тут же её читает — без синхронизации, без копирования. Студент видит причинно-следственную связь: записал → увидел. Это соответствует программе курса: «записать таблицу через Spark; прочитать ту же таблицу через Trino» ([course_program.md:204-205](../docs/course_program.md#L204)). + +### Greenplum-параллель: decoupled vs monolithic + +В Greenplum данные и движок — единая система. Чтобы другой инструмент прочитал те же данные, нужно подключиться через Greenplum или скопировать. В Lakehouse данные лежат снаружи в object storage, и любой движок с доступом к каталогу и хранилищу может их прочитать. Данные не заперты в одном движке. + +### Cleanup: удаление демо-таблицы + +Таблицы bronze и silver остаются для Модулей 7 и 8. Демо-таблица `lakehouse.default.borough_summary` удаляется в конце модуля — она нужна только для демонстрации write → read. Spark-сессия останавливается. + +## План работ + +1. Обновить `jupyter/Dockerfile`: добавить `"trino>=0.328"` в строку `pip3 install`. +2. Обновить `START_HERE.md`: добавить секцию «Подключение DBeaver к Trino (перед Модулем 6)» после секции «Подготовка учебного датасета». +3. Обновить `docs/stack_reference.md`: добавить блок о подключении через DBeaver в секцию «Trino». +4. Создать `notebooks/06_spark_and_trino_on_same_table.ipynb` по ячеечной структуре ниже. +5. Обновить `plans/README.md` — добавить строку Модуля 6. +6. Валидация: выполнить ноутбук сверху вниз на поднятом стенде после Модуля 5. Опционально: проверить DBeaver-подсказки из markdown в DBeaver на `localhost:8090`. + +## Структура ноутбука + +### Секция 0: Введение +- **[md]** Заголовок, цели модуля, prerequisites (Модуль 5 пройден). Если установлен DBeaver — можно подключить его к Trino по инструкции из START_HERE.md (необязательно). Таблица-сравнение: + +| | Классический DWH (Greenplum) | Lakehouse | +|---|---|---| +| Где лежат данные | Внутри СУБД, на управляемых дисках | В объектном хранилище (MinIO), отдельно от движков | +| Где лежат метаданные | `pg_catalog` внутри той же СУБД | Внешний JDBC-каталог (PostgreSQL) + metadata в MinIO | +| Кто может читать таблицу | Только сама СУБД | Любой движок с доступом к каталогу и хранилищу | +| Чтобы дать доступ другому инструменту | Подключиться к СУБД или скопировать данные | Подключить тот же каталог — данные уже доступны | + +- **[md]** Ключевая идея: в Lakehouse данные не заперты в одном движке. Spark записал таблицу, Trino может прочитать без копирования — общий каталог и общее хранилище. Это decoupled compute. +- **[md]** Как устроен этот ноутбук: Spark-код и Trino-запросы выполняются здесь, в Jupyter. Для Trino используется Python-клиент `trino`. Если у вас установлен DBeaver — каждый Trino-запрос можно выполнить и там (в markdown будут подсказки). Два движка — одни данные. + +### Секция 1: Spark-сессия, Trino-клиент и проверка таблиц +- **[code]** SparkSession (паттерн Модулей 2-5). Константы: `SILVER_TABLE`, `BRONZE_TABLE`. +- **[code]** Assert: silver и bronze таблицы существуют и непусты. Отсылка к Модулям 4-5. +- **[code]** Helper-функция `trino_query(sql)`: подключается к Trino через Python `trino` клиент, выполняет SQL, выводит результат в табличном виде. Используется во всех последующих Trino-ячейках. Если падает с `ImportError` — нужно пересобрать образ (`docker compose build jupyter && docker compose up -d`). +- **[md]** Обе таблицы на месте. Spark может их читать. Trino-клиент готов. Вопрос: может ли Trino прочитать те же таблицы? + +### Секция 2: Читаем silver через Spark — фиксируем метрики +- **[md]** Сначала получим числа из Spark. Потом сравним с Trino. +- **[code]** Spark SQL: `SELECT count(*), avg(fare_amount), avg(trip_distance), count(DISTINCT pickup_borough)`. Сохранение в переменную `spark_metrics`. +- **[code]** `spark.table(SILVER_TABLE).show(10, truncate=False)` — первые строки для визуального сравнения. +- **[md]** Запомни эти числа. Сейчас выполним тот же запрос через Trino. + +### Секция 3: Навигация по каталогу через Trino +- **[code]** `trino_query("SHOW SCHEMAS FROM lakehouse")`. Trino видит те же namespace (bronze, silver, default), потому что читает тот же JDBC-каталог. +- **[md]** _DBeaver: тот же запрос можно выполнить в DBeaver, если он подключён к Trino по инструкции из START_HERE.md._ +- **[code]** `trino_query("SHOW TABLES FROM lakehouse.silver")`. Ожидаем `nyc_taxi_yellow`. +- **[code]** `trino_query("DESCRIBE lakehouse.silver.nyc_taxi_yellow")`. Сравнить со схемой из Модуля 5 (не менее 24 колонок; если выполнено расширенное задание — может быть больше). Различия в нотации типов (varchar vs STRING, double vs DOUBLE) нормальны — это разница синтаксиса движков, не данных. + +### Секция 4: «Aha-момент» — одни и те же данные +- **[md]** Центральный момент модуля. Trino читает таблицу, записанную Spark в Модуле 5. +- **[code]** `trino_query("SELECT * FROM lakehouse.silver.nyc_taxi_yellow LIMIT 10")`. +- **[md]** Данные, записанные Spark. Trino не копировал — прочитал из MinIO через metadata из каталога. _DBeaver: `SELECT * FROM lakehouse.silver.nyc_taxi_yellow LIMIT 10;`_ +- **[code]** Trino-метрики: `trino_query("SELECT count(*) AS row_count, avg(fare_amount) AS avg_fare, avg(trip_distance) AS avg_distance, count(DISTINCT pickup_borough) AS boroughs FROM lakehouse.silver.nyc_taxi_yellow")`. +- **[code]** Вывод сохранённых Spark-метрик для удобного сравнения. +- **[md]** Ожидание: row_count совпадает точно, avg — с точностью до floating-point округления. Оба движка читают одни и те же data files из MinIO. + +### Секция 5: Почему это работает — роль общего каталога +- **[md]** Мини-лекция (4-5 абзацев): + - Общий каталог: оба движка подключены к PostgreSQL (`postgres-iceberg:5432/iceberg`). Spark создаёт запись — Trino читает ту же запись. + - Общее хранилище: data files и metadata в MinIO (`s3://lakehouse/warehouse`). Оба движка имеют доступ к бакету. + - Параллель с Greenplum: данные внутри СУБД, доступ только через Greenplum. В Lakehouse — данные снаружи, движки взаимозаменяемы. + - Decoupled compute: добавить новый движок = подключить его к каталогу и хранилищу. + +- **[md]** ASCII-диаграмма: + +``` + ┌─────────────────┐ ┌──────────────────┐ + │ Spark (Jupyter) │ │ Trino (DBeaver) │ + └────────┬────────┘ └────────┬──────────┘ + │ │ + ▼ ▼ + ┌──────────────────────────────────────────┐ + │ PostgreSQL (JDBC catalog) │ + │ metadata_location -> s3://... │ + └────────────────────┬─────────────────────┘ + ▼ + ┌──────────────────────────────────────────┐ + │ MinIO (S3-compatible storage) │ + │ metadata/ + data/ (Parquet files) │ + └──────────────────────────────────────────┘ +``` + +### Секция 6: Spark записывает — Trino читает (live write → read) +- **[md]** До этого мы читали таблицы, созданные в предыдущих модулях. Теперь — живой цикл: Spark создаёт таблицу прямо сейчас, и Trino её тут же видит. Это ключевое действие модуля: «записать таблицу через Spark; прочитать ту же таблицу через Trino» (программа курса). +- **[code]** Spark CTAS: создаём агрегатную таблицу `lakehouse.default.borough_summary`: + +```python +spark.sql(""" + CREATE OR REPLACE TABLE lakehouse.default.borough_summary + USING iceberg AS + SELECT pickup_borough, + count(*) AS trips, + avg(fare_amount) AS avg_fare, + avg(trip_distance) AS avg_distance + FROM lakehouse.silver.nyc_taxi_yellow + WHERE pickup_borough IS NOT NULL + GROUP BY pickup_borough +""") +spark.table("lakehouse.default.borough_summary").show() +``` + +- **[md]** Spark записал таблицу. Мы не делали никакой синхронизации. Видит ли Trino? +- **[code]** `trino_query("SELECT * FROM lakehouse.default.borough_summary ORDER BY trips DESC")`. +- **[md]** Trino видит таблицу мгновенно. Spark записал metadata в PostgreSQL и data files в MinIO. Trino прочитал тот же каталог — увидел таблицу. Никакого копирования, никакой синхронизации. _DBeaver: `SELECT * FROM lakehouse.default.borough_summary ORDER BY trips DESC;`_ +- **[md]** Это и есть decoupled compute: один движок создал, другой прочитал. В Greenplum для этого пришлось бы либо подключиться к тому же движку, либо скопировать данные. + +### Секция 7: Аналитические запросы в Trino +- **[md]** Trino как аналитический SQL-движок. Выполним несколько аналитических запросов. +- **[code]** `trino_query("SELECT pickup_borough, count(*) AS trips, avg(fare_amount) AS avg_fare, avg(trip_distance) AS avg_distance, avg(tip_amount) AS avg_tip FROM lakehouse.silver.nyc_taxi_yellow WHERE pickup_borough IS NOT NULL GROUP BY pickup_borough ORDER BY trips DESC")`. +- **[md]** _DBeaver: тот же запрос._ +- **[code]** Топ зон посадки: `trino_query("SELECT pickup_zone, count(*) AS trips, avg(total_amount) AS avg_total FROM lakehouse.silver.nyc_taxi_yellow WHERE pickup_zone IS NOT NULL GROUP BY pickup_zone ORDER BY trips DESC LIMIT 10")`. +- **[code]** Тот же запрос (топ зон) через Spark SQL для сравнения. +- **[md]** Результаты совпадают (с точностью до floating-point). Два движка, одна таблица. + +### Секция 8: Навигация по bronze из Trino +- **[md]** До этого bronze читали только из Spark. Проверим через Trino. +- **[code]** `trino_query("SHOW TABLES FROM lakehouse.bronze")`. +- **[code]** `trino_query("SELECT count(*) AS row_count FROM lakehouse.bronze.nyc_taxi_yellow")`. +- **[code]** `trino_query("SELECT * FROM lakehouse.bronze.nyc_taxi_yellow LIMIT 5")`. +- **[md]** Trino видит все таблицы из всех namespace каталога. Каталог — общий, хранилище — общее. _DBeaver: те же запросы._ + +### Секция 9: DBeaver — SQL-клиент для Trino (рекомендация) +- **[md]** В этом ноутбуке мы работали с Trino через Python-клиент — для воспроизводимости. В реальной аналитической работе Trino чаще используют через SQL-клиенты: DBeaver, DataGrip, DbVisualizer. DBeaver — де-факто стандарт для SQL, знакомый всем, кто работал с PostgreSQL/Greenplum. +- **[md]** Если DBeaver установлен и подключён (инструкция в START_HERE.md), попробуйте выполнить в нём любой запрос из этого модуля. Результат будет тот же — тот же протокол, тот же движок, та же таблица. Разница: DBeaver — для интерактивной SQL-работы, Python-клиент — для автоматизации и ноутбуков. + +### Секция 10: Самостоятельное задание +- **[md]** Четыре задачи. Центральная — самостоятельный цикл write → read, как в Секции 6. + + **Задача 1 (Spark → write).** Создай через Spark новую агрегатную таблицу `lakehouse.default.zone_summary` с CTAS: для каждой `pickup_zone` посчитай `count(*)`, `avg(total_amount)`, `avg(tip_amount)` по silver-таблице (WHERE pickup_zone IS NOT NULL). + + **Задача 2 (Trino → read).** Прочитай `lakehouse.default.zone_summary` через Trino (Python-клиент или DBeaver). Убедись, что Trino видит таблицу без синхронизации. + + **Задача 3 (сравнение).** Выполни тот же агрегатный запрос напрямую по silver через Trino (без промежуточной таблицы). Сравни результат с `zone_summary`. Числа должны совпасть. + + **Задача 4 (ответ).** Ответь в markdown-ячейке: почему Trino увидел `zone_summary` сразу после создания через Spark? Какие три компонента стенда это обеспечивают? Что произошло бы, если бы у Trino был другой каталог? + +- **[code]** Пустая ячейка: `# Ваш код (Spark): CREATE TABLE zone_summary` +- **[code]** Пустая ячейка: `# Ваш код (Trino): чтение zone_summary` +- **[code]** Пустая ячейка: `# Ваш код (Trino): тот же агрегат напрямую по silver` +- **[md]** Пустая markdown-ячейка: `Ваш ответ на Задачу 4: ...` +- **[code]** Cleanup: `spark.sql("DROP TABLE IF EXISTS lakehouse.default.zone_summary")` + +- **[md]** Дополнительная задача (по желанию): аналитический запрос по bronze через Trino и через Spark, сравнение. +- **[code]** Пустая ячейка: `# Ваш код (Trino): аналитический запрос по bronze` +- **[code]** Пустая ячейка: `# Ваш код (Spark): тот же запрос по bronze` + +### Секция 11: Что мы НЕ сравниваем +- **[md]** Ограничение: модуль не сравнивает Spark и Trino как движки. Не обсуждаем: какой быстрее, какой лучше, внутренние различия. Цель — показать, что оба работают с одной таблицей через общий каталог. Это свойство архитектуры Lakehouse, а не конкретного движка. + +### Секция 12: Checkpoint +- **[md]** Вопросы: + 1. Почему Trino видит таблицы, созданные Spark, без синхронизации? + 2. Какие два компонента стенда общие для Spark и Trino? + 3. Чем доступ к данным в Lakehouse отличается от Greenplum? + 4. Нужно ли копировать данные, чтобы Trino прочитал таблицу Spark? + 5. Что такое «decoupled compute» в одном предложении? + 6. Если добавить третий движок (Flink), что нужно сделать, чтобы он увидел те же таблицы? + 7. Что произошло, когда Spark создал `borough_summary`, а Trino её тут же прочитал? + +### Секция 13: Завершение +- **[md]** Удаляем демо-таблицу, она больше не нужна. Таблицы bronze и silver остаются для Модулей 7 и 8. +- **[code]** `spark.sql("DROP TABLE IF EXISTS lakehouse.default.borough_summary")`. +- **[md]** «В Модуле 7 — schema evolution и time travel. В Модуле 8 — финальная практика.» +- **[code]** `spark.stop()`. + +### Дизайн-решения по ноутбуку + +- **Исполняемый формат:** Spark-код и Trino-запросы (через Python `trino` клиент) — в code cells. Ноутбук выполняется сверху вниз без внешних зависимостей. +- **DBeaver-подсказки:** markdown-заметки рядом с Trino-ячейками: «этот же запрос можно выполнить в DBeaver». +- **Helper-код:** `trino_query(sql)` — единственный helper. Ноутбук проще предыдущих — основная работа в SQL. +- **Cleanup:** DROP демо-таблицы `borough_summary`. +- **Порядок:** Spark (метрики) -> Trino (сравнение) -> объяснение WHY -> write → read -> аналитика -> bronze -> DBeaver-рекомендация -> самостоятельное задание. + +## Изменения инфраструктуры + +### jupyter/Dockerfile + +Добавить `"trino>=0.328"` в pip install (строка 8-11): + +```dockerfile +RUN pip3 install --no-cache-dir \ + "jupyterlab==4.2.5" \ + "boto3>=1.35,<2" \ + "psycopg2-binary>=2.9,<3" \ + "trino>=0.328" +``` + +Чистый Python-пакет, без системных зависимостей. Используется для программного доступа к Trino из ноутбука. + +### START_HERE.md + +Новая секция после «Подготовка учебного датасета (перед Модулем 3)», перед «Что делать дальше»: + +**«Подключение DBeaver к Trino (перед Модулем 6)»** + +- Зачем: в Модуле 6 можно работать с Trino через DBeaver параллельно с ноутбуком — привычный SQL-интерфейс. +- Предусловие: DBeaver установлен (ссылка на dbeaver.io/download). Необязателен — ноутбук работает без DBeaver. +- Шаги: New Database Connection -> Trino. Host: `localhost`. Port: `8090`. Database/Catalog: `lakehouse`. Username: любая строка (напр. `student`). Password: пусто. Test Connection -> Finish. +- Проверка: `SHOW SCHEMAS FROM lakehouse;`. Ожидаем: `bronze`, `default`, `information_schema`, `silver`. +- Troubleshooting: стенд поднят? контейнер `trino` Up? порт 8090 свободен? + +### docs/stack_reference.md + +В секцию «Trino» добавить подсекцию «Подключение через DBeaver»: +- Host: `localhost`, Port: `8090`, Catalog: `lakehouse`, User: любая строка, Password: нет. +- Driver: Trino (встроен в DBeaver). +- Проверка: `SHOW SCHEMAS FROM lakehouse;`. + +## Checkpoint + +Студент должен уметь: + +- записать таблицу через Spark и прочитать её через Trino (live write → read); +- прочитать одну и ту же таблицу из Spark и из Trino и показать совпадение метрик; +- объяснить, какие компоненты обеспечивают доступ из двух движков (PostgreSQL catalog + MinIO storage); +- объяснить, почему данные не нужно копировать между движками; +- объяснить разницу между monolithic (Greenplum) и decoupled (Lakehouse) подходом. + +## Acceptance Criteria + +- `notebooks/06_spark_and_trino_on_same_table.ipynb` выполняется сверху вниз после прохождения Модуля 5 без внешних зависимостей (DBeaver не обязателен). +- Ноутбук содержит живой цикл write → read: Spark создаёт таблицу, Trino читает. +- Методика курса: объяснение -> демонстрация -> самостоятельное повторение -> checkpoint. +- Паттерны Модулей 1-5: SparkSession без `.master()`, `setLogLevel("ERROR")`. +- Trino-запросы — в исполняемых code cells через Python `trino` клиент. DBeaver-подсказки — в markdown. +- При отсутствии silver — assert с отсылкой к Модулю 5. Схема silver: не менее 24 колонок (допускается расширенная схема после Модуля 5). +- Текст на русском с параллелями к PostgreSQL/Greenplum. +- `jupyter/Dockerfile` содержит `trino>=0.328`. +- `START_HERE.md` содержит инструкцию по подключению DBeaver к Trino (рекомендуемый параллельный инструмент). +- `docs/stack_reference.md` содержит блок о подключении DBeaver. +- `plans/README.md` содержит строку Модуля 6. +- Демо-таблица `borough_summary` удаляется в конце модуля. Таблицы bronze и silver остаются для Модулей 7 и 8. + +## Риски + +- **DBeaver не установлен.** Не блокер — ноутбук самодостаточен через Python `trino` клиент. DBeaver рекомендуется как параллельный инструмент. В START_HERE.md ссылка на установку. Альтернативные SQL-клиенты (DataGrip, DbVisualizer) — аналогичное подключение. Fallback: Trino CLI (`docker compose exec -it trino trino --catalog lakehouse`). +- **Trino не видит таблицы.** Контейнер не поднят, PostgreSQL/MinIO недоступны. Диагностика через `docker compose ps` и логи. Пояснение в ноутбуке. +- **Различия типов между Spark и Trino.** STRING vs varchar, DOUBLE vs double — косметическая разница в синтаксисе, не в данных. Упомянуть в секции 3. +- **Floating-point различия в агрегатах.** `avg()` может дать незначительно разные последние знаки. Упомянуть как нормальное поведение. +- **Пакет `trino` требует пересборки образа.** Нужен `docker compose build jupyter`. Без пакета ноутбук не выполняется (ImportError в секции 1). Пояснение в markdown с командой пересборки. +- **Порт 8090 занят.** Стандартная диагностика в START_HERE.md. + +## Out of Scope + +- Глубокое сравнение Spark и Trino (performance, оптимизаторы). +- Запись данных через Trino (в курсе Trino только читает). +- Trino CLI как основной инструмент. +- Настройка Trino connector. +- dbt, Airflow, оркестрация. +- Партиционирование и partition pruning. +- Schema evolution через Trino — Модуль 7. +- Trino security, users, roles. From a31a020a76355cd53de904812cf56f22fd0bcd26 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 22:27:54 +0300 Subject: [PATCH 17/24] =?UTF-8?q?docs(plan):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=207=20=D0=BF=D0=BE=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D1=8B=D0=BC=20=D0=B8?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=D0=BC=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=B8=D1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужно продемонстрировать возможности Iceberg по schema evolution и time travel. - Что: - создан файл plans/module-07-safe-table-changes.md с планом модуля. - обновлен plans/README.md с записью о новом модуле в таблицу содержания. - Проверка: - проверка согласованности ссылок и структуры документации. --- plans/README.md | 1 + plans/module-07-safe-table-changes.md | 454 ++++++++++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 plans/module-07-safe-table-changes.md diff --git a/plans/README.md b/plans/README.md index df89af2..43640f9 100644 --- a/plans/README.md +++ b/plans/README.md @@ -19,3 +19,4 @@ | 4. Первая рабочая Iceberg-таблица и слой bronze | [module-04-bronze-with-iceberg.md](./module-04-bronze-with-iceberg.md) | Draft | `notebooks/04_bronze_with_iceberg.ipynb` | 2026-03-07 | | 5. Слой silver и воспроизводимые трансформации | [module-05-silver-layer.md](./module-05-silver-layer.md) | Draft | `notebooks/05_silver_layer.ipynb` | 2026-03-07 | | 6. Одна таблица, два движка: Spark и Trino | [module-06-spark-and-trino-on-same-table.md](./module-06-spark-and-trino-on-same-table.md) | Draft | `jupyter/Dockerfile`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/06_spark_and_trino_on_same_table.ipynb` | 2026-03-07 | +| 7. Безопасная работа с таблицами: schema evolution и time travel | [module-07-safe-table-changes.md](./module-07-safe-table-changes.md) | Draft | `notebooks/07_safe_table_changes.ipynb` | 2026-03-07 | diff --git a/plans/module-07-safe-table-changes.md b/plans/module-07-safe-table-changes.md new file mode 100644 index 0000000..79a8f60 --- /dev/null +++ b/plans/module-07-safe-table-changes.md @@ -0,0 +1,454 @@ +# Модуль 7. Безопасная работа с таблицами: schema evolution и time travel + +**Статус:** `Draft` +**Последнее обновление:** `2026-03-07` + +## Цель + +Научить студента безопасно изменять Iceberg-таблицы и использовать встроенные защитные механизмы формата. Студент добавляет колонку к silver-таблице (schema evolution), наблюдает эффект на downstream-чтение из Trino, изучает snapshot-историю, выполняет time travel к предыдущему состоянию и восстанавливает таблицу после намеренной ошибки. Результат: студент понимает, что Iceberg хранит полную историю изменений и предоставляет инструменты безопасной работы, которых нет в классических СУБД. + +## Результат для студента + +После прохождения модуля студент: + +- умеет добавить колонку к Iceberg-таблице через `ALTER TABLE` и понимает, что существующие данные получают NULL; +- умеет проверить результат schema evolution из Spark и из Trino; +- понимает влияние schema evolution на downstream-чтение (новая колонка видна всем движкам автоматически через общий каталог); +- умеет посмотреть историю snapshot-ов таблицы и понимает, что каждая операция с данными создаёт snapshot; +- умеет прочитать предыдущее состояние таблицы через time travel (`VERSION AS OF`, `TIMESTAMP AS OF`); +- умеет восстановить таблицу после ошибки через `rollback_to_snapshot`; +- знает типичные ошибки новичка: слепой overwrite (`CREATE OR REPLACE`), изменение типов без проверки, отсутствие проверки snapshot-истории перед деструктивными операциями; +- может сопоставить snapshot-ы и time travel с отсутствием аналогичных механизмов в PostgreSQL/Greenplum (где для отката нужен бэкап/PITR). + +## Deliverables + +- `notebooks/07_safe_table_changes.ipynb` — основной ноутбук Модуля 7; +- обновление `plans/README.md` — строка Модуля 7 в оглавлении. + +Инфраструктурные изменения не требуются: Python-пакет `trino` уже добавлен в `jupyter/Dockerfile` в Модуле 6. + +## Зависимости + +### Предусловия + +- Стенд поднят и работает (Модуль 1). +- Студент прошёл Модуль 5: таблица `lakehouse.silver.nyc_taxi_yellow` существует (не менее 24 колонок). +- Студент прошёл Модуль 6: Python `trino` клиент установлен, helper `trino_query()` используется для Trino-запросов. + +### Что используют последующие модули + +- Модуль 8 использует silver-таблицу для финальной практики, compaction и vacuum. Колонка `loaded_at` — демонстрационный результат schema evolution; Модуль 8 НЕ зависит от её наличия и должен работать независимо от того, прошёл ли студент Модуль 7 или перезапустил Модуль 5 после него. +- Модуль 8 демонстрирует `expire_snapshots` — логическое продолжение snapshot-концепции из Модуля 7. + +## Дизайн-решения + +### Schema evolution на silver: реалистичная безопасная операция + +`ADD COLUMN` — единственная операция schema evolution, которая применяется к production-like таблице (silver). Это осознанный выбор: + +- `ADD COLUMN` — гарантированно безопасная операция: существующие данные не изменяются, новая колонка получает NULL; +- студент видит, что schema evolution можно делать на рабочих таблицах, а не только на throwaway-демо; +- downstream (Trino) автоматически видит новую колонку — подкрепление урока Модуля 6; +- добавленная колонка `loaded_at TIMESTAMP` — демонстрационная модификация. Модуль 8 НЕ зависит от её наличия: если студент перезапустит Модуль 5 после Модуля 7, `CREATE OR REPLACE` пересоздаст silver без `loaded_at`, и Модуль 8 продолжит работать. Это осознанное решение: schema evolution демонстрируется на реальной таблице, но не создаёт хрупкой зависимости между модулями. + +`RENAME COLUMN` и другие потенциально опасные операции демонстрируются только на демо-таблице. + +### Различие между schema evolution и data snapshot + +Важный нюанс для ноутбука: `ALTER TABLE ADD COLUMNS` — metadata-only операция. Она создаёт новую версию metadata (новый `metadata.json`), но НЕ создаёт новый data snapshot. Data files не перезаписываются. Snapshot-ы фиксируют изменения данных (INSERT, overwrite, delete), а не изменения схемы. Это различие нужно проговорить в markdown, чтобы студент не путал schema change с data change. + +### Демо-таблица для time travel и экспериментов + +Для демонстрации time travel и восстановления после ошибки создаётся отдельная таблица `lakehouse.default.taxi_changes_demo` (небольшое подмножество silver — 1000 строк). Причины: + +- time travel требует нескольких snapshot-ов, которые создаются через `INSERT INTO`; +- деструктивные эксперименты (INSERT плохих данных, RENAME) не затрагивают silver; +- демо-таблица удаляется в конце модуля. + +### Механизм recovery: rollback_to_snapshot + +Iceberg предоставляет stored procedure `CALL system.rollback_to_snapshot(table, snapshot_id)`. Это: + +- создаёт НОВЫЙ snapshot, указывающий на данные старого — история не теряется; +- старые data files не удаляются — они остаются до процедуры `expire_snapshots` / vacuum (Модуль 8); +- семантически аналогично `git revert` (новый коммит, отменяющий изменение), а не `git reset --hard` (удаление истории). + +Точный синтаксис stored procedure зависит от версии Iceberg и Spark — нужна проверка на стенде при реализации. + +### Greenplum-параллель: отсутствие встроенного time travel + +В PostgreSQL/Greenplum нет встроенных snapshot-ов на уровне таблицы. Для отката к предыдущему состоянию нужно: + +- восстановление из бэкапа (`pg_dump` / `pg_restore`) — медленно, затрагивает всю базу; +- Point-in-Time Recovery (PITR) — сложная настройка, затрагивает весь кластер; +- ручной откат через «обратные» SQL-операции — хрупко и ненадёжно. + +В Iceberg каждая операция с данными автоматически создаёт snapshot. Time travel — встроенная возможность формата, а не дополнительная инфраструктура. + +### Cleanup + +Демо-таблица `taxi_changes_demo` удаляется в конце модуля. Silver остаётся для Модуля 8. Колонка `loaded_at` — демонстрационный результат schema evolution; Модуль 8 не зависит от её наличия. Spark-сессия останавливается. + +## План работ + +1. Создать `notebooks/07_safe_table_changes.ipynb` по ячеечной структуре ниже. +2. Обновить `plans/README.md` — добавить строку Модуля 7 в таблицу оглавления. +3. Валидация: выполнить ноутбук сверху вниз на поднятом стенде после Модуля 6. Silver-таблица и Trino-доступ должны быть на месте. + +## Структура ноутбука + +### Секция 0: Введение + +- **[md]** Заголовок, цели модуля, prerequisites (Модуль 6 пройден). Таблица-сравнение: + +| | Классический DWH (Greenplum) | Lakehouse (Iceberg) | +|---|---|---| +| Добавить колонку | `ALTER TABLE ADD COLUMN` (мгновенно, NULL) | `ALTER TABLE ADD COLUMNS` (мгновенно, NULL) | +| Переименовать колонку | `ALTER TABLE RENAME COLUMN` | `ALTER TABLE RENAME COLUMN` | +| Откатить данные к вчерашнему состоянию | Восстановление из бэкапа (pg_dump / PITR) | `SELECT ... VERSION AS OF ` | +| Посмотреть историю изменений таблицы | Нет встроенного механизма | `SELECT * FROM table.snapshots` | +| Последствия ошибочной записи | Нужен бэкап или ручной откат | Rollback к предыдущему snapshot | + +- **[md]** Ключевая идея: Iceberg хранит полную историю изменений данных. Schema evolution и time travel — встроенные инструменты формата, а не дополнительная инфраструктура. +- **[md]** Как устроен этот ноутбук: schema evolution демонстрируем на silver-таблице (безопасная операция). Time travel и восстановление после ошибки — на отдельной демо-таблице (чтобы не рисковать данными для Модуля 8). Trino используется для проверки downstream-эффекта. + +### Секция 1: Spark-сессия, Trino-клиент и проверка таблиц + +- **[code]** SparkSession (паттерн Модулей 2-6). Константы: `SILVER_TABLE = "lakehouse.silver.nyc_taxi_yellow"`, `DEMO_TABLE = "lakehouse.default.taxi_changes_demo"`. +- **[code]** Assert: silver существует и непуста. Отсылка к Модулю 5. +- **[code]** Helper `trino_query(sql)` (паттерн Модуля 6). При `ImportError` — отсылка к Модулю 6: нужно пересобрать образ с `trino` пакетом. +- **[md]** Silver на месте, Trino готов. В этом модуле мы будем изменять таблицу и наблюдать последствия из обоих движков. + +### Секция 2: Snapshot-ы — встроенная история изменений + +- **[md]** В Модуле 4 мы впервые увидели snapshot-ы bronze-таблицы. Теперь разберёмся глубже. Каждая операция с данными в Iceberg (INSERT, overwrite, delete) автоматически создаёт snapshot — «снимок» состояния таблицы. Snapshot фиксирует, какие data files составляли таблицу в этот момент. Это как коммит в git: можно вернуться к любому предыдущему состоянию. +- **[code]** `spark.sql("SELECT snapshot_id, committed_at, operation, summary FROM lakehouse.silver.nyc_taxi_yellow.snapshots").show(truncate=False)`. +- **[code]** `spark.sql("SELECT * FROM lakehouse.silver.nyc_taxi_yellow.history").show(truncate=False)`. +- **[md]** Пояснение полей: `committed_at` — когда произошла операция, `operation` — тип (append, overwrite), `summary` — краткая статистика (added-data-files, total-records). Сейчас у silver один snapshot — от CTAS в Модуле 5. В Greenplum такой истории нет: после `INSERT` предыдущее состояние таблицы недоступно без бэкапа. + +### Секция 3: Schema evolution — безопасное добавление колонки + +- **[md]** Добавляем колонку `loaded_at` к silver. В PostgreSQL `ALTER TABLE ADD COLUMN` — обычная операция. В Iceberg — аналогично, но с важным свойством: Iceberg хранит историю схем. Добавление колонки — metadata-only операция: data files не перезаписываются, существующие строки получают NULL. +- **[md]** Важно: `ALTER TABLE ADD COLUMNS` НЕ создаёт новый data snapshot. Это изменение метаданных (schema), а не данных. Snapshot-ы фиксируют операции с данными (INSERT, DELETE, overwrite). Различие важно: time travel работает с data snapshot-ами, а не с версиями схемы. +- **[code]** Добавление колонки с защитой от повторного запуска: + +```python +try: + spark.sql("ALTER TABLE lakehouse.silver.nyc_taxi_yellow ADD COLUMNS (loaded_at TIMESTAMP)") + print("Колонка loaded_at добавлена.") +except Exception as e: + if "already exists" in str(e).lower(): + print("Колонка loaded_at уже существует (повторный запуск ноутбука). Продолжаем.") + else: + raise +``` +- **[code]** Проверка через Spark: `spark.table(SILVER_TABLE).printSchema()` — новая колонка видна. `spark.sql("SELECT loaded_at FROM lakehouse.silver.nyc_taxi_yellow LIMIT 5").show()` — все значения NULL. +- **[md]** Колонка добавлена. Данные не изменились — Iceberg не перезаписывал data files. В PostgreSQL для nullable-колонок было бы так же: `ALTER TABLE ADD COLUMN` мгновенно, без перезаписи таблицы. +- **[md]** В рабочем сценарии `loaded_at` заполнялся бы при следующих INSERT-ах: `INSERT INTO ... SELECT ..., current_timestamp() AS loaded_at FROM ...`. Существующие строки остаются с NULL — это нормальная практика эволюции схемы. Заполнение существующих строк через UPDATE — отдельная тема, вне скоупа курса. +- **[md]** Примечание: если вы повторно запустите Модуль 5 после Модуля 7, `CREATE OR REPLACE TABLE` пересоздаст silver без колонки `loaded_at`. Это ещё одна иллюстрация того, почему `CREATE OR REPLACE` опасен в рабочих сценариях — он стирает все изменения, включая schema evolution. + +### Секция 4: Downstream-эффект — Trino видит изменение + +- **[md]** В Модуле 6 мы убедились: Spark и Trino читают одну таблицу через общий каталог. Schema evolution — ещё одно следствие: изменение схемы в Spark мгновенно видно в Trino. Не нужно «синхронизировать» или «обновлять» что-то на стороне Trino. +- **[code]** `trino_query("DESCRIBE lakehouse.silver.nyc_taxi_yellow")` — `loaded_at` присутствует в списке колонок. +- **[code]** `trino_query("SELECT loaded_at FROM lakehouse.silver.nyc_taxi_yellow LIMIT 5")` — NULL. +- **[md]** Trino увидел новую колонку мгновенно. Spark изменил метаданные в PostgreSQL (JDBC catalog), Trino прочитал обновлённые метаданные. Decoupled compute + общий каталог. _DBeaver: `DESCRIBE lakehouse.silver.nyc_taxi_yellow;`_ + +### Секция 5: Обзор операций schema evolution + +- **[md]** `ADD COLUMN` — самая безопасная операция. Но Iceberg поддерживает и другие: + +| Операция | Spark SQL | Безопасность | Влияние на downstream | +|---|---|---|---| +| Добавить колонку | `ALTER TABLE ADD COLUMNS (col type)` | Безопасно | Новая колонка с NULL, существующие запросы не ломаются | +| Переименовать колонку | `ALTER TABLE RENAME COLUMN old TO new` | Осторожно | Запросы с `SELECT old_name` перестают работать | +| Расширить тип | `ALTER TABLE ALTER COLUMN col TYPE bigint` | Безопасно | INT -> BIGINT: без потери данных | +| Сузить тип | — | Опасно | BIGINT -> INT: возможна потеря данных. Iceberg не поддерживает | +| Удалить колонку | `ALTER TABLE DROP COLUMN col` | Опасно | Запросы с `SELECT col` перестают работать | + +- **[md]** В этом модуле мы продемонстрируем `RENAME COLUMN` на демо-таблице (Секция 9) и покажем, как это ломает downstream-запросы. `ALTER COLUMN TYPE` (расширение) и `DROP COLUMN` — вне скоупа курса, упоминаются для полноты. + +### Секция 6: Подготовка демо-таблицы с несколькими snapshot-ами + +- **[md]** Для экспериментов с time travel и восстановлением создаём отдельную таблицу. Silver не трогаем — он нужен для Модуля 8. Создаём таблицу с небольшим подмножеством silver и наращиваем snapshot-историю через `INSERT INTO`. +- **[code]** Создание `lakehouse.default.taxi_changes_demo` через CTAS (1000 строк из silver): + +```python +spark.sql(""" + CREATE OR REPLACE TABLE lakehouse.default.taxi_changes_demo + USING iceberg AS + SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime, + passenger_count, trip_distance, fare_amount, total_amount, + pickup_borough, pickup_zone + FROM lakehouse.silver.nyc_taxi_yellow + LIMIT 1000 +""") +``` + +- **[code]** Проверка: count = 1000. Snapshot-ы: 1 snapshot (overwrite — от CTAS). +- **[code]** INSERT ещё 500 строк -> snapshot 2: + +```python +spark.sql(""" + INSERT INTO lakehouse.default.taxi_changes_demo + SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime, + passenger_count, trip_distance, fare_amount, total_amount, + pickup_borough, pickup_zone + FROM lakehouse.silver.nyc_taxi_yellow + WHERE pickup_borough = 'Manhattan' + LIMIT 500 +""") +``` + +- **[code]** Проверка: count = 1500. Snapshot-ы: 2 snapshot-а. Вывод `snapshot_id` обоих с сохранением в переменные `snapshot_1`, `snapshot_2`. +- **[md]** Теперь у таблицы два snapshot-а: начальная загрузка (1000 строк) и добавление (500 строк). Это как два коммита в git. Каждый фиксирует конкретное состояние таблицы. + +### Секция 7: Time travel — чтение предыдущих состояний + +- **[md]** Центральная возможность Iceberg: можно прочитать таблицу в том состоянии, в каком она была на момент любого snapshot-а. Это time travel. В Greenplum для этого пришлось бы восстанавливать бэкап целой базы. + +#### 7a: Time travel через Spark + +- **[code]** Чтение snapshot 1 (начальная загрузка, 1000 строк): + +```python +spark.sql(f""" + SELECT count(*) AS row_count + FROM lakehouse.default.taxi_changes_demo + VERSION AS OF {snapshot_1} +""").show() +``` + +- **[code]** Чтение текущего состояния (1500 строк) для сравнения. +- **[md]** Snapshot 1: 1000 строк. Текущий: 1500 строк. Данные из первого snapshot-а не потеряны — оба состояния доступны одновременно. Iceberg хранит все data files; snapshot определяет, какие из них составляют таблицу в конкретный момент. + +#### 7b: Time travel через Trino + +- **[code]** Time travel через Trino: + +```python +trino_query(f""" + SELECT count(*) AS row_count + FROM lakehouse.default.taxi_changes_demo + FOR VERSION AS OF {snapshot_1} +""") +``` + +- **[md]** Trino тоже умеет time travel. Тот же snapshot, та же таблица, тот же каталог. Обратите внимание на синтаксис: Spark — `VERSION AS OF`, Trino — `FOR VERSION AS OF`. _DBeaver: `SELECT count(*) FROM lakehouse.default.taxi_changes_demo FOR VERSION AS OF ;`_ + +#### 7c: Time travel по времени + +- **[code]** Альтернативный способ — по timestamp: + +```python +spark.sql(f""" + SELECT count(*) AS row_count + FROM lakehouse.default.taxi_changes_demo + TIMESTAMP AS OF '{committed_at_1}' +""").show() +``` + +- **[md]** `VERSION AS OF` — точный (по snapshot_id). `TIMESTAMP AS OF` — удобный (по времени, Iceberg находит ближайший snapshot). Для точного отката лучше использовать snapshot_id. + +### Секция 8: Намеренная ошибка и восстановление + +- **[md]** Ключевой урок модуля. Студент намеренно вносит «плохие» данные, затем восстанавливает таблицу через rollback. Цель: ошибки в Iceberg — не катастрофа, если понимаешь snapshot-историю. + +#### 8a: Вносим ошибку + +- **[code]** INSERT «плохих» данных (200 строк с отрицательными fare_amount): + +```python +spark.sql(""" + INSERT INTO lakehouse.default.taxi_changes_demo + SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime, + passenger_count, trip_distance, + -99.99 AS fare_amount, + -99.99 AS total_amount, + 'ERROR' AS pickup_borough, + 'BAD_DATA' AS pickup_zone + FROM lakehouse.silver.nyc_taxi_yellow + LIMIT 200 +""") +``` + +- **[code]** Проверка: count = 1700. `SELECT count(*) ... WHERE fare_amount < 0` -> 200 строк. Таблица «испорчена». +- **[code]** Snapshot-история: 3 snapshot-а. Третий — ошибка. +- **[md]** В Greenplum: 200 строк с `fare = -99.99` в production-таблице -> звонок DBA, восстановление из бэкапа (если он есть и свежий), простой сервиса. В Iceberg: rollback. + +#### 8b: Восстановление через rollback + +- **[code]** Восстановление: + +```python +spark.sql(f""" + CALL lakehouse.system.rollback_to_snapshot( + table => 'default.taxi_changes_demo', + snapshot_id => {snapshot_2} + ) +""") +``` + +- **[code]** Проверка: count = 1500. `SELECT count(*) ... WHERE fare_amount < 0` -> 0. Таблица восстановлена. + +#### 8c: Что произошло со snapshot-историей + +- **[code]** Snapshot-история: теперь 4 записи. Четвёртый snapshot — rollback, но его данные соответствуют snapshot 2. +- **[md]** Rollback не удаляет историю и не удаляет файлы. Он создаёт новый snapshot, указывающий на данные предыдущего состояния. Аналогия: + +| | git | Iceberg | +|---|---|---| +| Откат с сохранением истории | `git revert` (новый коммит) | `rollback_to_snapshot` (новый snapshot) | +| Откат с удалением истории | `git reset --hard` | `CREATE OR REPLACE` (уничтожает snapshot-ы) | + +- **[md]** Старые data files (включая «плохие» строки) остаются в MinIO до процедуры `expire_snapshots` — это тема Модуля 8. Rollback не чистит storage, он только меняет указатель текущего snapshot-а. + +### Секция 9: RENAME COLUMN и влияние на downstream + +- **[md]** Покажем, что `RENAME COLUMN` — безопасная операция для данных, но опасная для downstream-запросов. +- **[code]** `spark.sql("ALTER TABLE lakehouse.default.taxi_changes_demo RENAME COLUMN pickup_borough TO borough")`. +- **[code]** Spark: `spark.table(DEMO_TABLE).printSchema()` — колонка теперь `borough`. +- **[code]** Trino: `trino_query("SELECT borough FROM lakehouse.default.taxi_changes_demo LIMIT 3")` -> работает. +- **[code]** Trino: запрос со старым именем `pickup_borough` (обернуть в try/except, чтобы показать ошибку): + +```python +try: + trino_query("SELECT pickup_borough FROM lakehouse.default.taxi_changes_demo LIMIT 3") +except Exception as e: + print(f"Ошибка: {e}") +``` + +- **[md]** Данные не потеряны — колонка переименована в metadata. Но любой downstream-запрос, использовавший старое имя `pickup_borough`, сломается. В рабочем окружении перед `RENAME` нужно проверить, кто читает эту колонку. Параллель с PostgreSQL: `ALTER TABLE RENAME COLUMN` работает так же — запросы со старым именем перестают работать. + +### Секция 10: Типичные ошибки новичка + +- **[md]** Три ошибки, на которые обращает внимание PRD курса: + +**Ошибка 1: Слепой `CREATE OR REPLACE`.** +В Модулях 4 и 5 мы использовали `CREATE OR REPLACE TABLE ... AS SELECT` для создания bronze и silver. Это был осознанный компромисс: первичная загрузка, нет предыдущей истории, которую нужно сохранять. Но в рабочих сценариях `CREATE OR REPLACE` — деструктивная операция: она уничтожает ВСЮ snapshot-историю таблицы. Если у таблицы было 100 snapshot-ов, после `CREATE OR REPLACE` останется один. Time travel к предыдущим состояниям станет невозможен. + +**Ошибка 2: Изменение схемы без проверки downstream.** +`RENAME COLUMN` мы только что видели: данные целы, но запросы со старым именем ломаются. `ALTER COLUMN TYPE` (расширение INT -> BIGINT) безопасен для данных, но downstream-системы могут интерпретировать новый тип по-другому. Перед любым изменением схемы нужно понимать, кто читает эту таблицу. + +**Ошибка 3: Отсутствие проверки snapshot-истории перед деструктивной операцией.** +Перед любой операцией, изменяющей данные, полезно посмотреть текущее состояние: `SELECT * FROM table.snapshots`. Это занимает секунды и даёт понимание, какой snapshot станет точкой отката в случае проблемы. Привычка: посмотрел snapshot-ы -> выполнил операцию -> проверил результат -> убедился, что новый snapshot создан. + +### Секция 11: Самостоятельное задание + +- **[md]** Пересоздаём демо-таблицу с чистого листа. После демонстраций в Секциях 6-9 её схема изменена (RENAME), а snapshot-история содержит наши учебные эксперименты. Для самостоятельной работы нужна чистая таблица — студент сам построит snapshot-историю и выполнит rollback. +- **[code]** Пересоздание демо-таблицы (тот же CTAS, что в Секции 6): + +```python +spark.sql(""" + CREATE OR REPLACE TABLE lakehouse.default.taxi_changes_demo + USING iceberg AS + SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime, + passenger_count, trip_distance, fare_amount, total_amount, + pickup_borough, pickup_zone + FROM lakehouse.silver.nyc_taxi_yellow + LIMIT 1000 +""") +print(f"Демо-таблица пересоздана: {spark.table(DEMO_TABLE).count()} строк, 1 snapshot.") +``` + +- **[md]** Пять задач на демо-таблице `taxi_changes_demo`. + + **Задача 1 (schema evolution).** Добавь колонку `quality_flag STRING` к демо-таблице через `ALTER TABLE ADD COLUMNS`. Проверь из Spark и из Trino, что колонка появилась и все значения NULL. + + **Задача 2 (создание snapshot).** INSERT 300 строк из silver (WHERE `pickup_borough = 'Brooklyn'`) в демо-таблицу. Проверь, что появился новый snapshot. + + **Задача 3 (time travel).** Посмотри snapshot-историю демо-таблицы. Прочитай таблицу в состоянии ДО добавления 300 строк (Задача 2) через `VERSION AS OF`. Сравни количество строк. + + **Задача 4 (recovery).** INSERT 100 строк с `fare_amount = -1` и `pickup_zone = 'STUDENT_ERROR'` в демо-таблицу. Убедись, что «плохие» строки появились (`SELECT count(*) WHERE fare_amount < 0`). Затем выполни `rollback_to_snapshot` к snapshot-у ДО этого INSERT-а. Проверь, что таблица восстановлена: `fare_amount < 0` -> 0 строк, общий count вернулся к предыдущему значению. + + **Задача 5 (ответ).** Ответь в markdown-ячейке: чем `rollback_to_snapshot` отличается от `CREATE OR REPLACE TABLE ... AS SELECT * FROM table VERSION AS OF `? Что происходит с историей snapshot-ов в каждом случае? + +- **[code]** Пустая ячейка: `# Ваш код: ALTER TABLE ADD COLUMNS (quality_flag)` +- **[code]** Пустая ячейка: `# Ваш код: проверка из Spark и Trino` +- **[code]** Пустая ячейка: `# Ваш код: INSERT 300 строк из Brooklyn` +- **[code]** Пустая ячейка: `# Ваш код: snapshot-история` +- **[code]** Пустая ячейка: `# Ваш код: time travel — VERSION AS OF` +- **[code]** Пустая ячейка: `# Ваш код: INSERT 100 «плохих» строк` +- **[code]** Пустая ячейка: `# Ваш код: rollback_to_snapshot` +- **[code]** Пустая ячейка: `# Ваш код: проверка восстановления` +- **[md]** Пустая markdown-ячейка: `Ваш ответ на Задачу 5: ...` + +- **[md]** Дополнительная задача (по желанию): Посмотри snapshot-историю silver-таблицы. Сколько snapshot-ов у неё? Какие операции их создали? +- **[code]** Пустая ячейка: `# Ваш код: snapshot-история silver` + +### Секция 12: Checkpoint + +- **[md]** Вопросы: + 1. Что такое snapshot в Iceberg и когда он создаётся? + 2. Создаёт ли `ALTER TABLE ADD COLUMNS` новый snapshot? Почему? + 3. Чем `ADD COLUMN` отличается от `RENAME COLUMN` по влиянию на downstream? + 4. Как прочитать предыдущее состояние таблицы? (назовите два способа) + 5. Что делает `rollback_to_snapshot`? Теряется ли при этом snapshot-история? + 6. Почему `CREATE OR REPLACE` опаснее, чем `INSERT INTO` с последующим rollback? + 7. Какой аналог time travel в PostgreSQL/Greenplum и почему он сложнее? + +### Секция 13: Завершение + +- **[md]** Удаляем демо-таблицу — она больше не нужна. Silver остаётся для Модуля 8 (колонка `loaded_at` — демонстрационная; Модуль 8 не зависит от её наличия). +- **[code]** `spark.sql("DROP TABLE IF EXISTS lakehouse.default.taxi_changes_demo")`. +- **[md]** Итог модуля: + - Schema evolution (ADD COLUMN) — безопасная metadata-only операция, видна из всех движков. + - Snapshot-ы — автоматическая история изменений данных, встроенная в Iceberg. + - Time travel — чтение предыдущих состояний без бэкапов. + - Rollback — восстановление после ошибки с сохранением истории. + - В Модуле 8 — базовое обслуживание таблиц: compaction (проблема мелких файлов) и `expire_snapshots` (очистка старых snapshot-ов, которые мы научились использовать в этом модуле). Финальная практика. +- **[code]** `spark.stop()`. + +### Дизайн-решения по ноутбуку + +- **Исполняемый формат:** Spark SQL и PySpark для schema evolution и time travel. Trino-запросы через Python `trino` клиент для downstream-верификации. +- **DBeaver-подсказки:** markdown-заметки рядом с Trino-ячейками (паттерн Модуля 6). +- **Helper-код:** `trino_query(sql)` — переиспользование из Модуля 6. Snapshot_id сохраняются в Python-переменные для использования в time travel запросах. +- **Cleanup:** DROP демо-таблицы `taxi_changes_demo`. Silver остаётся (колонка `loaded_at` — демонстрационная, Модуль 8 не зависит от неё). +- **Порядок:** snapshots (обзор) -> schema evolution на silver -> downstream-проверка -> обзор операций -> демо-таблица -> time travel -> ошибка и recovery -> RENAME -> ошибки новичка -> самостоятельное задание. + +## Checkpoint + +Студент должен уметь: + +- добавить колонку к Iceberg-таблице и проверить результат из Spark и Trino; +- объяснить, почему ADD COLUMN безопасна, а RENAME опасна для downstream; +- показать snapshot-историю таблицы и объяснить, что означает каждый snapshot; +- прочитать предыдущее состояние таблицы через time travel (`VERSION AS OF`); +- восстановить таблицу после ошибки через `rollback_to_snapshot`; +- объяснить разницу между `rollback_to_snapshot` и `CREATE OR REPLACE`; +- назвать три типичные ошибки новичка при работе с Iceberg-таблицами. + +## Acceptance Criteria + +- `notebooks/07_safe_table_changes.ipynb` выполняется сверху вниз после прохождения Модуля 6 без внешних зависимостей (DBeaver не обязателен). +- Ноутбук содержит: демонстрацию schema evolution (`ADD COLUMN` на silver), time travel (`VERSION AS OF`), recovery (`rollback_to_snapshot`), демонстрацию влияния `RENAME COLUMN` на downstream. +- Методика курса: объяснение -> демонстрация -> самостоятельное повторение -> checkpoint. +- Паттерны Модулей 1-6: SparkSession без `.master()`, `setLogLevel("ERROR")`, `trino_query()`. +- Schema evolution на silver: только `ADD COLUMN` (безопасная операция). Опасные операции — на демо-таблице. +- При отсутствии silver — assert с отсылкой к Модулю 5. +- Текст на русском с параллелями к PostgreSQL/Greenplum. +- `plans/README.md` содержит строку Модуля 7. +- Демо-таблица `taxi_changes_demo` удаляется в конце модуля. Silver остаётся для Модуля 8 (колонка `loaded_at` — демонстрационная, Модуль 8 не зависит от её наличия). + +## Риски + +- **`rollback_to_snapshot` синтаксис.** Точный синтаксис stored procedure зависит от версии Iceberg и Spark. Варианты: именованные параметры (`table => '...', snapshot_id => ...`), позиционные (`'table', snapshot_id`). Нужна проверка на стенде. Fallback: `CREATE OR REPLACE TABLE ... AS SELECT * FROM table VERSION AS OF ` (менее элегантно, уничтожает snapshot-историю, но работает). +- **`ALTER TABLE ADD COLUMNS` vs `ADD COLUMN`.** В Spark SQL с Iceberg синтаксис — `ADD COLUMNS` (множественное число). Проверить на стенде. +- **Количество snapshot-ов silver.** Если студент перезапускал Модуль 5 через `CREATE OR REPLACE`, у silver может быть только 1 snapshot. Это нормально — `ADD COLUMN` не создаёт snapshot, но демо-таблица наращивает snapshot-историю через INSERT. +- **Time travel через Trino.** Синтаксис `FOR VERSION AS OF` может отличаться в зависимости от версии Trino. Проверить на стенде. +- **Floating-point в snapshot_id.** Snapshot ID — длинное число (long). При передаче в f-string и SQL нужно убедиться, что Python не конвертирует его в float. Использовать `int(snapshot_id)`. +- **RENAME COLUMN ошибка в Trino.** Trino-запрос со старым именем колонки должен упасть с ошибкой. Обернуть в try/except с пояснением в markdown. +- **Идемпотентность.** Повторный запуск ноутбука: `ALTER TABLE ADD COLUMNS (loaded_at TIMESTAMP)` упадёт, если колонка уже существует. Решение зафиксировано: обернуть в try/except с проверкой `"already exists"` в тексте ошибки (см. Секцию 3). Это переносимый подход, не зависящий от поддержки `IF NOT EXISTS` конкретной версией Iceberg. Демо-таблица пересоздаётся через `CREATE OR REPLACE` — идемпотентна. +- **Влияние на Модуль 8.** Колонка `loaded_at` — демонстрационная. Модуль 8 не должен от неё зависеть. Если студент перезапустит Модуль 5 после Модуля 7, silver будет без `loaded_at` — Модуль 8 должен работать в обоих случаях. + +## Out of Scope + +- `ALTER TABLE ALTER COLUMN TYPE` (widening/narrowing) — упоминается в обзорной таблице, не демонстрируется. +- `DROP COLUMN` — упоминается как опасная, не демонстрируется. +- `MERGE`, row-level `DELETE`, `UPDATE` — PRD: вне v1. +- Partition evolution — отложено (partition pruning вне v1). +- Deep dive в Iceberg metadata JSON / manifest files — PRD: вне v1. +- `expire_snapshots` / vacuum — тема Модуля 8. +- Schema evolution через Trino (запись через Trino вне курса). +- Branching / tagging в Iceberg — advanced feature, вне v1. From 4a736f6c4ea2884a00d8e2d3693b42e9d18be89c Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 23:13:37 +0300 Subject: [PATCH 18/24] =?UTF-8?q?docs(plan):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=208=20=D0=BF=D0=BE=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=81=D0=BB=D1=83=D0=B6=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8E=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужно показать базовые операции обслуживания Iceberg-таблиц и финальную практику. - Что: - создан файл plans/module-08-maintenance-and-final-lab.md с планом модуля. - обновлен plans/README.md с записью о новом модуле в таблицу содержания. - Проверка: - проверка согласованности ссылок и структуры документации. --- plans/README.md | 1 + plans/module-08-maintenance-and-final-lab.md | 521 +++++++++++++++++++ 2 files changed, 522 insertions(+) create mode 100644 plans/module-08-maintenance-and-final-lab.md diff --git a/plans/README.md b/plans/README.md index 43640f9..574145d 100644 --- a/plans/README.md +++ b/plans/README.md @@ -20,3 +20,4 @@ | 5. Слой silver и воспроизводимые трансформации | [module-05-silver-layer.md](./module-05-silver-layer.md) | Draft | `notebooks/05_silver_layer.ipynb` | 2026-03-07 | | 6. Одна таблица, два движка: Spark и Trino | [module-06-spark-and-trino-on-same-table.md](./module-06-spark-and-trino-on-same-table.md) | Draft | `jupyter/Dockerfile`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/06_spark_and_trino_on_same_table.ipynb` | 2026-03-07 | | 7. Безопасная работа с таблицами: schema evolution и time travel | [module-07-safe-table-changes.md](./module-07-safe-table-changes.md) | Draft | `notebooks/07_safe_table_changes.ipynb` | 2026-03-07 | +| 8. Базовое обслуживание таблиц и финальная практика | [module-08-maintenance-and-final-lab.md](./module-08-maintenance-and-final-lab.md) | Draft | `notebooks/08_maintenance_and_final_lab.ipynb` | 2026-03-07 | diff --git a/plans/module-08-maintenance-and-final-lab.md b/plans/module-08-maintenance-and-final-lab.md new file mode 100644 index 0000000..b37e823 --- /dev/null +++ b/plans/module-08-maintenance-and-final-lab.md @@ -0,0 +1,521 @@ +# Модуль 8. Базовое обслуживание таблиц и финальная практика + +**Статус:** `Draft` +**Последнее обновление:** `2026-03-07` + +## Цель + +Научить студента базовому обслуживанию Iceberg-таблиц (compaction и expire_snapshots) и закрепить все навыки курса в финальной сквозной практике. Студент создаёт демо-таблицу с искусственной проблемой мелких файлов, наблюдает деградацию, выполняет compaction и expire_snapshots, сопоставляет обслуживание с Greenplum AppendOnly, а затем самостоятельно проходит полный цикл `raw → bronze → silver → Trino → schema evolution → обслуживание` на отдельном мини-наборе данных. Результат: студент понимает, зачем Iceberg-таблицам нужно периодическое обслуживание, и может самостоятельно выполнить типовые DE-операции в Lakehouse-стенде. + +## Результат для студента + +После прохождения модуля студент: + +- понимает проблему мелких файлов (small files problem) и её влияние на производительность чтения; +- умеет выполнить compaction (`rewrite_data_files`) и проверить результат через metadata-таблицы Iceberg; +- умеет выполнить `expire_snapshots` и понимает компромисс: очистка хранилища vs потеря time travel; +- знает порядок обслуживания: сначала compaction, потом expire_snapshots; +- может сопоставить обслуживание Iceberg с обслуживанием Greenplum AppendOnly-таблиц; +- может самостоятельно выполнить полный цикл `raw → bronze → silver → Trino → schema evolution → обслуживание`; +- прошёл финальный checkpoint курса и может объяснить ключевые архитектурные и прикладные принципы Lakehouse. + +## Deliverables + +- `notebooks/08_maintenance_and_final_lab.ipynb` — основной ноутбук Модуля 8; +- обновление `plans/README.md` — строка Модуля 8 в оглавлении. + +Инфраструктурные изменения не требуются: Python-пакет `trino` уже добавлен в `jupyter/Dockerfile` в Модуле 6. + +## Зависимости + +### Предусловия + +- Стенд поднят и работает (Модуль 1). +- Студент прошёл Модуль 5: таблица `lakehouse.silver.nyc_taxi_yellow` существует (не менее 24 колонок). +- Студент прошёл Модуль 6: Python `trino` клиент установлен, helper `trino_query()` используется для Trino-запросов. +- Raw-данные загружены в MinIO (Модуль 3): `s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet`. +- Модуль 7 рекомендуется, но не является жёстким предусловием: концепции snapshot-ов кратко повторяются в контексте обслуживания. + +### Что используют последующие модули + +Это финальный модуль курса. Downstream-зависимостей нет. + +## Дизайн-решения + +### Демо-таблица для обслуживания: искусственная проблема мелких файлов + +Для демонстрации compaction и expire_snapshots создаётся таблица `lakehouse.default.taxi_maintenance_demo`. Silver-таблица не подходит: она создана одним CTAS и уже имеет оптимальную файловую структуру (мало крупных файлов). Демо-таблица намеренно фрагментируется: + +- CTAS с 500 строками (snapshot 1, 1 data file); +- 8 мелких INSERT INTO по ~100 строк каждый (snapshots 2–9, 8 новых data files); +- итого: ~1300 строк, 9 data files, 9 snapshots. + +Это создаёт наглядную проблему мелких файлов: 9 файлов вместо одного оптимального. + +### Compaction: rewrite_data_files + +`CALL lakehouse.system.rewrite_data_files(table => 'default.taxi_maintenance_demo')` — объединяет мелкие data files в меньшее количество крупных. Ключевые моменты для студента: + +- создаёт новый snapshot (данные переписаны в новые файлы); +- старые data files остаются в MinIO до `expire_snapshots` — они ещё нужны для time travel к старым snapshot-ам; +- аналогия: дефрагментация диска или `ALTER TABLE ... REORGANIZE` в Greenplum. + +### Порядок обслуживания: compaction → expire_snapshots + +Осознанный порядок: + +1. Сначала compaction — создаёт новые оптимальные файлы. +2. Потом expire_snapshots — удаляет старые snapshot-ы и файлы, на которые они ссылались (включая мелкие файлы до compaction). + +Если сделать наоборот: expire_snapshots удалит snapshot-ы, но старые мелкие файлы останутся, потому что текущий snapshot ещё на них ссылается. Compaction после этого создаст новые файлы, но старые мелкие уже не будут привязаны ни к одному snapshot-у и станут orphan-файлами. + +### expire_snapshots: компромисс между очисткой и time travel + +`CALL lakehouse.system.expire_snapshots(table => '...', retain_last => 2)` — удаляет все snapshot-ы, кроме N последних. Ключевые моменты: + +- удалённые snapshot-ы недоступны для time travel — это необратимо; +- по документации Iceberg, data files, на которые ссылались только удалённые snapshot-ы, должны удаляться из хранилища вместе со snapshot-ами. Однако поведение зависит от версии Iceberg, конфигурации каталога и файловой системы — **необходима проверка на стенде**. Если файлы не удаляются автоматически, в ноутбуке нужно явно отметить это и упомянуть `remove_orphan_files` как дополнительный шаг; +- `retain_last` — количество snapshot-ов, которые нужно сохранить (самые свежие); +- в production: expire_snapshots запускается периодически (Airflow, cron) с разумным retain_last (например, 10–30 дней истории). + +Точный синтаксис (именованные параметры, `older_than` vs `retain_last`) зависит от версии Iceberg — нужна проверка на стенде при реализации. + +### remove_orphan_files: упоминание без демонстрации + +`remove_orphan_files` удаляет файлы в хранилище, которые не принадлежат ни одному snapshot-у (например, от упавших записей). В курсе упоминается для полноты картины, но не демонстрируется: при штатной работе orphan-файлы не возникают, и процедура нужна только для edge cases. + +### Сравнение с silver: контрастная демонстрация + +После демонстрации проблемы мелких файлов на демо-таблице показываем файловую статистику silver-таблицы. Ожидаемый результат: silver имеет мало крупных файлов (создан одним CTAS). Контрастный вывод: compaction нужен после множества мелких записей, а не после однократной загрузки. + +### Greenplum AppendOnly: короткая параллель + +Greenplum AppendOnly-таблицы имеют аналогичные проблемы: + +- после UPDATE/DELETE в AppendOnly остаются «мёртвые» строки (dead tuples); +- `VACUUM` очищает мёртвые строки, `ALTER TABLE ... REORGANIZE` перестраивает сегменты; +- в Iceberg: `rewrite_data_files` аналог реорганизации, `expire_snapshots` аналог очистки устаревших версий. + +Ключевое сходство: обе системы требуют периодического обслуживания. Ни Iceberg, ни AppendOnly не делают это автоматически в базовой конфигурации (в отличие от autovacuum для heap-таблиц в PostgreSQL). + +### Финальная практика: отдельный namespace lakehouse.final + +Финальная сквозная практика выполняется в отдельном namespace `lakehouse.final`, чтобы: + +- не затрагивать основные таблицы bronze и silver; +- позволить студенту создать namespace самостоятельно (навык из Модуля 4); +- обеспечить чистый cleanup в конце. + +Студент создаёт две таблицы (`trips_bronze`, `trips_silver`), проводит весь цикл, после чего удаляет namespace. + +### Финальная практика: использование существующих raw-данных + +Студент читает raw-данные из MinIO (`s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet`), уже загруженные в Модуле 3. Повторная загрузка в MinIO не требуется. Для контроля объёма — LIMIT 5000 строк на этапе чтения raw. + +### Независимость от Модуля 7 + +Модуль 8 работает независимо от того, прошёл ли студент Модуль 7: + +- если Модуль 7 пройден: silver может содержать колонку `loaded_at` — Модуль 8 не зависит от её наличия; +- если студент перезапустил Модуль 5 после Модуля 7: silver будет без `loaded_at` — Модуль 8 продолжает работать; +- концепции snapshot-ов кратко повторяются при объяснении expire_snapshots. + +### Cleanup + +- Демо-таблица `taxi_maintenance_demo` удаляется после демонстрации. +- Финальная практика: таблицы `lakehouse.final.trips_bronze` и `lakehouse.final.trips_silver` удаляются, namespace `lakehouse.final` удаляется. +- Silver (`lakehouse.silver.nyc_taxi_yellow`) и bronze (`lakehouse.bronze.nyc_taxi_yellow`) сохраняются — студент может продолжить эксперименты. +- Spark-сессия останавливается. + +## План работ + +1. Создать `notebooks/08_maintenance_and_final_lab.ipynb` по ячеечной структуре ниже. +2. Обновить `plans/README.md` — добавить строку Модуля 8 в таблицу оглавления. +3. Валидация: выполнить ноутбук сверху вниз на поднятом стенде после Модуля 6 (silver-таблица и Trino-доступ на месте, raw-данные в MinIO). + +## Структура ноутбука + +### Секция 0: Введение + +- **[md]** Заголовок, цели модуля, prerequisites (Модуль 6 пройден, Модуль 7 рекомендуется). Две части модуля: + + 1. Базовое обслуживание: compaction и expire_snapshots. + 2. Финальная практика: сквозной end-to-end сценарий. + +- **[md]** Зачем нужно обслуживание. Iceberg-таблица — не «чёрный ящик». После множества записей накапливаются мелкие файлы и старые snapshot-ы. Без обслуживания: чтение замедляется (много мелких файлов), хранилище растёт (старые данные не удаляются). Это знакомо из мира Greenplum: AppendOnly-таблицы тоже требуют VACUUM и REORGANIZE. +- **[md]** Таблица-сравнение (предварительный обзор): + +| | Greenplum AppendOnly | Iceberg | +|---|---|---| +| Проблема | Мёртвые строки после UPDATE/DELETE | Мелкие файлы после множества INSERT | +| Компактификация | `ALTER TABLE ... REORGANIZE` | `rewrite_data_files` | +| Очистка устаревших версий | Нет встроенной истории версий | `expire_snapshots` | +| Очистка мёртвых строк/файлов | `VACUUM` | `expire_snapshots` (удаляет старые файлы) | +| Автоматизация в базовой конфигурации | Ручной запуск | Ручной запуск | +| В production | Расписание через cron/Airflow | Расписание через cron/Airflow | + +### Секция 1: Spark-сессия, Trino-клиент и проверка таблиц + +- **[code]** SparkSession (паттерн Модулей 2–7). Константы: `SILVER_TABLE = "lakehouse.silver.nyc_taxi_yellow"`, `DEMO_TABLE = "lakehouse.default.taxi_maintenance_demo"`, `RAW_PATH = "s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet"`. +- **[code]** Assert: silver существует и непуста. Отсылка к Модулю 5. +- **[code]** Assert: raw-данные доступны в MinIO. Проверка через `spark.read.parquet(RAW_PATH).limit(1)` — при ошибке понятное сообщение с отсылкой к Модулю 3: «Raw-данные не найдены по пути {RAW_PATH}. Вернись в Модуль 3 и выполни загрузку данных в MinIO.» Raw нужен для финальной практики (Секция 9). +- **[code]** Helper `trino_query(sql)` (паттерн Модуля 6). +- **[md]** Silver на месте, raw-данные доступны, Trino готов. В первой части модуля создадим демо-таблицу и научимся её обслуживать. Во второй — финальная практика. + +### Секция 2: Проблема мелких файлов + +- **[md]** Каждый `INSERT INTO` в Iceberg создаёт новые data files. Если вставлять данные мелкими порциями (частые микробатчи, ручные INSERT-ы, инкрементальные загрузки), таблица накапливает множество мелких файлов. Это замедляет чтение: движку приходится открывать и читать каждый файл отдельно. Проблема знакома в мире Hadoop/Hive — и в Iceberg она решается через compaction. + +- **[code]** Создание демо-таблицы с 500 строками (CTAS из silver): + +```python +spark.sql(f""" + CREATE OR REPLACE TABLE {DEMO_TABLE} + USING iceberg AS + SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime, + passenger_count, trip_distance, fare_amount, total_amount, + pickup_borough, pickup_zone + FROM {SILVER_TABLE} + LIMIT 500 +""") +print(f"Демо-таблица создана: {spark.table(DEMO_TABLE).count()} строк.") +``` + +- **[code]** 8 мелких INSERT INTO (цикл, по ~100 строк каждый): + +```python +for i in range(8): + spark.sql(f""" + INSERT INTO {DEMO_TABLE} + SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime, + passenger_count, trip_distance, fare_amount, total_amount, + pickup_borough, pickup_zone + FROM {SILVER_TABLE} + LIMIT 100 + """) + print(f"INSERT {i+1}/8 выполнен.") + +print(f"Итого строк: {spark.table(DEMO_TABLE).count()}") +``` + +- **[code]** Файловая статистика через metadata-таблицу: + +```python +files_df = spark.sql(f""" + SELECT file_path, file_format, record_count, file_size_in_bytes + FROM {DEMO_TABLE}.files +""") +print(f"Количество data files: {files_df.count()}") +files_df.show(truncate=40) +``` + +- **[code]** Snapshot-история: + +```python +spark.sql(f"SELECT snapshot_id, committed_at, operation, summary FROM {DEMO_TABLE}.snapshots").show(truncate=False) +``` + +- **[md]** Результат: 9 data files (1 от CTAS + 8 от INSERT), 9 snapshot-ов. Каждый INSERT создал отдельный маленький файл. В production-сценарии после недель инкрементальных загрузок таблица может накопить сотни и тысячи мелких файлов. + +- **[md]** Для сравнения — посмотрим на silver-таблицу, которая была создана одним CTAS: + +- **[code]** Файловая статистика silver: + +```python +silver_files = spark.sql(f"SELECT file_path, record_count, file_size_in_bytes FROM {SILVER_TABLE}.files") +print(f"Silver: {silver_files.count()} data file(s)") +silver_files.show(truncate=40) +``` + +- **[md]** Silver имеет мало крупных файлов — проблемы мелких файлов нет. Compaction нужен не всегда, а после множества мелких записей. Далее — исправляем проблему на демо-таблице. + +### Секция 3: Compaction — rewrite_data_files + +- **[md]** `rewrite_data_files` объединяет мелкие data files в меньшее количество крупных. Аналогия: дефрагментация диска. Или `ALTER TABLE ... REORGANIZE` в Greenplum, который перестраивает сегменты AppendOnly-таблицы. + +- **[code]** Файловая статистика ДО compaction (сохраняем для сравнения): + +```python +files_before = spark.sql(f"SELECT count(*) AS file_count, sum(file_size_in_bytes) AS total_bytes FROM {DEMO_TABLE}.files").first() +print(f"До compaction: {files_before['file_count']} файлов, {files_before['total_bytes']} байт") +``` + +- **[code]** Compaction: + +```python +spark.sql(f"CALL lakehouse.system.rewrite_data_files(table => 'default.taxi_maintenance_demo')") +print("Compaction выполнен.") +``` + +- **[code]** Файловая статистика ПОСЛЕ compaction: + +```python +files_after = spark.sql(f"SELECT count(*) AS file_count, sum(file_size_in_bytes) AS total_bytes FROM {DEMO_TABLE}.files").first() +print(f"После compaction: {files_after['file_count']} файлов, {files_after['total_bytes']} байт") +print(f"Было {files_before['file_count']} файлов → стало {files_after['file_count']}") +``` + +- **[code]** Проверка данных: count не изменился. + +```python +print(f"Строк в таблице: {spark.table(DEMO_TABLE).count()}") +``` + +- **[md]** Данные не изменились — только реорганизованы. Compaction создал новый snapshot с новыми, более крупными файлами. Но старые мелкие файлы всё ещё лежат в MinIO: они нужны для time travel к старым snapshot-ам. Чтобы освободить место — нужен `expire_snapshots`. + +- **[code]** Snapshot-история: появился новый snapshot от compaction. + +```python +spark.sql(f"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots").show(truncate=False) +``` + +### Секция 4: expire_snapshots — очистка истории + +- **[md]** После compaction в таблице 10 snapshot-ов: 9 от INSERT-ов + 1 от compaction. Старые snapshot-ы ссылаются на мелкие файлы, которые уже не нужны для текущего чтения. `expire_snapshots` удаляет старые snapshot-ы из метаданных. Data files, на которые ссылались только удалённые snapshot-ы, по спецификации Iceberg тоже должны удаляться — но это зависит от конфигурации стенда (проверим на практике). + +- **[md]** Важный компромисс: после expire_snapshots time travel к удалённым snapshot-ам невозможен. Это необратимо. В production нужно решить: сколько истории хранить? Обычно 10–30 дней. Для демо — оставим 2 последних snapshot-а. + +- **[code]** Snapshot-история ДО expire (сохраняем count): + +```python +snapshots_before = spark.sql(f"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots") +print(f"Snapshot-ов до expire: {snapshots_before.count()}") +snapshots_before.show(truncate=False) + +# Сохраняем ID первого snapshot для проверки time travel после expire +first_snapshot_id = snapshots_before.orderBy("committed_at").first()["snapshot_id"] +print(f"Самый старый snapshot: {first_snapshot_id}") +``` + +- **[code]** expire_snapshots: + +```python +spark.sql(f""" + CALL lakehouse.system.expire_snapshots( + table => 'default.taxi_maintenance_demo', + retain_last => 2 + ) +""") +print("expire_snapshots выполнен.") +``` + +- **[code]** Snapshot-история ПОСЛЕ expire: + +```python +snapshots_after = spark.sql(f"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots") +print(f"Snapshot-ов после expire: {snapshots_after.count()}") +snapshots_after.show(truncate=False) +``` + +- **[code]** Попытка time travel к удалённому snapshot (ожидаем ошибку): + +```python +try: + spark.sql(f""" + SELECT count(*) FROM {DEMO_TABLE} + VERSION AS OF {first_snapshot_id} + """).show() + print("Time travel сработал (snapshot не был удалён).") +except Exception as e: + print(f"Ожидаемая ошибка: {e}") + print("Snapshot удалён — time travel невозможен.") +``` + +- **[md]** Snapshot удалён, time travel невозможен. Это главное следствие expire_snapshots — необратимая потеря возможности отката к старым состояниям. В Модуле 7 мы использовали snapshot-ы для восстановления после ошибок — `expire_snapshots` лишает этой возможности для удалённых snapshot-ов. Поэтому expire нужно запускать обдуманно: не слишком агрессивно (потеря страховки), не слишком редко (рост метаданных и хранилища). + +- **[md]** Примечание: физическое удаление data files из MinIO после expire зависит от конфигурации стенда. Если файлы остались — для их очистки существует отдельная процедура `remove_orphan_files`, которая удаляет файлы, не принадлежащие ни одному snapshot-у. В штатном режиме orphan-файлы не возникают (кроме как после expire или упавших записей). В этом курсе `remove_orphan_files` не демонстрируется. + +### Секция 5: Порядок обслуживания и типичные ошибки + +- **[md]** Правильный порядок обслуживания Iceberg-таблицы: + +| Шаг | Операция | Что делает | Что будет, если пропустить | +|---|---|---|---| +| 1 | `rewrite_data_files` | Объединяет мелкие файлы в крупные | Чтение остаётся медленным | +| 2 | `expire_snapshots` | Удаляет старые snapshot-ы (и их файлы, если конфигурация позволяет) | Метаданные и хранилище растут без ограничений | + +Если сделать наоборот (сначала expire, потом compaction): expire удалит snapshot-ы, но мелкие файлы останутся (текущий snapshot всё ещё на них ссылается). Compaction создаст новые файлы, но старые мелкие станут orphan-файлами — нужен будет дополнительный `remove_orphan_files`. + +- **[md]** Типичные ошибки обслуживания: + + **Ошибка 1: Никогда не запускать compaction.** Таблица после месяцев инкрементальных загрузок имеет тысячи мелких файлов. Чтение занимает минуты вместо секунд. + + **Ошибка 2: expire_snapshots с `retain_last => 1` сразу после ошибки.** Студент вносит ошибку в данные (Модуль 7), затем запускает expire_snapshots, оставляя только последний (ошибочный) snapshot. Rollback к предыдущему состоянию невозможен. Правило: перед expire проверь, что текущее состояние таблицы корректно. + + **Ошибка 3: Запускать обслуживание на каждый INSERT.** Compaction и expire_snapshots — тяжёлые операции. Запускать их после каждой записи — расточительно. В production: по расписанию (раз в день/неделю), а не на каждый батч. + +### Секция 6: Параллель с Greenplum AppendOnly + +- **[md]** Развернутое сравнение для студентов с опытом Greenplum: + +| Аспект | Greenplum AppendOnly | Iceberg | +|---|---|---| +| Как накапливается «мусор» | UPDATE/DELETE оставляют мёртвые строки в сегментах | Множество INSERT создают мелкие data files | +| Влияние на чтение | Сканирование мёртвых строк замедляет запросы | Открытие множества мелких файлов замедляет запросы | +| Компактификация | `ALTER TABLE ... REORGANIZE` (перестройка сегментов) | `rewrite_data_files` (объединение файлов) | +| Очистка | `VACUUM` (удаление мёртвых строк) | `expire_snapshots` (удаление старых snapshot-ов; файлы — при поддержке конфигурацией) | +| Автоматизация | `autovacuum` для heap-таблиц, ручной VACUUM для AO | Ручной запуск, автоматизация через Airflow/cron | +| Просмотр состояния | `pg_stat_all_tables`, `gp_toolkit.gp_bloat_diag` | `table.files`, `table.snapshots` (metadata-таблицы Iceberg) | +| Time travel после очистки | Невозможен (нет встроенной истории) | Невозможен для удалённых snapshot-ов, возможен для оставшихся | + +- **[md]** Ключевое сходство: ни одна система не обслуживает себя полностью автоматически в базовой конфигурации. И в Greenplum, и в Iceberg — это задача инженера или оркестратора. Ключевое отличие: Iceberg даёт встроенную историю (snapshot-ы) и контролируемую очистку (`expire_snapshots` с `retain_last`), чего нет в Greenplum. + +### Секция 7: Самостоятельное задание — обслуживание + +- **[md]** Две задачи для закрепления навыков обслуживания. + + **Задача 1 (анализ silver).** Посмотри файловую статистику silver-таблицы (`lakehouse.silver.nyc_taxi_yellow`): количество data files, средний размер файла, количество snapshot-ов. Сравни с демо-таблицей до compaction. Нужна ли silver compaction? Ответь в markdown-ячейке и объясни — при каких условиях silver потребовала бы compaction? + + **Задача 2 (практика).** Создай таблицу `lakehouse.default.taxi_compaction_practice` через CTAS (500 строк из silver). Выполни 5 INSERT INTO по 200 строк. Посмотри файловую статистику, выполни compaction и expire_snapshots (retain_last => 1). Проверь результат. Удали таблицу. + +- **[code]** Пустая ячейка: `# Ваш код: файловая статистика silver (файлы, размеры, snapshot-ы)` +- **[md]** Пустая markdown-ячейка: `Ваш ответ на Задачу 1: нужна ли compaction для silver? При каких условиях потребовалась бы? ...` +- **[code]** Пустая ячейка: `# Ваш код: создание taxi_compaction_practice + 5 INSERT` +- **[code]** Пустая ячейка: `# Ваш код: файловая статистика до compaction` +- **[code]** Пустая ячейка: `# Ваш код: compaction + expire_snapshots` +- **[code]** Пустая ячейка: `# Ваш код: файловая статистика после + DROP TABLE` + +### Секция 8: Удаление демо-таблицы + +- **[md]** Демо-таблица для обслуживания больше не нужна. +- **[code]** `spark.sql(f"DROP TABLE IF EXISTS {DEMO_TABLE}")`. Вывод подтверждения. + +### Секция 9: Финальная практика — сквозной end-to-end сценарий + +- **[md]** Финальная практика курса. Студент самостоятельно проходит полный цикл Lakehouse-пайплайна: от raw-данных до обслуживания таблицы. Все навыки из Модулей 1–8 в одном сценарии. + + Работа выполняется в отдельном namespace `lakehouse.final`, чтобы не затрагивать основные таблицы курса. После завершения — cleanup. + + Подсказки: путь к raw-данным — `s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet` (Модуль 3). Для контроля объёма используй `LIMIT 5000`. + +- **[md]** **Сценарий: 8 шагов.** + + **Шаг 1 (namespace).** Создай namespace `lakehouse.final`. + + **Шаг 2 (bronze).** Прочитай raw parquet из MinIO (`s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet`, LIMIT 5000 строк) и создай таблицу `lakehouse.final.trips_bronze` через CTAS. Это bronze: данные «как есть», без трансформаций. + + **Шаг 3 (silver).** Создай `lakehouse.final.trips_silver` из bronze с тремя трансформациями: + - Фильтр: `fare_amount >= 0` (убрать аномалии) + - Нормализация: `passenger_count` → INT с обработкой NULL (`coalesce` + `cast`) + - Обогащение: вычисляемая колонка `trip_duration_minutes` + + **Шаг 4 (Trino).** Прочитай `lakehouse.final.trips_silver` из Trino. Сравни count с результатом из Spark. + + **Шаг 5 (schema evolution).** Добавь колонку `processed_at TIMESTAMP` к `trips_silver` через `ALTER TABLE ADD COLUMNS`. Проверь из Trino, что колонка видна. + + **Шаг 6 (snapshot-ы).** Выполни 3 INSERT INTO `trips_silver` по ~300 строк из `trips_bronze`. Каждый INSERT должен включать те же трансформации, что и в шаге 3, плюс `current_timestamp() AS processed_at` для новой колонки из шага 5. Подсказка: если не указать `processed_at`, Spark выдаст ошибку несовпадения числа колонок; альтернативный вариант — использовать явный список target-колонок в `INSERT INTO trips_silver (col1, col2, ...)`. Создай snapshot-историю. Посмотри файловую статистику. + + **Шаг 7 (обслуживание).** Выполни compaction (`rewrite_data_files`), затем `expire_snapshots` (retain_last => 1). Проверь: файлов стало меньше, snapshot-ов осталось 1. + + **Шаг 8 (cleanup).** DROP TABLE `trips_silver`, DROP TABLE `trips_bronze`, DROP NAMESPACE `lakehouse.final`. + +- **[code]** Пустая ячейка: `# Шаг 1: CREATE NAMESPACE` +- **[code]** Пустая ячейка: `# Шаг 2: прочитай raw и создай trips_bronze` +- **[code]** Пустая ячейка: `# Шаг 2: проверка trips_bronze (count, printSchema)` +- **[code]** Пустая ячейка: `# Шаг 3: создай trips_silver с трансформациями` +- **[code]** Пустая ячейка: `# Шаг 3: проверка trips_silver (count, проверка фильтра)` +- **[code]** Пустая ячейка: `# Шаг 4: чтение trips_silver из Trino` +- **[code]** Пустая ячейка: `# Шаг 5: ALTER TABLE ADD COLUMNS` +- **[code]** Пустая ячейка: `# Шаг 5: проверка из Trino (DESCRIBE)` +- **[code]** Пустая ячейка: `# Шаг 6: 3 INSERT INTO trips_silver` +- **[code]** Пустая ячейка: `# Шаг 6: файловая статистика и snapshot-ы` +- **[code]** Пустая ячейка: `# Шаг 7: compaction + expire_snapshots` +- **[code]** Пустая ячейка: `# Шаг 7: проверка результата` +- **[code]** Пустая ячейка: `# Шаг 8: cleanup (DROP TABLE, DROP NAMESPACE)` + +### Секция 10: Финальный checkpoint + +- **[md]** **Часть A — Обслуживание таблиц (Модуль 8):** + 1. Что такое проблема мелких файлов и когда она возникает? + 2. Что делает `rewrite_data_files`? Удаляет ли он старые файлы? + 3. Что делает `expire_snapshots`? Какой компромисс он создаёт? + 4. В каком порядке нужно выполнять compaction и expire_snapshots? Почему? + 5. Как обслуживание Iceberg-таблиц соотносится с VACUUM/REORGANIZE в Greenplum? + +- **[md]** **Часть B — Весь курс (итоговые вопросы):** + 1. Назови три роли в архитектуре Lakehouse и какие компоненты стенда их выполняют. + 2. Почему Spark может записать данные, а Trino прочитать ту же таблицу без копирования? + 3. Чем raw отличается от bronze? Чем bronze отличается от silver? + 4. Что такое snapshot в Iceberg? Чем он отличается от бэкапа в PostgreSQL? + 5. Назови три безопасные и три опасные операции с Iceberg-таблицей. + 6. Зачем нужны compaction и expire_snapshots в production-сценарии? + +### Секция 11: Завершение + +- **[md]** Итог модуля: + - Compaction (`rewrite_data_files`) — решает проблему мелких файлов, не меняя данные. + - `expire_snapshots` — очищает старые snapshot-ы и файлы, но лишает возможности time travel. + - Порядок: compaction → expire_snapshots. + - Обслуживание Iceberg похоже на обслуживание Greenplum AppendOnly: обе системы требуют периодической заботы. + +- **[md]** Итог курса. Что студент теперь умеет: + 1. Поднимать и диагностировать локальный Lakehouse-стенд (Модуль 1). + 2. Объяснять роли storage, catalog, compute (Модуль 2). + 3. Загружать raw-данные и проверять схему (Модуль 3). + 4. Создавать и заполнять Iceberg-таблицы (Модуль 4). + 5. Строить воспроизводимый поток raw → bronze → silver (Модуль 5). + 6. Читать одну таблицу из Spark и Trino (Модуль 6). + 7. Безопасно изменять таблицы: schema evolution, time travel, rollback (Модуль 7). + 8. Обслуживать таблицы: compaction, expire_snapshots (Модуль 8). + + Это базовый набор навыков для безопасной работы в Lakehouse-стенде. Дальше — production: партиционирование, MERGE, streaming, Airflow, governance. Но фундамент заложен. + +- **[code]** `spark.stop()`. + +### Дизайн-решения по ноутбуку + +- **Исполняемый формат:** Spark SQL для compaction и expire_snapshots (stored procedures). Trino-запросы через Python `trino` клиент для downstream-верификации в финальной практике. +- **DBeaver-подсказки:** markdown-заметки рядом с Trino-ячейками (паттерн Модуля 6). +- **Helper-код:** `trino_query(sql)` — переиспользование из Модуля 6. Файловая статистика через metadata-таблицы Iceberg (`.files`, `.snapshots`). +- **Cleanup:** DROP демо-таблицы `taxi_maintenance_demo`. Финальная практика: DROP таблиц в namespace `final`, DROP namespace. Silver и bronze сохраняются. +- **Порядок:** проблема мелких файлов → compaction → expire_snapshots → Greenplum-параллель → ошибки → самостоятельное задание (обслуживание) → финальная практика (end-to-end) → checkpoint (модуль + курс) → итог курса. + +## Checkpoint + +Студент должен уметь: + +- объяснить проблему мелких файлов и показать файловую статистику через metadata-таблицы; +- выполнить compaction и проверить, что файлов стало меньше, а данные не изменились; +- выполнить expire_snapshots и объяснить, почему time travel к удалённым snapshot-ам невозможен; +- назвать правильный порядок обслуживания и объяснить, почему он важен; +- сопоставить обслуживание Iceberg с обслуживанием Greenplum AppendOnly; +- самостоятельно выполнить полный цикл: raw → bronze → silver → Trino → schema evolution → обслуживание; +- ответить на итоговые вопросы курса по архитектуре, слоям, snapshot-ам и безопасным операциям. + +## Acceptance Criteria + +- `notebooks/08_maintenance_and_final_lab.ipynb` выполняется сверху вниз после прохождения Модуля 6 без внешних зависимостей (DBeaver не обязателен, Модуль 7 рекомендуется, но не обязателен). +- Ноутбук содержит: демонстрацию проблемы мелких файлов, compaction (`rewrite_data_files`), expire_snapshots, финальную сквозную практику. +- Методика курса: объяснение → демонстрация → самостоятельное повторение → checkpoint. +- Паттерны Модулей 1–7: SparkSession без `.master()`, `setLogLevel("ERROR")`, `trino_query()`. +- Демо-таблица `taxi_maintenance_demo` создаётся и удаляется внутри ноутбука. +- Финальная практика в отдельном namespace `lakehouse.final` с полным cleanup в конце. +- При отсутствии silver — assert с отсылкой к Модулю 5. При отсутствии raw-данных в MinIO — assert с отсылкой к Модулю 3. +- Текст на русском с параллелями к PostgreSQL/Greenplum. +- Финальный checkpoint покрывает как модуль, так и весь курс. +- Silver и bronze сохраняются после выполнения ноутбука. +- `plans/README.md` содержит строку Модуля 8. + +## Риски + +- **Синтаксис `rewrite_data_files`.** Точный синтаксис stored procedure зависит от версии Iceberg и Spark. Вариант: `CALL lakehouse.system.rewrite_data_files(table => 'default.taxi_maintenance_demo')`. Возможны различия в именованных vs позиционных параметрах. Нужна проверка на стенде. +- **Синтаксис `expire_snapshots`.** Возможные параметры: `older_than` (TIMESTAMP), `retain_last` (INT). По умолчанию `older_than` = 5 дней назад, что не подходит для демо (snapshot-ы созданы минуты назад). Нужно явно передать `retain_last => 2` и, возможно, `older_than => current_timestamp()`. Проверить на стенде, работает ли `retain_last` без `older_than`. +- **expire_snapshots не удаляет файлы.** В некоторых конфигурациях `expire_snapshots` может не удалять data files автоматически. В этом случае нужно добавить `remove_orphan_files` или явно упомянуть, что файлы удаляются отдельно. Проверить на стенде. +- **Время выполнения цикла INSERT.** 8 INSERT-ов по ~100 строк из silver: каждый INSERT — отдельный Spark job. На стенде это может занять 2–5 минут. Предупреждение в markdown. +- **Время выполнения compaction.** `rewrite_data_files` перечитывает и перезаписывает данные. На демо-таблице (~1300 строк) — секунды. На silver (миллионы строк) — потенциально минуты. Предупреждение в markdown. +- **Финальная практика: путь к raw-данным.** Путь `s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet` соответствует маршруту загрузки из Модуля 3. Если студент по какой-то причине загрузил файлы по другому пути, он увидит ошибку при чтении. Добавить подсказку в markdown: «Если путь отличается от указанного, посмотри Модуль 3.» +- **Финальная практика: объём INSERT-ов.** При LIMIT 5000 для bronze и 3 INSERT по 300 строк в silver — объём данных минимален. Compaction может быть no-op (файлы и так небольшие). Решение: в инструкции явно указать, что цель — увидеть изменение количества файлов и snapshot-ов, а не добиться значимого улучшения производительности. +- **Идемпотентность.** `CREATE OR REPLACE TABLE` для демо-таблицы — идемпотентна. Финальная практика: если студент запускает повторно, namespace и таблицы могут уже существовать. `CREATE NAMESPACE IF NOT EXISTS` и `CREATE OR REPLACE TABLE` обеспечивают идемпотентность. DROP в конце — `IF EXISTS`. +- **Независимость от Модуля 7.** Ноутбук не должен ссылаться на колонку `loaded_at` или результаты Модуля 7 как на обязательные предусловия. Snapshot-концепции кратко повторяются в контексте expire_snapshots. + +## Out of Scope + +- `remove_orphan_files` как отдельная демонстрация — упоминается, не демонстрируется. +- `rewrite_manifests` — оптимизация manifest-файлов, advanced-тема. +- Партиционирование и partition evolution — PRD: вне v1. +- Sort order и write distribution mode — advanced compaction options. +- Автоматизация обслуживания через Airflow/cron — упоминается как production-практика, не реализуется. +- Gold-слой в финальной практике — PRD: вне v1. +- Performance benchmarks (замеры времени чтения до/после compaction) — интересно, но выходит за рамки «базового обслуживания». +- `MERGE`, row-level `DELETE`, `UPDATE` — PRD: вне v1. +- Deep dive в Iceberg metadata JSON / manifest files — PRD: вне v1. From bcf7975227e0bcd7c4bf417c4b33c6d875b320c5 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 23:32:43 +0300 Subject: [PATCH 19/24] =?UTF-8?q?feat(notebooks):=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=205=20=D0=BF=D0=BE=20=D1=81=D0=BB=D0=BE?= =?UTF-8?q?=D1=8E=20silver=20=D0=B8=20=D1=82=D1=80=D0=B0=D0=BD=D1=81=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D1=8F=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - обучение переходу от bronze-таблицы к очищенному и обогащенному silver-слою. - Что: - создан notebooks/05_silver_layer.ipynb с пайплайном фильтрации, нормализации и JOIN. - в ноутбук добавлены проверки качества на уровнях строк, схемы и агрегатов. - статус модуля 5 в планах обновлен до Ready for validation. - Проверка: - ручная проверка структуры ноутбука и корректности Spark-трансформаций. --- notebooks/05_silver_layer.ipynb | 627 ++++++++++++++++++++++++++++++++ plans/README.md | 2 +- plans/module-05-silver-layer.md | 2 +- 3 files changed, 629 insertions(+), 2 deletions(-) create mode 100644 notebooks/05_silver_layer.ipynb diff --git a/notebooks/05_silver_layer.ipynb b/notebooks/05_silver_layer.ipynb new file mode 100644 index 0000000..03f6d60 --- /dev/null +++ b/notebooks/05_silver_layer.ipynb @@ -0,0 +1,627 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "section_0", + "metadata": {}, + "source": [ + "# Модуль 5. Слой silver и воспроизводимые трансформации\n", + "\n", + "В этом модуле мы переходим от bronze-таблицы «как есть» к осознанно очищенной и обогащённой silver-таблице.\n", + "\n", + "**Цели модуля:**\n", + "- понять, зачем нужен silver-слой и чем он отличается от bronze;\n", + "- научиться формулировать правила трансформации явно перед написанием кода;\n", + "- отфильтровать некачественные записи, нормализовать типы и обработать NULL;\n", + "- обогатить данные через JOIN с lookup-таблицей;\n", + "- выполнить проверки качества результата на трёх уровнях: строки, схема, агрегаты.\n", + "\n", + "**Prerequisite:**\n", + "- пройден Модуль 4: таблицы `lakehouse.bronze.nyc_taxi_yellow` и `lakehouse.bronze.taxi_zone_lookup` (самостоятельное задание) существуют.\n", + "\n", + "### Связка с привычным DWH (PostgreSQL / Greenplum)\n", + "\n", + "| | Классический DWH (Greenplum) | Lakehouse |\n", + "|---|---|---|\n", + "| Источник | ods/stg-таблица | Bronze Iceberg-таблица |\n", + "| Результат | dds/fact-таблица с очищенными данными | Silver Iceberg-таблица |\n", + "| Правила трансформаций | Mapping specification / ETL-документация | Явный реестр правил в ноутбуке перед кодом |\n", + "| Проверки качества | dq-check скрипты, data quality framework | Asserts на трёх уровнях |\n", + "| Воспроизводимость | Повторный запуск ETL | `CREATE OR REPLACE TABLE ... AS SELECT` |\n", + "\n", + "Ключевая идея: **silver = bronze + явные правила трансформации + проверки качества**." + ] + }, + { + "cell_type": "markdown", + "id": "section_1_title", + "metadata": {}, + "source": [ + "## 1. Spark-сессия и проверка bronze\n", + "\n", + "Инициализируем Spark и убеждаемся, что обе bronze-таблицы на месте. Теперь мы работаем только с ними и не обращаемся к raw-файлам напрямую." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_1_code", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "from pyspark.sql import SparkSession, functions as F\n", + "\n", + "CATALOG_NAME = \"lakehouse\"\n", + "BRONZE_NAMESPACE = f\"{CATALOG_NAME}.bronze\"\n", + "BRONZE_TABLE = f\"{BRONZE_NAMESPACE}.nyc_taxi_yellow\"\n", + "BRONZE_LOOKUP_TABLE = f\"{BRONZE_NAMESPACE}.taxi_zone_lookup\"\n", + "\n", + "SILVER_NAMESPACE = f\"{CATALOG_NAME}.silver\"\n", + "SILVER_TABLE = f\"{SILVER_NAMESPACE}.nyc_taxi_yellow\"\n", + "\n", + "spark = SparkSession.builder \\\n", + " .appName(\"module-05-silver-layer\") \\\n", + " .getOrCreate()\n", + "\n", + "spark.sparkContext.setLogLevel(\"ERROR\")\n", + "\n", + "print(\"Spark готов\")\n", + "print(f\"Bronze table: {BRONZE_TABLE}\")\n", + "print(f\"Silver table: {SILVER_TABLE}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_1_asserts", + "metadata": {}, + "outputs": [], + "source": [ + "assert spark.catalog.tableExists(BRONZE_TABLE), (\n", + " f\"Таблица {BRONZE_TABLE} не найдена. Вернись в Модуль 4 и выполни загрузку в bronze.\"\n", + ")\n", + "\n", + "assert spark.catalog.tableExists(BRONZE_LOOKUP_TABLE), (\n", + " f\"Таблица {BRONZE_LOOKUP_TABLE} не найдена. \"\n", + " \"Вернись в Модуль 4 и выполни самостоятельное задание (Секция 8).\"\n", + ")\n", + "\n", + "bronze_count = spark.table(BRONZE_TABLE).count()\n", + "assert bronze_count > 0, f\"Таблица {BRONZE_TABLE} пуста.\"\n", + "\n", + "print(f\"Обе bronze-таблицы на месте. В основной таблице {bronze_count:,} строк. Теперь строим silver.\")" + ] + }, + { + "cell_type": "markdown", + "id": "section_2_title", + "metadata": {}, + "source": [ + "## 2. Профиль bronze — что нужно трансформировать\n", + "\n", + "Прежде чем писать трансформации, понимаем текущее состояние данных. В Модуле 3 мы уже нашли аномалии. Теперь смотрим через bronze." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_2_code", + "metadata": {}, + "outputs": [], + "source": [ + "bronze_df = spark.table(BRONZE_TABLE)\n", + "\n", + "profile_df = bronze_df.select(\n", + " F.count(\"*\").alias(\"total_rows\"),\n", + " (F.sum(F.when(F.col(\"fare_amount\") < 0, 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"negative_fare_pct\"),\n", + " (F.sum(F.when(F.col(\"total_amount\") > 1000, 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"extreme_total_pct\"),\n", + " (F.sum(F.when((F.col(\"tpep_pickup_datetime\") < \"2024-01-01\") | (F.col(\"tpep_pickup_datetime\") >= \"2025-01-01\"), 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"out_of_range_date_pct\"),\n", + " (F.sum(F.when(F.col(\"passenger_count\").isNull(), 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"null_passengers_pct\"),\n", + " (F.sum(F.when(F.col(\"RatecodeID\").isNull(), 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"null_ratecode_pct\"),\n", + " (F.sum(F.when(F.col(\"congestion_surcharge\").isNull(), 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"null_congestion_pct\"),\n", + " (F.sum(F.when(F.col(\"Airport_fee\").isNull(), 1).otherwise(0)) / F.count(\"*\") * 100).alias(\"null_airport_fee_pct\")\n", + ")\n", + "\n", + "profile_pd = profile_df.toPandas().T\n", + "profile_pd.columns = [\"value\"]\n", + "profile_pd" + ] + }, + { + "cell_type": "markdown", + "id": "section_2_summary", + "metadata": {}, + "source": [ + "**Итоги профилирования:**\n", + "\n", + "| Проблема | Описание | Что будем делать |\n", + "|---|---|---|\n", + "| Отрицательные тарифы | Ошибки в данных (fare_amount < 0) | Фильтровать (F1) |\n", + "| Экстремальные суммы | Аномалии (total_amount > 1000) | Фильтровать (F2) |\n", + "| Даты вне диапазона | Записи за пределами 2024 года | Фильтровать (F3) |\n", + "| NULL в passenger_count | Не заполненные данные | Заполнять 0 и приводить к INT (N1) |\n", + "| NULL в RatecodeID | Не заполненные данные | Заполнять 99 (unknown) и приводить к INT (N2) |\n", + "| NULL в сборах | Не заполненные надбавки (surcharges/fees) | Заполнять 0.0 (N3, N4) |" + ] + }, + { + "cell_type": "markdown", + "id": "section_3_title", + "metadata": {}, + "source": [ + "## 3. Реестр правил трансформации\n", + "\n", + "Центральная идея модуля: фиксируем все правила явно перед кодом. Это аналог mapping specification в DWH. Если через месяц спросят «почему в silver нет строк с отрицательным fare?», ответ будет в реестре, а не зарыт в коде.\n", + "\n", + "| # | Колонка/область | Правило | Тип | Обоснование |\n", + "|---|---|---|---|---|\n", + "| **F1** | `fare_amount` | `fare_amount >= 0` | Фильтр | Отрицательные тарифы — явная аномалия |\n", + "| **F2** | `total_amount` | `total_amount <= 1000` | Фильтр | Экстремальные суммы — выбросы, искажающие среднее |\n", + "| **F3** | `tpep_pickup_datetime` | `[2024-01-01, 2025-01-01)` | Фильтр | Учебный диапазон данных — 2024 год |\n", + "| **N1** | `passenger_count` | `coalesce(cast(..., int), 0)` | Норм. | Приводим к целому, заменяем NULL на 0 |\n", + "| **N2** | `RatecodeID` | `coalesce(cast(..., int), 99)` | Норм. | Приводим к целому, заменяем NULL на 99 (Unknown) |\n", + "| **N3** | `congestion_surcharge` | `coalesce(..., 0.0)` | Норм. | Убираем NULL для корректности агрегатов |\n", + "| **N4** | `Airport_fee` | `coalesce(..., 0.0)` | Норм. | Убираем NULL для корректности агрегатов |\n", + "| **E1** | `trip_duration_minutes` | `(dropoff - pickup) / 60` | Обог. | Полезная вычисляемая метрика для анализа |\n", + "| **E2** | `pickup_zone/borough` | `LEFT JOIN zone_lookup` | Обог. | Понимаем, откуда поехала машина не по ID, а по имени |\n", + "| **E3** | `dropoff_zone/borough` | `LEFT JOIN zone_lookup` | Обог. | Понимаем, куда поехала машина не по ID, а по имени |" + ] + }, + { + "cell_type": "markdown", + "id": "section_4_title", + "metadata": {}, + "source": [ + "## 4. Применение правил — фильтрация\n", + "\n", + "Начинаем с фильтрации. Порядок осознанный: сначала фильтруем, потом нормализуем и обогащаем. Мы не тратим ресурсы на трансформацию строк, которые всё равно будут удалены." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_4_code", + "metadata": {}, + "outputs": [], + "source": [ + "bronze_df = spark.table(BRONZE_TABLE)\n", + "\n", + "filtered_df = (\n", + " bronze_df\n", + " .filter(F.col(\"fare_amount\") >= 0) # F1 \n", + " .filter(F.col(\"total_amount\") <= 1000) # F2 \n", + " .filter((F.col(\"tpep_pickup_datetime\") >= \"2024-01-01\") & (F.col(\"tpep_pickup_datetime\") < \"2025-01-01\")) # F3\n", + ")\n", + "\n", + "filtered_count = filtered_df.count()\n", + "removed_count = bronze_count - filtered_count\n", + "removed_pct = (removed_count / bronze_count) * 100\n", + "\n", + "print(f\"Строк до фильтрации: {bronze_count:,}\")\n", + "print(f\"Строк после фильтрации: {filtered_count:,}\")\n", + "print(f\"Удалено аномалий: {removed_count:,} ({removed_pct:.2f}%)\")" + ] + }, + { + "cell_type": "markdown", + "id": "section_4_note", + "metadata": {}, + "source": [ + "Обычно фильтры качества удаляют небольшую долю строк. Если фильтр убирает заметную часть данных — это сигнал перепроверить правило или источник." + ] + }, + { + "cell_type": "markdown", + "id": "section_5_title", + "metadata": {}, + "source": [ + "## 5. Применение правил — нормализация\n", + "\n", + "Приводим типы и обрабатываем NULL. В PostgreSQL мы бы использовали `ALTER COLUMN TYPE integer`, но в Lakehouse мы создаём новую версию данных между слоями." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_5_code", + "metadata": {}, + "outputs": [], + "source": [ + "normalized_df = (\n", + " filtered_df\n", + " .withColumn(\"passenger_count\", F.coalesce(F.col(\"passenger_count\").cast(\"int\"), F.lit(0))) # N1 \n", + " .withColumn(\"RatecodeID\", F.coalesce(F.col(\"RatecodeID\").cast(\"int\"), F.lit(99))) # N2 \n", + " .withColumn(\"congestion_surcharge\", F.coalesce(F.col(\"congestion_surcharge\"), F.lit(0.0))) # N3 \n", + " .withColumn(\"Airport_fee\", F.coalesce(F.col(\"Airport_fee\"), F.lit(0.0))) # N4\n", + ")\n", + "\n", + "normalized_df.select(\"passenger_count\", \"RatecodeID\", \"congestion_surcharge\", \"Airport_fee\").show(5)" + ] + }, + { + "cell_type": "markdown", + "id": "section_6_title", + "metadata": {}, + "source": [ + "## 6. Применение правил — обогащение\n", + "\n", + "Добавляем вычисляемые колонки и данные из lookup. Используем `LEFT JOIN`, чтобы не терять строки, если для какого-то ID не нашлось имени зоны. Это аналог денормализации в классическом DWH." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_6_code", + "metadata": {}, + "outputs": [], + "source": [ + "zone_lookup_df = spark.table(BRONZE_LOOKUP_TABLE)\n", + "\n", + "enriched_df = (\n", + " normalized_df\n", + " .withColumn(\"trip_duration_minutes\", (F.unix_timestamp(\"tpep_dropoff_datetime\") - F.unix_timestamp(\"tpep_pickup_datetime\")) / 60) # E1 \n", + " .join(zone_lookup_df.alias(\"pu_zone\"), F.col(\"PULocationID\") == F.col(\"pu_zone.LocationID\"), \"left\") # E2 \n", + " .withColumnRenamed(\"Borough\", \"pickup_borough\") \n", + " .withColumnRenamed(\"Zone\", \"pickup_zone\") \n", + " .drop(\"LocationID\", \"service_zone\") \n", + " .join(zone_lookup_df.alias(\"do_zone\"), F.col(\"DOLocationID\") == F.col(\"do_zone.LocationID\"), \"left\") # E3 \n", + " .withColumnRenamed(\"Borough\", \"dropoff_borough\") \n", + " .withColumnRenamed(\"Zone\", \"dropoff_zone\") \n", + " .drop(\"LocationID\", \"service_zone\")\n", + ")\n", + "\n", + "print(f\"Количество колонок после обогащения: {len(enriched_df.columns)}\")\n", + "enriched_df.select(\"tpep_pickup_datetime\", \"trip_duration_minutes\", \"pickup_zone\", \"dropoff_zone\").show(5, truncate=False)" + ] + }, + { + "cell_type": "markdown", + "id": "section_6_note", + "metadata": {}, + "source": [ + "**Обрати внимание:**\n", + "- **Отрицательная длительность:** если `tpep_dropoff_datetime` раньше `tpep_pickup_datetime`, колонка `trip_duration_minutes` будет отрицательной. Мы оставим это как наблюдение — это еще одна аномалия данных.\n", + "- **Смешанный регистр колонок:** оригинальные колонки (`VendorID`, `PULocationID`) сохраняют mixed case, тогда как наши новые колонки (`pickup_zone`, `trip_duration_minutes`) написаны в lowercase. `Iceberg` корректно обрабатывает такой набор колонок, но в реальных проектах лучше придерживаться единого стандарта." + ] + }, + { + "cell_type": "markdown", + "id": "section_7_title", + "metadata": {}, + "source": [ + "## 7. Создание silver-таблицы\n", + "\n", + "Используем `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) в namespace `lakehouse.silver`.\n", + "\n", + "**Важное предупреждение:** `CREATE OR REPLACE` — это полная перезапись таблицы. Все предыдущие данные и история snapshot-ов будут потеряны. В учебном silver это осознанный компромисс ради идемпотентности: повторный запуск ноутбука пересоздаёт таблицу с чистого листа. В Модуле 7 мы увидим, как делать изменения безопаснее.\n", + "\n", + "На 3 месяцах данных (~7 млн строк) эта операция может занять 2-4 минуты." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_7_code", + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(f\"CREATE NAMESPACE IF NOT EXISTS {SILVER_NAMESPACE}\")\n", + "\n", + "enriched_df.createOrReplaceTempView(\"silver_ready\")\n", + "\n", + "ctas_sql = f\"\"\"\n", + "CREATE OR REPLACE TABLE {SILVER_TABLE}\n", + "USING iceberg\n", + "AS\n", + "SELECT\n", + " VendorID,\n", + " tpep_pickup_datetime,\n", + " tpep_dropoff_datetime,\n", + " passenger_count,\n", + " trip_distance,\n", + " RatecodeID,\n", + " store_and_fwd_flag,\n", + " PULocationID,\n", + " DOLocationID,\n", + " payment_type,\n", + " fare_amount,\n", + " extra,\n", + " mta_tax,\n", + " tip_amount,\n", + " tolls_amount,\n", + " improvement_surcharge,\n", + " total_amount,\n", + " congestion_surcharge,\n", + " Airport_fee,\n", + " trip_duration_minutes,\n", + " pickup_borough,\n", + " pickup_zone,\n", + " dropoff_borough,\n", + " dropoff_zone\n", + "FROM silver_ready\n", + "\"\"\"\n", + "\n", + "print(f\"Выполняем CTAS для {SILVER_TABLE}...\")\n", + "spark.sql(ctas_sql)\n", + "\n", + "silver_df = spark.table(SILVER_TABLE)\n", + "print(f\"Silver-таблица готова. Строк: {silver_df.count():,}\")\n", + "silver_df.printSchema()" + ] + }, + { + "cell_type": "markdown", + "id": "section_8_title", + "metadata": {}, + "source": [ + "## 8. Проверки качества результата\n", + "\n", + "Ни одна трансформация не считается завершённой без проверок." + ] + }, + { + "cell_type": "markdown", + "id": "section_8a_title", + "metadata": {}, + "source": [ + "### 8a: Строковый уровень (Row-level)\n", + "Проверяем, что правила фильтрации и нормализации сработали для каждой строки." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_8a_code", + "metadata": {}, + "outputs": [], + "source": [ + "assert silver_df.filter(F.col(\"fare_amount\") < 0).count() == 0, \"Найдены отрицательные тарифы!\"\n", + "assert silver_df.filter(F.col(\"total_amount\") > 1000).count() == 0, \"Найдены экстремальные суммы!\"\n", + "assert silver_df.filter(F.col(\"passenger_count\").isNull()).count() == 0, \"Найдены NULL в passenger_count!\"\n", + "assert silver_df.filter(F.col(\"RatecodeID\").isNull()).count() == 0, \"Найдены NULL в RatecodeID!\"\n", + "\n", + "print(\"Row-level checks: OK\")" + ] + }, + { + "cell_type": "markdown", + "id": "section_8b_title", + "metadata": {}, + "source": [ + "### 8b: Схемный уровень (Schema-level)\n", + "Проверяем состав колонок и типы данных." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_8b_code", + "metadata": {}, + "outputs": [], + "source": [ + "expected_new_columns = [\"trip_duration_minutes\", \"pickup_borough\", \"pickup_zone\", \"dropoff_borough\", \"dropoff_zone\"]\n", + "for col in expected_new_columns:\n", + " assert col in silver_df.columns, f\"Колонка {col} отсутствует в silver!\"\n", + "\n", + "types = dict(silver_df.dtypes)\n", + "assert types[\"passenger_count\"] == \"int\", f\"Тип passenger_count должен быть int, а не {types['passenger_count']}\"\n", + "assert types[\"RatecodeID\"] == \"int\", f\"Тип RatecodeID должен быть int, а не {types['RatecodeID']}\"\n", + "\n", + "print(\"Schema-level checks: OK\")" + ] + }, + { + "cell_type": "markdown", + "id": "section_8c_title", + "metadata": {}, + "source": [ + "### 8c: Агрегатный уровень (Aggregate-level)\n", + "Сравниваем общие показатели bronze и silver, чтобы оценить масштаб изменений." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_8c_code", + "metadata": {}, + "outputs": [], + "source": [ + "comparison = spark.sql(f\"\"\"\n", + " WITH stats AS (\n", + " SELECT \n", + " 'bronze' as layer, \n", + " count(*) as rows, \n", + " avg(fare_amount) as avg_fare, \n", + " avg(trip_distance) as avg_dist\n", + " FROM {BRONZE_TABLE}\n", + " UNION ALL\n", + " SELECT \n", + " 'silver' as layer, \n", + " count(*) as rows, \n", + " avg(fare_amount) as avg_fare, \n", + " avg(trip_distance) as avg_dist\n", + " FROM {SILVER_TABLE}\n", + " )\n", + " SELECT \n", + " *,\n", + " CASE \n", + " WHEN layer = 'silver' THEN \n", + " (1 - rows * 1.0 / (SELECT rows FROM stats WHERE layer = 'bronze')) * 100 \n", + " ELSE 0 \n", + " END as filtered_pct\n", + " FROM stats\n", + "\"\"\").toPandas()\n", + "\n", + "comparison" + ] + }, + { + "cell_type": "markdown", + "id": "section_8c_note", + "metadata": {}, + "source": [ + "Ориентир: если фильтры удалили больше 5% строк, это повод пересмотреть правила или проверить данные." + ] + }, + { + "cell_type": "markdown", + "id": "section_9", + "metadata": {}, + "source": [ + "## 9. bronze vs silver — разделение ответственности\n", + "\n", + "Мы прошли путь от raw-файлов к очищенной silver-таблице. Почему бы не делать всё сразу при загрузке из raw?\n", + "\n", + "1. **Bronze хранит оригинал.** Если завтра выяснится, что правило фильтрации F1 было слишком жёстким, мы всегда можем пересоздать silver из bronze, не перечитывая исходные файлы из raw-зоны.\n", + "2. **Разделение сложности.** В bronze мы решаем задачу «прочитать и сохранить». В silver мы решаем задачу «очистить и обогатить». Это делает пайплайны проще в отладке.\n", + "3. **Единый источник правды.** Все downstream-потребители (аналитики, ML-инженеры) должны идти в silver. Bronze — это «внутренняя кухня» дата-инженера.\n", + "\n", + "| Свойство | Bronze | Silver |\n", + "|---|---|---|\n", + "| Состав данных | «Как в источнике» | Очищенные и обогащённые |\n", + "| NULL-значения | Сохранены | Обработаны (coalesce) |\n", + "| Типы данных | Технические (из raw) | Бизнес-ориентированные (INT для счетчиков) |\n", + "| Доп. колонки | Нет | Вычисляемые (длительность) и lookup (зоны) |\n", + "| Проверки качества | Минимальные (count) | Всесторонние (row/schema/agg) |\n", + "| Можно пересоздать из | Raw-файлов | Bronze-таблицы |" + ] + }, + { + "cell_type": "markdown", + "id": "section_10", + "metadata": {}, + "source": [ + "## 10. Самостоятельное задание\n", + "\n", + "Расширь silver-пайплайн тремя новыми правилами, пересоздай таблицу и проверь результат.\n", + "\n", + "**Фильтрация — F4:** удалить строки с `trip_distance = 0 AND total_amount != 0` (аномалия, найденная в Модуле 3).\n", + "\n", + "**Нормализация — N5:** привести `payment_type` из BIGINT в INT и заменить NULL на 0 (Unknown).\n", + "\n", + "**Обогащение — E4:** добавить вычисляемую колонку `speed_mph` — средняя скорость поездки в милях в час: `trip_distance / (trip_duration_minutes / 60)`. \n", + "*Подсказка: обработай деление на ноль — если trip_duration_minutes = 0, результат должен быть NULL, а не ошибка.*\n", + "\n", + "**Проверки:** после пересоздания silver напиши row-level assert для F4 и schema-level проверку для `speed_mph` и `payment_type`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_10_f4", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: фильтрация F4\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_10_n5", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: нормализация N5\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_10_e4", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: обогащение E4\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_10_ctas", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: пересоздание silver-таблицы (CTAS)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_10_checks", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: новые проверки качества\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_10_compare", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: сравнение количества строк до и после добавления F4\n" + ] + }, + { + "cell_type": "markdown", + "id": "section_11", + "metadata": {}, + "source": [ + "## 11. Checkpoint\n", + "\n", + "Проверь себя:\n", + "1. Чем silver отличается от bronze?\n", + "2. Зачем фиксировать правила перед кодом?\n", + "3. Какие три уровня проверок качества и зачем они нужны?\n", + "4. Почему мы использовали `LEFT JOIN`, а не `INNER JOIN` с lookup-таблицей?\n", + "5. Что делать, если фильтр внезапно удалил 50% строк?\n", + "6. Можно ли пересоздать silver, если через месяц мы обнаружим новую аномалию в данных?" + ] + }, + { + "cell_type": "markdown", + "id": "section_12", + "metadata": {}, + "source": [ + "## 12. Завершение\n", + "\n", + "Мы не удаляем silver-таблицу. В Модуле 6 мы будем читать её одновременно через Spark и Trino, а в Модуле 8 она понадобится для финальной практики." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_12_stop", + "metadata": {}, + "outputs": [], + "source": [ + "spark.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/plans/README.md b/plans/README.md index 574145d..2ba2197 100644 --- a/plans/README.md +++ b/plans/README.md @@ -17,7 +17,7 @@ | 2. Ментальная модель Lakehouse: storage, catalog, compute | [module-02-lakehouse-mental-model.md](./module-02-lakehouse-mental-model.md) | Ready for validation | `notebooks/02_lakehouse_mental_model.ipynb` | 2026-03-07 | | 3. Raw-данные и первое чтение в Spark | [module-03-raw-ingest-and-first-read.md](./module-03-raw-ingest-and-first-read.md) | Ready for validation | `docker-compose.yml`, `.gitignore`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/03_raw_ingest_and_first_read.ipynb` | 2026-03-07 | | 4. Первая рабочая Iceberg-таблица и слой bronze | [module-04-bronze-with-iceberg.md](./module-04-bronze-with-iceberg.md) | Draft | `notebooks/04_bronze_with_iceberg.ipynb` | 2026-03-07 | -| 5. Слой silver и воспроизводимые трансформации | [module-05-silver-layer.md](./module-05-silver-layer.md) | Draft | `notebooks/05_silver_layer.ipynb` | 2026-03-07 | +| 5. Слой silver и воспроизводимые трансформации | [module-05-silver-layer.md](./module-05-silver-layer.md) | Ready for validation | `notebooks/05_silver_layer.ipynb` | 2026-03-07 | | 6. Одна таблица, два движка: Spark и Trino | [module-06-spark-and-trino-on-same-table.md](./module-06-spark-and-trino-on-same-table.md) | Draft | `jupyter/Dockerfile`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/06_spark_and_trino_on_same_table.ipynb` | 2026-03-07 | | 7. Безопасная работа с таблицами: schema evolution и time travel | [module-07-safe-table-changes.md](./module-07-safe-table-changes.md) | Draft | `notebooks/07_safe_table_changes.ipynb` | 2026-03-07 | | 8. Базовое обслуживание таблиц и финальная практика | [module-08-maintenance-and-final-lab.md](./module-08-maintenance-and-final-lab.md) | Draft | `notebooks/08_maintenance_and_final_lab.ipynb` | 2026-03-07 | diff --git a/plans/module-05-silver-layer.md b/plans/module-05-silver-layer.md index c6ae736..f764d95 100644 --- a/plans/module-05-silver-layer.md +++ b/plans/module-05-silver-layer.md @@ -1,6 +1,6 @@ # Модуль 5. Слой silver и воспроизводимые трансформации -**Статус:** `Draft` +**Статус:** `Ready for validation` **Последнее обновление:** `2026-03-07` ## Цель From 68c530c855d13ff0201574099e2c85302b8e0565 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 23:47:05 +0300 Subject: [PATCH 20/24] =?UTF-8?q?refactor(course):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20taxi=5Fzone=5Flookup=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=B4=D0=BE=D0=BC=D0=B0=D1=88=D0=BA=D0=B8=20=D0=B2=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=BC=D0=BE-=D1=81=D0=B5=D0=BA=D1=86=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - демо-ячейки последующих модулей не должны зависеть от домашних заданий предыдущих — модуль 5 использует lookup в демо-коде для JOIN. - Что: - модуль 4: секция 8 из домашки (5 пустых ячеек) превращена в демо с заполненным кодом (чтение CSV, CTAS). - модуль 4: новая домашка (секция 9) — осмотр metadata lookup-таблицы, сравнение с основной. - модуль 4: секции перенумерованы (9→10, 10→11), завершение упоминает обе bronze-таблицы. - модуль 5: assert-сообщение для lookup обновлено (убрана ссылка на «самостоятельное задание»). - планы модулей 4 и 5: обновлены дизайн-решения, структура секций и раздел рисков. - Проверка: - прогнать готовые ячейки модуля 4 → lookup-таблица создаётся автоматически → модуль 5 проходит без ошибок. Co-Authored-By: Claude Opus 4.6 --- notebooks/04_bronze_with_iceberg.ipynb | 57 ++++---------------------- notebooks/05_silver_layer.ipynb | 18 +------- plans/module-04-bronze-with-iceberg.md | 21 ++++++---- plans/module-05-silver-layer.md | 8 ++-- 4 files changed, 28 insertions(+), 76 deletions(-) diff --git a/notebooks/04_bronze_with_iceberg.ipynb b/notebooks/04_bronze_with_iceberg.ipynb index 97291f2..8264559 100644 --- a/notebooks/04_bronze_with_iceberg.ipynb +++ b/notebooks/04_bronze_with_iceberg.ipynb @@ -564,21 +564,7 @@ "cell_type": "markdown", "id": "320c0199", "metadata": {}, - "source": [ - "## 8. Самостоятельное задание\n", - "\n", - "Используй raw-файл `taxi_zone_lookup.csv`, который уже лежит в `MinIO`.\n", - "\n", - "Что нужно сделать:\n", - "\n", - "1. Прочитать `taxi_zone_lookup.csv` из raw-зоны через `Spark`.\n", - "2. Создать таблицу `lakehouse.bronze.taxi_zone_lookup` с колонками `LocationID INT`, `Borough STRING`, `Zone STRING`, `service_zone STRING`.\n", - "3. Загрузить туда данные.\n", - "4. Проверить результат через `spark.table(...)`.\n", - "5. Посмотреть `snapshots` для этой таблицы.\n", - "\n", - "Подсказка: файл лежит по пути `s3a://lakehouse/raw/nyc_taxi/taxi_zone_lookup.csv`.\n" - ] + "source": "## 8. Вторая bronze-таблица: taxi_zone_lookup\n\nВ raw-зоне кроме Yellow Taxi лежит ещё один файл — `taxi_zone_lookup.csv`. Это справочник зон: каждому `LocationID` соответствует название района (`Borough`) и зоны (`Zone`).\n\nВ Модуле 5 мы будем обогащать silver-таблицу через `LEFT JOIN` с этим справочником. Сейчас загрузим его в bronze по тому же паттерну `CTAS`, но из CSV вместо Parquet." }, { "cell_type": "code", @@ -586,9 +572,7 @@ "id": "e94508bf", "metadata": {}, "outputs": [], - "source": [ - "# Ваш код: прочитай taxi_zone_lookup.csv из raw-зоны через Spark\n" - ] + "source": "raw_zone_df = (\n spark.read\n .option(\"header\", \"true\")\n .option(\"inferSchema\", \"true\")\n .csv(ZONE_LOOKUP_RAW_URI)\n)\n\nprint(f\"Строк в raw lookup: {raw_zone_df.count()}\")\nraw_zone_df.printSchema()\nraw_zone_df.show(5, truncate=False)" }, { "cell_type": "code", @@ -596,19 +580,15 @@ "id": "8e058aeb", "metadata": {}, "outputs": [], - "source": [ - "# Ваш код: создай таблицу lakehouse.bronze.taxi_zone_lookup\n" - ] + "source": "raw_zone_df.createOrReplaceTempView(\"raw_zone_lookup\")\n\nctas_lookup_sql = f\"\"\"\nCREATE OR REPLACE TABLE {BRONZE_LOOKUP_TABLE}\nUSING iceberg\nAS\nSELECT\n CAST(LocationID AS INT) AS LocationID,\n Borough,\n Zone,\n service_zone\nFROM raw_zone_lookup\n\"\"\"\n\nspark.sql(ctas_lookup_sql)\n\nlookup_df = spark.table(BRONZE_LOOKUP_TABLE)\nlookup_count = lookup_df.count()\n\nprint(f\"Строк в bronze lookup: {lookup_count}\")\nlookup_df.show(5, truncate=False)" }, { - "cell_type": "code", + "cell_type": "markdown", "execution_count": null, "id": "41c2c79a", "metadata": {}, "outputs": [], - "source": [ - "# Ваш код: загрузи данные в bronze lookup-таблицу\n" - ] + "source": "## 9. Самостоятельное задание\n\nПосмотри на внутреннюю структуру только что созданной `taxi_zone_lookup` — используй те же инструменты, что в Секциях 5 и 6.\n\n1. Выведи `snapshots` и `files` для `lakehouse.bronze.taxi_zone_lookup`.\n2. Сравни: сколько data files у lookup-таблицы и сколько у `nyc_taxi_yellow`? Почему такая разница?" }, { "cell_type": "code", @@ -616,9 +596,7 @@ "id": "80b392e1", "metadata": {}, "outputs": [], - "source": [ - "# Ваш код: проверь результат через spark.table(...)\n" - ] + "source": "# Ваш код: snapshots и files для taxi_zone_lookup\n" }, { "cell_type": "code", @@ -626,36 +604,19 @@ "id": "61e90959", "metadata": {}, "outputs": [], - "source": [ - "# Ваш код: посмотри snapshots для lakehouse.bronze.taxi_zone_lookup\n" - ] + "source": "# Ваш код: сравни количество data files в lookup и yellow taxi\n" }, { "cell_type": "markdown", "id": "24c04d60", "metadata": {}, - "source": [ - "## 9. Checkpoint\n", - "\n", - "Проверь себя:\n", - "\n", - "1. Чем отличается `spark.read.parquet(\"s3a://...\")` от `spark.table(\"lakehouse.bronze.nyc_taxi_yellow\")`?\n", - "2. Что хранит snapshot и зачем он нужен?\n", - "3. Где физически лежат данные таблицы и где лежат её метаданные?\n", - "4. Почему `bronze` это не то же самое, что `raw`?\n", - "5. Что произойдёт, если удалить один data file из `MinIO`, но metadata не поменять?\n", - "6. Можешь ли ты показать таблицу и через `Spark`, и через `MinIO Console`?\n" - ] + "source": "## 10. Checkpoint\n\nПроверь себя:\n\n1. Чем отличается `spark.read.parquet(\"s3a://...\")` от `spark.table(\"lakehouse.bronze.nyc_taxi_yellow\")`?\n2. Что хранит snapshot и зачем он нужен?\n3. Где физически лежат данные таблицы и где лежат её метаданные?\n4. Почему `bronze` это не то же самое, что `raw`?\n5. Что произойдёт, если удалить один data file из `MinIO`, но metadata не поменять?\n6. Можешь ли ты показать таблицу и через `Spark`, и через `MinIO Console`?" }, { "cell_type": "markdown", "id": "76d9061f", "metadata": {}, - "source": [ - "## 10. Завершение\n", - "\n", - "Мы намеренно не удаляем `lakehouse.bronze.nyc_taxi_yellow`. Эта таблица понадобится в Модуле 5, где мы будем строить `silver`-слой.\n" - ] + "source": "## 11. Завершение\n\nМы намеренно не удаляем bronze-таблицы (`nyc_taxi_yellow` и `taxi_zone_lookup`). Они понадобятся в Модуле 5, где мы будем строить silver-слой." }, { "cell_type": "code", diff --git a/notebooks/05_silver_layer.ipynb b/notebooks/05_silver_layer.ipynb index 03f6d60..6f26752 100644 --- a/notebooks/05_silver_layer.ipynb +++ b/notebooks/05_silver_layer.ipynb @@ -77,21 +77,7 @@ "id": "section_1_asserts", "metadata": {}, "outputs": [], - "source": [ - "assert spark.catalog.tableExists(BRONZE_TABLE), (\n", - " f\"Таблица {BRONZE_TABLE} не найдена. Вернись в Модуль 4 и выполни загрузку в bronze.\"\n", - ")\n", - "\n", - "assert spark.catalog.tableExists(BRONZE_LOOKUP_TABLE), (\n", - " f\"Таблица {BRONZE_LOOKUP_TABLE} не найдена. \"\n", - " \"Вернись в Модуль 4 и выполни самостоятельное задание (Секция 8).\"\n", - ")\n", - "\n", - "bronze_count = spark.table(BRONZE_TABLE).count()\n", - "assert bronze_count > 0, f\"Таблица {BRONZE_TABLE} пуста.\"\n", - "\n", - "print(f\"Обе bronze-таблицы на месте. В основной таблице {bronze_count:,} строк. Теперь строим silver.\")" - ] + "source": "assert spark.catalog.tableExists(BRONZE_TABLE), (\n f\"Таблица {BRONZE_TABLE} не найдена. Вернись в Модуль 4 и выполни загрузку в bronze.\"\n)\n\nassert spark.catalog.tableExists(BRONZE_LOOKUP_TABLE), (\n f\"Таблица {BRONZE_LOOKUP_TABLE} не найдена. \"\n \"Вернись в Модуль 4 и выполни Секцию 8.\"\n)\n\nbronze_count = spark.table(BRONZE_TABLE).count()\nassert bronze_count > 0, f\"Таблица {BRONZE_TABLE} пуста.\"\n\nprint(f\"Обе bronze-таблицы на месте. В основной таблице {bronze_count:,} строк. Теперь строим silver.\")" }, { "cell_type": "markdown", @@ -624,4 +610,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/plans/module-04-bronze-with-iceberg.md b/plans/module-04-bronze-with-iceberg.md index 8d16f8d..d8b92cf 100644 --- a/plans/module-04-bronze-with-iceberg.md +++ b/plans/module-04-bronze-with-iceberg.md @@ -70,9 +70,9 @@ Bronze нужен Модулю 5. Spark-сессия останавливается. Явное пояснение в финале ноутбука: «Мы намеренно не удаляем bronze-таблицы. В Модуле 5 мы будем строить silver на основе bronze.» -### Taxi zone lookup: самостоятельное задание +### Taxi zone lookup: демо-секция -Студент создаёт `lakehouse.bronze.taxi_zone_lookup` как упражнение. Это подготовка к join в Модуле 5 (silver). +Создание `lakehouse.bronze.taxi_zone_lookup` входит в демонстрационный код ноутбука (Секция 8), а не в самостоятельное задание. Причина: Модуль 5 использует lookup для JOIN в демо-коде — если lookup не создан, студент не может пройти Модуль 5, даже выполняя только готовые ячейки. Принцип: демо-ячейки последующих модулей не должны зависеть от домашних заданий предыдущих. ## План работ @@ -160,11 +160,16 @@ Bronze нужен Модулю 5. Spark-сессия останавливает - Bronze ≠ raw. Raw — неизменяемый архив файлов. Bronze — управляемая таблица, которую можно пересоздать из raw. - В Модуле 5 — silver: очистка, трансформации, бизнес-логика. -### Секция 7: Самостоятельное задание -- **[md]** Инструкция: (1) прочитать `taxi_zone_lookup.csv` из raw-зоны, (2) создать `lakehouse.bronze.taxi_zone_lookup` с DDL (LocationID INT, Borough STRING, Zone STRING, service_zone STRING), (3) загрузить данные, (4) проверить через `spark.table(...)`, (5) осмотреть snapshots. -- **[code]** 5 пустых ячеек `# Ваш код: ...`. +### Секция 7: Вторая bronze-таблица: taxi_zone_lookup +- **[md]** В raw-зоне кроме Yellow Taxi лежит `taxi_zone_lookup.csv` — справочник зон. В Модуле 5 он понадобится для JOIN. Загружаем в bronze по тому же паттерну CTAS, но из CSV. +- **[code]** Чтение CSV через `spark.read.option("header", "true").option("inferSchema", "true").csv(...)`. Вывод count, schema, preview. +- **[code]** CTAS: `CREATE OR REPLACE TABLE lakehouse.bronze.taxi_zone_lookup USING iceberg AS SELECT CAST(LocationID AS INT) AS LocationID, Borough, Zone, service_zone FROM raw_zone_lookup`. Верификация: count, show. -### Секция 8: Checkpoint +### Секция 8: Самостоятельное задание +- **[md]** Инструкция: осмотреть внутреннюю структуру `taxi_zone_lookup` теми же инструментами, что в Секциях 5 и 6. (1) Вывести snapshots и files. (2) Сравнить количество data files с `nyc_taxi_yellow` и объяснить разницу. +- **[code]** 2 пустых ячейки `# Ваш код: ...`. + +### Секция 9: Checkpoint - **[md]** Вопросы для самопроверки: 1. Чем отличается `spark.read.parquet("s3a://...")` от `spark.table("lakehouse.bronze.nyc_taxi_yellow")`? 2. Что хранит snapshot и зачем он нужен? @@ -173,8 +178,8 @@ Bronze нужен Модулю 5. Spark-сессия останавливает 5. Что произойдёт, если удалить один data file из MinIO, но не изменить metadata? 6. Можете ли вы показать таблицу в MinIO Console (через браузер)? -### Секция 9: Завершение -- **[md]** «Мы намеренно не удаляем bronze-таблицы. В Модуле 5 мы будем строить silver-слой на основе `lakehouse.bronze.nyc_taxi_yellow`.» +### Секция 10: Завершение +- **[md]** «Мы намеренно не удаляем bronze-таблицы (`nyc_taxi_yellow` и `taxi_zone_lookup`). Они понадобятся в Модуле 5, где мы будем строить silver-слой.» - **[code]** `spark.stop()`. ### Дизайн-решения по ноутбуку diff --git a/plans/module-05-silver-layer.md b/plans/module-05-silver-layer.md index f764d95..225307f 100644 --- a/plans/module-05-silver-layer.md +++ b/plans/module-05-silver-layer.md @@ -88,9 +88,9 @@ LEFT JOIN (не INNER), чтобы не терять строки с LocationID - **Схемный (schema-level):** наличие новых колонок, проверка типов (passenger_count = INT, RatecodeID = INT) - **Агрегатный (aggregate-level):** таблица-сравнение bronze vs silver (row count, % отфильтрованных, средние fare/distance) -### Taxi zone lookup: жёсткий prerequisite +### Taxi zone lookup: prerequisite из демо-секции Модуля 4 -Lookup — самостоятельное задание Модуля 4. Модуль 5 работает только с bronze-таблицами и не должен лезть в raw-слой — это нарушило бы принцип разделения ответственности между слоями (PRD Risk #4). Стратегия: жёсткий assert при отсутствии `lakehouse.bronze.taxi_zone_lookup` с понятным сообщением и отсылкой к Модулю 4: «Таблица `lakehouse.bronze.taxi_zone_lookup` не найдена. Вернись в Модуль 4 и выполни самостоятельное задание (Секция 8).» +Lookup создаётся в демонстрационном коде Модуля 4 (Секция 8), а не в самостоятельном задании. Это гарантирует, что студент, прогнавший готовые ячейки Модуля 4, имеет обе bronze-таблицы. Модуль 5 работает только с bronze-таблицами и не должен лезть в raw-слой — это нарушило бы принцип разделения ответственности между слоями (PRD Risk #4). Стратегия: жёсткий assert при отсутствии `lakehouse.bronze.taxi_zone_lookup` с понятным сообщением и отсылкой к Модулю 4: «Таблица `lakehouse.bronze.taxi_zone_lookup` не найдена. Вернись в Модуль 4 и выполни Секцию 8.» ### Cleanup: нет @@ -119,7 +119,7 @@ Silver нужен Модулям 6 и 8. Spark-сессия останавлив ### Секция 1: Spark-сессия и проверка bronze - **[code]** SparkSession (паттерн Модулей 2-4: без `.master()`, `setLogLevel("ERROR")`). Константы, вспомогательные функции (`format_bytes`, `list_objects` — переиспользование паттерна из Модуля 4). -- **[code]** Assert: `spark.table(BRONZE_TABLE).count() > 0` с отсылкой к Модулю 4. Assert: `spark.table(BRONZE_LOOKUP_TABLE)` существует — при отсутствии жёсткая ошибка с отсылкой к самостоятельному заданию Модуля 4 (Секция 8). +- **[code]** Assert: `spark.table(BRONZE_TABLE).count() > 0` с отсылкой к Модулю 4. Assert: `spark.table(BRONZE_LOOKUP_TABLE)` существует — при отсутствии жёсткая ошибка с отсылкой к Секции 8 Модуля 4. - **[md]** Обе bronze-таблицы на месте. Теперь строим silver. ### Секция 2: Профиль bronze — что нужно трансформировать @@ -272,7 +272,7 @@ speed_mph DOUBLE (E4: новая, trip_distance / (tr ## Риски -- **Taxi zone lookup отсутствует.** Самостоятельное задание Модуля 4 может быть не выполнено. Жёсткий assert с отсылкой к Модулю 4. Модуль 5 не создаёт bronze-таблицы самостоятельно — это нарушило бы разделение ответственности между слоями. +- **Taxi zone lookup отсутствует.** Lookup создаётся в демо-секции Модуля 4 (Секция 8), поэтому риск минимален — студент должен был прогнать готовые ячейки. Жёсткий assert с отсылкой к Модулю 4. Модуль 5 не создаёт bronze-таблицы самостоятельно — это нарушило бы разделение ответственности между слоями. - **Время CTAS.** JOIN + трансформации — на 30-50% медленнее bronze CTAS. На 3 мес (~7 млн строк): 2-4 мин. Extended: до 5-10 мин. Предупреждение в markdown. - **Column case sensitivity.** Новые колонки (lowercase) рядом с оригинальными (mixed case: VendorID, PULocationID). Нормально для Iceberg, но добавить пояснение. - **trip_duration_minutes отрицательные.** Если dropoff < pickup — длительность отрицательна. Упомянуть как наблюдение, не фильтровать в демо (потенциально — для самостоятельного задания или будущего правила). From 65d57ec6d66a4d09efb52cc845034cf84bf3b474 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 7 Mar 2026 23:58:36 +0300 Subject: [PATCH 21/24] =?UTF-8?q?feat(module-06):=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=20=D0=BF=D0=BE=20=D1=81=D0=BE=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BD=D0=BE=D0=B9=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B5=20Spark=20=D0=B8=20Trino?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - продемонстрировать возможность доступа нескольких движков к одной Iceberg-таблице через общий каталог. - Что: - создан notebooks/06_spark_and_trino_on_same_table.ipynb с примерами write (Spark) -> read (Trino). - в jupyter/Dockerfile добавлен клиент trino. - в START_HERE.md и docs/stack_reference.md добавлена инструкция по подключению DBeaver к Trino. - статус модуля 6 в планах обновлен до Ready for validation. - Проверка: - ручная проверка выполнения ячеек в ноутбуке (Python trino client). - проверка наличия всех инструкций в документации. --- START_HERE.md | 8 + docs/stack_reference.md | 5 + jupyter/Dockerfile | 3 +- .../06_spark_and_trino_on_same_table.ipynb | 667 ++++++++++++++++++ plans/README.md | 2 +- ...module-06-spark-and-trino-on-same-table.md | 2 +- 6 files changed, 684 insertions(+), 3 deletions(-) create mode 100644 notebooks/06_spark_and_trino_on_same_table.ipynb diff --git a/START_HERE.md b/START_HERE.md index ddb61d6..07b3da5 100644 --- a/START_HERE.md +++ b/START_HERE.md @@ -181,6 +181,14 @@ ls -lh data/nyc_taxi/ Если хочешь расширенный режим, можешь скачать все 12 месяцев `2024`, но основной маршрут курса и примеры опираются на первые 3 месяца. +## Подключение DBeaver к Trino (перед Модулем 6) + +- Зачем: в Модуле 6 можно работать с Trino через DBeaver параллельно с ноутбуком — привычный SQL-интерфейс. +- Предусловие: DBeaver установлен (ссылка на [dbeaver.io/download](https://dbeaver.io/download/)). Необязателен — ноутбук работает без DBeaver. +- Шаги: New Database Connection -> Trino. Host: `localhost`. Port: `8090`. Database/Catalog: `lakehouse`. Username: любая строка (напр. `student`). Password: пусто. Test Connection -> Finish. +- Проверка: `SHOW SCHEMAS FROM lakehouse;`. Ожидаем: `bronze`, `default`, `information_schema`, `silver`. +- Troubleshooting: стенд поднят? контейнер `trino` Up? порт 8090 свободен? + ## Что делать дальше - пройти `notebooks/01_environment_and_smoke_test.ipynb`; diff --git a/docs/stack_reference.md b/docs/stack_reference.md index b5c5154..c022fc2 100644 --- a/docs/stack_reference.md +++ b/docs/stack_reference.md @@ -80,6 +80,11 @@ SHOW SCHEMAS FROM lakehouse; SHOW TABLES FROM lakehouse.default; ``` +**Подключение через DBeaver:** +- Host: `localhost`, Port: `8090`, Catalog: `lakehouse`, User: любая строка, Password: нет. +- Driver: Trino (встроен в DBeaver). +- Проверка: `SHOW SCHEMAS FROM lakehouse;`. + ### MinIO - S3 endpoint: `http://localhost:9000` diff --git a/jupyter/Dockerfile b/jupyter/Dockerfile index 9bddc20..db381b9 100755 --- a/jupyter/Dockerfile +++ b/jupyter/Dockerfile @@ -8,7 +8,8 @@ ENV DEBIAN_FRONTEND=noninteractive RUN pip3 install --no-cache-dir \ "jupyterlab==4.2.5" \ "boto3>=1.35,<2" \ - "psycopg2-binary>=2.9,<3" + "psycopg2-binary>=2.9,<3" \ + "trino>=0.328" # Создаём непривилегированного пользователя ARG NB_USER=jovyan diff --git a/notebooks/06_spark_and_trino_on_same_table.ipynb b/notebooks/06_spark_and_trino_on_same_table.ipynb new file mode 100644 index 0000000..19c917c --- /dev/null +++ b/notebooks/06_spark_and_trino_on_same_table.ipynb @@ -0,0 +1,667 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "section_0", + "metadata": {}, + "source": [ + "# Модуль 6. Одна таблица, два движка: Spark и Trino\n", + "\n", + "В этом модуле мы увидим главное практическое следствие архитектуры Lakehouse: таблица, записанная одним движком, может быть прочитана другим без копирования данных.\n", + "\n", + "**Цели модуля:**\n", + "- записать таблицу через Spark и тут же прочитать её через Trino;\n", + "- выполнить SQL-запросы к Iceberg-таблицам из Trino;\n", + "- понять роль общего каталога и общего хранилища;\n", + "- осознать отличие Lakehouse-модели (decoupled compute) от классического DWH.\n", + "\n", + "**Prerequisite:** пройден Модуль 5 (silver-таблица существует).\n", + "\n", + "### Связка с привычным DWH (Greenplum)\n", + "\n", + "| | Классический DWH (Greenplum) | Lakehouse |\n", + "|---|---|---|\n", + "| Где лежат данные | Внутри СУБД, на управляемых дисках | В объектном хранилище (MinIO), отдельно от движков |\n", + "| Где лежат метаданные | `pg_catalog` внутри той же СУБД | Внешний JDBC-каталог (PostgreSQL) + metadata в MinIO |\n", + "| Кто может читать таблицу | Только сама СУБД | Любой движок с доступом к каталогу и хранилищу |\n", + "| Чтобы дать доступ другому инструменту | Подключиться к СУБД или скопировать данные | Подключить тот же каталог — данные уже доступны |\n", + "\n", + "**Ключевая идея:** в Lakehouse данные не заперты в одном движке. Это называется *decoupled compute*. \n", + "\n", + "**Как устроен этот ноутбук:** Spark-код и Trino-запросы выполняются здесь. Для Trino используется Python-клиент `trino`. Если у вас установлен DBeaver (и подключен по инструкции из START_HERE.md) — каждый Trino-запрос можно выполнить и там (в markdown будут подсказки)." + ] + }, + { + "cell_type": "markdown", + "id": "section_1_title", + "metadata": {}, + "source": [ + "## 1. Spark-сессия, Trino-клиент и проверка таблиц\n", + "\n", + "Инициализируем Spark и настраиваем helper для отправки запросов в Trino." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_1_code", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "from pyspark.sql import SparkSession\n", + "from trino.dbapi import connect\n", + "import trino\n", + "\n", + "CATALOG_NAME = \"lakehouse\"\n", + "BRONZE_TABLE = f\"{CATALOG_NAME}.bronze.nyc_taxi_yellow\"\n", + "SILVER_TABLE = f\"{CATALOG_NAME}.silver.nyc_taxi_yellow\"\n", + "\n", + "spark = SparkSession.builder \\\n", + " .appName(\"module-06-spark-and-trino\") \\\n", + " .getOrCreate()\n", + "\n", + "spark.sparkContext.setLogLevel(\"ERROR\")\n", + "\n", + "assert spark.catalog.tableExists(BRONZE_TABLE), f\"Таблица {BRONZE_TABLE} не найдена (пройди Модуль 4).\"\n", + "assert spark.catalog.tableExists(SILVER_TABLE), f\"Таблица {SILVER_TABLE} не найдена (пройди Модуль 5).\"\n", + "\n", + "def trino_query(sql: str) -> pd.DataFrame:\n", + " \"\"\"Выполняет SQL-запрос в Trino и возвращает результат как Pandas DataFrame.\"\"\"\n", + " conn = connect(\n", + " host=\"trino\",\n", + " port=8080,\n", + " user=\"jupyter\",\n", + " catalog=\"lakehouse\"\n", + " )\n", + " cur = conn.cursor()\n", + " try:\n", + " cur.execute(sql)\n", + " rows = cur.fetchall()\n", + " columns = [desc[0] for desc in cur.description]\n", + " return pd.DataFrame(rows, columns=columns)\n", + " except trino.exceptions.ProgrammingError as e:\n", + " if \"No nodes available to run query\" in str(e):\n", + " print(\"Ошибка: Trino ещё не готов. Подождите пару минут после старта контейнеров.\")\n", + " raise\n", + " # Запросы типа CREATE/DROP не возвращают строк\n", + " return pd.DataFrame([{\"status\": \"Success\"}])\n", + " finally:\n", + " conn.close()\n", + "\n", + "print(\"Spark готов.\")\n", + "print(f\"Версия Trino клиента: {trino.__version__}\")\n", + "print(\"Trino-клиент готов. Проверка...\")\n", + "display(trino_query(\"SELECT 1 AS test_col\"))" + ] + }, + { + "cell_type": "markdown", + "id": "section_1_note", + "metadata": {}, + "source": [ + "*Если ячейка выше упала с ImportError: trino — нужно пересобрать образ Jupyter (в терминале: `docker compose build jupyter && docker compose up -d`).*\n", + "\n", + "Обе таблицы на месте. Spark может их читать. Trino-клиент готов. Вопрос: может ли Trino прочитать те же таблицы?" + ] + }, + { + "cell_type": "markdown", + "id": "section_2_title", + "metadata": {}, + "source": [ + "## 2. Читаем silver через Spark — фиксируем метрики\n", + "\n", + "Сначала получим числа из Spark. Потом сравним с Trino." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_2_code", + "metadata": {}, + "outputs": [], + "source": [ + "spark_metrics = spark.sql(f\"\"\"\n", + " SELECT \n", + " count(*) AS row_count, \n", + " avg(fare_amount) AS avg_fare, \n", + " avg(trip_distance) AS avg_distance, \n", + " count(DISTINCT pickup_borough) AS boroughs \n", + " FROM {SILVER_TABLE}\n", + "\"\"\").toPandas()\n", + "\n", + "display(spark_metrics)\n", + "\n", + "spark.table(SILVER_TABLE).select(\"tpep_pickup_datetime\", \"fare_amount\", \"pickup_borough\").show(5)" + ] + }, + { + "cell_type": "markdown", + "id": "section_2_note", + "metadata": {}, + "source": [ + "Запомни эти числа. Сейчас выполним тот же запрос через Trino." + ] + }, + { + "cell_type": "markdown", + "id": "section_3_title", + "metadata": {}, + "source": [ + "## 3. Навигация по каталогу через Trino\n", + "\n", + "*DBeaver: тот же запрос можно выполнить в DBeaver, если он подключён к Trino по инструкции из START_HERE.md.*" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_3_schemas", + "metadata": {}, + "outputs": [], + "source": [ + "trino_query(\"SHOW SCHEMAS FROM lakehouse\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_3_tables", + "metadata": {}, + "outputs": [], + "source": [ + "trino_query(\"SHOW TABLES FROM lakehouse.silver\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_3_describe", + "metadata": {}, + "outputs": [], + "source": [ + "trino_query(\"DESCRIBE lakehouse.silver.nyc_taxi_yellow\")" + ] + }, + { + "cell_type": "markdown", + "id": "section_3_note", + "metadata": {}, + "source": [ + "Trino видит те же namespace (bronze, silver, default) и ту же таблицу с не менее чем 24 колонками. Различия в нотации типов (varchar vs STRING, double vs DOUBLE) нормальны — это разница синтаксиса движков, а не данных." + ] + }, + { + "cell_type": "markdown", + "id": "section_4_title", + "metadata": {}, + "source": [ + "## 4. «Aha-момент» — одни и те же данные\n", + "\n", + "Центральный момент модуля. Trino читает таблицу, записанную Spark в Модуле 5.\n", + "\n", + "*DBeaver: `SELECT * FROM lakehouse.silver.nyc_taxi_yellow LIMIT 10;`*" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_4_select", + "metadata": {}, + "outputs": [], + "source": [ + "trino_query(\"SELECT tpep_pickup_datetime, fare_amount, pickup_borough FROM lakehouse.silver.nyc_taxi_yellow LIMIT 5\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_4_metrics", + "metadata": {}, + "outputs": [], + "source": [ + "trino_metrics = trino_query(\"\"\"\n", + " SELECT \n", + " count(*) AS row_count, \n", + " avg(fare_amount) AS avg_fare, \n", + " avg(trip_distance) AS avg_distance, \n", + " count(DISTINCT pickup_borough) AS boroughs \n", + " FROM lakehouse.silver.nyc_taxi_yellow\n", + "\"\"\")\n", + "\n", + "print(\"Метрики Trino:\")\n", + "display(trino_metrics)\n", + "\n", + "print(\"Метрики Spark (для сравнения):\")\n", + "display(spark_metrics)" + ] + }, + { + "cell_type": "markdown", + "id": "section_4_note", + "metadata": {}, + "source": [ + "Строки совпадают. Оба движка читают одни и те же data files из MinIO (с точностью до floating-point округления в функции `avg`)." + ] + }, + { + "cell_type": "markdown", + "id": "section_5_title", + "metadata": {}, + "source": [ + "## 5. Почему это работает — роль общего каталога\n", + "\n", + "**Общий каталог:** оба движка подключены к одному инстансу PostgreSQL (`postgres-iceberg:5432/iceberg`). Когда Spark создаёт таблицу, он записывает ссылку на неё в PostgreSQL. Trino читает тот же PostgreSQL и находит эту ссылку.\n", + "\n", + "**Общее хранилище:** data files (Parquet) и metadata-файлы лежат в MinIO (`s3://lakehouse/warehouse`). Оба движка имеют сетевой доступ к бакету и могут прочитать файлы.\n", + "\n", + "**Параллель с Greenplum:** В классическом DWH данные лежат внутри СУБД на её собственных дисках. Доступ возможен только через саму СУБД. В Lakehouse данные лежат снаружи, а движки взаимозаменяемы.\n", + "\n", + "**Decoupled compute:** Вычислительные ресурсы отделены от хранения. Если нам потребуется добавить третий движок (например, Apache Flink для streaming), нужно будет просто подключить его к тому же каталогу и хранилищу.\n", + "\n", + "```\n", + " ┌─────────────────┐ ┌──────────────────┐\n", + " │ Spark (Jupyter)│ │ Trino (DBeaver) │\n", + " └────────┬────────┘ └────────┬─────────┘\n", + " │ │\n", + " ▼ ▼\n", + " ┌──────────────────────────────────────────┐\n", + " │ PostgreSQL (JDBC catalog) │\n", + " │ metadata_location -> s3://... │\n", + " └────────────────────┬─────────────────────┘\n", + " ▼\n", + " ┌──────────────────────────────────────────┐\n", + " │ MinIO (S3-compatible storage) │\n", + " │ metadata/ + data/ (Parquet files) │\n", + " └──────────────────────────────────────────┘\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "section_6_title", + "metadata": {}, + "source": [ + "## 6. Spark записывает — Trino читает (live write → read)\n", + "\n", + "До этого мы читали таблицы, созданные в предыдущих модулях. Теперь — живой цикл: Spark создаёт таблицу прямо сейчас, и Trino её тут же видит. \n", + "\n", + "Создаём агрегатную таблицу `lakehouse.default.borough_summary` через Spark:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_6_write", + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(\"\"\"\n", + " CREATE OR REPLACE TABLE lakehouse.default.borough_summary\n", + " USING iceberg AS\n", + " SELECT pickup_borough,\n", + " count(*) AS trips,\n", + " avg(fare_amount) AS avg_fare,\n", + " avg(trip_distance) AS avg_distance\n", + " FROM lakehouse.silver.nyc_taxi_yellow\n", + " WHERE pickup_borough IS NOT NULL\n", + " GROUP BY pickup_borough\n", + "\"\"\")\n", + "\n", + "spark.table(\"lakehouse.default.borough_summary\").show()" + ] + }, + { + "cell_type": "markdown", + "id": "section_6_note_1", + "metadata": {}, + "source": [ + "Spark записал таблицу. Мы не делали никакой синхронизации. Видит ли её Trino?\n", + "\n", + "*DBeaver: `SELECT * FROM lakehouse.default.borough_summary ORDER BY trips DESC;`*" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_6_read", + "metadata": {}, + "outputs": [], + "source": [ + "trino_query(\"SELECT * FROM lakehouse.default.borough_summary ORDER BY trips DESC\")" + ] + }, + { + "cell_type": "markdown", + "id": "section_6_note_2", + "metadata": {}, + "source": [ + "Trino видит таблицу мгновенно. Spark записал metadata в PostgreSQL и data files в MinIO. Trino прочитал тот же каталог — увидел таблицу. Никакого копирования, никакой синхронизации.\n", + "\n", + "Это и есть decoupled compute: один движок создал, другой прочитал. В Greenplum для этого пришлось бы либо подключиться к тому же движку, либо скопировать данные." + ] + }, + { + "cell_type": "markdown", + "id": "section_7_title", + "metadata": {}, + "source": [ + "## 7. Аналитические запросы в Trino\n", + "\n", + "Trino как аналитический SQL-движок. Выполним несколько аналитических запросов.\n", + "\n", + "*DBeaver: скопируй запрос ниже без обёртки trino_query().*" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_7_query1", + "metadata": {}, + "outputs": [], + "source": [ + "trino_query(\"\"\"\n", + " SELECT \n", + " pickup_borough, \n", + " count(*) AS trips, \n", + " avg(fare_amount) AS avg_fare, \n", + " avg(trip_distance) AS avg_distance, \n", + " avg(tip_amount) AS avg_tip \n", + " FROM lakehouse.silver.nyc_taxi_yellow \n", + " WHERE pickup_borough IS NOT NULL \n", + " GROUP BY pickup_borough \n", + " ORDER BY trips DESC\n", + "\"\"\")" + ] + }, + { + "cell_type": "markdown", + "id": "section_7_note_1", + "metadata": {}, + "source": [ + "Топ зон посадки:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_7_query2_trino", + "metadata": {}, + "outputs": [], + "source": [ + "trino_top_zones = trino_query(\"\"\"\n", + " SELECT \n", + " pickup_zone, \n", + " count(*) AS trips, \n", + " avg(total_amount) AS avg_total \n", + " FROM lakehouse.silver.nyc_taxi_yellow \n", + " WHERE pickup_zone IS NOT NULL \n", + " GROUP BY pickup_zone \n", + " ORDER BY trips DESC \n", + " LIMIT 10\n", + "\"\"\")\n", + "display(trino_top_zones)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_7_query2_spark", + "metadata": {}, + "outputs": [], + "source": [ + "spark_top_zones = spark.sql(\"\"\"\n", + " SELECT \n", + " pickup_zone, \n", + " count(*) AS trips, \n", + " avg(total_amount) AS avg_total \n", + " FROM lakehouse.silver.nyc_taxi_yellow \n", + " WHERE pickup_zone IS NOT NULL \n", + " GROUP BY pickup_zone \n", + " ORDER BY trips DESC \n", + " LIMIT 10\n", + "\"\"\").toPandas()\n", + "\n", + "display(spark_top_zones)" + ] + }, + { + "cell_type": "markdown", + "id": "section_7_note_2", + "metadata": {}, + "source": [ + "Результаты совпадают (с точностью до floating-point). Два движка, одна таблица." + ] + }, + { + "cell_type": "markdown", + "id": "section_8_title", + "metadata": {}, + "source": [ + "## 8. Навигация по bronze из Trino\n", + "\n", + "До этого bronze читали только из Spark. Проверим через Trino." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_8_code1", + "metadata": {}, + "outputs": [], + "source": [ + "trino_query(\"SHOW TABLES FROM lakehouse.bronze\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_8_code2", + "metadata": {}, + "outputs": [], + "source": [ + "trino_query(\"SELECT count(*) AS row_count FROM lakehouse.bronze.nyc_taxi_yellow\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_8_code3", + "metadata": {}, + "outputs": [], + "source": [ + "trino_query(\"SELECT * FROM lakehouse.bronze.nyc_taxi_yellow LIMIT 5\")" + ] + }, + { + "cell_type": "markdown", + "id": "section_8_note", + "metadata": {}, + "source": [ + "Trino видит все таблицы из всех namespace каталога. Каталог — общий, хранилище — общее. \n", + "\n", + "*DBeaver: те же запросы работают аналогично.*" + ] + }, + { + "cell_type": "markdown", + "id": "section_9", + "metadata": {}, + "source": [ + "## 9. DBeaver — SQL-клиент для Trino (рекомендация)\n", + "\n", + "В этом ноутбуке мы работали с Trino через Python-клиент — для воспроизводимости. В реальной аналитической работе Trino чаще используют через SQL-клиенты: DBeaver, DataGrip, DbVisualizer. DBeaver — де-факто стандарт для SQL, знакомый всем, кто работал с PostgreSQL/Greenplum.\n", + "\n", + "Если DBeaver установлен и подключён (инструкция в START_HERE.md), попробуйте выполнить в нём любой запрос из этого модуля. Результат будет тот же — тот же протокол, тот же движок, та же таблица. Разница: DBeaver — для интерактивной SQL-работы, Python-клиент — для автоматизации и ноутбуков." + ] + }, + { + "cell_type": "markdown", + "id": "section_10_title", + "metadata": {}, + "source": [ + "## 10. Самостоятельное задание\n", + "\n", + "Четыре задачи. Центральная — самостоятельный цикл write → read, как в Секции 6.\n", + "\n", + "**Задача 1 (Spark → write).** Создай через Spark новую агрегатную таблицу `lakehouse.default.zone_summary` с CTAS: для каждой `pickup_zone` посчитай `count(*)`, `avg(total_amount)`, `avg(tip_amount)` по silver-таблице (WHERE pickup_zone IS NOT NULL).\n", + "\n", + "**Задача 2 (Trino → read).** Прочитай `lakehouse.default.zone_summary` через Trino (Python-клиент или DBeaver). Убедись, что Trino видит таблицу без синхронизации.\n", + "\n", + "**Задача 3 (сравнение).** Выполни тот же агрегатный запрос напрямую по silver через Trino (без промежуточной таблицы). Сравни результат с `zone_summary`. Числа должны совпасть.\n", + "\n", + "**Задача 4 (ответ).** Ответь в markdown-ячейке ниже: почему Trino увидел `zone_summary` сразу после создания через Spark? Какие три компонента стенда это обеспечивают? Что произошло бы, если бы у Trino был другой каталог?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_10_task1", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код (Spark): CREATE TABLE zone_summary\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_10_task2", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код (Trino): чтение zone_summary\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_10_task3", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код (Trino): тот же агрегат напрямую по silver\n" + ] + }, + { + "cell_type": "markdown", + "id": "section_10_task4", + "metadata": {}, + "source": [ + "Ваш ответ на Задачу 4: ...\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_10_cleanup", + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(\"DROP TABLE IF EXISTS lakehouse.default.zone_summary\")" + ] + }, + { + "cell_type": "markdown", + "id": "section_10_optional", + "metadata": {}, + "source": [ + "Дополнительная задача (по желанию): аналитический запрос по bronze через Trino и через Spark, сравнение." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_10_opt_trino", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код (Trino): аналитический запрос по bronze\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_10_opt_spark", + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код (Spark): тот же запрос по bronze\n" + ] + }, + { + "cell_type": "markdown", + "id": "section_11", + "metadata": {}, + "source": [ + "## 11. Что мы НЕ сравниваем\n", + "\n", + "Ограничение модуля: мы не сравниваем Spark и Trino как движки. Не обсуждаем: какой быстрее, какой лучше, каковы их внутренние различия оптимизаторов. \n", + "\n", + "Цель — показать, что оба работают с одной таблицей через общий каталог. Это фундаментальное свойство архитектуры Lakehouse, а не конкретного движка." + ] + }, + { + "cell_type": "markdown", + "id": "section_12", + "metadata": {}, + "source": [ + "## 12. Checkpoint\n", + "\n", + "Проверь себя:\n", + "1. Почему Trino видит таблицы, созданные Spark, без синхронизации?\n", + "2. Какие два компонента стенда общие для Spark и Trino?\n", + "3. Чем доступ к данным в Lakehouse отличается от Greenplum?\n", + "4. Нужно ли копировать данные, чтобы Trino прочитал таблицу Spark?\n", + "5. Что такое «decoupled compute» в одном предложении?\n", + "6. Если добавить третий движок (Flink), что нужно сделать, чтобы он увидел те же таблицы?\n", + "7. Что произошло, когда Spark создал `borough_summary`, а Trino её тут же прочитал?" + ] + }, + { + "cell_type": "markdown", + "id": "section_13", + "metadata": {}, + "source": [ + "## 13. Завершение\n", + "\n", + "Удаляем демо-таблицу, она больше не нужна. Таблицы `bronze` и `silver` остаются для следующих модулей.\n", + "\n", + "В Модуле 7 — schema evolution и time travel. В Модуле 8 — финальная практика." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "section_13_stop", + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(\"DROP TABLE IF EXISTS lakehouse.default.borough_summary\")\n", + "spark.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/plans/README.md b/plans/README.md index 2ba2197..7376721 100644 --- a/plans/README.md +++ b/plans/README.md @@ -18,6 +18,6 @@ | 3. Raw-данные и первое чтение в Spark | [module-03-raw-ingest-and-first-read.md](./module-03-raw-ingest-and-first-read.md) | Ready for validation | `docker-compose.yml`, `.gitignore`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/03_raw_ingest_and_first_read.ipynb` | 2026-03-07 | | 4. Первая рабочая Iceberg-таблица и слой bronze | [module-04-bronze-with-iceberg.md](./module-04-bronze-with-iceberg.md) | Draft | `notebooks/04_bronze_with_iceberg.ipynb` | 2026-03-07 | | 5. Слой silver и воспроизводимые трансформации | [module-05-silver-layer.md](./module-05-silver-layer.md) | Ready for validation | `notebooks/05_silver_layer.ipynb` | 2026-03-07 | -| 6. Одна таблица, два движка: Spark и Trino | [module-06-spark-and-trino-on-same-table.md](./module-06-spark-and-trino-on-same-table.md) | Draft | `jupyter/Dockerfile`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/06_spark_and_trino_on_same_table.ipynb` | 2026-03-07 | +| 6. Одна таблица, два движка: Spark и Trino | [module-06-spark-and-trino-on-same-table.md](./module-06-spark-and-trino-on-same-table.md) | Ready for validation | `jupyter/Dockerfile`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/06_spark_and_trino_on_same_table.ipynb` | 2026-03-07 | | 7. Безопасная работа с таблицами: schema evolution и time travel | [module-07-safe-table-changes.md](./module-07-safe-table-changes.md) | Draft | `notebooks/07_safe_table_changes.ipynb` | 2026-03-07 | | 8. Базовое обслуживание таблиц и финальная практика | [module-08-maintenance-and-final-lab.md](./module-08-maintenance-and-final-lab.md) | Draft | `notebooks/08_maintenance_and_final_lab.ipynb` | 2026-03-07 | diff --git a/plans/module-06-spark-and-trino-on-same-table.md b/plans/module-06-spark-and-trino-on-same-table.md index 7c30898..140ec77 100644 --- a/plans/module-06-spark-and-trino-on-same-table.md +++ b/plans/module-06-spark-and-trino-on-same-table.md @@ -1,6 +1,6 @@ # Модуль 6. Одна таблица, два движка: Spark и Trino -**Статус:** `Draft` +**Статус:** `Ready for validation` **Последнее обновление:** `2026-03-07` ## Цель From 9ffb9b80e07a6856d654bed4882afe12ec771637 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 8 Mar 2026 00:19:06 +0300 Subject: [PATCH 22/24] =?UTF-8?q?feat(module-07):=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=20=D0=BF=D0=BE=20schema=20evolution=20=D0=B8?= =?UTF-8?q?=20time=20travel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - обучение студентов безопасным изменениям таблиц и механизмам отката Iceberg. - Что: - создан notebooks/07_safe_table_changes.ipynb с демонстрацией ALTER TABLE, VERSION AS OF и rollback. - в ноутбук добавлена самостоятельная работа на демо-таблице и чекпоинт для самопроверки. - статус модуля 7 в планах обновлен до Ready for validation. - Проверка: - ручная верификация структуры ноутбука и синтаксиса Spark SQL/trino_query. --- notebooks/07_safe_table_changes.ipynb | 904 ++++++++++++++++++++++++++ plans/README.md | 2 +- plans/module-07-safe-table-changes.md | 4 +- 3 files changed, 907 insertions(+), 3 deletions(-) create mode 100644 notebooks/07_safe_table_changes.ipynb diff --git a/notebooks/07_safe_table_changes.ipynb b/notebooks/07_safe_table_changes.ipynb new file mode 100644 index 0000000..196af74 --- /dev/null +++ b/notebooks/07_safe_table_changes.ipynb @@ -0,0 +1,904 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Модуль 7. Безопасная работа с таблицами: schema evolution и time travel\n", + "\n", + "**Цель:** Научиться безопасно изменять Iceberg-таблицы, использовать встроенные механизмы time travel (чтение предыдущих состояний) и rollback (восстановление после ошибок).\n", + "\n", + "В этом модуле мы сравним подходы классических DWH и Lakehouse к работе с изменениями:\n", + "\n", + "| | Классический DWH (Greenplum) | Lakehouse (Iceberg) |\n", + "|---|---|---|\n", + "| Добавить колонку | `ALTER TABLE ADD COLUMN` (мгновенно, NULL) | `ALTER TABLE ADD COLUMNS` (мгновенно, NULL) |\n", + "| Переименовать колонку | `ALTER TABLE RENAME COLUMN` | `ALTER TABLE RENAME COLUMN` |\n", + "| Откатить данные к вчерашнему состоянию | Восстановление из бэкапа (pg_dump / PITR) | `SELECT ... VERSION AS OF ` |\n", + "| Посмотреть историю изменений таблицы | Нет встроенного механизма | `SELECT * FROM table.snapshots` |\n", + "| Последствия ошибочной записи | Нужен бэкап или ручной откат | Rollback к предыдущему snapshot |\n", + "\n", + "**Ключевая идея:** Iceberg хранит полную историю изменений данных. Schema evolution (изменение структуры) и time travel (путешествие во времени) — встроенные инструменты формата, а не дополнительная инфраструктура.\n", + "\n", + "Как устроен этот ноутбук:\n", + "- Schema evolution демонстрируем на нашей рабочей таблице `silver` (это безопасная операция).\n", + "- Time travel и восстановление после ошибки — на отдельной демо-таблице (чтобы не рисковать данными для Модуля 8).\n", + "- Trino используется для проверки эффекта на downstream (системы, читающие данные после нас).\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 1: Spark-сессия, Trino-клиент и проверка таблиц\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "from pyspark.sql import SparkSession\n", + "from trino.dbapi import connect\n", + "import trino\n", + "from IPython.display import display\n", + "\n", + "spark = SparkSession.builder \\\n", + " .appName(\"module-07-safe-table-changes\") \\\n", + " .getOrCreate()\n", + "\n", + "spark.sparkContext.setLogLevel(\"ERROR\")\n", + "print(\"SparkSession создана.\")\n", + "\n", + "SILVER_TABLE = \"lakehouse.silver.nyc_taxi_yellow\"\n", + "DEMO_TABLE = \"lakehouse.default.taxi_changes_demo\"\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Проверяем, что silver-таблица существует (Модуль 5 должен быть пройден)\n", + "assert spark.catalog.tableExists(SILVER_TABLE), f\"Таблица {SILVER_TABLE} не найдена. Пройдите Модуль 5.\"\n", + "count = spark.table(SILVER_TABLE).count()\n", + "assert count > 0, f\"Таблица {SILVER_TABLE} пуста.\"\n", + "print(f\"Таблица {SILVER_TABLE} готова к работе. Строк: {count}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def trino_query(sql: str) -> pd.DataFrame:\n", + " \"\"\"Выполняет SQL-запрос в Trino и возвращает результат как Pandas DataFrame.\"\"\"\n", + " conn = connect(\n", + " host=\"trino\",\n", + " port=8080,\n", + " user=\"jupyter\",\n", + " catalog=\"lakehouse\"\n", + " )\n", + " cur = conn.cursor()\n", + " try:\n", + " cur.execute(sql)\n", + " rows = cur.fetchall()\n", + " columns = [desc[0] for desc in cur.description]\n", + " return pd.DataFrame(rows, columns=columns)\n", + " except trino.exceptions.ProgrammingError as e:\n", + " if \"No nodes available to run query\" in str(e):\n", + " print(\"Ошибка: Trino ещё не готов. Подождите пару минут после старта контейнеров.\")\n", + " raise\n", + " # Запросы типа CREATE/DROP не возвращают строк\n", + " return pd.DataFrame([{\"status\": \"Success\"}])\n", + " finally:\n", + " conn.close()\n", + "\n", + "print(\"Trino helper функция готова.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "*Если ячейки выше упали с `ImportError: trino` — нужно пересобрать образ Jupyter (в терминале: `docker compose build jupyter && docker compose up -d`).*\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Silver на месте, Trino готов. В этом модуле мы будем изменять таблицу и наблюдать последствия из обоих движков.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 2: Snapshot-ы — встроенная история изменений\n", + "\n", + "В Модуле 4 мы впервые увидели snapshot-ы bronze-таблицы. Теперь разберёмся глубже. Каждая операция с данными в Iceberg (INSERT, overwrite, delete) автоматически создаёт snapshot — «снимок» состояния таблицы. Snapshot фиксирует, какие data files составляли таблицу в этот момент. Это как коммит в git: можно вернуться к любому предыдущему состоянию.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Посмотрим snapshot-историю silver-таблицы\n", + "spark.sql(f\"SELECT snapshot_id, committed_at, operation, summary FROM {SILVER_TABLE}.snapshots\").show(truncate=False)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Дополнительная история с указанием предков (родительских snapshot-ов)\n", + "spark.sql(f\"SELECT * FROM {SILVER_TABLE}.history\").show(truncate=False)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Поля таблицы snapshots:\n", + "- `committed_at` — когда произошла операция.\n", + "- `operation` — тип операции (append, overwrite).\n", + "- `summary` — краткая статистика (added-data-files, total-records и т.д.).\n", + "\n", + "Если вы не перезапускали Модуль 5 через CREATE OR REPLACE, у silver будет 1 snapshot (от CTAS).\n", + "В Greenplum такой истории нет: после `INSERT` предыдущее состояние таблицы недоступно без бэкапа.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 3: Schema evolution — безопасное добавление колонки\n", + "\n", + "Добавляем колонку `loaded_at` к silver-таблице. В PostgreSQL `ALTER TABLE ADD COLUMN` — обычная операция. В Iceberg — аналогично, но с важным свойством: Iceberg хранит историю схем.\n", + "\n", + "Важно: `ALTER TABLE ADD COLUMNS` **НЕ создаёт** новый data snapshot. Это изменение метаданных (schema), а не данных: data files не перезаписываются, существующие строки логически получают NULL. Snapshot-ы фиксируют только операции с данными (INSERT, DELETE, overwrite).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " spark.sql(f\"ALTER TABLE {SILVER_TABLE} ADD COLUMNS (loaded_at TIMESTAMP)\")\n", + " print(\"Колонка loaded_at добавлена.\")\n", + "except Exception as e:\n", + " if \"already exists\" in str(e).lower():\n", + " print(\"Колонка loaded_at уже существует (повторный запуск ноутбука). Продолжаем.\")\n", + " else:\n", + " raise\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Проверим схему из Spark\n", + "spark.table(SILVER_TABLE).printSchema()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Посмотрим данные (должен быть NULL)\n", + "spark.sql(f\"SELECT VendorID, loaded_at FROM {SILVER_TABLE} LIMIT 5\").show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Колонка добавлена. Данные не изменились — Iceberg не перезаписывал data files. \n", + "В рабочем сценарии `loaded_at` заполнялся бы при следующих INSERT-ах: `INSERT INTO ... SELECT ..., current_timestamp() AS loaded_at FROM ...`. Существующие строки остаются с NULL — это нормальная практика эволюции схемы. Заполнение существующих строк (UPDATE) — отдельная тема, требующая перезаписи файлов.\n", + "\n", + "Примечание: если вы повторно запустите Модуль 5 после Модуля 7, `CREATE OR REPLACE TABLE` пересоздаст silver без колонки `loaded_at`. Это ещё одна иллюстрация того, почему `CREATE OR REPLACE` опасен в рабочих сценариях — он стирает все изменения, включая schema evolution.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 4: Downstream-эффект — Trino видит изменение\n", + "\n", + "В Модуле 6 мы убедились: Spark и Trino читают одну таблицу через общий каталог. Schema evolution — ещё одно следствие этого дизайна: изменение схемы в Spark мгновенно видно в Trino. Не нужно «синхронизировать» или «обновлять» что-то на стороне Trino.\n", + "\n", + "*В DBeaver: `DESCRIBE lakehouse.silver.nyc_taxi_yellow;`*\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Проверим схему из Trino\n", + "display(trino_query(f\"DESCRIBE {SILVER_TABLE}\"))\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Прочитаем данные из Trino\n", + "display(trino_query(f\"SELECT vendorid, loaded_at FROM {SILVER_TABLE} LIMIT 5\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Trino увидел новую колонку мгновенно. Spark изменил метаданные в PostgreSQL (JDBC catalog), Trino прочитал обновлённые метаданные. Decoupled compute + общий каталог в действии.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 5: Обзор операций schema evolution\n", + "\n", + "`ADD COLUMN` — самая безопасная операция. Но Iceberg поддерживает и другие:\n", + "\n", + "| Операция | Spark SQL | Безопасность | Влияние на downstream |\n", + "|---|---|---|---|\n", + "| Добавить колонку | `ALTER TABLE ADD COLUMNS (col type)` | Безопасно | Новая колонка с NULL, существующие запросы не ломаются |\n", + "| Переименовать колонку | `ALTER TABLE RENAME COLUMN old TO new` | Осторожно | Запросы с `SELECT old_name` перестают работать |\n", + "| Расширить тип | `ALTER TABLE ALTER COLUMN col TYPE bigint` | Безопасно | INT -> BIGINT: без потери данных |\n", + "| Сузить тип | — | Опасно | BIGINT -> INT: возможна потеря данных. Iceberg не поддерживает |\n", + "| Удалить колонку | `ALTER TABLE DROP COLUMN col` | Опасно | Запросы с `SELECT col` перестают работать |\n", + "\n", + "Далее в модуле мы продемонстрируем `RENAME COLUMN` на демо-таблице и покажем, как это ломает downstream-запросы.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 6: Подготовка демо-таблицы с несколькими snapshot-ами\n", + "\n", + "Для экспериментов с time travel и восстановлением (rollback) создаём отдельную демо-таблицу. Silver не трогаем — он нужен для финального Модуля 8. \n", + "\n", + "Мы создадим таблицу с небольшим подмножеством silver (1000 строк) и нарастим snapshot-историю через дополнительные `INSERT INTO`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Создание демо-таблицы\n", + "spark.sql(f\"\"\"\n", + " CREATE OR REPLACE TABLE {DEMO_TABLE}\n", + " USING iceberg AS\n", + " SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime,\n", + " passenger_count, trip_distance, fare_amount, total_amount,\n", + " pickup_borough, pickup_zone\n", + " FROM {SILVER_TABLE}\n", + " LIMIT 1000\n", + "\"\"\")\n", + "\n", + "count_1 = spark.table(DEMO_TABLE).count()\n", + "assert count_1 == 1000, f\"Ожидалось 1000 строк, получено {count_1}\"\n", + "print(f\"Таблица создана. Строк: {count_1}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Сохраним ID первого snapshot-а\n", + "df_snapshots = spark.sql(f\"SELECT snapshot_id, committed_at FROM {DEMO_TABLE}.snapshots ORDER BY committed_at ASC\").collect()\n", + "assert len(df_snapshots) == 1, f\"Ожидался 1 snapshot, получено {len(df_snapshots)}\"\n", + "snapshot_1 = df_snapshots[0]['snapshot_id']\n", + "committed_at_1 = df_snapshots[0]['committed_at']\n", + "\n", + "print(f\"Snapshot 1 ID: {snapshot_1} (создан: {committed_at_1})\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Добавим ещё 500 строк из Манхэттена (создаст второй snapshot)\n", + "spark.sql(f\"\"\"\n", + " INSERT INTO {DEMO_TABLE}\n", + " SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime,\n", + " passenger_count, trip_distance, fare_amount, total_amount,\n", + " pickup_borough, pickup_zone\n", + " FROM {SILVER_TABLE}\n", + " WHERE pickup_borough = 'Manhattan'\n", + " LIMIT 500\n", + "\"\"\")\n", + "\n", + "count_2 = spark.table(DEMO_TABLE).count()\n", + "print(f\"Добавлено 500 строк. Всего строк: {count_2}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Сохраним ID второго snapshot-а\n", + "df_snapshots = spark.sql(f\"SELECT snapshot_id, committed_at FROM {DEMO_TABLE}.snapshots ORDER BY committed_at ASC\").collect()\n", + "snapshot_2 = df_snapshots[1]['snapshot_id']\n", + "committed_at_2 = df_snapshots[1]['committed_at']\n", + "\n", + "print(f\"Snapshot 2 ID: {snapshot_2} (создан: {committed_at_2})\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Теперь у таблицы два snapshot-а: начальная загрузка (1000 строк) и добавление (500 строк). Это как два коммита в git. Каждый фиксирует конкретное состояние таблицы.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 7: Time travel — чтение предыдущих состояний\n", + "\n", + "Центральная возможность Iceberg: можно прочитать таблицу в том состоянии, в каком она была на момент любого snapshot-а. Это time travel. В Greenplum для этого пришлось бы восстанавливать бэкап целой базы или настраивать сложный PITR.\n", + "\n", + "#### 7a: Time travel через Spark\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Чтение первого snapshot-а (начальная загрузка)\n", + "print(f\"Читаем таблицу по состоянию Snapshot 1 ({snapshot_1}):\")\n", + "\n", + "spark.sql(f\"\"\"\n", + " SELECT count(*) AS row_count\n", + " FROM {DEMO_TABLE}\n", + " VERSION AS OF {snapshot_1}\n", + "\"\"\").show()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Чтение текущего состояния для сравнения\n", + "print(\"Читаем текущее состояние таблицы:\")\n", + "\n", + "spark.sql(f\"\"\"\n", + " SELECT count(*) AS row_count\n", + " FROM {DEMO_TABLE}\n", + "\"\"\").show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Данные из первого snapshot-а не потеряны — оба состояния доступны одновременно. Iceberg хранит все data files; snapshot определяет, какие из них составляют таблицу в конкретный момент.\n", + "\n", + "#### 7b: Time travel через Trino\n", + "\n", + "Trino тоже умеет time travel. Тот же snapshot, та же таблица, тот же каталог.\n", + "Обратите внимание на синтаксис: в Spark — `VERSION AS OF`, в Trino — `FOR VERSION AS OF`.\n", + "\n", + "*В DBeaver: `SELECT count(*) FROM lakehouse.default.taxi_changes_demo FOR VERSION AS OF ;`*\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Time travel через Trino к первому snapshot-у\n", + "print(f\"Trino читает Snapshot 1 ({snapshot_1}):\")\n", + "\n", + "display(trino_query(f\"\"\"\n", + " SELECT count(*) AS row_count\n", + " FROM {DEMO_TABLE}\n", + " FOR VERSION AS OF {snapshot_1}\n", + "\"\"\"))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 7c: Time travel по времени\n", + "\n", + "Альтернативный способ — по timestamp. `TIMESTAMP AS OF` — Iceberg находит ближайший snapshot, существовавший на этот момент времени. Для точного отката лучше использовать `snapshot_id`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Time travel по времени (состояние на момент создания Snapshot 1)\n", + "spark.sql(f\"\"\"\n", + " SELECT count(*) AS row_count\n", + " FROM {DEMO_TABLE}\n", + " TIMESTAMP AS OF '{committed_at_1}'\n", + "\"\"\").show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 8: Намеренная ошибка и восстановление (Rollback)\n", + "\n", + "Ключевой урок модуля. Мы намеренно внесём «плохие» данные, затем восстановим таблицу через rollback. Ошибки в Iceberg — не катастрофа, если понимаешь snapshot-историю.\n", + "\n", + "#### 8a: Вносим ошибку\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# INSERT «плохих» данных (200 строк с отрицательным fare_amount)\n", + "spark.sql(f\"\"\"\n", + " INSERT INTO {DEMO_TABLE}\n", + " SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime,\n", + " passenger_count, trip_distance,\n", + " -99.99 AS fare_amount,\n", + " -99.99 AS total_amount,\n", + " 'ERROR' AS pickup_borough,\n", + " 'BAD_DATA' AS pickup_zone\n", + " FROM {SILVER_TABLE}\n", + " LIMIT 200\n", + "\"\"\")\n", + "\n", + "# Убедимся, что таблица \"испорчена\"\n", + "spark.sql(f\"\"\"\n", + " SELECT count(*) as bad_rows \n", + " FROM {DEMO_TABLE} \n", + " WHERE fare_amount < 0\n", + "\"\"\").show()\n", + "\n", + "count_3 = spark.table(DEMO_TABLE).count()\n", + "print(f\"Всего строк сейчас: {count_3}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Посмотрим на 3-й snapshot (нашу ошибку)\n", + "spark.sql(f\"SELECT snapshot_id, operation FROM {DEMO_TABLE}.snapshots\").show(truncate=False)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "В Greenplum: 200 ошибочных строк в production-таблице -> звонок DBA, восстановление из бэкапа (если он есть и свежий), простой сервиса, или опасные ручные `DELETE`/`UPDATE` без возможности отката.\n", + "В Iceberg: rollback к предыдущему snapshot-у.\n", + "\n", + "#### 8b: Восстановление через rollback\n", + "Мы откатимся к snapshot 2, который мы сохранили в переменной `snapshot_2`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Выполняем rollback\n", + "spark.sql(f\"\"\"\n", + " CALL lakehouse.system.rollback_to_snapshot(\n", + " table => 'default.taxi_changes_demo',\n", + " snapshot_id => {snapshot_2}\n", + " )\n", + "\"\"\")\n", + "print(f\"Rollback к snapshot {snapshot_2} завершен.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Проверка восстановления\n", + "bad_rows = spark.sql(f\"SELECT count(*) as bad_rows FROM {DEMO_TABLE} WHERE fare_amount < 0\").collect()[0]['bad_rows']\n", + "current_count = spark.table(DEMO_TABLE).count()\n", + "\n", + "print(f\"Плохих строк (fare_amount < 0): {bad_rows}\")\n", + "print(f\"Всего строк: {current_count}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Таблица восстановлена!\n", + "\n", + "#### 8c: Что произошло со snapshot-историей?\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(f\"SELECT snapshot_id, operation FROM {DEMO_TABLE}.snapshots\").show(truncate=False)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Теперь в истории 4 записи. Четвёртый snapshot — это результат rollback, но его данные (набор файлов) в точности соответствуют snapshot 2.\n", + "\n", + "Rollback **не удаляет историю** и **не удаляет файлы**. Он создаёт новый snapshot, указывающий на данные предыдущего состояния. Аналогия:\n", + "\n", + "| | git | Iceberg |\n", + "|---|---|---|\n", + "| Откат с сохранением истории | `git revert` (новый коммит) | `rollback_to_snapshot` (новый snapshot) |\n", + "| Откат с удалением истории | `git reset --hard` | `CREATE OR REPLACE` (уничтожает snapshot-ы) |\n", + "\n", + "Старые data files (включая файлы с «плохими» строками) остаются в хранилище (MinIO) до процедуры `expire_snapshots` — это тема Модуля 8. Rollback не чистит storage, он только меняет указатель текущего snapshot-а.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 9: RENAME COLUMN и влияние на downstream\n", + "\n", + "Покажем, что `RENAME COLUMN` — безопасная операция для данных (они не перезаписываются), но опасная для downstream-запросов (они могут сломаться).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Переименуем колонку в демо-таблице\n", + "spark.sql(f\"ALTER TABLE {DEMO_TABLE} RENAME COLUMN pickup_borough TO borough\")\n", + "print(\"Колонка переименована в Spark.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Посмотрим схему из Spark - колонка теперь borough\n", + "spark.table(DEMO_TABLE).printSchema()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Запрос с новым именем borough через Trino работает\n", + "display(trino_query(f\"SELECT borough FROM {DEMO_TABLE} LIMIT 3\"))\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# А запрос со старым именем pickup_borough упадет с ошибкой\n", + "try:\n", + " display(trino_query(f\"SELECT pickup_borough FROM {DEMO_TABLE} LIMIT 3\"))\n", + "except Exception as e:\n", + " print(f\"Ошибка в Trino: {e}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Данные не потеряны — колонка переименована в метаданных. Но любой downstream-запрос, использовавший старое имя `pickup_borough`, сломается. В рабочем окружении перед `RENAME` нужно проверить, кто и как читает эту колонку.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 10: Типичные ошибки новичка\n", + "\n", + "**Ошибка 1: Слепой `CREATE OR REPLACE`.**\n", + "В Модулях 4 и 5 мы использовали `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) для создания bronze и silver. Это был осознанный компромисс: первичная загрузка, нет предыдущей истории, которую нужно сохранять. Но в рабочих сценариях `CREATE OR REPLACE` — **деструктивная операция**: она уничтожает ВСЮ snapshot-историю таблицы. Если у таблицы было 100 snapshot-ов, после `CREATE OR REPLACE` останется один. Time travel к предыдущим состояниям станет невозможен.\n", + "\n", + "**Ошибка 2: Изменение схемы без проверки downstream.**\n", + "Мы только что видели, как `RENAME COLUMN` ломает запросы со старым именем. `ALTER COLUMN TYPE` (расширение INT -> BIGINT) безопасен для данных, но downstream-системы могут интерпретировать новый тип по-другому и сломаться на своей стороне. Перед любым изменением схемы нужно понимать, кто читает эту таблицу.\n", + "\n", + "**Ошибка 3: Отсутствие проверки snapshot-истории перед деструктивной операцией.**\n", + "Перед любой сложной операцией, массово изменяющей данные (UPDATE/DELETE), полезно посмотреть текущее состояние: `SELECT * FROM table.snapshots`. Это занимает секунды и даёт понимание, какой snapshot станет точкой отката в случае проблемы. Привычка: посмотрел snapshot-ы -> выполнил операцию -> проверил результат -> убедился, что новый snapshot создан корректно.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 11: Самостоятельное задание\n", + "\n", + "Пересоздадим демо-таблицу с чистого листа. После демонстраций выше её схема изменена, а история содержит учебные эксперименты. Для самостоятельной работы нужна чистая таблица.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Пересоздаем демо-таблицу (это удалит всю ее snapshot-историю - Ошибка 1 в действии!)\n", + "spark.sql(f\"\"\"\n", + " CREATE OR REPLACE TABLE {DEMO_TABLE}\n", + " USING iceberg AS\n", + " SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime,\n", + " passenger_count, trip_distance, fare_amount, total_amount,\n", + " pickup_borough, pickup_zone\n", + " FROM {SILVER_TABLE}\n", + " LIMIT 1000\n", + "\"\"\")\n", + "print(f\"Демо-таблица пересоздана начисто: {spark.table(DEMO_TABLE).count()} строк, 1 snapshot.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Выполни 5 задач на демо-таблице `lakehouse.default.taxi_changes_demo`.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Задача 1 (schema evolution).** Добавь колонку `quality_flag STRING` к демо-таблице через `ALTER TABLE ADD COLUMNS`. Проверь из Spark и из Trino, что колонка появилась и все значения NULL.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "*DBeaver: `SELECT * FROM lakehouse.default.taxi_changes_demo LIMIT 10;`*\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: ALTER TABLE ADD COLUMNS (quality_flag)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: проверка из Spark и Trino\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Задача 2 (создание snapshot).** Вставь (INSERT INTO) 300 строк из silver (с условием `pickup_borough = 'Brooklyn'`) в демо-таблицу. Проверь, что появился новый snapshot (всего их станет 2).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: INSERT 300 строк из Brooklyn\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Задача 3 (time travel).** Посмотри snapshot-историю демо-таблицы. Прочитай таблицу в состоянии ДО добавления 300 строк (т.е. к snapshot 1) через `VERSION AS OF`. Сравни количество строк.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: snapshot-история\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: time travel — VERSION AS OF\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Задача 4 (recovery).** Сделай INSERT 100 строк с `fare_amount = -1` и `pickup_zone = 'STUDENT_ERROR'` в демо-таблицу. Убедись, что «плохие» строки появились. Затем выполни `rollback_to_snapshot` к snapshot-у ДО этого INSERT-а (т.е. к snapshot 2). Проверь, что таблица восстановлена.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: INSERT 100 «плохих» строк\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: rollback_to_snapshot\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: проверка восстановления\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Задача 5 (ответ).** Ответь текстом в ячейке ниже: чем `rollback_to_snapshot` отличается от `CREATE OR REPLACE TABLE ... AS SELECT * FROM table VERSION AS OF `? Что происходит с историей snapshot-ов в каждом случае?\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Ваш ответ на Задачу 5: \n", + "...\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "*(Дополнительно, по желанию)*: Посмотри snapshot-историю silver-таблицы. Сколько snapshot-ов у неё? Какие операции их создали?\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: snapshot-история silver\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 12: Checkpoint\n", + "\n", + "Ответь для себя на вопросы:\n", + "1. Что такое snapshot в Iceberg и когда он создаётся?\n", + "2. Создаёт ли `ALTER TABLE ADD COLUMNS` новый data snapshot? Почему?\n", + "3. Чем `ADD COLUMN` отличается от `RENAME COLUMN` по влиянию на downstream?\n", + "4. Как прочитать предыдущее состояние таблицы? (назови два способа)\n", + "5. Что делает `rollback_to_snapshot`? Теряется ли при этом snapshot-история?\n", + "6. Почему `CREATE OR REPLACE` опаснее, чем `INSERT INTO` с последующим rollback?\n", + "7. Какой аналог time travel в классических СУБД (PostgreSQL/Greenplum) и почему он сложнее в использовании?\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 13: Завершение\n", + "\n", + "Удаляем демо-таблицу — она больше не нужна. Silver остаётся для Модуля 8 (колонка `loaded_at` — демонстрационная; Модуль 8 не зависит от неё).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(f\"DROP TABLE IF EXISTS {DEMO_TABLE}\")\n", + "print(\"Демо-таблица удалена.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Итог модуля:**\n", + "- Schema evolution (`ADD COLUMN`) — безопасная metadata-only операция, которая мгновенно видна из всех движков через общий каталог.\n", + "- Snapshot-ы — автоматическая история изменений данных, встроенная в Iceberg.\n", + "- Time travel — чтение предыдущих состояний без бэкапов.\n", + "- Rollback — восстановление после ошибки с сохранением истории.\n", + "\n", + "В финальном Модуле 8 мы изучим базовое обслуживание таблиц: compaction (решение проблемы множества мелких файлов) и `expire_snapshots` (очистка старых файлов данных, оставшихся после time travel и rollback, которые мы научились применять в этом модуле).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spark.stop()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/plans/README.md b/plans/README.md index 7376721..0a9ec15 100644 --- a/plans/README.md +++ b/plans/README.md @@ -19,5 +19,5 @@ | 4. Первая рабочая Iceberg-таблица и слой bronze | [module-04-bronze-with-iceberg.md](./module-04-bronze-with-iceberg.md) | Draft | `notebooks/04_bronze_with_iceberg.ipynb` | 2026-03-07 | | 5. Слой silver и воспроизводимые трансформации | [module-05-silver-layer.md](./module-05-silver-layer.md) | Ready for validation | `notebooks/05_silver_layer.ipynb` | 2026-03-07 | | 6. Одна таблица, два движка: Spark и Trino | [module-06-spark-and-trino-on-same-table.md](./module-06-spark-and-trino-on-same-table.md) | Ready for validation | `jupyter/Dockerfile`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/06_spark_and_trino_on_same_table.ipynb` | 2026-03-07 | -| 7. Безопасная работа с таблицами: schema evolution и time travel | [module-07-safe-table-changes.md](./module-07-safe-table-changes.md) | Draft | `notebooks/07_safe_table_changes.ipynb` | 2026-03-07 | +| 7. Безопасная работа с таблицами: schema evolution и time travel | [module-07-safe-table-changes.md](./module-07-safe-table-changes.md) | Ready for validation | `notebooks/07_safe_table_changes.ipynb` | 2026-03-08 | | 8. Базовое обслуживание таблиц и финальная практика | [module-08-maintenance-and-final-lab.md](./module-08-maintenance-and-final-lab.md) | Draft | `notebooks/08_maintenance_and_final_lab.ipynb` | 2026-03-07 | diff --git a/plans/module-07-safe-table-changes.md b/plans/module-07-safe-table-changes.md index 79a8f60..77f2895 100644 --- a/plans/module-07-safe-table-changes.md +++ b/plans/module-07-safe-table-changes.md @@ -1,7 +1,7 @@ # Модуль 7. Безопасная работа с таблицами: schema evolution и time travel -**Статус:** `Draft` -**Последнее обновление:** `2026-03-07` +**Статус:** `Ready for validation` +**Последнее обновление:** `2026-03-08` ## Цель From 4bea2af9d2289f8cb8814ec648b452071238e43d Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 8 Mar 2026 00:33:40 +0300 Subject: [PATCH 23/24] =?UTF-8?q?feat(module-08):=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=20=D0=BF=D0=BE=20=D0=BE=D0=B1=D1=81=D0=BB?= =?UTF-8?q?=D1=83=D0=B6=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8E=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=86=20=D0=B8=20=D1=84=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - обучение студентов базовому обслуживанию Iceberg-таблиц (compaction, expire_snapshots) и закрепление навыков всего курса. - Что: - создан notebooks/08_maintenance_and_final_lab.ipynb с обзором проблемы мелких файлов и её решения. - добавлена финальная сквозная практика (raw -> bronze -> silver -> Trino -> schema evolution -> обслуживание) в namespace lakehouse.final. - в финальную практику включены педагогические подсказки и конкретные задания (trip_duration_minutes). - статус модуля 8 в планах обновлен до Ready for validation. - Проверка: - ручная проверка выполнения ячеек в ноутбуке и синтаксиса stored procedures Iceberg. --- notebooks/08_maintenance_and_final_lab.ipynb | 787 +++++++++++++++++++ plans/README.md | 2 +- plans/module-08-maintenance-and-final-lab.md | 4 +- 3 files changed, 790 insertions(+), 3 deletions(-) create mode 100644 notebooks/08_maintenance_and_final_lab.ipynb diff --git a/notebooks/08_maintenance_and_final_lab.ipynb b/notebooks/08_maintenance_and_final_lab.ipynb new file mode 100644 index 0000000..9c45274 --- /dev/null +++ b/notebooks/08_maintenance_and_final_lab.ipynb @@ -0,0 +1,787 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Модуль 8. Базовое обслуживание таблиц и финальная практика\n", + "\n", + "**Цель:** Научиться базовому обслуживанию Iceberg-таблиц (compaction и expire_snapshots) и закрепить все навыки курса в финальной сквозной практике.\n", + "\n", + "**Prerequisites:** пройден Модуль 6, Модуль 7 рекомендуется.\n", + "\n", + "Две части модуля:\n", + "1. Базовое обслуживание: compaction (решение проблемы мелких файлов) и expire_snapshots (очистка истории).\n", + "2. Финальная практика: сквозной end-to-end сценарий.\n", + "\n", + "Зачем нужно обслуживание? Iceberg-таблица — не «чёрный ящик». После множества записей накапливаются мелкие файлы и старые snapshot-ы. Без обслуживания чтение замедляется (много мелких файлов), а хранилище растёт (старые данные не удаляются). Это знакомо из мира Greenplum: AppendOnly-таблицы тоже требуют VACUUM и REORGANIZE.\n", + "\n", + "| | Greenplum AppendOnly | Iceberg |\n", + "|---|---|---|\n", + "| Проблема | Мёртвые строки после UPDATE/DELETE | Мелкие файлы после множества INSERT |\n", + "| Компактификация | `ALTER TABLE ... REORGANIZE` | `rewrite_data_files` |\n", + "| Очистка устаревших версий | Нет встроенной истории версий | `expire_snapshots` |\n", + "| Очистка мёртвых строк/файлов | `VACUUM` | `expire_snapshots` (удаляет старые файлы) |\n", + "| Автоматизация в базовой конфигурации | Ручной запуск | Ручной запуск |\n", + "| В production | Расписание через cron/Airflow | Расписание через cron/Airflow |\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 1: Spark-сессия, Trino-клиент и проверка таблиц\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "from pyspark.sql import SparkSession\n", + "from trino.dbapi import connect\n", + "import trino\n", + "from IPython.display import display\n", + "\n", + "spark = SparkSession.builder \\\n", + " .appName(\"module-08-maintenance-and-lab\") \\\n", + " .getOrCreate()\n", + "\n", + "spark.sparkContext.setLogLevel(\"ERROR\")\n", + "print(\"SparkSession создана.\")\n", + "\n", + "SILVER_TABLE = \"lakehouse.silver.nyc_taxi_yellow\"\n", + "DEMO_TABLE = \"lakehouse.default.taxi_maintenance_demo\"\n", + "RAW_PATH = \"s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet\"\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "*Если ячейки выше упали с `ImportError: trino` — нужно пересобрать образ Jupyter (в терминале: `docker compose build jupyter && docker compose up -d`).*\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Проверяем, что silver-таблица существует (Модуль 5 должен быть пройден)\n", + "assert spark.catalog.tableExists(SILVER_TABLE), f\"Таблица {SILVER_TABLE} не найдена. Пройдите Модуль 5.\"\n", + "assert spark.table(SILVER_TABLE).count() > 0, f\"Таблица {SILVER_TABLE} пуста.\"\n", + "print(f\"Таблица {SILVER_TABLE} готова к работе.\")\n", + "\n", + "# Проверяем, что raw-данные доступны (Модуль 3)\n", + "try:\n", + " spark.read.parquet(RAW_PATH).limit(1).collect()\n", + " print(f\"Raw-данные доступны по пути: {RAW_PATH}\")\n", + "except Exception as e:\n", + " raise AssertionError(f\"Raw-данные не найдены по пути {RAW_PATH}. Вернись в Модуль 3 и выполни загрузку данных в MinIO.\") from e\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Helper для выполнения запросов к Trino\n", + "def trino_query(sql: str) -> pd.DataFrame:\n", + " \"\"\"Выполняет SQL-запрос в Trino и возвращает результат как Pandas DataFrame.\"\"\"\n", + " conn = connect(\n", + " host=\"trino\",\n", + " port=8080,\n", + " user=\"jupyter\",\n", + " catalog=\"lakehouse\"\n", + " )\n", + " cur = conn.cursor()\n", + " try:\n", + " cur.execute(sql)\n", + " rows = cur.fetchall()\n", + " columns = [desc[0] for desc in cur.description]\n", + " return pd.DataFrame(rows, columns=columns)\n", + " except trino.exceptions.ProgrammingError as e:\n", + " if \"No nodes available to run query\" in str(e):\n", + " print(\"Ошибка: Trino ещё не готов. Подождите пару минут после старта контейнеров.\")\n", + " raise\n", + " # Запросы типа CREATE/DROP не возвращают строк\n", + " return pd.DataFrame([{\"status\": \"Success\"}])\n", + " finally:\n", + " conn.close()\n", + "\n", + "print(\"Trino helper функция готова.\")\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Silver на месте, raw-данные доступны, Trino готов. В первой части модуля создадим демо-таблицу и научимся её обслуживать. Во второй — финальная практика.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 2: Проблема мелких файлов\n", + "\n", + "Каждый `INSERT INTO` в Iceberg создаёт новые data files. Если вставлять данные мелкими порциями (частые микробатчи, ручные INSERT-ы, инкрементальные загрузки), таблица накапливает множество мелких файлов. Это замедляет чтение: движку приходится открывать и читать каждый файл отдельно. \n", + "\n", + "Создадим демо-таблицу и сымитируем эту проблему: один изначальный `CTAS` (500 строк) и 8 маленьких `INSERT INTO` (по 100 строк).\n", + "*(Внимание: 8 INSERT-ов могут занять 1-2 минуты)*\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Создание демо-таблицы (создаст 1 файл)\n", + "spark.sql(f\"\"\"\n", + " CREATE OR REPLACE TABLE {DEMO_TABLE}\n", + " USING iceberg AS\n", + " SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime,\n", + " passenger_count, trip_distance, fare_amount, total_amount,\n", + " pickup_borough, pickup_zone\n", + " FROM {SILVER_TABLE}\n", + " LIMIT 500\n", + "\"\"\")\n", + "print(f\"Демо-таблица создана: {spark.table(DEMO_TABLE).count()} строк.\")\n", + "\n", + "# 8 мелких INSERT INTO (создадут 8 новых мелких файлов)\n", + "print(\"Запуск 8 INSERT-ов (может занять 1-2 минуты)...\")\n", + "for i in range(8):\n", + " spark.sql(f\"\"\"\n", + " INSERT INTO {DEMO_TABLE}\n", + " SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime,\n", + " passenger_count, trip_distance, fare_amount, total_amount,\n", + " pickup_borough, pickup_zone\n", + " FROM {SILVER_TABLE}\n", + " LIMIT 100\n", + " \"\"\")\n", + " print(f\"INSERT {i+1}/8 выполнен.\")\n", + "\n", + "print(f\"Итого строк: {spark.table(DEMO_TABLE).count()}\")\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Файловая статистика через metadata-таблицу files\n", + "files_df = spark.sql(f\"\"\"\n", + " SELECT file_path, file_format, record_count, file_size_in_bytes\n", + " FROM {DEMO_TABLE}.files\n", + "\"\"\")\n", + "print(f\"Количество data files: {files_df.count()}\")\n", + "files_df.show(truncate=40)\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Snapshot-история\n", + "spark.sql(f\"SELECT snapshot_id, committed_at, operation, summary FROM {DEMO_TABLE}.snapshots\").show(truncate=False)\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Результат:** 9 data files, 9 snapshot-ов. Каждый INSERT создал отдельный маленький файл. В production-сценарии после недель инкрементальных загрузок таблица может накопить сотни и тысячи мелких файлов.\n", + "\n", + "Для сравнения посмотрим на файловую статистику нашей основной `silver`-таблицы, которая была создана одним большим CTAS.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "silver_files = spark.sql(f\"SELECT file_path, record_count, file_size_in_bytes FROM {SILVER_TABLE}.files\")\n", + "print(f\"Silver: {silver_files.count()} data file(s)\")\n", + "silver_files.show(truncate=40)\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`silver` имеет мало крупных файлов — проблемы мелких файлов нет. Compaction нужен не всегда, а после множества мелких записей. Далее мы исправим проблему на демо-таблице.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 3: Compaction — rewrite_data_files\n", + "\n", + "`rewrite_data_files` объединяет мелкие data files в меньшее количество крупных. \n", + "Аналогия: дефрагментация диска. Или `ALTER TABLE ... REORGANIZE` в Greenplum.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Сохраним статистику ДО compaction\n", + "files_before = spark.sql(f\"SELECT count(*) AS file_count, sum(file_size_in_bytes) AS total_bytes FROM {DEMO_TABLE}.files\").first()\n", + "print(f\"До compaction: {files_before['file_count']} файлов, {files_before['total_bytes']} байт\")\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Выполняем compaction\n", + "spark.sql(f\"CALL lakehouse.system.rewrite_data_files(table => 'default.taxi_maintenance_demo')\")\n", + "print(\"Compaction выполнен.\")\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Статистика ПОСЛЕ compaction\n", + "files_after = spark.sql(f\"SELECT count(*) AS file_count, sum(file_size_in_bytes) AS total_bytes FROM {DEMO_TABLE}.files\").first()\n", + "print(f\"После compaction: {files_after['file_count']} файлов, {files_after['total_bytes']} байт\")\n", + "print(f\"Было {files_before['file_count']} файлов → стало {files_after['file_count']} файлов\")\n", + "\n", + "# Проверим, что данные на месте\n", + "print(f\"Строк в таблице: {spark.table(DEMO_TABLE).count()}\")\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Данные не изменились — только реорганизованы файлы (чтение стало быстрее). \n", + "Compaction создал **новый snapshot** с новыми, более крупными файлами.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Посмотрим snapshot-историю: появился новый snapshot\n", + "spark.sql(f\"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots\").show(truncate=False)\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Но старые мелкие файлы всё ещё лежат в MinIO: они нужны для time travel к старым snapshot-ам (например, чтобы мы могли запросить данные ДО compaction или ДО какого-то INSERT-а). Чтобы освободить место в хранилище, нужен `expire_snapshots`.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 4: expire_snapshots — очистка истории\n", + "\n", + "`expire_snapshots` удаляет старые snapshot-ы из метаданных. \n", + "**Важный компромисс:** после expire_snapshots time travel к удалённым snapshot-ам невозможен. Это необратимо. \n", + "В production нужно решить: сколько истории хранить? Обычно 10–30 дней. Для демо оставим только 2 последних snapshot-а.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Snapshot-история ДО expire\n", + "snapshots_before = spark.sql(f\"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots\")\n", + "print(f\"Snapshot-ов до expire: {snapshots_before.count()}\")\n", + "snapshots_before.show(truncate=False)\n", + "\n", + "# Сохраняем ID самого старого snapshot для проверки time travel после expire\n", + "first_snapshot_id = snapshots_before.orderBy(\"committed_at\").first()[\"snapshot_id\"]\n", + "print(f\"Самый старый snapshot: {first_snapshot_id}\")\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Выполняем expire_snapshots (оставляем только 2 последних)\n", + "spark.sql(f\"\"\"\n", + " CALL lakehouse.system.expire_snapshots(\n", + " table => 'default.taxi_maintenance_demo',\n", + " retain_last => 2\n", + " )\n", + "\"\"\")\n", + "print(\"expire_snapshots выполнен.\")\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Snapshot-история ПОСЛЕ expire\n", + "snapshots_after = spark.sql(f\"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots\")\n", + "print(f\"Snapshot-ов после expire: {snapshots_after.count()}\")\n", + "snapshots_after.show(truncate=False)\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Осталось всего 2 snapshot-а. Попробуем прочитать данные из удалённого (самого первого) snapshot-а:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " spark.sql(f\"\"\"\n", + " SELECT count(*) FROM {DEMO_TABLE}\n", + " VERSION AS OF {first_snapshot_id}\n", + " \"\"\").show()\n", + " print(\"Time travel сработал (snapshot не был удалён).\")\n", + "except Exception as e:\n", + " print(f\"Ожидаемая ошибка: {e}\")\n", + " print(\"Snapshot удалён — time travel невозможен.\")\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Вывод:** expire нужно запускать обдуманно. Слишком агрессивно (потеря страховки для rollback), слишком редко (рост метаданных и стоимости хранения).\n", + "\n", + "*Примечание: физическое удаление файлов из MinIO зависит от конфигурации. Иногда требуется дополнительно запускать процедуру `remove_orphan_files`, которая физически чистит файлы без привязки к активным snapshot-ам.*\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 5: Порядок обслуживания и типичные ошибки\n", + "\n", + "**Правильный порядок обслуживания Iceberg-таблицы:**\n", + "| Шаг | Операция | Что делает | Что будет, если пропустить |\n", + "|---|---|---|---|\n", + "| 1 | `rewrite_data_files` | Объединяет мелкие файлы в крупные | Чтение остаётся медленным |\n", + "| 2 | `expire_snapshots` | Удаляет старые snapshot-ы | Метаданные и хранилище растут |\n", + "\n", + "\n", + "Если сделать наоборот: expire удалит snapshot-ы, но мелкие файлы останутся (так как на них всё ещё ссылается текущий snapshot). Compaction затем создаст новые крупные файлы, а старые мелкие станут \"orphan\" (сиротами) и не удалятся автоматически.\n", + "\n", + "**Типичные ошибки:**\n", + "1. **Никогда не запускать compaction:** таблица обрастает тысячами мелких файлов, чтение деградирует в десятки раз.\n", + "2. **`expire_snapshots` сразу после ошибки (с `retain_last => 1`):** если записали плохие данные и запустили expire, откатиться назад (rollback) уже не выйдет. Правило: перед expire убедись, что текущее состояние корректно.\n", + "3. **Обслуживание на каждый INSERT:** compaction — тяжёлая операция. В production его делают по расписанию (например, 1 раз в сутки ночью).\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 6: Параллель с Greenplum AppendOnly\n", + "\n", + "Развернутое сравнение для студентов с опытом Greenplum:\n", + "\n", + "| Аспект | Greenplum AppendOnly | Iceberg |\n", + "|---|---|---|\n", + "| Как накапливается «мусор» | UPDATE/DELETE оставляют мёртвые строки в сегментах | Множество INSERT создают мелкие data files |\n", + "| Влияние на чтение | Сканирование мёртвых строк замедляет запросы | Открытие множества мелких файлов замедляет запросы |\n", + "| Компактификация | `ALTER TABLE ... REORGANIZE` | `rewrite_data_files` (объединение файлов) |\n", + "| Очистка | `VACUUM` (удаление мёртвых строк) | `expire_snapshots` (удаление старых snapshot-ов) |\n", + "| Автоматизация | `autovacuum` для heap-таблиц, ручной VACUUM для AO | Ручной запуск, автоматизация через Airflow/cron |\n", + "| Просмотр состояния | `pg_stat_all_tables`, `gp_toolkit.gp_bloat_diag` | `table.files`, `table.snapshots` |\n", + "| Time travel после очистки | Невозможен | Невозможен для удалённых snapshot-ов |\n", + "\n", + "Ключевое сходство: ни одна система не обслуживает себя сама. И там, и там это задача инженера. Ключевое отличие: Iceberg даёт встроенную историю (snapshot-ы) и контролируемую очистку (`retain_last`), чего нет в классическом DWH.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 7: Самостоятельное задание — обслуживание\n", + "\n", + "**Задача 1 (анализ silver).** Посмотри файловую статистику `silver`-таблицы (`lakehouse.silver.nyc_taxi_yellow`): количество data files, средний размер файла, количество snapshot-ов. Сравни с демо-таблицей до compaction. Ответь: нужна ли silver compaction? При каких условиях потребовалась бы?\n", + "\n", + "**Задача 2 (практика).** \n", + "- Создай таблицу `lakehouse.default.taxi_compaction_practice` через CTAS (500 строк из `silver`). \n", + "- Выполни 5 INSERT INTO по 200 строк. \n", + "- Посмотри файловую статистику ДО.\n", + "- Выполни compaction и expire_snapshots (retain_last => 1). \n", + "- Проверь результат ПОСЛЕ. \n", + "- Удали таблицу (`DROP TABLE`).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: файловая статистика silver (файлы, размеры, snapshot-ы)\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Ваш ответ на Задачу 1: нужна ли compaction для silver? При каких условиях потребовалась бы? \n", + "...\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: создание taxi_compaction_practice + 5 INSERT\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: файловая статистика до compaction\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: compaction + expire_snapshots\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Ваш код: файловая статистика после + DROP TABLE\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 8: Удаление демо-таблицы\n", + "\n", + "Демо-таблица для обслуживания больше не нужна.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spark.sql(f\"DROP TABLE IF EXISTS {DEMO_TABLE}\")\n", + "print(\"Демо-таблица удалена.\")\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 9: Финальная практика — сквозной end-to-end сценарий\n", + "\n", + "Финальная практика курса. Самостоятельно пройди полный цикл Lakehouse-пайплайна: от raw-данных до обслуживания таблицы. Все навыки из Модулей 1–8 в одном сценарии.\n", + "\n", + "Работаем в отдельном namespace `lakehouse.final`, чтобы не затрагивать основные таблицы.\n", + "\n", + "**Сценарий (8 шагов):**\n", + "\n", + "**Шаг 1 (namespace).** Создай namespace `lakehouse.final`.\n", + "\n", + "**Шаг 2 (bronze).** Прочитай raw parquet из MinIO (`s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet`, LIMIT 5000 строк) и создай таблицу `lakehouse.final.trips_bronze` через CTAS. Это bronze: данные «как есть».\n", + "\n", + "**Шаг 3 (silver).** Создай `lakehouse.final.trips_silver` из bronze с тремя трансформациями:\n", + "- Фильтр: `fare_amount >= 0`\n", + "- Нормализация: `passenger_count` → INT с обработкой NULL (`coalesce` + `cast`)\n", + "- Обогащение: вычисляемая колонка `trip_duration_minutes` (разница между dropoff и pickup в минутах)\n", + "\n", + "**Шаг 4 (Trino).** Прочитай `lakehouse.final.trips_silver` из Trino. Сравни count с результатом из Spark.\n", + "\n", + "**Шаг 5 (schema evolution).** Добавь колонку `processed_at TIMESTAMP` к `trips_silver` через `ALTER TABLE ADD COLUMNS`. Проверь из Trino (`DESCRIBE`), что колонка видна.\n", + "\n", + "**Шаг 6 (snapshot-ы).** Выполни 3 `INSERT INTO trips_silver` (например, по 300 строк из `trips_bronze`). Каждый INSERT должен включать трансформации из шага 3, плюс `current_timestamp() AS processed_at`. *Подсказка: если не указать `processed_at`, Spark выдаст ошибку несовпадения числа колонок; альтернативный вариант — использовать явный список target-колонок в `INSERT INTO trips_silver (col1, col2, ...)`.* Посмотри файловую статистику и snapshot-ы.\n", + "\n", + "**Шаг 7 (обслуживание).** Выполни compaction (`rewrite_data_files`), затем `expire_snapshots` (retain_last => 1). Проверь, что файлов стало меньше, а snapshot-ов остался 1.\n", + "\n", + "**Шаг 8 (cleanup).** Удали `trips_silver`, `trips_bronze` и namespace `lakehouse.final`.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Шаг 1: CREATE NAMESPACE\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Шаг 2: прочитай raw и создай trips_bronze (LIMIT 5000)\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Шаг 2: проверка trips_bronze (count, printSchema)\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Шаг 3: создай trips_silver с трансформациями\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Шаг 3: проверка trips_silver (count, проверка фильтра)\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "*DBeaver: `SELECT count(*) FROM lakehouse.final.trips_silver;`*\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Шаг 4: чтение trips_silver из Trino\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Шаг 5: ALTER TABLE ADD COLUMNS\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "*DBeaver: `DESCRIBE lakehouse.final.trips_silver;`*\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Шаг 5: проверка из Trino (DESCRIBE)\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Шаг 6: 3 INSERT INTO trips_silver\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Шаг 6: файловая статистика и snapshot-ы\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Шаг 7: compaction + expire_snapshots\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Шаг 7: проверка результата\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Шаг 8: cleanup (DROP TABLE, DROP NAMESPACE)\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 10: Финальный checkpoint\n", + "\n", + "**Часть A — Обслуживание таблиц (Модуль 8):**\n", + "1. Что такое проблема мелких файлов и когда она возникает?\n", + "2. Что делает `rewrite_data_files`? Удаляет ли он старые файлы?\n", + "3. Что делает `expire_snapshots`? Какой компромисс он создаёт?\n", + "4. В каком порядке нужно выполнять compaction и expire_snapshots? Почему?\n", + "5. Как обслуживание Iceberg-таблиц соотносится с VACUUM/REORGANIZE в Greenplum?\n", + "\n", + "**Часть B — Весь курс (итоговые вопросы):**\n", + "1. Назови три роли в архитектуре Lakehouse и какие компоненты стенда их выполняют.\n", + "2. Почему Spark может записать данные, а Trino прочитать ту же таблицу без копирования?\n", + "3. Чем raw отличается от bronze? Чем bronze отличается от silver?\n", + "4. Что такое snapshot в Iceberg? Чем он отличается от бэкапа в PostgreSQL?\n", + "5. Назови три безопасные и три опасные операции с Iceberg-таблицей.\n", + "6. Зачем нужны compaction и expire_snapshots в production-сценарии?\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Секция 11: Завершение\n", + "\n", + "**Итог модуля:**\n", + "- Compaction (`rewrite_data_files`) — решает проблему мелких файлов, не меняя данные (чтение становится быстрее).\n", + "- `expire_snapshots` — очищает старые snapshot-ы (сокращая размер метаданных и хранилища), но лишает возможности time travel.\n", + "- Порядок: compaction → expire_snapshots.\n", + "- Обслуживание Iceberg похоже на обслуживание Greenplum AppendOnly: обе системы требуют периодической заботы.\n", + "\n", + "**Итог курса. Что ты теперь умеешь:**\n", + "1. Поднимать и диагностировать локальный Lakehouse-стенд (Модуль 1).\n", + "2. Объяснять роли storage, catalog, compute (Модуль 2).\n", + "3. Загружать raw-данные и проверять схему (Модуль 3).\n", + "4. Создавать и заполнять Iceberg-таблицы (Модуль 4).\n", + "5. Строить воспроизводимый поток raw → bronze → silver (Модуль 5).\n", + "6. Читать одну таблицу из Spark и Trino (Модуль 6).\n", + "7. Безопасно изменять таблицы: schema evolution, time travel, rollback (Модуль 7).\n", + "8. Обслуживать таблицы: compaction, expire_snapshots (Модуль 8).\n", + "\n", + "Это базовый набор навыков для безопасной работы в Lakehouse-стенде. Дальше — production: партиционирование, MERGE, streaming, Airflow, governance. Но фундамент заложен.\n", + "\n", + "**Курс завершен! Поздравляем! 🎉**\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spark.stop()\n", + "\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/plans/README.md b/plans/README.md index 0a9ec15..f2c9eed 100644 --- a/plans/README.md +++ b/plans/README.md @@ -20,4 +20,4 @@ | 5. Слой silver и воспроизводимые трансформации | [module-05-silver-layer.md](./module-05-silver-layer.md) | Ready for validation | `notebooks/05_silver_layer.ipynb` | 2026-03-07 | | 6. Одна таблица, два движка: Spark и Trino | [module-06-spark-and-trino-on-same-table.md](./module-06-spark-and-trino-on-same-table.md) | Ready for validation | `jupyter/Dockerfile`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/06_spark_and_trino_on_same_table.ipynb` | 2026-03-07 | | 7. Безопасная работа с таблицами: schema evolution и time travel | [module-07-safe-table-changes.md](./module-07-safe-table-changes.md) | Ready for validation | `notebooks/07_safe_table_changes.ipynb` | 2026-03-08 | -| 8. Базовое обслуживание таблиц и финальная практика | [module-08-maintenance-and-final-lab.md](./module-08-maintenance-and-final-lab.md) | Draft | `notebooks/08_maintenance_and_final_lab.ipynb` | 2026-03-07 | +| 8. Базовое обслуживание таблиц и финальная практика | [module-08-maintenance-and-final-lab.md](./module-08-maintenance-and-final-lab.md) | Ready for validation | `notebooks/08_maintenance_and_final_lab.ipynb` | 2026-03-08 | diff --git a/plans/module-08-maintenance-and-final-lab.md b/plans/module-08-maintenance-and-final-lab.md index b37e823..8e25d42 100644 --- a/plans/module-08-maintenance-and-final-lab.md +++ b/plans/module-08-maintenance-and-final-lab.md @@ -1,7 +1,7 @@ # Модуль 8. Базовое обслуживание таблиц и финальная практика -**Статус:** `Draft` -**Последнее обновление:** `2026-03-07` +**Статус:** `Ready for validation` +**Последнее обновление:** `2026-03-08` ## Цель From 0ae83cecdb940e55bccb60b3d3a703182651e162 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 8 Mar 2026 01:04:26 +0300 Subject: [PATCH 24/24] =?UTF-8?q?docs(course):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20glossary,=20mentor=20notes,=20?= =?UTF-8?q?=D1=88=D0=BF=D0=B0=D1=80=D0=B3=D0=B0=D0=BB=D0=BA=D0=B0=20=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=20REA?= =?UTF-8?q?DME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - закрыты 3 вспомогательных артефакта из course_program.md §3.3: glossary, cheat sheet, mentor notes. - README переписан с фокусом на ценность для студента. - Что: - создан docs/glossary.md (16 терминов, сгруппированных по темам с параллелями к DWH). - создан docs/mentor_notes.md (тайминг, типичные вопросы, checkpoint-ы, формат «менти работает сам»). - добавлена секция «Краткая шпаргалка» в docs/stack_reference.md (S3-пути, таблицы, SQL-команды, маунты). - README.md переписан: лид с навыками, убрано дублирование со stack_reference. - обновлены перекрёстные ссылки в AGENTS.md, course_program.md, maintainer_guide.md. - Проверка: - все ссылки между документами валидны (glossary.md, mentor_notes.md существуют). - термины glossary и команды шпаргалки верифицированы по содержимому ноутбуков. Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 2 + README.md | 100 +++++++++++----------------- docs/course_program.md | 6 +- docs/glossary.md | 137 +++++++++++++++++++++++++++++++++++++++ docs/maintainer_guide.md | 1 + docs/mentor_notes.md | 108 ++++++++++++++++++++++++++++++ docs/stack_reference.md | 62 +++++++++++++++++- 7 files changed, 350 insertions(+), 66 deletions(-) create mode 100644 docs/glossary.md create mode 100644 docs/mentor_notes.md diff --git a/AGENTS.md b/AGENTS.md index 74065f2..15d9706 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,8 @@ - `docs/stack_reference.md` — технический reference по сервисам, портам, доступу и smoke-тестам. - `docs/course_prd.md` — рамки курса, learning outcomes, scope и out of scope. - `docs/course_program.md` — модульная структура курса и состав учебных материалов. +- `docs/glossary.md` — справочник терминов курса. +- `docs/mentor_notes.md` — заметки для ведения курса с ментором (опционально). - `docs/maintainer_guide.md` — карта репозитория и правила синхронизации изменений. - `plans/` — внутренние living docs; не источник истины для студентского маршрута. - `docs/archive/` — архивные материалы; не использовать как актуальную документацию без явного запроса. diff --git a/README.md b/README.md index 2dd1d0a..eb72f6c 100755 --- a/README.md +++ b/README.md @@ -1,16 +1,23 @@ -# Lakehouse без магии: локальный стенд и учебные материалы +# Lakehouse без магии -Этот репозиторий объединяет: +Практический курс, после которого ты будешь уверенно работать с Lakehouse-стеком: поднимать стенд, строить пайплайн `raw -> bronze -> silver`, читать одну таблицу из двух движков и не бояться изменений в данных. -- локальный Lakehouse-стенд на `Spark + Trino + Iceberg + MinIO + PostgreSQL`; -- учебный курс `Lakehouse без магии`, который использует этот стенд как практическую среду; -- стартовые ноутбуки и demo-скрипты для первых экспериментов. +## Что ты получишь -Репозиторий рассчитан не на «универсальную платформу для всего», а на понятную локальную песочницу, где можно руками пройти путь от запуска стенда до чтения одной и той же Iceberg-таблицы из `Spark` и `Trino`. +После прохождения 8 модулей ты умеешь: -## Архитектура +1. **Поднимать и диагностировать** локальный Lakehouse-стенд — не по инструкции, а с пониманием, что и зачем работает. +2. **Объяснять архитектуру** `storage + catalog + compute` — и видеть, как знакомые концепции из PostgreSQL/Greenplum ложатся на новый стек. +3. **Строить пайплайн** `raw -> bronze -> silver` на реальном датасете NYC Taxi — с проверками качества и воспроизводимостью. +4. **Работать с двумя движками** — записывать данные через Spark, читать через Trino, и понимать, почему это работает без копирования. +5. **Безопасно менять таблицы** — schema evolution, time travel, rollback к предыдущему состоянию вместо паники. +6. **Обслуживать таблицы** — compaction и expire_snapshots, с пониманием параллелей к VACUUM/REORGANIZE. -Ниже показана упрощённая рабочая схема стенда: пользователь входит через `JupyterLab` и `Trino UI / CLI`, а `Spark` и `Trino` независимо работают поверх общего `storage` и общего `catalog`. +Курс рассчитан на `~12-15 часов` самостоятельной работы. Каждый модуль: объяснение, демонстрация, самостоятельное задание, checkpoint. + +## Стек + +Всё работает локально в Docker. Никаких облаков, внешних зависимостей и регистраций. ```mermaid graph TB @@ -41,23 +48,21 @@ graph TB T --> M ``` -Коротко по ролям: - -- `MinIO` хранит данные и служебные файлы Iceberg. -- `PostgreSQL` хранит метаданные JDBC-каталога `lakehouse`. -- `Spark` и `Trino` работают как два compute-движка поверх одного storage и одного catalog. -- `JupyterLab` служит основной точкой входа в практическую часть курса. -- `Trino UI / CLI` даёт отдельную точку входа для ad hoc SQL и проверки таблиц. +| Компонент | Роль в стенде | +| --- | --- | +| `MinIO` | Storage — хранит данные и служебные файлы Iceberg | +| `PostgreSQL` | Catalog — метаданные JDBC-каталога `lakehouse` | +| `Spark` | Compute — ETL, запись и чтение Iceberg-таблиц | +| `Trino` | Compute — ad hoc SQL, чтение тех же таблиц | +| `JupyterLab` | Точка входа — практические ноутбуки курса | ## С чего начать -Если ты заходишь в репозиторий впервые, используй такой маршрут: +1. Открой [START_HERE.md](./START_HERE.md) — запуск стенда, диагностика, первый ноутбук. +2. Пройди `notebooks/01_environment_and_smoke_test.ipynb`. +3. Дальше по порядку: [docs/course_program.md](./docs/course_program.md). -1. Открой [START_HERE.md](./START_HERE.md) для первого запуска стенда и базовой диагностики. -2. После старта стенда выполни `notebooks/01_environment_and_smoke_test.ipynb`. -3. Для структуры курса смотри [docs/course_program.md](./docs/course_program.md). - -Если нужен только краткий запуск, из корня репозитория достаточно: +Краткий запуск из корня репозитория: ```bash docker compose build @@ -65,54 +70,25 @@ docker compose up -d docker compose ps ``` -Основные UI после старта: +После старта: -- Spark Master UI: `http://localhost:8080` -- Trino UI: `http://localhost:8090` -- MinIO Console: `http://localhost:9001` -- JupyterLab: `http://localhost:8888` - -Полный onboarding, диагностика и reset-сценарии находятся в `START_HERE.md`. - -## Как устроен репозиторий - -| Где | Что лежит | +| UI | Адрес | | --- | --- | -| `docker-compose.yml` | состав сервисов стенда, порты, сети, init-контейнеры | -| `spark/` | Dockerfile и конфиг Spark для Iceberg + MinIO | -| `trino/` | каталог `lakehouse` и настройки Trino | -| `jupyter/` | образ JupyterLab на базе Spark-образа | -| `notebooks/` | практические ноутбуки курса | -| `src/` | smoke-скрипты, SQL-демо и helper-логика | -| `docs/` | PRD, программа курса, reference-документы | -| `plans/` | внутренние living docs по развитию материалов | +| JupyterLab | `http://localhost:8888` | +| Spark Master | `http://localhost:8080` | +| Trino | `http://localhost:8090` | +| MinIO Console | `http://localhost:9001` | -## Основные документы для прохождения +## Документы для прохождения -- [START_HERE.md](./START_HERE.md) — первый маршрут для студента. +- [START_HERE.md](./START_HERE.md) — первый маршрут: prerequisites, запуск, диагностика. - [docs/course_program.md](./docs/course_program.md) — модульная структура и состав учебных материалов. -- [docs/stack_reference.md](./docs/stack_reference.md) — технический reference по сервисам, портам, конфигам и smoke-тестам. +- [docs/stack_reference.md](./docs/stack_reference.md) — порты, команды, конфиги, шпаргалка. +- [docs/glossary.md](./docs/glossary.md) — справочник терминов (storage, catalog, compute, snapshot и др.). -## Что уже можно делать в стенде +## Менторство -- поднять локальный кластер `Spark` с двумя worker-ами; -- создать Iceberg-таблицу из `Spark`; -- прочитать ту же таблицу из `Trino`; -- пройти базовый smoke test через ноутбук или demo-скрипты; -- использовать стенд как основу для следующих модулей курса. - -## Куда смотреть за техническими деталями - -- [docs/stack_reference.md](./docs/stack_reference.md) — сервисы, порты, доступ, reset, smoke tests; -- [spark/spark-defaults.conf](./spark/spark-defaults.conf) — конфигурация Spark-каталога `lakehouse`; -- [trino/catalog/lakehouse.properties](./trino/catalog/lakehouse.properties) — конфигурация каталога Trino; -- [docker-compose.yml](./docker-compose.yml) — фактический состав стенда. - -## Автор и менторство - -Этот репозиторий и материалы курса можно проходить самостоятельно, но при желании их можно разбирать вместе с автором как с ментором по `Data Engineering`. - -Если хочешь глубже пройти темы `Spark`, `Trino`, `Iceberg`, `Lakehouse` и связанные практики по `DE`, напиши: [@dementev_dev](https://t.me/dementev_dev). +Курс рассчитан на самостоятельное прохождение, но если хочешь разобрать темы глубже с ментором по Data Engineering — напиши: [@dementev_dev](https://t.me/dementev_dev). ## Лицензия diff --git a/docs/course_program.md b/docs/course_program.md index 5e5491d..149424c 100644 --- a/docs/course_program.md +++ b/docs/course_program.md @@ -59,9 +59,9 @@ * вспомогательные скрипты в `src/spark` и `src/trino`; * инструкции по загрузке или подготовке учебных датасетов; -* краткий glossary по терминам `storage`, `catalog`, `compute`, `table format`, `namespace`, `metadata`, `manifest`, `snapshot`, `time travel`, `schema evolution`, `compaction`, `vacuum`; -* cheat sheet по типовым командам, адресам сервисов, ключевым путям и точкам входа; -* опционально, отдельные mentor notes для ведения курса с ментором. +* краткий [glossary](./glossary.md) по терминам `storage`, `catalog`, `compute`, `table format`, `namespace`, `metadata`, `manifest`, `snapshot`, `time travel`, `schema evolution`, `compaction`, `vacuum`; +* [шпаргалка](./stack_reference.md#краткая-шпаргалка) по типовым командам, адресам сервисов, ключевым путям и точкам входа (секция в `stack_reference.md`); +* опционально, [заметки для ментора](./mentor_notes.md) для ведения курса с ментором. ## 4. Трассировка Learning Outcomes на модули diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..f2f1782 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,137 @@ +# Глоссарий курса + +Краткий справочник терминов, сгруппированных по темам. Если нужен полный технический reference стенда, используй [stack_reference.md](./stack_reference.md). + +## Архитектура Lakehouse + +### Storage + +Физическое хранилище файлов. Хранит data files, metadata-файлы Iceberg и raw-данные. + +**В этом курсе:** MinIO (`s3a://lakehouse/`). +**Параллель с DWH:** каталог `pg_data` в PostgreSQL, только вынесенный за пределы СУБД. +**Где в курсе:** Модули 1, 2, 3. + +### Catalog + +Реестр метаданных таблиц: какие таблицы существуют, где лежат их данные, какова текущая схема. Не хранит сами данные. + +**В этом курсе:** PostgreSQL (JDBC catalog `lakehouse`). +**Параллель с DWH:** системный каталог `pg_catalog` в PostgreSQL. +**Где в курсе:** Модули 2, 4, 6. + +### Compute + +Вычислительный движок, который читает метаданные из каталога и данные из хранилища для выполнения запросов. Не владеет ни данными, ни метаданными. + +**В этом курсе:** Spark (запись и чтение), Trino (чтение и ad hoc SQL). +**Параллель с DWH:** процесс СУБД PostgreSQL/Greenplum, но в Lakehouse движков может быть несколько одновременно. +**Где в курсе:** Модули 1, 2, 6. + +### Table format + +Спецификация, определяющая, как таблица организована на уровне файлов: какие data files входят в таблицу, где лежат метаданные, как работают snapshot-ы. Это не база данных и не хранилище — это набор правил. + +**В этом курсе:** Apache Iceberg. +**Параллель с DWH:** ближайший аналог — формат хранения таблицы (heap / AppendOnly в Greenplum), но table format в Lakehouse делает больше: он управляет историей, схемой и файлами. +**Где в курсе:** Модули 2, 4. + +### Decoupled compute + +Принцип, при котором вычислительные движки не зависят друг от друга и от хранилища. Один движок может писать, другой — читать те же данные без копирования, потому что оба обращаются к общему каталогу и хранилищу. + +**В этом курсе:** Spark пишет таблицу, Trino читает ту же таблицу через общий JDBC-каталог. +**Параллель с DWH:** в классическом DWH вычислитель один, поэтому проблема не возникает. +**Где в курсе:** Модуль 6. + +## Структура Iceberg-таблицы + +### Namespace + +Логическая группа таблиц внутри каталога. Используется для организации таблиц по слоям или доменам. + +**В этом курсе:** `bronze`, `silver`, `default` — namespace-ы внутри каталога `lakehouse`. +**Параллель с DWH:** `schema` в PostgreSQL. +**Где в курсе:** Модули 4, 5. + +### Metadata + +JSON-файлы, описывающие текущее состояние таблицы: схему, свойства, список snapshot-ов. Лежат в каталоге `metadata/` рядом с data files в хранилище. + +**В этом курсе:** файлы в `s3a://lakehouse/warehouse///metadata/`. +**Параллель с DWH:** системные таблицы `pg_catalog`, но вынесенные в файлы. +**Где в курсе:** Модули 2, 4. + +### Manifest + +Avro-файл со списком data files и их статистиками (количество строк, диапазоны значений колонок). Позволяет движку пропускать ненужные файлы при чтении. + +**В этом курсе:** видны при осмотре структуры таблицы в MinIO. +**Где в курсе:** Модуль 4. + +### Manifest list + +Avro-файл со списком manifest-ов, составляющих один snapshot. Каждый snapshot ссылается на свой manifest list. + +**В этом курсе:** видны как часть внутренней структуры Iceberg при исследовании MinIO. +**Где в курсе:** Модуль 4. + +### Snapshot + +Неизменяемая версия таблицы, зафиксированная в момент операции с данными (INSERT, overwrite, delete). Каждый snapshot определяет, какие data files составляли таблицу в конкретный момент. Как коммит в git. + +**В этом курсе:** `SELECT * FROM table.snapshots` показывает историю snapshot-ов. +**Параллель с DWH:** бэкап или PITR (Point-In-Time Recovery), но значительно легче: snapshot-ы создаются автоматически и не требуют копирования данных. +**Где в курсе:** Модули 4, 7, 8. + +## Операции + +### Time travel + +Чтение таблицы в состоянии на момент определённого snapshot-а. Позволяет сравнить текущие данные с предыдущими или восстановить потерянную информацию. В Spark: `VERSION AS OF `, в Trino: `FOR VERSION AS OF `. + +**В этом курсе:** демонстрация на демо-таблице в Модуле 7. +**Параллель с DWH:** восстановление из бэкапа (pg_dump / PITR), но без остановки сервиса и без восстановления всей базы целиком. +**Где в курсе:** Модуль 7. + +### Schema evolution + +Изменение схемы таблицы (добавление, переименование, удаление колонок) без перезаписи data files. Метаданные обновляются, данные остаются на месте. + +**В этом курсе:** `ALTER TABLE ADD COLUMNS`, `ALTER TABLE RENAME COLUMN`. +**Параллель с DWH:** `ALTER TABLE` в PostgreSQL, но в Iceberg старые data files не перезаписываются — новые колонки возвращают NULL для существующих строк. +**Где в курсе:** Модули 7, 8. + +### Compaction (rewrite_data_files) + +Объединение множества мелких data files в меньшее количество крупных. Решает проблему деградации чтения после множества мелких INSERT-ов. Данные не меняются, только реорганизуются файлы. + +**В этом курсе:** `CALL lakehouse.system.rewrite_data_files(table => '...')`. +**Параллель с DWH:** `ALTER TABLE ... REORGANIZE` в Greenplum AppendOnly. +**Где в курсе:** Модуль 8. + +### Vacuum (expire_snapshots) + +Удаление старых snapshot-ов и связанных с ними data files из хранилища. Освобождает место, но делает невозможным time travel к удалённым snapshot-ам. Необратимая операция. + +**В этом курсе:** `CALL lakehouse.system.expire_snapshots(table => '...', retain_last => N)`. +**Параллель с DWH:** `VACUUM` в PostgreSQL/Greenplum (удаление мёртвых строк). +**Где в курсе:** Модуль 8. + +## Слои данных + +### Raw + +Неизменяемые исходные файлы, загруженные из внешнего источника. Хранятся в S3 как обычные файлы (Parquet, CSV), а не как Iceberg-таблицы. Точка воспроизводимости: если что-то пошло не так на следующих слоях, всегда можно перестроить пайплайн от raw. + +**В этом курсе:** `s3a://lakehouse/raw/nyc_taxi/` — Parquet-файлы NYC Taxi. +**Параллель с DWH:** staging-зона (stg), внешние таблицы. +**Где в курсе:** Модуль 3. + +### Bronze / Silver + +Управляемые Iceberg-таблицы с разным уровнем обработки. Bronze — данные «как есть» из raw, загруженные в Iceberg-таблицу. Silver — очищенные и трансформированные данные, готовые для анализа. + +**В этом курсе:** `lakehouse.bronze.nyc_taxi_yellow` (bronze), `lakehouse.silver.nyc_taxi_yellow` (silver). +**Параллель с DWH:** ODS (bronze) / DDS (silver). +**Где в курсе:** Модули 4 (bronze), 5 (silver), 8 (финальная практика). diff --git a/docs/maintainer_guide.md b/docs/maintainer_guide.md index d85920a..867dbf2 100644 --- a/docs/maintainer_guide.md +++ b/docs/maintainer_guide.md @@ -21,6 +21,7 @@ | Onboarding | `README.md`, `START_HERE.md` | Вход в репозиторий и первый пользовательский маршрут | | Runtime reference | `docs/stack_reference.md` | Технические детали стенда: порты, доступ, команды, smoke-тесты | | Course definition | `docs/course_prd.md`, `docs/course_program.md` | Границы курса, learning outcomes, структура модулей | +| Reference materials | `docs/glossary.md`, `docs/mentor_notes.md` | Справочник терминов и заметки для ментора | | Practice materials | `notebooks/`, `src/` | Практика студента, smoke-скрипты, демонстрации, helper-логика | | Internal planning | `plans/` | Внутренние living docs по разработке материалов | | Archive | `docs/archive/` | Исторические материалы, не входящие в актуальный маршрут | diff --git a/docs/mentor_notes.md b/docs/mentor_notes.md new file mode 100644 index 0000000..72e227a --- /dev/null +++ b/docs/mentor_notes.md @@ -0,0 +1,108 @@ +# Заметки для ментора + +Этот документ — для ведения курса с ментором. Студенту он не нужен. + +## Формат работы + +Менти работает преимущественно самостоятельно. Ноутбуки написаны так, чтобы студент мог пройти демо-часть и самостоятельные задания без посторонней помощи. + +Роль ментора: +- **задать направление** — обозначить, на что обратить внимание в модуле, какие параллели с текущим опытом студента искать; +- **отвечать на вопросы** — по ходу прохождения или на checkpoint-е; +- **не вести за руку** — не объяснять материал до того, как студент попробовал сам. + +Типичный цикл: ментор даёт задание на модуль → менти проходит самостоятельно → встреча для обсуждения вопросов и checkpoint-а. + +## Ориентировочный тайминг + +| Модуль | Тема | Самостоятельная работа | Обсуждение с ментором | Комментарий | +| --- | --- | --- | --- | --- | +| 1 | Вход в стенд | 30-40 мин | 10-15 мин | Если Docker знаком, идёт быстро | +| 2 | Ментальная модель | 40-60 мин | 15-20 мин | Ключевой модуль; убедись, что студент заходил в MinIO | +| 3 | Raw-данные | 40-50 мин | 10 мин | Зависит от скорости загрузки данных | +| 4 | Bronze + Iceberg | 60-80 мин | 15-20 мин | Первая таблица — много новых концепций | +| 5 | Silver | 50-70 мин | 15 мин | Трансформации + проверки качества | +| 6 | Spark + Trino | 40-60 мин | 15 мин | Если DBeaver настроен заранее — быстрее | +| 7 | Schema evolution, time travel | 60-80 мин | 15-20 мин | Rollback требует внимания | +| 8 | Обслуживание + финальная | 80-120 мин | 20-30 мин | Финальная практика занимает больше всего | + +Общий объём самостоятельной работы: ~12-15 часов. Время с ментором: ~2-2.5 часа суммарно (по 15-20 минут на модуль). + +## Типичные вопросы и затруднения + +С чем студент, скорее всего, придёт к тебе. + +### Модуль 1. Вход в стенд + +- **Docker не хватает ресурсов.** Стенд требует ~4-6 ГБ RAM. На машинах с 8 ГБ бывают проблемы. Решение: увеличить лимиты Docker Desktop или закрыть лишние приложения. +- **Порты заняты.** 8080, 8888, 9000 — популярные порты. `docker compose ps` покажет, какой сервис не стартовал. Решение: остановить конфликтующий процесс или (крайний вариант) поменять порт в `docker-compose.yml`. +- **Студент не читает `START_HERE.md`.** Начинает с ноутбуков до поднятия стенда. Направь обратно к стартовому документу. + +### Модуль 2. Ментальная модель + +- **Путаница storage vs catalog.** Студент думает, что MinIO — это база данных. Помогает аналогия: MinIO — это «диск», PostgreSQL — это «оглавление книги», Spark — это «читатель». +- **«Зачем нужен отдельный каталог?»** Объясни через decoupled compute: два движка могут работать с одними данными, только если есть общий реестр таблиц. +- **Студент не заходит в MinIO Console.** Без визуального осмотра файлов модель остаётся абстрактной. Попроси студента найти конкретные data files в MinIO. + +### Модуль 3. Raw-данные + +- **Путь к данным не совпадает.** Студент скачал данные, но положил не в `./data/nyc_taxi/`. Проверь монтирование: файлы должны быть видны внутри контейнера по пути `/opt/data/nyc_taxi/`. +- **Ошибки чтения Parquet.** Иногда файл скачивается не полностью. Решение: перекачать файл. +- **Студент хочет «починить» raw-данные.** Объясни принцип неизменяемости raw: чистка — задача следующих слоёв. + +### Модуль 4. Bronze + Iceberg + +- **Ошибки при CREATE TABLE.** Обычно namespace не создан. Проверь, что `CREATE NAMESPACE lakehouse.bronze` выполнен. +- **Студент не понимает разницу между Parquet-файлами и Iceberg-таблицей.** Помогает осмотр MinIO: Iceberg-таблица содержит `metadata/` с JSON и Avro-файлами, а не просто набор Parquet. +- **Самостоятельное задание (taxi_zone_lookup) сложнее, чем кажется.** CSV-файл требует чтения через `spark.read.csv()` с заголовками. Подсказка в ноутбуке есть, но студенты часто пропускают её. + +### Модуль 5. Silver + +- **Ошибки в трансформациях.** Студент путает порядок операций (фильтрация до/после JOIN). Помоги разобрать логику пошагово. +- **Не знает PySpark API.** Если студент привык к чистому SQL, покажи эквивалентный SQL через `spark.sql()` — он поддерживается наравне с DataFrame API. +- **Проверки качества кажутся «лишними».** Объясни, что в production без проверок ошибки обнаруживаются на этапе отчётов, когда уже поздно. + +### Модуль 6. Spark + Trino + +- **Trino не видит таблицу.** Обычно Trino не успел стартовать или каталог не настроен. Проверь `docker compose logs trino` и убедись, что контейнер `healthy`. +- **DBeaver не подключается.** Host: `localhost`, Port: `8090`, User: любая строка, Password: пусто. Драйвер Trino встроен в DBeaver. +- **«Зачем два движка, если Spark всё умеет?»** В production Trino используется для ad hoc запросов аналитиками, которые не работают с Spark. Разделение ролей: Spark — ETL, Trino — BI/analytics. + +### Модуль 7. Schema evolution и time travel + +- **Студент путает schema evolution и data snapshot.** `ALTER TABLE ADD COLUMNS` не создаёт новый data snapshot — это metadata-only операция. Snapshot создаётся только при изменении данных (INSERT, DELETE и т.д.). +- **Time travel: забывает сохранить snapshot_id.** Без сохранённого ID в переменную приходится заново запрашивать `table.snapshots`. Привычка: перед экспериментом запиши ID текущего состояния. +- **`rollback_to_snapshot` vs `CREATE OR REPLACE`.** Ключевое отличие: rollback сохраняет историю, `CREATE OR REPLACE` уничтожает её. Это описано в Секции 10 ноутбука. + +### Модуль 8. Обслуживание и финальная практика + +- **8 INSERT-ов в демо-таблице идут медленно.** Каждый INSERT запускает отдельный Spark job. На слабых машинах может занять 2-3 минуты. Это нормально. +- **Студент запускает expire перед compaction.** Порядок важен: сначала compaction, потом expire. Иначе старые мелкие файлы становятся «сиротами». +- **Финальная практика: ошибка несовпадения колонок при INSERT.** После `ADD COLUMNS (processed_at)` INSERT требует указания всех колонок, включая `processed_at`. Подсказка есть в описании шага 6. + +## Checkpoint-ы: как использовать + +Checkpoint — не тест. Это повод для короткого разговора. Студент уже ответил на вопросы сам (они есть в ноутбуке), задача ментора — проверить понимание и дополнить, если нужно. + +Что работает: +- **Попросить объяснить своими словами**, а не зачитать ответ. «Расскажи, как ты понимаешь, что такое snapshot» лучше, чем «что такое snapshot?». +- **Связать с опытом студента.** Параллели с PostgreSQL/Greenplum есть в каждом модуле — используй их. +- **3-4 вопросов достаточно.** Если студент уверенно отвечает на первые, не нужно проходить весь список. +- **Финальный checkpoint (Модуль 8) — самый важный.** Часть B покрывает весь курс. Хороший признак: студент объясняет, почему Spark и Trino видят одну таблицу, без подсказок. + +## Если студент опытный + +Некоторые модули можно ускорить для студентов с опытом в DE/DWH: + +| Модуль | Можно ускорить? | Что пропустить | Что нельзя пропускать | +| --- | --- | --- | --- | +| 1 | Да | Детальную диагностику Docker | Проверку, что все UI доступны | +| 2 | Частично | Базовые пояснения про storage/catalog | Практический осмотр MinIO и PostgreSQL | +| 3 | Да | Пояснения про raw-зону | Загрузку данных (без неё не работают Модули 4-8) | +| 4 | Нет | — | Создание таблицы и осмотр структуры Iceberg | +| 5 | Частично | Базовые трансформации | Проверки качества и принцип воспроизводимости | +| 6 | Нет | — | Практику с Trino (даже если студент знает Trino) | +| 7 | Нет | — | Time travel и rollback — ядро безопасной работы | +| 8 | Нет | — | Финальная практика — итоговая проверка всех навыков | + +**Модули 4, 6, 7, 8 нельзя пропускать** даже для опытных студентов. Они содержат ключевые практики, которые отличают «знаю теорию» от «умею делать руками». diff --git a/docs/stack_reference.md b/docs/stack_reference.md index c022fc2..75cf937 100644 --- a/docs/stack_reference.md +++ b/docs/stack_reference.md @@ -172,9 +172,69 @@ docker compose exec spark-master \ /opt/spark/bin/spark-sql -e "DROP TABLE IF EXISTS lakehouse.default.spark_trino_smoke" ``` +## Краткая шпаргалка + +### Ключевые S3-пути курса + +| Путь | Назначение | +| --- | --- | +| `s3a://lakehouse/raw/nyc_taxi/` | raw-зона: исходные Parquet и CSV | +| `s3a://lakehouse/warehouse/bronze/` | bronze-таблицы Iceberg | +| `s3a://lakehouse/warehouse/silver/` | silver-таблицы Iceberg | + +### Основные таблицы курса + +| Таблица | Создаётся в | +| --- | --- | +| `lakehouse.bronze.nyc_taxi_yellow` | Модуль 4 | +| `lakehouse.bronze.taxi_zone_lookup` | Модуль 4 (самостоятельное задание) | +| `lakehouse.silver.nyc_taxi_yellow` | Модуль 5 | + +### Часто используемые Spark SQL + +```sql +-- Просмотр структуры каталога +SHOW TABLES IN lakehouse.bronze; + +-- Метаданные Iceberg +SELECT * FROM
.snapshots; +SELECT * FROM
.files; + +-- Обслуживание (Модуль 8) +CALL lakehouse.system.rewrite_data_files(table => '.
'); +CALL lakehouse.system.expire_snapshots(table => '.
', retain_last => N); + +-- Rollback (Модуль 7) +CALL lakehouse.system.rollback_to_snapshot(table => '.
', snapshot_id => ); + +-- Time travel (Модуль 7) +SELECT * FROM
VERSION AS OF ; +``` + +### Часто используемые Trino SQL + +```sql +SHOW SCHEMAS FROM lakehouse; +SHOW TABLES FROM lakehouse.silver; +DESCRIBE lakehouse.silver.nyc_taxi_yellow; + +-- Time travel (синтаксис Trino) +SELECT * FROM
FOR VERSION AS OF ; +``` + +### Монтирование (хост → контейнер) + +| Хост | Контейнер | Режим | +| --- | --- | --- | +| `./notebooks` | `/opt/work` | read-write | +| `./src` | `/opt/src` | read-only | +| `./data` | `/opt/data` | read-only | + ## Когда какой документ использовать - `README.md` — чтобы понять, что это за репозиторий и куда идти дальше. - `START_HERE.md` — чтобы впервые поднять стенд и пройти Модуль 1. -- `stack_reference.md` — чтобы быстро вспомнить порты, команды, точки доступа и smoke-тесты. +- `stack_reference.md` — чтобы быстро вспомнить порты, команды, точки доступа, шпаргалку и smoke-тесты. - `course_program.md` — чтобы понять учебную траекторию дальше первого модуля. +- `glossary.md` — чтобы вернуться к определению термина (storage, catalog, compute, snapshot и др.). +- `mentor_notes.md` — заметки для ведения курса с ментором (опционально).