merge: курс «Lakehouse без магии» v1
Вливает ветку docs/lab-materials: 8 модулей, стартовая документация, вспомогательные материалы (glossary, mentor notes, шпаргалка).
This commit is contained in:
commit
df39ff9e64
35 files changed
+8492
-682
No files matched your search
@@ -21,3 +21,9 @@ venv/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Dataset (downloaded by student, not stored in repo)
|
||||
/data/*
|
||||
!/data/nyc_taxi/
|
||||
/data/nyc_taxi/*
|
||||
!/data/nyc_taxi/.gitkeep
|
||||
@@ -1,56 +1,62 @@
|
||||
# Repository Guidelines
|
||||
|
||||
This repository contains a small teaching Lakehouse stack (Spark + Trino + Iceberg + MinIO) intended for demos and mentoring.
|
||||
Этот репозиторий теперь представляет собой не только локальный Lakehouse-стенд, но и каркас учебного курса `Lakehouse без магии`.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
В `AGENTS.md` держим только стабильные правила для автоматизированных правок. Изменчивый контекст проекта, карта репозитория и правила синхронизации вынесены в [docs/maintainer_guide.md](docs/maintainer_guide.md).
|
||||
|
||||
- `docker-compose.yml`: orchestrates Spark, Trino, MinIO, PostgreSQL, and Jupyter.
|
||||
- `spark/`: Spark image (`Dockerfile`) and `spark-defaults.conf`.
|
||||
- `jupyter/`: Jupyter image (`Dockerfile`) based on the Spark image.
|
||||
- `trino/`: catalog config, e.g. `trino/catalog/lakehouse.properties`.
|
||||
- `src/`: PySpark and SQL examples split by engine (`src/spark/...`, `src/trino/...`).
|
||||
- `notebooks/`: demo notebooks (mounted into Jupyter at `/opt/work`).
|
||||
## Канонические документы
|
||||
|
||||
Keep new examples in `src/` or `notebooks/`, and avoid mixing configuration and code.
|
||||
- `README.md` — верхнеуровневое описание репозитория, архитектуры и точек входа.
|
||||
- `START_HERE.md` — первый маршрут для студента до запуска ноутбуков.
|
||||
- `docs/stack_reference.md` — технический reference по сервисам, портам, доступу и smoke-тестам.
|
||||
- `docs/course_prd.md` — рамки курса, learning outcomes, scope и out of scope.
|
||||
- `docs/course_program.md` — модульная структура курса и состав учебных материалов.
|
||||
- `docs/glossary.md` — справочник терминов курса.
|
||||
- `docs/mentor_notes.md` — заметки для ведения курса с ментором (опционально).
|
||||
- `docs/maintainer_guide.md` — карта репозитория и правила синхронизации изменений.
|
||||
- `plans/` — внутренние living docs; не источник истины для студентского маршрута.
|
||||
- `docs/archive/` — архивные материалы; не использовать как актуальную документацию без явного запроса.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
## Структура репозитория
|
||||
|
||||
Run from the repo root:
|
||||
- `docker-compose.yml`, `spark/`, `trino/`, `jupyter/` — исполняемый стенд.
|
||||
- `notebooks/`, `src/` — учебные материалы и вспомогательные примеры.
|
||||
- `docs/`, `START_HERE.md`, `README.md` — входная и методическая документация.
|
||||
- `plans/` — внутреннее планирование.
|
||||
|
||||
- `docker compose build`: build custom Spark and Jupyter images.
|
||||
- `docker compose up -d`: start the full stack in the background.
|
||||
- `docker compose ps`: check container status.
|
||||
- `docker compose down -v`: stop the stack and remove volumes (for a clean slate).
|
||||
Новые учебные примеры держи в `src/` или `notebooks/`, а конфигурацию стенда не смешивай с учебным контентом.
|
||||
|
||||
Use `docker compose logs -f <service>` when debugging (`spark-master`, `trino`, `minio`, etc.).
|
||||
## Правила изменений
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Сначала меняй канонический документ для соответствующего слоя, потом синхронизируй связанные ссылки и упоминания.
|
||||
- Если меняются сервисы, порты, имена контейнеров, образы или команды запуска, обновляй как минимум `README.md`, `START_HERE.md`, `docs/stack_reference.md` и затронутые примеры.
|
||||
- Если меняются scope курса, learning outcomes или модульная структура, обновляй `docs/course_prd.md` и `docs/course_program.md`, а не только `plans/`.
|
||||
- Поддерживай согласованный маршрут `README -> START_HERE -> docs/course_program.md -> notebooks/src`.
|
||||
- Не дублируй крупные фрагменты между документами, если можно сослаться на канонический файл.
|
||||
- Legacy-материалы явно помечай как архивные и не возвращай их в основной маршрут без явного запроса.
|
||||
|
||||
- Python: PEP 8, 4-space indentation, snake_case for functions, lower_snake_case for files (e.g. `spark_join_demo.py`).
|
||||
- SQL: uppercase keywords, `schema.table` naming, short English identifiers; comments may be in Russian.
|
||||
- Compose/Docker: service names kebab-case (`spark-master`), env vars UPPER_SNAKE_CASE.
|
||||
- Prefer small, focused examples; reuse helpers from `src/` in notebooks where possible.
|
||||
## Стиль и содержание
|
||||
|
||||
## Testing Guidelines
|
||||
- Сохраняй русский язык в student-facing документации, если задача не про перевод.
|
||||
- Предпочитай небольшие, сфокусированные примеры и не усложняй стенд без явного запроса.
|
||||
- Держи ноутбуки понятными для самостоятельного прохождения; переиспользуемую логику выноси в `src/`.
|
||||
- Не добавляй обязательные шаги со скачиванием данных прямо из ноутбуков, если это можно оформить через onboarding-документацию.
|
||||
|
||||
There is no formal automated test suite yet. Validate changes by:
|
||||
## Проверка изменений
|
||||
|
||||
- building and starting the stack, then
|
||||
- running `src/spark/cluster_smoke.py` or the SQL in `src/spark/iceberg_demo.sql`,
|
||||
- opening `notebooks/spark-basic-test.ipynb` in Jupyter and checking it runs end-to-end.
|
||||
- Для изменений только в документации проверь согласованность ссылок, терминов и маршрута прохождения.
|
||||
- Для изменений стенда сначала используй лёгкую валидацию вроде `docker compose config`.
|
||||
- Если меняется поведение стенда или учебных примеров, по возможности проверь сценарии из `src/spark/cluster_smoke.py`, `src/spark/iceberg_smoke.py`, `src/trino/iceberg_smoke.sql` или `notebooks/01_environment_and_smoke_test.ipynb`.
|
||||
- Если полная проверка не выполнялась, укажи это явно.
|
||||
|
||||
If you add tests, prefer `pytest` under `tests/` and mark slow, integration-heavy tests clearly.
|
||||
## Commit & PR Notes
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
- Commits: short, descriptive subject in Russian or English, present tense (e.g. `Спарк запускается, создаётся тестовая таблица`, `Add Iceberg join demo`).
|
||||
- Keep changes small and focused; update `README.md` when behavior, ports, or images change.
|
||||
- PRs should describe the problem, the solution, and any impact on local setup; include screenshots (Trino UI, MinIO, Jupyter) when UI changes are relevant.
|
||||
- Коммиты: короткий subject в настоящем времени, на русском или английском.
|
||||
- Изменения поведения, портов, маршрута входа или состава материалов обычно требуют обновления документации рядом с кодом.
|
||||
- Избегай больших рефакторингов структуры без явного запроса пользователя.
|
||||
|
||||
## Agent-Specific Instructions
|
||||
|
||||
When editing as an automated agent:
|
||||
|
||||
- Respect this file and avoid large refactors without an explicit request.
|
||||
- Preserve Russian user-facing text unless the change is explicitly about translation.
|
||||
- Prefer minimal changes that keep the demo simple and robust for newcomers.
|
||||
- Уважай существующие изменения пользователя и не откатывай их без запроса.
|
||||
- Предпочитай минимальные изменения, которые делают курс проще и устойчивее для новичка.
|
||||
- Если непонятно, какой маршрут сейчас актуален, доверяй `START_HERE.md` и `docs/course_program.md`, а не старым HOWTO или планам.
|
||||
@@ -1,391 +1,96 @@
|
||||
# Учебный Lakehouse-стенд (Trino + Spark + Iceberg + MinIO)
|
||||
# Lakehouse без магии
|
||||
|
||||
Учебный стенд для демонстрации **Lakehouse-архитектуры** на одном ноутбуке:
|
||||
Практический курс, после которого ты будешь уверенно работать с Lakehouse-стеком: поднимать стенд, строить пайплайн `raw -> bronze -> silver`, читать одну таблицу из двух движков и не бояться изменений в данных.
|
||||
|
||||
- объектное хранилище S3-класса (MinIO),
|
||||
- движок запросов Trino,
|
||||
- Spark для batch/ETL и интерактивных экспериментов,
|
||||
- формат таблиц Iceberg,
|
||||
- JDBC-каталог (PostgreSQL) для метаданных Iceberg в Trino,
|
||||
- конфиг Spark, заточенный под работу с Iceberg + S3.
|
||||
## Что ты получишь
|
||||
|
||||
Стенд ориентирован на обучение менти и быструю демонстрацию концепции Lakehouse: разделение **storage / compute / catalog** без лишней обвязки (Hive Metastore, полноценный Hadoop-кластер и т.п.).
|
||||
После прохождения 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.
|
||||
|
||||
## Архитектура
|
||||
Курс рассчитан на `~12-15 часов` самостоятельной работы. Каждый модуль: объяснение, демонстрация, самостоятельное задание, checkpoint.
|
||||
|
||||
## Стек
|
||||
|
||||
Всё работает локально в Docker. Никаких облаков, внешних зависимостей и регистраций.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Storage
|
||||
M[(MinIO<br/>S3-compatible)]
|
||||
end
|
||||
graph TB
|
||||
U[Student / engineer]
|
||||
|
||||
subgraph Catalog
|
||||
P[(PostgreSQL<br/>Iceberg JDBC catalog)]
|
||||
subgraph Access
|
||||
J[JupyterLab<br/>notebooks / PySpark]
|
||||
Q[Trino UI / CLI<br/>ad hoc SQL]
|
||||
end
|
||||
|
||||
subgraph Compute
|
||||
S[Spark 3.5.1<br/>PySpark / SQL]
|
||||
S[Spark cluster<br/>master + workers]
|
||||
T[Trino 478<br/>Iceberg connector]
|
||||
end
|
||||
|
||||
S <--> M
|
||||
S <--> P
|
||||
T <--> M
|
||||
T <--> P
|
||||
subgraph Shared Services
|
||||
P[(PostgreSQL<br/>Iceberg JDBC catalog)]
|
||||
M[(MinIO<br/>S3-compatible)]
|
||||
end
|
||||
|
||||
U --> J
|
||||
U --> Q
|
||||
J --> S
|
||||
Q --> T
|
||||
S --> P
|
||||
S --> M
|
||||
T --> P
|
||||
T --> M
|
||||
```
|
||||
|
||||
**Основные идеи:**
|
||||
| Компонент | Роль в стенде |
|
||||
| --- | --- |
|
||||
| `MinIO` | Storage — хранит данные и служебные файлы Iceberg |
|
||||
| `PostgreSQL` | Catalog — метаданные JDBC-каталога `lakehouse` |
|
||||
| `Spark` | Compute — ETL, запись и чтение Iceberg-таблиц |
|
||||
| `Trino` | Compute — ad hoc SQL, чтение тех же таблиц |
|
||||
| `JupyterLab` | Точка входа — практические ноутбуки курса |
|
||||
|
||||
* **Данные** (Parquet-файлы + служебные каталоги Iceberg) лежат в бакете MinIO (`s3a://lakehouse/warehouse/...`).
|
||||
* **Метаданные** Iceberg хранятся в PostgreSQL (JDBC-каталог `lakehouse`).
|
||||
* **Spark и Trino** используют один и тот же JDBC-каталог: таблицы, созданные в Spark, доступны в Trino, и наоборот.
|
||||
## С чего начать
|
||||
|
||||
---
|
||||
1. Открой [START_HERE.md](./START_HERE.md) — запуск стенда, диагностика, первый ноутбук.
|
||||
2. Пройди `notebooks/01_environment_and_smoke_test.ipynb`.
|
||||
3. Дальше по порядку: [docs/course_program.md](./docs/course_program.md).
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
1. Установить Docker и Docker Compose (см. раздел «Требования» ниже).
|
||||
2. В корне репозитория выполнить:
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. Открыть основные интерфейсы:
|
||||
|
||||
- Trino Web UI: `http://localhost:8090`
|
||||
- MinIO Console: `http://localhost:9001`
|
||||
- JupyterLab (если включён): `http://localhost:8888`
|
||||
|
||||
4. Для пошаговых лабораторных работ см. файл `HOWTO.md`.
|
||||
|
||||
---
|
||||
|
||||
## Состав репозитория
|
||||
|
||||
* `docker-compose.yml` — описание всех сервисов стенда:
|
||||
|
||||
* Spark master / worker(ы) на базе кастомного образа `spark-iceberg`,
|
||||
* Trino,
|
||||
* MinIO (S3-совместимое хранилище),
|
||||
* PostgreSQL под Iceberg JDBC-каталог,
|
||||
* (опционально) Jupyter / notebook для PySpark.
|
||||
* `spark/Dockerfile` — сборка кастомного образа **Spark** с зависимостями:
|
||||
|
||||
* `hadoop-aws`, `aws-java-sdk`,
|
||||
* библиотека Iceberg нужной версии,
|
||||
* `pyspark`, `pyarrow` и базовый набор инструментов.
|
||||
* `jupyter/Dockerfile` — образ JupyterLab на базе Spark-образа.
|
||||
* `spark/spark-defaults.conf` — конфигурация Spark для работы с:
|
||||
|
||||
* Iceberg-каталогом `lakehouse` (тип `jdbc`, метаданные в Postgres),
|
||||
* MinIO через `s3a://`,
|
||||
* расширениями `IcebergSparkSessionExtensions`.
|
||||
* `src/` — учебные примеры для Spark и Trino:
|
||||
|
||||
* `src/spark/cluster_smoke.py` — проверка, что кластер жив (Spark master/worker).
|
||||
* `src/spark/iceberg_smoke.py` — Spark создаёт Iceberg-таблицу и читает её.
|
||||
* `src/spark/iceberg_demo.sql` — пример создания Iceberg-таблицы через Spark SQL.
|
||||
* `src/trino/iceberg_smoke.sql` — Trino читает таблицу, созданную в Spark.
|
||||
* `trino/catalog/lakehouse.properties` — конфиг каталога Trino `lakehouse`:
|
||||
|
||||
* коннектор `iceberg`,
|
||||
* `jdbc`-каталог (PostgreSQL),
|
||||
* доступ к MinIO как к S3-хранилищу.
|
||||
|
||||
---
|
||||
|
||||
## Требования
|
||||
|
||||
- Docker и Docker Compose.
|
||||
- Порты по умолчанию должны быть свободны (см. таблицу «Сервисы и порты» ниже).
|
||||
|
||||
### Сервисы и порты
|
||||
|
||||
| Сервис | Контейнер | Порт (host → container) | Назначение / UI |
|
||||
|------------------------|-------------------|-------------------------|-------------------------------------|
|
||||
| Trino | `trino` | `8090 → 8080` | Web UI Trino |
|
||||
| MinIO API | `minio` | `9000 → 9000` | S3 endpoint |
|
||||
| MinIO Console | `minio` | `9001 → 9001` | Веб-консоль MinIO |
|
||||
| PostgreSQL (каталог) | `postgres-iceberg`| `5432 → 5432` | Доступ для psql/DBeaver и т.п. |
|
||||
| Spark master UI | `spark-master` | `8080 → 8080` | Web UI мастера Spark |
|
||||
| Spark worker-1 UI | `spark-worker-1` | `8081 → 8081` | Web UI первого воркера |
|
||||
| Spark worker-2 UI | `spark-worker-2` | `8082 → 8081` | Web UI второго воркера (host 8082) |
|
||||
| JupyterLab | `jupyter` | `8888 → 8888` | JupyterLab с PySpark |
|
||||
|
||||
---
|
||||
|
||||
## Сборка и запуск
|
||||
|
||||
Все команды в этом разделе выполняются из корня репозитория.
|
||||
|
||||
### 1. Собрать образы
|
||||
Краткий запуск из корня репозитория:
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
```
|
||||
|
||||
Будут собраны кастомные образы Spark/Jupyter с зависимостями Iceberg, S3A, JDBC-драйвером Postgres и Python-библиотеками.
|
||||
|
||||
### 2. Поднять стенд
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Что происходит при старте:
|
||||
|
||||
* MinIO поднимается с root-пользователем/паролем (по умолчанию смотри в `docker-compose.yml`, обычно `minioadmin/minioadmin`), init-контейнер создаёт бакет `lakehouse`.
|
||||
* PostgreSQL под каталог Iceberg создаёт БД и пользователя (значения — в `docker-compose.yml` / `.env`).
|
||||
* Trino стартует с каталогом `lakehouse`, описанным в `lakehouse.properties`.
|
||||
* Spark master/worker получают конфиг из `spark-defaults.conf` (общий JDBC-каталог Iceberg + MinIO).
|
||||
|
||||
Проверить статус:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### 3. Остановить стенд и очистить данные
|
||||
После старта:
|
||||
|
||||
Чтобы остановить все сервисы и удалить данные в MinIO/PostgreSQL (Docker volumes), можно выполнить:
|
||||
| UI | Адрес |
|
||||
| --- | --- |
|
||||
| JupyterLab | `http://localhost:8888` |
|
||||
| Spark Master | `http://localhost:8080` |
|
||||
| Trino | `http://localhost:8090` |
|
||||
| MinIO Console | `http://localhost:9001` |
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
## Документы для прохождения
|
||||
|
||||
---
|
||||
- [START_HERE.md](./START_HERE.md) — первый маршрут: prerequisites, запуск, диагностика.
|
||||
- [docs/course_program.md](./docs/course_program.md) — модульная структура и состав учебных материалов.
|
||||
- [docs/stack_reference.md](./docs/stack_reference.md) — порты, команды, конфиги, шпаргалка.
|
||||
- [docs/glossary.md](./docs/glossary.md) — справочник терминов (storage, catalog, compute, snapshot и др.).
|
||||
|
||||
## Доступ к сервисам
|
||||
## Менторство
|
||||
|
||||
### Trino
|
||||
|
||||
Web UI (координатор):
|
||||
|
||||
```text
|
||||
http://localhost:8090
|
||||
```
|
||||
|
||||
(точный порт см. в `docker-compose.yml`).
|
||||
|
||||
Подключение **из контейнера trino**:
|
||||
|
||||
```bash
|
||||
docker exec -it trino trino \
|
||||
--server http://localhost:8080 \
|
||||
--catalog lakehouse
|
||||
```
|
||||
|
||||
Проверка:
|
||||
|
||||
```sql
|
||||
SHOW CATALOGS;
|
||||
SHOW SCHEMAS FROM lakehouse;
|
||||
```
|
||||
|
||||
### MinIO
|
||||
|
||||
* Консоль: `http://localhost:9001`
|
||||
* S3 endpoint: `http://localhost:9000`
|
||||
|
||||
Учётные данные — `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` из `docker-compose.yml` или `.env`.
|
||||
|
||||
Создай в MinIO бакет `lakehouse` (если его нет) — данные Iceberg будут храниться именно там.
|
||||
|
||||
### PostgreSQL (каталог Iceberg для Trino)
|
||||
|
||||
Подключение (пример):
|
||||
|
||||
```bash
|
||||
psql -h localhost -p 5432 -U iceberg -d iceberg
|
||||
```
|
||||
|
||||
(имя пользователя, БД и порт уточняются в `docker-compose.yml`).
|
||||
|
||||
Служебные таблицы Iceberg JDBC-каталога (`iceberg_tables`, `iceberg_namespace_properties`) создаёт однократный контейнер `iceberg-catalog-init`. Если база уже запускалась без них, можно переинициализировать вручную:
|
||||
|
||||
```bash
|
||||
docker compose run --rm iceberg-catalog-init
|
||||
```
|
||||
|
||||
### Jupyter / notebooks
|
||||
|
||||
Веб-интерфейс: `http://localhost:8888` (по умолчанию без токена).
|
||||
|
||||
* В контейнере монтируется `./notebooks` в `/opt/work`.
|
||||
* `./src` доступен read-only в `/opt/src`, переменная `PYTHONPATH=/opt/src` уже установлена — можно импортировать функции из скриптов прямо в ноутбуках.
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация Trino (каталог `lakehouse`)
|
||||
|
||||
Файл `lakehouse.properties` монтируется в `/etc/trino/catalog/lakehouse.properties`.
|
||||
|
||||
Ключевые параметры:
|
||||
|
||||
```properties
|
||||
connector.name=iceberg
|
||||
|
||||
# Каталог Iceberg типа JDBC (метаданные в PostgreSQL)
|
||||
iceberg.catalog.type=jdbc
|
||||
iceberg.jdbc-catalog.catalog-name=lakehouse
|
||||
iceberg.jdbc-catalog.driver-class=org.postgresql.Driver
|
||||
iceberg.jdbc-catalog.connection-url=jdbc:postgresql://postgres-iceberg:5432/iceberg
|
||||
iceberg.jdbc-catalog.connection-user=iceberg
|
||||
iceberg.jdbc-catalog.connection-password=iceberg
|
||||
iceberg.jdbc-catalog.schema-version=V1
|
||||
|
||||
# Хранилище файлов Iceberg – S3 (MinIO) через hadoop-клиент
|
||||
iceberg.file-system.type=hadoop
|
||||
fs.native-s3.enabled=true
|
||||
|
||||
s3.endpoint=http://minio:9000
|
||||
s3.region=us-east-1
|
||||
s3.path-style-access=true
|
||||
s3.aws-access-key=minioadmin
|
||||
s3.aws-secret-key=minioadmin
|
||||
```
|
||||
|
||||
**Что это даёт:**
|
||||
|
||||
* Trino хранит *метаданные* Iceberg в PostgreSQL (таблицы каталога, снапшоты, манифесты и т.п.).
|
||||
* *Файлы данных* лежат в MinIO, в бакете `lakehouse`, к которому Trino ходит по `s3://`/`s3a://` через S3-клиент.
|
||||
|
||||
Пример создания схемы и таблицы из Trino:
|
||||
|
||||
```sql
|
||||
-- Схема в каталоге lakehouse (метаданные в PostgreSQL)
|
||||
CREATE SCHEMA lakehouse.default;
|
||||
|
||||
-- Таблица Iceberg с данными в s3://lakehouse/default/test_table/
|
||||
CREATE TABLE lakehouse.default.test_table (
|
||||
id bigint,
|
||||
name varchar
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация Spark (spark-defaults.conf)
|
||||
|
||||
`spark-defaults.conf` монтируется в `/opt/spark/conf/spark-defaults.conf` в контейнеры Spark.
|
||||
|
||||
Ключевые моменты:
|
||||
|
||||
```properties
|
||||
# Iceberg Spark extensions
|
||||
spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions
|
||||
|
||||
# Каталог Iceberg для Spark (JDBC, метаданные в Postgres)
|
||||
spark.sql.catalog.lakehouse=org.apache.iceberg.spark.SparkCatalog
|
||||
spark.sql.catalog.lakehouse.catalog-impl=org.apache.iceberg.jdbc.JdbcCatalog
|
||||
spark.sql.catalog.lakehouse.uri=jdbc:postgresql://postgres-iceberg:5432/iceberg
|
||||
spark.sql.catalog.lakehouse.jdbc.user=iceberg
|
||||
spark.sql.catalog.lakehouse.jdbc.password=iceberg
|
||||
spark.sql.catalog.lakehouse.jdbc.driver=org.postgresql.Driver
|
||||
spark.sql.catalog.lakehouse.warehouse=s3a://lakehouse/warehouse
|
||||
spark.sql.catalog.lakehouse.default-namespace=default
|
||||
|
||||
# S3/MinIO через s3a
|
||||
spark.hadoop.fs.s3a.endpoint=http://minio:9000
|
||||
spark.hadoop.fs.s3a.access.key=minioadmin
|
||||
spark.hadoop.fs.s3a.secret.key=minioadmin
|
||||
spark.hadoop.fs.s3a.path.style.access=true
|
||||
spark.hadoop.fs.s3a.impl=org.apache.hadoop.fs.s3a.S3AFileSystem
|
||||
spark.hadoop.fs.s3a.connection.ssl.enabled=false
|
||||
```
|
||||
|
||||
**Важно:** Spark и Trino используют единый JDBC-каталог `lakehouse`: метаданные лежат в Postgres, данные — в MinIO. Таблица, созданная в Spark, видна в Trino без дополнительной настройки.
|
||||
|
||||
Пример создания таблицы из Spark:
|
||||
|
||||
```python
|
||||
from pyspark.sql import SparkSession
|
||||
|
||||
spark = (SparkSession.builder
|
||||
.appName("lakehouse-demo")
|
||||
.getOrCreate())
|
||||
|
||||
# Каталог lakehouse указан явно
|
||||
spark.sql("""
|
||||
CREATE TABLE lakehouse.default.spark_table (
|
||||
id BIGINT,
|
||||
name STRING
|
||||
)
|
||||
USING iceberg
|
||||
""")
|
||||
|
||||
spark.sql("INSERT INTO lakehouse.default.spark_table VALUES (1, 'Alice'), (2, 'Bob')")
|
||||
```
|
||||
|
||||
Файлы окажутся в `s3a://lakehouse/warehouse/default/spark_table/`.
|
||||
|
||||
---
|
||||
|
||||
## Типовой учебный сценарий
|
||||
|
||||
1. **Поднять стенд** (`docker compose build`, затем `docker compose up -d`).
|
||||
2. **Создать бакет `lakehouse`** в MinIO Console (если ещё нет).
|
||||
3. **Создать таблицу из Spark**, записать туда данные, показать дерево файлов Iceberg в MinIO (data/manifest/metadata).
|
||||
4. **Прочитать ту же таблицу из Trino** (проверка общего каталога).
|
||||
5. **Создать таблицу из Trino** и прочитать её из Spark.
|
||||
6. Обсудить архитектуру: Postgres хранит метаданные Iceberg, MinIO — данные, Spark/Trino — compute.
|
||||
|
||||
---
|
||||
|
||||
## Smoke-тесты Spark → Trino
|
||||
|
||||
Быстрая проверка, что таблица, созданная в Spark, читается в Trino через общий JDBC-каталог.
|
||||
|
||||
1. Убедиться, что стенд запущен: `docker compose up -d`.
|
||||
2. Скопировать скрипты внутрь контейнеров:
|
||||
|
||||
```bash
|
||||
docker compose cp src/spark/iceberg_smoke.py spark-master:/tmp/
|
||||
docker compose cp src/trino/iceberg_smoke.sql trino:/tmp/
|
||||
```
|
||||
|
||||
3. Выполнить smoke из Spark:
|
||||
|
||||
```bash
|
||||
docker compose exec spark-master /opt/spark/bin/spark-submit /tmp/iceberg_smoke.py
|
||||
```
|
||||
|
||||
Скрипт создаст `lakehouse.default.spark_trino_smoke`, вставит строку `1, from_spark` и прочитает её.
|
||||
|
||||
4. Прочитать ту же таблицу из Trino:
|
||||
|
||||
```bash
|
||||
docker compose exec trino trino --file /tmp/iceberg_smoke.sql
|
||||
```
|
||||
|
||||
В выводе должны быть строки `spark_trino_smoke` в списке таблиц и `1, from_spark` в результате выборки.
|
||||
|
||||
5. Очистка (опционально):
|
||||
|
||||
```bash
|
||||
docker compose exec spark-master /opt/spark/bin/spark-sql -e "DROP TABLE IF EXISTS lakehouse.default.spark_trino_smoke"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Дальнейшее развитие
|
||||
|
||||
Планируемые/возможные расширения:
|
||||
|
||||
* Подключить **Airflow** и запускать Spark-job’ы поверх этого же Lakehouse.
|
||||
* Вынести настройки (`MINIO_ROOT_USER`, `ICEBERG_*`, `POSTGRES_*`) в `.env` с шаблоном для студентов.
|
||||
* Добавить отдельные каталоги Trino (например, `hive`, `tpch`) для демонстрации федеративных запросов.
|
||||
* Добавить пример интеграции с BI-инструментом (DBeaver/Metabase/Superset) поверх Trino.
|
||||
|
||||
Для пошаговых учебных сценариев (лабораторных работ) см. файл `HOWTO.md`.
|
||||
|
||||
---
|
||||
Курс рассчитан на самостоятельное прохождение, но если хочешь разобрать темы глубже с ментором по Data Engineering — напиши: [@dementev_dev](https://t.me/dementev_dev).
|
||||
|
||||
## Лицензия
|
||||
|
||||
Материалы этого репозитория лицензированы на условиях Creative Commons Attribution 4.0 International (CC BY 4.0).
|
||||
См. файл `LICENSE` или <https://creativecommons.org/licenses/by/4.0/>.
|
||||
Материалы этого репозитория лицензированы на условиях Creative Commons Attribution 4.0 International (`CC BY 4.0`).
|
||||
См. [LICENSE](./LICENSE).
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
# START HERE
|
||||
|
||||
Этот документ нужен для первого входа в курс: поднять стенд, проверить сервисы, открыть интерфейсы, выполнить базовый smoke test и подготовить локальный data bundle для следующих модулей.
|
||||
|
||||
## Маршрут прохождения
|
||||
|
||||
1. Проверить prerequisites.
|
||||
2. Собрать и поднять стенд.
|
||||
3. Убедиться, что контейнеры живы.
|
||||
4. Открыть основные UI.
|
||||
5. Зайти в Jupyter и выполнить `notebooks/01_environment_and_smoke_test.ipynb`.
|
||||
6. Перед Модулем 3 подготовить учебный датасет в `./data/nyc_taxi`.
|
||||
7. При проблемах использовать логи и шаги диагностики из этого документа.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Нужно заранее установить:
|
||||
|
||||
- Docker;
|
||||
- Docker Compose;
|
||||
- современный браузер для UI;
|
||||
- свободные порты на хосте.
|
||||
|
||||
Рекомендуемые ресурсы хоста:
|
||||
|
||||
- не менее `4 vCPU`;
|
||||
- не менее `10 GB RAM`, иначе `Spark`, `Trino` и `Jupyter` могут стартовать нестабильно;
|
||||
- хотя бы `8-10 GB` свободного места под образы и контейнеры.
|
||||
|
||||
## Порты стенда
|
||||
|
||||
| Сервис | Адрес | Зачем нужен |
|
||||
| --- | --- | --- |
|
||||
| Spark Master UI | `http://localhost:8080` | Проверка мастера Spark и подключённых worker-ов |
|
||||
| Spark Worker 1 UI | `http://localhost:8081` | Проверка первого worker-а |
|
||||
| Spark Worker 2 UI | `http://localhost:8082` | Проверка второго worker-а |
|
||||
| Trino UI | `http://localhost:8090` | Проверка координатора Trino |
|
||||
| MinIO API | `http://localhost:9000` | S3-compatible endpoint |
|
||||
| MinIO Console | `http://localhost:9001` | Просмотр бакетов и файлов |
|
||||
| JupyterLab | `http://localhost:8888` | Основная точка входа в практику |
|
||||
| PostgreSQL | `localhost:5432` | JDBC-каталог Iceberg, нужен для диагностики |
|
||||
|
||||
## Быстрый запуск
|
||||
|
||||
Все команды выполняются из корня репозитория.
|
||||
|
||||
### 1. Собрать образы
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
```
|
||||
|
||||
### 2. Поднять стенд
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3. Проверить статус контейнеров
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Ожидаемое состояние:
|
||||
|
||||
- сервисы `spark-master`, `spark-worker-1`, `spark-worker-2`, `minio`, `postgres-iceberg`, `trino`, `jupyter` находятся в состоянии `Up`;
|
||||
- однократные init-контейнеры вроде `minio-init` и `iceberg-catalog-init` могут завершиться после успешной инициализации.
|
||||
|
||||
## Куда заходить после старта
|
||||
|
||||
Открой в браузере:
|
||||
|
||||
- `http://localhost:8080` для `Spark Master UI`;
|
||||
- `http://localhost:8090` для `Trino UI`;
|
||||
- `http://localhost:9001` для `MinIO Console`;
|
||||
- `http://localhost:8888` для `JupyterLab`.
|
||||
|
||||
Для входа в `MinIO Console` используй:
|
||||
|
||||
- логин: `minioadmin`;
|
||||
- пароль: `minioadmin`.
|
||||
|
||||
Если интерфейсы открываются, переходи в Jupyter и запускай:
|
||||
|
||||
```text
|
||||
notebooks/01_environment_and_smoke_test.ipynb
|
||||
```
|
||||
|
||||
## Роли сервисов в стенде
|
||||
|
||||
| Сервис | Роль в модуле |
|
||||
| --- | --- |
|
||||
| `MinIO` | `storage`: объектное хранилище для данных и файлов Iceberg |
|
||||
| `PostgreSQL` | `catalog`: хранит метаданные JDBC-каталога Iceberg |
|
||||
| `Spark` | `compute`: выполняет PySpark и Spark SQL задания |
|
||||
| `Trino` | `compute`: читает те же таблицы через общий каталог |
|
||||
| `Jupyter` | Точка входа в учебные ноутбуки |
|
||||
|
||||
## Как работать в Jupyter
|
||||
|
||||
- `./notebooks` смонтирован в контейнер как `/opt/work`;
|
||||
- `./src` смонтирован read-only как `/opt/src`;
|
||||
- `./data` смонтирован read-only как `/opt/data`;
|
||||
- `PYTHONPATH=/opt/src`, поэтому helper-скрипты из `src/` доступны для импорта в ноутбуках;
|
||||
- первый ноутбук курса: `01_environment_and_smoke_test.ipynb`;
|
||||
- локальный учебный data bundle для Модуля 3 должен быть доступен внутри Jupyter по пути `/opt/data/nyc_taxi`.
|
||||
|
||||
## Базовая диагностика
|
||||
|
||||
### Посмотреть список контейнеров
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Посмотреть логи конкретного сервиса
|
||||
|
||||
```bash
|
||||
docker compose logs -f spark-master
|
||||
docker compose logs -f trino
|
||||
docker compose logs -f minio
|
||||
docker compose logs -f jupyter
|
||||
```
|
||||
|
||||
### Типовые первые проверки
|
||||
|
||||
- `Spark UI` не открывается: проверь `docker compose ps` и логи `spark-master`.
|
||||
- `Trino UI` не открывается: проверь `docker compose ps` и логи `trino`.
|
||||
- `JupyterLab` не открывается: проверь `docker compose ps` и логи `jupyter`.
|
||||
- `MinIO Console` не открывается: проверь `docker compose ps` и логи `minio`.
|
||||
- smoke test падает из ноутбука: сначала убедись, что `spark-master` и worker-ы видны в `Spark UI`.
|
||||
|
||||
## Restart и reset
|
||||
|
||||
### Мягкий перезапуск стенда
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Полный reset с удалением данных
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Используй полный reset, если хочешь пройти практику заново с чистого состояния.
|
||||
|
||||
## Подготовка учебного датасета (перед Модулем 3)
|
||||
|
||||
Перед `notebooks/03_raw_ingest_and_first_read.ipynb` нужно заранее скачать учебный data bundle на хост, а не изнутри ноутбука.
|
||||
|
||||
Скачай каноническое учебное подмножество `NYC TLC Yellow Taxi Trip Records`:
|
||||
|
||||
```bash
|
||||
curl -fLo data/nyc_taxi/yellow_tripdata_2024-01.parquet \
|
||||
https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-01.parquet
|
||||
curl -fLo data/nyc_taxi/yellow_tripdata_2024-02.parquet \
|
||||
https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-02.parquet
|
||||
curl -fLo data/nyc_taxi/yellow_tripdata_2024-03.parquet \
|
||||
https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-03.parquet
|
||||
curl -fLo data/nyc_taxi/taxi_zone_lookup.csv \
|
||||
https://d37ci6vzurychx.cloudfront.net/misc/taxi_zone_lookup.csv
|
||||
```
|
||||
|
||||
Проверь, что файлы на месте:
|
||||
|
||||
```bash
|
||||
ls -lh data/nyc_taxi/
|
||||
```
|
||||
|
||||
Минимальный набор для курса:
|
||||
|
||||
- `yellow_tripdata_2024-01.parquet`
|
||||
- `yellow_tripdata_2024-02.parquet`
|
||||
- `yellow_tripdata_2024-03.parquet`
|
||||
- `taxi_zone_lookup.csv`
|
||||
|
||||
Если хочешь расширенный режим, можешь скачать все 12 месяцев `2024`, но основной маршрут курса и примеры опираются на первые 3 месяца.
|
||||
|
||||
## Подключение DBeaver к Trino (перед Модулем 6)
|
||||
|
||||
- Зачем: в Модуле 6 можно работать с Trino через DBeaver параллельно с ноутбуком — привычный SQL-интерфейс.
|
||||
- Предусловие: DBeaver установлен (ссылка на [dbeaver.io/download](https://dbeaver.io/download/)). Необязателен — ноутбук работает без DBeaver.
|
||||
- Шаги: New Database Connection -> Trino. Host: `localhost`. Port: `8090`. Database/Catalog: `lakehouse`. Username: любая строка (напр. `student`). Password: пусто. Test Connection -> Finish.
|
||||
- Проверка: `SHOW SCHEMAS FROM lakehouse;`. Ожидаем: `bronze`, `default`, `information_schema`, `silver`.
|
||||
- Troubleshooting: стенд поднят? контейнер `trino` Up? порт 8090 свободен?
|
||||
|
||||
## Что делать дальше
|
||||
|
||||
- пройти `notebooks/01_environment_and_smoke_test.ipynb`;
|
||||
- пройти `notebooks/02_lakehouse_mental_model.ipynb`;
|
||||
- подготовить data bundle по инструкции выше и пройти `notebooks/03_raw_ingest_and_first_read.ipynb`;
|
||||
- свериться с программой курса в `docs/course_program.md`;
|
||||
- после прохождения первых модулей переходить к следующим учебным материалам.
|
||||
Whitespace-only changes.
@@ -181,6 +181,7 @@ services:
|
||||
- ./spark/spark-defaults.conf:/opt/spark/conf/spark-defaults.conf:ro
|
||||
- ./notebooks:/opt/work
|
||||
- ./src:/opt/src:ro
|
||||
- ./data:/opt/data:ro
|
||||
|
||||
trino:
|
||||
image: trinodb/trino:478
|
||||
|
||||
Executable → Regular
+46
-26
@@ -1,3 +1,23 @@
|
||||
# Legacy HOWTO: ранние лабораторные по Lakehouse-стенду
|
||||
|
||||
Этот документ сохранён как архивный материал и не является актуальным учебным маршрутом для курса `Lakehouse без магии`.
|
||||
|
||||
Зачем он сохранён:
|
||||
|
||||
* в нём есть полезные ранние учебные сценарии и привязка к существующим demo-скриптам;
|
||||
* он помогает понять, как эволюционировал стенд и какие практики уже когда-то обкатывались;
|
||||
* его можно использовать как reference при создании новых ноутбуков и стартовой документации.
|
||||
|
||||
Почему это legacy:
|
||||
|
||||
* документ был написан до текущего PRD и новой программы курса;
|
||||
* он смешивает актуальные темы с темами вне обязательного трека `v1`, например с отдельной лабой про партиционирование;
|
||||
* он не соответствует новой структуре материалов `README -> START_HERE -> course_program -> notebooks`.
|
||||
|
||||
Ниже сохранено исходное содержимое старого `HOWTO.md` почти без изменений.
|
||||
|
||||
---
|
||||
|
||||
# HOWTO: Учебные лабораторные по Lakehouse-стенду
|
||||
|
||||
Этот файл описывает пошаговые учебные сценарии (лабораторные работы), которые можно выполнять поверх стенда из `docker-compose.yml`.
|
||||
@@ -6,29 +26,29 @@
|
||||
|
||||
Лабораторки опираются на примеры в `src/`:
|
||||
|
||||
- `src/spark/cluster_smoke.py` — проверка, что Spark‑кластер жив.
|
||||
- `src/spark/iceberg_demo.sql` — первая Iceberg‑таблица в Spark.
|
||||
- `src/spark/iceberg_smoke.py` — Spark создаёт Iceberg‑таблицу и читает её.
|
||||
- `src/spark/cluster_smoke.py` — проверка, что Spark-кластер жив.
|
||||
- `src/spark/iceberg_demo.sql` — первая Iceberg-таблица в Spark.
|
||||
- `src/spark/iceberg_smoke.py` — Spark создаёт Iceberg-таблицу и читает её.
|
||||
- `src/trino/iceberg_smoke.sql` — Trino читает таблицу, созданную в Spark.
|
||||
|
||||
Все команды ниже выполняются из корня репозитория.
|
||||
|
||||
Краткая карта лабораторных:
|
||||
|
||||
- **Лаба 0** — стенд поднят, Spark‑кластер жив.
|
||||
- **Лаба 1** — первая Iceberg‑таблица в Spark.
|
||||
- **Лаба 0** — стенд поднят, Spark-кластер жив.
|
||||
- **Лаба 1** — первая Iceberg-таблица в Spark.
|
||||
- **Лаба 2** — общий каталог Spark ↔ Trino.
|
||||
- **Лаба 3** — партиционирование и эволюция схемы.
|
||||
- **Лаба 4** — мини‑ETL поверх Lakehouse (эскиз).
|
||||
- **Лаба 4** — мини-ETL поверх Lakehouse (эскиз).
|
||||
|
||||
---
|
||||
|
||||
## Лаба 0. Стенд поднят, Spark‑кластер жив
|
||||
## Лаба 0. Стенд поднят, Spark-кластер жив
|
||||
|
||||
**Цель**
|
||||
|
||||
- Убедиться, что все контейнеры поднялись.
|
||||
- Проверить, что Spark‑кластер (master + workers) работает.
|
||||
- Проверить, что Spark-кластер (master + workers) работает.
|
||||
|
||||
**Предусловия**
|
||||
|
||||
@@ -52,7 +72,7 @@
|
||||
|
||||
Ожидаем, что `spark-master`, `spark-worker-1`, `spark-worker-2` в статусе `Up`.
|
||||
|
||||
3. Запустить smoke‑скрипт кластера из контейнера `spark-master`:
|
||||
3. Запустить smoke-скрипт кластера из контейнера `spark-master`:
|
||||
|
||||
```bash
|
||||
docker compose exec spark-master \
|
||||
@@ -71,11 +91,11 @@
|
||||
|
||||
---
|
||||
|
||||
## Лаба 1. Первая Iceberg‑таблица из Spark
|
||||
## Лаба 1. Первая Iceberg-таблица из Spark
|
||||
|
||||
**Цель**
|
||||
|
||||
- Создать Iceberg‑таблицу с помощью Spark SQL.
|
||||
- Создать Iceberg-таблицу с помощью Spark SQL.
|
||||
- Посмотреть файлы таблицы в MinIO (`warehouse/default/demo_tbl/...`).
|
||||
|
||||
**Предусловия**
|
||||
@@ -91,7 +111,7 @@
|
||||
/opt/spark/bin/spark-sql
|
||||
```
|
||||
|
||||
2. Выполнить учебный SQL‑скрипт:
|
||||
2. Выполнить учебный SQL-скрипт:
|
||||
|
||||
- Внутри интерактивной сессии `spark-sql`:
|
||||
|
||||
@@ -127,7 +147,7 @@
|
||||
|
||||
возвращает данные.
|
||||
|
||||
- В MinIO видна структура Iceberg‑таблицы: служебные файлы и файлы данных.
|
||||
- В MinIO видна структура Iceberg-таблицы: служебные файлы и файлы данных.
|
||||
|
||||
---
|
||||
|
||||
@@ -135,7 +155,7 @@
|
||||
|
||||
**Цель**
|
||||
|
||||
- Показать, что Spark и Trino используют общий Iceberg‑каталог (метаданные в Postgres, данные в MinIO).
|
||||
- Показать, что Spark и Trino используют общий Iceberg-каталог (метаданные в Postgres, данные в MinIO).
|
||||
- Создать таблицу из Spark и прочитать её через Trino.
|
||||
|
||||
**Предусловия**
|
||||
@@ -145,7 +165,7 @@
|
||||
|
||||
**Шаги**
|
||||
|
||||
1. Создать таблицу и записать данные из Spark (PySpark‑скрипт):
|
||||
1. Создать таблицу и записать данные из Spark (PySpark-скрипт):
|
||||
|
||||
```bash
|
||||
docker compose exec spark-master \
|
||||
@@ -169,7 +189,7 @@
|
||||
|
||||
3. Прочитать ту же таблицу из Trino:
|
||||
|
||||
- Вариант через заранее скопированный SQL‑файл (как в README):
|
||||
- Вариант через заранее скопированный SQL-файл (как в README):
|
||||
|
||||
```bash
|
||||
docker compose cp src/trino/iceberg_smoke.sql trino:/tmp/
|
||||
@@ -211,10 +231,10 @@
|
||||
|
||||
**Подготовленные примеры**
|
||||
|
||||
- `src/spark/partitioned_table_demo.sql` — создание партиционированной Iceberg‑таблицы и вставка данных.
|
||||
- `src/spark/partitioned_table_demo.sql` — создание партиционированной Iceberg-таблицы и вставка данных.
|
||||
- `src/spark/schema_evolution_demo.sql` — демонстрация `ALTER TABLE` и добавления колонок.
|
||||
- `src/trino/schema_evolution_demo.sql` — чтение той же таблицы с эволюцией схемы из Trino.
|
||||
- `notebooks/03_partitioning_and_schema_evolution.ipynb` — интерактивный разбор тех же примеров в Jupyter.
|
||||
- интерактивный ноутбук для этой legacy-лабы удалён из актуального репозитория, чтобы не смешивать его с основным треком курса.
|
||||
|
||||
**Вариант A: через Spark SQL и Trino CLI**
|
||||
|
||||
@@ -271,22 +291,22 @@
|
||||
docker compose exec trino trino --file /tmp/schema_evolution_demo.sql
|
||||
```
|
||||
|
||||
**Вариант B: через Jupyter‑ноутбук**
|
||||
**Вариант B: через Jupyter вручную**
|
||||
|
||||
- Открыть `http://localhost:8888` и запустить ноутбук `03_partitioning_and_schema_evolution.ipynb`.
|
||||
- Ноутбук:
|
||||
- создаёт SparkSession, подключённый к кластеру;
|
||||
- выполняет скрипты `partitioned_table_demo.sql` и `schema_evolution_demo.sql`;
|
||||
- показывает агрегаты по партициям и данные до/после эволюции схемы в интерактивном виде.
|
||||
- Открыть `http://localhost:8888`, если нужен самостоятельный разбор SQL-примеров в Jupyter.
|
||||
- Повторить те же шаги вручную в кодовых ячейках:
|
||||
- создать SparkSession, подключённый к кластеру;
|
||||
- выполнить скрипты `partitioned_table_demo.sql` и `schema_evolution_demo.sql`;
|
||||
- посмотреть агрегаты по партициям и данные до/после эволюции схемы в интерактивном виде.
|
||||
|
||||
---
|
||||
|
||||
## Лаба 4. Мини‑ETL поверх Lakehouse (эскиз)
|
||||
## Лаба 4. Мини-ETL поверх Lakehouse (эскиз)
|
||||
|
||||
Идея лабы — собрать end-to-end сценарий:
|
||||
|
||||
- есть сырые данные (CSV/JSON) в S3/MinIO;
|
||||
- Spark читает raw‑данные, чистит и пишет в Iceberg‑таблицу;
|
||||
- Spark читает raw-данные, чистит и пишет в Iceberg-таблицу;
|
||||
- Trino делает поверх неё аналитику.
|
||||
|
||||
Планируемые компоненты:
|
||||
@@ -0,0 +1,197 @@
|
||||
# Product Requirements Document (PRD): Учебный курс «Lakehouse без магии»
|
||||
|
||||
## 1. Product Vision & Value Proposition
|
||||
|
||||
**Видение:** создать практический курс, который помогает студенту перейти от привычной картины мира классических СУБД к базовой рабочей модели Lakehouse без лишнего стресса и без опасных иллюзий про "магическую" распределенную платформу.
|
||||
|
||||
**Ценность:** дать безопасную и наглядную песочницу на базе локального стенда (`Spark + Trino + Iceberg + MinIO + PostgreSQL`), в которой студент может руками повторить типовые повседневные действия Data Engineer и понять, как устроена современная Lakehouse-архитектура.
|
||||
|
||||
## 2. Target Audience & Prerequisites
|
||||
|
||||
**Основная аудитория:** студент курса [`de-roadmap`](https://github.com/dementev-dev/de-roadmap), который уже прошел или уверенно знает:
|
||||
|
||||
* SQL
|
||||
* Python
|
||||
* Git
|
||||
* Airflow
|
||||
* Greenplum
|
||||
|
||||
**Дополнительно:** возможно, уже поверхностно знаком с `NiFi` или `Kafka`, но это не является обязательным требованием.
|
||||
|
||||
**Важно:** курс рассчитан не на абсолютного новичка, а на человека, который уже понимает базовые DE-процессы и хочет перенести это понимание в мир Lakehouse.
|
||||
|
||||
## 3. Problem Statement & JTBD
|
||||
|
||||
**Ключевая проблема:** при первом столкновении с Lakehouse студент часто не понимает:
|
||||
|
||||
* как набор файлов в объектном хранилище может быть "таблицей";
|
||||
* зачем отдельно нужны `storage`, `catalog` и `compute`;
|
||||
* почему один движок может писать данные, а другой читать те же самые таблицы;
|
||||
* как безопасно работать с такими таблицами, не ломая данные и не теряя воспроизводимость пайплайна.
|
||||
|
||||
В результате Lakehouse воспринимается либо как непонятная магия, либо как хаотичный набор технологий без общей модели.
|
||||
|
||||
**Основной JTBD:** «Когда я попадаю в команду, где есть Lakehouse-стек, я хочу быстро понять, как он устроен и как в нем выполнять простые рабочие задачи, чтобы не быть опасным для окружения и не тратить недели на распаковку базовых концепций».
|
||||
|
||||
**Вторичный JTBD:** «Я хочу руками пощупать популярный стек `Spark + Trino + Iceberg + S3-compatible storage`, чтобы увереннее обсуждать такие системы на собеседованиях и в работе».
|
||||
|
||||
## 4. Product Goals
|
||||
|
||||
Курс должен помочь студенту:
|
||||
|
||||
1. Получить практический, а не только теоретический, ввод в Lakehouse.
|
||||
2. Освоить безопасный набор базовых ежедневных действий, полезных на старте работы.
|
||||
3. Понять архитектуру ровно настолько, чтобы осознанно пользоваться инструментами и не допускать грубых ошибок.
|
||||
4. Сопоставить новые концепции с уже знакомой картиной мира `PostgreSQL/Greenplum` и классических DWH-слоев.
|
||||
|
||||
## 5. Learning Outcomes
|
||||
|
||||
После прохождения курса студент:
|
||||
|
||||
1. Понимает различие между `storage`, `catalog` и `compute`, и может объяснить роль `MinIO`, `PostgreSQL`, `Spark` и `Trino` в этом стенде.
|
||||
2. Умеет поднять локальный стенд, проверить его базовую работоспособность и использовать логи/веб-интерфейсы для первичной диагностики проблем.
|
||||
3. Умеет загрузить raw-данные, прочитать их в `Spark`, проверить схему и подготовить данные к дальнейшей обработке.
|
||||
4. Умеет создать и заполнить `Iceberg`-таблицу как основной табличный формат курса.
|
||||
5. Умеет реализовать простой поток `raw -> bronze -> silver` с базовыми трансформациями и сохранением воспроизводимости шагов.
|
||||
6. Умеет читать одну и ту же таблицу из `Spark` и `Trino` и понимает, почему это возможно без копирования данных.
|
||||
7. Умеет выполнить базовые безопасные операции с таблицей: изменение схемы, просмотр snapshot-ов, чтение предыдущего состояния, базовый `compaction` и `vacuum`.
|
||||
|
||||
Трассировка этих outcomes на модули и checkpoints фиксируется в [course_program.md](./course_program.md).
|
||||
|
||||
## 6. Teaching Principles
|
||||
|
||||
**Принцип 1. Практика первична.** Курс не должен превращаться в обзорную лекцию. Теория нужна для объяснения того, что студент делает руками.
|
||||
|
||||
**Принцип 2. Ноутбук как учебник и тренажер.** В ноутбуках должны быть:
|
||||
|
||||
* готовые демонстрационные ячейки с объяснением подхода;
|
||||
* обязательные самостоятельные задания на похожее действие;
|
||||
* `checkpoints` с ожидаемым результатом.
|
||||
|
||||
**Принцип 3. Постоянный мостик к знакомому миру.** Новые концепции объясняются через короткие сравнения с `PostgreSQL/Greenplum` и классической нотацией слоев `stg/ods/dds`, где это уместно.
|
||||
|
||||
**Принцип 4. Безопасность важнее глубины.** Курс должен в первую очередь снижать риск типовых ошибок новичка, а не учить всем возможностям стека.
|
||||
|
||||
## 7. Risk Reduction / Common Mistakes
|
||||
|
||||
Курс должен явно учить не допускать следующие ошибки:
|
||||
|
||||
1. Путать `storage`, `catalog` и `compute`, не понимая, где реально лежат данные и кто отвечает за метаданные.
|
||||
2. Воспринимать Lakehouse как "одну базу данных", скрывающую физическую структуру хранения.
|
||||
3. Делать разрушительные операции вслепую: неаккуратный `overwrite`, `drop`, неконтролируемую перезапись слоя.
|
||||
4. Смешивать роли слоев `raw`, `bronze`, `silver`, из-за чего теряется происхождение и воспроизводимость данных.
|
||||
5. Менять схему и типы данных без понимания влияния на downstream-чтение и на другие движки.
|
||||
|
||||
## 8. Scope
|
||||
|
||||
**В первой версии курса в скоупе:**
|
||||
|
||||
* архитектурная модель `storage + catalog + compute`;
|
||||
* один основной табличный формат: `Iceberg`;
|
||||
* работа с raw-данными;
|
||||
* построение слоев `bronze` и `silver`;
|
||||
* чтение и запись через `Spark`;
|
||||
* чтение через `Trino`;
|
||||
* базовое schema evolution;
|
||||
* snapshot/time travel на прикладном уровне;
|
||||
* базовый `compaction/vacuum` с параллелями к `Greenplum AppendOnly`;
|
||||
* набор практик в Jupyter Notebooks с обязательными заданиями и checkpoints;
|
||||
* стартовая документация до первого ноутбука: prerequisites, запуск стенда, вход в Jupyter и базовый troubleshooting.
|
||||
|
||||
Ориентировочная программа курса вынесена в отдельный документ: **[course_program.md](./course_program.md)**.
|
||||
|
||||
## 9. Dataset Strategy
|
||||
|
||||
**Основной датасет курса:** `NYC TLC Yellow Taxi Trip Records` + `Taxi Zone Lookup`.
|
||||
|
||||
**Почему он выбран:**
|
||||
|
||||
* это узнаваемый и реалистичный DE-кейс;
|
||||
* он хорошо подходит для слоев `raw -> bronze -> silver`;
|
||||
* его удобно использовать для демонстрации работы `Spark`, `Trino` и `Iceberg`;
|
||||
* он поставляется в `PARQUET`, что делает raw-слой ближе к реальной жизни.
|
||||
|
||||
**Режимы использования данных:**
|
||||
|
||||
* `Default`: 3-6 месяцев данных для стандартного прохождения курса;
|
||||
* `Extended`: до полного года данных для более тяжелого режима или будущего расширения курса.
|
||||
|
||||
**Методическое решение:** курс строится вокруг одного домена данных, но не требует жесткого сквозного проекта. Основной формат курса - набор связанных практик на одном и том же наборе данных.
|
||||
|
||||
**Механика доставки данных в v1:**
|
||||
|
||||
* raw-датасет не хранится в репозитории целиком;
|
||||
* стартовая документация должна давать воспроизводимый способ получить фиксированный учебный набор данных (`Default` или `Extended`) из публичного источника;
|
||||
* `Taxi Zone Lookup` доставляется тем же способом, что и основной датасет;
|
||||
* перед началом модуля про raw-данные студент должен иметь локальный data bundle на хосте;
|
||||
* в модуле про raw-ingest студент загружает уже полученные файлы в raw-зону `MinIO`, а не ищет и не скачивает данные вручную прямо из ноутбука;
|
||||
* ручная загрузка файлов через UI допускается только как fallback-сценарий, а не как основной путь прохождения.
|
||||
|
||||
## 10. User Experience / Learning Flow
|
||||
|
||||
Курс строится по модели **"объяснение -> демонстрация -> самостоятельное повторение -> checkpoint"**.
|
||||
|
||||
Каждый урок должен:
|
||||
|
||||
1. Коротко объяснить новую концепцию.
|
||||
2. Показать готовый рабочий пример.
|
||||
3. Дать студенту похожее задание для самостоятельного выполнения.
|
||||
4. Заканчиваться проверяемым результатом.
|
||||
|
||||
**Формат прохождения:** желательно с ментором, но курс должен быть самодостаточным и для самостоятельного изучения.
|
||||
|
||||
## 11. Product Targets & Acceptance Criteria
|
||||
|
||||
**Ориентиры продукта:**
|
||||
|
||||
Это целевые ориентиры курса, но не blocking acceptance criteria для материалов.
|
||||
|
||||
1. Студент проходит курс за `~12-15 часов` вдумчивой работы.
|
||||
2. Студент после курса способен безопасно выполнить базовые операции в Lakehouse-стенде без постоянной внешней помощи.
|
||||
3. Студент может внятно объяснить ключевые архитектурные принципы Lakehouse на уровне junior/middle interview readiness.
|
||||
|
||||
**Acceptance Criteria для учебных материалов и результата:**
|
||||
|
||||
1. В составе курса есть стартовая документация до первого ноутбука, включая prerequisites, запуск стенда, вход в `Jupyter` и получение учебного data bundle.
|
||||
2. Студент проходит обязательные `checkpoints` по урокам.
|
||||
3. Студент самостоятельно выполняет набор практик на одном датасете, включая:
|
||||
* загрузку raw-данных;
|
||||
* построение `bronze`;
|
||||
* построение `silver`;
|
||||
* создание/чтение `Iceberg`-таблицы;
|
||||
* чтение одной и той же таблицы через `Spark` и `Trino`;
|
||||
* демонстрацию базового schema evolution;
|
||||
* демонстрацию snapshot/time travel;
|
||||
* демонстрацию базового `compaction/vacuum`.
|
||||
4. Студент может своими словами объяснить:
|
||||
* что такое `Lakehouse` и чем он отличается от классической БД;
|
||||
* как разделены `storage`, `catalog`, `compute`;
|
||||
* что такое `Iceberg` в практическом смысле;
|
||||
* почему `Spark` может писать, а `Trino` читать одни и те же таблицы;
|
||||
* зачем нужны слои `raw -> bronze -> silver`.
|
||||
|
||||
## 12. Technical Requirements
|
||||
|
||||
**В скоупе:**
|
||||
|
||||
* использование существующего `docker-compose.yml` (`Spark`, `Trino`, `MinIO`, `PostgreSQL`, `Jupyter`);
|
||||
* подготовка учебных датасетов и/или инструкций по их загрузке в репозиторий;
|
||||
* наличие воспроизводимого способа получить учебный data bundle без ручного поиска по внешним сайтам;
|
||||
* поддержка выполнения запросов к `Trino` из ноутбуков;
|
||||
* подготовка Jupyter Notebooks как основного формата практической части курса;
|
||||
* подготовка стартовой документации для входа в курс до первого запуска ноутбуков;
|
||||
* наличие воспроизводимых шагов для старта, сброса и повторного прохождения практик.
|
||||
|
||||
## 13. Out of Scope (v1)
|
||||
|
||||
**Вне первой версии курса:**
|
||||
|
||||
* `gold`-слой и полноценные бизнес-витрины;
|
||||
* `Airflow`, `Kafka`, `NiFi`, `streaming`, `CDC`;
|
||||
* сложные `MERGE`-сценарии, row-level deletes и update-heavy кейсы;
|
||||
* партиционирование и `partition pruning` как отдельная учебная тема первой версии;
|
||||
* глубокий performance tuning;
|
||||
* production security, governance, multi-user setup;
|
||||
* Kubernetes / облачное развертывание;
|
||||
* детальное сравнение нескольких типов catalog;
|
||||
* глубокий разбор internals `Iceberg` на уровне спецификации.
|
||||
@@ -0,0 +1,287 @@
|
||||
# Программа учебного курса: Lakehouse без магии
|
||||
|
||||
Этот документ заменяет предыдущий черновой план и выравнивает программу с PRD из [course_prd.md](./course_prd.md).
|
||||
|
||||
## 1. Рамка курса
|
||||
|
||||
**Формат:** стартовая документация + 8 практических уроков в `Jupyter Notebooks` + вспомогательные справочные материалы.
|
||||
|
||||
**Оценка длительности:** `~12-15 часов` вдумчивой работы.
|
||||
|
||||
**Ориентир на модуль:** в среднем `~1.5 часа`, но финальная практика может занять дольше.
|
||||
|
||||
**Сквозной учебный кейс:** `NYC TLC Yellow Taxi Trip Records` + `Taxi Zone Lookup`.
|
||||
|
||||
**Методика каждого урока:** `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`.
|
||||
|
||||
**Главная цель курса:** дать студенту безопасную рабочую модель Lakehouse на локальном стенде `Spark + Trino + Iceberg + MinIO + PostgreSQL`, а не обзор всех возможных фич.
|
||||
|
||||
## 2. Принципы декомпозиции программы
|
||||
|
||||
1. Сначала студент должен научиться поднимать и диагностировать стенд, и только потом строить пайплайн.
|
||||
2. Архитектурная модель `storage + catalog + compute` проходит через весь курс, а не выносится в одну "теоретическую" лекцию.
|
||||
3. Весь курс строится вокруг одного датасета и одного потока `raw -> bronze -> silver`.
|
||||
4. В первой версии курса основной табличный формат только один: `Iceberg`.
|
||||
5. Практики про безопасность важнее "вау-фич": аккуратный `overwrite`, понимание снапшотов, воспроизводимость шагов, базовое обслуживание таблиц.
|
||||
6. Темы вне PRD v1 не включаются в основной трек и остаются в backlog.
|
||||
|
||||
## 3. Состав учебных материалов
|
||||
|
||||
Курс не должен состоять только из ноутбуков. Ноутбуки являются ядром практики, но вход в курс, запуск стенда и правила работы должны быть вынесены в обычную документацию, доступную до старта `Jupyter`.
|
||||
|
||||
### 3.1. Стартовая документация до первого ноутбука
|
||||
|
||||
Этот слой нужен, потому что студент ещё не имеет доступа к ноутбукам, пока не поднят стенд.
|
||||
|
||||
**Минимальный обязательный набор:**
|
||||
|
||||
* `START_HERE.md` или аналогичный стартовый документ с маршрутом прохождения курса;
|
||||
* инструкция по prerequisites: `Docker`, `Docker Compose`, свободные порты, базовые команды;
|
||||
* пошаговый гайд по запуску стенда: `build`, `up`, проверка сервисов, открытие UI;
|
||||
* краткое объяснение, как пользоваться `Jupyter`, где лежат ноутбуки и как читать структуру урока;
|
||||
* troubleshooting по типовым проблемам старта;
|
||||
* инструкция по reset/restart стенда для повторного прохождения практик.
|
||||
|
||||
**Что должен закрывать этот слой:**
|
||||
|
||||
* студент понимает, что нужно установить и проверить до начала курса;
|
||||
* студент может поднять стенд без ментора;
|
||||
* студент знает, как попасть в `Jupyter`, `MinIO`, `Trino UI`;
|
||||
* студент понимает, как устроен формат уроков и что от него ожидается.
|
||||
|
||||
### 3.2. Практическое ядро курса
|
||||
|
||||
Это 8 ноутбуков, в которых живут демонстрации, самостоятельные задания и checkpoints.
|
||||
|
||||
### 3.3. Вспомогательные материалы
|
||||
|
||||
Кроме ноутбуков и стартового onboarding-слоя, курсу потребуются:
|
||||
|
||||
* вспомогательные скрипты в `src/spark` и `src/trino`;
|
||||
* инструкции по загрузке или подготовке учебных датасетов;
|
||||
* краткий [glossary](./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 на модули
|
||||
|
||||
| Learning Outcome | Основные модули | Где проверяется |
|
||||
| --- | --- | --- |
|
||||
| `LO1`. Понимание различий между `storage`, `catalog`, `compute` | 1, 2, 6 | checkpoints модулей 2 и 6 |
|
||||
| `LO2`. Умение поднять и диагностировать стенд | 1 | checkpoint модуля 1 |
|
||||
| `LO3`. Умение загрузить raw-данные и проверить схему | 3 | checkpoint модуля 3 |
|
||||
| `LO4`. Умение создать и заполнить `Iceberg`-таблицу | 4 | checkpoint модуля 4 |
|
||||
| `LO5`. Умение собрать поток `raw -> bronze -> silver` | 4, 5, 8 | checkpoints модулей 5 и 8 |
|
||||
| `LO6`. Умение читать одну таблицу из `Spark` и `Trino` | 6, 8 | checkpoint модуля 6 и финальная практика |
|
||||
| `LO7`. Умение выполнять безопасные операции с таблицей | 7, 8 | checkpoints модулей 7 и 8 |
|
||||
|
||||
## 5. Верхнеуровневая структура практической части
|
||||
|
||||
### Модуль 1. Вход в стенд и базовая диагностика
|
||||
|
||||
**Рабочее название ноутбука:** `01_environment_and_smoke_test.ipynb`
|
||||
|
||||
**Зачем нужен:** студент должен уметь самостоятельно поднять локальный стенд и понять, куда смотреть, если что-то не работает.
|
||||
|
||||
**Содержание:**
|
||||
|
||||
* запуск `docker compose build` и `docker compose up -d`;
|
||||
* проверка сервисов и веб-интерфейсов;
|
||||
* базовый smoke test для Spark;
|
||||
* первый обзор того, где в стенде `MinIO`, `PostgreSQL`, `Spark`, `Trino`, `Jupyter`.
|
||||
|
||||
**Практика студента:**
|
||||
|
||||
* запустить стенд;
|
||||
* проверить статусы контейнеров и основные UI;
|
||||
* выполнить простой smoke test и прочитать его результат.
|
||||
|
||||
**Checkpoint:** студент подтверждает, что стенд поднят, понимает назначение сервисов и умеет сделать первичную диагностику через `docker compose ps`, логи и UI.
|
||||
|
||||
### Модуль 2. Ментальная модель Lakehouse: storage, catalog, compute
|
||||
|
||||
**Рабочее название ноутбука:** `02_lakehouse_mental_model.ipynb`
|
||||
|
||||
**Зачем нужен:** убрать магическое восприятие Lakehouse и связать новую модель с привычным миром `PostgreSQL/Greenplum`.
|
||||
|
||||
**Содержание:**
|
||||
|
||||
* демонстрационный проход по цепочке `Spark -> catalog -> MinIO` на маленькой demo-таблице;
|
||||
* что такое таблица в Lakehouse в практическом смысле;
|
||||
* где лежат данные, где лежат метаданные, кто выполняет вычисления;
|
||||
* разница между "одной базой данных" и набором согласованных компонентов;
|
||||
* короткие параллели с классическим DWH.
|
||||
|
||||
**Практика студента:**
|
||||
|
||||
* выполнить готовую демонстрационную запись через `Spark` в небольшую demo-таблицу;
|
||||
* найти соответствующие артефакты в `MinIO` и запись о таблице в каталоге через подготовленные диагностические шаги;
|
||||
* сопоставить наблюдения с ролями `storage / catalog / compute`.
|
||||
|
||||
**Checkpoint:** студент своими словами объясняет роли `MinIO`, `PostgreSQL`, `Spark` и `Trino`, не путает физическое хранение с логической таблицей и может показать, где в стенде видны данные, метаданные и вычислитель.
|
||||
|
||||
### Модуль 3. Raw-данные и первое чтение в Spark
|
||||
|
||||
**Рабочее название ноутбука:** `03_raw_ingest_and_first_read.ipynb`
|
||||
|
||||
**Зачем нужен:** показать, как raw-данные попадают в стенд и как с ними безопасно начать работать.
|
||||
|
||||
**Содержание:**
|
||||
|
||||
* работа с заранее подготовленным локальным data bundle из onboarding-документации;
|
||||
* явная загрузка исходных `PARQUET`-файлов в raw-зону;
|
||||
* чтение raw-данных через Spark;
|
||||
* проверка схемы, типов и базового качества данных;
|
||||
* обсуждение того, почему raw лучше не "чинить на месте" и почему датасет не должен скачиваться "из интернета из ноутбука".
|
||||
|
||||
**Практика студента:**
|
||||
|
||||
* проверить состав локально полученного набора `NYC Taxi`;
|
||||
* загрузить его в raw-зону `MinIO`;
|
||||
* прочитать несколько месяцев `NYC Taxi`;
|
||||
* проверить схему и посчитать базовые метрики;
|
||||
* найти простые аномалии или проблемные значения.
|
||||
|
||||
**Checkpoint:** студент умеет загрузить учебный набор в raw-зону, прочитать raw-данные, проверить схему и объяснить, почему raw-слой нужен как воспроизводимая точка входа.
|
||||
|
||||
### Модуль 4. Первая рабочая Iceberg-таблица и слой bronze
|
||||
|
||||
**Рабочее название ноутбука:** `04_bronze_with_iceberg.ipynb`
|
||||
|
||||
**Зачем нужен:** перейти от набора файлов к управляемой таблице и построить первый слой обработки.
|
||||
|
||||
**Содержание:**
|
||||
|
||||
* создание namespace и Iceberg-таблицы через Spark;
|
||||
* запись raw-данных в `bronze`;
|
||||
* осмотр структуры Iceberg на прикладном уровне: data files, metadata, snapshots;
|
||||
* связь `raw -> bronze` с привычным `stg/ods`-мышлением.
|
||||
|
||||
**Практика студента:**
|
||||
|
||||
* создать первую Iceberg-таблицу;
|
||||
* загрузить в неё данные из raw;
|
||||
* проверить результат через чтение таблицы и осмотр артефактов хранения.
|
||||
|
||||
**Checkpoint:** студент умеет создать и заполнить Iceberg-таблицу и понимает, что таблица в Lakehouse не сводится к одному каталогу с файлами.
|
||||
|
||||
### Модуль 5. Слой silver и воспроизводимые трансформации
|
||||
|
||||
**Рабочее название ноутбука:** `05_silver_layer.ipynb`
|
||||
|
||||
**Зачем нужен:** научить строить простой, понятный и воспроизводимый pipeline `raw -> bronze -> silver`.
|
||||
|
||||
**Содержание:**
|
||||
|
||||
* базовые трансформации и очистка данных;
|
||||
* явная фиксация правил преобразования;
|
||||
* разделение ответственности между слоями;
|
||||
* проверки качества на уровне строк, схемы и агрегатов.
|
||||
|
||||
**Практика студента:**
|
||||
|
||||
* собрать `silver` из `bronze`;
|
||||
* нормализовать часть полей;
|
||||
* добавить простые проверки результата.
|
||||
|
||||
**Checkpoint:** студент может воспроизводимо построить `silver` и объяснить, чем `bronze` отличается от `silver`.
|
||||
|
||||
### Модуль 6. Одна таблица, два движка: Spark и Trino
|
||||
|
||||
**Рабочее название ноутбука:** `06_spark_and_trino_on_same_table.ipynb`
|
||||
|
||||
**Зачем нужен:** закрепить идею разделения вычислительных движков и показать, что данные не нужно копировать между системами.
|
||||
|
||||
**Содержание:**
|
||||
|
||||
* чтение одной и той же Iceberg-таблицы из Spark и Trino;
|
||||
* роль общего каталога;
|
||||
* простые SQL-проверки в Trino;
|
||||
* ограничение темы: курс не уходит в глубокое сравнение движков.
|
||||
|
||||
**Практика студента:**
|
||||
|
||||
* записать таблицу через Spark;
|
||||
* прочитать ту же таблицу через Trino;
|
||||
* сверить результаты и ответить, почему это работает.
|
||||
|
||||
**Checkpoint:** студент понимает, почему `Spark` может писать, а `Trino` читать ту же таблицу без копирования данных.
|
||||
|
||||
### Модуль 7. Безопасная работа с таблицами: schema evolution и time travel
|
||||
|
||||
**Рабочее название ноутбука:** `07_safe_table_changes.ipynb`
|
||||
|
||||
**Зачем нужен:** научить не ломать таблицы вслепую и пользоваться базовыми защитными механизмами.
|
||||
|
||||
**Содержание:**
|
||||
|
||||
* добавление и изменение схемы на базовом уровне;
|
||||
* влияние schema evolution на downstream-чтение;
|
||||
* snapshots и time travel;
|
||||
* разбор типовых ошибок новичка: неаккуратный `overwrite`, слепая перезапись, неявные изменения типов.
|
||||
|
||||
**Практика студента:**
|
||||
|
||||
* добавить новую колонку или безопасно изменить схему;
|
||||
* посмотреть историю snapshot-ов;
|
||||
* прочитать предыдущее состояние таблицы после намеренно "неудачного" изменения.
|
||||
|
||||
**Checkpoint:** студент умеет делать базовые изменения схемы, смотреть историю таблицы и использовать time travel как страховку.
|
||||
|
||||
### Модуль 8. Базовое обслуживание таблиц и финальная практика
|
||||
|
||||
**Рабочее название ноутбука:** `08_maintenance_and_final_lab.ipynb`
|
||||
|
||||
**Зачем нужен:** завершить курс рабочим циклом поддержки таблицы и собрать все изученное в одну практику.
|
||||
|
||||
**Содержание:**
|
||||
|
||||
* проблема мелких файлов;
|
||||
* базовый `compaction`;
|
||||
* базовый `vacuum` / cleanup старых версий;
|
||||
* короткая параллель с обслуживанием `Greenplum AppendOnly`-таблиц;
|
||||
* финальный мини-сценарий: `raw -> bronze -> silver -> проверка через Trino -> безопасное изменение -> обслуживание`.
|
||||
|
||||
**Практика студента:**
|
||||
|
||||
* выполнить compaction на учебной таблице;
|
||||
* очистить старые версии в контролируемом сценарии;
|
||||
* пройти финальный end-to-end checkpoint.
|
||||
|
||||
**Checkpoint:** студент выполняет полный учебный сценарий и может объяснить, зачем нужны compaction и cleanup в прикладной работе.
|
||||
|
||||
## 6. Что получает студент по итогам курса
|
||||
|
||||
После прохождения программы студент:
|
||||
|
||||
1. Поднимает локальный стенд и диагностирует типовые проблемы на старте.
|
||||
2. Понимает различие между `storage`, `catalog` и `compute`.
|
||||
3. Умеет загрузить raw-данные и прочитать их в Spark.
|
||||
4. Умеет создать и заполнить Iceberg-таблицу.
|
||||
5. Умеет построить простой поток `raw -> bronze -> silver`.
|
||||
6. Умеет читать одну и ту же таблицу из `Spark` и `Trino`.
|
||||
7. Умеет выполнять базовые безопасные операции: schema evolution, snapshots/time travel, compaction, vacuum.
|
||||
|
||||
## 7. Что не входит в первую версию курса
|
||||
|
||||
Следующие темы сознательно исключены из основного плана и могут стать отдельным расширением:
|
||||
|
||||
* `gold`-слой и полноценные бизнес-витрины;
|
||||
* партиционирование и `partition pruning` как отдельная обязательная тема v1;
|
||||
* performance tuning и физический дизайн таблиц как самостоятельный модуль;
|
||||
* `MERGE`, row-level deletes, update-heavy сценарии;
|
||||
* streaming, `Kafka`, `CDC`, `Airflow`, `NiFi`;
|
||||
* deep dive во внутренности `Iceberg` на уровне спецификации;
|
||||
* production security, governance, multi-user setup, Kubernetes и облака.
|
||||
|
||||
Существующие demo-артефакты по партиционированию в репозитории считаются legacy-материалами и не входят в обязательный трек первой версии курса.
|
||||
|
||||
## 8. Следующий уровень детализации
|
||||
|
||||
После утверждения этой структуры следующий документ должен описывать не "темы вообще", а каркас учебных артефактов:
|
||||
|
||||
1. состав стартовой документации и порядок чтения до первого запуска, включая получение data bundle;
|
||||
2. список ноутбуков и их точные learning objectives;
|
||||
3. обязательные демонстрации, самостоятельные задания и checkpoints по каждому уроку;
|
||||
4. набор вспомогательных `src/spark` и `src/trino`-скриптов, включая загрузку датасета и диагностические шаги для модуля 2;
|
||||
5. правила сброса стенда и повторного прохождения практик.
|
||||
@@ -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 (финальная практика).
|
||||
@@ -0,0 +1,114 @@
|
||||
# Карта репозитория и источники истины
|
||||
|
||||
Этот документ нужен, чтобы не раздувать `AGENTS.md` и не дублировать изменчивый контекст проекта в нескольких местах.
|
||||
|
||||
Это внутренний документ сопровождения репозитория. Для прохождения курса и первого запуска стенда он обычно не нужен.
|
||||
|
||||
## Что это за репозиторий сейчас
|
||||
|
||||
Репозиторий состоит из двух тесно связанных слоёв:
|
||||
|
||||
1. Локальный Lakehouse-стенд на `Spark + Trino + Iceberg + MinIO + PostgreSQL`.
|
||||
2. Учебный курс `Lakehouse без магии`, который использует этот стенд как практическую среду.
|
||||
|
||||
Из-за этого изменения часто затрагивают не только конфиги и контейнеры, но и маршрут студента по документации и ноутбукам.
|
||||
|
||||
## Основные зоны репозитория
|
||||
|
||||
| Зона | Где лежит | Назначение |
|
||||
| --- | --- | --- |
|
||||
| Runtime / stand | `docker-compose.yml`, `spark/`, `trino/`, `jupyter/` | Топология сервисов, образы, конфиги, порты |
|
||||
| Onboarding | `README.md`, `START_HERE.md` | Вход в репозиторий и первый пользовательский маршрут |
|
||||
| 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/` | Исторические материалы, не входящие в актуальный маршрут |
|
||||
|
||||
## Источники истины
|
||||
|
||||
| Файл или каталог | За что отвечает | Что не стоит туда складывать |
|
||||
| --- | --- | --- |
|
||||
| `docker-compose.yml` | Реальный состав сервисов, контейнеров, сетей, портов и зависимостей | Учебные пояснения, которые не нужны для запуска |
|
||||
| `spark/`, `trino/`, `jupyter/` | Конкретные runtime-конфиги и образы | Описание программы курса |
|
||||
| `README.md` | Верхнеуровневое объяснение репозитория, архитектуры и точек входа | Пошаговый student onboarding во всех деталях и low-level runtime reference |
|
||||
| `START_HERE.md` | Первый маршрут студента: prerequisites, запуск, первые UI, первый ноутбук, базовая диагностика | Полный PRD курса или подробный бэклог модулей |
|
||||
| `docs/stack_reference.md` | Технический reference стенда: сервисы, порты, доступ, reset, smoke-тесты | Роль основного onboarding-документа или описание всей программы курса |
|
||||
| `docs/course_prd.md` | Product scope, аудитория, learning outcomes, dataset strategy, out of scope | Технические мелочи запуска контейнеров |
|
||||
| `docs/course_program.md` | Модульная структура курса, состав материалов, checkpoints | Подробные docker-команды и legacy-лабы |
|
||||
| `notebooks/` | Каноническая практическая часть курса | Длинные инфраструктурные HOWTO |
|
||||
| `src/` | Smoke-скрипты, SQL-демо и переиспользуемая helper-логика | Случайные одноразовые заметки и черновики |
|
||||
| `plans/` | Внутренний статус работ, живые планы, декомпозиция задач | Источник истины для student-facing маршрута |
|
||||
| `docs/archive/` | Справочный legacy-контент | Актуальные инструкции без явной пометки `legacy` |
|
||||
|
||||
## Порядок приоритетов, если документы расходятся
|
||||
|
||||
- Для реального поведения стенда: `docker-compose.yml` и runtime-конфиги важнее `README.md`.
|
||||
- Для первого student onboarding: `START_HERE.md` важнее старых HOWTO и внутренних планов.
|
||||
- Для scope курса: `docs/course_prd.md` важнее `plans/`.
|
||||
- Для структуры модулей и состава практик: `docs/course_program.md` важнее случайных упоминаний в README или archive-документах.
|
||||
|
||||
Если после изменения документы начинают спорить друг с другом, исправляй не только тот файл, который менял, но и ближайший канонический слой.
|
||||
|
||||
## Что обновлять при разных типах изменений
|
||||
|
||||
### Если меняется стенд
|
||||
|
||||
Примеры: новые сервисы, другие порты, переименование контейнеров, изменения образов, новые обязательные env vars.
|
||||
|
||||
Минимум проверь и при необходимости обнови:
|
||||
|
||||
- `docker-compose.yml`;
|
||||
- `README.md`;
|
||||
- `START_HERE.md`;
|
||||
- `docs/stack_reference.md`;
|
||||
- затронутые команды в `src/`, ноутбуках и планах.
|
||||
|
||||
### Если меняется маршрут студента
|
||||
|
||||
Примеры: новый первый ноутбук, другой onboarding-flow, перенос шагов из README в отдельный документ.
|
||||
|
||||
Минимум проверь и при необходимости обнови:
|
||||
|
||||
- `START_HERE.md`;
|
||||
- `docs/course_program.md`;
|
||||
- соответствующие `notebooks/`;
|
||||
- `README.md`, если меняется верхнеуровневый вход в репозиторий.
|
||||
|
||||
### Если меняется scope курса
|
||||
|
||||
Примеры: новый обязательный модуль, изменение learning outcomes, перенос темы в backlog, смена основного датасета.
|
||||
|
||||
Минимум проверь и при необходимости обнови:
|
||||
|
||||
- `docs/course_prd.md`;
|
||||
- `docs/course_program.md`;
|
||||
- релевантные `plans/`;
|
||||
- student-facing материалы, если scope уже отражён в них явно.
|
||||
|
||||
### Если добавляется новый учебный материал
|
||||
|
||||
Примеры: новый ноутбук, новый smoke-скрипт, новый SQL demo.
|
||||
|
||||
Обычно нужно:
|
||||
|
||||
- положить практику в `notebooks/` или `src/`;
|
||||
- отразить её в `docs/course_program.md`, если материал входит в основной трек;
|
||||
- при необходимости обновить `START_HERE.md`, если это новая точка входа;
|
||||
- не раздувать `README.md` подробностями модуля, если это уже описано в программе курса.
|
||||
|
||||
## Что не стоит держать в AGENTS.md
|
||||
|
||||
- Полную программу курса по модулям.
|
||||
- Подробные таблицы портов и runtime-параметров.
|
||||
- Длинные HOWTO и лабораторные сценарии.
|
||||
- Исторические объяснения того, как стенд выглядел раньше.
|
||||
|
||||
`AGENTS.md` должен оставаться коротким набором правил. Всё, что часто меняется вместе с курсом и стендом, лучше хранить в обычной документации.
|
||||
|
||||
## Работа с legacy-материалами
|
||||
|
||||
- `docs/archive/` хранит полезный контекст, но не определяет текущий маршрут курса.
|
||||
- Если старый материал нужен как reference, оставляй его в archive и явно помечай, почему он не канонический.
|
||||
- Если legacy-документ снова становится частью основного курса, переноси его обратно в актуальную структуру, а не оставляй полуживой копией.
|
||||
@@ -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 нельзя пропускать** даже для опытных студентов. Они содержат ключевые практики, которые отличают «знаю теорию» от «умею делать руками».
|
||||
@@ -0,0 +1,240 @@
|
||||
# Технический reference стенда
|
||||
|
||||
Этот документ описывает сам стенд как runtime-среду: сервисы, порты, доступ, основные команды и smoke-тесты.
|
||||
|
||||
Если нужен первый student onboarding, используй [START_HERE.md](../START_HERE.md). Если нужна программа курса, используй [course_program.md](./course_program.md).
|
||||
|
||||
## Состав сервисов
|
||||
|
||||
| Сервис | Контейнер | Порт на хосте | Назначение |
|
||||
| --- | --- | --- | --- |
|
||||
| Spark master | `spark-master` | `7077`, `8080` | мастер Spark и его UI |
|
||||
| Spark worker 1 | `spark-worker-1` | `8081` | первый worker Spark |
|
||||
| Spark worker 2 | `spark-worker-2` | `8082` | второй worker Spark |
|
||||
| Trino | `trino` | `8090` | SQL engine и Web UI |
|
||||
| MinIO API | `minio` | `9000` | S3-compatible endpoint |
|
||||
| MinIO Console | `minio` | `9001` | веб-консоль бакетов и объектов |
|
||||
| PostgreSQL | `postgres-iceberg` | `5432` | JDBC-каталог Iceberg |
|
||||
| JupyterLab | `jupyter` | `8888` | практические ноутбуки |
|
||||
|
||||
## Основные команды
|
||||
|
||||
Все команды выполняются из корня репозитория.
|
||||
|
||||
### Сборка и запуск
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Логи
|
||||
|
||||
```bash
|
||||
docker compose logs -f spark-master
|
||||
docker compose logs -f trino
|
||||
docker compose logs -f minio
|
||||
docker compose logs -f jupyter
|
||||
```
|
||||
|
||||
### Остановка и reset
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
`down -v` удаляет volumes и возвращает стенд в чистое состояние.
|
||||
|
||||
## Доступ к сервисам
|
||||
|
||||
### Spark
|
||||
|
||||
- Master UI: `http://localhost:8080`
|
||||
- Worker UI: `http://localhost:8081` и `http://localhost:8082`
|
||||
- Spark master endpoint внутри сети compose: `spark://spark-master:7077`
|
||||
|
||||
Пример запуска smoke-скрипта:
|
||||
|
||||
```bash
|
||||
docker compose exec spark-master \
|
||||
/opt/spark/bin/spark-submit /opt/src/spark/cluster_smoke.py
|
||||
```
|
||||
|
||||
### Trino
|
||||
|
||||
- Web UI: `http://localhost:8090`
|
||||
|
||||
CLI внутри контейнера:
|
||||
|
||||
```bash
|
||||
docker compose exec -it trino trino --catalog lakehouse
|
||||
```
|
||||
|
||||
Примеры первых команд:
|
||||
|
||||
```sql
|
||||
SHOW CATALOGS;
|
||||
SHOW SCHEMAS FROM lakehouse;
|
||||
SHOW TABLES FROM lakehouse.default;
|
||||
```
|
||||
|
||||
**Подключение через DBeaver:**
|
||||
- Host: `localhost`, Port: `8090`, Catalog: `lakehouse`, User: любая строка, Password: нет.
|
||||
- Driver: Trino (встроен в DBeaver).
|
||||
- Проверка: `SHOW SCHEMAS FROM lakehouse;`.
|
||||
|
||||
### MinIO
|
||||
|
||||
- S3 endpoint: `http://localhost:9000`
|
||||
- Console: `http://localhost:9001`
|
||||
- логин: `minioadmin`
|
||||
- пароль: `minioadmin`
|
||||
|
||||
Бакет `lakehouse` обычно создаётся автоматически контейнером `minio-init`.
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
Подключение с хоста:
|
||||
|
||||
```bash
|
||||
psql -h localhost -p 5432 -U iceberg -d iceberg
|
||||
```
|
||||
|
||||
Служебные таблицы JDBC-каталога создаёт `iceberg-catalog-init`.
|
||||
|
||||
Если нужен повторный запуск инициализации:
|
||||
|
||||
```bash
|
||||
docker compose run --rm iceberg-catalog-init
|
||||
```
|
||||
|
||||
### JupyterLab
|
||||
|
||||
- адрес: `http://localhost:8888`
|
||||
- `./notebooks` смонтирован как `/opt/work`
|
||||
- `./src` смонтирован read-only как `/opt/src`
|
||||
- `./data` смонтирован read-only как `/opt/data`
|
||||
- `PYTHONPATH=/opt/src`
|
||||
|
||||
Первый ноутбук курса: `notebooks/01_environment_and_smoke_test.ipynb`.
|
||||
|
||||
Для Модуля 3 локально скачанный data bundle должен лежать на хосте в `./data/nyc_taxi` и будет доступен внутри Jupyter по пути `/opt/data/nyc_taxi`.
|
||||
|
||||
## Как связаны Spark, Trino, PostgreSQL и MinIO
|
||||
|
||||
- `Spark` использует каталог `lakehouse` через `JdbcCatalog`.
|
||||
- `Trino` использует тот же каталог `lakehouse` через `iceberg.jdbc-catalog`.
|
||||
- метаданные таблиц лежат в `PostgreSQL`;
|
||||
- данные и metadata-файлы Iceberg лежат в `MinIO` в бакете `lakehouse`.
|
||||
|
||||
Именно поэтому таблица, созданная в `Spark`, может читаться в `Trino` без копирования данных.
|
||||
|
||||
## Где лежат ключевые конфиги
|
||||
|
||||
| Файл | Что задаёт |
|
||||
| --- | --- |
|
||||
| `docker-compose.yml` | состав сервисов, порты, volumes, init-контейнеры |
|
||||
| `spark/spark-defaults.conf` | Spark catalog `lakehouse`, `s3a`, Iceberg extensions |
|
||||
| `trino/catalog/lakehouse.properties` | каталог Trino `lakehouse`, JDBC и S3-доступ |
|
||||
| `spark/Dockerfile` | образ Spark с Iceberg, S3A и Python-зависимостями |
|
||||
| `jupyter/Dockerfile` | образ JupyterLab на базе Spark |
|
||||
|
||||
## Smoke-тесты
|
||||
|
||||
### 1. Проверка, что Spark-кластер жив
|
||||
|
||||
```bash
|
||||
docker compose exec spark-master \
|
||||
/opt/spark/bin/spark-submit /opt/src/spark/cluster_smoke.py
|
||||
```
|
||||
|
||||
### 2. Проверка Spark -> Trino через общий каталог
|
||||
|
||||
```bash
|
||||
docker compose cp src/spark/iceberg_smoke.py spark-master:/tmp/
|
||||
docker compose cp src/trino/iceberg_smoke.sql trino:/tmp/
|
||||
docker compose exec spark-master /opt/spark/bin/spark-submit /tmp/iceberg_smoke.py
|
||||
docker compose exec trino trino --file /tmp/iceberg_smoke.sql
|
||||
```
|
||||
|
||||
Ожидаемый результат:
|
||||
|
||||
- в Spark создаётся `lakehouse.default.spark_trino_smoke`;
|
||||
- в Trino видна та же таблица;
|
||||
- выборка возвращает строку `1, from_spark`.
|
||||
|
||||
Опциональная очистка:
|
||||
|
||||
```bash
|
||||
docker compose exec spark-master \
|
||||
/opt/spark/bin/spark-sql -e "DROP TABLE IF EXISTS lakehouse.default.spark_trino_smoke"
|
||||
```
|
||||
|
||||
## Краткая шпаргалка
|
||||
|
||||
### Ключевые 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-тесты.
|
||||
- `course_program.md` — чтобы понять учебную траекторию дальше первого модуля.
|
||||
- `glossary.md` — чтобы вернуться к определению термина (storage, catalog, compute, snapshot и др.).
|
||||
- `mentor_notes.md` — заметки для ведения курса с ментором (опционально).
|
||||
+11
-5
@@ -4,18 +4,24 @@ FROM de-spark-iceberg:3.5.1
|
||||
USER root
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Только JupyterLab, без дополнительных пакетов
|
||||
RUN pip3 install --no-cache-dir "jupyterlab==4.2.5"
|
||||
# Устанавливаем JupyterLab и пакеты для инспекции стенда (MinIO, PostgreSQL)
|
||||
RUN pip3 install --no-cache-dir \
|
||||
"jupyterlab==4.2.5" \
|
||||
"boto3>=1.35,<2" \
|
||||
"psycopg2-binary>=2.9,<3" \
|
||||
"trino>=0.328"
|
||||
|
||||
# Создаём непривилегированного пользователя
|
||||
ARG NB_USER=jovyan
|
||||
ARG NB_UID=1000
|
||||
ARG NB_GID=100
|
||||
|
||||
RUN useradd --uid ${NB_UID} -m -s /bin/bash ${NB_USER} \
|
||||
RUN if ! getent group ${NB_GID} >/dev/null; then groupadd --gid ${NB_GID} ${NB_USER}; fi \
|
||||
&& useradd --uid ${NB_UID} --gid ${NB_GID} -m -s /bin/bash ${NB_USER} \
|
||||
&& mkdir -p /opt/work \
|
||||
&& chown -R ${NB_UID}:${NB_UID} /opt/work
|
||||
&& chown -R ${NB_UID}:${NB_GID} /opt/work
|
||||
|
||||
USER ${NB_UID}
|
||||
USER ${NB_UID}:${NB_GID}
|
||||
WORKDIR /opt/work
|
||||
EXPOSE 8888
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "module1-intro",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Модуль 1. Вход в стенд и базовая диагностика\n",
|
||||
"\n",
|
||||
"Этот ноутбук является канонической практикой Модуля 1.\n",
|
||||
"\n",
|
||||
"На выходе ты должен:\n",
|
||||
"\n",
|
||||
"- понимать, что стенд уже поднят на хосте;\n",
|
||||
"- подключиться к Spark-кластеру из Jupyter;\n",
|
||||
"- выполнить базовый smoke test;\n",
|
||||
"- сопоставить сервисы стенда с их ролями;\n",
|
||||
"- знать первый маршрут диагностики через `docker compose ps`, логи и UI.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "module1-before-start",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Перед стартом\n",
|
||||
"\n",
|
||||
"Перед запуском этого ноутбука пройди шаги из `START_HERE.md`.\n",
|
||||
"\n",
|
||||
"Важно: `docker compose build`, `docker compose up -d`, `docker compose ps` и просмотр логов выполняются на хосте, а не внутри Jupyter.\n",
|
||||
"\n",
|
||||
"Ожидаемое состояние перед началом:\n",
|
||||
"\n",
|
||||
"- контейнеры `spark-master`, `spark-worker-1`, `spark-worker-2`, `minio`, `postgres-iceberg`, `trino`, `jupyter` уже подняты;\n",
|
||||
"- открываются `Spark UI`, `Trino UI`, `MinIO Console` и `JupyterLab`;\n",
|
||||
"- ты работаешь в Jupyter внутри контейнера `jupyter`.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "module1-service-map",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Карта сервисов\n",
|
||||
"\n",
|
||||
"| Сервис | Роль |\n",
|
||||
"| --- | --- |\n",
|
||||
"| `MinIO` | `storage`: объектное хранилище для данных и файлов Iceberg |\n",
|
||||
"| `PostgreSQL` | `catalog`: хранит метаданные JDBC-каталога Iceberg |\n",
|
||||
"| `Spark` | `compute`: выполняет PySpark и Spark SQL |\n",
|
||||
"| `Trino` | `compute`: читает те же таблицы через общий каталог |\n",
|
||||
"| `Jupyter` | Точка входа в учебные ноутбуки |\n",
|
||||
"\n",
|
||||
"В этом модуле мы не строим пайплайн и не создаём Iceberg-таблицы. Задача только одна: убедиться, что среда рабочая и понятная.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "module1-create-session",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from spark.cluster_smoke import (\n",
|
||||
" create_spark_session,\n",
|
||||
" format_smoke_report,\n",
|
||||
" run_cluster_smoke,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"spark = create_spark_session(app_name=\"module-01-environment-and-smoke-test\")\n",
|
||||
"spark\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "module1-cluster-demo",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Демонстрация: подключение к кластеру\n",
|
||||
"\n",
|
||||
"Сначала посмотрим на базовые признаки того, что ноутбук говорит именно с кластером, а не с локальным `local[*]` режимом.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "module1-connection-details",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"connection_details = {\n",
|
||||
" \"app_name\": spark.sparkContext.appName,\n",
|
||||
" \"master_url\": spark.sparkContext.master,\n",
|
||||
" \"default_parallelism\": spark.sparkContext.defaultParallelism,\n",
|
||||
" \"spark_version\": spark.version,\n",
|
||||
"}\n",
|
||||
"connection_details\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "module1-visual-check",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Проверь глазами:\n",
|
||||
"\n",
|
||||
"- `master_url` должен быть `spark://spark-master:7077`, а не `local[*]`;\n",
|
||||
"- `default_parallelism` должен быть больше `1`;\n",
|
||||
"- в `Spark UI` должны быть видны master и worker-ы.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "module1-smoke-explanation",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Демонстрация: Spark smoke test\n",
|
||||
"\n",
|
||||
"Smoke test запускает распределённое вычисление на `range(0, 1_000_000)` и сверяет детерминированный результат.\n",
|
||||
"\n",
|
||||
"Если тест проходит, это означает минимум следующее:\n",
|
||||
"\n",
|
||||
"- Jupyter может создать SparkSession;\n",
|
||||
"- Spark подключён к кластерному master;\n",
|
||||
"- задание действительно выполняется, а не падает на первом действии.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "module1-run-smoke",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"smoke_report = run_cluster_smoke(spark)\n",
|
||||
"print(format_smoke_report(smoke_report))\n",
|
||||
"smoke_report\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "module1-self-check",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Самостоятельное повторение\n",
|
||||
"\n",
|
||||
"Сделай руками и проверь себя:\n",
|
||||
"\n",
|
||||
"1. Открой `Spark UI`, `Trino UI`, `MinIO Console` и `JupyterLab`.\n",
|
||||
"2. Сопоставь каждый сервис с одной из ролей: `storage`, `catalog`, `compute`, `entrypoint`.\n",
|
||||
"3. Объясни, почему этот smoke test не должен работать в `local[*]` режиме.\n",
|
||||
"4. На хосте выполни `docker compose ps` и посмотри, какие контейнеры должны быть в состоянии `Up`.\n",
|
||||
"5. На хосте выполни `docker compose logs -f trino` или `docker compose logs -f spark-master`, чтобы посмотреть, где начинается первичная диагностика.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "module1-service-roles",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"service_roles = {\n",
|
||||
" # Заполни значения самостоятельно: storage, catalog, compute или entrypoint.\n",
|
||||
" \"MinIO\": \"TODO\",\n",
|
||||
" \"PostgreSQL\": \"TODO\",\n",
|
||||
" \"Spark\": \"TODO\",\n",
|
||||
" \"Trino\": \"TODO\",\n",
|
||||
" \"Jupyter\": \"TODO\",\n",
|
||||
"}\n",
|
||||
"service_roles\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "module1-checkpoint",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Checkpoint\n",
|
||||
"\n",
|
||||
"К концу модуля ты должен уметь подтвердить:\n",
|
||||
"\n",
|
||||
"- стенд поднят и основные контейнеры живы;\n",
|
||||
"- `Spark UI`, `MinIO Console`, `Trino UI` и `JupyterLab` открываются;\n",
|
||||
"- smoke test завершился успешно и вернул ожидаемый результат;\n",
|
||||
"- ты понимаешь назначение `MinIO`, `PostgreSQL`, `Spark`, `Trino`, `Jupyter`;\n",
|
||||
"- ты знаешь, что первый маршрут диагностики начинается с `docker compose ps`, `docker compose logs -f <service>` и проверки UI.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "module1-stop-session",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"spark.stop()\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Модуль 2. Ментальная модель Lakehouse: storage, catalog, compute\n",
|
||||
"\n",
|
||||
"В этом модуле мы на практике разберем, чем Lakehouse отличается от классических реляционных СУБД (PostgreSQL, Greenplum).\n",
|
||||
"В классической СУБД данные, метаданные и вычислитель жестко связаны внутри одной системы. В Lakehouse эти слои физически разделены:\n",
|
||||
"- **Storage (Хранилище)**: где лежат физические файлы (у нас это MinIO / S3).\n",
|
||||
"- **Catalog (Каталог)**: где хранится информация о таблицах и их расположении (у нас это PostgreSQL).\n",
|
||||
"- **Compute (Вычислитель)**: кто обрабатывает данные (у нас это Spark, а позже добавится Trino).\n",
|
||||
"\n",
|
||||
"| | PostgreSQL / Greenplum | Lakehouse |\n",
|
||||
"|---|---|---|\n",
|
||||
"| **Данные** | Внутри СУБД (pg_data) | Файлы в объектном хранилище (MinIO) |\n",
|
||||
"| **Метаданные** | Системные каталоги (pg_catalog) | Внешний каталог (PostgreSQL JDBC) |\n",
|
||||
"| **Вычислитель** | Тот же процесс СУБД | Независимые движки (Spark, Trino) |"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Мы не указываем .master() явно, так как он и другие настройки Iceberg\n",
|
||||
"# подтягиваются из конфигурации spark-defaults.conf при старте сессии.\n",
|
||||
"from pyspark.sql import SparkSession\n",
|
||||
"\n",
|
||||
"spark = SparkSession.builder \\\n",
|
||||
" .appName(\"Module-02-Mental-Model\") \\\n",
|
||||
" .getOrCreate()\n",
|
||||
"\n",
|
||||
"# Spark выводит много INFO/WARN логов — убираем их, чтобы не засорять вывод ноутбука\n",
|
||||
"spark.sparkContext.setLogLevel(\"ERROR\")\n",
|
||||
"print(\"Spark сессия готова!\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Запись таблицы (Compute)\n",
|
||||
"Мы используем Spark (вычислитель) для создания таблицы и записи данных."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Создаем namespace (логическую базу данных)\n",
|
||||
"spark.sql(\"CREATE NAMESPACE IF NOT EXISTS lakehouse.module_02\")\n",
|
||||
"\n",
|
||||
"# Создаем или перезаписываем таблицу и вставляем пару строк\n",
|
||||
"spark.sql(\"\"\"\n",
|
||||
"CREATE OR REPLACE TABLE lakehouse.module_02.demo_table (\n",
|
||||
" id int,\n",
|
||||
" name string,\n",
|
||||
" created_at timestamp\n",
|
||||
")\n",
|
||||
"USING iceberg\n",
|
||||
"\"\"\")\n",
|
||||
"\n",
|
||||
"spark.sql(\"\"\"\n",
|
||||
"INSERT INTO lakehouse.module_02.demo_table \n",
|
||||
"VALUES \n",
|
||||
" (1, 'Alice', current_timestamp()),\n",
|
||||
" (2, 'Bob', current_timestamp())\n",
|
||||
"\"\"\")\n",
|
||||
"\n",
|
||||
"# Прочитаем данные через Spark, чтобы убедиться, что они записались\n",
|
||||
"spark.sql(\"SELECT * FROM lakehouse.module_02.demo_table\").show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. Где живут метаданные? (Catalog)\n",
|
||||
"Таблица в Lakehouse — это не просто файлы. Чтобы Spark (или Trino) знал, где лежат данные и какая у них схема, используется Catalog. В нашем стенде это PostgreSQL.\n",
|
||||
"Посмотрим, какая запись появилась в базе данных каталога."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import psycopg2\n",
|
||||
"import pandas as pd\n",
|
||||
"import warnings\n",
|
||||
"\n",
|
||||
"# Подключаемся к PostgreSQL, который хранит метаданные Iceberg (наш Catalog)\n",
|
||||
"with psycopg2.connect(\n",
|
||||
" host=\"postgres-iceberg\",\n",
|
||||
" database=\"iceberg\",\n",
|
||||
" user=\"iceberg\",\n",
|
||||
" password=\"iceberg\"\n",
|
||||
") as conn:\n",
|
||||
" # Запрашиваем информацию о нашей таблице\n",
|
||||
" query = \"\"\"\n",
|
||||
" SELECT table_namespace, table_name, metadata_location, previous_metadata_location \n",
|
||||
" FROM iceberg_tables \n",
|
||||
" WHERE table_namespace = 'module_02'\n",
|
||||
" \"\"\"\n",
|
||||
"\n",
|
||||
" # Используем catch_warnings локально, чтобы скрыть UserWarning от pandas при чтении из DB-API\n",
|
||||
" with warnings.catch_warnings():\n",
|
||||
" warnings.simplefilter('ignore', UserWarning)\n",
|
||||
" df_catalog = pd.read_sql(query, conn)\n",
|
||||
"\n",
|
||||
"# Посмотрим, куда указывает catalog (обратите внимание на metadata_location)\n",
|
||||
"df_catalog"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"В поле `metadata_location` хранится путь к актуальному файлу метаданных таблицы в физическом хранилище.\n",
|
||||
"\n",
|
||||
"## 3. Где живут сами данные? (Storage)\n",
|
||||
"В качестве физического хранилища (Storage) мы используем MinIO — S3-совместимое объектное хранилище. Данные и файлы метаданных лежат там.\n",
|
||||
"Посмотрим на физические артефакты нашей таблицы."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import boto3\n",
|
||||
"\n",
|
||||
"# Подключаемся к MinIO\n",
|
||||
"s3 = boto3.client(\n",
|
||||
" 's3',\n",
|
||||
" endpoint_url='http://minio:9000',\n",
|
||||
" aws_access_key_id='minioadmin',\n",
|
||||
" aws_secret_access_key='minioadmin'\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"bucket_name = 'lakehouse'\n",
|
||||
"# Путь (prefix) начинается с warehouse, так как это задано в настройках каталога\n",
|
||||
"prefix = 'warehouse/module_02/demo_table/'\n",
|
||||
"\n",
|
||||
"# Получаем список всех файлов (объектов), относящихся к нашей таблице\n",
|
||||
"response = s3.list_objects_v2(Bucket=bucket_name, Prefix=prefix)\n",
|
||||
"\n",
|
||||
"print(\"Файлы таблицы в MinIO:\\n\")\n",
|
||||
"contents = response.get('Contents', [])\n",
|
||||
"if not contents:\n",
|
||||
" print(\"Файлы не найдены. Проверьте prefix или убедитесь, что таблица создана.\")\n",
|
||||
"else:\n",
|
||||
" for obj in contents:\n",
|
||||
" key = obj['Key']\n",
|
||||
" size_kb = obj['Size'] / 1024\n",
|
||||
" if '/data/' in key:\n",
|
||||
" print(f\"[DATA] {key} ({size_kb:.2f} KB)\")\n",
|
||||
" elif '/metadata/' in key:\n",
|
||||
" print(f\"[META] {key} ({size_kb:.2f} KB)\")\n",
|
||||
" else:\n",
|
||||
" print(f\"[OTHER] {key} ({size_kb:.2f} KB)\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Что мы видим?**\n",
|
||||
"1. Папка `metadata/` содержит файлы `.json` (снимки состояния таблицы), `.avro` (манифесты, описывающие, какие файлы данных актуальны).\n",
|
||||
"2. Папка `data/` содержит сами данные в формате `.parquet`.\n",
|
||||
"\n",
|
||||
"> **Подумайте:** Что произойдет, если вы удалите файлы из MinIO, но не удалите запись в каталоге? А что если наоборот?"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## 4. Самостоятельное задание\n",
|
||||
"\n",
|
||||
"1. Создайте таблицу `lakehouse.module_02.my_first_table`.\n",
|
||||
"2. Запишите в неё несколько произвольных строк.\n",
|
||||
"3. Прочитайте таблицу через Spark.\n",
|
||||
"4. Выполните запросы к PostgreSQL и MinIO (аналогично примерам выше), чтобы найти `metadata_location` и физические `parquet` файлы вашей новой таблицы."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Ваш код: создание таблицы my_first_table и вставка данных\n",
|
||||
"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Ваш код: чтение данных через Spark\n",
|
||||
"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Ваш код: инспекция каталога (PostgreSQL)\n",
|
||||
"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Ваш код: инспекция хранилища (MinIO)\n",
|
||||
"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Checkpoint\n",
|
||||
"\n",
|
||||
"Убедитесь, что вы можете ответить на следующие вопросы:\n",
|
||||
"- Можете ли вы показать конкретные файлы данных и метаданных созданной таблицы в MinIO (через UI MinIO на порту 9001 или через код выше)?\n",
|
||||
"- Можете ли вы показать запись о таблице в PostgreSQL и объяснить, на что указывает поле `metadata_location`?\n",
|
||||
"- Что произойдёт, если удалить файлы из MinIO, но не трогать каталог (или наоборот)?\n",
|
||||
"- Понимаете ли вы теперь разницу между `storage` (MinIO) и `catalog` (PostgreSQL)?"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## 5. Очистка ресурсов (Cleanup)\n",
|
||||
"Выполните ячейку ниже, чтобы удалить созданные таблицы и namespace. Это необходимо, чтобы не оставлять \"мусор\" перед переходом к следующим модулям."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Удаляем таблицы (это удалит их и из каталога, и из хранилища)\n",
|
||||
"spark.sql(\"DROP TABLE IF EXISTS lakehouse.module_02.demo_table\")\n",
|
||||
"spark.sql(\"DROP TABLE IF EXISTS lakehouse.module_02.my_first_table\")\n",
|
||||
"\n",
|
||||
"# Удаляем namespace\n",
|
||||
"spark.sql(\"DROP NAMESPACE IF EXISTS lakehouse.module_02\")\n",
|
||||
"\n",
|
||||
"spark.stop()\n",
|
||||
"print(\"Очистка завершена, Spark сессия остановлена.\")"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.10.12"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Лаба 3: партиционирование и эволюция схемы",
|
||||
"",
|
||||
"В этом ноутбуке мы посмотрим, как Iceberg работает с партиционированными таблицами и эволюцией схемы поверх общего каталога `lakehouse`.",
|
||||
"",
|
||||
"Перед началом убедись, что стенд запущен (`docker compose up -d`) и Spark-кластер доступен."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from pyspark.sql import SparkSession",
|
||||
"",
|
||||
"spark = (",
|
||||
" SparkSession.builder",
|
||||
" .appName(\"lab3-partitioning-schema-evolution\")",
|
||||
" .master(\"spark://spark-master:7077\")",
|
||||
" .getOrCreate()",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Часть 1. Партиционированная таблица",
|
||||
"",
|
||||
"Для начала создадим партиционированную Iceberg-таблицу `lakehouse.default.partition_demo` с помощью готового SQL-скрипта из `src/spark/partitioned_table_demo.sql`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from pathlib import Path",
|
||||
"",
|
||||
"sql_path = Path(\"/opt/src/spark/partitioned_table_demo.sql\")",
|
||||
"sql_text = sql_path.read_text(encoding=\"utf-8\")",
|
||||
"",
|
||||
"for statement in sql_text.split(\";\"):",
|
||||
" stmt = statement.strip()",
|
||||
" if stmt:",
|
||||
" spark.sql(stmt)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Посмотрим, какие данные записаны по датам, и обсудим партиционирование по `event_date`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"spark.sql(\"\"\"",
|
||||
"SELECT",
|
||||
" event_date,",
|
||||
" COUNT(*) AS cnt,",
|
||||
" SUM(amount) AS total_amount",
|
||||
"FROM lakehouse.default.partition_demo",
|
||||
"GROUP BY event_date",
|
||||
"ORDER BY event_date",
|
||||
"\"\"\").show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"spark.sql(\"\"\"",
|
||||
"SELECT *",
|
||||
"FROM lakehouse.default.partition_demo",
|
||||
"WHERE event_date = DATE '2024-01-01'",
|
||||
"ORDER BY user_id",
|
||||
"\"\"\").show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Часть 2. Эволюция схемы",
|
||||
"",
|
||||
"Теперь посмотрим на эволюцию схемы: создадим таблицу, добавим колонку и вставим новые строки с дополнительными данными.",
|
||||
"",
|
||||
"Для подготовки таблицы используем скрипт `src/spark/schema_evolution_demo.sql`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"sql_path = Path(\"/opt/src/spark/schema_evolution_demo.sql\")",
|
||||
"sql_text = sql_path.read_text(encoding=\"utf-8\")",
|
||||
"",
|
||||
"for statement in sql_text.split(\";\"):",
|
||||
" stmt = statement.strip()",
|
||||
" if stmt:",
|
||||
" spark.sql(stmt)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"spark.sql(\"DESCRIBE TABLE lakehouse.default.schema_evolution_demo\").show(truncate=False)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"spark.sql(\"\"\"",
|
||||
"SELECT *",
|
||||
"FROM lakehouse.default.schema_evolution_demo",
|
||||
"ORDER BY id",
|
||||
"\"\"\").show(truncate=False)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Обрати внимание, что старые строки имеют `NULL` в колонке `metadata`, а новые — заполненное значение.",
|
||||
"Iceberg хранит историю снапшотов и позволяет эволюцию схемы без сложных миграций."
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "471a32f6",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Модуль 3. Raw-данные и первое чтение в Spark\n",
|
||||
"\n",
|
||||
"В этом модуле мы разбираем полный первый цикл работы с учебным датасетом в Lakehouse:\n",
|
||||
"\n",
|
||||
"- проверяем заранее скачанный локальный data bundle;\n",
|
||||
"- загружаем исходные файлы в raw-зону `MinIO`;\n",
|
||||
"- читаем raw-данные через `Spark` по `s3a://` пути;\n",
|
||||
"- смотрим схему, типы, базовые метрики и простые аномалии;\n",
|
||||
"- фиксируем, почему raw-слой нужен как воспроизводимая неизменяемая точка входа.\n",
|
||||
"\n",
|
||||
"После этого ноутбука ты должен уметь объяснить, почему raw-данные не надо «чинить на месте» и почему их не стоит скачивать прямо из ноутбука.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "723dba8f",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 0. Перед стартом\n",
|
||||
"\n",
|
||||
"Перед выполнением ноутбука:\n",
|
||||
"\n",
|
||||
"- подними стенд по `START_HERE.md`;\n",
|
||||
"- пройди `notebooks/01_environment_and_smoke_test.ipynb`;\n",
|
||||
"- желательно пройти `notebooks/02_lakehouse_mental_model.ipynb`;\n",
|
||||
"- заранее скачай data bundle в `./data/nyc_taxi` по инструкции из `START_HERE.md`.\n",
|
||||
"\n",
|
||||
"В классическом `PostgreSQL / Greenplum` данные часто уже лежат внутри системы. В Lakehouse путь более явный: сначала исходные файлы должны попасть в object storage, и только потом мы начинаем поверх них читать, профилировать и строить таблицы.\n",
|
||||
"\n",
|
||||
"| | Классический DWH | Lakehouse |\n",
|
||||
"| --- | --- | --- |\n",
|
||||
"| Где стартует работа | Данные уже внутри СУБД | Исходные файлы сначала попадают в storage |\n",
|
||||
"| Первая практическая задача | `SELECT` из готовой таблицы | Загрузить raw и прочитать raw |\n",
|
||||
"| Что важно не сломать | Таблицу / схему БД | Неизменяемый raw-слой |\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "a5c56637",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from pathlib import Path\n",
|
||||
"import re\n",
|
||||
"from datetime import datetime\n",
|
||||
"\n",
|
||||
"import boto3\n",
|
||||
"import pandas as pd\n",
|
||||
"from botocore.exceptions import ClientError\n",
|
||||
"from pyspark.sql import SparkSession, functions as F\n",
|
||||
"\n",
|
||||
"DATA_DIR = Path(\"/opt/data/nyc_taxi\")\n",
|
||||
"BUCKET_NAME = \"lakehouse\"\n",
|
||||
"RAW_PREFIX = \"raw/nyc_taxi/\"\n",
|
||||
"RAW_URI = f\"s3a://{BUCKET_NAME}/{RAW_PREFIX}\"\n",
|
||||
"ZONE_LOOKUP_RAW_URI = f\"{RAW_URI}taxi_zone_lookup.csv\"\n",
|
||||
"\n",
|
||||
"spark = SparkSession.builder .appName(\"module-03-raw-ingest-and-first-read\") .getOrCreate()\n",
|
||||
"\n",
|
||||
"spark.sparkContext.setLogLevel(\"ERROR\")\n",
|
||||
"\n",
|
||||
"s3 = boto3.client(\n",
|
||||
" \"s3\",\n",
|
||||
" endpoint_url=\"http://minio:9000\",\n",
|
||||
" aws_access_key_id=\"minioadmin\",\n",
|
||||
" aws_secret_access_key=\"minioadmin\",\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def format_bytes(num_bytes: int) -> str:\n",
|
||||
" units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"]\n",
|
||||
" value = float(num_bytes)\n",
|
||||
" for unit in units:\n",
|
||||
" if value < 1024 or unit == units[-1]:\n",
|
||||
" return f\"{value:.1f} {unit}\"\n",
|
||||
" value /= 1024\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def object_exists(bucket: str, key: str) -> bool:\n",
|
||||
" try:\n",
|
||||
" s3.head_object(Bucket=bucket, Key=key)\n",
|
||||
" return True\n",
|
||||
" except ClientError as exc:\n",
|
||||
" error_code = exc.response.get(\"Error\", {}).get(\"Code\", \"\")\n",
|
||||
" if error_code in {\"404\", \"NoSuchKey\", \"NotFound\"}:\n",
|
||||
" return False\n",
|
||||
" raise\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def list_objects(bucket: str, prefix: str) -> list[dict]:\n",
|
||||
" paginator = s3.get_paginator(\"list_objects_v2\")\n",
|
||||
" objects = []\n",
|
||||
" for page in paginator.paginate(Bucket=bucket, Prefix=prefix):\n",
|
||||
" objects.extend(page.get(\"Contents\", []))\n",
|
||||
" return objects\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def infer_month_bounds(paths: list[Path]) -> tuple[datetime, datetime]:\n",
|
||||
" months = []\n",
|
||||
" for path in paths:\n",
|
||||
" match = re.fullmatch(r\"yellow_tripdata_(\\d{4})-(\\d{2})\\.parquet\", path.name)\n",
|
||||
" if match:\n",
|
||||
" year = int(match.group(1))\n",
|
||||
" month = int(match.group(2))\n",
|
||||
" months.append((year, month))\n",
|
||||
"\n",
|
||||
" assert months, \"Не удалось определить месяцы из имён yellow_tripdata_*.parquet\"\n",
|
||||
"\n",
|
||||
" start_year, start_month = min(months)\n",
|
||||
" end_year, end_month = max(months)\n",
|
||||
"\n",
|
||||
" expected_start = datetime(start_year, start_month, 1)\n",
|
||||
" if end_month == 12:\n",
|
||||
" expected_end = datetime(end_year + 1, 1, 1)\n",
|
||||
" else:\n",
|
||||
" expected_end = datetime(end_year, end_month + 1, 1)\n",
|
||||
"\n",
|
||||
" return expected_start, expected_end\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"print(\"Spark и MinIO-клиент готовы\")\n",
|
||||
"print(f\"RAW URI: {RAW_URI}\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "d4d8f302",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Проверка data bundle\n",
|
||||
"\n",
|
||||
"Сначала убеждаемся, что нужные файлы уже лежат внутри Jupyter-контейнера по пути `/opt/data/nyc_taxi`.\n",
|
||||
"\n",
|
||||
"Почему не скачиваем данные прямо здесь:\n",
|
||||
"\n",
|
||||
"- так модуль остаётся воспроизводимым и не зависит от сетевого доступа внутри ноутбука;\n",
|
||||
"- onboarding и подготовка окружения остаются в `START_HERE.md`, а практика в ноутбуке занимается только Lakehouse-операциями;\n",
|
||||
"- студент видит границу ответственности: сначала подготовить bundle, потом работать с raw-слоем.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "da2f7b51",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"assert DATA_DIR.exists(), (\n",
|
||||
" \"Каталог /opt/data/nyc_taxi не найден. \"\n",
|
||||
" \"Сначала скачай data bundle по инструкции из START_HERE.md и убедись, что ./data смонтирован в Jupyter как /opt/data.\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"bundle_files = sorted(path for path in DATA_DIR.iterdir() if path.is_file())\n",
|
||||
"yellow_files = sorted(DATA_DIR.glob(\"yellow_tripdata_*.parquet\"))\n",
|
||||
"zone_lookup_file = DATA_DIR / \"taxi_zone_lookup.csv\"\n",
|
||||
"\n",
|
||||
"assert yellow_files, (\n",
|
||||
" \"Не найдены файлы yellow_tripdata_*.parquet в /opt/data/nyc_taxi. \"\n",
|
||||
" \"Скачай каноническое подмножество из START_HERE.md.\"\n",
|
||||
")\n",
|
||||
"assert zone_lookup_file.exists(), (\n",
|
||||
" \"Не найден taxi_zone_lookup.csv. Он нужен и для этого модуля, и для самостоятельного задания. \"\n",
|
||||
" \"Скачай его по инструкции из START_HERE.md.\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"bundle_report = pd.DataFrame(\n",
|
||||
" [\n",
|
||||
" {\n",
|
||||
" \"file\": path.name,\n",
|
||||
" \"size_bytes\": path.stat().st_size,\n",
|
||||
" \"size_human\": format_bytes(path.stat().st_size),\n",
|
||||
" }\n",
|
||||
" for path in bundle_files\n",
|
||||
" ]\n",
|
||||
").sort_values(\"file\").reset_index(drop=True)\n",
|
||||
"\n",
|
||||
"bundle_report\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c8defd34",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Набор файлов должен быть понятным и фиксированным. В этом модуле мы работаем не с «каким-то случайным parquet из интернета», а с заранее определённым учебным набором.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "dabd90f2",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. Загрузка в raw-зону MinIO\n",
|
||||
"\n",
|
||||
"Raw-слой в Lakehouse похож на воспроизводимый входной `stg`-контур: мы сохраняем исходные файлы как есть, под оригинальными именами, не переписывая их содержимое.\n",
|
||||
"\n",
|
||||
"Конвенция пути в этом курсе:\n",
|
||||
"\n",
|
||||
"- bucket: `lakehouse`\n",
|
||||
"- raw prefix: `raw/nyc_taxi/`\n",
|
||||
"- полный путь для чтения: `s3a://lakehouse/raw/nyc_taxi/`\n",
|
||||
"\n",
|
||||
"Важно: raw-зона не смешивается с `warehouse/`, где потом будут жить Iceberg-таблицы.\n",
|
||||
"\n",
|
||||
"Повторный запуск этой секции должен быть безопасным:\n",
|
||||
"\n",
|
||||
"- если объекта ещё нет в raw-зоне, мы его загружаем;\n",
|
||||
"- если объект уже есть и размер совпадает, мы пропускаем загрузку;\n",
|
||||
"- если объект уже есть, но размер отличается, это сигнал проблемы с данными или bundle, и мы падаем сразу, без перезаписи raw.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "8644cc94",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"upload_candidates = [*yellow_files, zone_lookup_file]\n",
|
||||
"upload_report = []\n",
|
||||
"\n",
|
||||
"for path in upload_candidates:\n",
|
||||
" object_key = f\"{RAW_PREFIX}{path.name}\"\n",
|
||||
" local_size = path.stat().st_size\n",
|
||||
" existed_before = object_exists(BUCKET_NAME, object_key)\n",
|
||||
"\n",
|
||||
" if existed_before:\n",
|
||||
" metadata = s3.head_object(Bucket=BUCKET_NAME, Key=object_key)\n",
|
||||
" remote_size = metadata[\"ContentLength\"]\n",
|
||||
" if remote_size != local_size:\n",
|
||||
" raise ValueError(\n",
|
||||
" \"Raw object already exists with different size: \"\n",
|
||||
" f\"{object_key} (local={local_size}, remote={remote_size}). \"\n",
|
||||
" \"Остановись и проверь data bundle вместо перезаписи raw-слоя.\"\n",
|
||||
" )\n",
|
||||
" action = \"skipped_same_size\"\n",
|
||||
" else:\n",
|
||||
" s3.upload_file(str(path), BUCKET_NAME, object_key)\n",
|
||||
" metadata = s3.head_object(Bucket=BUCKET_NAME, Key=object_key)\n",
|
||||
" remote_size = metadata[\"ContentLength\"]\n",
|
||||
" action = \"uploaded\"\n",
|
||||
"\n",
|
||||
" upload_report.append(\n",
|
||||
" {\n",
|
||||
" \"file\": path.name,\n",
|
||||
" \"raw_key\": object_key,\n",
|
||||
" \"action\": action,\n",
|
||||
" \"local_size_human\": format_bytes(local_size),\n",
|
||||
" \"remote_size_human\": format_bytes(remote_size),\n",
|
||||
" }\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
"pd.DataFrame(upload_report)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "11f4c459",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"raw_objects = list_objects(BUCKET_NAME, RAW_PREFIX)\n",
|
||||
"\n",
|
||||
"pd.DataFrame(\n",
|
||||
" [\n",
|
||||
" {\n",
|
||||
" \"key\": obj[\"Key\"],\n",
|
||||
" \"size_human\": format_bytes(obj[\"Size\"]),\n",
|
||||
" }\n",
|
||||
" for obj in raw_objects\n",
|
||||
" ]\n",
|
||||
")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "48295c73",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Если объект уже существовал, мы не перезаписываем его без необходимости. Совпадающий размер означает, что повторный запуск секции идемпотентен. Несовпадающий размер считается конфликтом и останавливает сценарий до любых изменений в raw.\n",
|
||||
"\n",
|
||||
"На этом шаге данные уже лежат в storage. `Spark` может читать их напрямую по `s3a://` пути, а для `Trino` понадобится зарегистрированная таблица. До неё мы дойдём в Модулях 4 и 6.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "696d6adc",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Первое чтение raw PARQUET через Spark\n",
|
||||
"\n",
|
||||
"Теперь читаем raw-файлы напрямую из `MinIO` по `s3a://` пути. Это и есть первая важная практика Lakehouse: вычислитель (`Spark`) работает поверх файлов в object storage, а не только поверх локальной файловой системы или таблиц внутри одной СУБД.\n",
|
||||
"\n",
|
||||
"Здесь важно различать два режима работы:\n",
|
||||
"\n",
|
||||
"- `spark.read.parquet(\"s3a://...\")` читает raw-файлы напрямую по физическому пути;\n",
|
||||
"- `spark.table(\"lakehouse....\")` читает уже зарегистрированную управляемую таблицу через каталог.\n",
|
||||
"\n",
|
||||
"Сейчас мы ещё не создавали `Iceberg`-таблицу, поэтому работаем именно через `spark.read.parquet(...)`. К `spark.table(...)` вернёмся в Модуле 4.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "0e8823aa",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"single_file_uri = f\"{RAW_URI}yellow_tripdata_2024-01.parquet\"\n",
|
||||
"single_month_df = spark.read.parquet(single_file_uri)\n",
|
||||
"\n",
|
||||
"print(f\"Читаем один raw-файл: {single_file_uri}\")\n",
|
||||
"print(f\"Строк в одном файле: {single_month_df.count():,}\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "fd5ad623",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"yellow_df = spark.read.parquet(f\"{RAW_URI}yellow_tripdata_*.parquet\")\n",
|
||||
"row_count = yellow_df.count()\n",
|
||||
"\n",
|
||||
"print(f\"Прочитано строк: {row_count:,}\")\n",
|
||||
"print(f\"Количество parquet-файлов в наборе: {len(yellow_files)}\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "5e147271",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"yellow_df.show(10, truncate=False)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "efa037e6",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"input_files = sorted(yellow_df.inputFiles())\n",
|
||||
"pd.DataFrame({\"input_file\": input_files})\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e8866f38",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Обрати внимание: мы читаем сразу несколько файлов wildcard-путём. Это обычный рабочий сценарий для raw-слоя, где данные часто приходят партиями по датам, дням или месяцам.\n",
|
||||
"\n",
|
||||
"В этом модуле мы намеренно не используем `cache()` для всего raw DataFrame. Для учебного профилирования важнее устойчиво прочитать исходные файлы, чем пытаться держать весь raw-срез в памяти executor-ов.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "9701728a",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. Схема, типы и null-значения\n",
|
||||
"\n",
|
||||
"Следующий шаг после первого чтения: понять, какие колонки пришли, какие у них типы и где уже на raw-слое встречаются `null`.\n",
|
||||
"\n",
|
||||
"Здесь важно не перепутать диагностику и очистку. В raw-слое мы наблюдаем и фиксируем реальность данных, а не исправляем её.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "e2809a8b",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"yellow_df.printSchema()\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "126e5601",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"schema_df = pd.DataFrame(yellow_df.dtypes, columns=[\"column\", \"spark_type\"])\n",
|
||||
"print(f\"Количество колонок: {len(yellow_df.columns)}\")\n",
|
||||
"schema_df\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "c6093e2a",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"null_counts_row = yellow_df.select(\n",
|
||||
" *[F.sum(F.col(column).isNull().cast(\"int\")).alias(column) for column in yellow_df.columns]\n",
|
||||
").toPandas().iloc[0]\n",
|
||||
"\n",
|
||||
"null_report = pd.DataFrame(\n",
|
||||
" {\n",
|
||||
" \"column\": yellow_df.columns,\n",
|
||||
" \"null_count\": [int(null_counts_row[column]) for column in yellow_df.columns],\n",
|
||||
" }\n",
|
||||
")\n",
|
||||
"null_report[\"null_share\"] = (null_report[\"null_count\"] / row_count).round(6)\n",
|
||||
"null_report.sort_values([\"null_count\", \"column\"], ascending=[False, True]).reset_index(drop=True)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "567156e6",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"`Null` в raw-данных сам по себе не является багом загрузки. Это нормальная часть профиля источника. Позже, на `bronze` и `silver`, мы будем принимать явные решения: какие поля очищать, что отбрасывать, а что нормализовать.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "69e6b77e",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5. Базовые метрики и аномалии\n",
|
||||
"\n",
|
||||
"Теперь быстро профилируем данные и ищем несколько очевидных проблемных паттернов:\n",
|
||||
"\n",
|
||||
"- отрицательный `fare_amount`;\n",
|
||||
"- нулевая дистанция при ненулевой сумме;\n",
|
||||
"- слишком большой `total_amount`;\n",
|
||||
"- `pickup` вне ожидаемого диапазона дат.\n",
|
||||
"\n",
|
||||
"Диапазон дат будем выводить из имён файлов, а не хардкодить руками.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "3a4ed596",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"numeric_columns = [\n",
|
||||
" column\n",
|
||||
" for column, spark_type in yellow_df.dtypes\n",
|
||||
" if spark_type.startswith((\"tinyint\", \"smallint\", \"int\", \"bigint\", \"float\", \"double\", \"decimal\"))\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"yellow_df.select(*numeric_columns).describe().toPandas()\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cc029c98",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"required_columns = {\n",
|
||||
" \"fare_amount\",\n",
|
||||
" \"trip_distance\",\n",
|
||||
" \"total_amount\",\n",
|
||||
" \"tpep_pickup_datetime\",\n",
|
||||
" \"PULocationID\",\n",
|
||||
" \"DOLocationID\",\n",
|
||||
"}\n",
|
||||
"missing_columns = sorted(required_columns.difference(yellow_df.columns))\n",
|
||||
"assert not missing_columns, (\n",
|
||||
" \"В raw-схеме не хватает ожидаемых колонок: \"\n",
|
||||
" f\"{missing_columns}. Возможно, источник поменял формат, и модуль требует обновления.\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"expected_start, expected_end = infer_month_bounds(yellow_files)\n",
|
||||
"\n",
|
||||
"anomaly_report = pd.DataFrame(\n",
|
||||
" [\n",
|
||||
" {\n",
|
||||
" \"check\": \"fare_amount < 0\",\n",
|
||||
" \"count\": yellow_df.filter(F.col(\"fare_amount\") < 0).count(),\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"check\": \"trip_distance = 0 and total_amount != 0\",\n",
|
||||
" \"count\": yellow_df.filter(\n",
|
||||
" (F.col(\"trip_distance\") == 0) & (F.col(\"total_amount\") != 0)\n",
|
||||
" ).count(),\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"check\": \"total_amount > 1000\",\n",
|
||||
" \"count\": yellow_df.filter(F.col(\"total_amount\") > 1000).count(),\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"check\": f\"pickup outside [{expected_start.date()}, {expected_end.date()})\",\n",
|
||||
" \"count\": yellow_df.filter(\n",
|
||||
" (F.col(\"tpep_pickup_datetime\") < F.lit(expected_start))\n",
|
||||
" | (F.col(\"tpep_pickup_datetime\") >= F.lit(expected_end))\n",
|
||||
" ).count(),\n",
|
||||
" },\n",
|
||||
" ]\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"anomaly_report\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "0cc5aaf7",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"suspicious_rows = yellow_df.filter(\n",
|
||||
" (F.col(\"fare_amount\") < 0)\n",
|
||||
" | ((F.col(\"trip_distance\") == 0) & (F.col(\"total_amount\") != 0))\n",
|
||||
" | (F.col(\"total_amount\") > 1000)\n",
|
||||
" | (F.col(\"tpep_pickup_datetime\") < F.lit(expected_start))\n",
|
||||
" | (F.col(\"tpep_pickup_datetime\") >= F.lit(expected_end))\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"suspicious_rows.select(\n",
|
||||
" \"tpep_pickup_datetime\",\n",
|
||||
" \"passenger_count\",\n",
|
||||
" \"trip_distance\",\n",
|
||||
" \"fare_amount\",\n",
|
||||
" \"total_amount\",\n",
|
||||
" \"PULocationID\",\n",
|
||||
" \"DOLocationID\",\n",
|
||||
").show(20, truncate=False)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "9ac27204",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Реальные данные почти всегда содержат выбросы, странные записи и неоднозначные значения. Это не повод немедленно переписывать raw-файлы. Наоборот, raw нужен именно затем, чтобы исходный срез можно было перечитать и переработать заново с новыми правилами.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "095c9ac6",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6. Самостоятельное задание\n",
|
||||
"\n",
|
||||
"Используй raw-данные, которые уже лежат в `MinIO`:\n",
|
||||
"\n",
|
||||
"1. Прочитай `taxi_zone_lookup.csv` из raw-зоны через `Spark`.\n",
|
||||
"2. Выведи схему и первые 10 строк.\n",
|
||||
"3. Посчитай количество зон по `Borough`.\n",
|
||||
"4. Найди `top-5` `pickup`-локаций в поездках и присоедини к ним названия зон.\n",
|
||||
"5. Найди ещё одну аномалию или интересный паттерн и коротко опиши его.\n",
|
||||
"\n",
|
||||
"Подсказка: CSV-файл лежит по пути `s3a://lakehouse/raw/nyc_taxi/taxi_zone_lookup.csv`.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "fe530d7d",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Ваш код: прочитай taxi_zone_lookup.csv из raw-зоны через Spark\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "99fb5256",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Ваш код: выведи схему и первые 10 строк\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "219a40fc",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Ваш код: посчитай количество зон по Borough и top-5 pickup locations с join на lookup\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "5c944f60",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Ваш код: найди ещё одну аномалию или интересный паттерн в данных\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e23dfe47",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 7. Почему raw-слой важен\n",
|
||||
"\n",
|
||||
"Raw-слой полезен не потому, что в нём удобно жить постоянно, а потому что он даёт устойчивую исходную точку. Если завтра поменяются правила очистки, дедупликации или нормализации, мы не обязаны заново скачивать источник и гадать, что именно уже успели «подправить». Мы просто перечитываем raw и перестраиваем downstream-слои.\n",
|
||||
"\n",
|
||||
"Для инженера с опытом `PostgreSQL / Greenplum` полезна такая аналогия: raw похож на аккуратный внешний `staging`-контур, но в Lakehouse он обычно хранится в object storage как набор файлов, а не как таблица внутри одной СУБД.\n",
|
||||
"\n",
|
||||
"Отдельно важно, что загрузка источника вынесена из ноутбука в onboarding. Это делает практику менее хрупкой: ноутбук не зависит от внешней сети, а набор данных остаётся одинаковым у всех студентов.\n",
|
||||
"\n",
|
||||
"Наконец, raw не стоит чинить «на месте». Если в исходных данных есть `null`, отрицательные значения или странные даты, это сигнал для явных трансформаций в `bronze` и `silver`, а не повод тихо переписать входные файлы.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "8849c73f",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 8. Checkpoint\n",
|
||||
"\n",
|
||||
"Проверь себя:\n",
|
||||
"\n",
|
||||
"1. Можешь ли ты показать файлы raw-зоны в `MinIO` через код или `MinIO Console`?\n",
|
||||
"2. Сколько колонок в схеме `Yellow Taxi` и какие типы там встречаются?\n",
|
||||
"3. Какие колонки содержат `null` и почему это не обязательно баг загрузки?\n",
|
||||
"4. Зачем нужен raw-слой, если дальше всё равно будут трансформации?\n",
|
||||
"5. Почему мы не скачиваем датасет прямо из ноутбука?\n",
|
||||
"6. Что произойдёт, если источник завтра поменяет формат файлов?\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "371c4c9d",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 9. Завершение\n",
|
||||
"\n",
|
||||
"Мы намеренно не удаляем raw-данные из `MinIO`. В Модуле 4 они понадобятся для построения первого управляемого `bronze`-слоя на `Iceberg`.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "8574e96b",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"spark.stop()\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.10.12"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "2a1c9579",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Модуль 4. Первая рабочая Iceberg-таблица и слой bronze\n",
|
||||
"\n",
|
||||
"В этом модуле мы переходим от raw-файлов в `MinIO` к первой управляемой таблице `Iceberg`.\n",
|
||||
"\n",
|
||||
"Что делаем на практике:\n",
|
||||
"\n",
|
||||
"- проверяем, что raw-данные из Модуля 3 доступны в `MinIO`;\n",
|
||||
"- создаём namespace `lakehouse.bronze`;\n",
|
||||
"- загружаем raw `Yellow Taxi` в `Iceberg`-таблицу через `CTAS`;\n",
|
||||
"- сравниваем чтение по физическому пути и чтение через каталог;\n",
|
||||
"- смотрим `snapshots`, `history`, `files` и физическую структуру таблицы в `MinIO`.\n",
|
||||
"\n",
|
||||
"После этого ноутбука ты должен понимать, почему `bronze`-слой это уже не «просто папка с parquet», а управляемая таблица с каталогом и metadata.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e13e7b2b",
|
||||
"metadata": {},
|
||||
"source": "## 0. Перед стартом\n\nПеред выполнением ноутбука:\n\n- подними стенд по `START_HERE.md`;\n- пройди `notebooks/03_raw_ingest_and_first_read.ipynb`;\n- убедись, что raw-файлы уже лежат в `s3a://lakehouse/raw/nyc_taxi/`.\n\nСвязка с привычным `PostgreSQL / Greenplum`:\n\n| | Классический DWH (`Greenplum` / `PostgreSQL`) | Lakehouse |\n| --- | --- | --- |\n| Raw-данные | Загружены в `stg`-таблицу или внешний staging | Файлы в object storage (`raw/`) |\n| Первый управляемый слой | Явный DDL + `INSERT INTO ... SELECT` из `stg` | `CREATE TABLE ... USING iceberg AS SELECT` из raw (CTAS) |\n| Метаданные | `pg_catalog` внутри СУБД | JDBC catalog + metadata files в `MinIO` |\n| История изменений | Нет встроенной истории таблицы | `snapshots`, `manifests`, metadata JSON |\n\nЗдесь важно различать два уровня:\n\n- raw: исходные файлы, которые можно перечитать заново;\n- bronze: первая управляемая таблица, которую удобно читать из `Spark` и позже из `Trino`."
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "33cd36c0",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from pathlib import PurePosixPath\n",
|
||||
"\n",
|
||||
"import boto3\n",
|
||||
"import pandas as pd\n",
|
||||
"from pyspark.sql import SparkSession, functions as F\n",
|
||||
"\n",
|
||||
"BUCKET_NAME = \"lakehouse\"\n",
|
||||
"RAW_PREFIX = \"raw/nyc_taxi/\"\n",
|
||||
"RAW_URI = f\"s3a://{BUCKET_NAME}/{RAW_PREFIX}\"\n",
|
||||
"RAW_YELLOW_URI = f\"{RAW_URI}yellow_tripdata_*.parquet\"\n",
|
||||
"ZONE_LOOKUP_RAW_URI = f\"{RAW_URI}taxi_zone_lookup.csv\"\n",
|
||||
"CATALOG_NAME = \"lakehouse\"\n",
|
||||
"BRONZE_NAMESPACE = f\"{CATALOG_NAME}.bronze\"\n",
|
||||
"BRONZE_TABLE = f\"{BRONZE_NAMESPACE}.nyc_taxi_yellow\"\n",
|
||||
"BRONZE_LOOKUP_TABLE = f\"{BRONZE_NAMESPACE}.taxi_zone_lookup\"\n",
|
||||
"\n",
|
||||
"spark = SparkSession.builder .appName(\"module-04-bronze-with-iceberg\") .getOrCreate()\n",
|
||||
"\n",
|
||||
"spark.sparkContext.setLogLevel(\"ERROR\")\n",
|
||||
"\n",
|
||||
"s3 = boto3.client(\n",
|
||||
" \"s3\",\n",
|
||||
" endpoint_url=\"http://minio:9000\",\n",
|
||||
" aws_access_key_id=\"minioadmin\",\n",
|
||||
" aws_secret_access_key=\"minioadmin\",\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def format_bytes(num_bytes: int) -> str:\n",
|
||||
" units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"]\n",
|
||||
" value = float(num_bytes)\n",
|
||||
" for unit in units:\n",
|
||||
" if value < 1024 or unit == units[-1]:\n",
|
||||
" return f\"{value:.1f} {unit}\"\n",
|
||||
" value /= 1024\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def list_objects(bucket: str, prefix: str) -> list[dict]:\n",
|
||||
" paginator = s3.get_paginator(\"list_objects_v2\")\n",
|
||||
" objects = []\n",
|
||||
" for page in paginator.paginate(Bucket=bucket, Prefix=prefix):\n",
|
||||
" objects.extend(page.get(\"Contents\", []))\n",
|
||||
" return objects\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def get_table_location(table_name: str) -> str:\n",
|
||||
" details = spark.sql(f\"DESCRIBE TABLE EXTENDED {table_name}\").toPandas()\n",
|
||||
" location_row = details.loc[details[\"col_name\"] == \"Location\", \"data_type\"]\n",
|
||||
" assert not location_row.empty, f\"Не удалось найти Location для {table_name}\"\n",
|
||||
" return location_row.iloc[0]\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"print(\"Spark и MinIO-клиент готовы\")\n",
|
||||
"print(f\"RAW URI: {RAW_URI}\")\n",
|
||||
"print(f\"Bronze table: {BRONZE_TABLE}\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e195a355",
|
||||
"metadata": {},
|
||||
"source": "## 1. Проверка raw перед загрузкой в bronze\n\nСначала убеждаемся, что raw-данные из Модуля 3 действительно доступны в `MinIO`. Если их нет, строить `bronze` просто не из чего.\n\nКлючевое различие:\n\n- `raw` читается по физическому пути;\n- `bronze` будет читаться уже как логическая таблица через каталог."
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "9e69d44a",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"raw_objects = list_objects(BUCKET_NAME, RAW_PREFIX)\n",
|
||||
"assert raw_objects, (\n",
|
||||
" \"Raw-зона пуста. Сначала пройди Модуль 3 и загрузи data bundle в MinIO.\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"raw_report = pd.DataFrame(\n",
|
||||
" [\n",
|
||||
" {\n",
|
||||
" \"key\": obj[\"Key\"],\n",
|
||||
" \"size_bytes\": obj[\"Size\"],\n",
|
||||
" \"size_human\": format_bytes(obj[\"Size\"]),\n",
|
||||
" }\n",
|
||||
" for obj in raw_objects\n",
|
||||
" ]\n",
|
||||
").sort_values(\"key\").reset_index(drop=True)\n",
|
||||
"\n",
|
||||
"yellow_object_count = int(raw_report[\"key\"].str.contains(r\"yellow_tripdata_.*\\.parquet\", regex=True).sum())\n",
|
||||
"assert yellow_object_count > 0, (\n",
|
||||
" \"В raw-зоне не найдены yellow_tripdata_*.parquet. Сначала пройди Модуль 3.\"\n",
|
||||
")\n",
|
||||
"assert (raw_report[\"key\"] == f\"{RAW_PREFIX}taxi_zone_lookup.csv\").any(), (\n",
|
||||
" \"В raw-зоне не найден taxi_zone_lookup.csv. Он нужен для задания этого модуля и join в Модуле 5.\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"print(f\"Всего объектов в raw-зоне: {len(raw_report)}\")\n",
|
||||
"print(f\"Parquet-файлов Yellow Taxi: {yellow_object_count}\")\n",
|
||||
"raw_report\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "f97b8ec8",
|
||||
"metadata": {},
|
||||
"source": "## 2. Чтение raw-файлов через Spark\n\nСейчас мы ещё работаем с raw напрямую: `spark.read.parquet(\"s3a://...\")`.\n\nЭто не таблица `Iceberg`, а просто набор parquet-файлов по физическому пути. Нам нужно посмотреть на схему и подготовить явный список колонок для `CTAS`, чтобы не полагаться на `SELECT *`."
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "61f18800",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"raw_yellow_df = spark.read.parquet(RAW_YELLOW_URI)\n",
|
||||
"raw_row_count = raw_yellow_df.count()\n",
|
||||
"\n",
|
||||
"print(f\"Прочитано raw-строк: {raw_row_count:,}\")\n",
|
||||
"print(f\"Количество колонок: {len(raw_yellow_df.columns)}\")\n",
|
||||
"raw_yellow_df.show(10, truncate=False)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "a04c71e6",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"raw_yellow_df.printSchema()\n",
|
||||
"\n",
|
||||
"pd.DataFrame(raw_yellow_df.dtypes, columns=[\"column\", \"spark_type\"])\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "ce148bf6",
|
||||
"metadata": {},
|
||||
"source": "Ниже список колонок, который мы явно зафиксируем в `CTAS`.\n\nПочему это лучше, чем `SELECT *`:\n\n- порядок и состав колонок становятся явными;\n- проще заметить неожиданные изменения источника;\n- загрузка в `bronze` остаётся сознательным решением, а не непрозрачным автоматизмом."
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "305ec274",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"expected_columns = [\n",
|
||||
" \"VendorID\",\n",
|
||||
" \"tpep_pickup_datetime\",\n",
|
||||
" \"tpep_dropoff_datetime\",\n",
|
||||
" \"passenger_count\",\n",
|
||||
" \"trip_distance\",\n",
|
||||
" \"RatecodeID\",\n",
|
||||
" \"store_and_fwd_flag\",\n",
|
||||
" \"PULocationID\",\n",
|
||||
" \"DOLocationID\",\n",
|
||||
" \"payment_type\",\n",
|
||||
" \"fare_amount\",\n",
|
||||
" \"extra\",\n",
|
||||
" \"mta_tax\",\n",
|
||||
" \"tip_amount\",\n",
|
||||
" \"tolls_amount\",\n",
|
||||
" \"improvement_surcharge\",\n",
|
||||
" \"total_amount\",\n",
|
||||
" \"congestion_surcharge\",\n",
|
||||
" \"Airport_fee\",\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"missing_columns = sorted(set(expected_columns).difference(raw_yellow_df.columns))\n",
|
||||
"assert not missing_columns, (\n",
|
||||
" \"В raw-схеме не хватает ожидаемых колонок: \"\n",
|
||||
" f\"{missing_columns}. Проверь data bundle и инструкцию из START_HERE.md.\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"pd.DataFrame({\n",
|
||||
" \"column\": expected_columns,\n",
|
||||
" \"present_in_raw\": [column in raw_yellow_df.columns for column in expected_columns],\n",
|
||||
"})\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "350d2087",
|
||||
"metadata": {},
|
||||
"source": "## 3. Создание `bronze` namespace и `Iceberg`-таблицы\n\nСначала создаём namespace `lakehouse.bronze`, а затем выполняем `CTAS` (`CREATE OR REPLACE TABLE ... USING iceberg AS SELECT ...`).\n\nЧто такое namespace в `Iceberg`:\n\n- это логический контейнер для таблиц;\n- если ты работал с `PostgreSQL` — это ближайший аналог `schema` внутри базы;\n- запись `lakehouse.bronze.nyc_taxi_yellow` читается как `catalog.namespace.table`.\n\nПочему здесь используем именно `bronze`, а не `module_04`:\n\n- имя сразу отражает роль слоя в пайплайне `raw -> bronze -> silver`;\n- Модуль 5 будет читать именно `lakehouse.bronze.*`, и семантическое имя делает маршрут прозрачным;\n- в отличие от демонстрационного `module_02`, этот слой не временный и будет использоваться в следующих модулях.\n\nЭто удобный учебный компромисс:\n\n- один шаг создаёт таблицу и сразу наполняет её;\n- повторный запуск секции остаётся идемпотентным;\n- минус: `CREATE OR REPLACE` пересоздаёт таблицу и сбрасывает старую историю snapshot-ов.\n\nДля первого `bronze`-слоя это допустимо. В Модуле 7 мы отдельно разберём, почему в рабочих сценариях нужно аккуратнее относиться к истории таблицы."
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "a52a03b4",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"spark.sql(f\"CREATE NAMESPACE IF NOT EXISTS {BRONZE_NAMESPACE}\")\n",
|
||||
"raw_yellow_df.createOrReplaceTempView(\"raw_yellow\")\n",
|
||||
"\n",
|
||||
"ctas_sql = f\"\"\"\n",
|
||||
"CREATE OR REPLACE TABLE {BRONZE_TABLE}\n",
|
||||
"USING iceberg\n",
|
||||
"AS\n",
|
||||
"SELECT\n",
|
||||
" VendorID,\n",
|
||||
" tpep_pickup_datetime,\n",
|
||||
" tpep_dropoff_datetime,\n",
|
||||
" passenger_count,\n",
|
||||
" trip_distance,\n",
|
||||
" RatecodeID,\n",
|
||||
" store_and_fwd_flag,\n",
|
||||
" PULocationID,\n",
|
||||
" DOLocationID,\n",
|
||||
" payment_type,\n",
|
||||
" fare_amount,\n",
|
||||
" extra,\n",
|
||||
" mta_tax,\n",
|
||||
" tip_amount,\n",
|
||||
" tolls_amount,\n",
|
||||
" improvement_surcharge,\n",
|
||||
" total_amount,\n",
|
||||
" congestion_surcharge,\n",
|
||||
" Airport_fee\n",
|
||||
"FROM raw_yellow\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"print(\"Temp view ready: raw_yellow\")\n",
|
||||
"print(ctas_sql)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "773150ad",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Справочная форма DDL, более похожая на привычный `PostgreSQL`-стиль, могла бы выглядеть так:\n",
|
||||
"\n",
|
||||
"```sql\n",
|
||||
"CREATE TABLE lakehouse.bronze.nyc_taxi_yellow (\n",
|
||||
" VendorID BIGINT,\n",
|
||||
" tpep_pickup_datetime TIMESTAMP,\n",
|
||||
" tpep_dropoff_datetime TIMESTAMP,\n",
|
||||
" passenger_count DOUBLE,\n",
|
||||
" trip_distance DOUBLE,\n",
|
||||
" RatecodeID DOUBLE,\n",
|
||||
" store_and_fwd_flag STRING,\n",
|
||||
" PULocationID BIGINT,\n",
|
||||
" DOLocationID BIGINT,\n",
|
||||
" payment_type BIGINT,\n",
|
||||
" fare_amount DOUBLE,\n",
|
||||
" extra DOUBLE,\n",
|
||||
" mta_tax DOUBLE,\n",
|
||||
" tip_amount DOUBLE,\n",
|
||||
" tolls_amount DOUBLE,\n",
|
||||
" improvement_surcharge DOUBLE,\n",
|
||||
" total_amount DOUBLE,\n",
|
||||
" congestion_surcharge DOUBLE,\n",
|
||||
" Airport_fee DOUBLE\n",
|
||||
") USING iceberg;\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"Но в этом модуле мы используем именно `CTAS`, потому что хотим одной операцией и создать таблицу, и загрузить в неё raw-срез.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "3fa6f522",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Для полного набора из нескольких месяцев эта ячейка может выполняться 1-3 минуты.\n",
|
||||
"spark.sql(ctas_sql)\n",
|
||||
"\n",
|
||||
"bronze_df = spark.table(BRONZE_TABLE)\n",
|
||||
"bronze_row_count = bronze_df.count()\n",
|
||||
"\n",
|
||||
"print(f\"Строк в raw: {raw_row_count:,}\")\n",
|
||||
"print(f\"Строк в bronze: {bronze_row_count:,}\")\n",
|
||||
"assert bronze_row_count == raw_row_count, \"Количество строк в bronze не совпало с raw\"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "70dead5a",
|
||||
"metadata": {},
|
||||
"source": "## 4. Читаем уже не raw, а управляемую таблицу\n\nТеперь ключевое отличие:\n\n- `spark.read.parquet(\"s3a://...\")` читает набор файлов по физическому пути;\n- `spark.table(\"lakehouse.bronze.nyc_taxi_yellow\")` читает логическую таблицу через каталог `Iceberg`.\n\nПо результату оба варианта могут вернуть похожие строки. Но второй вариант работает с каталогом и metadata: знает схему, историю изменений и согласованное состояние таблицы."
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "252cba12",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"bronze_df.show(10, truncate=False)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "abfd4ee4",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"comparison_df = pd.DataFrame(\n",
|
||||
" [\n",
|
||||
" {\n",
|
||||
" \"mode\": \"raw path\",\n",
|
||||
" \"reader\": f\"spark.read.parquet('{RAW_YELLOW_URI}')\",\n",
|
||||
" \"row_count\": raw_row_count,\n",
|
||||
" \"object\": \"набор parquet-файлов\",\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"mode\": \"catalog table\",\n",
|
||||
" \"reader\": f\"spark.table('{BRONZE_TABLE}')\",\n",
|
||||
" \"row_count\": bronze_row_count,\n",
|
||||
" \"object\": \"Iceberg-таблица\",\n",
|
||||
" },\n",
|
||||
" ]\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"comparison_df\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "5b4dffa6",
|
||||
"metadata": {},
|
||||
"source": "Эту разницу полезно зафиксировать в одной таблице:\n\n| Свойство | Raw-файлы | Iceberg-таблица |\n| --- | --- | --- |\n| Каталогизация | Нет | Да (`PostgreSQL` JDBC catalog) |\n| История изменений | Нет | `Snapshots` |\n| Контроль схемы | Выводится из файлов | Зафиксирована в metadata |\n| Чтение из `Trino` | Невозможно без внешней таблицы | Да |\n| Атомарность записи | Нет | Да |"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "ccb4fb69",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5. Что знает `Iceberg` о таблице: snapshots, history, files\n",
|
||||
"\n",
|
||||
"У `Iceberg` есть встроенные metadata-таблицы. Это один из самых наглядных ответов на вопрос, почему таблица не сводится к «папке с parquet-файлами».\n",
|
||||
"\n",
|
||||
"Нас интересуют три представления:\n",
|
||||
"\n",
|
||||
"- `.snapshots` — какие snapshots есть у таблицы;\n",
|
||||
"- `.history` — как менялось текущее состояние таблицы;\n",
|
||||
"- `.files` — какие data files входят в текущий snapshot.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "03fa2fc4",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"spark.sql(\n",
|
||||
" f\"SELECT committed_at, snapshot_id, parent_id, operation, manifest_list, summary \"\n",
|
||||
" f\"FROM {BRONZE_TABLE}.snapshots ORDER BY committed_at DESC\"\n",
|
||||
").toPandas()\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "3f048c15",
|
||||
"metadata": {},
|
||||
"source": "Как читать поля в `.snapshots`:\n\n- `committed_at` — когда snapshot стал текущим;\n- `snapshot_id` — уникальный идентификатор версии таблицы;\n- `operation` — какая операция создала snapshot, например `append` или `replace`;\n- `summary` — краткое описание результата операции: сколько файлов добавлено, сколько строк записано и т.д.\n\nНа практике это можно воспринимать как именованный checkpoint таблицы: каждая запись в истории фиксирует согласованное состояние, к которому движок может обратиться."
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "db404328",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"spark.sql(\n",
|
||||
" f\"SELECT made_current_at, snapshot_id, parent_id, is_current_ancestor \"\n",
|
||||
" f\"FROM {BRONZE_TABLE}.history ORDER BY made_current_at DESC\"\n",
|
||||
").toPandas()\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "dc0f3c91",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"files_df = spark.sql(\n",
|
||||
" f\"\"\"\n",
|
||||
" SELECT\n",
|
||||
" content,\n",
|
||||
" file_path,\n",
|
||||
" file_format,\n",
|
||||
" record_count,\n",
|
||||
" file_size_in_bytes\n",
|
||||
" FROM {BRONZE_TABLE}.files\n",
|
||||
" ORDER BY file_path\n",
|
||||
" \"\"\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"files_pd = files_df.toPandas()\n",
|
||||
"files_pd[\"file_size_human\"] = files_pd[\"file_size_in_bytes\"].map(format_bytes)\n",
|
||||
"files_pd\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "a9cfcaa6",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"file_summary = pd.DataFrame(\n",
|
||||
" [\n",
|
||||
" {\n",
|
||||
" \"metric\": \"data_files_count\",\n",
|
||||
" \"value\": len(files_pd),\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"metric\": \"total_records_in_files\",\n",
|
||||
" \"value\": int(files_pd[\"record_count\"].sum()) if not files_pd.empty else 0,\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"metric\": \"total_file_size_bytes\",\n",
|
||||
" \"value\": int(files_pd[\"file_size_in_bytes\"].sum()) if not files_pd.empty else 0,\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"metric\": \"total_file_size_human\",\n",
|
||||
" \"value\": format_bytes(int(files_pd[\"file_size_in_bytes\"].sum())) if not files_pd.empty else \"0 B\",\n",
|
||||
" },\n",
|
||||
" ]\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"file_summary\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "bc85ca8f",
|
||||
"metadata": {},
|
||||
"source": "`Iceberg` хранит не только путь к данным, но и состояние таблицы как набора metadata-артефактов.\n\nПрактический смысл:\n\n- snapshot фиксирует согласованную версию таблицы;\n- manifests описывают, какие data files входят в snapshot;\n- каталог указывает на актуальный metadata-файл таблицы.\n\nИменно поэтому удалить один parquet-файл «руками» из `MinIO` опаснее, чем кажется: metadata продолжит ссылаться на него, а таблица станет неконсистентной."
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "d48ea169",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6. Физическая структура таблицы в `MinIO`\n",
|
||||
"\n",
|
||||
"Теперь посмотрим на ту же таблицу с физической стороны. Нас интересуют две группы объектов:\n",
|
||||
"\n",
|
||||
"- `data/` — parquet-файлы с данными;\n",
|
||||
"- `metadata/` — metadata JSON, manifest list и manifests.\n",
|
||||
"\n",
|
||||
"Это хороший момент, чтобы связать логическую таблицу и физическую структуру в object storage.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "9d2d088d",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"bronze_location = get_table_location(BRONZE_TABLE)\n",
|
||||
"bronze_prefix = bronze_location.replace(f\"s3a://{BUCKET_NAME}/\", \"\").rstrip(\"/\") + \"/\"\n",
|
||||
"bronze_objects = list_objects(BUCKET_NAME, bronze_prefix)\n",
|
||||
"assert bronze_objects, f\"По пути {bronze_location} не найдены объекты таблицы\"\n",
|
||||
"\n",
|
||||
"bronze_storage_report = pd.DataFrame(\n",
|
||||
" [\n",
|
||||
" {\n",
|
||||
" \"key\": obj[\"Key\"],\n",
|
||||
" \"kind\": \"DATA\" if \"/data/\" in obj[\"Key\"] else \"META\" if \"/metadata/\" in obj[\"Key\"] else \"OTHER\",\n",
|
||||
" \"size_bytes\": obj[\"Size\"],\n",
|
||||
" \"size_human\": format_bytes(obj[\"Size\"]),\n",
|
||||
" }\n",
|
||||
" for obj in bronze_objects\n",
|
||||
" ]\n",
|
||||
").sort_values([\"kind\", \"key\"]).reset_index(drop=True)\n",
|
||||
"\n",
|
||||
"print(f\"Table location: {bronze_location}\")\n",
|
||||
"bronze_storage_report\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "c2fbc7e1",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"storage_summary = bronze_storage_report.groupby(\"kind\", as_index=False)[\"size_bytes\"].sum()\n",
|
||||
"storage_summary[\"size_human\"] = storage_summary[\"size_bytes\"].map(format_bytes)\n",
|
||||
"storage_summary\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "2fb4911d",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"root = PurePosixPath(bronze_prefix.rstrip(\"/\"))\n",
|
||||
"rendered_lines = [str(root)]\n",
|
||||
"\n",
|
||||
"for key in bronze_storage_report[\"key\"]:\n",
|
||||
" rel = PurePosixPath(key).relative_to(root)\n",
|
||||
" parts = rel.parts\n",
|
||||
" for depth, part in enumerate(parts):\n",
|
||||
" prefix = \" \" * depth + (\"- \" if depth else \"\")\n",
|
||||
" rendered = prefix + part\n",
|
||||
" if rendered not in rendered_lines:\n",
|
||||
" rendered_lines.append(rendered)\n",
|
||||
"\n",
|
||||
"print(\"Структура каталога таблицы:\\n\")\n",
|
||||
"print(\"\\n\".join(rendered_lines))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "ef5b24bf",
|
||||
"metadata": {},
|
||||
"source": "Обычно после первого `CTAS` ты увидишь примерно такую картину:\n\n```text\nwarehouse/bronze/nyc_taxi_yellow/\n - data\n - *.parquet\n - metadata\n - v1.metadata.json\n - snap-*.avro\n - *.avro\n```\n\nСмысл структуры:\n\n- `data/` хранит содержимое таблицы;\n- `metadata/` хранит описание состояния таблицы;\n- JDBC catalog знает, где лежит актуальный metadata-файл.\n\nЭто и есть практическая разница между «таблица как логический объект» и «папка с parquet-файлами»."
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "2b58a761",
|
||||
"metadata": {},
|
||||
"source": "## 7. Почему `raw -> bronze` похоже на `stg -> ods`\n\nВ классическом DWH часто есть переход `stg -> ods`: сначала источник попадает в staging, потом появляется первая рабочая таблица, с которой удобно работать дальше.\n\nВ этом курсе аналог выглядит так:\n\n- `raw` — неизменяемый архив исходных файлов в object storage;\n- `bronze` — первая управляемая таблица на `Iceberg`, которую можно читать через каталог, осматривать через metadata-таблицы и позже запрашивать из `Trino`;\n- `silver` — следующий слой, где появятся нормализация, очистка и правила трансформаций.\n\nПоэтому `bronze` не равно `raw`:\n\n- raw нужен как воспроизводимая точка входа;\n- bronze нужен как управляемая таблица с каталогом и snapshot-ами;\n- bronze можно пересоздать из raw, если логика меняется."
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "320c0199",
|
||||
"metadata": {},
|
||||
"source": "## 8. Вторая 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",
|
||||
"execution_count": null,
|
||||
"id": "e94508bf",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"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",
|
||||
"execution_count": null,
|
||||
"id": "8e058aeb",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"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": "markdown",
|
||||
"execution_count": null,
|
||||
"id": "41c2c79a",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"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",
|
||||
"execution_count": null,
|
||||
"id": "80b392e1",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": "# Ваш код: snapshots и files для taxi_zone_lookup\n"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "61e90959",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": "# Ваш код: сравни количество data files в lookup и yellow taxi\n"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "24c04d60",
|
||||
"metadata": {},
|
||||
"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": "## 11. Завершение\n\nМы намеренно не удаляем bronze-таблицы (`nyc_taxi_yellow` и `taxi_zone_lookup`). Они понадобятся в Модуле 5, где мы будем строить silver-слой."
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "078b9876",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"spark.stop()\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.10.12"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "ca5e5822-18cb-405e-90f3-a0f2f8ad2260",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stderr",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Setting default log level to \"WARN\".\n",
|
||||
"To adjust logging level use sc.setLogLevel(newLevel). For SparkR, use setLogLevel(newLevel).\n",
|
||||
"25/12/03 09:08:48 WARN NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable\n",
|
||||
"25/12/03 09:08:52 WARN MetricsConfig: Cannot locate configuration: tried hadoop-metrics2-s3a-file-system.properties,hadoop-metrics2.properties\n",
|
||||
" \r"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"+---+---+\n",
|
||||
"| id|txt|\n",
|
||||
"+---+---+\n",
|
||||
"| 1| ok|\n",
|
||||
"+---+---+\n",
|
||||
"\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from pyspark.sql import SparkSession\n",
|
||||
"\n",
|
||||
"spark = (\n",
|
||||
" SparkSession.builder\n",
|
||||
" .appName(\"check\")\n",
|
||||
" .master(\"spark://spark-master:7077\")\n",
|
||||
" .getOrCreate()\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"spark.sql(\"\"\"\n",
|
||||
" CREATE TABLE IF NOT EXISTS lakehouse.default.demo_fix (\n",
|
||||
" id BIGINT,\n",
|
||||
" txt STRING\n",
|
||||
" )\n",
|
||||
" USING iceberg\n",
|
||||
"\"\"\")\n",
|
||||
"\n",
|
||||
"spark.sql(\"INSERT INTO lakehouse.default.demo_fix VALUES (1, 'ok')\")\n",
|
||||
"\n",
|
||||
"spark.sql(\"SELECT * FROM lakehouse.default.demo_fix\").show()\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "1e125075-feab-4974-baa2-f787e41b46fd",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.10.12"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Планы работ
|
||||
|
||||
Этот каталог хранит внутренние рабочие планы по развитию учебного курса.
|
||||
|
||||
## Правила хранения
|
||||
|
||||
- Формат: `living docs`.
|
||||
- История изменений хранится в git.
|
||||
- Именование файлов: `module-NN-<english-slug-from-module-title>.md`.
|
||||
- В каждом плане фиксируются `Статус` и `Последнее обновление`.
|
||||
|
||||
## Оглавление
|
||||
|
||||
| Модуль | План | Статус | Целевые артефакты | Обновлён |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1. Вход в стенд и базовая диагностика | [module-01-environment-and-basic-diagnostics.md](./module-01-environment-and-basic-diagnostics.md) | Ready for validation | `START_HERE.md`, `notebooks/01_environment_and_smoke_test.ipynb`, `src/spark/cluster_smoke.py` | 2026-03-06 |
|
||||
| 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) | 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 |
|
||||
@@ -0,0 +1,67 @@
|
||||
# Модуль 1. Вход в стенд и базовая диагностика
|
||||
|
||||
**Статус:** `Ready for validation`
|
||||
**Последнее обновление:** `2026-03-06`
|
||||
|
||||
## Цель
|
||||
|
||||
Дать студенту безопасный и воспроизводимый вход в курс: он должен сам поднять локальный стенд, открыть основные веб-интерфейсы, выполнить Spark smoke test и понять первый маршрут диагностики проблем.
|
||||
|
||||
## Результат для студента
|
||||
|
||||
После прохождения модуля студент:
|
||||
|
||||
- поднимает стенд командами `docker compose build` и `docker compose up -d`;
|
||||
- проверяет состояние контейнеров через `docker compose ps`;
|
||||
- открывает `Spark UI`, `MinIO Console`, `Trino UI` и `JupyterLab`;
|
||||
- запускает smoke test Spark-кластера и понимает, что именно он проверяет;
|
||||
- знает, куда смотреть при первичной диагностике через логи и UI.
|
||||
|
||||
## Deliverables
|
||||
|
||||
- `plans/README.md` как индекс всех рабочих планов;
|
||||
- `START_HERE.md` как канонический вход в курс до первого ноутбука;
|
||||
- `notebooks/01_environment_and_smoke_test.ipynb` как канонический ноутбук Модуля 1;
|
||||
- `src/spark/cluster_smoke.py` как общий helper и CLI smoke test;
|
||||
- обновлённые ссылки и инструкции в `README.md` и `AGENTS.md`.
|
||||
|
||||
## План работ
|
||||
|
||||
1. Создать каталог `plans/` и зафиксировать в нём правила хранения и индекс.
|
||||
2. Подготовить `START_HERE.md` с prerequisites, запуском, проверкой сервисов, входом в Jupyter и reset/restart шагами.
|
||||
3. Расширить `src/spark/cluster_smoke.py` до reusable helper с функцией `run_cluster_smoke(spark) -> dict` и CLI `main()`.
|
||||
4. Собрать `notebooks/01_environment_and_smoke_test.ipynb` по схеме `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`.
|
||||
5. Перенести полезное содержимое из старого `spark-basic-test.ipynb` в новый ноутбук и убрать старый артефакт из активного маршрута.
|
||||
6. Обновить `README.md` и `AGENTS.md`, чтобы новый вход и новый ноутбук стали каноническими.
|
||||
7. Прогнать валидацию: smoke script должен проходить синтаксическую проверку, а ноутбук должен быть валидным `ipynb`.
|
||||
|
||||
## Checkpoint
|
||||
|
||||
Студент должен уметь:
|
||||
|
||||
- показать результат `docker compose ps`;
|
||||
- открыть `Spark UI`, `MinIO Console`, `Trino UI` и `JupyterLab`;
|
||||
- выполнить smoke test и объяснить, что он подтвердил;
|
||||
- назвать роли `MinIO`, `PostgreSQL`, `Spark`, `Trino` и `Jupyter`;
|
||||
- описать первый шаг диагностики, если не поднимается `trino` или `spark-master`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `START_HERE.md` позволяет поднять стенд с нуля без обращения к `docs/archive/legacy_howto.md`;
|
||||
- `notebooks/01_environment_and_smoke_test.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении;
|
||||
- один и тот же smoke helper используется из CLI и из ноутбука;
|
||||
- в репозитории остаётся один канонический вход в Модуль 1;
|
||||
- `plans/README.md` позволяет быстро найти модульный план и понять его статус.
|
||||
|
||||
## Риски
|
||||
|
||||
- Старые ссылки на `spark-basic-test.ipynb` могут остаться в инструкциях и создавать путаницу.
|
||||
- Если smoke test будет слишком сложным, он перестанет быть быстрым диагностическим шагом.
|
||||
- Если в ноутбуке окажутся host-level команды, студент может ошибочно пытаться запускать `docker compose` внутри Jupyter.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- загрузка raw-данных;
|
||||
- разбор internals `Iceberg`;
|
||||
- запросы к `Trino` как отдельная практика;
|
||||
- `schema evolution`, `time travel`, `compaction`, `vacuum`.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Модуль 2. Ментальная модель Lakehouse: storage, catalog, compute
|
||||
|
||||
**Статус:** `Ready for validation`
|
||||
**Последнее обновление:** `2026-03-07`
|
||||
|
||||
## Цель
|
||||
|
||||
Убрать "магическое" восприятие Lakehouse и связать новую модель с привычным миром классических монолитных СУБД (PostgreSQL/Greenplum). Студент должен на практике увидеть физическое разделение ролей: где лежат данные, где хранятся метаданные, и кто выполняет вычисления.
|
||||
|
||||
## Результат для студента
|
||||
|
||||
После прохождения модуля студент:
|
||||
|
||||
- понимает, что таблица в Lakehouse в практическом смысле — это согласованный набор файлов данных и метаданных;
|
||||
- может проследить цепочку записи `Spark -> catalog -> MinIO` на примере маленькой demo-таблицы;
|
||||
- осознает разницу между "одной базой данных" и набором независимых компонентов Lakehouse;
|
||||
- умеет находить физические артефакты таблицы в объектном хранилище (MinIO);
|
||||
- умеет находить записи о таблице в каталоге метаданных (PostgreSQL);
|
||||
- не путает физическое хранение с логической таблицей.
|
||||
|
||||
## Deliverables
|
||||
|
||||
- `notebooks/02_lakehouse_mental_model.ipynb` как основной ноутбук Модуля 2;
|
||||
- Подготовленные диагностические шаги (например, вспомогательные функции в `src/spark` или запросы/скрипты прямо в ноутбуке) для инспекции MinIO и PostgreSQL;
|
||||
- Обновление индекса планов в `plans/README.md`.
|
||||
|
||||
## План работ
|
||||
|
||||
1. Подготовить структуру `notebooks/02_lakehouse_mental_model.ipynb` по методике курса: `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`.
|
||||
2. Написать короткий теоретический блок с параллелями: классический DWH (Greenplum/Postgres) vs. Lakehouse (Storage + Catalog + Compute).
|
||||
3. Сделать готовую демонстрацию: создание отдельного namespace (например, `lakehouse.module_02`) и небольшой demo-таблицы через Spark, запись пары строк. Для обеспечения идемпотентности и чистоты при повторном прохождении использовать `CREATE OR REPLACE TABLE` (или очистку таблицы перед записью) и конструкции `IF NOT EXISTS` для namespace.
|
||||
4. Разработать шаги для инспекции:
|
||||
- запрос к PostgreSQL (каталогу метаданных), чтобы увидеть регистрацию таблицы;
|
||||
- запрос к MinIO (например, s3a list или boto3), чтобы увидеть появление файлов данных (parquet) и метаданных (json/avro).
|
||||
5. Сформулировать самостоятельное задание: студент создает свою таблицу, пишет в нее данные и находит её следы в хранилище и каталоге.
|
||||
6. Включить в конец ноутбука обязательный шаг очистки (cleanup) — удаление таблиц и namespace `module_02`, чтобы не оставлять мусор для следующих модулей.
|
||||
7. Описать вопросы для Checkpoint.
|
||||
|
||||
## Checkpoint
|
||||
|
||||
Студент должен уметь:
|
||||
|
||||
- показать конкретные файлы данных и метаданных созданной таблицы в MinIO (через UI или код);
|
||||
- показать запись о таблице в PostgreSQL и объяснить, на что указывает поле `metadata_location`;
|
||||
- объяснить, что произойдёт, если удалить файлы из MinIO, но не трогать каталог (или наоборот);
|
||||
- сопоставить эти наблюдения с ролями `storage` и `catalog`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Ноутбук `02_lakehouse_mental_model.ipynb` полностью раскрывает тему декомпозиции Lakehouse без глубокого погружения в спецификацию Iceberg.
|
||||
- Демонстрации работают в локальном окружении (Jupyter) и наглядно показывают изменения в MinIO и PostgreSQL.
|
||||
- Самостоятельное задание выполнимо на базе показанных примеров.
|
||||
- Модуль 2 добавлен в индекс `plans/README.md`.
|
||||
|
||||
## Риски
|
||||
|
||||
- Излишнее углубление во внутренности Iceberg-манифестов. Нужно держать фокус только на факте их наличия и разделения `data/metadata`.
|
||||
- Доступность PostgreSQL/MinIO из Jupyter: необходимо убедиться, что в `jupyter/Dockerfile` установлены `boto3` и `psycopg2-binary`, а также учитывать DNS-резолв `postgres-iceberg` из контейнера `jupyter` (сеть `spark-net` это позволяет, но могут быть нюансы).
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Загрузка и чтение сырых данных (NYC Taxi) — отложено до Модуля 3.
|
||||
- Сложные трансформации.
|
||||
- Чтение одной таблицы двумя движками (Spark и Trino) — отложено до Модуля 6 (в этом модуле чтение демо-таблицы будет только через Spark SQL для базовой верификации записи).
|
||||
@@ -0,0 +1,173 @@
|
||||
# Модуль 3. Raw-данные и первое чтение в Spark
|
||||
|
||||
**Статус:** `Ready for validation`
|
||||
**Последнее обновление:** `2026-03-07`
|
||||
|
||||
## Цель
|
||||
|
||||
Научить студента полному циклу работы с raw-данными в Lakehouse: доставка заранее скачанного PARQUET-датасета в raw-зону MinIO, чтение raw-данных через Spark, проверка схемы, типов и базового качества. Студент должен понять, зачем нужен raw-слой и почему raw-данные не следует «чинить на месте».
|
||||
|
||||
## Результат для студента
|
||||
|
||||
После прохождения модуля студент:
|
||||
|
||||
- понимает, как локальный data bundle попадает в raw-зону MinIO;
|
||||
- умеет загружать PARQUET-файлы в MinIO программно через `boto3`;
|
||||
- умеет читать raw-данные из MinIO через Spark (`spark.read.parquet` по `s3a://` пути);
|
||||
- умеет проверять схему DataFrame (`.printSchema()`, `.dtypes`), считать базовые метрики (`.describe()`, `.count()`, null-доли);
|
||||
- может найти простые аномалии или проблемные значения в реальном датасете;
|
||||
- понимает, почему raw-слой нужен как воспроизводимая неизменяемая точка входа;
|
||||
- понимает, почему датасет не следует скачивать из интернета прямо в ноутбуке.
|
||||
|
||||
## Deliverables
|
||||
|
||||
- `notebooks/03_raw_ingest_and_first_read.ipynb` — основной ноутбук Модуля 3;
|
||||
- изменение `docker-compose.yml` — volume mount `./data:/opt/data:ro` в сервис `jupyter`;
|
||||
- изменение `.gitignore` — добавление `/data/`;
|
||||
- изменение `START_HERE.md` — секция «Подготовка учебного датасета (перед Модулем 3)» + обновление маршрута прохождения и секции «Как работать в Jupyter» (добавить упоминание `/opt/data`);
|
||||
- изменение `docs/stack_reference.md` — обновление секции JupyterLab (добавить `./data` mount);
|
||||
- обновление индекса планов в `plans/README.md`.
|
||||
|
||||
## Зависимости от инфраструктуры
|
||||
|
||||
### Доставка данных в контейнер
|
||||
|
||||
Добавить volume mount `./data:/opt/data:ro` в секцию `jupyter.volumes` файла `docker-compose.yml` (аналогично `./src:/opt/src:ro`).
|
||||
|
||||
Студент заранее скачивает PARQUET на хост по инструкции из `START_HERE.md`. В ноутбуке студент загружает файлы из `/opt/data/` в MinIO через `boto3`. Это делает процесс наглядным и воспроизводимым.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml, секция jupyter.volumes
|
||||
volumes:
|
||||
- ./spark/spark-defaults.conf:/opt/spark/conf/spark-defaults.conf:ro
|
||||
- ./notebooks:/opt/work
|
||||
- ./src:/opt/src:ro
|
||||
- ./data:/opt/data:ro # учебный data bundle (NYC Taxi)
|
||||
```
|
||||
|
||||
### Инструкция скачивания датасета
|
||||
|
||||
Добавить в `START_HERE.md` секцию после «Restart и reset», перед «Что делать дальше»:
|
||||
|
||||
- `mkdir -p data/nyc_taxi`
|
||||
- 3 команды `curl` для Yellow Tripdata 2024-01..03 с `https://d37ci6vzurychx.cloudfront.net/trip-data/`
|
||||
- 1 команда `curl` для `taxi_zone_lookup.csv` с `https://d37ci6vzurychx.cloudfront.net/misc/`
|
||||
- Проверочный `ls -lh data/nyc_taxi/`
|
||||
- Инструкция фиксирует конкретные файлы (2024-01, 2024-02, 2024-03) как каноническое учебное подмножество; расширенный режим — до 12 месяцев 2024 года
|
||||
- Пометка: после изменения `docker-compose.yml` (добавления volume) нужен `docker compose up -d` для пересоздания контейнера `jupyter`; после этого новые файлы в `./data` видны сразу без перезапуска
|
||||
|
||||
### Структура raw-зоны в MinIO
|
||||
|
||||
Путь: `s3a://lakehouse/raw/nyc_taxi/<original_filename>`. Отдельный prefix `raw/` не смешивается с `warehouse/` (Iceberg-таблицы). Имена файлов сохраняются оригинальными, чтобы подчеркнуть принцип «raw не изменяется».
|
||||
|
||||
### .gitignore
|
||||
|
||||
Добавить в конец:
|
||||
|
||||
```
|
||||
# Dataset (downloaded by student, not stored in repo)
|
||||
/data/
|
||||
```
|
||||
|
||||
## План работ
|
||||
|
||||
1. Добавить `/data/` в `.gitignore`.
|
||||
2. Добавить volume mount `./data:/opt/data:ro` в `docker-compose.yml`, секция `jupyter.volumes`.
|
||||
3. Добавить в `START_HERE.md` секцию «Подготовка учебного датасета (перед Модулем 3)» с воспроизводимыми командами скачивания NYC Taxi Yellow Tripdata PARQUET (3 месяца + taxi zone lookup). Обновить маршрут прохождения (добавить шаг подготовки данных). Обновить секцию «Как работать в Jupyter» — добавить упоминание `./data` mount как `/opt/data`. Обновить секцию «Что делать дальше», чтобы маршрут включал Модуль 3.
|
||||
4. Обновить `docs/stack_reference.md` — в секции JupyterLab добавить строку `./data` смонтирован read-only как `/opt/data`.
|
||||
5. Создать `notebooks/03_raw_ingest_and_first_read.ipynb` по ячеечной структуре, описанной ниже.
|
||||
6. Обновить `plans/README.md` — добавить строку Модуля 3 в таблицу оглавления.
|
||||
7. Валидация: убедиться, что `docker compose config` проходит без ошибок; выполнить ноутбук сверху вниз на поднятом стенде с data bundle.
|
||||
|
||||
## Структура ноутбука
|
||||
|
||||
### Секция 0: Введение
|
||||
- **[md]** Заголовок, цели модуля, prerequisite (data bundle из `START_HERE.md`). Таблица-сравнение: «Классический DWH: данные уже внутри СУБД» vs «Lakehouse: данные нужно явно доставить в storage».
|
||||
|
||||
### Секция 1: Проверка data bundle
|
||||
- **[md]** Зачем проверяем; почему не скачиваем из интернета в ноутбуке (воспроизводимость, сетевые ограничения, разделение ответственности onboarding vs практика).
|
||||
- **[code]** `os.listdir("/opt/data/nyc_taxi")`, вывод файлов с размерами, assert на наличие PARQUET-файлов и `taxi_zone_lookup.csv` — оба нужны для полного прохождения модуля (включая самостоятельное задание в Секции 6). Понятное сообщение с отсылкой к `START_HERE.md` при отсутствии любого из компонентов.
|
||||
|
||||
### Секция 2: Загрузка в raw-зону MinIO
|
||||
- **[md]** Что такое raw-зона, параллель с stg-слоем в Greenplum, конвенция путей, почему raw не чинится «на месте».
|
||||
- **[code]** Демонстрация: перед загрузкой проверяем через `s3.head_object()`, существует ли уже объект с таким ключом. Три ветки: (1) объект не существует — загружаем через `boto3.upload_file()`; (2) объект существует и размер совпадает с локальным файлом — пропускаем с пометкой «already exists, skipping»; (3) объект существует, но размер отличается — **fail fast** с понятной ошибкой (raise), без перезаписи. Это обеспечивает идемпотентность и подкрепляет принцип «raw не перезаписывается». В markdown-пояснении перед ячейкой явно объяснить: повторный запуск безопасен, уже загруженные файлы не перетираются, а конфликт размеров сигнализирует о проблеме с данными.
|
||||
- **[code]** Верификация: `s3.list_objects_v2()` — листинг raw-зоны.
|
||||
- **[md]** Данные теперь в storage. Spark может читать их напрямую по `s3a://` пути; для Trino потребуется зарегистрированная таблица (это будет в Модулях 4 и 6).
|
||||
|
||||
### Секция 3: Первое чтение в Spark
|
||||
- **[md]** Разница `spark.read.parquet("s3a://...")` (raw-файлы) vs `spark.table(...)` (управляемая Iceberg-таблица). Мы пока на этапе raw, Iceberg-таблица появится в Модуле 4.
|
||||
- **[code]** Создание SparkSession (паттерн из Module 2 — без `.master()`, `setLogLevel("ERROR")`).
|
||||
- **[code]** Чтение одного файла: `spark.read.parquet("s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_2024-01.parquet")`.
|
||||
- **[code]** Чтение всех файлов wildcard: `yellow_tripdata_*.parquet` (без привязки к конкретному году в wildcard, чтобы код работал с любыми скачанными файлами).
|
||||
|
||||
### Секция 4: Проверка схемы
|
||||
- **[md]** Зачем проверять схему: в классическом DWH схема задана DDL, в raw — вычитывается из файлов. Что может пойти не так: разные типы в разных файлах, неожиданные nullable-поля.
|
||||
- **[code]** `.printSchema()`.
|
||||
- **[code]** Колонки и типы в удобном формате (`.dtypes`).
|
||||
- **[code]** Null-анализ ключевых колонок (`VendorID`, `tpep_pickup_datetime`, `passenger_count`, `trip_distance`, `total_amount`) — количество и доля null в каждой.
|
||||
- **[md]** Обсуждение: null в raw — норма, не исправляем на месте. Очистка — задача bronze/silver слоев (Модули 4-5).
|
||||
|
||||
### Секция 5: Базовые метрики и аномалии
|
||||
- **[md]** Цель: быстро понять диапазоны значений, выявить очевидные проблемы.
|
||||
- **[code]** `.describe()` на числовых колонках.
|
||||
- **[code]** Поиск аномалий: отрицательные `fare_amount`, нулевая дистанция с ненулевой суммой, `total_amount > $1000`, pickup за пределами ожидаемого диапазона дат (определяется динамически из имён файлов, а не хардкодится).
|
||||
- **[md]** Аномалии в реальных данных — норма, а не баг загрузки. Задача raw — сохранить как есть.
|
||||
|
||||
### Секция 6: Самостоятельное задание
|
||||
- **[md]** Инструкция: (1) прочитать `taxi_zone_lookup.csv` из raw-зоны через Spark, (2) вывести схему + 10 строк, (3) посчитать количество зон по borough, (4) найти топ-5 pickup locations и join с зонами, (5) найти ещё одну аномалию или интересный паттерн в данных.
|
||||
- **[code]** 4 пустых ячейки с `# Ваш код: ...`.
|
||||
|
||||
### Секция 7: Почему raw-слой важен
|
||||
- **[md]** Мини-лекция (3-5 абзацев): raw = неизменяемый архив; параллель с stg/ods в Greenplum; почему не скачиваем в ноутбуке; почему не чиним на месте (можно перестроить bronze/silver заново); в Lakehouse raw-файлы лежат в storage отдельно от compute — Spark читает их напрямую, а для Trino потребуется оформить данные в таблицу.
|
||||
|
||||
### Секция 8: Checkpoint
|
||||
- **[md]** Вопросы для самопроверки: (1) показать файлы в raw-зоне MinIO, (2) какая схема у Yellow Taxi — сколько колонок, (3) какие колонки содержат null — это баг или норма, (4) зачем raw-слой, если данные потом трансформируются, (5) почему не скачиваем из ноутбука, (6) что произойдёт, если NYC TLC изменит формат файла.
|
||||
|
||||
### Секция 9: Завершение
|
||||
- **[md]** «Мы намеренно не удаляем raw-данные из MinIO. В Модуле 4 мы будем использовать их для создания bronze-слоя.»
|
||||
- **[code]** `spark.stop()`.
|
||||
|
||||
### Дизайн-решения по ноутбуку
|
||||
|
||||
- **Helper-код:** весь код inline в ноутбуке. Отдельный модуль в `src/` не создаётся — студент должен видеть каждый шаг явно (`boto3.upload_file()`, `spark.read.parquet()`, `.printSchema()`).
|
||||
- **Cleanup:** ноутбук НЕ удаляет raw-данные из MinIO — они нужны Модулю 4 (bronze). Spark-сессия останавливается. Явное пояснение в конце ноутбука.
|
||||
|
||||
## Checkpoint
|
||||
|
||||
Студент должен уметь:
|
||||
|
||||
- показать файлы в raw-зоне MinIO (через MinIO Console или через код);
|
||||
- прочитать raw PARQUET через Spark и вывести несколько строк;
|
||||
- назвать схему Yellow Taxi (количество колонок, основные типы);
|
||||
- показать, какие колонки содержат null-значения;
|
||||
- объяснить, почему raw-слой нужен как воспроизводимая неизменяемая точка входа;
|
||||
- объяснить, почему данные не скачиваются прямо из ноутбука.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `docker-compose.yml` содержит volume mount `./data:/opt/data:ro` для контейнера `jupyter`, и `docker compose config` проходит без ошибок.
|
||||
- `START_HERE.md` содержит воспроизводимую инструкцию по скачиванию NYC Taxi PARQUET (минимум 3 файла + taxi zone lookup) до запуска Модуля 3.
|
||||
- `.gitignore` содержит `/data/` для исключения датасета из git.
|
||||
- `notebooks/03_raw_ingest_and_first_read.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении при наличии скачанного data bundle.
|
||||
- Ноутбук следует методике курса: `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`.
|
||||
- Ноутбук использует паттерны из Модулей 1-2: `boto3` для MinIO, SparkSession без `.master()`, `setLogLevel("ERROR")`.
|
||||
- Raw-данные остаются в MinIO после выполнения ноутбука (cleanup отсутствует намеренно).
|
||||
- При отсутствии data bundle — понятный assert с отсылкой к `START_HERE.md`.
|
||||
- `docs/stack_reference.md` обновлён: секция JupyterLab содержит `./data` mount.
|
||||
- `plans/README.md` содержит строку Модуля 3 в оглавлении.
|
||||
|
||||
## Риски
|
||||
|
||||
- **Размер данных.** 3 месяца Yellow Taxi — примерно 120-150 MB (~40-50 MB на файл). Комфортно для стенда с 10 GB RAM. Extended (12 месяцев, ~600 MB) может замедлить Spark, но не должен вызывать падения.
|
||||
- **Студент забыл скачать данные.** Assert в Секции 1 с понятным сообщением и ссылкой на `START_HERE.md`.
|
||||
- **NYC TLC изменит формат/URL.** Ноутбук использует wildcard `yellow_tripdata_*.parquet` без привязки к году. Инструкция в `START_HERE.md` фиксирует конкретные файлы 2024-01..03, но если URL станут недоступны — потребуется обновление инструкции.
|
||||
- **Директория `data/` не существует.** Docker volume mount создаст пустую директорию, assert в ноутбуке поймает отсутствие файлов.
|
||||
- **Пересоздание контейнера.** Добавление volume в `docker-compose.yml` требует пересоздания контейнера `jupyter` (`docker compose up -d` достаточно — Docker пересоздаст только изменённый сервис). После этого bind mount уже работает: новые файлы в `./data` видны сразу без дополнительных перезапусков. Указать явно в инструкции.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Создание Iceberg-таблиц — отложено до Модуля 4.
|
||||
- Трансформации и очистка данных — отложено до Модулей 4-5.
|
||||
- Чтение raw-данных через Trino (Trino не умеет читать произвольные файлы без таблицы) — отложено до Модуля 6.
|
||||
- Партиционирование, MERGE, streaming, gold-слой.
|
||||
- Автоматизированный скрипт скачивания (может быть добавлен позже, не является блокером).
|
||||
@@ -0,0 +1,228 @@
|
||||
# Модуль 4. Первая рабочая Iceberg-таблица и слой bronze
|
||||
|
||||
**Статус:** `Draft`
|
||||
**Последнее обновление:** `2026-03-07`
|
||||
|
||||
## Цель
|
||||
|
||||
Научить студента переходу от набора raw-файлов к управляемой Iceberg-таблице. Студент создаёт namespace `bronze`, записывает raw-данные NYC Taxi в Iceberg-таблицу и осматривает внутреннюю структуру таблицы (snapshots, data files, manifests). Студент понимает разницу между «каталогом с Parquet-файлами» и «управляемой таблицей» и связывает bronze-слой с привычным stg/ods-мышлением.
|
||||
|
||||
## Результат для студента
|
||||
|
||||
После прохождения модуля студент:
|
||||
|
||||
- умеет создать Iceberg namespace и таблицу через Spark SQL;
|
||||
- умеет загрузить raw-данные из MinIO в bronze-таблицу через CTAS (`CREATE OR REPLACE TABLE ... AS SELECT`);
|
||||
- понимает разницу между `spark.read.parquet("s3a://...")` (набор файлов) и `spark.table("lakehouse.bronze.nyc_taxi_yellow")` (управляемая таблица);
|
||||
- умеет осмотреть структуру Iceberg-таблицы: snapshots, history, data files;
|
||||
- умеет сопоставить физическую структуру в MinIO (metadata/, data/) с логической таблицей;
|
||||
- понимает, зачем нужен bronze-слой и как он соотносится с stg/ods в классическом DWH;
|
||||
- понимает, что таблица в Lakehouse — это не «один каталог с файлами», а согласованный набор data files + metadata + catalog entry.
|
||||
|
||||
## Deliverables
|
||||
|
||||
- `notebooks/04_bronze_with_iceberg.ipynb` — основной ноутбук Модуля 4;
|
||||
- обновление индекса планов в `plans/README.md`.
|
||||
|
||||
Инфраструктурные изменения не требуются: `docker-compose.yml`, `START_HERE.md`, `docs/stack_reference.md` уже содержат всё необходимое после Модуля 3.
|
||||
|
||||
## Зависимости
|
||||
|
||||
### Предусловия
|
||||
|
||||
- Стенд поднят и работает (Модуль 1).
|
||||
- Студент прошёл Модуль 3: raw-данные загружены в `s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet` и `taxi_zone_lookup.csv`.
|
||||
- Каталог `lakehouse` настроен в `spark-defaults.conf` как JdbcCatalog с warehouse `s3a://lakehouse/warehouse`.
|
||||
|
||||
### Что используют последующие модули
|
||||
|
||||
- Модуль 5 читает `lakehouse.bronze.nyc_taxi_yellow` для построения silver.
|
||||
- Модуль 6 читает bronze/silver через Trino.
|
||||
|
||||
## Дизайн-решения
|
||||
|
||||
### Namespace: `lakehouse.bronze` (не `module_04`)
|
||||
|
||||
Семантическое имя, потому что:
|
||||
- Модуль 5 будет читать `lakehouse.bronze.*` для построения silver — семантическое имя делает связь явной.
|
||||
- Подкрепляет мышление слоями `raw → bronze → silver` (PRD Risk #4: не смешивать роли слоёв).
|
||||
- Модуль 2 использовал `module_02` для throwaway-демо с cleanup. Bronze — постоянный слой без cleanup, поэтому другая конвенция.
|
||||
|
||||
### Создание и наполнение таблицы: CTAS с явным списком колонок
|
||||
|
||||
Используем `CREATE OR REPLACE TABLE ... USING iceberg AS SELECT col1, col2, ... FROM raw_view` (CTAS). Это:
|
||||
- объединяет создание и наполнение в один шаг — понятнее и ближе к `CREATE TABLE AS SELECT` в PostgreSQL;
|
||||
- явный список колонок в SELECT исключает ошибки из-за несовпадения порядка или состава колонок (PRD Risk #3, #5);
|
||||
- `CREATE OR REPLACE` обеспечивает идемпотентность: повторный запуск пересоздаёт таблицу, snapshot history остаётся чистой;
|
||||
- trade-off: `CREATE OR REPLACE` уничтожает историю — в production это опасно, но для первичной загрузки учебного bronze допустимо. В Модуле 7 студент увидит, почему в рабочих сценариях нужен другой подход.
|
||||
|
||||
В Секции 2 DDL с явной схемой показывается как справочный пример (не выполняется) — чтобы студент увидел знакомую форму `CREATE TABLE (col type, ...)`.
|
||||
|
||||
### Инспекция Iceberg: три уровня глубины
|
||||
|
||||
Модуль 2 показал level 1 (catalog entry в PostgreSQL + файлы в MinIO). Модуль 4 идёт глубже:
|
||||
|
||||
- **Spark metadata tables:** `.snapshots`, `.history`, `.files` — встроенные metadata-таблицы Iceberg;
|
||||
- **Физическая структура в MinIO:** boto3 листинг с разделением `[DATA]` / `[META]`, ASCII-дерево каталогов;
|
||||
- **Таблица-сравнение** PostgreSQL/Greenplum vs Iceberg (pg_catalog ↔ JDBC catalog, heap files ↔ Parquet data files, WAL/MVCC ↔ snapshots).
|
||||
|
||||
### Cleanup: нет
|
||||
|
||||
Bronze нужен Модулю 5. Spark-сессия останавливается. Явное пояснение в финале ноутбука: «Мы намеренно не удаляем bronze-таблицы. В Модуле 5 мы будем строить silver на основе bronze.»
|
||||
|
||||
### Taxi zone lookup: демо-секция
|
||||
|
||||
Создание `lakehouse.bronze.taxi_zone_lookup` входит в демонстрационный код ноутбука (Секция 8), а не в самостоятельное задание. Причина: Модуль 5 использует lookup для JOIN в демо-коде — если lookup не создан, студент не может пройти Модуль 5, даже выполняя только готовые ячейки. Принцип: демо-ячейки последующих модулей не должны зависеть от домашних заданий предыдущих.
|
||||
|
||||
## План работ
|
||||
|
||||
1. Создать `notebooks/04_bronze_with_iceberg.ipynb` по ячеечной структуре, описанной ниже.
|
||||
2. Обновить `plans/README.md` — добавить строку Модуля 4 в таблицу оглавления.
|
||||
3. Валидация: убедиться, что ноутбук выполняется сверху вниз на поднятом стенде после прохождения Модуля 3 (raw-данные в MinIO).
|
||||
|
||||
## Структура ноутбука
|
||||
|
||||
### Секция 0: Введение
|
||||
- **[md]** Заголовок: «Модуль 4. Первая рабочая Iceberg-таблица и слой bronze». Цели модуля. Prerequisite: Модуль 3 пройден, raw-данные в MinIO. Таблица-сравнение:
|
||||
|
||||
| | Классический DWH (Greenplum) | Lakehouse |
|
||||
|---|---|---|
|
||||
| Raw-данные | Загружены в stg-таблицу через COPY/gpfdist | Файлы в объектном хранилище (raw-зона) |
|
||||
| Bronze/ODS | `CREATE TABLE AS SELECT` из stg | `CREATE TABLE ... USING iceberg AS SELECT` из raw (CTAS) |
|
||||
| Метаданные | pg_catalog (внутри СУБД) | Внешний каталог (PostgreSQL JDBC) + файлы metadata в MinIO |
|
||||
| История изменений | Нет (или WAL на уровне СУБД) | Snapshots и manifests (встроены в формат) |
|
||||
|
||||
### Секция 1: Spark-сессия и проверка raw
|
||||
- **[code]** SparkSession (паттерн Модулей 2-3: без `.master()`, `setLogLevel("ERROR")`).
|
||||
- **[code]** Быстрая проверка: `spark.read.parquet("s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet").count()` + `.printSchema()`. Assert на ненулевой count с отсылкой к Модулю 3.
|
||||
- **[md]** Сейчас читаем как набор файлов — нет истории, каталогизации, контроля схемы. Дальше создадим управляемую таблицу.
|
||||
|
||||
### Секция 2: Создание namespace и знакомство с DDL
|
||||
- **[md]** Что такое namespace в Iceberg (аналог schema/database в PostgreSQL). Почему `bronze`, а не `module_04`.
|
||||
- **[code]** `CREATE NAMESPACE IF NOT EXISTS lakehouse.bronze`.
|
||||
- **[md]** Как выглядит DDL Iceberg-таблицы: показываем полную схему Yellow Taxi как справочный пример (не выполняем). Это знакомая студенту форма — `CREATE TABLE ... (col type, ...)`, как в PostgreSQL. Объясняем ключевое отличие: `USING iceberg` — указание на формат таблицы.
|
||||
- **[md]** Для создания и одновременного наполнения таблицы данными используем CTAS (CREATE TABLE AS SELECT) — это удобнее, чем отдельные DDL + INSERT. Перечисляем колонки: VendorID, tpep_pickup_datetime, tpep_dropoff_datetime, passenger_count, trip_distance, RatecodeID, store_and_fwd_flag, PULocationID, DOLocationID, payment_type, fare_amount, extra, mta_tax, tip_amount, tolls_amount, improvement_surcharge, total_amount, congestion_surcharge, Airport_fee.
|
||||
|
||||
### Секция 3: Загрузка raw → bronze (CTAS)
|
||||
- **[md]** Аналогия с `CREATE TABLE ods AS SELECT ... FROM stg` в Greenplum. Почему явный список колонок: `SELECT *` привязывается к порядку, а не к именам — это хрупко и может дать некорректное сопоставление полей. Используем `CREATE OR REPLACE TABLE ... AS SELECT col1, col2, ... FROM raw_yellow` — CTAS одновременно создаёт таблицу и наполняет данными.
|
||||
- **[md]** Идемпотентность: `CREATE OR REPLACE` при повторном запуске пересоздаёт таблицу с чистого листа — snapshot history остаётся чистой (один snapshot = одна загрузка). Trade-off: в production `CREATE OR REPLACE` — разрушительная операция (удаляет историю), но для первичной загрузки учебного bronze это осознанный выбор ради простоты. В Модуле 7 увидим, почему в рабочих сценариях нужен другой подход.
|
||||
- **[code]** Чтение raw → `createOrReplaceTempView("raw_yellow")` → `CREATE OR REPLACE TABLE lakehouse.bronze.nyc_taxi_yellow USING iceberg AS SELECT VendorID, tpep_pickup_datetime, ... FROM raw_yellow` (CTAS с явным списком колонок).
|
||||
- **[code]** Верификация: count bronze vs count raw — должны совпасть.
|
||||
|
||||
### Секция 4: Файлы vs управляемая таблица
|
||||
- **[md]** Ключевой момент модуля: два способа доступа к одним и тем же данным.
|
||||
- **[code]** Демо обоих способов: `spark.read.parquet("s3a://...")` vs `spark.table("lakehouse.bronze.nyc_taxi_yellow")`. Оба дают одинаковый count и columns, но таблица даёт каталог, историю, схему, доступ через Trino.
|
||||
- **[md]** Таблица-сравнение свойств:
|
||||
|
||||
| Свойство | Raw файлы | Iceberg-таблица |
|
||||
|---|---|---|
|
||||
| Каталогизация | Нет | Да (PostgreSQL JDBC) |
|
||||
| История изменений | Нет | Snapshots |
|
||||
| Контроль схемы | Выводится из файлов | Зафиксирована в metadata |
|
||||
| Чтение из Trino | Невозможно | Да |
|
||||
| Атомарность записи | Нет | Да |
|
||||
|
||||
### Секция 5: Осмотр структуры Iceberg
|
||||
|
||||
#### 5a: Snapshots
|
||||
- **[md]** Snapshot = «снимок» состояния таблицы. Аналогия с PostgreSQL: как если бы каждая транзакция оставляла именованный checkpoint.
|
||||
- **[code]** `SELECT * FROM lakehouse.bronze.nyc_taxi_yellow.snapshots`.
|
||||
- **[md]** Пояснение полей: `committed_at`, `snapshot_id`, `operation`, `summary`.
|
||||
|
||||
#### 5b: History
|
||||
- **[code]** `SELECT * FROM lakehouse.bronze.nyc_taxi_yellow.history`.
|
||||
- **[md]** Последовательность snapshots. Каждый — результат конкретной операции.
|
||||
|
||||
#### 5c: Data files
|
||||
- **[code]** `SELECT file_path, file_format, record_count, file_size_in_bytes / 1024 / 1024 AS size_mb FROM lakehouse.bronze.nyc_taxi_yellow.files`.
|
||||
- **[md]** Каждый data file — отдельный Parquet в MinIO. Iceberg знает, какие файлы принадлежат текущему snapshot.
|
||||
|
||||
#### 5d: Физическая структура в MinIO
|
||||
- **[code]** Листинг через boto3 с разделением `[DATA]` / `[META]` (паттерн из Module 2). Итого по размерам data vs metadata.
|
||||
- **[md]** ASCII-дерево (после CTAS — один snapshot, одна операция):
|
||||
```
|
||||
warehouse/bronze/nyc_taxi_yellow/
|
||||
├── metadata/
|
||||
│ ├── v1.metadata.json ← CTAS создал таблицу и записал данные (текущий)
|
||||
│ ├── snap-*.avro ← manifest list (один snapshot)
|
||||
│ └── *.avro ← manifests (списки data files)
|
||||
└── data/
|
||||
├── *.parquet ← данные
|
||||
└── ...
|
||||
```
|
||||
CTAS создаёт таблицу и наполняет данными за одну операцию, поэтому в metadata один metadata JSON и один snapshot.
|
||||
В Модуле 7 увидим, как snapshots помогают вернуться к предыдущему состоянию.
|
||||
|
||||
### Секция 6: raw → bronze и привычное мышление
|
||||
- **[md]** Мини-лекция (3-4 абзаца):
|
||||
- В классическом DWH: stg → ods → dds. В Lakehouse: raw → bronze → silver (→ gold, за рамками курса).
|
||||
- Bronze — первый управляемый слой. Содержит данные «как есть» из raw, но уже как Iceberg-таблицу: с каталогом, историей, возможностью чтения через Trino.
|
||||
- Bronze ≠ raw. Raw — неизменяемый архив файлов. Bronze — управляемая таблица, которую можно пересоздать из raw.
|
||||
- В Модуле 5 — silver: очистка, трансформации, бизнес-логика.
|
||||
|
||||
### Секция 7: Вторая 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: Самостоятельное задание
|
||||
- **[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 и зачем он нужен?
|
||||
3. Где физически лежат данные bronze-таблицы и где метаданные?
|
||||
4. Почему bronze — это не то же самое, что raw?
|
||||
5. Что произойдёт, если удалить один data file из MinIO, но не изменить metadata?
|
||||
6. Можете ли вы показать таблицу в MinIO Console (через браузер)?
|
||||
|
||||
### Секция 10: Завершение
|
||||
- **[md]** «Мы намеренно не удаляем bronze-таблицы (`nyc_taxi_yellow` и `taxi_zone_lookup`). Они понадобятся в Модуле 5, где мы будем строить silver-слой.»
|
||||
- **[code]** `spark.stop()`.
|
||||
|
||||
### Дизайн-решения по ноутбуку
|
||||
|
||||
- **Helper-код:** весь код inline в ноутбуке. Студент должен видеть каждый шаг: CTAS, metadata-запросы, boto3.
|
||||
- **Cleanup:** нет. Bronze нужен Модулю 5. Spark-сессия останавливается.
|
||||
|
||||
## Checkpoint
|
||||
|
||||
Студент должен уметь:
|
||||
|
||||
- создать Iceberg namespace и таблицу через Spark SQL;
|
||||
- загрузить raw-данные в bronze-таблицу;
|
||||
- объяснить разницу между чтением raw-файлов и чтением управляемой таблицы;
|
||||
- показать snapshots, history и data files через metadata-таблицы Iceberg;
|
||||
- показать физическую структуру таблицы в MinIO (через UI или код);
|
||||
- объяснить, зачем нужен bronze-слой и чем он отличается от raw.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `notebooks/04_bronze_with_iceberg.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении после прохождения Модуля 3 (raw-данные в MinIO).
|
||||
- Ноутбук следует методике курса: `объяснение → демонстрация → самостоятельное повторение → checkpoint`.
|
||||
- Ноутбук использует паттерны из Модулей 1-3: SparkSession без `.master()`, `setLogLevel("ERROR")`, `boto3` для MinIO.
|
||||
- Ноутбук идемпотентен: повторное выполнение (`CREATE OR REPLACE`) не дублирует данные и не роняет ячейки.
|
||||
- CTAS использует явный список колонок, а не `SELECT *`.
|
||||
- Bronze-таблица `lakehouse.bronze.nyc_taxi_yellow` остаётся после выполнения ноутбука для использования в Модуле 5.
|
||||
- При отсутствии raw-данных — понятный assert с отсылкой к Модулю 3.
|
||||
- Текст ноутбука на русском языке с параллелями к PostgreSQL/Greenplum.
|
||||
- `plans/README.md` содержит строку Модуля 4 в оглавлении.
|
||||
|
||||
## Риски
|
||||
|
||||
- **Время CTAS.** ~7-10 млн строк за 1-3 мин на стенде (2 workers × 2 GB). Extended (12 мес) — до 5-10 мин. Добавить markdown-предупреждение перед ячейкой загрузки.
|
||||
- **Схема raw PARQUET.** Фиксируем колонки NYC Taxi 2024 в DDL. Если студент скачал другой год — DDL может не совпасть. Пояснение: придерживаться инструкции из `START_HERE.md`.
|
||||
- **Column case sensitivity.** NYC Taxi PARQUET использует mixed case (`VendorID`, `PULocationID`). Iceberg хранит имена case-sensitive. DDL должен совпадать с именами в PARQUET. Проверить на стенде при реализации.
|
||||
- **Мелкие файлы.** Spark может создать много мелких data files (по числу partitions). Нормально для Module 4 — тема compaction отложена до Модуля 8. Упомянуть, не решать.
|
||||
- **Идемпотентность.** Используем `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) для повторных запусков — пересоздаёт таблицу целиком, snapshot history остаётся чистой. Это проще и надёжнее, чем `DELETE FROM` + `INSERT INTO` (который усложняет snapshot history и требует проверки поддержки `DELETE` на текущем стеке). Компромисс: `CREATE OR REPLACE` уничтожает предыдущие snapshots, но для учебного bronze это допустимо. В markdown явно объяснить этот trade-off.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Трансформации и очистка данных — отложено до Модуля 5.
|
||||
- Чтение bronze через Trino — отложено до Модуля 6.
|
||||
- Time travel и schema evolution — отложено до Модуля 7.
|
||||
- Compaction и vacuum — отложено до Модуля 8.
|
||||
- Партиционирование bronze-таблицы (PRD: вне v1).
|
||||
- Gold-слой и бизнес-витрины.
|
||||
@@ -0,0 +1,289 @@
|
||||
# Модуль 5. Слой silver и воспроизводимые трансформации
|
||||
|
||||
**Статус:** `Ready for validation`
|
||||
**Последнее обновление:** `2026-03-07`
|
||||
|
||||
## Цель
|
||||
|
||||
Научить студента переходу от bronze-таблицы «как есть» к осознанно очищенной и обогащённой silver-таблице. Студент формулирует правила трансформаций явно (не зарытыми в код), применяет их к bronze, обогащает таблицу вычисляемыми колонками и join-данными, а затем проверяет результат на трёх уровнях: строки, схема, агрегаты.
|
||||
|
||||
## Результат для студента
|
||||
|
||||
После прохождения модуля студент:
|
||||
|
||||
- понимает, зачем нужен silver-слой и чем он отличается от bronze;
|
||||
- умеет сформулировать правила трансформации явно и зафиксировать их как «реестр правил» перед кодом;
|
||||
- умеет отфильтровать некачественные записи из bronze по осознанным критериям;
|
||||
- умеет нормализовать типы колонок (DOUBLE -> INT для passenger_count, RatecodeID);
|
||||
- умеет обработать NULL-значения явным образом (coalesce);
|
||||
- умеет добавить вычисляемую колонку (trip_duration_minutes);
|
||||
- умеет обогатить данные через JOIN с lookup-таблицей (названия зон pickup/dropoff);
|
||||
- умеет выполнить проверки качества результата на трёх уровнях: строки, схема, агрегаты;
|
||||
- может сопоставить bronze/silver с привычным ods/dds-мышлением.
|
||||
|
||||
## Deliverables
|
||||
|
||||
- `notebooks/05_silver_layer.ipynb` — основной ноутбук Модуля 5;
|
||||
- обновление `plans/README.md` — строка Модуля 5 в оглавлении.
|
||||
|
||||
Инфраструктурные изменения не требуются.
|
||||
|
||||
## Зависимости
|
||||
|
||||
### Предусловия
|
||||
|
||||
- Стенд поднят и работает (Модуль 1).
|
||||
- Студент прошёл Модуль 4: таблицы `lakehouse.bronze.nyc_taxi_yellow` и `lakehouse.bronze.taxi_zone_lookup` существуют.
|
||||
|
||||
### Что используют последующие модули
|
||||
|
||||
- Модуль 6 читает `lakehouse.silver.nyc_taxi_yellow` через Spark и Trino.
|
||||
- Модуль 8 использует silver в финальной практике.
|
||||
|
||||
## Дизайн-решения
|
||||
|
||||
### Namespace: `lakehouse.silver`
|
||||
|
||||
Семантическое имя (как `lakehouse.bronze` в Модуле 4). Подкрепляет мышление слоями, Модуль 6 будет читать `lakehouse.silver.*`.
|
||||
|
||||
### Правила трансформаций: явный реестр в markdown перед кодом
|
||||
|
||||
Ключевая методическая идея модуля — правила не должны быть зарыты в код. Перед блоком кода стоит markdown-таблица с реестром правил (аналог mapping specification в DWH-практике). Это делает трансформации прозрачными и подкрепляет PRD Risk #4.
|
||||
|
||||
### Состав трансформаций: три группы
|
||||
|
||||
**Группа 1 — Фильтрация:**
|
||||
- F1: `fare_amount < 0` — отрицательные тарифы (аномалия из Модуля 3)
|
||||
- F2: `total_amount > 1000` — экстремальные суммы (аномалия из Модуля 3)
|
||||
- F3: `tpep_pickup_datetime` вне `[2024-01-01, 2025-01-01)` — записи за пределами учебного диапазона (аномалия из Модуля 3)
|
||||
|
||||
**Группа 2 — Нормализация и обработка NULL:**
|
||||
- N1: `passenger_count` — DOUBLE -> INT, NULL -> 0
|
||||
- N2: `RatecodeID` — DOUBLE -> INT, NULL -> 99 (unknown)
|
||||
- N3: `congestion_surcharge` — NULL -> 0.0
|
||||
- N4: `Airport_fee` — NULL -> 0.0
|
||||
|
||||
**Группа 3 — Обогащение:**
|
||||
- E1: `trip_duration_minutes` — вычисляемая колонка `(unix_timestamp(dropoff) - unix_timestamp(pickup)) / 60`
|
||||
- E2: `pickup_borough`, `pickup_zone` — LEFT JOIN с taxi_zone_lookup по PULocationID
|
||||
- E3: `dropoff_borough`, `dropoff_zone` — LEFT JOIN с taxi_zone_lookup по DOLocationID
|
||||
|
||||
Ряд правил намеренно оставлен для самостоятельного задания: фильтрация `trip_distance = 0 AND total_amount != 0`, нормализация `payment_type` (BIGINT -> INT с именованными значениями), вычисляемая колонка `speed_mph`.
|
||||
|
||||
### Диапазон дат для F3
|
||||
|
||||
Фиксированный `[2024-01-01, 2025-01-01)` — полный 2024 год, а не только каноническое подмножество `2024-01..2024-03` из `START_HERE.md`. Причина: фильтр ловит записи с явно ошибочным годом (2023, 2025 и т.д.), а не зависит от того, какие месяцы скачал студент. Канонический bundle — 3 месяца, расширенный — до 12, и в обоих случаях диапазон `[2024-01-01, 2025-01-01)` корректен. В Модуле 3 диапазон выводился из имён файлов через `infer_month_bounds`, но в silver мы работаем с bronze-таблицей (не с файлами), поэтому фиксированный диапазон проще и уместнее.
|
||||
|
||||
### JOIN: два LEFT JOIN с алиасами
|
||||
|
||||
LEFT JOIN (не INNER), чтобы не терять строки с LocationID без соответствия в lookup. Потеря строк из-за INNER JOIN была бы неявной трансформацией.
|
||||
|
||||
### Создание таблицы: SQL CTAS с temp view
|
||||
|
||||
Тот же паттерн, что в Модуле 4: трансформации через PySpark DataFrame API (`.filter()`, `.withColumn()`, `.join()`), затем `createOrReplaceTempView("silver_ready")`, затем `CREATE OR REPLACE TABLE lakehouse.silver.nyc_taxi_yellow USING iceberg AS SELECT col1, col2, ... FROM silver_ready` с явным списком колонок.
|
||||
|
||||
### Проверки качества: три уровня
|
||||
|
||||
- **Строковый (row-level):** assert нет fare_amount < 0, нет total_amount > 1000, нет NULL в passenger_count/RatecodeID
|
||||
- **Схемный (schema-level):** наличие новых колонок, проверка типов (passenger_count = INT, RatecodeID = INT)
|
||||
- **Агрегатный (aggregate-level):** таблица-сравнение bronze vs silver (row count, % отфильтрованных, средние fare/distance)
|
||||
|
||||
### Taxi zone lookup: prerequisite из демо-секции Модуля 4
|
||||
|
||||
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: нет
|
||||
|
||||
Silver нужен Модулям 6 и 8. Spark-сессия останавливается.
|
||||
|
||||
## План работ
|
||||
|
||||
1. Создать `notebooks/05_silver_layer.ipynb` по ячеечной структуре, описанной ниже.
|
||||
2. Обновить `plans/README.md` — добавить строку Модуля 5 в таблицу оглавления.
|
||||
3. Валидация: убедиться, что ноутбук выполняется сверху вниз на поднятом стенде после прохождения Модуля 4 (bronze-таблицы в каталоге).
|
||||
|
||||
## Структура ноутбука
|
||||
|
||||
### Секция 0: Введение
|
||||
- **[md]** Заголовок, цели модуля, prerequisite (Модуль 4). Таблица-сравнение:
|
||||
|
||||
| | Классический DWH (Greenplum) | Lakehouse |
|
||||
|---|---|---|
|
||||
| Источник | ods/stg-таблица | Bronze Iceberg-таблица |
|
||||
| Результат | dds/fact-таблица с очищенными данными | Silver Iceberg-таблица |
|
||||
| Правила трансформаций | Mapping specification / ETL-документация | Явный реестр правил в ноутбуке перед кодом |
|
||||
| Проверки качества | dq-check скрипты, data quality framework | Asserts на трёх уровнях |
|
||||
| Воспроизводимость | Повторный запуск ETL | `CREATE OR REPLACE TABLE ... AS SELECT` |
|
||||
|
||||
- **[md]** Ключевая идея: silver = bronze + явные правила трансформации + проверки качества.
|
||||
|
||||
### Секция 1: Spark-сессия и проверка bronze
|
||||
- **[code]** SparkSession (паттерн Модулей 2-4: без `.master()`, `setLogLevel("ERROR")`). Константы, вспомогательные функции (`format_bytes`, `list_objects` — переиспользование паттерна из Модуля 4).
|
||||
- **[code]** Assert: `spark.table(BRONZE_TABLE).count() > 0` с отсылкой к Модулю 4. Assert: `spark.table(BRONZE_LOOKUP_TABLE)` существует — при отсутствии жёсткая ошибка с отсылкой к Секции 8 Модуля 4.
|
||||
- **[md]** Обе bronze-таблицы на месте. Теперь строим silver.
|
||||
|
||||
### Секция 2: Профиль bronze — что нужно трансформировать
|
||||
- **[md]** Прежде чем писать трансформации, понимаем текущее состояние данных. В Модуле 3 мы уже нашли аномалии. Теперь смотрим через bronze.
|
||||
- **[code]** Краткий профиль: null-доли ключевых колонок, количество строк с fare_amount < 0, total_amount > 1000, pickup вне диапазона.
|
||||
- **[md]** Таблица-профиль: какие проблемы нашли, сколько строк затронуто, что будем делать.
|
||||
|
||||
### Секция 3: Реестр правил трансформации
|
||||
- **[md]** Центральная идея модуля. Фиксируем все правила явно перед кодом. Аналог mapping specification в DWH.
|
||||
- **[md]** Таблица правил (F1-F3, N1-N4, E1-E3) с колонками: #, Колонка/область, Правило, Тип, Обоснование.
|
||||
- **[md]** Зачем это нужно: если через месяц спросят «почему в silver нет строк с отрицательным fare?», ответ в реестре, а не в git blame.
|
||||
|
||||
### Секция 4: Применение правил — фильтрация
|
||||
- **[md]** Начинаем с фильтрации. Порядок осознанный: сначала фильтруем, потом нормализуем и обогащаем (не тратим ресурсы на трансформацию строк, которые будут удалены).
|
||||
- **[code]** Чтение bronze DataFrame. Применение F1, F2, F3. Подсчёт строк до/после, вывод количества и процента удалённых.
|
||||
- **[md]** Обычно фильтры качества удаляют небольшую долю строк. Если фильтр убирает заметную часть данных — это сигнал перепроверить правило или источник.
|
||||
|
||||
### Секция 5: Применение правил — нормализация
|
||||
- **[md]** Приводим типы и обрабатываем NULL. Параллель с PostgreSQL: как `ALTER COLUMN TYPE integer`, но между слоями, а не in-place.
|
||||
- **[code]** Применение N1-N4: `coalesce` + `cast` для passenger_count и RatecodeID, `coalesce` для surcharge и fee. Каждый `.withColumn()` с комментарием, какому правилу соответствует.
|
||||
|
||||
### Секция 6: Применение правил — обогащение
|
||||
- **[md]** Добавляем вычисляемые колонки и данные из lookup. LEFT JOIN — не теряем строки. Параллель с денормализацией в PostgreSQL.
|
||||
- **[code]** E1: `trip_duration_minutes`. E2, E3: два LEFT JOIN с `taxi_zone_lookup` (алиасы `pu_zone`, `do_zone`).
|
||||
- **[code]** Вывод нескольких строк с новыми колонками.
|
||||
|
||||
### Секция 7: Создание silver-таблицы
|
||||
- **[md]** Используем CTAS с `CREATE OR REPLACE`. Namespace `lakehouse.silver`.
|
||||
- **[md]** Явное предупреждение о `CREATE OR REPLACE`: это полная перезапись таблицы с потерей предыдущего состояния и snapshot-истории. Для учебного silver это осознанный компромисс ради идемпотентности (повторный запуск ноутбука пересоздаёт таблицу с чистого листа). В рабочих сценариях это разрушительная операция — в Модуле 7 студент увидит, почему нужен другой подход. Параллель: `DROP TABLE + CREATE TABLE AS SELECT` в PostgreSQL.
|
||||
- **[code]** `CREATE NAMESPACE IF NOT EXISTS lakehouse.silver`. Регистрация temp view. CTAS с явным списком демо-схемы: 24 колонки (19 оригинальных + 5 новых). Это каноническая схема, на которую опираются Модули 6 и 8.
|
||||
- **[md]** Предупреждение о времени выполнения (2-4 мин на 3 мес данных).
|
||||
- **[code]** Верификация: count, printSchema.
|
||||
|
||||
### Секция 8: Проверки качества результата
|
||||
|
||||
#### 8a: Строковый уровень
|
||||
- **[code]** Assert: нет строк с fare_amount < 0, total_amount > 1000, NULL в passenger_count, NULL в RatecodeID.
|
||||
|
||||
#### 8b: Схемный уровень
|
||||
- **[code]** Проверка наличия новых колонок (trip_duration_minutes, pickup_borough, pickup_zone, dropoff_borough, dropoff_zone). Проверка типов: passenger_count = int, RatecodeID = int.
|
||||
|
||||
#### 8c: Агрегатный уровень
|
||||
- **[code]** Таблица-сравнение bronze vs silver: row count, % отфильтрованных, средние fare_amount, trip_distance.
|
||||
- **[md]** Ориентир: если фильтры удалили больше 5% строк, это повод пересмотреть правила или проверить данные.
|
||||
|
||||
### Секция 9: bronze vs silver — разделение ответственности
|
||||
- **[md]** Мини-лекция (3-4 абзаца): raw -> bronze -> silver (-> gold, вне курса). Bronze = «как есть», silver = очищенные + обогащённые. Если правило неверно — bronze хранит оригинал, silver можно пересоздать.
|
||||
- **[md]** Таблица-сравнение bronze vs silver по свойствам: данные, NULL, типы, доп. колонки, проверки, правила, «можно пересоздать из».
|
||||
|
||||
### Секция 10: Самостоятельное задание
|
||||
- **[md]** Инструкция: расширить silver-пайплайн тремя новыми правилами (по одному на каждый тип трансформации), пересоздать таблицу и проверить результат.
|
||||
|
||||
**Фильтрация — F4:** удалить строки с `trip_distance = 0 AND total_amount != 0` (аномалия, найденная в Модуле 3, но не включённая в демонстрацию).
|
||||
|
||||
**Нормализация — N5:** привести `payment_type` из BIGINT в INT и дать осмысленное значение для NULL (например, 0 = unknown). Подсказка: паттерн `coalesce` + `cast` тот же, что для `passenger_count`.
|
||||
|
||||
**Обогащение — E4:** добавить вычисляемую колонку `speed_mph` — средняя скорость поездки в милях в час: `trip_distance / (trip_duration_minutes / 60)`. Подсказка: нужно обработать деление на ноль (если `trip_duration_minutes = 0`, результат должен быть NULL, а не ошибка).
|
||||
|
||||
**Проверки:** после пересоздания silver написать: (1) row-level assert для F4, (2) schema-level проверку наличия `speed_mph` и типа `payment_type`, (3) сравнить количество строк до и после добавления F4.
|
||||
|
||||
- **[code]** 6 пустых ячеек `# Ваш код: ...` (фильтрация, нормализация, обогащение, пересоздание таблицы, проверки, сравнение).
|
||||
|
||||
### Секция 11: Checkpoint
|
||||
- **[md]** Вопросы:
|
||||
1. Чем silver отличается от bronze?
|
||||
2. Зачем фиксировать правила перед кодом?
|
||||
3. Какие три уровня проверок качества и зачем?
|
||||
4. Почему LEFT JOIN, а не INNER JOIN с lookup?
|
||||
5. Что если фильтр удалит 50% строк?
|
||||
6. Можно ли пересоздать silver, если обнаружится новая аномалия?
|
||||
|
||||
### Секция 12: Завершение
|
||||
- **[md]** «Не удаляем silver-таблицу. В Модуле 6 будем читать через Spark и Trino, в Модуле 8 — финальная практика.»
|
||||
- **[code]** `spark.stop()`.
|
||||
|
||||
### Дизайн-решения по ноутбуку
|
||||
|
||||
- **Helper-код:** весь код inline в ноутбуке. Студент должен видеть каждый шаг: фильтрацию, нормализацию, join, проверки.
|
||||
- **Cleanup:** нет. Silver нужен Модулям 6 и 8. Spark-сессия останавливается.
|
||||
- **Порядок трансформаций:** фильтрация -> нормализация -> обогащение. Логический порядок: сначала убираем мусор, потом чистим типы, потом добавляем контекст.
|
||||
- **Spark API:** трансформации через PySpark DataFrame API (`.filter()`, `.withColumn()`, `.join()`), запись через SQL CTAS (консистентно с Модулем 4).
|
||||
|
||||
## Схемы silver-таблицы
|
||||
|
||||
### Демо-схема (24 колонки) — после секций 4-7
|
||||
|
||||
Это каноническая схема, на которую опираются downstream-модули (6, 8). Она создаётся демонстрационным кодом ноутбука и является минимальной гарантированной схемой `lakehouse.silver.nyc_taxi_yellow`.
|
||||
|
||||
```
|
||||
VendorID BIGINT (без изменений)
|
||||
tpep_pickup_datetime TIMESTAMP (без изменений)
|
||||
tpep_dropoff_datetime TIMESTAMP (без изменений)
|
||||
passenger_count INT (N1: DOUBLE->INT, NULL->0)
|
||||
trip_distance DOUBLE (без изменений)
|
||||
RatecodeID INT (N2: DOUBLE->INT, NULL->99)
|
||||
store_and_fwd_flag STRING (без изменений)
|
||||
PULocationID BIGINT (без изменений)
|
||||
DOLocationID BIGINT (без изменений)
|
||||
payment_type BIGINT (без изменений)
|
||||
fare_amount DOUBLE (без изменений, F1 отфильтровал < 0)
|
||||
extra DOUBLE (без изменений)
|
||||
mta_tax DOUBLE (без изменений)
|
||||
tip_amount DOUBLE (без изменений)
|
||||
tolls_amount DOUBLE (без изменений)
|
||||
improvement_surcharge DOUBLE (без изменений)
|
||||
total_amount DOUBLE (без изменений, F2 отфильтровал > 1000)
|
||||
congestion_surcharge DOUBLE (N3: NULL->0.0)
|
||||
Airport_fee DOUBLE (N4: NULL->0.0)
|
||||
trip_duration_minutes DOUBLE (E1: новая, вычисляемая)
|
||||
pickup_borough STRING (E2: новая, из lookup JOIN)
|
||||
pickup_zone STRING (E2: новая, из lookup JOIN)
|
||||
dropoff_borough STRING (E3: новая, из lookup JOIN)
|
||||
dropoff_zone STRING (E3: новая, из lookup JOIN)
|
||||
```
|
||||
|
||||
### Расширенная схема (26 колонок) — после самостоятельного задания (секция 10)
|
||||
|
||||
Студент пересоздаёт silver-таблицу с дополнительными правилами. Итоговая таблица содержит 24 демо-колонки + 2 новых, с изменением типа `payment_type`. Downstream-модули (6, 8) не зависят от расширенной схемы: `payment_type INT` обратно совместим с `BIGINT` для чтения, а дополнительные колонки (`speed_mph`) не используются downstream.
|
||||
|
||||
```
|
||||
payment_type INT (N5: BIGINT->INT, NULL->0; было BIGINT)
|
||||
speed_mph DOUBLE (E4: новая, trip_distance / (trip_duration_minutes / 60))
|
||||
```
|
||||
|
||||
Также добавляется фильтр F4 (`trip_distance = 0 AND total_amount != 0`), который уменьшает количество строк.
|
||||
|
||||
## Checkpoint
|
||||
|
||||
Студент должен уметь:
|
||||
|
||||
- создать silver-таблицу из bronze с явными правилами трансформации;
|
||||
- показать реестр правил и объяснить обоснование каждого;
|
||||
- показать результаты проверок качества на трёх уровнях;
|
||||
- объяснить, чем silver отличается от bronze;
|
||||
- объяснить, почему silver можно пересоздать из bronze.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `notebooks/05_silver_layer.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении после прохождения Модуля 4 (bronze-таблицы в каталоге).
|
||||
- Ноутбук следует методике курса: `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`.
|
||||
- Ноутбук использует паттерны из Модулей 1-4: SparkSession без `.master()`, `setLogLevel("ERROR")`.
|
||||
- Ноутбук идемпотентен: повторное выполнение (`CREATE OR REPLACE`) не дублирует данные и не роняет ячейки.
|
||||
- CTAS использует явный список колонок, а не `SELECT *`.
|
||||
- Правила трансформации зафиксированы в markdown-таблице перед кодом, а не только в коде.
|
||||
- Проверки качества выполняются на трёх уровнях: строки, схема, агрегаты.
|
||||
- Silver-таблица `lakehouse.silver.nyc_taxi_yellow` остаётся после выполнения ноутбука для использования в Модулях 6 и 8.
|
||||
- При отсутствии bronze-таблиц — понятный assert с отсылкой к Модулю 4.
|
||||
- Текст ноутбука на русском языке с параллелями к PostgreSQL/Greenplum.
|
||||
- `plans/README.md` содержит строку Модуля 5 в оглавлении.
|
||||
|
||||
## Риски
|
||||
|
||||
- **Taxi zone lookup отсутствует.** 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 — длительность отрицательна. Упомянуть как наблюдение, не фильтровать в демо (потенциально — для самостоятельного задания или будущего правила).
|
||||
- **Идемпотентность.** `CREATE OR REPLACE TABLE ... AS SELECT` (CTAS) для повторных запусков — тот же паттерн, что в Модуле 4. Пересоздаёт таблицу целиком.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Gold-слой и бизнес-витрины.
|
||||
- Чтение silver через Trino — отложено до Модуля 6.
|
||||
- Сложные трансформации (MERGE, SCD, window functions).
|
||||
- dq-фреймворки (Great Expectations, dbt tests) — принцип показан через assert.
|
||||
- Time travel и schema evolution — отложено до Модуля 7.
|
||||
- Compaction и vacuum — отложено до Модуля 8.
|
||||
- Партиционирование silver-таблицы (PRD: вне v1).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
from .cluster_smoke import create_spark_session, format_smoke_report, run_cluster_smoke
|
||||
|
||||
__all__ = ["create_spark_session", "format_smoke_report", "run_cluster_smoke"]
|
||||
@@ -1,12 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pyspark.sql import SparkSession
|
||||
|
||||
spark = (
|
||||
SparkSession.builder
|
||||
.appName("test-cluster")
|
||||
.master("spark://spark-master:7077") # важно: не local[*]
|
||||
.getOrCreate()
|
||||
)
|
||||
DEFAULT_MASTER_URL = "spark://spark-master:7077"
|
||||
DEFAULT_APP_NAME = "cluster-smoke"
|
||||
DEFAULT_ROW_COUNT = 1_000_000
|
||||
|
||||
spark.range(0, 1000000).groupBy().sum().show()
|
||||
|
||||
spark.stop()
|
||||
def create_spark_session(
|
||||
app_name: str = DEFAULT_APP_NAME,
|
||||
master_url: str = DEFAULT_MASTER_URL,
|
||||
) -> SparkSession:
|
||||
"""Создать SparkSession для проверок окружения в Модуле 1.
|
||||
|
||||
Функция специально остаётся минимальной и опирается на смонтированный
|
||||
в контейнер `spark-defaults.conf` для остальной Spark/Iceberg-конфигурации.
|
||||
"""
|
||||
return SparkSession.builder.appName(app_name).master(master_url).getOrCreate()
|
||||
|
||||
|
||||
def run_cluster_smoke(spark: SparkSession, row_count: int = DEFAULT_ROW_COUNT) -> dict:
|
||||
spark_context = spark.sparkContext
|
||||
master_url = spark_context.master
|
||||
|
||||
if master_url.startswith("local"):
|
||||
raise RuntimeError(
|
||||
"Smoke test must run against the Spark cluster, not local mode."
|
||||
)
|
||||
|
||||
# Берём больше партиций, чем даёт defaultParallelism, чтобы задача точно
|
||||
# распределилась по worker-ам, а не схлопнулась в слишком маленький job.
|
||||
partition_count = max(spark_context.defaultParallelism * 2, 8)
|
||||
expected_sum = row_count * (row_count - 1) // 2
|
||||
actual_sum = (
|
||||
spark.range(0, row_count, 1, numPartitions=partition_count)
|
||||
.selectExpr("SUM(id) AS total_sum")
|
||||
.collect()[0]["total_sum"]
|
||||
)
|
||||
|
||||
if actual_sum != expected_sum:
|
||||
raise RuntimeError(
|
||||
f"Unexpected smoke result: expected {expected_sum}, got {actual_sum}."
|
||||
)
|
||||
|
||||
return {
|
||||
"app_name": spark_context.appName,
|
||||
"master_url": master_url,
|
||||
"spark_version": spark.version,
|
||||
"default_parallelism": spark_context.defaultParallelism,
|
||||
"partition_count": partition_count,
|
||||
"row_count": row_count,
|
||||
"result_sum": actual_sum,
|
||||
"expected_sum": expected_sum,
|
||||
}
|
||||
|
||||
|
||||
def format_smoke_report(report: dict) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"Spark cluster smoke test passed.",
|
||||
f"app_name={report['app_name']}",
|
||||
f"master_url={report['master_url']}",
|
||||
f"spark_version={report['spark_version']}",
|
||||
f"default_parallelism={report['default_parallelism']}",
|
||||
f"partition_count={report['partition_count']}",
|
||||
f"row_count={report['row_count']}",
|
||||
f"result_sum={report['result_sum']}",
|
||||
f"expected_sum={report['expected_sum']}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
spark = create_spark_session()
|
||||
try:
|
||||
report = run_cluster_smoke(spark)
|
||||
print(format_smoke_report(report))
|
||||
finally:
|
||||
spark.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user