Compare commits
10
Commits
82d067d6c6
...
df39ff9e64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df39ff9e64 | ||
|
|
0ae83cecdb | ||
|
|
4bea2af9d2 | ||
|
|
9ffb9b80e0 | ||
|
|
65d57ec6d6 | ||
|
|
68c530c855 | ||
|
|
bcf7975227 | ||
|
|
4a736f6c4e | ||
|
|
a31a020a76 | ||
|
|
39b1a5618c |
@@ -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/` — архивные материалы; не использовать как актуальную документацию без явного запроса.
|
||||
|
||||
@@ -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).
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 на модули
|
||||
|
||||
|
||||
@@ -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 (финальная практика).
|
||||
@@ -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/` | Исторические материалы, не входящие в актуальный маршрут |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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()`.
|
||||
|
||||
### Дизайн-решения по ноутбуку
|
||||
|
||||
@@ -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.
|
||||
@@ -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 2–9, 8 новых data files);
|
||||
- итого: ~1300 строк, 9 data files, 9 snapshots.
|
||||
|
||||
Это создаёт наглядную проблему мелких файлов: 9 файлов вместо одного оптимального.
|
||||
|
||||
### Compaction: rewrite_data_files
|
||||
|
||||
`CALL lakehouse.system.rewrite_data_files(table => 'default.taxi_maintenance_demo')` — объединяет мелкие data files в меньшее количество крупных. Ключевые моменты для студента:
|
||||
|
||||
- создаёт новый snapshot (данные переписаны в новые файлы);
|
||||
- старые data files остаются в MinIO до `expire_snapshots` — они ещё нужны для time travel к старым snapshot-ам;
|
||||
- аналогия: дефрагментация диска или `ALTER TABLE ... REORGANIZE` в Greenplum.
|
||||
|
||||
### Порядок обслуживания: compaction → expire_snapshots
|
||||
|
||||
Осознанный порядок:
|
||||
|
||||
1. Сначала compaction — создаёт новые оптимальные файлы.
|
||||
2. Потом expire_snapshots — удаляет старые snapshot-ы и файлы, на которые они ссылались (включая мелкие файлы до compaction).
|
||||
|
||||
Если сделать наоборот: expire_snapshots удалит snapshot-ы, но старые мелкие файлы останутся, потому что текущий snapshot ещё на них ссылается. Compaction после этого создаст новые файлы, но старые мелкие уже не будут привязаны ни к одному snapshot-у и станут orphan-файлами.
|
||||
|
||||
### expire_snapshots: компромисс между очисткой и time travel
|
||||
|
||||
`CALL lakehouse.system.expire_snapshots(table => '...', retain_last => 2)` — удаляет все snapshot-ы, кроме N последних. Ключевые моменты:
|
||||
|
||||
- удалённые snapshot-ы недоступны для time travel — это необратимо;
|
||||
- по документации Iceberg, data files, на которые ссылались только удалённые snapshot-ы, должны удаляться из хранилища вместе со snapshot-ами. Однако поведение зависит от версии Iceberg, конфигурации каталога и файловой системы — **необходима проверка на стенде**. Если файлы не удаляются автоматически, в ноутбуке нужно явно отметить это и упомянуть `remove_orphan_files` как дополнительный шаг;
|
||||
- `retain_last` — количество snapshot-ов, которые нужно сохранить (самые свежие);
|
||||
- в production: expire_snapshots запускается периодически (Airflow, cron) с разумным retain_last (например, 10–30 дней истории).
|
||||
|
||||
Точный синтаксис (именованные параметры, `older_than` vs `retain_last`) зависит от версии Iceberg — нужна проверка на стенде при реализации.
|
||||
|
||||
### remove_orphan_files: упоминание без демонстрации
|
||||
|
||||
`remove_orphan_files` удаляет файлы в хранилище, которые не принадлежат ни одному snapshot-у (например, от упавших записей). В курсе упоминается для полноты картины, но не демонстрируется: при штатной работе orphan-файлы не возникают, и процедура нужна только для edge cases.
|
||||
|
||||
### Сравнение с silver: контрастная демонстрация
|
||||
|
||||
После демонстрации проблемы мелких файлов на демо-таблице показываем файловую статистику silver-таблицы. Ожидаемый результат: silver имеет мало крупных файлов (создан одним CTAS). Контрастный вывод: compaction нужен после множества мелких записей, а не после однократной загрузки.
|
||||
|
||||
### Greenplum AppendOnly: короткая параллель
|
||||
|
||||
Greenplum AppendOnly-таблицы имеют аналогичные проблемы:
|
||||
|
||||
- после UPDATE/DELETE в AppendOnly остаются «мёртвые» строки (dead tuples);
|
||||
- `VACUUM` очищает мёртвые строки, `ALTER TABLE ... REORGANIZE` перестраивает сегменты;
|
||||
- в Iceberg: `rewrite_data_files` аналог реорганизации, `expire_snapshots` аналог очистки устаревших версий.
|
||||
|
||||
Ключевое сходство: обе системы требуют периодического обслуживания. Ни Iceberg, ни AppendOnly не делают это автоматически в базовой конфигурации (в отличие от autovacuum для heap-таблиц в PostgreSQL).
|
||||
|
||||
### Финальная практика: отдельный namespace lakehouse.final
|
||||
|
||||
Финальная сквозная практика выполняется в отдельном namespace `lakehouse.final`, чтобы:
|
||||
|
||||
- не затрагивать основные таблицы bronze и silver;
|
||||
- позволить студенту создать namespace самостоятельно (навык из Модуля 4);
|
||||
- обеспечить чистый cleanup в конце.
|
||||
|
||||
Студент создаёт две таблицы (`trips_bronze`, `trips_silver`), проводит весь цикл, после чего удаляет namespace.
|
||||
|
||||
### Финальная практика: использование существующих raw-данных
|
||||
|
||||
Студент читает raw-данные из MinIO (`s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet`), уже загруженные в Модуле 3. Повторная загрузка в MinIO не требуется. Для контроля объёма — LIMIT 5000 строк на этапе чтения raw.
|
||||
|
||||
### Независимость от Модуля 7
|
||||
|
||||
Модуль 8 работает независимо от того, прошёл ли студент Модуль 7:
|
||||
|
||||
- если Модуль 7 пройден: silver может содержать колонку `loaded_at` — Модуль 8 не зависит от её наличия;
|
||||
- если студент перезапустил Модуль 5 после Модуля 7: silver будет без `loaded_at` — Модуль 8 продолжает работать;
|
||||
- концепции snapshot-ов кратко повторяются при объяснении expire_snapshots.
|
||||
|
||||
### Cleanup
|
||||
|
||||
- Демо-таблица `taxi_maintenance_demo` удаляется после демонстрации.
|
||||
- Финальная практика: таблицы `lakehouse.final.trips_bronze` и `lakehouse.final.trips_silver` удаляются, namespace `lakehouse.final` удаляется.
|
||||
- Silver (`lakehouse.silver.nyc_taxi_yellow`) и bronze (`lakehouse.bronze.nyc_taxi_yellow`) сохраняются — студент может продолжить эксперименты.
|
||||
- Spark-сессия останавливается.
|
||||
|
||||
## План работ
|
||||
|
||||
1. Создать `notebooks/08_maintenance_and_final_lab.ipynb` по ячеечной структуре ниже.
|
||||
2. Обновить `plans/README.md` — добавить строку Модуля 8 в таблицу оглавления.
|
||||
3. Валидация: выполнить ноутбук сверху вниз на поднятом стенде после Модуля 6 (silver-таблица и Trino-доступ на месте, raw-данные в MinIO).
|
||||
|
||||
## Структура ноутбука
|
||||
|
||||
### Секция 0: Введение
|
||||
|
||||
- **[md]** Заголовок, цели модуля, prerequisites (Модуль 6 пройден, Модуль 7 рекомендуется). Две части модуля:
|
||||
|
||||
1. Базовое обслуживание: compaction и expire_snapshots.
|
||||
2. Финальная практика: сквозной end-to-end сценарий.
|
||||
|
||||
- **[md]** Зачем нужно обслуживание. Iceberg-таблица — не «чёрный ящик». После множества записей накапливаются мелкие файлы и старые snapshot-ы. Без обслуживания: чтение замедляется (много мелких файлов), хранилище растёт (старые данные не удаляются). Это знакомо из мира Greenplum: AppendOnly-таблицы тоже требуют VACUUM и REORGANIZE.
|
||||
- **[md]** Таблица-сравнение (предварительный обзор):
|
||||
|
||||
| | Greenplum AppendOnly | Iceberg |
|
||||
|---|---|---|
|
||||
| Проблема | Мёртвые строки после UPDATE/DELETE | Мелкие файлы после множества INSERT |
|
||||
| Компактификация | `ALTER TABLE ... REORGANIZE` | `rewrite_data_files` |
|
||||
| Очистка устаревших версий | Нет встроенной истории версий | `expire_snapshots` |
|
||||
| Очистка мёртвых строк/файлов | `VACUUM` | `expire_snapshots` (удаляет старые файлы) |
|
||||
| Автоматизация в базовой конфигурации | Ручной запуск | Ручной запуск |
|
||||
| В production | Расписание через cron/Airflow | Расписание через cron/Airflow |
|
||||
|
||||
### Секция 1: Spark-сессия, Trino-клиент и проверка таблиц
|
||||
|
||||
- **[code]** SparkSession (паттерн Модулей 2–7). Константы: `SILVER_TABLE = "lakehouse.silver.nyc_taxi_yellow"`, `DEMO_TABLE = "lakehouse.default.taxi_maintenance_demo"`, `RAW_PATH = "s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet"`.
|
||||
- **[code]** Assert: silver существует и непуста. Отсылка к Модулю 5.
|
||||
- **[code]** Assert: raw-данные доступны в MinIO. Проверка через `spark.read.parquet(RAW_PATH).limit(1)` — при ошибке понятное сообщение с отсылкой к Модулю 3: «Raw-данные не найдены по пути {RAW_PATH}. Вернись в Модуль 3 и выполни загрузку данных в MinIO.» Raw нужен для финальной практики (Секция 9).
|
||||
- **[code]** Helper `trino_query(sql)` (паттерн Модуля 6).
|
||||
- **[md]** Silver на месте, raw-данные доступны, Trino готов. В первой части модуля создадим демо-таблицу и научимся её обслуживать. Во второй — финальная практика.
|
||||
|
||||
### Секция 2: Проблема мелких файлов
|
||||
|
||||
- **[md]** Каждый `INSERT INTO` в Iceberg создаёт новые data files. Если вставлять данные мелкими порциями (частые микробатчи, ручные INSERT-ы, инкрементальные загрузки), таблица накапливает множество мелких файлов. Это замедляет чтение: движку приходится открывать и читать каждый файл отдельно. Проблема знакома в мире Hadoop/Hive — и в Iceberg она решается через compaction.
|
||||
|
||||
- **[code]** Создание демо-таблицы с 500 строками (CTAS из silver):
|
||||
|
||||
```python
|
||||
spark.sql(f"""
|
||||
CREATE OR REPLACE TABLE {DEMO_TABLE}
|
||||
USING iceberg AS
|
||||
SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime,
|
||||
passenger_count, trip_distance, fare_amount, total_amount,
|
||||
pickup_borough, pickup_zone
|
||||
FROM {SILVER_TABLE}
|
||||
LIMIT 500
|
||||
""")
|
||||
print(f"Демо-таблица создана: {spark.table(DEMO_TABLE).count()} строк.")
|
||||
```
|
||||
|
||||
- **[code]** 8 мелких INSERT INTO (цикл, по ~100 строк каждый):
|
||||
|
||||
```python
|
||||
for i in range(8):
|
||||
spark.sql(f"""
|
||||
INSERT INTO {DEMO_TABLE}
|
||||
SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime,
|
||||
passenger_count, trip_distance, fare_amount, total_amount,
|
||||
pickup_borough, pickup_zone
|
||||
FROM {SILVER_TABLE}
|
||||
LIMIT 100
|
||||
""")
|
||||
print(f"INSERT {i+1}/8 выполнен.")
|
||||
|
||||
print(f"Итого строк: {spark.table(DEMO_TABLE).count()}")
|
||||
```
|
||||
|
||||
- **[code]** Файловая статистика через metadata-таблицу:
|
||||
|
||||
```python
|
||||
files_df = spark.sql(f"""
|
||||
SELECT file_path, file_format, record_count, file_size_in_bytes
|
||||
FROM {DEMO_TABLE}.files
|
||||
""")
|
||||
print(f"Количество data files: {files_df.count()}")
|
||||
files_df.show(truncate=40)
|
||||
```
|
||||
|
||||
- **[code]** Snapshot-история:
|
||||
|
||||
```python
|
||||
spark.sql(f"SELECT snapshot_id, committed_at, operation, summary FROM {DEMO_TABLE}.snapshots").show(truncate=False)
|
||||
```
|
||||
|
||||
- **[md]** Результат: 9 data files (1 от CTAS + 8 от INSERT), 9 snapshot-ов. Каждый INSERT создал отдельный маленький файл. В production-сценарии после недель инкрементальных загрузок таблица может накопить сотни и тысячи мелких файлов.
|
||||
|
||||
- **[md]** Для сравнения — посмотрим на silver-таблицу, которая была создана одним CTAS:
|
||||
|
||||
- **[code]** Файловая статистика silver:
|
||||
|
||||
```python
|
||||
silver_files = spark.sql(f"SELECT file_path, record_count, file_size_in_bytes FROM {SILVER_TABLE}.files")
|
||||
print(f"Silver: {silver_files.count()} data file(s)")
|
||||
silver_files.show(truncate=40)
|
||||
```
|
||||
|
||||
- **[md]** Silver имеет мало крупных файлов — проблемы мелких файлов нет. Compaction нужен не всегда, а после множества мелких записей. Далее — исправляем проблему на демо-таблице.
|
||||
|
||||
### Секция 3: Compaction — rewrite_data_files
|
||||
|
||||
- **[md]** `rewrite_data_files` объединяет мелкие data files в меньшее количество крупных. Аналогия: дефрагментация диска. Или `ALTER TABLE ... REORGANIZE` в Greenplum, который перестраивает сегменты AppendOnly-таблицы.
|
||||
|
||||
- **[code]** Файловая статистика ДО compaction (сохраняем для сравнения):
|
||||
|
||||
```python
|
||||
files_before = spark.sql(f"SELECT count(*) AS file_count, sum(file_size_in_bytes) AS total_bytes FROM {DEMO_TABLE}.files").first()
|
||||
print(f"До compaction: {files_before['file_count']} файлов, {files_before['total_bytes']} байт")
|
||||
```
|
||||
|
||||
- **[code]** Compaction:
|
||||
|
||||
```python
|
||||
spark.sql(f"CALL lakehouse.system.rewrite_data_files(table => 'default.taxi_maintenance_demo')")
|
||||
print("Compaction выполнен.")
|
||||
```
|
||||
|
||||
- **[code]** Файловая статистика ПОСЛЕ compaction:
|
||||
|
||||
```python
|
||||
files_after = spark.sql(f"SELECT count(*) AS file_count, sum(file_size_in_bytes) AS total_bytes FROM {DEMO_TABLE}.files").first()
|
||||
print(f"После compaction: {files_after['file_count']} файлов, {files_after['total_bytes']} байт")
|
||||
print(f"Было {files_before['file_count']} файлов → стало {files_after['file_count']}")
|
||||
```
|
||||
|
||||
- **[code]** Проверка данных: count не изменился.
|
||||
|
||||
```python
|
||||
print(f"Строк в таблице: {spark.table(DEMO_TABLE).count()}")
|
||||
```
|
||||
|
||||
- **[md]** Данные не изменились — только реорганизованы. Compaction создал новый snapshot с новыми, более крупными файлами. Но старые мелкие файлы всё ещё лежат в MinIO: они нужны для time travel к старым snapshot-ам. Чтобы освободить место — нужен `expire_snapshots`.
|
||||
|
||||
- **[code]** Snapshot-история: появился новый snapshot от compaction.
|
||||
|
||||
```python
|
||||
spark.sql(f"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots").show(truncate=False)
|
||||
```
|
||||
|
||||
### Секция 4: expire_snapshots — очистка истории
|
||||
|
||||
- **[md]** После compaction в таблице 10 snapshot-ов: 9 от INSERT-ов + 1 от compaction. Старые snapshot-ы ссылаются на мелкие файлы, которые уже не нужны для текущего чтения. `expire_snapshots` удаляет старые snapshot-ы из метаданных. Data files, на которые ссылались только удалённые snapshot-ы, по спецификации Iceberg тоже должны удаляться — но это зависит от конфигурации стенда (проверим на практике).
|
||||
|
||||
- **[md]** Важный компромисс: после expire_snapshots time travel к удалённым snapshot-ам невозможен. Это необратимо. В production нужно решить: сколько истории хранить? Обычно 10–30 дней. Для демо — оставим 2 последних snapshot-а.
|
||||
|
||||
- **[code]** Snapshot-история ДО expire (сохраняем count):
|
||||
|
||||
```python
|
||||
snapshots_before = spark.sql(f"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots")
|
||||
print(f"Snapshot-ов до expire: {snapshots_before.count()}")
|
||||
snapshots_before.show(truncate=False)
|
||||
|
||||
# Сохраняем ID первого snapshot для проверки time travel после expire
|
||||
first_snapshot_id = snapshots_before.orderBy("committed_at").first()["snapshot_id"]
|
||||
print(f"Самый старый snapshot: {first_snapshot_id}")
|
||||
```
|
||||
|
||||
- **[code]** expire_snapshots:
|
||||
|
||||
```python
|
||||
spark.sql(f"""
|
||||
CALL lakehouse.system.expire_snapshots(
|
||||
table => 'default.taxi_maintenance_demo',
|
||||
retain_last => 2
|
||||
)
|
||||
""")
|
||||
print("expire_snapshots выполнен.")
|
||||
```
|
||||
|
||||
- **[code]** Snapshot-история ПОСЛЕ expire:
|
||||
|
||||
```python
|
||||
snapshots_after = spark.sql(f"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots")
|
||||
print(f"Snapshot-ов после expire: {snapshots_after.count()}")
|
||||
snapshots_after.show(truncate=False)
|
||||
```
|
||||
|
||||
- **[code]** Попытка time travel к удалённому snapshot (ожидаем ошибку):
|
||||
|
||||
```python
|
||||
try:
|
||||
spark.sql(f"""
|
||||
SELECT count(*) FROM {DEMO_TABLE}
|
||||
VERSION AS OF {first_snapshot_id}
|
||||
""").show()
|
||||
print("Time travel сработал (snapshot не был удалён).")
|
||||
except Exception as e:
|
||||
print(f"Ожидаемая ошибка: {e}")
|
||||
print("Snapshot удалён — time travel невозможен.")
|
||||
```
|
||||
|
||||
- **[md]** Snapshot удалён, time travel невозможен. Это главное следствие expire_snapshots — необратимая потеря возможности отката к старым состояниям. В Модуле 7 мы использовали snapshot-ы для восстановления после ошибок — `expire_snapshots` лишает этой возможности для удалённых snapshot-ов. Поэтому expire нужно запускать обдуманно: не слишком агрессивно (потеря страховки), не слишком редко (рост метаданных и хранилища).
|
||||
|
||||
- **[md]** Примечание: физическое удаление data files из MinIO после expire зависит от конфигурации стенда. Если файлы остались — для их очистки существует отдельная процедура `remove_orphan_files`, которая удаляет файлы, не принадлежащие ни одному snapshot-у. В штатном режиме orphan-файлы не возникают (кроме как после expire или упавших записей). В этом курсе `remove_orphan_files` не демонстрируется.
|
||||
|
||||
### Секция 5: Порядок обслуживания и типичные ошибки
|
||||
|
||||
- **[md]** Правильный порядок обслуживания Iceberg-таблицы:
|
||||
|
||||
| Шаг | Операция | Что делает | Что будет, если пропустить |
|
||||
|---|---|---|---|
|
||||
| 1 | `rewrite_data_files` | Объединяет мелкие файлы в крупные | Чтение остаётся медленным |
|
||||
| 2 | `expire_snapshots` | Удаляет старые snapshot-ы (и их файлы, если конфигурация позволяет) | Метаданные и хранилище растут без ограничений |
|
||||
|
||||
Если сделать наоборот (сначала expire, потом compaction): expire удалит snapshot-ы, но мелкие файлы останутся (текущий snapshot всё ещё на них ссылается). Compaction создаст новые файлы, но старые мелкие станут orphan-файлами — нужен будет дополнительный `remove_orphan_files`.
|
||||
|
||||
- **[md]** Типичные ошибки обслуживания:
|
||||
|
||||
**Ошибка 1: Никогда не запускать compaction.** Таблица после месяцев инкрементальных загрузок имеет тысячи мелких файлов. Чтение занимает минуты вместо секунд.
|
||||
|
||||
**Ошибка 2: expire_snapshots с `retain_last => 1` сразу после ошибки.** Студент вносит ошибку в данные (Модуль 7), затем запускает expire_snapshots, оставляя только последний (ошибочный) snapshot. Rollback к предыдущему состоянию невозможен. Правило: перед expire проверь, что текущее состояние таблицы корректно.
|
||||
|
||||
**Ошибка 3: Запускать обслуживание на каждый INSERT.** Compaction и expire_snapshots — тяжёлые операции. Запускать их после каждой записи — расточительно. В production: по расписанию (раз в день/неделю), а не на каждый батч.
|
||||
|
||||
### Секция 6: Параллель с Greenplum AppendOnly
|
||||
|
||||
- **[md]** Развернутое сравнение для студентов с опытом Greenplum:
|
||||
|
||||
| Аспект | Greenplum AppendOnly | Iceberg |
|
||||
|---|---|---|
|
||||
| Как накапливается «мусор» | UPDATE/DELETE оставляют мёртвые строки в сегментах | Множество INSERT создают мелкие data files |
|
||||
| Влияние на чтение | Сканирование мёртвых строк замедляет запросы | Открытие множества мелких файлов замедляет запросы |
|
||||
| Компактификация | `ALTER TABLE ... REORGANIZE` (перестройка сегментов) | `rewrite_data_files` (объединение файлов) |
|
||||
| Очистка | `VACUUM` (удаление мёртвых строк) | `expire_snapshots` (удаление старых snapshot-ов; файлы — при поддержке конфигурацией) |
|
||||
| Автоматизация | `autovacuum` для heap-таблиц, ручной VACUUM для AO | Ручной запуск, автоматизация через Airflow/cron |
|
||||
| Просмотр состояния | `pg_stat_all_tables`, `gp_toolkit.gp_bloat_diag` | `table.files`, `table.snapshots` (metadata-таблицы Iceberg) |
|
||||
| Time travel после очистки | Невозможен (нет встроенной истории) | Невозможен для удалённых snapshot-ов, возможен для оставшихся |
|
||||
|
||||
- **[md]** Ключевое сходство: ни одна система не обслуживает себя полностью автоматически в базовой конфигурации. И в Greenplum, и в Iceberg — это задача инженера или оркестратора. Ключевое отличие: Iceberg даёт встроенную историю (snapshot-ы) и контролируемую очистку (`expire_snapshots` с `retain_last`), чего нет в Greenplum.
|
||||
|
||||
### Секция 7: Самостоятельное задание — обслуживание
|
||||
|
||||
- **[md]** Две задачи для закрепления навыков обслуживания.
|
||||
|
||||
**Задача 1 (анализ silver).** Посмотри файловую статистику silver-таблицы (`lakehouse.silver.nyc_taxi_yellow`): количество data files, средний размер файла, количество snapshot-ов. Сравни с демо-таблицей до compaction. Нужна ли silver compaction? Ответь в markdown-ячейке и объясни — при каких условиях silver потребовала бы compaction?
|
||||
|
||||
**Задача 2 (практика).** Создай таблицу `lakehouse.default.taxi_compaction_practice` через CTAS (500 строк из silver). Выполни 5 INSERT INTO по 200 строк. Посмотри файловую статистику, выполни compaction и expire_snapshots (retain_last => 1). Проверь результат. Удали таблицу.
|
||||
|
||||
- **[code]** Пустая ячейка: `# Ваш код: файловая статистика silver (файлы, размеры, snapshot-ы)`
|
||||
- **[md]** Пустая markdown-ячейка: `Ваш ответ на Задачу 1: нужна ли compaction для silver? При каких условиях потребовалась бы? ...`
|
||||
- **[code]** Пустая ячейка: `# Ваш код: создание taxi_compaction_practice + 5 INSERT`
|
||||
- **[code]** Пустая ячейка: `# Ваш код: файловая статистика до compaction`
|
||||
- **[code]** Пустая ячейка: `# Ваш код: compaction + expire_snapshots`
|
||||
- **[code]** Пустая ячейка: `# Ваш код: файловая статистика после + DROP TABLE`
|
||||
|
||||
### Секция 8: Удаление демо-таблицы
|
||||
|
||||
- **[md]** Демо-таблица для обслуживания больше не нужна.
|
||||
- **[code]** `spark.sql(f"DROP TABLE IF EXISTS {DEMO_TABLE}")`. Вывод подтверждения.
|
||||
|
||||
### Секция 9: Финальная практика — сквозной end-to-end сценарий
|
||||
|
||||
- **[md]** Финальная практика курса. Студент самостоятельно проходит полный цикл Lakehouse-пайплайна: от raw-данных до обслуживания таблицы. Все навыки из Модулей 1–8 в одном сценарии.
|
||||
|
||||
Работа выполняется в отдельном namespace `lakehouse.final`, чтобы не затрагивать основные таблицы курса. После завершения — cleanup.
|
||||
|
||||
Подсказки: путь к raw-данным — `s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet` (Модуль 3). Для контроля объёма используй `LIMIT 5000`.
|
||||
|
||||
- **[md]** **Сценарий: 8 шагов.**
|
||||
|
||||
**Шаг 1 (namespace).** Создай namespace `lakehouse.final`.
|
||||
|
||||
**Шаг 2 (bronze).** Прочитай raw parquet из MinIO (`s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet`, LIMIT 5000 строк) и создай таблицу `lakehouse.final.trips_bronze` через CTAS. Это bronze: данные «как есть», без трансформаций.
|
||||
|
||||
**Шаг 3 (silver).** Создай `lakehouse.final.trips_silver` из bronze с тремя трансформациями:
|
||||
- Фильтр: `fare_amount >= 0` (убрать аномалии)
|
||||
- Нормализация: `passenger_count` → INT с обработкой NULL (`coalesce` + `cast`)
|
||||
- Обогащение: вычисляемая колонка `trip_duration_minutes`
|
||||
|
||||
**Шаг 4 (Trino).** Прочитай `lakehouse.final.trips_silver` из Trino. Сравни count с результатом из Spark.
|
||||
|
||||
**Шаг 5 (schema evolution).** Добавь колонку `processed_at TIMESTAMP` к `trips_silver` через `ALTER TABLE ADD COLUMNS`. Проверь из Trino, что колонка видна.
|
||||
|
||||
**Шаг 6 (snapshot-ы).** Выполни 3 INSERT INTO `trips_silver` по ~300 строк из `trips_bronze`. Каждый INSERT должен включать те же трансформации, что и в шаге 3, плюс `current_timestamp() AS processed_at` для новой колонки из шага 5. Подсказка: если не указать `processed_at`, Spark выдаст ошибку несовпадения числа колонок; альтернативный вариант — использовать явный список target-колонок в `INSERT INTO trips_silver (col1, col2, ...)`. Создай snapshot-историю. Посмотри файловую статистику.
|
||||
|
||||
**Шаг 7 (обслуживание).** Выполни compaction (`rewrite_data_files`), затем `expire_snapshots` (retain_last => 1). Проверь: файлов стало меньше, snapshot-ов осталось 1.
|
||||
|
||||
**Шаг 8 (cleanup).** DROP TABLE `trips_silver`, DROP TABLE `trips_bronze`, DROP NAMESPACE `lakehouse.final`.
|
||||
|
||||
- **[code]** Пустая ячейка: `# Шаг 1: CREATE NAMESPACE`
|
||||
- **[code]** Пустая ячейка: `# Шаг 2: прочитай raw и создай trips_bronze`
|
||||
- **[code]** Пустая ячейка: `# Шаг 2: проверка trips_bronze (count, printSchema)`
|
||||
- **[code]** Пустая ячейка: `# Шаг 3: создай trips_silver с трансформациями`
|
||||
- **[code]** Пустая ячейка: `# Шаг 3: проверка trips_silver (count, проверка фильтра)`
|
||||
- **[code]** Пустая ячейка: `# Шаг 4: чтение trips_silver из Trino`
|
||||
- **[code]** Пустая ячейка: `# Шаг 5: ALTER TABLE ADD COLUMNS`
|
||||
- **[code]** Пустая ячейка: `# Шаг 5: проверка из Trino (DESCRIBE)`
|
||||
- **[code]** Пустая ячейка: `# Шаг 6: 3 INSERT INTO trips_silver`
|
||||
- **[code]** Пустая ячейка: `# Шаг 6: файловая статистика и snapshot-ы`
|
||||
- **[code]** Пустая ячейка: `# Шаг 7: compaction + expire_snapshots`
|
||||
- **[code]** Пустая ячейка: `# Шаг 7: проверка результата`
|
||||
- **[code]** Пустая ячейка: `# Шаг 8: cleanup (DROP TABLE, DROP NAMESPACE)`
|
||||
|
||||
### Секция 10: Финальный checkpoint
|
||||
|
||||
- **[md]** **Часть A — Обслуживание таблиц (Модуль 8):**
|
||||
1. Что такое проблема мелких файлов и когда она возникает?
|
||||
2. Что делает `rewrite_data_files`? Удаляет ли он старые файлы?
|
||||
3. Что делает `expire_snapshots`? Какой компромисс он создаёт?
|
||||
4. В каком порядке нужно выполнять compaction и expire_snapshots? Почему?
|
||||
5. Как обслуживание Iceberg-таблиц соотносится с VACUUM/REORGANIZE в Greenplum?
|
||||
|
||||
- **[md]** **Часть B — Весь курс (итоговые вопросы):**
|
||||
1. Назови три роли в архитектуре Lakehouse и какие компоненты стенда их выполняют.
|
||||
2. Почему Spark может записать данные, а Trino прочитать ту же таблицу без копирования?
|
||||
3. Чем raw отличается от bronze? Чем bronze отличается от silver?
|
||||
4. Что такое snapshot в Iceberg? Чем он отличается от бэкапа в PostgreSQL?
|
||||
5. Назови три безопасные и три опасные операции с Iceberg-таблицей.
|
||||
6. Зачем нужны compaction и expire_snapshots в production-сценарии?
|
||||
|
||||
### Секция 11: Завершение
|
||||
|
||||
- **[md]** Итог модуля:
|
||||
- Compaction (`rewrite_data_files`) — решает проблему мелких файлов, не меняя данные.
|
||||
- `expire_snapshots` — очищает старые snapshot-ы и файлы, но лишает возможности time travel.
|
||||
- Порядок: compaction → expire_snapshots.
|
||||
- Обслуживание Iceberg похоже на обслуживание Greenplum AppendOnly: обе системы требуют периодической заботы.
|
||||
|
||||
- **[md]** Итог курса. Что студент теперь умеет:
|
||||
1. Поднимать и диагностировать локальный Lakehouse-стенд (Модуль 1).
|
||||
2. Объяснять роли storage, catalog, compute (Модуль 2).
|
||||
3. Загружать raw-данные и проверять схему (Модуль 3).
|
||||
4. Создавать и заполнять Iceberg-таблицы (Модуль 4).
|
||||
5. Строить воспроизводимый поток raw → bronze → silver (Модуль 5).
|
||||
6. Читать одну таблицу из Spark и Trino (Модуль 6).
|
||||
7. Безопасно изменять таблицы: schema evolution, time travel, rollback (Модуль 7).
|
||||
8. Обслуживать таблицы: compaction, expire_snapshots (Модуль 8).
|
||||
|
||||
Это базовый набор навыков для безопасной работы в Lakehouse-стенде. Дальше — production: партиционирование, MERGE, streaming, Airflow, governance. Но фундамент заложен.
|
||||
|
||||
- **[code]** `spark.stop()`.
|
||||
|
||||
### Дизайн-решения по ноутбуку
|
||||
|
||||
- **Исполняемый формат:** Spark SQL для compaction и expire_snapshots (stored procedures). Trino-запросы через Python `trino` клиент для downstream-верификации в финальной практике.
|
||||
- **DBeaver-подсказки:** markdown-заметки рядом с Trino-ячейками (паттерн Модуля 6).
|
||||
- **Helper-код:** `trino_query(sql)` — переиспользование из Модуля 6. Файловая статистика через metadata-таблицы Iceberg (`.files`, `.snapshots`).
|
||||
- **Cleanup:** DROP демо-таблицы `taxi_maintenance_demo`. Финальная практика: DROP таблиц в namespace `final`, DROP namespace. Silver и bronze сохраняются.
|
||||
- **Порядок:** проблема мелких файлов → compaction → expire_snapshots → Greenplum-параллель → ошибки → самостоятельное задание (обслуживание) → финальная практика (end-to-end) → checkpoint (модуль + курс) → итог курса.
|
||||
|
||||
## Checkpoint
|
||||
|
||||
Студент должен уметь:
|
||||
|
||||
- объяснить проблему мелких файлов и показать файловую статистику через metadata-таблицы;
|
||||
- выполнить compaction и проверить, что файлов стало меньше, а данные не изменились;
|
||||
- выполнить expire_snapshots и объяснить, почему time travel к удалённым snapshot-ам невозможен;
|
||||
- назвать правильный порядок обслуживания и объяснить, почему он важен;
|
||||
- сопоставить обслуживание Iceberg с обслуживанием Greenplum AppendOnly;
|
||||
- самостоятельно выполнить полный цикл: raw → bronze → silver → Trino → schema evolution → обслуживание;
|
||||
- ответить на итоговые вопросы курса по архитектуре, слоям, snapshot-ам и безопасным операциям.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `notebooks/08_maintenance_and_final_lab.ipynb` выполняется сверху вниз после прохождения Модуля 6 без внешних зависимостей (DBeaver не обязателен, Модуль 7 рекомендуется, но не обязателен).
|
||||
- Ноутбук содержит: демонстрацию проблемы мелких файлов, compaction (`rewrite_data_files`), expire_snapshots, финальную сквозную практику.
|
||||
- Методика курса: объяснение → демонстрация → самостоятельное повторение → checkpoint.
|
||||
- Паттерны Модулей 1–7: SparkSession без `.master()`, `setLogLevel("ERROR")`, `trino_query()`.
|
||||
- Демо-таблица `taxi_maintenance_demo` создаётся и удаляется внутри ноутбука.
|
||||
- Финальная практика в отдельном namespace `lakehouse.final` с полным cleanup в конце.
|
||||
- При отсутствии silver — assert с отсылкой к Модулю 5. При отсутствии raw-данных в MinIO — assert с отсылкой к Модулю 3.
|
||||
- Текст на русском с параллелями к PostgreSQL/Greenplum.
|
||||
- Финальный checkpoint покрывает как модуль, так и весь курс.
|
||||
- Silver и bronze сохраняются после выполнения ноутбука.
|
||||
- `plans/README.md` содержит строку Модуля 8.
|
||||
|
||||
## Риски
|
||||
|
||||
- **Синтаксис `rewrite_data_files`.** Точный синтаксис stored procedure зависит от версии Iceberg и Spark. Вариант: `CALL lakehouse.system.rewrite_data_files(table => 'default.taxi_maintenance_demo')`. Возможны различия в именованных vs позиционных параметрах. Нужна проверка на стенде.
|
||||
- **Синтаксис `expire_snapshots`.** Возможные параметры: `older_than` (TIMESTAMP), `retain_last` (INT). По умолчанию `older_than` = 5 дней назад, что не подходит для демо (snapshot-ы созданы минуты назад). Нужно явно передать `retain_last => 2` и, возможно, `older_than => current_timestamp()`. Проверить на стенде, работает ли `retain_last` без `older_than`.
|
||||
- **expire_snapshots не удаляет файлы.** В некоторых конфигурациях `expire_snapshots` может не удалять data files автоматически. В этом случае нужно добавить `remove_orphan_files` или явно упомянуть, что файлы удаляются отдельно. Проверить на стенде.
|
||||
- **Время выполнения цикла INSERT.** 8 INSERT-ов по ~100 строк из silver: каждый INSERT — отдельный Spark job. На стенде это может занять 2–5 минут. Предупреждение в markdown.
|
||||
- **Время выполнения compaction.** `rewrite_data_files` перечитывает и перезаписывает данные. На демо-таблице (~1300 строк) — секунды. На silver (миллионы строк) — потенциально минуты. Предупреждение в markdown.
|
||||
- **Финальная практика: путь к raw-данным.** Путь `s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet` соответствует маршруту загрузки из Модуля 3. Если студент по какой-то причине загрузил файлы по другому пути, он увидит ошибку при чтении. Добавить подсказку в markdown: «Если путь отличается от указанного, посмотри Модуль 3.»
|
||||
- **Финальная практика: объём INSERT-ов.** При LIMIT 5000 для bronze и 3 INSERT по 300 строк в silver — объём данных минимален. Compaction может быть no-op (файлы и так небольшие). Решение: в инструкции явно указать, что цель — увидеть изменение количества файлов и snapshot-ов, а не добиться значимого улучшения производительности.
|
||||
- **Идемпотентность.** `CREATE OR REPLACE TABLE` для демо-таблицы — идемпотентна. Финальная практика: если студент запускает повторно, namespace и таблицы могут уже существовать. `CREATE NAMESPACE IF NOT EXISTS` и `CREATE OR REPLACE TABLE` обеспечивают идемпотентность. DROP в конце — `IF EXISTS`.
|
||||
- **Независимость от Модуля 7.** Ноутбук не должен ссылаться на колонку `loaded_at` или результаты Модуля 7 как на обязательные предусловия. Snapshot-концепции кратко повторяются в контексте expire_snapshots.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- `remove_orphan_files` как отдельная демонстрация — упоминается, не демонстрируется.
|
||||
- `rewrite_manifests` — оптимизация manifest-файлов, advanced-тема.
|
||||
- Партиционирование и partition evolution — PRD: вне v1.
|
||||
- Sort order и write distribution mode — advanced compaction options.
|
||||
- Автоматизация обслуживания через Airflow/cron — упоминается как production-практика, не реализуется.
|
||||
- Gold-слой в финальной практике — PRD: вне v1.
|
||||
- Performance benchmarks (замеры времени чтения до/после compaction) — интересно, но выходит за рамки «базового обслуживания».
|
||||
- `MERGE`, row-level `DELETE`, `UPDATE` — PRD: вне v1.
|
||||
- Deep dive в Iceberg metadata JSON / manifest files — PRD: вне v1.
|
||||
Reference in New Issue
Block a user