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/course_prd.md` — рамки курса, learning outcomes, scope и out of scope.
- `docs/course_program.md` — модульная структура курса и состав учебных материалов.
- `docs/glossary.md` — справочник терминов курса.
- `docs/mentor_notes.md` — заметки для ведения курса с ментором (опционально).
- `docs/maintainer_guide.md` — карта репозитория и правила синхронизации изменений.
- `plans/` — внутренние living docs; не источник истины для студентского маршрута.
- `docs/archive/` — архивные материалы; не использовать как актуальную документацию без явного запроса.
+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
graph TB
@@ -41,23 +48,21 @@ graph TB
T --> M
```
Коротко по ролям:
- `MinIO` хранит данные и служебные файлы Iceberg.
- `PostgreSQL` хранит метаданные JDBC-каталога `lakehouse`.
- `Spark` и `Trino` работают как два compute-движка поверх одного storage и одного catalog.
- `JupyterLab` служит основной точкой входа в практическую часть курса.
- `Trino UI / CLI` даёт отдельную точку входа для ad hoc SQL и проверки таблиц.
| Компонент | Роль в стенде |
| --- | --- |
| `MinIO` | Storage — хранит данные и служебные файлы Iceberg |
| `PostgreSQL` | Catalog — метаданные JDBC-каталога `lakehouse` |
| `Spark` | Compute — ETL, запись и чтение Iceberg-таблиц |
| `Trino` | Compute — ad hoc SQL, чтение тех же таблиц |
| `JupyterLab` | Точка входа — практические ноутбуки курса |
## С чего начать
Если ты заходишь в репозиторий впервые, используй такой маршрут:
1. Открой [START_HERE.md](./START_HERE.md) — запуск стенда, диагностика, первый ноутбук.
2. Пройди `notebooks/01_environment_and_smoke_test.ipynb`.
3. Дальше по порядку: [docs/course_program.md](./docs/course_program.md).
1. Открой [START_HERE.md](./START_HERE.md) для первого запуска стенда и базовой диагностики.
2. После старта стенда выполни `notebooks/01_environment_and_smoke_test.ipynb`.
3. Для структуры курса смотри [docs/course_program.md](./docs/course_program.md).
Если нужен только краткий запуск, из корня репозитория достаточно:
Краткий запуск из корня репозитория:
```bash
docker compose build
@@ -65,54 +70,25 @@ docker compose up -d
docker compose ps
```
Основные UI после старта:
После старта:
- Spark Master UI: `http://localhost:8080`
- Trino UI: `http://localhost:8090`
- MinIO Console: `http://localhost:9001`
- JupyterLab: `http://localhost:8888`
Полный onboarding, диагностика и reset-сценарии находятся в `START_HERE.md`.
## Как устроен репозиторий
| Где | Что лежит |
| UI | Адрес |
| --- | --- |
| `docker-compose.yml` | состав сервисов стенда, порты, сети, init-контейнеры |
| `spark/` | Dockerfile и конфиг Spark для Iceberg + MinIO |
| `trino/` | каталог `lakehouse` и настройки Trino |
| `jupyter/` | образ JupyterLab на базе Spark-образа |
| `notebooks/` | практические ноутбуки курса |
| `src/` | smoke-скрипты, SQL-демо и helper-логика |
| `docs/` | PRD, программа курса, reference-документы |
| `plans/` | внутренние living docs по развитию материалов |
| JupyterLab | `http://localhost:8888` |
| Spark Master | `http://localhost:8080` |
| Trino | `http://localhost:8090` |
| MinIO Console | `http://localhost:9001` |
## Основные документы для прохождения
## Документы для прохождения
- [START_HERE.md](./START_HERE.md) — первый маршрут для студента.
- [START_HERE.md](./START_HERE.md) — первый маршрут: prerequisites, запуск, диагностика.
- [docs/course_program.md](./docs/course_program.md) — модульная структура и состав учебных материалов.
- [docs/stack_reference.md](./docs/stack_reference.md) — технический reference по сервисам, портам, конфигам и smoke-тестам.
- [docs/stack_reference.md](./docs/stack_reference.md) — порты, команды, конфиги, шпаргалка.
- [docs/glossary.md](./docs/glossary.md) — справочник терминов (storage, catalog, compute, snapshot и др.).
## Что уже можно делать в стенде
## Менторство
- поднять локальный кластер `Spark` с двумя worker-ами;
- создать Iceberg-таблицу из `Spark`;
- прочитать ту же таблицу из `Trino`;
- пройти базовый smoke test через ноутбук или demo-скрипты;
- использовать стенд как основу для следующих модулей курса.
## Куда смотреть за техническими деталями
- [docs/stack_reference.md](./docs/stack_reference.md) — сервисы, порты, доступ, reset, smoke tests;
- [spark/spark-defaults.conf](./spark/spark-defaults.conf) — конфигурация Spark-каталога `lakehouse`;
- [trino/catalog/lakehouse.properties](./trino/catalog/lakehouse.properties) — конфигурация каталога Trino;
- [docker-compose.yml](./docker-compose.yml) — фактический состав стенда.
## Автор и менторство
Этот репозиторий и материалы курса можно проходить самостоятельно, но при желании их можно разбирать вместе с автором как с ментором по `Data Engineering`.
Если хочешь глубже пройти темы `Spark`, `Trino`, `Iceberg`, `Lakehouse` и связанные практики по `DE`, напиши: [@dementev_dev](https://t.me/dementev_dev).
Курс рассчитан на самостоятельное прохождение, но если хочешь разобрать темы глубже с ментором по Data Engineering — напиши: [@dementev_dev](https://t.me/dementev_dev).
## Лицензия
+8
View File
@@ -181,6 +181,14 @@ ls -lh data/nyc_taxi/
Если хочешь расширенный режим, можешь скачать все 12 месяцев `2024`, но основной маршрут курса и примеры опираются на первые 3 месяца.
## Подключение DBeaver к Trino (перед Модулем 6)
- Зачем: в Модуле 6 можно работать с Trino через DBeaver параллельно с ноутбуком — привычный SQL-интерфейс.
- Предусловие: DBeaver установлен (ссылка на [dbeaver.io/download](https://dbeaver.io/download/)). Необязателен — ноутбук работает без DBeaver.
- Шаги: New Database Connection -> Trino. Host: `localhost`. Port: `8090`. Database/Catalog: `lakehouse`. Username: любая строка (напр. `student`). Password: пусто. Test Connection -> Finish.
- Проверка: `SHOW SCHEMAS FROM lakehouse;`. Ожидаем: `bronze`, `default`, `information_schema`, `silver`.
- Troubleshooting: стенд поднят? контейнер `trino` Up? порт 8090 свободен?
## Что делать дальше
- пройти `notebooks/01_environment_and_smoke_test.ipynb`;
+3 -3
View File
@@ -59,9 +59,9 @@
* вспомогательные скрипты в `src/spark` и `src/trino`;
* инструкции по загрузке или подготовке учебных датасетов;
* краткий glossary по терминам `storage`, `catalog`, `compute`, `table format`, `namespace`, `metadata`, `manifest`, `snapshot`, `time travel`, `schema evolution`, `compaction`, `vacuum`;
* cheat sheet по типовым командам, адресам сервисов, ключевым путям и точкам входа;
* опционально, отдельные mentor notes для ведения курса с ментором.
* краткий [glossary](./glossary.md) по терминам `storage`, `catalog`, `compute`, `table format`, `namespace`, `metadata`, `manifest`, `snapshot`, `time travel`, `schema evolution`, `compaction`, `vacuum`;
* [шпаргалка](./stack_reference.md#краткая-шпаргалка) по типовым командам, адресам сервисов, ключевым путям и точкам входа (секция в `stack_reference.md`);
* опционально, [заметки для ментора](./mentor_notes.md) для ведения курса с ментором.
## 4. Трассировка Learning Outcomes на модули
+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` | Вход в репозиторий и первый пользовательский маршрут |
| Runtime reference | `docs/stack_reference.md` | Технические детали стенда: порты, доступ, команды, smoke-тесты |
| Course definition | `docs/course_prd.md`, `docs/course_program.md` | Границы курса, learning outcomes, структура модулей |
| Reference materials | `docs/glossary.md`, `docs/mentor_notes.md` | Справочник терминов и заметки для ментора |
| Practice materials | `notebooks/`, `src/` | Практика студента, smoke-скрипты, демонстрации, helper-логика |
| Internal planning | `plans/` | Внутренние living docs по разработке материалов |
| Archive | `docs/archive/` | Исторические материалы, не входящие в актуальный маршрут |
+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;
```
**Подключение через DBeaver:**
- Host: `localhost`, Port: `8090`, Catalog: `lakehouse`, User: любая строка, Password: нет.
- Driver: Trino (встроен в DBeaver).
- Проверка: `SHOW SCHEMAS FROM lakehouse;`.
### MinIO
- 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"
```
## Краткая шпаргалка
### Ключевые 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` — чтобы понять, что это за репозиторий и куда идти дальше.
- `START_HERE.md` — чтобы впервые поднять стенд и пройти Модуль 1.
- `stack_reference.md` — чтобы быстро вспомнить порты, команды, точки доступа и smoke-тесты.
- `stack_reference.md` — чтобы быстро вспомнить порты, команды, точки доступа, шпаргалку и smoke-тесты.
- `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 \
"jupyterlab==4.2.5" \
"boto3>=1.35,<2" \
"psycopg2-binary>=2.9,<3"
"psycopg2-binary>=2.9,<3" \
"trino>=0.328"
# Создаём непривилегированного пользователя
ARG NB_USER=jovyan
+9 -48
View File
@@ -564,21 +564,7 @@
"cell_type": "markdown",
"id": "320c0199",
"metadata": {},
"source": [
"## 8. Самостоятельное задание\n",
"\n",
"Используй raw-файл `taxi_zone_lookup.csv`, который уже лежит в `MinIO`.\n",
"\n",
"Что нужно сделать:\n",
"\n",
"1. Прочитать `taxi_zone_lookup.csv` из raw-зоны через `Spark`.\n",
"2. Создать таблицу `lakehouse.bronze.taxi_zone_lookup` с колонками `LocationID INT`, `Borough STRING`, `Zone STRING`, `service_zone STRING`.\n",
"3. Загрузить туда данные.\n",
"4. Проверить результат через `spark.table(...)`.\n",
"5. Посмотреть `snapshots` для этой таблицы.\n",
"\n",
"Подсказка: файл лежит по пути `s3a://lakehouse/raw/nyc_taxi/taxi_zone_lookup.csv`.\n"
]
"source": "## 8. Вторая bronze-таблица: taxi_zone_lookup\n\nВ raw-зоне кроме Yellow Taxi лежит ещё один файл — `taxi_zone_lookup.csv`. Это справочник зон: каждому `LocationID` соответствует название района (`Borough`) и зоны (`Zone`).\n\nВ Модуле 5 мы будем обогащать silver-таблицу через `LEFT JOIN` с этим справочником. Сейчас загрузим его в bronze по тому же паттерну `CTAS`, но из CSV вместо Parquet."
},
{
"cell_type": "code",
@@ -586,9 +572,7 @@
"id": "e94508bf",
"metadata": {},
"outputs": [],
"source": [
"# Ваш код: прочитай taxi_zone_lookup.csv из raw-зоны через Spark\n"
]
"source": "raw_zone_df = (\n spark.read\n .option(\"header\", \"true\")\n .option(\"inferSchema\", \"true\")\n .csv(ZONE_LOOKUP_RAW_URI)\n)\n\nprint(f\"Строк в raw lookup: {raw_zone_df.count()}\")\nraw_zone_df.printSchema()\nraw_zone_df.show(5, truncate=False)"
},
{
"cell_type": "code",
@@ -596,19 +580,15 @@
"id": "8e058aeb",
"metadata": {},
"outputs": [],
"source": [
"# Ваш код: создай таблицу lakehouse.bronze.taxi_zone_lookup\n"
]
"source": "raw_zone_df.createOrReplaceTempView(\"raw_zone_lookup\")\n\nctas_lookup_sql = f\"\"\"\nCREATE OR REPLACE TABLE {BRONZE_LOOKUP_TABLE}\nUSING iceberg\nAS\nSELECT\n CAST(LocationID AS INT) AS LocationID,\n Borough,\n Zone,\n service_zone\nFROM raw_zone_lookup\n\"\"\"\n\nspark.sql(ctas_lookup_sql)\n\nlookup_df = spark.table(BRONZE_LOOKUP_TABLE)\nlookup_count = lookup_df.count()\n\nprint(f\"Строк в bronze lookup: {lookup_count}\")\nlookup_df.show(5, truncate=False)"
},
{
"cell_type": "code",
"cell_type": "markdown",
"execution_count": null,
"id": "41c2c79a",
"metadata": {},
"outputs": [],
"source": [
"# Ваш код: загрузи данные в bronze lookup-таблицу\n"
]
"source": "## 9. Самостоятельное задание\n\nПосмотри на внутреннюю структуру только что созданной `taxi_zone_lookup` — используй те же инструменты, что в Секциях 5 и 6.\n\n1. Выведи `snapshots` и `files` для `lakehouse.bronze.taxi_zone_lookup`.\n2. Сравни: сколько data files у lookup-таблицы и сколько у `nyc_taxi_yellow`? Почему такая разница?"
},
{
"cell_type": "code",
@@ -616,9 +596,7 @@
"id": "80b392e1",
"metadata": {},
"outputs": [],
"source": [
"# Ваш код: проверь результат через spark.table(...)\n"
]
"source": "# Ваш код: snapshots и files для taxi_zone_lookup\n"
},
{
"cell_type": "code",
@@ -626,36 +604,19 @@
"id": "61e90959",
"metadata": {},
"outputs": [],
"source": [
"# Ваш код: посмотри snapshots для lakehouse.bronze.taxi_zone_lookup\n"
]
"source": "# Ваш код: сравни количество data files в lookup и yellow taxi\n"
},
{
"cell_type": "markdown",
"id": "24c04d60",
"metadata": {},
"source": [
"## 9. Checkpoint\n",
"\n",
"Проверь себя:\n",
"\n",
"1. Чем отличается `spark.read.parquet(\"s3a://...\")` от `spark.table(\"lakehouse.bronze.nyc_taxi_yellow\")`?\n",
"2. Что хранит snapshot и зачем он нужен?\n",
"3. Где физически лежат данные таблицы и где лежат её метаданные?\n",
"4. Почему `bronze` это не то же самое, что `raw`?\n",
"5. Что произойдёт, если удалить один data file из `MinIO`, но metadata не поменять?\n",
"6. Можешь ли ты показать таблицу и через `Spark`, и через `MinIO Console`?\n"
]
"source": "## 10. Checkpoint\n\nПроверь себя:\n\n1. Чем отличается `spark.read.parquet(\"s3a://...\")` от `spark.table(\"lakehouse.bronze.nyc_taxi_yellow\")`?\n2. Что хранит snapshot и зачем он нужен?\n3. Где физически лежат данные таблицы и где лежат её метаданные?\n4. Почему `bronze` это не то же самое, что `raw`?\n5. Что произойдёт, если удалить один data file из `MinIO`, но metadata не поменять?\n6. Можешь ли ты показать таблицу и через `Spark`, и через `MinIO Console`?"
},
{
"cell_type": "markdown",
"id": "76d9061f",
"metadata": {},
"source": [
"## 10. Завершение\n",
"\n",
"Мы намеренно не удаляем `lakehouse.bronze.nyc_taxi_yellow`. Эта таблица понадобится в Модуле 5, где мы будем строить `silver`-слой.\n"
]
"source": "## 11. Завершение\n\nМы намеренно не удаляем bronze-таблицы (`nyc_taxi_yellow` и `taxi_zone_lookup`). Они понадобятся в Модуле 5, где мы будем строить silver-слой."
},
{
"cell_type": "code",
+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 |
| 3. Raw-данные и первое чтение в Spark | [module-03-raw-ingest-and-first-read.md](./module-03-raw-ingest-and-first-read.md) | Ready for validation | `docker-compose.yml`, `.gitignore`, `START_HERE.md`, `docs/stack_reference.md`, `notebooks/03_raw_ingest_and_first_read.ipynb` | 2026-03-07 |
| 4. Первая рабочая Iceberg-таблица и слой bronze | [module-04-bronze-with-iceberg.md](./module-04-bronze-with-iceberg.md) | Draft | `notebooks/04_bronze_with_iceberg.ipynb` | 2026-03-07 |
| 5. Слой silver и воспроизводимые трансформации | [module-05-silver-layer.md](./module-05-silver-layer.md) | Draft | `notebooks/05_silver_layer.ipynb` | 2026-03-07 |
| 5. Слой silver и воспроизводимые трансформации | [module-05-silver-layer.md](./module-05-silver-layer.md) | Ready for validation | `notebooks/05_silver_layer.ipynb` | 2026-03-07 |
| 6. Одна таблица, два движка: Spark и Trino | [module-06-spark-and-trino-on-same-table.md](./module-06-spark-and-trino-on-same-table.md) | 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.»
### Taxi zone lookup: самостоятельное задание
### Taxi zone lookup: демо-секция
Студент создаёт `lakehouse.bronze.taxi_zone_lookup` как упражнение. Это подготовка к join в Модуле 5 (silver).
Создание `lakehouse.bronze.taxi_zone_lookup` входит в демонстрационный код ноутбука (Секция 8), а не в самостоятельное задание. Причина: Модуль 5 использует lookup для JOIN в демо-коде — если lookup не создан, студент не может пройти Модуль 5, даже выполняя только готовые ячейки. Принцип: демо-ячейки последующих модулей не должны зависеть от домашних заданий предыдущих.
## План работ
@@ -160,11 +160,16 @@ Bronze нужен Модулю 5. Spark-сессия останавливает
- Bronze ≠ raw. Raw — неизменяемый архив файлов. Bronze — управляемая таблица, которую можно пересоздать из raw.
- В Модуле 5 — silver: очистка, трансформации, бизнес-логика.
### Секция 7: Самостоятельное задание
- **[md]** Инструкция: (1) прочитать `taxi_zone_lookup.csv` из raw-зоны, (2) создать `lakehouse.bronze.taxi_zone_lookup` с DDL (LocationID INT, Borough STRING, Zone STRING, service_zone STRING), (3) загрузить данные, (4) проверить через `spark.table(...)`, (5) осмотреть snapshots.
- **[code]** 5 пустых ячеек `# Ваш код: ...`.
### Секция 7: Вторая bronze-таблица: taxi_zone_lookup
- **[md]** В raw-зоне кроме Yellow Taxi лежит `taxi_zone_lookup.csv` — справочник зон. В Модуле 5 он понадобится для JOIN. Загружаем в bronze по тому же паттерну CTAS, но из CSV.
- **[code]** Чтение CSV через `spark.read.option("header", "true").option("inferSchema", "true").csv(...)`. Вывод count, schema, preview.
- **[code]** CTAS: `CREATE OR REPLACE TABLE lakehouse.bronze.taxi_zone_lookup USING iceberg AS SELECT CAST(LocationID AS INT) AS LocationID, Borough, Zone, service_zone FROM raw_zone_lookup`. Верификация: count, show.
### Секция 8: Checkpoint
### Секция 8: Самостоятельное задание
- **[md]** Инструкция: осмотреть внутреннюю структуру `taxi_zone_lookup` теми же инструментами, что в Секциях 5 и 6. (1) Вывести snapshots и files. (2) Сравнить количество data files с `nyc_taxi_yellow` и объяснить разницу.
- **[code]** 2 пустых ячейки `# Ваш код: ...`.
### Секция 9: Checkpoint
- **[md]** Вопросы для самопроверки:
1. Чем отличается `spark.read.parquet("s3a://...")` от `spark.table("lakehouse.bronze.nyc_taxi_yellow")`?
2. Что хранит snapshot и зачем он нужен?
@@ -173,8 +178,8 @@ Bronze нужен Модулю 5. Spark-сессия останавливает
5. Что произойдёт, если удалить один data file из MinIO, но не изменить metadata?
6. Можете ли вы показать таблицу в MinIO Console (через браузер)?
### Секция 9: Завершение
- **[md]** «Мы намеренно не удаляем bronze-таблицы. В Модуле 5 мы будем строить silver-слой на основе `lakehouse.bronze.nyc_taxi_yellow`
### Секция 10: Завершение
- **[md]** «Мы намеренно не удаляем bronze-таблицы (`nyc_taxi_yellow` и `taxi_zone_lookup`). Они понадобятся в Модуле 5, где мы будем строить silver-слой.»
- **[code]** `spark.stop()`.
### Дизайн-решения по ноутбуку
+5 -5
View File
@@ -1,6 +1,6 @@
# Модуль 5. Слой silver и воспроизводимые трансформации
**Статус:** `Draft`
**Статус:** `Ready for validation`
**Последнее обновление:** `2026-03-07`
## Цель
@@ -88,9 +88,9 @@ LEFT JOIN (не INNER), чтобы не терять строки с LocationID
- **Схемный (schema-level):** наличие новых колонок, проверка типов (passenger_count = INT, RatecodeID = INT)
- **Агрегатный (aggregate-level):** таблица-сравнение bronze vs silver (row count, % отфильтрованных, средние fare/distance)
### Taxi zone lookup: жёсткий prerequisite
### Taxi zone lookup: prerequisite из демо-секции Модуля 4
Lookup самостоятельное задание Модуля 4. Модуль 5 работает только с bronze-таблицами и не должен лезть в raw-слой — это нарушило бы принцип разделения ответственности между слоями (PRD Risk #4). Стратегия: жёсткий assert при отсутствии `lakehouse.bronze.taxi_zone_lookup` с понятным сообщением и отсылкой к Модулю 4: «Таблица `lakehouse.bronze.taxi_zone_lookup` не найдена. Вернись в Модуль 4 и выполни самостоятельное задание (Секция 8)
Lookup создаётся в демонстрационном коде Модуля 4 (Секция 8), а не в самостоятельном задании. Это гарантирует, что студент, прогнавший готовые ячейки Модуля 4, имеет обе bronze-таблицы. Модуль 5 работает только с bronze-таблицами и не должен лезть в raw-слой — это нарушило бы принцип разделения ответственности между слоями (PRD Risk #4). Стратегия: жёсткий assert при отсутствии `lakehouse.bronze.taxi_zone_lookup` с понятным сообщением и отсылкой к Модулю 4: «Таблица `lakehouse.bronze.taxi_zone_lookup` не найдена. Вернись в Модуль 4 и выполни Секцию 8.»
### Cleanup: нет
@@ -119,7 +119,7 @@ Silver нужен Модулям 6 и 8. Spark-сессия останавлив
### Секция 1: Spark-сессия и проверка bronze
- **[code]** SparkSession (паттерн Модулей 2-4: без `.master()`, `setLogLevel("ERROR")`). Константы, вспомогательные функции (`format_bytes`, `list_objects` — переиспользование паттерна из Модуля 4).
- **[code]** Assert: `spark.table(BRONZE_TABLE).count() > 0` с отсылкой к Модулю 4. Assert: `spark.table(BRONZE_LOOKUP_TABLE)` существует — при отсутствии жёсткая ошибка с отсылкой к самостоятельному заданию Модуля 4 (Секция 8).
- **[code]** Assert: `spark.table(BRONZE_TABLE).count() > 0` с отсылкой к Модулю 4. Assert: `spark.table(BRONZE_LOOKUP_TABLE)` существует — при отсутствии жёсткая ошибка с отсылкой к Секции 8 Модуля 4.
- **[md]** Обе bronze-таблицы на месте. Теперь строим silver.
### Секция 2: Профиль bronze — что нужно трансформировать
@@ -272,7 +272,7 @@ speed_mph DOUBLE (E4: новая, trip_distance / (tr
## Риски
- **Taxi zone lookup отсутствует.** Самостоятельное задание Модуля 4 может быть не выполнено. Жёсткий assert с отсылкой к Модулю 4. Модуль 5 не создаёт bronze-таблицы самостоятельно — это нарушило бы разделение ответственности между слоями.
- **Taxi zone lookup отсутствует.** Lookup создаётся в демо-секции Модуля 4 (Секция 8), поэтому риск минимален — студент должен был прогнать готовые ячейки. Жёсткий assert с отсылкой к Модулю 4. Модуль 5 не создаёт bronze-таблицы самостоятельно — это нарушило бы разделение ответственности между слоями.
- **Время CTAS.** JOIN + трансформации — на 30-50% медленнее bronze CTAS. На 3 мес (~7 млн строк): 2-4 мин. Extended: до 5-10 мин. Предупреждение в markdown.
- **Column case sensitivity.** Новые колонки (lowercase) рядом с оригинальными (mixed case: VendorID, PULocationID). Нормально для Iceberg, но добавить пояснение.
- **trip_duration_minutes отрицательные.** Если dropoff < pickup — длительность отрицательна. Упомянуть как наблюдение, не фильтровать в демо (потенциально — для самостоятельного задания или будущего правила).
@@ -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.