Compare commits

...
10 Commits
Author SHA1 Message Date
ddadmin df39ff9e64 merge: курс «Lakehouse без магии» v1
Вливает ветку docs/lab-materials: 8 модулей, стартовая документация,
вспомогательные материалы (glossary, mentor notes, шпаргалка).
2026-03-08 01:07:02 +03:00
ddadminandClaude Opus 4.6 0ae83cecdb docs(course): добавлены glossary, mentor notes, шпаргалка и переписан README
- Зачем:
  - закрыты 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 <noreply@anthropic.com>
2026-03-08 01:04:26 +03:00
ddadmin 4bea2af9d2 feat(module-08): реализован модуль по обслуживанию таблиц и финальная практика
- Зачем:
  - обучение студентов базовому обслуживанию 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.
2026-03-08 00:33:40 +03:00
ddadmin 9ffb9b80e0 feat(module-07): реализован модуль по schema evolution и time travel
- Зачем:
  - обучение студентов безопасным изменениям таблиц и механизмам отката Iceberg.
- Что:
  - создан notebooks/07_safe_table_changes.ipynb с демонстрацией ALTER TABLE, VERSION AS OF и rollback.
  - в ноутбук добавлена самостоятельная работа на демо-таблице и чекпоинт для самопроверки.
  - статус модуля 7 в планах обновлен до Ready for validation.
- Проверка:
  - ручная верификация структуры ноутбука и синтаксиса Spark SQL/trino_query.
2026-03-08 00:19:06 +03:00
ddadmin 65d57ec6d6 feat(module-06): реализован модуль по совместной работе Spark и Trino
- Зачем:
  - продемонстрировать возможность доступа нескольких движков к одной 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).
  - проверка наличия всех инструкций в документации.
2026-03-07 23:58:36 +03:00
ddadminandClaude Opus 4.6 68c530c855 refactor(course): перенесена загрузка taxi_zone_lookup из домашки в демо-секцию
- Зачем:
  - демо-ячейки последующих модулей не должны зависеть от домашних заданий предыдущих — модуль 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 <noreply@anthropic.com>
2026-03-07 23:47:05 +03:00
ddadmin bcf7975227 feat(notebooks): реализован модуль 5 по слою silver и трансформациям
- Зачем:
  - обучение переходу от bronze-таблицы к очищенному и обогащенному silver-слою.
- Что:
  - создан notebooks/05_silver_layer.ipynb с пайплайном фильтрации, нормализации и JOIN.
  - в ноутбук добавлены проверки качества на уровнях строк, схемы и агрегатов.
  - статус модуля 5 в планах обновлен до Ready for validation.
- Проверка:
  - ручная проверка структуры ноутбука и корректности Spark-трансформаций.
2026-03-07 23:32:43 +03:00
ddadmin 4a736f6c4e docs(plan): добавлен план модуля 8 по обслуживанию таблиц
- Зачем:
  - нужно показать базовые операции обслуживания Iceberg-таблиц и финальную практику.
- Что:
  - создан файл plans/module-08-maintenance-and-final-lab.md с планом модуля.
  - обновлен plans/README.md с записью о новом модуле в таблицу содержания.
- Проверка:
  - проверка согласованности ссылок и структуры документации.
2026-03-07 23:13:37 +03:00
ddadmin a31a020a76 docs(plan): добавлен план модуля 7 по безопасным изменениям таблиц
- Зачем:
  - нужно продемонстрировать возможности Iceberg по schema evolution и time travel.
- Что:
  - создан файл plans/module-07-safe-table-changes.md с планом модуля.
  - обновлен plans/README.md с записью о новом модуле в таблицу содержания.
- Проверка:
  - проверка согласованности ссылок и структуры документации.
2026-03-07 22:27:54 +03:00
ddadmin 39b1a5618c docs(plan): добавлен план модуля 6 по Spark и Trino на одной таблице
- Зачем:
  - нужно продемонстрировать возможность одновременной работы Spark и Trino с одной Iceberg-таблицей.
- Что:
  - создан файл plans/module-06-spark-and-trino-on-same-table.md с планом модуля.
  - обновлен plans/README.md с записью о новом модуле в таблицу содержания.
- Проверка:
  - проверка согласованности ссылок и структуры документации.
2026-03-07 21:30:26 +03:00
20 changed files with 4660 additions and 129 deletions
+2
View File
@@ -11,6 +11,8 @@
- `docs/stack_reference.md` — технический reference по сервисам, портам, доступу и smoke-тестам. - `docs/stack_reference.md` — технический reference по сервисам, портам, доступу и smoke-тестам.
- `docs/course_prd.md` — рамки курса, learning outcomes, scope и out of scope. - `docs/course_prd.md` — рамки курса, learning outcomes, scope и out of scope.
- `docs/course_program.md` — модульная структура курса и состав учебных материалов. - `docs/course_program.md` — модульная структура курса и состав учебных материалов.
- `docs/glossary.md` — справочник терминов курса.
- `docs/mentor_notes.md` — заметки для ведения курса с ментором (опционально).
- `docs/maintainer_guide.md` — карта репозитория и правила синхронизации изменений. - `docs/maintainer_guide.md` — карта репозитория и правила синхронизации изменений.
- `plans/` — внутренние living docs; не источник истины для студентского маршрута. - `plans/` — внутренние living docs; не источник истины для студентского маршрута.
- `docs/archive/` — архивные материалы; не использовать как актуальную документацию без явного запроса. - `docs/archive/` — архивные материалы; не использовать как актуальную документацию без явного запроса.
+38 -62
View File
@@ -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 ```mermaid
graph TB graph TB
@@ -41,23 +48,21 @@ graph TB
T --> M T --> M
``` ```
Коротко по ролям: | Компонент | Роль в стенде |
| --- | --- |
- `MinIO` хранит данные и служебные файлы Iceberg. | `MinIO` | Storage — хранит данные и служебные файлы Iceberg |
- `PostgreSQL` хранит метаданные JDBC-каталога `lakehouse`. | `PostgreSQL` | Catalog — метаданные JDBC-каталога `lakehouse` |
- `Spark` и `Trino` работают как два compute-движка поверх одного storage и одного catalog. | `Spark` | Compute — ETL, запись и чтение Iceberg-таблиц |
- `JupyterLab` служит основной точкой входа в практическую часть курса. | `Trino` | Compute — ad hoc SQL, чтение тех же таблиц |
- `Trino UI / CLI` даёт отдельную точку входа для 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 ```bash
docker compose build docker compose build
@@ -65,54 +70,25 @@ docker compose up -d
docker compose ps docker compose ps
``` ```
Основные UI после старта: После старта:
- Spark Master UI: `http://localhost:8080` | UI | Адрес |
- Trino UI: `http://localhost:8090`
- MinIO Console: `http://localhost:9001`
- JupyterLab: `http://localhost:8888`
Полный onboarding, диагностика и reset-сценарии находятся в `START_HERE.md`.
## Как устроен репозиторий
| Где | Что лежит |
| --- | --- | | --- | --- |
| `docker-compose.yml` | состав сервисов стенда, порты, сети, init-контейнеры | | JupyterLab | `http://localhost:8888` |
| `spark/` | Dockerfile и конфиг Spark для Iceberg + MinIO | | Spark Master | `http://localhost:8080` |
| `trino/` | каталог `lakehouse` и настройки Trino | | Trino | `http://localhost:8090` |
| `jupyter/` | образ JupyterLab на базе Spark-образа | | MinIO Console | `http://localhost:9001` |
| `notebooks/` | практические ноутбуки курса |
| `src/` | smoke-скрипты, SQL-демо и helper-логика |
| `docs/` | PRD, программа курса, reference-документы |
| `plans/` | внутренние living docs по развитию материалов |
## Основные документы для прохождения ## Документы для прохождения
- [START_HERE.md](./START_HERE.md) — первый маршрут для студента. - [START_HERE.md](./START_HERE.md) — первый маршрут: prerequisites, запуск, диагностика.
- [docs/course_program.md](./docs/course_program.md) — модульная структура и состав учебных материалов. - [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-ами; Курс рассчитан на самостоятельное прохождение, но если хочешь разобрать темы глубже с ментором по Data Engineering — напиши: [@dementev_dev](https://t.me/dementev_dev).
- создать 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).
## Лицензия ## Лицензия
+8
View File
@@ -181,6 +181,14 @@ ls -lh data/nyc_taxi/
Если хочешь расширенный режим, можешь скачать все 12 месяцев `2024`, но основной маршрут курса и примеры опираются на первые 3 месяца. Если хочешь расширенный режим, можешь скачать все 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`; - пройти `notebooks/01_environment_and_smoke_test.ipynb`;
+3 -3
View File
@@ -59,9 +59,9 @@
* вспомогательные скрипты в `src/spark` и `src/trino`; * вспомогательные скрипты в `src/spark` и `src/trino`;
* инструкции по загрузке или подготовке учебных датасетов; * инструкции по загрузке или подготовке учебных датасетов;
* краткий glossary по терминам `storage`, `catalog`, `compute`, `table format`, `namespace`, `metadata`, `manifest`, `snapshot`, `time travel`, `schema evolution`, `compaction`, `vacuum`; * краткий [glossary](./glossary.md) по терминам `storage`, `catalog`, `compute`, `table format`, `namespace`, `metadata`, `manifest`, `snapshot`, `time travel`, `schema evolution`, `compaction`, `vacuum`;
* cheat sheet по типовым командам, адресам сервисов, ключевым путям и точкам входа; * [шпаргалка](./stack_reference.md#краткая-шпаргалка) по типовым командам, адресам сервисов, ключевым путям и точкам входа (секция в `stack_reference.md`);
* опционально, отдельные mentor notes для ведения курса с ментором. * опционально, [заметки для ментора](./mentor_notes.md) для ведения курса с ментором.
## 4. Трассировка Learning Outcomes на модули ## 4. Трассировка Learning Outcomes на модули
+137
View File
@@ -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/<namespace>/<table>/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 <snapshot_id>`, в Trino: `FOR VERSION AS OF <snapshot_id>`.
**В этом курсе:** демонстрация на демо-таблице в Модуле 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 (финальная практика).
+1
View File
@@ -21,6 +21,7 @@
| Onboarding | `README.md`, `START_HERE.md` | Вход в репозиторий и первый пользовательский маршрут | | Onboarding | `README.md`, `START_HERE.md` | Вход в репозиторий и первый пользовательский маршрут |
| Runtime reference | `docs/stack_reference.md` | Технические детали стенда: порты, доступ, команды, smoke-тесты | | Runtime reference | `docs/stack_reference.md` | Технические детали стенда: порты, доступ, команды, smoke-тесты |
| Course definition | `docs/course_prd.md`, `docs/course_program.md` | Границы курса, learning outcomes, структура модулей | | 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-логика | | Practice materials | `notebooks/`, `src/` | Практика студента, smoke-скрипты, демонстрации, helper-логика |
| Internal planning | `plans/` | Внутренние living docs по разработке материалов | | Internal planning | `plans/` | Внутренние living docs по разработке материалов |
| Archive | `docs/archive/` | Исторические материалы, не входящие в актуальный маршрут | | Archive | `docs/archive/` | Исторические материалы, не входящие в актуальный маршрут |
+108
View File
@@ -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 нельзя пропускать** даже для опытных студентов. Они содержат ключевые практики, которые отличают «знаю теорию» от «умею делать руками».
+66 -1
View File
@@ -80,6 +80,11 @@ SHOW SCHEMAS FROM lakehouse;
SHOW TABLES FROM lakehouse.default; SHOW TABLES FROM lakehouse.default;
``` ```
**Подключение через DBeaver:**
- Host: `localhost`, Port: `8090`, Catalog: `lakehouse`, User: любая строка, Password: нет.
- Driver: Trino (встроен в DBeaver).
- Проверка: `SHOW SCHEMAS FROM lakehouse;`.
### MinIO ### MinIO
- S3 endpoint: `http://localhost:9000` - S3 endpoint: `http://localhost:9000`
@@ -167,9 +172,69 @@ docker compose exec spark-master \
/opt/spark/bin/spark-sql -e "DROP TABLE IF EXISTS lakehouse.default.spark_trino_smoke" /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 <table>.snapshots;
SELECT * FROM <table>.files;
-- Обслуживание (Модуль 8)
CALL lakehouse.system.rewrite_data_files(table => '<namespace>.<table>');
CALL lakehouse.system.expire_snapshots(table => '<namespace>.<table>', retain_last => N);
-- Rollback (Модуль 7)
CALL lakehouse.system.rollback_to_snapshot(table => '<namespace>.<table>', snapshot_id => <id>);
-- Time travel (Модуль 7)
SELECT * FROM <table> VERSION AS OF <snapshot_id>;
```
### Часто используемые Trino SQL
```sql
SHOW SCHEMAS FROM lakehouse;
SHOW TABLES FROM lakehouse.silver;
DESCRIBE lakehouse.silver.nyc_taxi_yellow;
-- Time travel (синтаксис Trino)
SELECT * FROM <table> FOR VERSION AS OF <snapshot_id>;
```
### Монтирование (хост → контейнер)
| Хост | Контейнер | Режим |
| --- | --- | --- |
| `./notebooks` | `/opt/work` | read-write |
| `./src` | `/opt/src` | read-only |
| `./data` | `/opt/data` | read-only |
## Когда какой документ использовать ## Когда какой документ использовать
- `README.md` — чтобы понять, что это за репозиторий и куда идти дальше. - `README.md` — чтобы понять, что это за репозиторий и куда идти дальше.
- `START_HERE.md` — чтобы впервые поднять стенд и пройти Модуль 1. - `START_HERE.md` — чтобы впервые поднять стенд и пройти Модуль 1.
- `stack_reference.md` — чтобы быстро вспомнить порты, команды, точки доступа и smoke-тесты. - `stack_reference.md` — чтобы быстро вспомнить порты, команды, точки доступа, шпаргалку и smoke-тесты.
- `course_program.md` — чтобы понять учебную траекторию дальше первого модуля. - `course_program.md` — чтобы понять учебную траекторию дальше первого модуля.
- `glossary.md` — чтобы вернуться к определению термина (storage, catalog, compute, snapshot и др.).
- `mentor_notes.md` — заметки для ведения курса с ментором (опционально).
+2 -1
View File
@@ -8,7 +8,8 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN pip3 install --no-cache-dir \ RUN pip3 install --no-cache-dir \
"jupyterlab==4.2.5" \ "jupyterlab==4.2.5" \
"boto3>=1.35,<2" \ "boto3>=1.35,<2" \
"psycopg2-binary>=2.9,<3" "psycopg2-binary>=2.9,<3" \
"trino>=0.328"
# Создаём непривилегированного пользователя # Создаём непривилегированного пользователя
ARG NB_USER=jovyan ARG NB_USER=jovyan
+9 -48
View File
@@ -564,21 +564,7 @@
"cell_type": "markdown", "cell_type": "markdown",
"id": "320c0199", "id": "320c0199",
"metadata": {}, "metadata": {},
"source": [ "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."
"## 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", "cell_type": "code",
@@ -586,9 +572,7 @@
"id": "e94508bf", "id": "e94508bf",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "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)"
"# Ваш код: прочитай taxi_zone_lookup.csv из raw-зоны через Spark\n"
]
}, },
{ {
"cell_type": "code", "cell_type": "code",
@@ -596,19 +580,15 @@
"id": "8e058aeb", "id": "8e058aeb",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "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)"
"# Ваш код: создай таблицу lakehouse.bronze.taxi_zone_lookup\n"
]
}, },
{ {
"cell_type": "code", "cell_type": "markdown",
"execution_count": null, "execution_count": null,
"id": "41c2c79a", "id": "41c2c79a",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "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`? Почему такая разница?"
"# Ваш код: загрузи данные в bronze lookup-таблицу\n"
]
}, },
{ {
"cell_type": "code", "cell_type": "code",
@@ -616,9 +596,7 @@
"id": "80b392e1", "id": "80b392e1",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": "# Ваш код: snapshots и files для taxi_zone_lookup\n"
"# Ваш код: проверь результат через spark.table(...)\n"
]
}, },
{ {
"cell_type": "code", "cell_type": "code",
@@ -626,36 +604,19 @@
"id": "61e90959", "id": "61e90959",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": "# Ваш код: сравни количество data files в lookup и yellow taxi\n"
"# Ваш код: посмотри snapshots для lakehouse.bronze.taxi_zone_lookup\n"
]
}, },
{ {
"cell_type": "markdown", "cell_type": "markdown",
"id": "24c04d60", "id": "24c04d60",
"metadata": {}, "metadata": {},
"source": [ "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`?"
"## 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", "cell_type": "markdown",
"id": "76d9061f", "id": "76d9061f",
"metadata": {}, "metadata": {},
"source": [ "source": "## 11. Завершение\n\nМы намеренно не удаляем bronze-таблицы (`nyc_taxi_yellow` и `taxi_zone_lookup`). Они понадобятся в Модуле 5, где мы будем строить silver-слой."
"## 10. Завершение\n",
"\n",
"Мы намеренно не удаляем `lakehouse.bronze.nyc_taxi_yellow`. Эта таблица понадобится в Модуле 5, где мы будем строить `silver`-слой.\n"
]
}, },
{ {
"cell_type": "code", "cell_type": "code",
+613
View File
@@ -0,0 +1,613 @@
{
"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\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",
"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
}
@@ -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
}
+904
View File
@@ -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 <snapshot_id>` |\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 <snapshot_id>;`*\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_id>`? Что происходит с историей 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
}
@@ -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
}
+4 -1
View File
@@ -17,4 +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 | | 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 | | 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 | | 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) | 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) | Ready for validation | `notebooks/08_maintenance_and_final_lab.ipynb` | 2026-03-08 |
+13 -8
View File
@@ -70,9 +70,9 @@
Bronze нужен Модулю 5. Spark-сессия останавливается. Явное пояснение в финале ноутбука: «Мы намеренно не удаляем bronze-таблицы. В Модуле 5 мы будем строить silver на основе bronze.» 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. - Bronze ≠ raw. Raw — неизменяемый архив файлов. Bronze — управляемая таблица, которую можно пересоздать из raw.
- В Модуле 5 — silver: очистка, трансформации, бизнес-логика. - В Модуле 5 — silver: очистка, трансформации, бизнес-логика.
### Секция 7: Самостоятельное задание ### Секция 7: Вторая bronze-таблица: taxi_zone_lookup
- **[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. - **[md]** В raw-зоне кроме Yellow Taxi лежит `taxi_zone_lookup.csv` — справочник зон. В Модуле 5 он понадобится для JOIN. Загружаем в bronze по тому же паттерну CTAS, но из CSV.
- **[code]** 5 пустых ячеек `# Ваш код: ...`. - **[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]** Вопросы для самопроверки: - **[md]** Вопросы для самопроверки:
1. Чем отличается `spark.read.parquet("s3a://...")` от `spark.table("lakehouse.bronze.nyc_taxi_yellow")`? 1. Чем отличается `spark.read.parquet("s3a://...")` от `spark.table("lakehouse.bronze.nyc_taxi_yellow")`?
2. Что хранит snapshot и зачем он нужен? 2. Что хранит snapshot и зачем он нужен?
@@ -173,8 +178,8 @@ Bronze нужен Модулю 5. Spark-сессия останавливает
5. Что произойдёт, если удалить один data file из MinIO, но не изменить metadata? 5. Что произойдёт, если удалить один data file из MinIO, но не изменить metadata?
6. Можете ли вы показать таблицу в MinIO Console (через браузер)? 6. Можете ли вы показать таблицу в MinIO Console (через браузер)?
### Секция 9: Завершение ### Секция 10: Завершение
- **[md]** «Мы намеренно не удаляем bronze-таблицы. В Модуле 5 мы будем строить silver-слой на основе `lakehouse.bronze.nyc_taxi_yellow` - **[md]** «Мы намеренно не удаляем bronze-таблицы (`nyc_taxi_yellow` и `taxi_zone_lookup`). Они понадобятся в Модуле 5, где мы будем строить silver-слой.»
- **[code]** `spark.stop()`. - **[code]** `spark.stop()`.
### Дизайн-решения по ноутбуку ### Дизайн-решения по ноутбуку
+5 -5
View File
@@ -1,6 +1,6 @@
# Модуль 5. Слой silver и воспроизводимые трансформации # Модуль 5. Слой silver и воспроизводимые трансформации
**Статус:** `Draft` **Статус:** `Ready for validation`
**Последнее обновление:** `2026-03-07` **Последнее обновление:** `2026-03-07`
## Цель ## Цель
@@ -88,9 +88,9 @@ LEFT JOIN (не INNER), чтобы не терять строки с LocationID
- **Схемный (schema-level):** наличие новых колонок, проверка типов (passenger_count = INT, RatecodeID = INT) - **Схемный (schema-level):** наличие новых колонок, проверка типов (passenger_count = INT, RatecodeID = INT)
- **Агрегатный (aggregate-level):** таблица-сравнение bronze vs silver (row count, % отфильтрованных, средние fare/distance) - **Агрегатный (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: нет ### Cleanup: нет
@@ -119,7 +119,7 @@ Silver нужен Модулям 6 и 8. Spark-сессия останавлив
### Секция 1: Spark-сессия и проверка bronze ### Секция 1: Spark-сессия и проверка bronze
- **[code]** SparkSession (паттерн Модулей 2-4: без `.master()`, `setLogLevel("ERROR")`). Константы, вспомогательные функции (`format_bytes`, `list_objects` — переиспользование паттерна из Модуля 4). - **[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. - **[md]** Обе bronze-таблицы на месте. Теперь строим silver.
### Секция 2: Профиль bronze — что нужно трансформировать ### Секция 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. - **Время CTAS.** JOIN + трансформации — на 30-50% медленнее bronze CTAS. На 3 мес (~7 млн строк): 2-4 мин. Extended: до 5-10 мин. Предупреждение в markdown.
- **Column case sensitivity.** Новые колонки (lowercase) рядом с оригинальными (mixed case: VendorID, PULocationID). Нормально для Iceberg, но добавить пояснение. - **Column case sensitivity.** Новые колонки (lowercase) рядом с оригинальными (mixed case: VendorID, PULocationID). Нормально для Iceberg, но добавить пояснение.
- **trip_duration_minutes отрицательные.** Если dropoff < pickup — длительность отрицательна. Упомянуть как наблюдение, не фильтровать в демо (потенциально — для самостоятельного задания или будущего правила). - **trip_duration_minutes отрицательные.** Если dropoff < pickup — длительность отрицательна. Упомянуть как наблюдение, не фильтровать в демо (потенциально — для самостоятельного задания или будущего правила).
@@ -0,0 +1,318 @@
# Модуль 6. Одна таблица, два движка: Spark и Trino
**Статус:** `Ready for validation`
**Последнее обновление:** `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.
+454
View File
@@ -0,0 +1,454 @@
# Модуль 7. Безопасная работа с таблицами: schema evolution и time travel
**Статус:** `Ready for validation`
**Последнее обновление:** `2026-03-08`
## Цель
Научить студента безопасно изменять 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 <snapshot_id>` |
| Посмотреть историю изменений таблицы | Нет встроенного механизма | `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 <snapshot_id>;`_
#### 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_id>`? Что происходит с историей 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_id>` (менее элегантно, уничтожает 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.
@@ -0,0 +1,521 @@
# Модуль 8. Базовое обслуживание таблиц и финальная практика
**Статус:** `Ready for validation`
**Последнее обновление:** `2026-03-08`
## Цель
Научить студента базовому обслуживанию 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 29, 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.